From 2c9eda5b6f752a48396ce22387f569d0712e3183 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 8 Sep 2026 12:05:06 +0800 Subject: [PATCH 001/111] 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 002/111] 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 003/111] 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 8e4d3bab9fd0bb1b6b3fa4bc9a2174953cf9a1a8 Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Tue, 8 Sep 2026 16:33:58 +0800 Subject: [PATCH 004/111] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E6=89=93?= =?UTF-8?q?=E5=8C=85=E6=96=B9=E5=BC=8F=E3=80=81=E5=90=AF=E5=8A=A8=E9=80=9F?= =?UTF-8?q?=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...on-desktop-packaging-and-updates.i18n.yaml | 4 +- ...-electron-desktop-packaging-and-updates.md | 60 +- ...ectron-desktop-packaging-and-updates.zh.md | 58 +- ...led-runtime-and-external-plugins.i18n.yaml | 6 + ...op-bundled-runtime-and-external-plugins.md | 51 ++ ...bundled-runtime-and-external-plugins.zh.md | 51 ++ ...ktop-uninstall-preserve-dsh-home.i18n.yaml | 6 + ...-08-desktop-uninstall-preserve-dsh-home.md | 95 ++++ ...-desktop-uninstall-preserve-dsh-home.zh.md | 95 ++++ apps/desktop-host/src/index.ts | 34 +- apps/desktop/README.i18n.yaml | 4 +- apps/desktop/README.md | 58 +- apps/desktop/README.zh.md | 58 +- apps/desktop/electron-builder.config.d.mts | 4 +- apps/desktop/electron-builder.config.mjs | 17 +- apps/desktop/package.json | 5 +- apps/desktop/renderer/plugin-manager.html | 6 + apps/desktop/renderer/plugin-manager.js | 19 +- .../desktop/scripts/desktop-build-paths.d.mts | 4 +- apps/desktop/scripts/desktop-build-paths.mjs | 6 +- apps/desktop/scripts/macos-runtime.ts | 43 ++ apps/desktop/scripts/macos-seed-store.ts | 413 -------------- apps/desktop/scripts/package-target.ts | 4 +- apps/desktop/scripts/prepare-dsh.ts | 150 +++++ apps/desktop/scripts/prepare-seed.ts | 200 ------- apps/desktop/scripts/smoke-runtime.ts | 58 ++ .../scripts/verify-macos-signature.d.mts | 12 +- .../scripts/verify-macos-signature.mjs | 22 +- apps/desktop/src/core-package-set.ts | 2 +- apps/desktop/src/host-process.ts | 13 +- apps/desktop/src/ipc.ts | 10 + apps/desktop/src/locale.ts | 14 + apps/desktop/src/main.ts | 65 ++- apps/desktop/src/preload.ts | 6 + apps/desktop/src/profile-packages.ts | 254 +++++++++ apps/desktop/src/project-manager.ts | 348 +++++------- apps/desktop/src/release.ts | 4 +- apps/desktop/src/runtime-tree.ts | 180 ++++++ apps/desktop/src/seed-store.ts | 271 --------- apps/desktop/src/update-coordinator.ts | 2 +- .../desktop/tests/desktop-build-paths.spec.ts | 6 +- apps/desktop/tests/host-process.spec.ts | 35 +- apps/desktop/tests/macos-runtime.spec.ts | 48 ++ apps/desktop/tests/macos-seed-store.spec.ts | 245 -------- apps/desktop/tests/macos-signature.spec.ts | 49 +- apps/desktop/tests/package-target.spec.ts | 2 +- apps/desktop/tests/plugin-manager.spec.ts | 33 ++ apps/desktop/tests/plugin-pnpm.spec.ts | 72 +++ apps/desktop/tests/profile-packages.spec.ts | 81 +++ apps/desktop/tests/project-manager.spec.ts | 537 ++++++------------ apps/desktop/tests/runtime-fixture.ts | 42 ++ apps/desktop/tests/runtime-tree.spec.ts | 46 ++ apps/desktop/tests/seed-store.spec.ts | 167 ------ docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 4 +- docs/architecture.zh.md | 4 +- pnpm-lock.yaml | 89 +-- 57 files changed, 2013 insertions(+), 2163 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md create mode 100644 .agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md create mode 100644 .agents/notes/proposed/feature/2026-09-08-desktop-uninstall-preserve-dsh-home.i18n.yaml create mode 100644 .agents/notes/proposed/feature/2026-09-08-desktop-uninstall-preserve-dsh-home.md create mode 100644 .agents/notes/proposed/feature/2026-09-08-desktop-uninstall-preserve-dsh-home.zh.md create mode 100644 apps/desktop/scripts/macos-runtime.ts delete mode 100644 apps/desktop/scripts/macos-seed-store.ts create mode 100644 apps/desktop/scripts/prepare-dsh.ts delete mode 100644 apps/desktop/scripts/prepare-seed.ts create mode 100644 apps/desktop/scripts/smoke-runtime.ts create mode 100644 apps/desktop/src/profile-packages.ts create mode 100644 apps/desktop/src/runtime-tree.ts delete mode 100644 apps/desktop/src/seed-store.ts create mode 100644 apps/desktop/tests/macos-runtime.spec.ts delete mode 100644 apps/desktop/tests/macos-seed-store.spec.ts create mode 100644 apps/desktop/tests/plugin-manager.spec.ts create mode 100644 apps/desktop/tests/plugin-pnpm.spec.ts create mode 100644 apps/desktop/tests/profile-packages.spec.ts create mode 100644 apps/desktop/tests/runtime-fixture.ts create mode 100644 apps/desktop/tests/runtime-tree.spec.ts delete mode 100644 apps/desktop/tests/seed-store.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml index 8aa7824f1f..e0081b64f5 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md -2026-08-25-electron-desktop-packaging-and-updates.md: 6d22777c8913911dbb1d89c09de9e891636873c8 -2026-08-25-electron-desktop-packaging-and-updates.zh.md: 5108492ba6f029847735f570aa77c2cb505f981c +2026-08-25-electron-desktop-packaging-and-updates.md: fea2c419d73e4eff66de08483733cbc336b6f9af +2026-08-25-electron-desktop-packaging-and-updates.zh.md: 88e2875cd35909c0111926377df83c9fe21841dc diff --git a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md index 6d22777c89..fea2c419d7 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md +++ b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md @@ -10,13 +10,13 @@ DeepSeek Harness needs an Electron desktop application that reuses the Web UI, w The desktop application and an npm-installed dsh share the `.dsh` data root, but they may have different dsh and plugin versions. They must share supported product data without sharing executable packages, lockfiles, `node_modules`, plugin activation, or package-manager configuration. -The current GUI protocol binds the Web client and backend release. Independently versioning the Electron artifact and its pnpm-installed dsh would create unqualified shell, client, backend, and plugin combinations and make update availability ambiguous. +The current GUI protocol binds the Web client and backend release. Independently versioning the Electron artifact and its bundled dsh would create unqualified shell, client, backend, and plugin combinations and make update availability ambiguous. ## Decision Ship a small Electron shell with a bundled upstream Node.js executable and pinned pnpm. Electron starts the private Desktop Host package as an isolated child process; that package composes the installed dsh backend and matching client graph. Fetch metadata and bounded raw request and response chunks travel over two versioned framed byte pipes, Node IPC is reserved for readiness, fatal failure, and shutdown, and Electron serves validated assets through `dsh-app://`; it opens no listening port. Each frame carries a fixed marker, type, monotonic stream id, payload length, and validated payload. Serialized writers honor pipe drain, readers pause globally when a request or response stream applies backpressure, cancellation closes the matching stream, and late response frames for a retired stream stay inert. The Connection plugin provides its carrier-neutral RPC and Fetch registries without requiring `webServer`, while Client Modules provides the exact advertised combo-bundle responses to the shell-owned carrier; Web compositions attach their optional HTTP routes for both. The renderer keeps the same Fetch, RPC, and Remote-stream formats, while the child carrier avoids Base64 expansion and V8 serialization compatibility between Electron and the bundled upstream Node.js. Electron closes its request-pipe writer after sending shutdown, releasing an in-flight Windows pipe read before it waits for child exit. This follows the Electron reservation in the [GUI layering and RPC protocol note](../../archived/architecture/2026-07-19-gui-layering-and-rpc-protocol.md). -Electron owns the reserved profile at `.dsh/profiles/desktop`. Its exact `@deepseek-ai/dsh` dependency supplies the backend and matching Web UI, while the matching private `@deepseek-ai/dsh-desktop-host` dependency supplies only the Electron child-process entry and composition overlay. The dsh release, private Host, and their first-party dependency closures use local npm tarballs packed from the same source build; the profile manifest lists every core package as a local `file:` dependency, and `pnpm-workspace.yaml` repeats the mapping as overrides. The Host remains outside the public CLI package and is never published to npm. Desktop plugins are additional registry npm dependencies and ordered `dsh.profile.bundles` entries in the same profile, and resolve from its one `node_modules`. +Electron owns the reserved profile at `.dsh/profiles/desktop`. The [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md) owns core resource storage, external plugin dependencies, shared package links, and profile reconciliation. The private Desktop Host remains outside the public CLI package and is never published to npm. One Desktop release number identifies the Electron artifact and its exact `@deepseek-ai/dsh` and `@deepseek-ai/dsh-desktop-host` dependencies. A release cannot select a different core version at build or runtime. Updating dsh therefore requires a new Electron release even when shell code is unchanged. @@ -28,7 +28,7 @@ The browser Web UI, dsh backend, existing `dsh plugin` CLI, user npm, and user p |---|---| | Electron shell | Window and child lifecycle, framed byte pipes, lifecycle IPC, custom protocol, reserved desktop profile, plugin GUI, update coordination, rollback | | Bundled Node.js and pnpm | Execute dsh and install exact desktop-project dependencies without consulting user `PATH` or pnpm state | -| Desktop profile | One dependency graph, ordered bundle list, and `node_modules` for the desktop dsh package and desktop plugins | +| Desktop profile | External plugin dependencies, ordered enabled bundles, and shared links defined by the bundled-runtime decision | | Private Desktop Host package | Electron-only child-process entry and composition overlay installed with dsh but excluded from the public CLI package and npm publication | | Installed dsh package | Backend, matching Web UI, boot manifest, client bundles, and product behavior | | Shared `.dsh` owners | Sessions, settings, credentials, workspaces, and storage, guarded by their existing locks and format versions | @@ -55,15 +55,13 @@ The renderer uses `nodeIntegration: false`, `contextIsolation: true`, and `sandb package.json pnpm-lock.yaml pnpm-workspace.yaml - desktop-release.json - desktop-packages.json - desktop-packages/ + desktop-runtime-state.json node_modules/ sessions/ storages/ ``` -`.dsh/profiles/desktop` is the only active desktop profile. Its package manifest records the built-in and installed plugin bundle order; Electron alone mutates its dependencies, lockfile, and `node_modules`. Production startup rejects a bundle resolved outside this profile, including the CLI-maintained `.dsh/profiles/node_modules` fallback. Package content installed for the desktop profile uses `.dsh/desktop/pnpm/store`. +`.dsh/profiles/desktop` is the only active desktop profile. Its executable package ownership and allowed resolution directories follow the [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md). Plugin package content uses `.dsh/desktop/pnpm/store`. ## Installation and resolution @@ -71,35 +69,27 @@ The installer never mutates the active profile in place. It copies profile metad The process-lifetime Electron lock is the authoritative Desktop owner. The package transaction lock is depth defense and records the process that can still mutate package state: Electron between package operations and the spawned pnpm PID while pnpm runs. The owner change is truncated, written, and synchronized through the already-open exclusive lock file. If Electron terminates during pnpm execution, a later process observes the live worker and refuses to start a competing store or staging transaction; after that worker exits, the stale PID can be recovered. -The packaged seed is an offline installation kit, not an executable dsh tree. It contains the release identity, initial desktop-project manifest, a descriptor and immutable tarballs for the union of the first-party package closures rooted at dsh and the private Desktop Host, lockfile, integrity inventory, and required store subset. Each `mac-arm64`, `mac-x64`, and `win-x64` build owns its packed packages, runtime, package set, seed, pnpm preparation state, unpacked application, update metadata, and final artifacts under `.desktop-build/targets/`; only the immutable, checksum-verified Node.js download cache is shared. The release build requires the Electron package, root dsh package, and private Host package to have the same version, creates final npm tarballs from the official source build, locally packs the private Host, selects the reachable dsh, Host, and vendored packages plus the Landlock entry, and verifies that the Host tarball contains `lib/index.js` and `config/desktop.cordis.patch.yml`. The Host `files` manifest contains only that runtime entry and overlay, and the package is never published to npm. Public package tarballs remain the official `pnpm pack` results governed by each package's publication manifest; Desktop does not remove published declarations or otherwise create a second package-content policy. The seed manifest lists every selected package as a local direct dependency, automatic peer installation is disabled, and the workspace file overrides every selected first-party name to its local tarball. The target Node.js executes bundled pnpm, so pnpm's operating-system and CPU selection makes the materialized dependency graph and seed target-specific. Bundled pnpm disables its global virtual store, materializes external production dependencies from npm without lifecycle scripts, deletes `node_modules` and every temporary pnpm cache, config, and state directory, then performs a clean offline installation from the final store alone and checks the private Host entry and overlay. The build rejects any lockfile that resolves one of the local first-party names by registry version. Inventory generation follows removal of that second `node_modules` tree and temporary pnpm project registrations. Requiring both Host files before copying the package set and after offline installation prevents a release whose process entry loads but cannot compose its required overlay from reaching application signing. - -The seed stores pnpm content in 16 deterministic uncompressed tar shards selected by normalized store path. Apple notarization inspects Mach-O code inside those archives, so macOS seed preparation stages every referenced Mach-O content-addressed object and runs at most four independent Developer ID signers concurrently with a secure timestamp and hardened runtime. A signer failure is observed only after every active signer exits and leaves the original CAS objects and package index unchanged. After all signers succeed, preparation writes each object at its new SHA-512 path and transactionally rewrites every base and side-effects file reference in pnpm's MessagePack SQLite index. A second offline installation proves that pnpm resolves the rewritten store; preparation then shards it, extracts the final archives, and verifies every embedded signature. Package paths and non-native bytes remain unchanged, and the seed retains bundled architecture variants because removing files would create a Desktop-specific package file set. Seed integrity covers the shard manifest and every archive before extraction. Startup validates archive paths, entry types, uniqueness, and counts, extracts every shard into a unique Desktop-owned staging directory, replaces matching immutable store files, and transactionally merges each pnpm store version's SQLite `package_index` into `.dsh/desktop/pnpm/store`. Seed records replace matching keys while records downloaded for Desktop plugins remain. An interrupted file merge may leave valid immutable cache content, but each SQLite merge is atomic, and profile installation and activation still require pnpm integrity and the complete health check. - -Startup requires the packaged release identity to equal Electron's application version, then compares `.dsh/profiles/desktop/desktop-release.json` plus the installed dsh and Desktop Host packages with that release before launching the backend. It installs the new seed manifest and lockfile with `pnpm install --offline --frozen-lockfile --trust-lockfile` in staging. After Electron replacement, it restores every plugin bundle recorded in the active profile at its exact installed version through one offline pnpm add from the existing desktop store and metadata cache. The complete graph must pass the same health check before activation. - -The plugin GUI performs registry npm-package operations equivalent to `pnpm add --save-exact`, `pnpm remove `, and exact-version update in staging. Every mutation retains the local core-package descriptor, tarballs, dsh and Desktop Host dependencies, and complete override map. Electron validates the installed package manifest and updates the profile's dependency and ordered bundle entries; no renderer request can choose the registry, install directory, lifecycle policy, or arbitrary pnpm flags. - -The backend and Loader use `.dsh/profiles/desktop/package.json` as their profile manifest and npm resolution anchor. The shared profile loader composes its ordered bundle entries, then the private Desktop Host applies its packaged overlay. The Host, dsh, Cordis, desktop plugins, plugin dependencies, and peer dependencies resolve through the ordinary pnpm `node_modules` graph. A desktop plugin contributing `dsh.client` code enters the boot manifest only after the complete profile passes health checking. +Core materialization, first launch, plugin installation, and shared-module resolution follow the [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md). A desktop plugin contributing `dsh.client` code enters the client graph only after the complete profile passes health checking. ## Updates and recovery Electron update uses one `electron-updater` release stream and signed `electron-builder` artifacts. Its version is the Desktop release version; there is no independent dsh manifest, compatibility range, or dsh-only update operation. A foreground install waits for an in-flight background check rather than reusing its result as an install result. The update dialog downloads and installs the Electron artifact, then restarts into the new release. -Before the new release opens a window, startup reconciles dsh from its packaged seed while retaining installed desktop plugins. The health check covers dependency resolution, native modules, shell API compatibility, backend startup and shutdown, Web assets, and the client boot graph. An incompatible plugin blocks activation and leaves the previous project available for rollback. Startup fails visibly rather than launching a shell and dsh version that do not match. +Before opening the product window, startup validates the current bundled runtime and reconciles external plugins according to the [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md). Backend failure opens the Electron-owned recovery window instead of booting a mismatched dsh release. `DSH_DESKTOP_AUTO_UPDATE_ENV` selects the test deployment by default or the production deployment for both the target-specific generic-provider URL and COS destination. Release automation supplies the test HTTPS origin through `DOWNLOAD_TEST_ORIGIN` and each deployment's bucket through `DOWNLOAD_TEST_COS_BUCKET` or `DOWNLOAD_PROD_COS_BUCKET`; keeping mutable test routing and COS storage identities out of source lets deployment infrastructure change without a code release, while the public production origin remains fixed. Packaging resolves only the public updater URL, disables electron-builder publishing, removes every COS credential field from its subprocess environment, and writes a completion record only after electron-builder and every signing or notarization hook succeeds. Target upload additionally requires the selected bucket, then requires the completion record, root dsh version, Desktop version, version-derived channel metadata, artifact names, sizes, and SHA-512 values to agree before it reads the selected credentials or sends data. It uploads immutable versioned updater payloads and any separate blockmaps before replacing the channel metadata emitted by electron-builder, and it never deletes historical objects. Stable versions use the `latest` metadata name; prereleases use the first semantic-version prerelease identifier. NSIS embeds its blockmap in the signed executable; the macOS ZIP carries a separate blockmap. Both let electron-updater download changed blocks when supported, while application replacement and the local pnpm staging transaction remain separate operations. ## Security and release policy -Core dsh and the private Desktop Host come only from integrity-recorded local npm tarballs inside the signed Electron release; pnpm overrides prevent transitive core packages from falling back to a registry. Store archives are integrity-checked and fully validated in an isolated extraction directory before their files can enter writable package state. Plugin installation accepts registry package specs allowed by desktop policy but never raw pnpm commands. Exact versions, lockfile integrity, a reviewed `allowBuilds` set, user-only directory permissions, redacted diagnostics, and health checking are required before activation. +Core dsh and the private Desktop Host come only from the signed application resource tree. Plugin installation accepts registry package specs allowed by desktop policy but never raw pnpm commands. Exact versions, lockfile integrity, a reviewed `allowBuilds` set, user-only directory permissions, and health checking are required before activation. -Electron artifacts are signed; macOS artifacts are notarized. Release automation must supply the application ID, macOS Developer ID qualifier, expected Team ID, and one complete notarytool credential strategy through explicit environment variables. Configuration loading rejects missing or malformed identifiers and incomplete notarization credentials, while macOS packaging requires signing so certificate discovery cannot silently select another installed identity or emit an unsigned release. Seed preparation verifies the exact Authority and Team ID plus the timestamp and hardened-runtime flags on every embedded Mach-O file. An after-sign hook performs Apple's deep strict application verification and requires the same leaf Authority and Team ID before artifact creation continues. Electron-builder then notarizes and staples the application and signs the DMG. The DMG artifact-completion hook separately notarizes and staples every DMG before requiring the configured identity, a valid ticket, and Gatekeeper acceptance; the upload event runs only after that hook succeeds. DMG blockmaps are disabled because macOS updates consume the signed ZIP, and stapling would otherwise invalidate an already-generated DMG blockmap. The custom protocol serves the installed frontend distribution plus client files named by the active module graph and rejects traversal or access outside those roots. The plugin installer API is available only to the Electron-owned management GUI and is absent from the browser application and backend RPC. +Electron artifacts are signed; macOS artifacts are notarized. Release automation must supply the application ID, macOS Developer ID qualifier, expected Team ID, and one complete notarytool credential strategy through explicit environment variables. Configuration loading rejects missing or malformed identifiers and incomplete notarization credentials, while macOS packaging requires signing so certificate discovery cannot silently select another installed identity or emit an unsigned release. Runtime preparation verifies the exact Authority and Team ID plus the timestamp and hardened-runtime flags on every embedded Mach-O file. An after-sign hook performs Apple's deep strict application verification and requires the same leaf Authority and Team ID before artifact creation continues. Electron-builder then notarizes and staples the application and signs the DMG. The DMG artifact-completion hook separately notarizes and staples every DMG before requiring the configured identity, a valid ticket, and Gatekeeper acceptance; the upload event runs only after that hook succeeds. DMG blockmaps are disabled because macOS updates consume the signed ZIP, and stapling would otherwise invalidate an already-generated DMG blockmap. The custom protocol serves the installed frontend distribution plus client files named by the active module graph and rejects traversal or access outside those roots. The plugin installer API is available only to the Electron-owned management GUI and is absent from the browser application and backend RPC. -Windows release packaging supplies the public EV leaf certificate named by `DSH_DESKTOP_WINDOWS_CER_FILE` to the configured SafeNet-compatible SignTool through `/f` and identifies its matching private key through the required `DSH_DESKTOP_WINDOWS_KEY_CONTAINER`. The certificate file remains outside source control, and the private key remains on the USB token. The electron-builder hook passes each artifact to the CRLF `windows-sign.cmd`, whose single SignTool invocation uses the SafeNet `/kc "[{{PIN}}]=container"` value and CSP, a SHA-256 file digest, and a DigiCert SHA-256 RFC 3161 timestamp. The hook never substitutes another SignTool and never retries a failed request. Package orchestration withholds every `DSH_DESKTOP_WINDOWS_*` field from build and seed-preparation children and passes only the certificate path, SignTool path, key container, and PIN into electron-builder. The signer supplies only validated signing fields in an otherwise scrubbed CMD environment; the CMD disables delayed expansion, clears those fields before SignTool starts, and preserves the PIN only in the required SignTool command line. Every surfaced diagnostic replaces the PIN, and only the dedicated build account and administrators may inspect the runner. The signer signs electron-builder's temporary NSIS bootstrap before enterprise Code Integrity evaluates that executable and clears a generated executable's certificate-table entry only when it points beyond the file before applying the final signature. Packaging fails before producing unsigned artifacts when the SignTool, certificate, container, PIN, token, or signature is unavailable. The custom protocol serves the installed frontend distribution plus client files named by the active module graph and rejects traversal or access outside those roots. The plugin installer API is available only to the Electron-owned management GUI and is absent from the browser application and backend RPC. +Windows release packaging supplies the public EV leaf certificate named by `DSH_DESKTOP_WINDOWS_CER_FILE` to the configured SafeNet-compatible SignTool through `/f` and identifies its matching private key through the required `DSH_DESKTOP_WINDOWS_KEY_CONTAINER`. The certificate file remains outside source control, and the private key remains on the USB token. The electron-builder hook passes each artifact to the CRLF `windows-sign.cmd`, whose single SignTool invocation uses the SafeNet `/kc "[{{PIN}}]=container"` value and CSP, a SHA-256 file digest, and a DigiCert SHA-256 RFC 3161 timestamp. The hook never substitutes another SignTool and never retries a failed request. Package orchestration withholds every `DSH_DESKTOP_WINDOWS_*` field from build and runtime-preparation children and passes only the certificate path, SignTool path, key container, and PIN into electron-builder. The signer supplies only validated signing fields in an otherwise scrubbed CMD environment; the CMD disables delayed expansion, clears those fields before SignTool starts, and preserves the PIN only in the required SignTool command line. Every surfaced diagnostic replaces the PIN, and only the dedicated build account and administrators may inspect the runner. The signer signs electron-builder's temporary NSIS bootstrap before enterprise Code Integrity evaluates that executable and clears a generated executable's certificate-table entry only when it points beyond the file before applying the final signature. Packaging fails before producing unsigned artifacts when the SignTool, certificate, container, PIN, token, or signature is unavailable. The custom protocol serves the installed frontend distribution plus client files named by the active module graph and rejects traversal or access outside those roots. The plugin installer API is available only to the Electron-owned management GUI and is absent from the browser application and backend RPC. -Packaged applications ignore development resource and project environment overrides. Only an unpackaged Electron process can replace the Node.js binary, pnpm entry, seed, or active project. +Packaged applications ignore development resource and project environment overrides. Only an unpackaged Electron process can replace the Node.js binary, pnpm entry, dsh resources, or active project. -The bundled upstream Node.js and pnpm are expected to add about 35–50 MB compressed and 120–165 MB installed before the seed store subset. Architecture-specific builds must report actual component-level size deltas. +The bundled upstream Node.js and pnpm are expected to add about 35–50 MB compressed and 120–165 MB installed before the dsh production tree. Architecture-specific builds must report actual component-level size deltas. ## Implementation @@ -107,10 +97,10 @@ The bundled upstream Node.js and pnpm are expected to add about 35–50 MB compr |---|---| | Shell | `apps/desktop` owns Electron windows, restricted preloads, the custom protocol, child lifecycle, project transactions, the plugin GUI, update coordination, and electron-builder configuration. | | Installed runtime | Private `@deepseek-ai/dsh-desktop-host` boots the portless desktop composition from the active project and streams API and asset responses over validated framed byte pipes. | -| Package state | The release seed and every later mutation run through bundled Node.js and pnpm with desktop-owned store, config, cache, state, and home paths; core packages resolve from release tarballs while plugins resolve from the fixed npm registry. | -| Qualification | macOS packaging requires the configured company identity and notary credentials, verifies every native seed object after final archive extraction, verifies the completed application signature, and requires notarization plus Gatekeeper acceptance for both the application and DMG. Windows packaging requires the configured public certificate, SafeNet private-key container, Token Password, and SignTool, and verifies every produced signature. Update hosting, previous-version installed-artifact tests, and platform GUI recordings remain release-environment gates. | +| Package state | Bundled Node.js executes immutable core resources; bundled pnpm modifies only the external plugin graph in Desktop staging. | +| Qualification | macOS packaging requires the configured company identity and notary credentials, verifies every native runtime file before inventory generation, verifies the completed application signature, and requires notarization plus Gatekeeper acceptance for both the application and DMG. Windows packaging requires the configured public certificate, SafeNet private-key container, Token Password, and SignTool, and verifies every produced signature. Update hosting, previous-version installed-artifact tests, and platform GUI recordings remain release-environment gates. | -`dev:desktop` builds the current workspace, projects the built CLI and private Desktop Host packages plus their dependency links into a disposable project, uses an isolated Harness home, opens the Main, Renderer, and Host debuggers, and starts unpackaged Electron without preparing release resources. Package mutation is disabled in this mode because its linked dependency graph is not a pnpm-installed desktop project. Fixed macOS arm64, macOS x64, and Windows x64 package commands pass one target through runtime preparation, seed installation, and electron-builder; each also has an unpacked-directory variant for release-path verification before installer generation. +`dev:desktop` builds the current workspace, projects the built CLI and private Desktop Host packages plus their dependency links into a disposable project, uses an isolated Harness home, opens the Main, Renderer, and Host debuggers, and starts unpackaged Electron without preparing release resources. Package mutation is disabled in this mode because its linked dependency graph is not a pnpm-installed desktop project. Fixed macOS arm64, macOS x64, and Windows x64 package commands pass one target through runtime preparation, runtime preparation, and electron-builder; each also has an unpacked-directory variant for release-path verification before installer generation. ## Alternatives considered @@ -122,11 +112,11 @@ The bundled upstream Node.js and pnpm are expected to add about 35–50 MB compr **Reuse the existing CLI or browser plugin installer.** That crosses the desktop authorization and release scope and can use the user's package-manager state. Desktop package mutation remains exclusively Electron-owned. -**Let the desktop profile use CLI-managed packages or plugins.** Either product could change the other's dependency graph, Cordis version, plugin version, or native module. The desktop profile therefore owns a complete `node_modules` and rejects bundle resolution through the CLI profile fallback. +**Let the desktop profile use CLI-managed packages or plugins.** Either product could change the other’s dependency graph, Cordis version, plugin version, or native module. Desktop rejects package resolution through the CLI profile fallback. -**Install dsh and plugins into separate desktop projects.** This creates a second resolution anchor and peer-dependency fallback. One ordinary npm project already provides the required installation and resolution model. +**Separate core and plugin resolution without shared package ownership.** That permits duplicate host modules and uncontrolled peer fallback. The [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md) supplies explicit links and dependency validation for the separate resource and profile directories. -**Remove non-target Mach-O files from registry packages.** Architecture pruning saves a small amount of seed space, but packages can deliberately ship several architecture variants and callers can observe their installed file set. Signing every shipped Mach-O object satisfies notarization without inventing a Desktop-specific package layout. +**Remove non-target Mach-O files from registry packages.** Architecture pruning saves a small amount of runtime space, but packages can deliberately ship several architecture variants and callers can observe their installed file set. Signing every shipped Mach-O object satisfies notarization without inventing a Desktop-specific package layout. **Export the Windows EV private key in a PFX file.** The externally supplied public leaf certificate lets SignTool construct the signature while `/csp` and `/kc` locate the hardware key. The EV private key remains non-exportable on the token. @@ -136,9 +126,9 @@ The bundled upstream Node.js and pnpm are expected to add about 35–50 MB compr ## Consequences -- A clean offline machine with no system Node.js or pnpm installs the seed into `.dsh/profiles/desktop` and starts a working dsh session. -- The signed application inventories a fixed small set of seed store shards instead of every pnpm cache file; every Mach-O object inside the macOS shards has the release Developer ID, secure timestamp, and hardened runtime, every Windows artifact has the configured hardware-backed EV signature, and the installed private store retains the ordinary pnpm layout. -- `.dsh/profiles/desktop/node_modules` contains and resolves the desktop dsh package and every GUI-installed desktop plugin. +- A clean offline machine without system Node.js or pnpm starts bundled dsh without installing core dependencies. +- The signed application inventories final runtime files; every macOS native file has the release Developer ID, secure timestamp, and hardened runtime, and every Windows artifact has the configured hardware-backed EV signature. +- `.dsh/profiles/desktop/node_modules` resolves shared host links and every GUI-installed desktop plugin. - Every desktop pnpm operation uses the bundled executable and `.dsh/desktop/pnpm/store`; none reads user `PATH`, config, store, or profile `node_modules`. - The Electron-only GUI installs, removes, and updates ordinary npm plugin packages without exposing raw pnpm arguments. - The backend and browser application cannot mutate desktop packages. @@ -157,8 +147,8 @@ The bundled upstream Node.js and pnpm are expected to add about 35–50 MB compr | Decision | Recommendation | |---|---| -| First launch | Bundle an offline seed store subset and install it through pnpm | -| Desktop profile | One Electron-owned reserved profile containing exact dsh and plugin dependencies | +| First launch | Validate bundled production packages and create profile links without installing core dependencies | +| Desktop profile | One Electron-owned reserved profile for external plugins and shared package links | | Plugin management | Electron-only GUI and package service; no CLI, backend, or browser installation path | | Activation | Staging project, complete health check, journaled directory replacement, one rollback copy | | Initial platforms | macOS arm64/x64 and Windows x64; Linux has no supported release target | @@ -175,3 +165,7 @@ An npm-installed dsh and desktop dsh may have different versions while sharing d Directory replacement differs across operating systems and can be interrupted. The activation journal and installed-artifact fault tests must prove recovery at every filesystem move. Code signing, notarization, and update hosting require production release infrastructure. Repository tests alone cannot complete that qualification. + +## Related proposals + +The [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md) supersedes this note’s offline runtime preparation and single-project package ownership. Release identity, signing, portless transport, process ownership, and Electron-only package authorization remain owned here. diff --git a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md index 5108492ba6..88e2875cd3 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md @@ -10,13 +10,13 @@ DeepSeek Harness 需要一个复用 Web UI 的 Electron 桌面应用。该应用 桌面应用与通过 npm 安装的 dsh 共享 `.dsh` 数据根目录,但两者可能使用不同的 dsh 与插件版本。它们必须共享受支持的产品数据,同时不得共享可执行包、lockfile、`node_modules`、插件激活状态或包管理器配置。 -当前 GUI 协议绑定 Web 客户端与后端版本。Electron 产物与其中通过 pnpm 安装的 dsh 如果独立定版本,就会产生未经验证的壳、客户端、后端与插件组合,也无法明确判断更新是否可用。 +当前 GUI 协议绑定 Web 客户端与后端版本。Electron 产物与其中通过 内置 dsh 如果独立定版本,就会产生未经验证的壳、客户端、后端与插件组合,也无法明确判断更新是否可用。 ## 决策 交付一个小型 Electron 壳,其中内置上游 Node.js 可执行文件和固定版本的 pnpm。Electron 把私有 Desktop Host 包作为隔离子进程启动;该包组合已安装的 dsh 后端与匹配的客户端图。Fetch 元数据及有界的原始请求与响应分块通过两条带版本的分帧字节管道传递,Node IPC 只承载就绪、致命失败和关闭,Electron 通过 `dsh-app://` 提供经过验证的资源;它不会打开监听端口。每个帧都包含固定标记、类型、单调 stream id、负载长度和经过验证的负载。串行 writer 遵守 pipe drain,请求或响应 stream 施加背压时 reader 会全局暂停,取消会关闭匹配的 stream,已退役 stream 的迟到响应帧保持无效。Connection 插件无需 `webServer` 即可提供与载体无关的 RPC 与 Fetch 注册表,Client Modules 则向 shell-owned carrier 提供与广告内容完全一致的组合 bundle 响应;Web 组合为两者挂载可选 HTTP route。渲染进程保留相同的 Fetch、RPC 与 Remote-stream 格式,子进程载体则避免 Base64 膨胀,也不依赖 Electron 与内置上游 Node.js 之间的 V8 序列化兼容性。发送 shutdown 后,Electron 会关闭自己持有的请求管道写端,以便在等待子进程退出前释放 Windows 上仍在进行的管道读取。该设计沿用 [GUI 分层与 RPC 协议 Agent Note](../../archived/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)中的 Electron 预留。 -Electron 拥有保留 profile `.dsh/profiles/desktop`。其中精确的 `@deepseek-ai/dsh` 依赖提供后端与匹配的 Web UI,匹配的私有 `@deepseek-ai/dsh-desktop-host` 依赖则只提供 Electron 子进程入口与组合 overlay。dsh 发布、私有 Host 及其第一方依赖闭包使用同一次源码构建生成的本地 npm tarball;profile manifest 把每个核心包列为本地 `file:` 依赖,`pnpm-workspace.yaml` 再通过 overrides 重复该映射。Host 不进入公共 CLI 包,也不会发布到 npm。桌面插件既是同一 profile 中来自 registry 的其他 npm 依赖,也是有序的 `dsh.profile.bundles` 条目,并从该 profile 唯一的 `node_modules` 解析。 +Electron 拥有 `.dsh/profiles/desktop` 保留 profile。[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)负责核心资源存储、外部插件依赖、共享包链接和 profile 协调。私有 Desktop Host 保持独立于公共 CLI 包,且不会发布到 npm。 一个 Desktop 发布号同时标识 Electron 产物及其精确的 `@deepseek-ai/dsh` 与 `@deepseek-ai/dsh-desktop-host` 依赖。发布不能在构建或运行时选择不同的核心版本。因此,即使壳代码没有变化,更新 dsh 也必须产生新的 Electron 发布。 @@ -28,7 +28,7 @@ Electron 拥有保留 profile `.dsh/profiles/desktop`。其中精确的 `@deepse |---|---| | Electron 壳 | 窗口与子进程生命周期、分帧字节管道、生命周期 IPC、自定义协议、保留 desktop profile、插件 GUI、更新协调、回滚 | | 内置 Node.js 与 pnpm | 执行 dsh 并安装桌面项目的精确依赖,不读取用户 `PATH` 或 pnpm 状态 | -| Desktop profile | 为桌面 dsh 包与桌面插件提供一个依赖图、有序 bundle 列表和一个 `node_modules` | +| Desktop profile | 由内置运行时决策定义的外部插件依赖、已启用 bundle 顺序和共享链接 | | 私有 Desktop Host 包 | 与 dsh 一起安装、但不进入公共 CLI 包或 npm 发布的 Electron 专用子进程入口与组合 overlay | | 已安装 dsh 包 | 后端、匹配的 Web UI、启动 manifest、客户端包和产品行为 | | 共享 `.dsh` owner | 会话、设置、凭据、工作区和存储,由其现有锁与格式版本保护 | @@ -55,15 +55,13 @@ Electron 拥有保留 profile `.dsh/profiles/desktop`。其中精确的 `@deepse package.json pnpm-lock.yaml pnpm-workspace.yaml - desktop-release.json - desktop-packages.json - desktop-packages/ + desktop-runtime-state.json node_modules/ sessions/ storages/ ``` -`.dsh/profiles/desktop` 是唯一活跃的 desktop profile。其 package manifest 记录内置与已安装插件 bundle 的顺序;只有 Electron 可以修改它的依赖、lockfile 和 `node_modules`。生产启动会拒绝解析到该 profile 之外的 bundle,包括 CLI 维护的 `.dsh/profiles/node_modules` fallback。desktop profile 安装的所有包内容都使用 `.dsh/desktop/pnpm/store`。 +`.dsh/profiles/desktop` 是唯一活动桌面 profile。其可执行包归属及允许解析的目录遵循[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)。插件包内容使用 `.dsh/desktop/pnpm/store`。 ## 安装与解析 @@ -71,33 +69,25 @@ Electron 拥有保留 profile `.dsh/profiles/desktop`。其中精确的 `@deepse 进程生命周期 Electron 锁是 Desktop 的权威 owner。包事务锁用于纵深防御,并记录仍能修改包状态的进程:包操作之间记录 Electron,pnpm 运行期间记录已生成的 pnpm PID。Owner 变更通过已经打开的排他锁文件完成截断、写入与同步。如果 Electron 在 pnpm 执行期间终止,后续进程会发现仍存活的 worker,并拒绝启动并发的 store 或 staging 事务;该 worker 退出后,陈旧 PID 才可以恢复。 -打包 seed 是离线安装包,而不是可执行 dsh 目录。它包含发布身份、初始桌面项目 manifest、分别以 dsh 和私有 Desktop Host 为根的第一方包闭包之并集的描述文件及不可变 tarball、lockfile、完整性清单和所需 store 子集。每个 `mac-arm64`、`mac-x64` 和 `win-x64` 构建都在 `.desktop-build/targets/` 下持有自己的打包输入、运行时、包集合、seed、pnpm 准备状态、未打包应用、更新元数据和最终产物;只有不可变且经过校验和验证的 Node.js 下载缓存会被共享。发布构建要求 Electron 包、根 dsh 包与私有 Host 包使用相同版本,从正式源码构建生成最终 npm tarball,在本地打包私有 Host,选择可达的 dsh、Host 与 vendored 包以及 Landlock 入口,并验证 Host tarball 中包含 `lib/index.js` 与 `config/desktop.cordis.patch.yml`。Host 的 `files` manifest 只包含该运行入口与 overlay,并且该包不会发布到 npm。公共包 tarball 仍是由各包发布 manifest 控制的正式 `pnpm pack` 结果;Desktop 不删除已发布的声明文件,也不建立第二套包内容策略。seed manifest 把每个选中的包列为本地直接依赖,关闭 peer dependency 自动安装,workspace 文件再把每个选中的第一方包 override 到对应本地 tarball。目标 Node.js 执行内置 pnpm,因此 pnpm 的操作系统和 CPU 选择会使物化的依赖图与 seed 成为目标专用内容。内置 pnpm 关闭全局 virtual store,在禁用生命周期脚本的情况下从 npm 物化外部生产依赖,删除 `node_modules` 以及所有临时 pnpm cache、config 和 state 目录,然后只使用最终 store 执行一次干净的离线安装,并检查私有 Host 的入口与 overlay。构建会拒绝任何通过 registry 版本解析本地第一方包名的 lockfile。生成清单前会删除第二次生成的 `node_modules` 和临时 pnpm 项目注册。在复制 package set 前与离线安装后都要求这两个 Host 文件,可防止进程入口能够加载、却无法组合所需 overlay 的发布进入应用签名阶段。 - -种子根据规范化 store 路径,把 pnpm 内容放入 16 个确定性的未压缩 tar 分片。Apple 公证会检查这些归档内的 Mach-O 代码,因此 macOS seed 会 staging 每个被引用的内容寻址 Mach-O 对象,最多并发四个独立的 Developer ID 签名进程,并带上安全时间戳与 hardened runtime。任一签名失败后,准备过程会等待已启动的签名进程全部退出,原始 CAS 对象与包索引保持不变。所有签名成功后,准备过程把每个对象写到新的 SHA-512 路径,并以事务方式重写 pnpm MessagePack SQLite 索引内全部基础文件和 side-effects 文件引用。第二次离线安装证明 pnpm 可以解析重写后的 store;准备过程随后完成分片、解包最终归档并验证每个内嵌签名。包路径和非原生字节保持不变;种子保留包内附带的架构变体,因为删除文件会创建 Desktop 专属的包文件集。种子完整性覆盖分片 manifest 和解包前的每个归档。启动时验证归档路径、条目类型、唯一性和数量,把所有分片解包到唯一且由 Desktop 拥有的 staging 目录,替换匹配的不可变 store 文件,并以事务方式把各 pnpm store 版本的 SQLite `package_index` 合并进 `.dsh/desktop/pnpm/store`。Seed 记录替换匹配的键,为 Desktop 插件下载的记录继续保留。中断的文件合并可能留下有效的不可变缓存内容,但每次 SQLite 合并都是原子的,profile 安装与激活仍必须通过 pnpm 完整性与完整健康检查。 - -启动过程先要求安装包内的发布身份等于 Electron 应用版本,再在启动后端前比较 `.dsh/profiles/desktop/desktop-release.json`、已安装 dsh 包、已安装 Desktop Host 包与该发布版本。它在 staging 中通过 `pnpm install --offline --frozen-lockfile --trust-lockfile` 安装新的 seed manifest 与 lockfile。Electron 替换后,启动过程再通过一次离线 pnpm add,从桌面端现有 store 与元数据缓存恢复活跃 profile 记录的每个插件 bundle 精确版本。完整依赖图必须通过同一套健康检查才能激活。 - -插件 GUI 执行等价于 `pnpm add --save-exact`、`pnpm remove ` 和精确版本更新的 registry npm 包操作。每次修改都保留本地核心包描述文件、tarball、dsh 与 Desktop Host 依赖和完整 override 映射。Electron 验证已安装包 manifest,并更新 profile 的依赖与有序 bundle 条目;任何渲染进程请求都不能选择 registry、安装目录、生命周期策略或任意 pnpm flag。 - -后端与 Loader 把 `.dsh/profiles/desktop/package.json` 作为 profile manifest 和 npm 解析锚点。公共 profile loader 先组合其中的有序 bundle 条目,再由私有 Desktop Host 应用其打包的 overlay。Host、dsh、Cordis、桌面插件、插件依赖和 peer dependency 均通过普通 pnpm `node_modules` 图解析。贡献 `dsh.client` 代码的桌面插件只有在完整 profile 通过健康检查后才进入启动 manifest。 +核心物化、首次启动、插件安装和共享模块解析遵循[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)。提供 `dsh.client` 代码的桌面插件只有在完整 profile 通过健康检查后才会进入客户端依赖图。 ## 更新与恢复 Electron 更新只使用一个 `electron-updater` 发布流和签名 `electron-builder` 产物。该版本就是 Desktop 发布版本;不存在独立 dsh manifest、兼容范围或仅更新 dsh 的操作。前台安装会等待正在进行的后台检查,而不会把检查结果复用成安装结果。更新弹窗下载并安装 Electron 产物,然后重启进入新发布。 -新发布在打开窗口前从安装包种子校准 dsh,同时保留已安装桌面插件。健康检查覆盖依赖解析、原生模块、壳 API 兼容性、后端启停、Web 资源和客户端启动图。不兼容插件会阻止激活,并保留上一个项目用于回滚。启动过程会明确失败,而不会运行版本不匹配的壳与 dsh。 +打开产品窗口前,启动过程会验证当前内置运行时,并根据[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)协调外部插件。后端失败会打开 Electron 拥有的恢复窗口,不会启动版本不匹配的 dsh。 `DSH_DESKTOP_AUTO_UPDATE_ENV` 默认为测试部署,也可以选择生产部署,并同时决定目标专用的 generic-provider URL 与 COS 目标。发布自动化通过 `DOWNLOAD_TEST_ORIGIN` 提供测试 HTTPS origin,并通过 `DOWNLOAD_TEST_COS_BUCKET` 或 `DOWNLOAD_PROD_COS_BUCKET` 提供各部署的 bucket;可变的测试路由与 COS 存储身份不写入源码,部署基础设施变更时无需发布新代码,而公开的生产 origin 仍固定。打包只解析公开更新 URL、禁止 electron-builder 发布、从子进程环境中删除每个 COS 凭据字段,并且只有在 electron-builder 以及每个签名或公证 hook 成功后才写入完成记录。目标上传还必须提供所选 bucket,随后会先要求完成记录、根 dsh 版本、Desktop 版本、根据版本得出的频道元数据、产物名称、大小与 SHA-512 全部一致,再读取所选凭据或发送数据。它先上传不可变且带版本的更新载荷与所有独立 blockmap,最后替换 electron-builder 生成的频道元数据,并且不会删除历史对象。稳定版本使用 `latest` 元数据名称,预发布版本则使用语义化版本的第一个预发布标识符。NSIS 把 blockmap 嵌入已签名的可执行文件,macOS ZIP 则使用独立 blockmap;两者都让 electron-updater 在平台支持时只下载变化的数据块,而应用替换与本地 pnpm staging 事务仍是两个独立操作。 ## 安全与发布策略 -核心 dsh 与私有 Desktop Host 只能来自签名 Electron 发布内经过完整性记录的本地 npm tarball;pnpm overrides 防止传递核心包回退到 registry。Store 归档经过完整性检查,并在隔离的解包目录中完成全部验证,归档文件随后才能进入可写包状态。插件安装接受桌面策略允许的 registry 包 spec,但绝不接受原始 pnpm 命令。激活前必须具备精确版本、lockfile 完整性、经过评审的 `allowBuilds` 集合、仅限用户的目录权限、遮盖后的诊断和健康检查。 +核心 dsh 和私有 Desktop Host 只来自签名应用的资源树。插件安装接受桌面策略允许的 registry 包规格,不接受原始 pnpm 命令。激活前要求精确版本、锁文件完整性、经过审查的 `allowBuilds` 集合、仅限用户访问的目录权限和健康检查。 -Electron 产物必须签名;macOS 产物必须公证。发布自动化必须通过明确的环境变量提供应用 ID、macOS Developer ID 限定名、预期 Team ID 与一套完整的 notarytool 凭据。配置加载会拒绝缺失或格式错误的标识符和不完整的公证凭据,macOS 打包还会强制签名,避免证书发现过程静默选择其他已安装身份或生成未签名发布。Seed 准备会验证每个内嵌 Mach-O 文件的精确 Authority 与 Team ID,以及时间戳和 hardened-runtime 标记。签名后钩子会执行 Apple 的深度严格应用验证,并要求同一叶证书 Authority 与 Team ID 完全匹配,验证通过后才继续生成产物。Electron-builder 随后公证应用并钉票、签署 DMG。DMG 的 artifact-completion hook 会单独公证每个 DMG 并钉票,再要求其使用配置的身份、具备有效票据并通过 Gatekeeper;只有该 hook 成功,上传事件才会执行。macOS 更新使用签名 ZIP,因此 DMG 不生成 blockmap;否则钉票会让已经生成的 DMG blockmap 失效。自定义协议提供已安装的前端分发目录和活跃模块图点名的客户端文件,并拒绝路径穿越或访问这些根目录之外的内容。插件安装器 API 只对 Electron 拥有的管理 GUI 可用,不存在于浏览器应用或后端 RPC 中。 +Electron 产物必须签名;macOS 产物必须公证。发布自动化必须通过明确的环境变量提供应用 ID、macOS Developer ID 限定名、预期 Team ID 与一套完整的 notarytool 凭据。配置加载会拒绝缺失或格式错误的标识符和不完整的公证凭据,macOS 打包还会强制签名,避免证书发现过程静默选择其他已安装身份或生成未签名发布。运行时准备会验证每个内嵌 Mach-O 文件的精确 Authority 与 Team ID,以及时间戳和 hardened-runtime 标记。签名后钩子会执行 Apple 的深度严格应用验证,并要求同一叶证书 Authority 与 Team ID 完全匹配,验证通过后才继续生成产物。Electron-builder 随后公证应用并钉票、签署 DMG。DMG 的 artifact-completion hook 会单独公证每个 DMG 并钉票,再要求其使用配置的身份、具备有效票据并通过 Gatekeeper;只有该 hook 成功,上传事件才会执行。macOS 更新使用签名 ZIP,因此 DMG 不生成 blockmap;否则钉票会让已经生成的 DMG blockmap 失效。自定义协议提供已安装的前端分发目录和活跃模块图点名的客户端文件,并拒绝路径穿越或访问这些根目录之外的内容。插件安装器 API 只对 Electron 拥有的管理 GUI 可用,不存在于浏览器应用或后端 RPC 中。 -Windows 发布打包通过 `/f` 向已配置且与 SafeNet 兼容的 SignTool 提供 `DSH_DESKTOP_WINDOWS_CER_FILE` 指定的公开 EV 叶证书,并通过必需的 `DSH_DESKTOP_WINDOWS_KEY_CONTAINER` 标识匹配的私钥。证书文件保留在源码仓库之外,私钥仍留在 USB Token 上。electron-builder hook 把每个产物交给采用 CRLF 的 `windows-sign.cmd`;该 CMD 只调用一次 SignTool,并指定 SafeNet `/kc "[{{PIN}}]=容器"` 值与 CSP、SHA-256 文件摘要和 DigiCert SHA-256 RFC 3161 时间戳。hook 不会改用其他 SignTool,也不会重试失败的请求。打包编排不会把任何 `DSH_DESKTOP_WINDOWS_*` 字段传给构建与 seed 准备子进程,只会把证书路径、SignTool 路径、密钥容器和 PIN 传入 electron-builder。签名器在已清理的 CMD 环境中只提供经过校验的签名字段;CMD 会禁用延迟展开,在 SignTool 启动前清除这些字段,并仅在 SignTool 必需的命令行中保留 PIN。所有对外诊断都会替换 PIN,而且只能允许专用构建账号和管理员检查该 runner。签名器会在企业 Code Integrity 检查 electron-builder 的临时 NSIS bootstrap 前先为该可执行文件签名;对于生成的可执行文件,只有证书表条目指向文件末尾之外时,才会在最终签名前清除该条目。SignTool、证书、容器、PIN、Token 或签名不可用时,打包会在产生未签名产物前失败。自定义协议提供已安装的前端分发目录和活跃模块图点名的客户端文件,并拒绝路径穿越或访问这些根目录之外的内容。插件安装器 API 只对 Electron 持有的管理 GUI 可用,不存在于浏览器应用或后端 RPC 中。 +Windows 发布打包通过 `/f` 向已配置且与 SafeNet 兼容的 SignTool 提供 `DSH_DESKTOP_WINDOWS_CER_FILE` 指定的公开 EV 叶证书,并通过必需的 `DSH_DESKTOP_WINDOWS_KEY_CONTAINER` 标识匹配的私钥。证书文件保留在源码仓库之外,私钥仍留在 USB Token 上。electron-builder hook 把每个产物交给采用 CRLF 的 `windows-sign.cmd`;该 CMD 只调用一次 SignTool,并指定 SafeNet `/kc "[{{PIN}}]=容器"` 值与 CSP、SHA-256 文件摘要和 DigiCert SHA-256 RFC 3161 时间戳。hook 不会改用其他 SignTool,也不会重试失败的请求。打包编排不会把任何 `DSH_DESKTOP_WINDOWS_*` 字段传给构建与 运行时准备子进程,只会把证书路径、SignTool 路径、密钥容器和 PIN 传入 electron-builder。签名器在已清理的 CMD 环境中只提供经过校验的签名字段;CMD 会禁用延迟展开,在 SignTool 启动前清除这些字段,并仅在 SignTool 必需的命令行中保留 PIN。所有对外诊断都会替换 PIN,而且只能允许专用构建账号和管理员检查该 runner。签名器会在企业 Code Integrity 检查 electron-builder 的临时 NSIS bootstrap 前先为该可执行文件签名;对于生成的可执行文件,只有证书表条目指向文件末尾之外时,才会在最终签名前清除该条目。SignTool、证书、容器、PIN、Token 或签名不可用时,打包会在产生未签名产物前失败。自定义协议提供已安装的前端分发目录和活跃模块图点名的客户端文件,并拒绝路径穿越或访问这些根目录之外的内容。插件安装器 API 只对 Electron 持有的管理 GUI 可用,不存在于浏览器应用或后端 RPC 中。 -打包应用会忽略开发资源和项目环境变量覆盖。只有未打包的 Electron 进程可以替换 Node.js 可执行文件、pnpm 入口、seed 或活跃项目。 +打包应用会忽略开发资源和项目环境变量覆盖。只有未打包的 Electron 进程可以替换 Node.js 可执行文件、pnpm 入口、dsh 资源 或活跃项目。 在种子 store 子集之外,内置上游 Node.js 与 pnpm 预计增加约 35–50 MB 压缩体积和 120–165 MB 安装体积。分架构构建必须报告实际组件级体积增量。 @@ -107,10 +97,10 @@ Windows 发布打包通过 `/f` 向已配置且与 SafeNet 兼容的 SignTool |---|---| | 壳 | `apps/desktop` 负责 Electron 窗口、受限 preload、自定义协议、子进程生命周期、项目事务、插件 GUI、更新协调和 electron-builder 配置。 | | 已安装运行时 | 私有 `@deepseek-ai/dsh-desktop-host` 从活跃项目启动无端口桌面组合,并通过经过验证的分帧字节管道流式传输 API 与资源响应。 | -| 包状态 | 发布种子和后续每次修改都通过内置 Node.js 与 pnpm 执行,并使用桌面端拥有的 store、config、cache、state 和 home 路径;核心包从发布 tarball 解析,插件从固定 npm registry 解析。 | -| 资格验证 | macOS 打包要求已配置的公司身份与公证凭据可用,在解包最终归档后验证每个原生 seed 对象,验证完整应用签名,并要求应用和 DMG 都完成公证且通过 Gatekeeper。Windows 打包要求已配置的公开证书、SafeNet 私钥容器、Token Password 与 SignTool,并验证生成的每个签名。更新托管、跨上一版本的已安装产物测试和各平台 GUI 录制仍是发布环境门槛。 | +| 包状态 | 内置 Node.js 执行不可变核心资源;内置 pnpm 只修改 Desktop staging 中的外部插件依赖图。 | +| 资格验证 | macOS 打包要求已配置的公司身份与公证凭据可用,在生成清单前验证每个原生运行时文件,验证完整应用签名,并要求应用和 DMG 都完成公证且通过 Gatekeeper。Windows 打包要求已配置的公开证书、SafeNet 私钥容器、Token Password 与 SignTool,并验证生成的每个签名。更新托管、跨上一版本的已安装产物测试和各平台 GUI 录制仍是发布环境门槛。 | -`dev:desktop` 会构建当前 workspace,把已构建 CLI 包、私有 Desktop Host 包及其依赖链接投影为一次性项目,使用隔离的 Harness home,打开 Main、Renderer 和 Host 调试器,并在不准备发布资源的情况下启动未打包 Electron。该模式的链接依赖图不是由 pnpm 安装的桌面项目,因此会禁用包修改。固定的 macOS arm64、macOS x64 与 Windows x64 打包命令会把同一目标传给运行时准备、seed 安装和 electron-builder;每条命令还提供未封装安装器的变体,用于在生成安装器前验证发布路径。 +`dev:desktop` 会构建当前 workspace,把已构建 CLI 包、私有 Desktop Host 包及其依赖链接投影为一次性项目,使用隔离的 Harness home,打开 Main、Renderer 和 Host 调试器,并在不准备发布资源的情况下启动未打包 Electron。该模式的链接依赖图不是由 pnpm 安装的桌面项目,因此会禁用包修改。固定的 macOS arm64、macOS x64 与 Windows x64 打包命令会把同一目标传给运行时准备、运行时准备和 electron-builder;每条命令还提供未封装安装器的变体,用于在生成安装器前验证发布路径。 ## 考虑过的替代方案 @@ -122,11 +112,11 @@ Windows 发布打包通过 `/f` 向已配置且与 SafeNet 兼容的 SignTool **复用现有 CLI 或浏览器插件安装器。** 这会跨越桌面授权与发布 scope,并可能使用用户的包管理器状态。桌面包修改完全由 Electron 拥有。 -**让 desktop profile 使用 CLI 管理的包或插件。** 任一产品都可能改变另一方的依赖图、Cordis 版本、插件版本或原生模块。因此 desktop profile 持有完整 `node_modules`,并拒绝通过 CLI profile fallback 解析 bundle。 +**让桌面 profile 使用 CLI 管理的包或插件。** 任一产品都可能改变另一产品的依赖图、Cordis 版本、插件版本或原生模块。Desktop 拒绝通过 CLI profile 回退目录解析包。 -**把 dsh 与插件安装到不同桌面项目。** 这会产生第二解析锚点和 peer dependency 回退。一个普通 npm 项目已经提供所需安装与解析模型。 +**分离核心与插件解析,却不明确共享包归属。** 这会允许宿主模块重复以及不可控的 peer 回退。[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)为分离的资源和 profile 目录提供明确链接及依赖验证。 -**从 registry 包删除非目标 Mach-O 文件。** 架构裁剪可以节省少量 seed 空间,但包可能有意附带多个架构变体,调用方也可以观察安装后的文件集。签署每个实际携带的 Mach-O 对象,无需发明 Desktop 专属包布局就能满足公证要求。 +**从 registry 包删除非目标 Mach-O 文件。** 架构裁剪可以节省少量 运行时空间,但包可能有意附带多个架构变体,调用方也可以观察安装后的文件集。签署每个实际携带的 Mach-O 对象,无需发明 Desktop 专属包布局就能满足公证要求。 **把 Windows EV 私钥导出到 PFX 文件。** 外部提供的公开叶证书让 SignTool 构造签名,`/csp` 与 `/kc` 则定位硬件密钥。EV 私钥保持不可导出,并留在 Token 上。 @@ -136,9 +126,9 @@ Windows 发布打包通过 `/f` 向已配置且与 SafeNet 兼容的 SignTool ## 结果 -- 没有系统 Node.js 或 pnpm 的干净离线机器把种子安装进 `.dsh/profiles/desktop`,并启动可工作的 dsh 会话。 -- 已签名应用记录固定少量的 seed store 分片,而不是记录每个 pnpm 缓存文件;macOS 分片内每个 Mach-O 对象都带有发布 Developer ID、安全时间戳与 hardened runtime,每个 Windows 产物都带有配置的硬件支持 EV 签名,安装后的私有 store 仍保持普通 pnpm 布局。 -- `.dsh/profiles/desktop/node_modules` 包含并解析桌面 dsh 包和每个 GUI 安装的桌面插件。 +- 没有系统 Node.js 或 pnpm 的干净离线机器能够启动内置 dsh,无需安装核心依赖。 +- 签名应用记录最终运行时文件清单;每个 macOS 原生文件都具有发布 Developer ID、安全时间戳和 hardened runtime,每个 Windows 产物都具有配置的硬件 EV 签名。 +- `.dsh/profiles/desktop/node_modules` 能解析共享宿主链接和每个通过 GUI 安装的桌面插件。 - 每个桌面 pnpm 操作都使用内置可执行文件和 `.dsh/desktop/pnpm/store`;不读取用户 `PATH`、配置、store 或 profile `node_modules`。 - Electron-only GUI 安装、删除和更新普通 npm 插件包,而不暴露原始 pnpm 参数。 - 后端与浏览器应用不能修改桌面包。 @@ -157,8 +147,8 @@ Windows 发布打包通过 `/f` 向已配置且与 SafeNet 兼容的 SignTool | 决策 | 建议 | |---|---| -| 首次启动 | 打包离线种子 store 子集,并通过 pnpm 安装 | -| Desktop profile | 一个包含精确 dsh 与插件依赖、由 Electron 持有的保留 profile | +| 首次启动 | 验证内置生产包并创建 profile 链接,不安装核心依赖 | +| 桌面 profile | 一个由 Electron 拥有的保留 profile,保存外部插件和共享包链接 | | 插件管理 | Electron-only GUI 与包服务;没有 CLI、后端或浏览器安装路径 | | 激活 | 暂存项目、完整健康检查、记录式目录替换和一个回滚副本 | | 初始平台 | macOS arm64/x64 与 Windows x64;Linux 尚无受支持的发布目标 | @@ -175,3 +165,7 @@ Windows 发布打包通过 `/f` 向已配置且与 SafeNet 兼容的 SignTool 不同操作系统的目录替换行为不同,而且可能中断。激活记录与已安装产物故障测试必须证明每次文件系统移动都可以恢复。 代码签名、公证和更新托管需要生产发布基础设施。只运行仓库测试不能完成这些认证。 + +## 相关提案 + +[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)取代本记录的离线 运行时准备与单项目包归属决策。发布身份、签名、无端口传输、进程归属及仅限 Electron 的包授权仍由本记录负责。 diff --git a/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.i18n.yaml new file mode 100644 index 0000000000..9729df077f --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.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-08-desktop-bundled-runtime-and-external-plugins.md +2026-09-08-desktop-bundled-runtime-and-external-plugins.md: 8278107fb3c37aca909541886d9379a1bfd64bac +2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md: b29eb1e6cbe19d4a5b4ea23a5d6159cfd3973bcd diff --git a/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md b/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md new file mode 100644 index 0000000000..8278107fb3 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md @@ -0,0 +1,51 @@ +# Agent Note: Bundle the Desktop runtime and retain external plugins + +Status: implemented + +English | [中文](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md) + +## Problem + +Installing the core dependency graph during Desktop initialization repeats work already done by the release builder. An offline store eliminates downloads but retains extraction, package-manager startup, and installation costs. Users need the application to start with its production packages present while retaining ordinary npm plugin installation and plugin state across application upgrades. + +Separate package directories can load duplicate Cordis or service modules. Retaining plugin files also does not prove compatibility with a new host API or Node runtime. + +## Decision + +[Runtime preparation](../../../../apps/desktop/scripts/prepare-dsh.ts) materializes the production graph once at build time and ships it through `extraResources/dsh`. The Electron shell stays in ASAR. A bundled upstream Node process runs the private Desktop Host from resources and loads enabled plugins from `$DSH_HOME/profiles/desktop`. + +Desktop has not been released. This is its first installation format; there are no readers or migrations for the unpublished seed-based profile. This note supersedes core seed installation and single-project dependency ownership in the [Desktop packaging decision](2026-08-25-electron-desktop-packaging-and-updates.md). That note continues to own release identity, signing, portless transport, process ownership, and Electron-only plugin authorization. No existing note is fully superseded or archived. + +## Package ownership + +The resource descriptor records the exact release, Node version, platform, architecture, shared package versions, and final file hashes. The runtime tree contains ordinary files and directories, without links back to pnpm’s build store. Native Mach-O files are signed before hashing; the application signer preserves their bytes and checks the inventory after signing. An explicit `dsh/node_modules` resource mapping bypasses electron-builder’s root `node_modules` exclusion, and the copied tree is verified before any signing or notarization. + +Every first-party package in the dsh and private Host production closures is shared. The profile contains directory symlinks to those resource packages, or junctions on Windows. Links resolve to real host package directories under normal Node resolution. Host and plugin imports therefore share the same module instance for each resolved export. Distinct ESM and CommonJS conditional exports remain distinct entry points; a link cannot merge a package’s dual implementations. + +External plugins declare shared host packages as peers. Ordinary dependencies remain plugin-owned and may differ from the versions used by dsh. Validation rejects incompatible enabled peers, nested or aliased copies of shared packages, private package links, and dependency resolution through CLI or other ancestor directories. A third-party package requiring host-wide instance identity must be explicitly added to the runtime’s shared inventory; matching version numbers alone are insufficient. + +The profile manifest records exact installed plugin dependencies separately from its enabled bundle list. Disabling a plugin preserves its package, lockfile entry, and user configuration. The shared links are Desktop-owned derived state, recorded separately from pnpm; package-manager operations run without those links, then Desktop recreates and validates them. + +## Transactions and upgrades + +First launch creates profile metadata and host links without running pnpm. A compatible release change or application relocation copies plugin files into staging, refreshes links, validates enabled peers, and boots the staged backend. Node version, platform, or architecture changes reinstall the locked plugin graph before activation. Writable staging and rollback files are independent copies, never hardlinks into the active profile. + +Dependency mutations install with scripts disabled, validate the plugin graph and host links, run the reviewed pending lifecycle builds, and validate again. This permits approved native dependencies to resolve host peers while preventing accidental duplicate host packages from reaching startup. The `allowBuilds` policy remains explicit; unsupported build-requiring dependencies fail the transaction. + +The transaction journal records source and target runtime identities and directory-move phases. Desktop waits for pnpm exit and stops the active backend before replacement. A failed or interrupted activation restores a complete profile. A runtime identity mismatch prevents the restored profile from booting under a different application release. The journal covers profile files, not arbitrary plugin startup effects or durable Session writes. + +An Electron-owned recovery window remains available when the backend cannot start. Users can update, remove, disable, or re-enable plugins and retry startup. Incompatible plugins are not silently deleted or automatically downgraded. Application rollback and profile rollback are separate operations; each backend launch requires the current runtime identity. + +## Alternatives considered + +- **Install the bundled offline seed at startup.** This preserves an ordinary pnpm installation procedure but repeats core extraction and installation on every affected machine. Materialized resources remove that work at the cost of more application files and release-builder responsibility. +- **Link all host dependencies into plugins.** This unnecessarily couples ordinary plugin dependencies to the host. Only the explicit shared inventory is linked; private packages retain independent versions. +- **Use hardlinks.** They cannot represent directories, may not cross volumes, share writable bytes, and retain old inodes after application replacement. Directory symlinks and Windows junctions express the intended package target. +- **Use `NODE_PATH` or preserve symlink paths.** These do not provide uniform ESM resolution or shared module identity. Normal package lookup through explicit links is directly testable. +- **Keep core packages in ASAR.** The backend uses upstream Node rather than Electron’s patched filesystem. Ordinary `extraResources` also preserves native loading and subprocess paths. + +## Consequences + +Core package installation is absent from first launch and compatible upgrades. Runtime integrity verification and a complete backend health check still cost startup time; no release latency or download-size improvement is claimed without measurement. Plugin preservation is conditional on host API and native runtime compatibility, with a visible recovery path when that condition fails. + +The [Desktop README](../../../../apps/desktop/README.md) owns operational guidance. Focused tests cover real pnpm installation and approved builds, shared ESM instance identity, private dependency versions, relocation, disabled plugins, native rebuild selection, activation failures, and transaction locking. Signed installed-artifact upgrades, macOS notarization, Windows junction/native behavior, release size and startup benchmarks, and real-model GUI recordings remain release-environment qualification requirements; unit fixtures do not substitute for them. diff --git a/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md b/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md new file mode 100644 index 0000000000..b29eb1e6cb --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md @@ -0,0 +1,51 @@ +# Agent Note: 内置 Desktop 运行时并保留外部插件 + +Status: implemented + +[English](2026-09-08-desktop-bundled-runtime-and-external-plugins.md) | 中文 + +## 问题 + +Desktop 初始化时安装核心依赖图,会重复发布构建器已经完成的工作。离线 store 消除了下载,但仍有解压、包管理器启动和安装成本。用户需要应用在生产依赖已就绪时启动,同时保留普通 npm 插件安装能力,以及跨应用升级的插件状态。 + +分离的包目录可能加载重复的 Cordis 或服务模块。仅保留插件文件也不能证明它与新的宿主 API 或 Node 运行时兼容。 + +## 决策 + +[运行时准备](../../../../apps/desktop/scripts/prepare-dsh.ts)在构建时物化一次生产依赖图,并通过 `extraResources/dsh` 分发。Electron 壳保留在 ASAR 中。内置上游 Node 进程从资源启动私有 Desktop Host,并从 `$DSH_HOME/profiles/desktop` 加载已启用插件。 + +Desktop 尚未发布。这是它的第一种安装格式;不提供未发布 seed profile 的读取器或迁移。本记录取代 [Desktop 打包决策](2026-08-25-electron-desktop-packaging-and-updates.zh.md)中的核心 seed 安装和单项目依赖归属部分。该记录继续负责发布身份、签名、无端口传输、进程归属和仅限 Electron 的插件授权。没有现有记录被完全取代或归档。 + +## 包归属 + +资源描述文件记录精确发布版本、Node 版本、平台、架构、共享包版本和最终文件哈希。运行时树包含普通文件和目录,不包含指回 pnpm 构建 store 的链接。原生 Mach-O 文件先签名再哈希;应用签名器保留其字节,并在签名后检查清单。明确的 `dsh/node_modules` 资源映射绕过 electron-builder 对根 `node_modules` 的排除,并在任何签名或公证前验证复制后的依赖树。 + +dsh 与私有 Host 生产闭包中的每个第一方包都共享。profile 包含指向这些资源包的目录软链接,在 Windows 上使用 junction。正常 Node 解析会把链接解析到实际宿主包目录。因此,宿主与插件对每个已解析导出的导入共享同一模块实例。不同的 ESM 与 CommonJS 条件导出仍是不同入口;链接不能合并包的两套实现。 + +外部插件把共享宿主包声明为 peer。普通依赖由插件拥有,可以不同于 dsh 使用的版本。验证拒绝已启用插件的不兼容 peer、共享包的嵌套或别名副本、私有包链接,以及通过 CLI 或其他祖先目录解析依赖。如果第三方包需要宿主范围的实例身份,必须明确加入运行时共享清单;版本号相同并不足够。 + +profile manifest 分别记录精确的已安装插件依赖和已启用 bundle 列表。停用插件会保留其包、锁文件条目和用户配置。共享链接是 Desktop 拥有的派生状态,独立于 pnpm 记录;包管理器操作不携带这些链接,随后 Desktop 重建并验证它们。 + +## 事务与升级 + +首次启动创建 profile 元数据和宿主链接,不运行 pnpm。兼容的发布变化或应用移动会把插件文件复制到 staging,刷新链接,验证已启用的 peer,并启动 staging 后端。Node 版本、平台或架构变化时,激活前会重新安装锁定的插件依赖图。可写 staging 与回滚文件使用独立副本,不使用指向活动 profile 的硬链接。 + +依赖修改先禁用脚本安装,验证插件依赖图和宿主链接,运行经过审查的待执行生命周期构建,再次验证。这允许已批准的原生依赖解析宿主 peer,同时阻止意外的重复宿主包进入启动过程。`allowBuilds` 策略保持明确;不受支持且需要构建的依赖会使事务失败。 + +事务日志记录源与目标运行时身份以及目录移动阶段。Desktop 等待 pnpm 退出,并在替换前停止活动后端。失败或中断的激活会恢复完整 profile。运行时身份不匹配会阻止恢复的 profile 在其他应用版本下启动。事务日志覆盖 profile 文件,不覆盖任意插件启动副作用或持久 Session 写入。 + +后端无法启动时,Electron 拥有的恢复窗口仍可使用。用户可以更新、移除、停用或重新启用插件,并重试启动。不兼容插件不会被静默删除或自动降级。应用回滚与 profile 回滚是独立操作;每次后端启动都要求当前运行时身份。 + +## 考虑过的替代方案 + +- **启动时安装内置离线 seed。** 这保留普通 pnpm 安装流程,但会在每台受影响机器上重复核心解压与安装。物化资源消除了这部分工作,代价是更多应用文件和发布构建器责任。 +- **把所有宿主依赖链接给插件。** 这会让普通插件依赖与宿主产生不必要的耦合。只链接明确的共享清单;私有包保留独立版本。 +- **使用硬链接。** 它不能表示目录,可能无法跨卷,共享可写字节,并在应用替换后保留旧 inode。目录软链接和 Windows junction 能表达预期的包目标。 +- **使用 `NODE_PATH` 或保留软链接路径。** 它们不能提供统一的 ESM 解析或共享模块身份。通过明确链接进行正常包查找可以直接测试。 +- **把核心包留在 ASAR。** 后端使用上游 Node,而不是 Electron 修改过的文件系统。普通 `extraResources` 也能保留原生加载和子进程路径。 + +## 影响 + +首次启动和兼容升级不安装核心包。运行时完整性验证和完整后端健康检查仍需要启动时间;没有测量前,不声称发布启动延迟或下载体积改善。插件保留以宿主 API 和原生运行时兼容为条件,条件不满足时提供可见的恢复入口。 + +[Desktop README](../../../../apps/desktop/README.zh.md)负责操作说明。定向测试覆盖真实 pnpm 安装与已批准构建、共享 ESM 实例身份、私有依赖版本、应用移动、停用插件、原生重建选择、激活失败和事务锁。签名安装产物升级、macOS 公证、Windows junction 与原生行为、发布体积与启动基准,以及真实模型 GUI 录制仍是发布环境验收要求;单元夹具不能替代这些验证。 diff --git a/.agents/notes/proposed/feature/2026-09-08-desktop-uninstall-preserve-dsh-home.i18n.yaml b/.agents/notes/proposed/feature/2026-09-08-desktop-uninstall-preserve-dsh-home.i18n.yaml new file mode 100644 index 0000000000..cd64ee4423 --- /dev/null +++ b/.agents/notes/proposed/feature/2026-09-08-desktop-uninstall-preserve-dsh-home.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/proposed/feature/2026-09-08-desktop-uninstall-preserve-dsh-home.md +2026-09-08-desktop-uninstall-preserve-dsh-home.md: e1c2435814a4563f1fed6652d521928200d4ade7 +2026-09-08-desktop-uninstall-preserve-dsh-home.zh.md: 27fd7c8cbec360db35cadc75ff26b575629ad571 diff --git a/.agents/notes/proposed/feature/2026-09-08-desktop-uninstall-preserve-dsh-home.md b/.agents/notes/proposed/feature/2026-09-08-desktop-uninstall-preserve-dsh-home.md new file mode 100644 index 0000000000..e1c2435814 --- /dev/null +++ b/.agents/notes/proposed/feature/2026-09-08-desktop-uninstall-preserve-dsh-home.md @@ -0,0 +1,95 @@ +# Agent Note: Uninstall Desktop while preserving the Harness home + +Status: proposed + +English | [中文](2026-09-08-desktop-uninstall-preserve-dsh-home.zh.md) + +## Problem + +Users need Desktop uninstallation to remove its application files and external application state while retaining the complete Harness home. Removing an application directory does not account for browser storage, cached installers, or native updater state. Browser storage also contains unsent drafts and UI preferences, so removing it changes more than disk cache usage. + +The [packaging configuration](../../../../apps/desktop/electron-builder.config.mjs), [desktop entry](../../../../apps/desktop/src/main.ts), and [update coordinator](../../../../apps/desktop/src/update-coordinator.ts) are the inspection inputs. Source inspection identifies cleanup candidates; installed-artifact observation must establish the complete supported inventory before implementation is accepted. No uninstall experiment has yet established an exhaustive Windows or macOS inventory. + +## Proposal + +Provide an uninstall operation whose successful result leaves the complete default `~/.dsh` and any configured `DSH_HOME` intact, removes Desktop-owned files outside those homes, and removes the selected application installation. Preserve plugin files, package-manager state, sessions, settings, and credentials within the retained homes. Do not launch or repair the Desktop profile during cleanup. + +Windows Control Panel and Settings uninstallation will run this operation through NSIS. macOS will expose a localized **Uninstall and keep Harness data** action backed by a signed cleanup helper. Finder drag-to-Trash alone cannot invoke this operation; document that limitation and provide the same helper as a signed standalone uninstaller for users who already removed the application. A plain drag operation must not be advertised as complete cleanup. + +User-created projects, separately installed CLI software, user-saved downloads and exports, arbitrary third-party plugin/tool outputs, and OS-maintained audit/security records are outside application cleanup. Do not search the disk for names containing `dsh` or `DeepSeek`. The success promise concerns application-owned state, not deletion of user work or erasure of OS history. + +### 1. Establish ownership and actual paths + +Use disposable Windows and macOS accounts to capture filesystem and relevant registration differences across installation, first launch, draft editing, plugin installation, update download, update installation, and uninstallation. Inspect the packaged `package.json`, macOS `Info.plist`, `app-update.yml`, and runtime `app.getPath()` values. Include failed/interrupted updates. Record discovered owners and cleanup locations beside the desktop implementation, with fixture evidence rather than a dynamically accepted list of arbitrary absolute paths. + +| Candidate | Windows | macOS | Required treatment | +|---|---|---|---| +| Electron user/session data | Runtime `userData` and `sessionData`, normally under `%APPDATA%` | Runtime `userData` and `sessionData`, normally under `~/Library/Application Support` | Remove browser storage, preferences, cache, and `.updaterId`; disclose loss of unsent drafts. | +| Installer/update cache | `%LOCALAPPDATA%/` | `~/Library/Caches/` | Remove cached installers, ZIPs, blockmaps, pending downloads, and metadata; include the NSIS installer copy created during installation. | +| Native updater state | Any additional paths proved by the installed NSIS flow | App-specific Squirrel and `.ShipIt` cache/state/log directories | Stop the owned updater job and remove its remaining state after it exits. | +| Other native application state | App-owned files/registrations proved by observation | App-specific preferences, saved state, logs, or diagnostics proved by observation | Add only established ownership; an Electron path API alone does not prove a file is created. | +| Application and integration | Installation directory, installed shortcuts, installation/uninstallation registration | Selected `.app` and helper-owned integration | Use the native uninstall flow and validate the selected installation identity. | +| Harness home | Default `.dsh` and configured `DSH_HOME` | Default `.dsh` and configured `DSH_HOME` | Preserve the complete trees, including Desktop-only subdirectories. | + +Create one small application-owned cleanup inventory, consumed by packaging and cleanup implementations. Derive identity and updater cache names from the release metadata. The same inventory must explain test expectations. Verify any existing distributed release before adding a legacy path; do not introduce migrations for unpublished development layouts. + +### 2. Implement shared preservation and cleanup rules + +Resolve targets for the installation owner, not the elevated administrator's home. Preserve the default home even when an override is active, and protect configured homes recorded by Desktop as well as the current override. Validate any persisted record at this file boundary; it may protect a location but may never authorize deletion of an arbitrary location. + +Reject deletion of a protected home, its ancestor, or its contents. Account for case-insensitive paths, symlinks, Windows junctions/reparse points, and path replacement during cleanup. Do not follow a link into another tree. An installation or cache location containing a protected home is a conflict: stop before recursive removal and report the remaining path. Keep these checks in the actual native removal path, not only in a preview. + +Stop new windows, updates, and plugin mutations; finish or safely stop active operations; stop the owned Host/process tree; then wait for exit and released files before removal. Do not kill processes solely by executable name. Perform an ownership/path preflight before destructive steps. Treat missing targets as success, and support retry after partial cleanup. Report locked or inaccessible paths explicitly; a queued reboot deletion or partial result is not complete cleanup. + +Keep `.dsh` free of cleanup journals or receipts. Any temporary helper/state must live in a private temporary directory, avoid executable-search-path lookup, and have a bounded self-cleanup path. Verify that cleanup does not recreate Electron user data after it has been removed. Cancellation before removal leaves the installation usable. + +### 3. Windows implementation + +Keep the assisted NSIS installer (`oneClick: false`). Extend its custom uninstall hooks and removal ordering rather than replacing the complete installer. Do not depend on `deleteAppDataOnUninstall` to cover updater caches, custom paths, or protected homes. Apply preservation checks before the default installation-directory removal as well as before extra cleanup. + +Normal Control Panel, Settings, direct uninstaller, and silent uninstall must share the same cleanup rules and useful exit status. Automatic update/replacement must skip destructive user-state cleanup; validate the actual electron-updater flags and NSIS update condition with a real upgrade. A failed upgrade must retain browser data and `.dsh`. + +Per-user uninstall cleans that installation owner's Desktop state. All-user uninstall must identify each affected local user profile and clean its Desktop-owned state while preserving every Harness home, under the required Windows privileges. Do not resolve all users through one `$APPDATA` expansion. If an affected profile is inaccessible or another installation still owns a shared path, report that condition instead of deleting shared state or claiming complete cleanup. Include this distinction in the uninstall UI and silent result. + +Let NSIS remove the application, its installed shortcuts, and installation registration. Explicitly remove the application-owned cache/user-data inventory, including the cached copy of the installer. Preserve existing signing requirements for installer, uninstaller, and any added executable. Add a build-time check that custom hooks and the cleanup inventory are actually packaged. + +### 4. macOS implementation + +Add the localized uninstall action to the Electron-owned menu/recovery UI so it remains available when the Host cannot boot. Before proceeding, show that `.dsh` is retained and unsent drafts/UI preferences outside it are deleted. Complete cleanup must include removal of the selected application bundle; if only moving it to Trash, report that disk space remains until Trash is emptied. + +Use a signed helper that can finish after Electron and the Host exit. Verify bundle identity and the selected path before removing it; handle `/Applications`, `~/Applications`, and a relocated writable bundle. Obtain authorization only for paths requiring it. Coordinate with Squirrel/ShipIt, remove only this application's updater jobs/state, and delete the discovered external application directories and preferences. Include helper staging files in completion verification. + +Package the same cleanup implementation as a signed, notarized standalone uninstaller for the already-deleted-app case. It must not require the removed bundle or a working `.dsh` profile. A read-only mounted DMG is not an installed writable application; provide an actionable result rather than attempting to alter it. For a shared application installation, account for affected users and privileges as on Windows; report any state that could not be cleaned. + +### 5. Verification and documentation + +Add focused tests for target ownership, protected-home overlap, link/reparse-point traversal, identity mismatch, arbitrary-path rejection, custom/absent `DSH_HOME`, another installation sharing data, and inaccessible profiles. Exercise cancellation, active Host/update/plugin processes, interruption between removals, retries, and self-cleanup without using the developer's real home. Follow the [CI reliability workflow](../../../skills/dsh-ci-test-reliability/SKILL.md) when implementing process and filesystem tests. + +Run installed-artifact e2e checks on Windows and both shipped macOS architectures. Cover first installation without updates, one downloaded/installed update, failed update, per-user/shared installation, relocated application, and standalone macOS cleanup after drag deletion. Compare filesystem/registration inventories independently of the uninstaller's success response. Hash retained `.dsh` content after the app and CLI are quiescent and before removal; require identical content, paths, and link entries afterward. Do not require preserved runtime links to resolve after their application target is removed. + +Add owner-local expected output for uninstall UI and failure results. Add a keyless recorded-session scenario proving an existing conversation survives uninstall/reinstall; keep purely installer/UI expectations outside the top-level Session tree. Record the required real-server/model GUI GIF for the implementation PR. Update the [Desktop README](../../../../apps/desktop/README.md), its Chinese counterpart, user instructions, locale dictionaries, JSDoc, and this proposal's lifecycle when implementation ships. Select focused checks through [dsh-pre-push-checks](../../../skills/dsh-pre-push-checks/SKILL.md); signed packaging and real OS uninstall evidence are required, not replaceable by unit tests. + +## Alternatives considered + +**Keep the default uninstallers.** They do not establish removal of application-owned state outside the application directory and therefore do not meet the requested outcome. + +**Move all Electron and update data under `.dsh`.** This can simplify file placement but preserves caches and browser state instead of cleaning them, and does not by itself cover Squirrel/native state or existing external files. It is not the cleanup solution proposed here. + +**Detect Finder deletion with a permanent background watcher.** This adds a resident component that itself needs removal and cannot reliably cover deletion while it is stopped. Use an explicit uninstall operation. + +**Delete `.dsh/desktop` or `.dsh/profiles/desktop`.** These paths contain Desktop-specific state, but the requested retention rule preserves the whole Harness home rather than only conversations. + +## Acceptance criteria + +- A successful supported uninstall removes the selected application, its owned registrations, and every external application-owned path established by the installed-artifact inventory; no cleanup helper or updater process remains. +- Default and configured Harness homes retain identical content and links after quiescence. User projects and independent CLI installations remain intact. +- Windows Control Panel/Settings and silent uninstall satisfy the same retention rule. An actual automatic upgrade retains browser state and Harness data. +- macOS's explicit and standalone uninstallers satisfy the cleanup rule. Documentation accurately distinguishes them from Finder drag deletion and from merely moving files to Trash. +- Every unremoved owned path is reported with a retry/recovery action. Unavailable privileges, an inaccessible user profile, and shared ownership never produce a false full-success result. +- Fresh install and uninstall/reinstall tests prove no untracked outside-home application state survives and that retained conversations can be reopened. Native OS inventories, not only mocked API calls, establish completion. + +## Risks + +Deleting browser storage loses unsent drafts and UI preferences. Whole-home preservation deliberately retains Desktop plugins, pnpm state, and possibly dangling links until reinstall repairs them. Custom data paths and shared installations can prevent complete removal without additional privileges or path relocation; the operation must report that limitation rather than violate retention. + +OS-owned security/audit history and user-created files cannot be covered by a promise of erasing all traces. The [Desktop packaging decision](../../implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md) and [bundled runtime/external plugin decision](../../implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md) remain independent authorities for packaging and data ownership; this proposal supersedes neither and archives no active note. diff --git a/.agents/notes/proposed/feature/2026-09-08-desktop-uninstall-preserve-dsh-home.zh.md b/.agents/notes/proposed/feature/2026-09-08-desktop-uninstall-preserve-dsh-home.zh.md new file mode 100644 index 0000000000..27fd7c8cbe --- /dev/null +++ b/.agents/notes/proposed/feature/2026-09-08-desktop-uninstall-preserve-dsh-home.zh.md @@ -0,0 +1,95 @@ +# Agent Note: 卸载 Desktop 并保留 Harness 主目录 + +Status: proposed + +[English](2026-09-08-desktop-uninstall-preserve-dsh-home.md) | 中文 + +## 问题 + +用户需要 Desktop 卸载删除应用文件及外部应用状态,同时保留完整的 Harness 主目录。删除应用目录并不能覆盖浏览器存储、安装器缓存或原生更新器状态。浏览器存储还包含未发送草稿和界面偏好,因此清理它不只是释放磁盘缓存。 + +[打包配置](../../../../apps/desktop/electron-builder.config.mjs)、[桌面入口](../../../../apps/desktop/src/main.ts)和[更新协调器](../../../../apps/desktop/src/update-coordinator.ts)是代码检查依据。源码检查用于识别清理候选项;实现验收前,必须通过已安装产物的观察确定受支持的完整清单。目前尚未通过卸载实验建立完整的 Windows 或 macOS 清单。 + +## 提案 + +提供卸载操作:成功后保留完整的默认 `~/.dsh` 和已配置的 `DSH_HOME`,删除这些主目录之外由 Desktop 拥有的文件,并移除选定的应用安装。保留主目录内的插件文件、包管理器状态、会话、设置和凭据。清理期间不得启动或修复 Desktop profile。 + +Windows 控制面板和设置中的卸载将通过 NSIS 执行此操作。macOS 将提供本地化的**卸载并保留 Harness 数据**操作,由签名的清理辅助程序完成;单纯通过 Finder 拖进废纸篓不能调用此操作,文档必须说明这一限制,并将同一辅助程序作为签名的独立卸载器提供给已经删除应用的用户。不得把单纯拖拽宣传为完整清理。 + +用户创建的项目、单独安装的 CLI(命令行界面)软件、用户保存的下载文件和导出文件、任意第三方插件或工具的输出,以及操作系统维护的审计和安全记录,不属于应用清理范围。不得按文件名包含 `dsh` 或 `DeepSeek` 全盘搜索。成功承诺针对应用拥有的状态,不包括删除用户工作成果或抹除系统历史。 + +### 1. 确定归属和实际路径 + +使用一次性 Windows 和 macOS 账户,记录安装、首次启动、编辑草稿、安装插件、下载更新、安装更新及卸载前后的文件系统和相关注册信息差异。检查打包后的 `package.json`、macOS `Info.plist`、`app-update.yml` 和运行时 `app.getPath()` 值。覆盖更新失败和中断。将发现的归属与清理位置记录在桌面实现附近,提供 fixture(测试前置数据)证据,不采用动态接收任意绝对路径的清单。 + +| 候选项 | Windows | macOS | 处理要求 | +|---|---|---|---| +| Electron 用户及会话数据 | 运行时 `userData` 和 `sessionData`,通常位于 `%APPDATA%` 下 | 运行时 `userData` 和 `sessionData`,通常位于 `~/Library/Application Support` 下 | 删除浏览器存储、偏好、缓存和 `.updaterId`;说明未发送草稿会丢失。 | +| 安装器及更新缓存 | `%LOCALAPPDATA%/` | `~/Library/Caches/` | 删除安装器副本、ZIP、blockmap、待处理下载和元数据;包括 NSIS 安装期间创建的安装器缓存副本。 | +| 原生更新器状态 | 通过实际安装的 NSIS 流程证实的其他路径 | 应用专属的 Squirrel 和 `.ShipIt` 缓存、状态及日志目录 | 停止所属更新任务,等待退出后删除残留状态。 | +| 其他原生应用状态 | 观察证实由应用拥有的文件和注册信息 | 观察证实的应用专属偏好、窗口恢复状态、日志或诊断数据 | 只加入归属已确定的项目;存在 Electron 路径 API 不足以证明文件会产生。 | +| 应用及系统集成 | 安装目录、安装的快捷方式、安装和卸载注册信息 | 选定的 `.app` 及辅助程序拥有的系统集成 | 使用原生卸载流程,并验证选定安装的身份。 | +| Harness 主目录 | 默认 `.dsh` 和已配置的 `DSH_HOME` | 默认 `.dsh` 和已配置的 `DSH_HOME` | 保留完整目录树,包括 Desktop 专属子目录。 | + +建立一份精简的应用清理清单,由打包和清理实现共同使用。应用身份与更新缓存名称从发布元数据推导。同一清单必须对应测试期望。加入旧路径前先核查实际已分发版本,不为未发布的开发布局增加迁移。 + +### 2. 实现共用的保留与清理规则 + +按安装所属用户解析目标,不能使用提权管理员的主目录。即使覆盖路径已启用,也保留默认主目录;同时保护 Desktop 已记录的配置主目录和当前覆盖路径。持久化记录在文件边界进行校验;记录可以保护位置,但绝不能授权删除任意位置。 + +拒绝删除受保护主目录、其祖先目录或其内容。处理路径大小写不敏感、符号链接、Windows junction/reparse point,以及清理期间路径被替换的情况。不得沿链接进入其他目录树。如果安装或缓存目录包含受保护主目录,则视为冲突:递归删除前停止,并报告剩余路径。这些检查必须位于实际原生删除流程中,不能只放在预览阶段。 + +阻止新窗口、更新和插件变更;完成或安全停止正在进行的操作;停止所属 Host 和进程树;等待进程退出并释放文件后再删除。不得仅凭可执行文件名终止进程。破坏性步骤前先检查归属和路径。目标不存在视为成功,部分清理后允许重试。明确报告被占用或无法访问的路径;已安排重启后删除或部分完成,不等于完整清理。 + +不得在 `.dsh` 写入清理日志或回执。临时辅助程序和状态必须位于私有临时目录,避免通过可执行文件搜索路径寻找程序,并具有有界的自行清理流程。验证清理不会在删除后重新创建 Electron 用户数据。删除开始前取消操作应保持安装可用。 + +### 3. Windows 实现 + +保留向导式 NSIS 安装器(`oneClick: false`)。扩展自定义卸载钩子和删除顺序,不替换整个安装器。不得依赖 `deleteAppDataOnUninstall` 覆盖更新缓存、自定义路径或主目录保护。在默认安装目录删除和额外清理前都应用保留检查。 + +正常控制面板、设置、直接运行卸载器和静默卸载必须共用清理规则,并返回有用的退出状态。自动更新或替换安装必须跳过破坏性的用户状态清理;通过实际升级验证 electron-updater 参数和 NSIS 更新条件。升级失败必须保留浏览器数据和 `.dsh`。 + +按用户安装的卸载清理该安装所属用户的 Desktop 状态。全用户卸载必须识别每个受影响的本地用户配置目录,在具备所需 Windows 权限的情况下清理其中由 Desktop 拥有的状态,并保留每个 Harness 主目录。不能用一次 `$APPDATA` 展开代表所有用户。如果受影响的用户目录无法访问,或另一个安装仍拥有共享路径,应报告该情况,不得删除共享状态或宣称完整清理。卸载界面和静默执行结果都要体现这一区别。 + +由 NSIS 删除应用、安装的快捷方式和安装注册信息。显式删除清单中的应用缓存和用户数据,包括缓存的安装器副本。维持安装器、卸载器及新增可执行文件的签名要求。增加构建期检查,证明自定义钩子和清理清单确实进入产物。 + +### 4. macOS 实现 + +在 Electron 拥有的菜单和恢复界面加入本地化卸载操作,保证 Host 无法启动时仍可使用。执行前说明保留 `.dsh`,并删除主目录外的未发送草稿和界面偏好。完整清理必须包括移除选定应用包;如果只是移入废纸篓,应说明清空废纸篓前仍占用磁盘。 + +使用签名的辅助程序,在 Electron 和 Host 退出后完成清理。删除前验证应用包身份和选定路径;覆盖 `/Applications`、`~/Applications` 及移动后的可写应用包。仅对需要权限的路径请求授权。协调 Squirrel/ShipIt,只移除本应用的更新任务和状态,再删除已识别的外部应用目录及偏好。完成验证必须包括辅助程序的临时文件。 + +把同一清理实现打包为签名、公证的独立卸载器,支持应用已被删除的情况。它不能依赖已删除的应用包或可启动的 `.dsh` profile。只读挂载的 DMG 不属于已安装的可写应用;应给出可操作的结果,不能尝试修改它。共享应用安装和 Windows 一样需要处理受影响用户及权限,并报告未能清理的状态。 + +### 5. 验证和文档 + +增加针对性测试,覆盖目标归属、受保护主目录重叠、链接和 reparse point 遍历、身份不匹配、任意路径拒绝、自定义或未设置的 `DSH_HOME`、另一安装共享数据,以及无法访问的用户配置目录。覆盖取消、活跃 Host/更新/插件进程、删除步骤间中断、重试及自行清理,禁止使用开发者真实主目录。实现进程和文件系统测试时遵循 [CI 可靠性工作流](../../../skills/dsh-ci-test-reliability/SKILL.md)。 + +在 Windows 和两个已发布 macOS 架构上执行已安装产物的 e2e 检查。覆盖首次安装且未更新、下载并安装一次更新、更新失败、按用户或共享安装、移动应用,以及拖拽删除后的独立 macOS 清理。独立比较文件系统和注册信息清单,不以卸载器的成功响应代替验证。应用和 CLI 静止后、删除前对保留的 `.dsh` 内容计算哈希;要求删除后内容、路径和链接条目完全一致。应用目标被删除后,不要求保留的运行时链接仍可解析。 + +为卸载界面和失败结果增加归属模块旁的预期输出。增加无密钥的录制会话场景,证明已有会话能跨卸载和重装保留;纯安装器或界面预期不放入顶层 Session 树。为实现 PR(Pull Request)录制规则要求的真实服务器和模型 GUI GIF。实现交付时更新 [Desktop README](../../../../apps/desktop/README.zh.md)、英文对应文档、用户说明、语言字典、JSDoc 和本提案的生命周期。通过 [dsh-pre-push-checks](../../../skills/dsh-pre-push-checks/SKILL.md) 选择针对性检查;签名打包和真实操作系统卸载证据为必需项,不能由单元测试替代。 + +## 考虑过的替代方案 + +**维持默认卸载器。** 它们不能证明应用目录之外的应用状态已被移除,因此不满足要求。 + +**把全部 Electron 和更新数据移到 `.dsh`。** 这能简化文件位置,但会保留缓存和浏览器状态而不是清理它们,而且自身不能覆盖 Squirrel/原生状态或已有的外部文件。本提案不采用它作为清理方案。 + +**通过常驻后台监视器检测 Finder 删除。** 这会增加一个本身也需要卸载的常驻组件,并且不能可靠覆盖监视器停止期间发生的删除。采用显式卸载操作。 + +**删除 `.dsh/desktop` 或 `.dsh/profiles/desktop`。** 这些路径包含 Desktop 专属状态,但要求保留的是整个 Harness 主目录,而不只是会话。 + +## 验收标准 + +- 受支持的卸载成功后,选定应用、它拥有的注册信息,以及已安装产物清单确定的每个外部应用路径均已移除;没有清理辅助程序或更新器进程残留。 +- 静止后,默认和已配置 Harness 主目录的内容及链接完全保留。用户项目和独立 CLI 安装保持完整。 +- Windows 控制面板、设置及静默卸载满足同一保留规则。实际自动升级保留浏览器状态和 Harness 数据。 +- macOS 显式操作和独立卸载器满足清理规则。文档准确区分它们与 Finder 拖拽删除,以及仅移入废纸篓的情况。 +- 每个未移除的所属路径都报告重试或恢复方式。权限不足、用户配置目录不可访问和共享归属均不得产生虚假的完整成功结果。 +- 首次安装及卸载重装测试证明,没有未纳入清单的主目录外应用状态残留,且能重新打开保留的会话。通过原生操作系统清单验证完成,不能只依赖模拟 API 调用。 + +## 风险 + +删除浏览器存储会丢失未发送草稿和界面偏好。完整保留主目录意味着有意保留 Desktop 插件、pnpm 状态,以及可能在重装修复前悬空的链接。自定义数据路径和共享安装可能需要额外权限或移动路径才能完整删除;操作必须报告限制,不能违反保留规则。 + +操作系统拥有的安全和审计历史,以及用户创建的文件,不能被“抹除所有痕迹”的承诺覆盖。[Desktop 打包决策](../../implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md)和[内置运行时与外部插件决策](../../implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)继续分别拥有打包和数据归属规则;本提案不取代这两份记录,也不归档任何活跃记录。 diff --git a/apps/desktop-host/src/index.ts b/apps/desktop-host/src/index.ts index e85badba04..3abebfc3c6 100644 --- a/apps/desktop-host/src/index.ts +++ b/apps/desktop-host/src/index.ts @@ -149,12 +149,12 @@ function isProjectPath(projectDir: string, target: string): boolean { return path === root || path.startsWith(root + sep) } -function desktopPatches(projectDir: string, allowLinkedPackages: boolean): PatchOptions[] { - const dshRoot = dirname(packageManifestPath(projectDir, '@deepseek-ai/dsh')) +function desktopPatches(runtimeDir: string, projectDir: string, allowLinkedPackages: boolean): PatchOptions[] { + const dshRoot = dirname(packageManifestPath(runtimeDir, '@deepseek-ai/dsh')) const profile = loadProfileDirectory('dsh desktop', projectDir, join(dshRoot, 'package.json')) for (const layer of profile.layers) { - if (!allowLinkedPackages && !isProjectPath(projectDir, layer.packageDir)) { - throw new Error(`dsh desktop: profile bundle ${JSON.stringify(layer.packageName)} resolved outside the desktop profile`) + if (!allowLinkedPackages && !isProjectPath(projectDir, layer.packageDir) && !isProjectPath(runtimeDir, layer.packageDir)) { + throw new Error(`dsh desktop: profile bundle ${JSON.stringify(layer.packageName)} resolved outside the Desktop runtime and profile`) } } const layers = [ @@ -176,14 +176,14 @@ function desktopPatches(projectDir: string, allowLinkedPackages: boolean): Patch return layers.flat() } -function dshVersion(projectDir: string): string { - const manifest = readManifest(packageManifestPath(projectDir, '@deepseek-ai/dsh')) +function dshVersion(runtimeDir: string): string { + const manifest = readManifest(packageManifestPath(runtimeDir, '@deepseek-ai/dsh')) if (typeof manifest.version !== 'string') throw new Error('dsh desktop: installed dsh manifest has no version') return manifest.version } -function assetHandler(ctx: Context, projectDir: string): ConnectionFetchHandler { - const require = createRequire(join(projectDir, 'package.json')) +function assetHandler(ctx: Context, runtimeDir: string): ConnectionFetchHandler { + const require = createRequire(join(runtimeDir, 'package.json')) const distIndex = require.resolve('@deepseek-ai/dsh-web-frontend/dist/index.html') const distRoot = realpathSync(dirname(distIndex)) const renderIndex = async (): Promise => { @@ -270,12 +270,14 @@ interface NodeRequestInit extends RequestInit { /** * Boot one installed desktop npm project. + * @param runtimeDir - immutable dsh packages supplied by the Electron application. * @param projectDir - active or staged Electron-owned desktop profile. * @param writeResponse - serialized response-pipe writer that applies byte backpressure. * @param options - development-only allowance for workspace-linked bundle packages. * @returns controller after every Host and client-manifest row is active. */ export async function runDesktopHost( + runtimeDir: string, projectDir: string, writeResponse: (frame: Buffer) => Promise, options: { allowLinkedPackages?: boolean } = {}, @@ -287,6 +289,7 @@ export async function runDesktopHost( const environment = loadLayeredEnv('dsh desktop') let current: Context | undefined const ctx = await boot('dsh desktop', rootConfig, structuredClone(desktopPatches( + resolve(runtimeDir), absoluteProject, options.allowLinkedPackages === true, )), (hostCtx) => { @@ -303,7 +306,7 @@ export async function runDesktopHost( throw new Error('dsh desktop: composition did not provide connection, typertGateway, and clientModules') } const api = connection.createSharedFetchHandler('/api') - const assets = assetHandler(ctx, absoluteProject) + const assets = assetHandler(ctx, resolve(runtimeDir)) const streams = remoteStreamHandler(ctx) const requests = new Map() let disposing: Promise | undefined @@ -319,7 +322,7 @@ export async function runDesktopHost( } return { - dshVersion: dshVersion(absoluteProject), + dshVersion: dshVersion(resolve(runtimeDir)), cancel(streamId) { requests.get(streamId)?.abort() }, @@ -374,11 +377,12 @@ export async function runDesktopHost( } async function main(): Promise { - const projectDir = process.argv[2] - if (projectDir === undefined || process.send === undefined) { - throw new Error('dsh desktop: expected project directory, byte pipes, and a Node IPC channel') + const runtimeDir = process.argv[2] + const projectDir = process.argv[3] + if (runtimeDir === undefined || projectDir === undefined || process.send === undefined) { + throw new Error('dsh desktop: expected runtime and profile directories, byte pipes, and a Node IPC channel') } - const option = process.argv[3] + const option = process.argv[4] if (option !== undefined && option !== '--allow-linked-profile') { throw new Error(`dsh desktop: unsupported internal option ${JSON.stringify(option)}`) } @@ -403,7 +407,7 @@ async function main(): Promise { if ((error as NodeJS.ErrnoException).code !== 'ERR_IPC_CHANNEL_CLOSED') throw error } } - const controller = await runDesktopHost(projectDir, writeResponse, { allowLinkedPackages: option !== undefined }) + const controller = await runDesktopHost(runtimeDir, projectDir, writeResponse, { allowLinkedPackages: option !== undefined }) send({ type: 'ready', protocolVersion: DESKTOP_HOST_PROTOCOL_VERSION, diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index de168b99dd..123b44f866 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/desktop/README.md -README.md: cf350a9d5e9577f5cf52c4371b32f16e278133f9 -README.zh.md: df48eb5903e21cae9ff041626c28d41d12e5c632 +README.md: b2a51010dc37bcca7c7846819b72ababdc227496 +README.zh.md: 241a1e1d900fef49042a7526d29fc473ef568088 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index cf350a9d5e..b2a51010dc 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -10,46 +10,36 @@ The desktop application is an Electron shell around the dsh Web UI. It opens no |---|---|---| | Release identity | The shell API, Web client, backend, and plugin graph are qualified as one combination; independent versions would create untested combinations and ambiguous update availability. | Electron and `@deepseek-ai/dsh` always have the same exact version. A dsh upgrade is a Desktop release, even when the shell code is unchanged. | | Runtime | Electron's Node.js carries Electron patches, fuses, ABI, and lifecycle constraints, while system runtimes and package-manager state are uncontrolled. | dsh runs under the bundled upstream Node.js and every package operation uses the bundled pnpm. Electron's Node.js, system Node.js, system pnpm, and user package-manager configuration are outside the execution path. | -| Package sources | The exact dsh source build must be packageable before npm publication and install offline; plugins must remain ordinary user-selected npm packages. | The signed application carries locally packed first-party dsh packages and an offline seed store. Desktop plugins remain ordinary npm dependencies resolved from the fixed Desktop registry. | -| Seed transport | Apple notarization inspects code inside archives; shipping every pnpm store file separately would also make the application signature inventory tens of thousands of cache entries, while one compressed archive would amplify small package changes. | macOS packaging signs every Mach-O CAS object, rewrites its pnpm hashes, and proves another offline install before assigning store files to 16 deterministic uncompressed tar shards. The outer installer compresses them, and differential updates can reuse unchanged shards. | +| Package sources | Core installation at startup adds work even when offline. | `extraResources/dsh` carries a complete production dependency tree; the profile installs only external plugins. | +| Shared modules | Host APIs can depend on module identity. | Desktop links every bundled first-party package into the profile using directory symlinks, or Windows junctions; ordinary plugin dependencies remain local. | | State ownership | Sharing executable dependency graphs would let CLI and Desktop change each other's dsh, Cordis, plugin, or native-module versions, while two desktop processes could race on the same profile. | Electron acquires its process-lifetime single-instance lock before any profile access and exclusively owns `$DSH_HOME/profiles/desktop` plus its package-manager state. CLI and Desktop share supported product data under `$DSH_HOME`, but never executable packages, plugin activation, lockfiles, or `node_modules`. | | Transport | A listening Web service adds port ownership, authentication, CORS, and exposure concerns; Electron and upstream Node.js also need an explicit cross-process protocol. | The application opens no Web port. `dsh-app://` carries Web assets and Fetch traffic; framed byte pipes carry bounded request and response chunks with backpressure, while Node IPC carries only child lifecycle control. | | Activation | Dependency resolution, lifecycle scripts, native modules, and plugin startup can fail, and a process can stop during directory replacement. | Release and plugin changes install in staging, boot a complete backend health check, and replace the active profile only after success; a journal and one rollback profile cover interrupted replacement. | -| Updates | Independent shell and dsh updates would recreate version splits, while unchanged shell blocks should not require a complete transfer. | The Electron shell, matching dsh seed, Node.js, and pnpm form one signed update unit. Platform update artifacts may reuse unchanged blocks, but runtime version selection never splits from the Desktop release. | +| Updates | Independent shell and dsh updates would recreate version splits, while unchanged shell blocks should not require a complete transfer. | The Electron shell, matching dsh runtime, Node.js, and pnpm form one signed update unit. Platform update artifacts may reuse unchanged blocks, but runtime version selection never splits from the Desktop release. | The [Electron packaging and update Agent Note](../../.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md) owns the rationale, alternatives, security constraints, and release qualification requirements behind these decisions. ## Installation ownership -Electron owns the reserved profile at `$DSH_HOME/profiles/desktop`. Its manifest lists the built-in and installed plugin bundles in `dsh.profile.bundles`, while its `node_modules` contains the exact `@deepseek-ai/dsh` release, its matching private `@deepseek-ai/dsh-desktop-host`, and every desktop plugin. Keeping the Electron-only process entry and overlay in a private app package prevents Desktop implementation from becoming part of the public CLI package. The CLI cannot boot or mutate this profile. Electron always invokes its bundled Node.js and pnpm with the store at `$DSH_HOME/desktop/pnpm/store`; it never uses system pnpm or the caller's npm/pnpm configuration. +Electron owns `$DSH_HOME/profiles/desktop`. Its `dependencies` contains only installed external plugins at exact versions; `dsh.profile.bundles` contains the built-in bundles followed by enabled plugins. The signed application supplies dsh, the private Desktop Host, and their production packages from `resources/dsh`. Shared package links resolve to those actual directories. Both host and plugins execute in the same bundled upstream Node process, with normal realpath resolution; Desktop does not enable `--preserve-symlinks`. The CLI cannot boot or mutate this profile. The main dsh renderer receives only the desktop protocol marker. The separate plugin window receives structured list, install, remove, update, and update-check operations; neither renderer receives filesystem access, raw Electron IPC, a shell, or arbitrary pnpm arguments. Electron chooses typed English or Chinese shell copy from its application locale and falls back to English. Menus, native dialogs, and the plugin-management renderer use the same locale payload; the repository Client UI i18n gate checks these desktop sources. -### Seed installation +### Runtime and plugin activation -The packaged seed is an installation kit, not a ready-to-run `node_modules` tree. Packaging creates the lockfile, materializes the production graph online with lifecycle scripts disabled, deletes `node_modules` and every temporary pnpm cache, config, and state directory, and proves one complete installation offline from the final store alone with the private Desktop Host entry and overlay present. A macOS build stages every Mach-O object from pnpm's content-addressed store, Developer ID signs at most four independent copies concurrently, and updates the affected SHA-512 index records only after all signers succeed. Another offline install proves the rewritten store before sharding; preparation then extracts the final archives and verifies every embedded signature. The signed seed retains the release identity, local first-party tarballs and their descriptor, project metadata, lockfile, integrity inventory, and pnpm store content required to repeat that installation on the user's machine. +The signed `resources/dsh/desktop-runtime.json` binds the shell version, bundled Node version, platform, architecture, shared package versions, and final file inventory. Startup verifies the tree before creating host links. Core packages are never copied into profile storage or installed by pnpm at first launch. -| Seed content | Writable destination or use | -|---|---| -| `integrity.json` and `desktop-packages.json` | Verify every inventoried seed file, local tarball hash, and the bound dsh and Desktop Host versions before package state changes. | -| `store-archives.json` and `store-archives/*.tar` | Validate the deterministic uncompressed shards, extract them into a unique Desktop staging directory, replace matching immutable store files, and transactionally merge pnpm's versioned SQLite package index into `$DSH_HOME/desktop/pnpm/store` without removing packages already downloaded for Desktop plugins. | -| Project metadata and `desktop-packages/` | Copy into a unique `$DSH_HOME/desktop/staging//profile` project. | -| Lockfile and local package mappings | Drive the bundled pnpm installation without resolving a packaged core name from npm. | +1. A fresh profile creates its manifest and shared package links, then boots and stops the complete backend before activation. +2. A compatible application upgrade copies plugin files and configuration into staging, refreshes shared links, checks enabled plugins’ peer requirements, and runs the same backend health check. Plugin versions and lockfile remain unchanged; pnpm does not run. +3. A changed bundled Node version, platform, or architecture reinstalls the locked plugin graph with scripts disabled, validates and links host packages, then runs approved pending builds and validates again. +4. Plugin add, update, and remove operations use bundled pnpm and Desktop-owned package-manager state. Reserved host packages must be peers; nested copies and aliases of shared packages fail validation. Ordinary plugin dependencies must resolve inside the profile. +5. Activation stops the active backend, records runtime identities and the next directory move, and replaces the profile while retaining one rollback copy. Copies do not share writable hardlinks. A failed activation restores the previous profile; a profile belonging to another runtime cannot boot under the current shell. -Startup installs or reconciles the seed as one serialized transaction: +The Electron-owned plugin window remains available when backend startup fails. Disable one plugin or all plugins to retry with their files, exact versions, and configuration retained. Disabling removes only activation entries. Updating or removing an installed plugin remains available while it is disabled. Application rollback and profile rollback are separate; runtime compatibility is checked before any backend starts. -1. Recover an interrupted activation journal, verify the complete seed inventory and local package set, and require the seed version to equal Electron's application version. -2. If the active profile already contains that release plus the matching dsh and Desktop Host versions, verify its local package set and reuse it without reinstalling. -3. Otherwise validate every archive entry, extract all store shards into a temporary Desktop-owned staging directory, merge the package files and SQLite package-index records into the private store, create a staging profile, and run `pnpm install --offline --frozen-lockfile --trust-lockfile` through the bundled Node.js and pnpm. Seed records replace matching index keys while plugin-only records remain available. -4. During an Electron upgrade, read every plugin name and exact version from the old active profile and add those versions to staging with `--offline` from existing Desktop pnpm state. A first installation has no plugin-restore step. -5. Stop the active backend, boot and stop the complete staged backend as a health check, then restart the active backend before activation. This serialization prevents two desktop backends from sharing `$DSH_HOME`; installation or plugin incompatibility before activation deletes staging and leaves the active profile unchanged. -6. Persist each next activation phase before its directory move, move the active profile to `$DSH_HOME/desktop/rollback/profile`, and move staging into `$DSH_HOME/profiles/desktop`. Recovery combines the journal with the actual profile, rollback, and staging directories, so interruption in either write-to-move gap restores or retains a complete profile. - -GUI plugin mutations use the same staging, health-check, activation, and rollback path after installing registry packages into the shared Desktop pnpm store. - -The process-lifetime Electron lock is the primary desktop owner. The transaction lock is depth defense: it records Electron while preparing local state, records the spawned pnpm worker while that worker can still write, and returns ownership to Electron after the worker exits. A later process cannot treat a live orphaned worker as a stale transaction. +Package transactions hold an exclusive lock through pnpm process exit. Shared links use directory symlinks on macOS/Linux and junctions on Windows; hardlinks are unsuitable because they cannot represent package directories and would retain old file content after application replacement. Native builds follow the profile’s reviewed `allowBuilds` list; installing a new build-requiring package without approval in that list fails the transaction. ## Develop @@ -67,7 +57,7 @@ After an explicit build, `start:desktop` reconstructs the disposable project and pnpm run start:desktop ``` -Workspace development runs the current CLI and private Desktop Host packages under the invoking Node.js and disables desktop package mutations. Its explicitly linked disposable profile is the only mode allowed to resolve bundles outside its own directory. Use an unpacked application to exercise the bundled Node.js, bundled pnpm, release seed, plugin installation, staging, and rollback paths. +Workspace development runs the current CLI and private Desktop Host packages under the invoking Node.js and disables desktop package mutations. Its explicitly linked disposable profile is the only mode allowed to resolve bundles outside its own directory. Use an unpacked application to exercise the bundled Node.js, bundled pnpm, bundled dsh resources, plugin installation, staging, and rollback paths. ## Package @@ -88,7 +78,7 @@ export APPLE_API_ISSUER='' pnpm run package:desktop ``` -Release automation uses fixed target commands so runtime preparation, seed installation, and electron-builder receive the same platform and architecture: +Release automation uses fixed target commands so runtime preparation, dsh preparation, and electron-builder receive the same platform and architecture: ```sh pnpm run package:desktop:mac:arm64 @@ -98,7 +88,7 @@ pnpm run package:desktop:win:x64 The macOS arm64 command requires Apple Silicon. The macOS x64 command runs on Intel macOS or Apple Silicon with Rosetta. The Windows x64 command requires Windows x64. Linux is not a supported Desktop release target. -Each target owns its packed package inputs, prepared runtime, package set, seed, pnpm preparation state, unpacked application, update metadata, and final artifacts under `apps/desktop/.desktop-build/targets//`. The Node.js archive cache remains shared under `.desktop-build/downloads` because every archive name includes its version, platform, and architecture and is verified before extraction. A target build never consumes another target's mutable preparation state. +Each target owns its packed package inputs, prepared runtime, package set, dsh tree, pnpm preparation state, unpacked application, update metadata, and final artifacts under `apps/desktop/.desktop-build/targets//`. The Node.js archive cache remains shared under `.desktop-build/downloads` because every archive name includes its version, platform, and architecture and is verified before extraction. A target build never consumes another target's mutable preparation state. ### Upload updates @@ -125,7 +115,7 @@ pnpm run upload:mac:arm64 Set `DSH_DESKTOP_AUTO_UPDATE_ENV=production` before packaging, then provide `DOWNLOAD_PROD_COS_BUCKET` and the production credential pair before running `upload:mac:arm64`, `upload:mac:x64`, or `upload:win:x64`. Packaging does not require a COS bucket or credentials. It explicitly disables electron-builder publishing, strips all four COS credential fields from its subprocesses, and writes a target completion record only after electron-builder and every signing or notarization hook succeeds. Upload requires that record to match the selected environment, target, public URL, and current dsh version; it also requires the root dsh version, Desktop version, channel metadata version, artifact names, sizes, and SHA-512 values to agree before it reads the selected COS credential pair. It uploads only that target's immutable versioned artifacts, uploads the version-derived channel metadata last with `no-cache`, and never deletes historical objects. Stable releases use `latest-mac.yml` or `latest.yml`; a prerelease such as `alpha` uses `alpha-mac.yml` or `alpha.yml`, matching electron-builder's emitted filename. -The macOS configuration uses the required release environment instead of accepting whichever certificate appears first in a keychain. It rejects empty values, a malformed Team ID, a signing identity that includes electron-builder's unsupported `Developer ID Application:` prefix, and incomplete notarization credentials. macOS packaging requires the configured identity and its private key. Seed preparation applies that identity, a secure timestamp, and hardened runtime to every embedded Mach-O file; after signing the application, a deep strict check rejects any other leaf authority or Team ID before artifact creation. Electron-builder notarizes and staples the application before packaging and signs the DMG. The DMG artifact-completion hook then notarizes and staples it before requiring its exact identity, ticket, and Gatekeeper acceptance; only after the hook succeeds can electron-builder publish the file. The private key can come from the login keychain or electron-builder's standard `CSC_LINK` input; ambient `CSC_NAME` and certificate discovery order do not select the release owner. Notary credentials may instead use electron-builder's complete Apple ID or keychain-profile strategy. The two macOS identity variables are also required when repeating the application check manually with `pnpm --dir apps/desktop run verify:mac-signature -- `. +The macOS configuration uses the required release environment instead of accepting whichever certificate appears first in a keychain. It rejects empty values, a malformed Team ID, a signing identity that includes electron-builder's unsupported `Developer ID Application:` prefix, and incomplete notarization credentials. macOS packaging requires the configured identity and its private key. Runtime preparation applies that identity, a secure timestamp, and hardened runtime to every embedded Mach-O file; after signing the application, a deep strict check rejects any other leaf authority or Team ID before artifact creation. Electron-builder notarizes and staples the application before packaging and signs the DMG. The DMG artifact-completion hook then notarizes and staples it before requiring its exact identity, ticket, and Gatekeeper acceptance; only after the hook succeeds can electron-builder publish the file. The private key can come from the login keychain or electron-builder's standard `CSC_LINK` input; ambient `CSC_NAME` and certificate discovery order do not select the release owner. Notary credentials may instead use electron-builder's complete Apple ID or keychain-profile strategy. The two macOS identity variables are also required when repeating the application check manually with `pnpm --dir apps/desktop run verify:mac-signature -- `. ### Windows EV signing @@ -141,7 +131,7 @@ pnpm run package:desktop:win:x64 Insert and unlock the token before packaging. The electron-builder hook passes each artifact to the CRLF `scripts/windows-sign.cmd`, which invokes the configured SignTool once with `/f`, SafeNet `/kc "[{{PIN}}]=container"`, `/csp "eToken Base Cryptographic Provider"`, a SHA-256 file digest, and a DigiCert SHA-256 RFC 3161 timestamp. The hook never substitutes electron-builder's bundled SignTool and never retries a failed signing request. Windows packaging fails instead of emitting unsigned artifacts when the SignTool, certificate, container, PIN, token, or signature is unavailable. -The PIN cannot contain `]`, a quote, or a line break because those characters delimit the SafeNet `/kc` value or its CMD argument. The CMD disables delayed expansion so a PIN containing `!` reaches SafeNet unchanged. Packaging withholds every `DSH_DESKTOP_WINDOWS_*` field from build and seed-preparation subprocesses, gives electron-builder only the four configured inputs, gives the signing CMD only the validated signing fields in an otherwise scrubbed environment, clears those fields before SignTool starts, and redacts SignTool diagnostics. SafeNet still requires the PIN in the SignTool process command line. Inject it as an ephemeral secret only on a controlled self-hosted Windows runner with the physical token attached; never commit it, put it in `.env`, or persist it as a Windows user or system environment variable. +The PIN cannot contain `]`, a quote, or a line break because those characters delimit the SafeNet `/kc` value or its CMD argument. The CMD disables delayed expansion so a PIN containing `!` reaches SafeNet unchanged. Packaging withholds every `DSH_DESKTOP_WINDOWS_*` field from build and runtime-preparation subprocesses, gives electron-builder only the four configured inputs, gives the signing CMD only the validated signing fields in an otherwise scrubbed environment, clears those fields before SignTool starts, and redacts SignTool diagnostics. SafeNet still requires the PIN in the SignTool process command line. Inject it as an ephemeral secret only on a controlled self-hosted Windows runner with the physical token attached; never commit it, put it in `.env`, or persist it as a Windows user or system environment variable. Create a runnable application directory instead of an installer by using the matching `:dir` command, such as: @@ -156,21 +146,21 @@ To inspect or troubleshoot the prepared host-target resources without invoking e pnpm run prepare:desktop ``` -This diagnostic command is an alternative stopping point, not the first half of a two-command build. A later `package:desktop*` command repeats the official build and preparation so it cannot consume stale dsh packages, runtime files, or seed content. +This diagnostic command is an alternative stopping point, not the first half of a two-command build. A later `package:desktop*` command repeats the official build and preparation so it cannot consume stale dsh packages, runtime files, or dsh content. -Every package command performs the official repository build, packs the dsh and vendored package families, locally packs the private Desktop Host package, and packs the Landlock entry before preparing release resources. `prepare:packages` selects the union of the first-party production closures rooted at `@deepseek-ai/dsh` and `@deepseek-ai/dsh-desktop-host`, verifies that the private Host tarball contains `lib/index.js` and `config/desktop.cordis.patch.yml`, copies the selected tarballs into the seed input, and records their sizes and SHA-512 integrity. The Host package is never published to npm; its `files` manifest contains only that runtime entry and overlay. Public package tarballs remain the official `pnpm pack` outputs governed by each package's publication manifest, so Desktop adds no second filter, retains published declarations such as `lib/types`, and neither strips nor adds source maps independently. Registry packages likewise retain their published package bytes in pnpm's content-addressed store. The dsh release bump updates both private Desktop manifests together with the root and publishable workspaces; packaging also requires the root dsh package, Desktop Host package, and Electron package to have the same version. Neither dsh nor the private Host needs to be published to npm before the Desktop application is built. `prepare:runtime` downloads Node.js 24.17.0 from the official Node.js release service, verifies its SHA-256 entry before extraction, and executes the prepared target binary on a compatible build host to verify its reported version. It copies the pnpm version declared by the desktop package and records both runtime versions in the release seed. `prepare:seed` runs that target Node.js and bundled pnpm, so platform- and CPU-filtered optional dependencies make the pnpm store and seed target-specific. It generates local core-package mappings, disables the global virtual store, materializes external production dependencies from npm without lifecycle scripts, deletes `node_modules` and all temporary pnpm cache, config, and state, proves the complete graph installs offline with the private Host entry and overlay, performs the macOS rewrite when applicable, proves the rewritten store with another offline installation, removes temporary pnpm project registrations, and replaces the loose store with 16 deterministic uncompressed tar shards. It extracts those final shards and verifies every embedded macOS signature before inventory generation. Later GUI plugin operations retain the local core mappings while resolving plugin packages and their external dependencies from the fixed Desktop npm registry. `electron-builder` emits each target's platform artifacts under `apps/desktop/.desktop-build/targets//artifacts`; a later version keeps differently named immutable installers and blockmaps while replacing that target's unpacked application, diagnostics, completion record, and channel metadata. +Every package command builds the repository, packs the first-party production closures rooted at dsh and the private Desktop Host, and prepares target-specific Node and pnpm executables. `prepare:dsh` installs the production graph once at build time, copies materialized packages into `extraResources/dsh`, removes package-manager metadata, and writes `desktop-runtime.json` with shared package versions and final file hashes. On macOS it signs and verifies native files before inventory generation; electron-builder excludes this already-signed tree from nested re-signing. Resource mappings explicitly include `dsh/node_modules`, which the default root-directory filter omits; the copied inventory is checked before signing and again after signing. Signed installer, notarization, installed upgrade, and target-specific native-module qualification require the release environment. -An unpacked artifact contains four independent size contributors: Electron, the offline seed store shards and local dsh tarballs, the upstream Node.js and pnpm runtime, and the small shell application. The shards are uncompressed so the outer DMG, ZIP, or NSIS compressor and differential updater can operate on stable ranges. Filesystem size is not installer download size, so measure both separately. First packaged startup also extracts the seed store into `$DSH_HOME/desktop/pnpm/store` before installing the writable profile, so release qualification must measure both application and Harness-home disk use. +An unpacked artifact contains Electron, the materialized dsh production tree, upstream Node.js and pnpm, and the shell application. Installer size and filesystem size differ; release qualification measures both, plus the profile’s plugin storage and first-launch latency. The runtime trades more application files for eliminating core package installation on the user’s machine. ## Updates -A packaged application checks its target-specific release stream ten seconds after the main window opens; the localized **Check for Updates…** menu item triggers the same check manually. An available release opens one native confirmation dialog. Accepting it waits for an in-flight check, downloads and verifies the signed Desktop release, stops the dsh child, and hands installation plus restart to electron-updater. The next launch reconciles the version-bound seed before reopening the product window. +A packaged application checks its target-specific release stream ten seconds after the main window opens; the localized **Check for Updates…** menu item triggers the same check manually. An available release opens one native confirmation dialog. Accepting it waits for an in-flight check, downloads and verifies the signed Desktop release, stops the dsh child, and hands installation plus restart to electron-updater. The next launch reconciles the version-bound runtime before reopening the product window. -Electron-builder always emits generic-provider channel metadata for the deployment selected by `DSH_DESKTOP_AUTO_UPDATE_ENV`. NSIS differential packages and the macOS ZIP target allow electron-updater to reuse unchanged blocks; the manually installed DMG is notarized without a blockmap because it is not a macOS updater payload. The seed and shell still form one signed Desktop release. macOS signing and notarization credentials use electron-builder's standard environment; Windows EV signing uses the public certificate, validated SignTool, SafeNet container, and runner PIN described above. The required Desktop release environment selects the application and platform signature identities that the build verifies. +Electron-builder always emits generic-provider channel metadata for the deployment selected by `DSH_DESKTOP_AUTO_UPDATE_ENV`. NSIS differential packages and the macOS ZIP target allow electron-updater to reuse unchanged blocks; the manually installed DMG is notarized without a blockmap because it is not a macOS updater payload. The runtime and shell still form one signed Desktop release. macOS signing and notarization credentials use electron-builder's standard environment; Windows EV signing uses the public certificate, validated SignTool, SafeNet container, and runner PIN described above. The required Desktop release environment selects the application and platform signature identities that the build verifies. ## Low-level development overrides -`DSH_DESKTOP_NODE_BINARY`, `DSH_DESKTOP_PNPM_ENTRY`, `DSH_DESKTOP_SEED_DIR`, and `DSH_DESKTOP_DEV_PROJECT_DIR` select explicit resources for an unpackaged Electron process. Packaged applications ignore these variables and resolve signed resources from `process.resourcesPath`. +`DSH_DESKTOP_NODE_BINARY`, `DSH_DESKTOP_PNPM_ENTRY`, `DSH_DESKTOP_DSH_DIR`, and `DSH_DESKTOP_DEV_PROJECT_DIR` select explicit resources for an unpackaged Electron process. Packaged applications ignore these variables and resolve signed resources from `process.resourcesPath`. ## Known limitations diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index df48eb5903..241a1e1d90 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -10,46 +10,36 @@ |---|---|---| | 发布身份 | 桌面壳 API、Web 客户端、后端与插件依赖图作为一个组合完成验证;独立版本会产生未经验证的组合,并让更新可用性含糊不清。 | Electron 与 `@deepseek-ai/dsh` 始终使用同一精确版本。即使桌面壳代码不变,升级 dsh 也必须发布新 Desktop 版本。 | | 运行时 | Electron 的 Node.js 带有 Electron 补丁、fuse、ABI 与生命周期约束,而系统运行时和用户包管理器状态不可控。 | dsh 通过内置的上游 Node.js 运行,所有包操作都使用内置 pnpm。Electron 的 Node.js、系统 Node.js、系统 pnpm 与用户的包管理器配置都不进入执行路径。 | -| 包来源 | 必须能在发布到 npm 之前从同一次源码构建打包精确的 dsh,并支持离线安装;插件则需要保留为用户选择的普通 npm 包。 | 已签名应用携带本地打包的第一方 dsh 包与离线 seed store。桌面插件仍是从固定 Desktop registry 解析的普通 npm 依赖。 | -| Seed 传输 | Apple 公证会检查归档内的代码;把 pnpm store 的每个文件分别放入应用,还会让应用签名记录数万个缓存条目,而单个压缩归档会放大小幅包变更。 | macOS 打包先签署每个 Mach-O CAS 对象、重写其 pnpm 哈希并再次证明离线安装,再把 store 文件分配到 16 个确定性的未压缩 tar 分片。外层安装包负责压缩,差分更新可以复用未变化的分片。 | +| 包来源 | 即使离线,启动时安装核心依赖也会增加开销。 | `extraResources/dsh` 携带完整生产依赖树;profile 只安装外部插件。 | +| 共享模块 | 宿主 API 可能依赖模块实例身份。 | Desktop 用目录软链接或 Windows junction 把每个内置第一方包连接到 profile;普通插件依赖保留在本地。 | | 状态归属 | 共享可执行依赖图会让 CLI 与 Desktop 相互改变 dsh、Cordis、插件或原生模块版本,而两个桌面进程还可能争用同一个 profile。 | Electron 在访问任何 profile 前获取进程生命周期单实例锁,并独占 `$DSH_HOME/profiles/desktop` 及其包管理器状态。CLI 与 Desktop 共享 `$DSH_HOME` 下受支持的产品数据,但绝不共享可执行包、插件激活、锁文件或 `node_modules`。 | | 通信 | 监听 Web 服务会引入端口归属、认证、CORS 与暴露风险;Electron 与上游 Node.js 之间也需要明确的跨进程协议。 | 应用不打开 Web 端口。`dsh-app://` 承载 Web 资源和 Fetch 流量;分帧字节管道以背压传输有界请求与响应分块,Node IPC 只承载子进程生命周期控制。 | | 激活 | 依赖解析、生命周期脚本、原生模块与插件启动都可能失败,目录替换期间进程也可能中断。 | 发布与插件变更先安装到 staging,并启动完整后端执行健康检查;只有成功后才替换活跃 profile,中断替换由事务日志和一个 rollback profile 恢复。 | -| 更新 | 桌面壳与 dsh 独立更新会重新产生版本分裂,而桌面壳未变化的数据块不应强制完整传输。 | Electron 壳、匹配的 dsh seed、Node.js 与 pnpm 组成一个已签名更新单元。平台更新产物可以复用未变化的数据块,但运行时版本选择绝不脱离 Desktop 发布。 | +| 更新 | 桌面壳与 dsh 独立更新会重新产生版本分裂,而桌面壳未变化的数据块不应强制完整传输。 | Electron 壳、匹配的 dsh 运行时、Node.js 与 pnpm 组成一个已签名更新单元。平台更新产物可以复用未变化的数据块,但运行时版本选择绝不脱离 Desktop 发布。 | [Electron 打包与更新 Agent Note](../../.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md)记录了这些决策背后的理由、替代方案、安全约束和发布验证要求。 ## 安装归属 -Electron 拥有保留 profile `$DSH_HOME/profiles/desktop`。其 manifest 通过 `dsh.profile.bundles` 列出内置与已安装插件 bundle,`node_modules` 则同时包含精确版本的 `@deepseek-ai/dsh`、与之匹配的私有 `@deepseek-ai/dsh-desktop-host` 和所有桌面插件。把 Electron 专用进程入口与 overlay 放入私有应用包,可以避免 Desktop 实现成为公共 CLI 包的一部分。CLI 不能启动或修改该 profile。Electron 始终调用自身内置的 Node.js 与 pnpm,并把 store 固定在 `$DSH_HOME/desktop/pnpm/store`;它绝不使用系统 pnpm 或调用方的 npm/pnpm 配置。 +Electron 拥有 `$DSH_HOME/profiles/desktop`。其 `dependencies` 只包含已安装外部插件的精确版本;`dsh.profile.bundles` 包含内置 bundle,后接已启用插件。签名应用从 `resources/dsh` 提供 dsh、私有 Desktop Host 及其生产依赖。共享包链接解析到这些实际目录。宿主与插件在同一个内置上游 Node 进程中执行,使用正常的 realpath 解析;Desktop 不启用 `--preserve-symlinks`。CLI 不能启动或修改此 profile。 dsh 主渲染进程只获得桌面协议标记。独立插件窗口获得结构化的列出、安装、移除、更新和更新检查操作;两个渲染进程都拿不到文件系统、原始 Electron IPC、shell 或任意 pnpm 参数。 Electron 根据应用 locale 选择类型化的中英文字典,并以英文作为 fallback。菜单、原生对话框与插件管理渲染进程使用同一 locale 数据;仓库的 Client UI i18n gate 会检查这些桌面源文件。 -### Seed 安装 +### 运行时与插件激活 -安装包内的 seed 是安装工具包,不是可以直接运行的 `node_modules` 目录。打包过程会生成锁文件,在禁用生命周期脚本的情况下在线物化生产依赖图,删除 `node_modules` 以及所有临时 pnpm cache、config 和 state 目录,然后只使用最终 store 完成一次完整离线安装,并验证私有 Desktop Host 的入口与 overlay 均存在。macOS 构建随后从 pnpm 内容寻址 store staging 每个 Mach-O 对象,最多并发四个 Developer ID 签名进程,并且只在所有签名成功后才更新受影响的 SHA-512 索引记录。再一次离线安装会在分片前证明重写后的 store;准备过程随后解包最终归档,并验证每个内嵌签名。签名 seed 保留发布身份、本地第一方 tarball 及其描述文件、项目元数据、锁文件、完整性清单,以及在用户机器上重复该安装所需的 pnpm store 内容。 +签名资源中的 `resources/dsh/desktop-runtime.json` 绑定 shell 版本、内置 Node 版本、平台、架构、共享包版本和最终文件清单。启动会先验证依赖树,再创建宿主链接。首次启动不会把核心包复制到 profile 存储或通过 pnpm 安装核心包。 -| Seed 内容 | 可写目标或用途 | -|---|---| -| `integrity.json` 与 `desktop-packages.json` | 在修改包状态前验证清单记录的每个 seed 文件、本地 tarball 哈希以及绑定的 dsh 与 Desktop Host 版本。 | -| `store-archives.json` 与 `store-archives/*.tar` | 验证确定性的未压缩分片,把它们解包到唯一的 Desktop staging 目录,替换匹配的不可变 store 文件,并以事务方式把 pnpm 的版本化 SQLite 包索引合并进 `$DSH_HOME/desktop/pnpm/store`,且不移除已经为 Desktop 插件下载的包。 | -| 项目元数据与 `desktop-packages/` | 复制到唯一的 `$DSH_HOME/desktop/staging//profile` 项目。 | -| 锁文件与本地包映射 | 驱动内置 pnpm 完成安装,且不会从 npm 解析已打包的核心包名。 | +1. 新 profile 创建 manifest 和共享包链接,然后启动并停止完整后端,通过后才激活。 +2. 兼容的应用升级把插件文件和配置复制到 staging,刷新共享链接,检查已启用插件的 peer 要求,并执行相同的后端健康检查。插件版本和锁文件保持不变;不运行 pnpm。 +3. 内置 Node 版本、平台或架构变化时,禁用脚本重新安装锁定的插件依赖图,验证并链接宿主包,然后运行已批准的待执行构建并再次验证。 +4. 插件添加、更新和删除使用内置 pnpm 及 Desktop 独有的包管理器状态。保留的宿主包必须声明为 peer;共享包的嵌套副本和别名会被验证拒绝。普通插件依赖必须解析到 profile 内部。 +5. 激活会停止活动后端,记录运行时身份和下一次目录移动,并替换 profile,同时保留一个回滚副本。复制不共享可写硬链接。激活失败会恢复旧 profile;属于其他运行时的 profile 不能在当前 shell 下启动。 -启动过程把 seed 安装或校准为一个串行事务: +后端启动失败时,Electron 拥有的插件窗口仍然可用。可以停用一个或全部插件后重试,同时保留其文件、精确版本和配置。停用只移除激活条目。已停用插件仍可更新或删除。应用回滚和 profile 回滚分别处理;每次启动后端前都会检查运行时兼容性。 -1. 恢复中断的激活事务日志,验证完整 seed 清单与本地包集,并要求 seed 版本等于 Electron 应用版本。 -2. 如果活跃 profile 已包含该发布及匹配的 dsh 与 Desktop Host 版本,则验证其中的本地包集并直接复用,不重新安装。 -3. 否则验证每个归档条目,把全部 store 分片解包到 Desktop 拥有的临时 staging 目录,将包文件与 SQLite 包索引记录合并进私有 store,再创建 staging profile,并通过内置 Node.js 与 pnpm 执行 `pnpm install --offline --frozen-lockfile --trust-lockfile`。Seed 记录替换匹配的索引键,插件专属记录继续保留。 -4. Electron 升级时,从旧活跃 profile 读取每个插件的名称和精确版本,再通过现有 Desktop pnpm 状态以 `--offline` 把这些版本加入 staging。首次安装不执行插件恢复。 -5. 停止活跃后端,启动并停止完整的 staging 后端执行健康检查,再在激活前重新启动活跃后端。这种串行方式避免两个桌面后端共享 `$DSH_HOME`;安装错误或插件不兼容会删除 staging,并保持活跃 profile 不变。 -6. 在每次目录移动前先持久化下一个激活阶段,把活跃 profile 移到 `$DSH_HOME/desktop/rollback/profile`,再把 staging 移到 `$DSH_HOME/profiles/desktop`。恢复过程同时检查日志与真实的 profile、rollback 和 staging 目录,因此在任一个写入与移动间隙中断后仍会恢复或保留一个完整 profile。 - -GUI 插件修改会在把 registry 包安装到共享 Desktop pnpm store 后,使用相同的 staging、健康检查、激活与 rollback 路径。 - -进程生命周期 Electron 锁是桌面端的主要 owner。事务锁用于纵深防御:准备本地状态时记录 Electron,在 pnpm worker 仍可能写入时记录该 worker,worker 退出后再把 owner 交还 Electron。后续进程不会把仍然存活的孤儿 worker 误判为陈旧事务。 +包事务持有独占锁直到 pnpm 进程退出。共享链接在 macOS/Linux 使用目录软链接,在 Windows 使用 junction;硬链接无法表示包目录,而且应用替换后仍会保留旧文件内容,因此不适合此用途。原生构建遵循 profile 中经过审查的 `allowBuilds` 列表;新安装的包如果需要构建但未在列表中获准,事务会失败。 ## 开发 @@ -67,7 +57,7 @@ pnpm run dev:desktop pnpm run start:desktop ``` -Workspace 开发使用调用命令的 Node.js 运行当前 CLI 与私有 Desktop Host 包,并禁用桌面包修改;只有该模式明确链接的一次性 profile 可以从自身目录外解析 bundle。需要验证内置 Node.js、内置 pnpm、发布 seed、插件安装、staging 和 rollback 时,应运行未封装安装器的应用目录。 +Workspace 开发使用调用命令的 Node.js 运行当前 CLI 与私有 Desktop Host 包,并禁用桌面包修改;只有该模式明确链接的一次性 profile 可以从自身目录外解析 bundle。需要验证内置 Node.js、内置 pnpm、内置 dsh 资源、插件安装、staging 和 rollback 时,应运行未封装安装器的应用目录。 ## 打包 @@ -88,7 +78,7 @@ export APPLE_API_ISSUER='' pnpm run package:desktop ``` -发布自动化使用固定目标命令,确保运行时准备、seed 安装与 electron-builder 接收相同的平台和架构: +发布自动化使用固定目标命令,确保运行时准备、dsh 准备与 electron-builder 接收相同的平台和架构: ```sh pnpm run package:desktop:mac:arm64 @@ -98,7 +88,7 @@ pnpm run package:desktop:win:x64 macOS arm64 命令要求 Apple Silicon。macOS x64 命令可以在 Intel macOS 或带 Rosetta 的 Apple Silicon 上运行。Windows x64 命令要求 Windows x64。Desktop 尚不支持 Linux 发布目标。 -每个目标都在 `apps/desktop/.desktop-build/targets//` 下持有自己的打包输入、已准备运行时、包集合、seed、pnpm 准备状态、未打包应用、更新元数据和最终产物。Node.js 归档缓存继续由 `.desktop-build/downloads` 共享,因为每个归档文件名都包含版本、平台和架构,并且在解包前经过验证。目标构建绝不读取其他目标的可变准备状态。 +每个目标都在 `apps/desktop/.desktop-build/targets//` 下持有自己的打包输入、已准备运行时、包集合、dsh 依赖树、pnpm 准备状态、未打包应用、更新元数据和最终产物。Node.js 归档缓存继续由 `.desktop-build/downloads` 共享,因为每个归档文件名都包含版本、平台和架构,并且在解包前经过验证。目标构建绝不读取其他目标的可变准备状态。 ### 上传更新 @@ -125,7 +115,7 @@ pnpm run upload:mac:arm64 生产发布需在打包前设置 `DSH_DESKTOP_AUTO_UPDATE_ENV=production`,再在执行 `upload:mac:arm64`、`upload:mac:x64` 或 `upload:win:x64` 前提供 `DOWNLOAD_PROD_COS_BUCKET` 与生产凭据对。打包不要求 COS bucket 或凭据。它会明确禁止 electron-builder 发布,从其子进程中删除全部四个 COS 凭据字段,并且只有在 electron-builder 以及全部签名或公证 hook 成功后才写入目标完成记录。上传会先要求该记录与所选环境、目标、公开 URL 和当前 dsh 版本一致,再要求根 dsh 版本、Desktop 版本、频道元数据版本、产物名称、大小与 SHA-512 全部一致,之后才读取所选 COS 凭据对。它只上传该目标不可变且带版本的产物,最后以 `no-cache` 上传根据版本得出的频道元数据,并且不会删除历史对象。稳定版本使用 `latest-mac.yml` 或 `latest.yml`;`alpha` 等预发布版本则使用 `alpha-mac.yml` 或 `alpha.yml`,与 electron-builder 生成的文件名一致。 -macOS 配置使用必填发布环境,不会接受钥匙串中最先发现的证书。空值、格式错误的 Team ID、包含 electron-builder 不支持的 `Developer ID Application:` 前缀的签名身份,以及不完整的公证凭据都会被拒绝。macOS 打包要求已配置的身份及其私钥可用。Seed 准备会把该身份、安全时间戳与 hardened runtime 应用到每个内嵌 Mach-O 文件;应用签名完成后,深度严格检查会拒绝其他叶证书 Authority 或 Team ID,验证通过才生成发布产物。Electron-builder 会在封装前公证应用并钉票,然后签署 DMG。DMG 的 artifact-completion hook 随后会公证它并钉票,再要求其身份、票据与 Gatekeeper 验证全部通过;只有 hook 成功,electron-builder 才能发布该文件。私钥可以来自登录钥匙串或 electron-builder 的标准 `CSC_LINK` 输入;环境中的 `CSC_NAME` 与证书发现顺序都不能选择发布所有者。公证凭据也可以使用 electron-builder 支持的完整 Apple ID 或钥匙串 profile 方式。手动执行 `pnpm --dir apps/desktop run verify:mac-signature -- ` 重复应用检查时,也必须提供两个 macOS 身份变量。 +macOS 配置使用必填发布环境,不会接受钥匙串中最先发现的证书。空值、格式错误的 Team ID、包含 electron-builder 不支持的 `Developer ID Application:` 前缀的签名身份,以及不完整的公证凭据都会被拒绝。macOS 打包要求已配置的身份及其私钥可用。运行时准备会把该身份、安全时间戳与 hardened runtime 应用到每个内嵌 Mach-O 文件;应用签名完成后,深度严格检查会拒绝其他叶证书 Authority 或 Team ID,验证通过才生成发布产物。Electron-builder 会在封装前公证应用并钉票,然后签署 DMG。DMG 的 artifact-completion hook 随后会公证它并钉票,再要求其身份、票据与 Gatekeeper 验证全部通过;只有 hook 成功,electron-builder 才能发布该文件。私钥可以来自登录钥匙串或 electron-builder 的标准 `CSC_LINK` 输入;环境中的 `CSC_NAME` 与证书发现顺序都不能选择发布所有者。公证凭据也可以使用 electron-builder 支持的完整 Apple ID 或钥匙串 profile 方式。手动执行 `pnpm --dir apps/desktop run verify:mac-signature -- ` 重复应用检查时,也必须提供两个 macOS 身份变量。 ### Windows EV 签名 @@ -141,7 +131,7 @@ pnpm run package:desktop:win:x64 打包前插入并解锁 Token。electron-builder hook 把每个产物交给采用 CRLF 的 `scripts/windows-sign.cmd`;该 CMD 只调用一次已配置的 SignTool,并指定 `/f`、SafeNet `/kc "[{{PIN}}]=容器"`、`/csp "eToken Base Cryptographic Provider"`、SHA-256 文件摘要和 DigiCert SHA-256 RFC 3161 时间戳。hook 不会改用 electron-builder 内置的 SignTool,也不会重试失败的签名请求。SignTool、证书、容器、PIN、Token 或签名不可用时,Windows 打包会失败,不会生成未签名产物。 -PIN 不能包含 `]`、引号或换行,因为这些字符用于分隔 SafeNet `/kc` 值或对应的 CMD 参数。CMD 会禁用延迟展开,因此包含 `!` 的 PIN 可以原样到达 SafeNet。打包流程不会把任何 `DSH_DESKTOP_WINDOWS_*` 字段传给构建与 seed 准备子进程;它只向 electron-builder 提供四个配置输入,在其他字段已经清理的环境中只向签名 CMD 提供经过校验的签名字段,在 SignTool 启动前清除这些字段,并遮盖 SignTool 诊断。SafeNet 仍要求 PIN 出现在 SignTool 进程命令行中。只能在连接了物理 Token 的受控 self-hosted Windows runner 上把它注入为临时 secret;绝不能提交该值、把它写进 `.env`,或持久保存为 Windows 用户或系统环境变量。 +PIN 不能包含 `]`、引号或换行,因为这些字符用于分隔 SafeNet `/kc` 值或对应的 CMD 参数。CMD 会禁用延迟展开,因此包含 `!` 的 PIN 可以原样到达 SafeNet。打包流程不会把任何 `DSH_DESKTOP_WINDOWS_*` 字段传给构建与 运行时准备子进程;它只向 electron-builder 提供四个配置输入,在其他字段已经清理的环境中只向签名 CMD 提供经过校验的签名字段,在 SignTool 启动前清除这些字段,并遮盖 SignTool 诊断。SafeNet 仍要求 PIN 出现在 SignTool 进程命令行中。只能在连接了物理 Token 的受控 self-hosted Windows runner 上把它注入为临时 secret;绝不能提交该值、把它写进 `.env`,或持久保存为 Windows 用户或系统环境变量。 使用对应的 `:dir` 命令可以生成可直接运行的应用目录,而不是安装包,例如: @@ -156,21 +146,21 @@ pnpm run package:desktop:mac:arm64:dir pnpm run prepare:desktop ``` -这条诊断命令是另一种停止位置,并非两条命令构建流程的前半段。之后执行 `package:desktop*` 时仍会重新完成正式构建与准备,避免使用陈旧的 dsh 包、运行时文件或 seed 内容。 +这条诊断命令是另一种停止位置,并非两条命令构建流程的前半段。之后执行 `package:desktop*` 时仍会重新完成正式构建与准备,避免使用陈旧的 dsh 包、运行时文件或 dsh 内容。 -每条打包命令都会先执行仓库的正式构建,打包 dsh 与 vendored 包族,在本地打包私有 Desktop Host 包,并打包 Landlock 入口,然后再准备发布资源。`prepare:packages` 选择分别以 `@deepseek-ai/dsh` 和 `@deepseek-ai/dsh-desktop-host` 为根的第一方生产依赖闭包之并集,验证私有 Host tarball 同时包含 `lib/index.js` 与 `config/desktop.cordis.patch.yml`,把选中的 tarball 复制到 seed 输入,并记录其大小与 SHA-512 完整性。Host 包不会发布到 npm;它的 `files` manifest 只包含该运行入口与 overlay。公共包 tarball 仍是由各包发布 manifest 控制的正式 `pnpm pack` 输出,因此 Desktop 不增加第二套过滤规则,会保留 `lib/types` 等已发布声明,也不会独立删除或增加 source map。Registry 包同样在 pnpm 内容寻址 store 中保留其发布的包字节。dsh 发布版本更新会同步更新两个私有 Desktop manifest、仓库根与可发布 workspace;打包还会要求根 dsh 包、Desktop Host 包与 Electron 包使用同一版本。构建 Desktop 应用前不要求 dsh 或私有 Host 已发布到 npm。`prepare:runtime` 从 Node.js 官方发行服务下载 Node.js 24.17.0,在解压前验证其 SHA-256 条目,并在兼容的构建宿主上执行准备完成的目标二进制文件以验证其报告版本。它复制桌面包声明的 pnpm 版本,并把两个运行时版本记录进发布 seed。`prepare:seed` 运行该目标 Node.js 与内置 pnpm,因此按平台和 CPU 过滤的可选依赖会使 pnpm store 与 seed 成为目标专用内容。它生成本地核心包映射、禁用全局 virtual store、从 npm 物化外部生产依赖并禁用生命周期脚本、删除 `node_modules` 以及所有临时 pnpm cache、config 和 state,证明完整依赖图可以离线安装并包含私有 Host 的入口与 overlay,在适用时执行 macOS 重写,再通过一次离线安装证明重写后的 store,删除临时 pnpm 项目注册,然后把松散 store 替换为 16 个确定性的未压缩 tar 分片。它会解包这些最终分片,并在生成清单前验证每个内嵌 macOS 签名。后续 GUI 插件操作保留本地核心包映射,同时从固定的 Desktop npm registry 解析插件包及其外部依赖。`electron-builder` 把各目标的平台产物写到 `apps/desktop/.desktop-build/targets//artifacts`;后续版本会保留不同名称的不可变安装包与 blockmap,但会替换该目标的未打包应用、诊断文件、完成记录与频道元数据。 +每条打包命令都会构建仓库,打包以 dsh 和私有 Desktop Host 为根的第一方生产依赖闭包,并准备目标专用的 Node 与 pnpm 可执行文件。`prepare:dsh` 在构建时安装一次生产依赖图,把物化包复制到 `extraResources/dsh`,移除包管理器元数据,并生成包含共享包版本和最终文件哈希的 `desktop-runtime.json`。在 macOS 上,它先签名并验证原生文件,再生成清单;electron-builder 不对已签名的此目录重复进行嵌套签名。资源映射明确包含默认根目录过滤器会忽略的 `dsh/node_modules`;复制后的清单在签名前及签名后分别验证。签名安装包、公证、已安装应用升级和各目标原生模块的验收需要发布环境。 -未压缩产物包含四块相互独立的体积:Electron、离线 seed store 分片与本地 dsh tarball、上游 Node.js 与 pnpm 运行时,以及很小的桌面壳应用。分片不压缩,使外层 DMG、ZIP 或 NSIS 压缩器与差分更新器可以处理稳定的数据区间。文件系统占用不等于安装包下载大小,因此必须分别测量。打包应用首次启动时还会先把 seed store 解包到 `$DSH_HOME/desktop/pnpm/store`,再安装可写 profile,因此发布验证必须同时测量应用与 Harness home 的磁盘占用。 +未压缩产物包含 Electron、物化后的 dsh 生产依赖树、上游 Node.js 与 pnpm,以及壳应用。安装包大小与文件系统占用不同;发布验收需要测量两者,以及 profile 插件存储和首次启动耗时。此布局用更多应用内文件换取消除用户机器上的核心包安装过程。 ## 更新 -打包应用会在主窗口打开十秒后检查目标专用的发布流;本地化的 **检查更新…** 菜单项会手动触发同一检查。发现可用版本时,应用打开一个原生确认弹窗。用户确认后,应用等待正在进行的检查完成,下载并验证已签名的 Desktop 发布、停止 dsh 子进程,并把安装与重启交给 electron-updater。下次启动会先校准版本绑定的 seed,再重新打开产品窗口。 +打包应用会在主窗口打开十秒后检查目标专用的发布流;本地化的 **检查更新…** 菜单项会手动触发同一检查。发现可用版本时,应用打开一个原生确认弹窗。用户确认后,应用等待正在进行的检查完成,下载并验证已签名的 Desktop 发布、停止 dsh 子进程,并把安装与重启交给 electron-updater。下次启动会先校准版本绑定的运行时,再重新打开产品窗口。 -Electron-builder 始终为 `DSH_DESKTOP_AUTO_UPDATE_ENV` 选择的部署生成 generic-provider 频道元数据。NSIS 差分包与 macOS ZIP 目标让 electron-updater 可以复用未变化的数据块;供手动安装的 DMG 经过公证,但不生成 blockmap,因为它不是 macOS updater 的载荷。Seed 与桌面壳仍属于同一个签名 Desktop 发布。macOS 签名与公证凭据使用 electron-builder 的标准环境变量;Windows EV 签名使用上文所述的公开证书、已验证 SignTool、SafeNet 容器和 runner PIN。必填 Desktop 发布环境选择构建所验证的应用身份与平台签名身份。 +Electron-builder 始终为 `DSH_DESKTOP_AUTO_UPDATE_ENV` 选择的部署生成 generic-provider 频道元数据。NSIS 差分包与 macOS ZIP 目标让 electron-updater 可以复用未变化的数据块;供手动安装的 DMG 经过公证,但不生成 blockmap,因为它不是 macOS updater 的载荷。运行时与桌面壳仍属于同一个签名 Desktop 发布。macOS 签名与公证凭据使用 electron-builder 的标准环境变量;Windows EV 签名使用上文所述的公开证书、已验证 SignTool、SafeNet 容器和 runner PIN。必填 Desktop 发布环境选择构建所验证的应用身份与平台签名身份。 ## 底层开发覆盖项 -`DSH_DESKTOP_NODE_BINARY`、`DSH_DESKTOP_PNPM_ENTRY`、`DSH_DESKTOP_SEED_DIR` 和 `DSH_DESKTOP_DEV_PROJECT_DIR` 可以为未打包 Electron 进程选择明确的资源。打包应用会忽略这些变量,并从 `process.resourcesPath` 解析签名资源。 +`DSH_DESKTOP_NODE_BINARY`、`DSH_DESKTOP_PNPM_ENTRY`、`DSH_DESKTOP_DSH_DIR` 和 `DSH_DESKTOP_DEV_PROJECT_DIR` 可以为未打包 Electron 进程选择明确的资源。打包应用会忽略这些变量,并从 `process.resourcesPath` 解析签名资源。 ## 已知限制 diff --git a/apps/desktop/electron-builder.config.d.mts b/apps/desktop/electron-builder.config.d.mts index 2f4b88de64..760f8cb8a1 100644 --- a/apps/desktop/electron-builder.config.d.mts +++ b/apps/desktop/electron-builder.config.d.mts @@ -6,12 +6,14 @@ export interface DesktopElectronBuilderConfig { } readonly extraResources: readonly [ { readonly from: string, readonly to: 'runtime' }, - { readonly from: string, readonly to: 'seed' }, + { readonly from: string, readonly to: 'dsh' }, + { readonly from: string, readonly to: 'dsh/node_modules' }, ] readonly mac: { readonly identity: string | undefined readonly forceCodeSigning: boolean readonly notarize: boolean + readonly signIgnore: readonly string[] } readonly dmg: { readonly sign: boolean diff --git a/apps/desktop/electron-builder.config.mjs b/apps/desktop/electron-builder.config.mjs index c47bd6d7a9..d082658695 100644 --- a/apps/desktop/electron-builder.config.mjs +++ b/apps/desktop/electron-builder.config.mjs @@ -1,3 +1,4 @@ +import { join } from 'node:path' import { resolveDesktopAppId, resolveMacOSNotarizationEnvironment, @@ -59,13 +60,17 @@ export function createElectronBuilderConfig( ], extraResources: [ { from: buildPaths.runtime, to: 'runtime' }, - { from: buildPaths.seed, to: 'seed' }, + { from: buildPaths.dsh, to: 'dsh' }, + // electron-builder excludes a source directory's root node_modules. + { from: join(buildPaths.dsh, 'node_modules'), to: 'dsh/node_modules' }, ], mac: { category: 'public.app-category.developer-tools', identity: macOSSigning?.signingIdentity, forceCodeSigning: true, hardenedRuntime: true, + // Runtime files are signed before their integrity inventory is sealed. + signIgnore: ['/Contents/Resources/dsh(?:/|$)'], notarize: true, target: ['dmg', 'zip'], }, @@ -73,8 +78,16 @@ export function createElectronBuilderConfig( sign: true, writeUpdateInfo: false, }, - afterSign: context => { + afterPack: async context => { + const { verifyDesktopRuntime } = await import('./lib/types/runtime-tree.js') + verifyDesktopRuntime(join(context.packager.getResourcesDir(context.appOutDir), 'dsh'), + context.packager.appInfo.version, { platform: resolvedPlatform, arch: resolvedArch }) + }, + afterSign: async context => { if (context.electronPlatformName !== 'darwin') return + const { verifyDesktopRuntime } = await import('./lib/types/runtime-tree.js') + verifyDesktopRuntime(join(context.appOutDir, `${context.packager.appInfo.productFilename}.app`, 'Contents', 'Resources', 'dsh'), + context.packager.appInfo.version, { platform: 'darwin', arch: resolvedArch }) verifyMacOSSignatureAfterSign(context, macOSSigning ?? resolveMacOSSigningEnvironment(env)) }, artifactBuildCompleted: artifact => { diff --git a/apps/desktop/package.json b/apps/desktop/package.json index ec0b802a17..e8d38981c5 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-desktop", - "description": "Electron desktop shell for an isolated pnpm-installed dsh runtime", + "description": "Electron desktop shell for a bundled dsh runtime and external plugins", "version": "0.1.3-alpha.2", "private": true, "license": "MIT", @@ -12,7 +12,7 @@ "start": "tsx scripts/dev.ts --skip-build", "prepare:runtime": "tsx scripts/prepare-runtime.ts", "prepare:packages": "tsx scripts/prepare-package-set.ts", - "prepare:seed": "tsx scripts/prepare-seed.ts", + "prepare:dsh": "tsx scripts/prepare-dsh.ts", "prepare:package": "tsx scripts/package-target.ts --prepare-only", "verify:mac-signature": "node scripts/verify-macos-signature.mjs", "package": "tsx scripts/package-target.ts", @@ -43,7 +43,6 @@ "electron-builder": "^26.15.3", "extract-zip": "^2.0.1", "js-yaml": "^4.2.0", - "msgpackr": "2.0.4", "pnpm": "11.7.0", "tar": "^7.5.0", "typescript": "^6.0.3" diff --git a/apps/desktop/renderer/plugin-manager.html b/apps/desktop/renderer/plugin-manager.html index 03fbb4b8aa..112fd1825f 100644 --- a/apps/desktop/renderer/plugin-manager.html +++ b/apps/desktop/renderer/plugin-manager.html @@ -16,6 +16,12 @@ +
diff --git a/apps/desktop/renderer/plugin-manager.js b/apps/desktop/renderer/plugin-manager.js index a50de3a825..6599c5b9d5 100644 --- a/apps/desktop/renderer/plugin-manager.js +++ b/apps/desktop/renderer/plugin-manager.js @@ -14,6 +14,10 @@ async function main() { document.querySelector('#installed-heading').textContent = messages.installed document.querySelector('#empty').textContent = messages.noPlugins + document.querySelector('#recovery-description').textContent = messages.recoveryDescription + document.querySelector('#retry').textContent = messages.retry + document.querySelector('#disable-all').textContent = messages.disableAll + const list = document.querySelector('#plugins') const empty = document.querySelector('#empty') const status = document.querySelector('#status') @@ -27,13 +31,16 @@ async function main() { } async function render() { + const backend = await api.backend.status() + document.querySelector('#recovery').hidden = backend.ready + document.querySelector('#startup-error').textContent = backend.error ?? '' const plugins = await api.plugins.list() list.replaceChildren(...plugins.map(plugin => { const item = document.createElement('li') const identity = document.createElement('span') const version = document.createElement('span') version.className = 'package-version' - version.textContent = plugin.version + version.textContent = plugin.enabled ? plugin.version : `${plugin.version} · ${messages.disabled}` identity.append(document.createTextNode(plugin.name), version) const remove = document.createElement('button') remove.type = 'button' @@ -52,7 +59,13 @@ async function main() { }) const actions = document.createElement('span') actions.className = 'package-actions' - actions.append(update, remove) + const toggle = document.createElement('button') + toggle.type = 'button' + toggle.textContent = plugin.enabled ? messages.disable : messages.enable + toggle.addEventListener('click', () => void run( + () => api.plugins.toggle(plugin.name, !plugin.enabled), messages.changingActivation, + )) + actions.append(toggle, update, remove) item.append(identity, actions) return item })) @@ -93,6 +106,8 @@ async function main() { input.value = '' }, message('installing', { spec })) }) + document.querySelector('#retry').addEventListener('click', () => void run(() => api.backend.retry(), messages.retry)) + document.querySelector('#disable-all').addEventListener('click', () => void run(() => api.plugins.disableAll(), messages.changingActivation)) refresh.addEventListener('click', () => void load(messages.refreshing, messages.refreshed)) await load(messages.loadingPlugins, '') diff --git a/apps/desktop/scripts/desktop-build-paths.d.mts b/apps/desktop/scripts/desktop-build-paths.d.mts index 423e017b15..add677f668 100644 --- a/apps/desktop/scripts/desktop-build-paths.d.mts +++ b/apps/desktop/scripts/desktop-build-paths.d.mts @@ -6,8 +6,8 @@ export interface DesktopTargetBuildPaths { readonly artifacts: string readonly runtime: string readonly packageSet: string - readonly seed: string - readonly seedPnpm: string + readonly dsh: string + readonly dshPnpm: string readonly nodeExtract: string readonly packedDsh: string readonly packedVendor: string diff --git a/apps/desktop/scripts/desktop-build-paths.mjs b/apps/desktop/scripts/desktop-build-paths.mjs index 504136e1db..770b3333e3 100644 --- a/apps/desktop/scripts/desktop-build-paths.mjs +++ b/apps/desktop/scripts/desktop-build-paths.mjs @@ -31,7 +31,7 @@ export function resolveDesktopBuildTarget( /** * Return the mutable preparation and artifact directories owned by one release target. * @param {'mac-arm64' | 'mac-x64' | 'win-x64'} target - Supported Desktop target name. - * @returns {{ root: string, artifacts: string, runtime: string, packageSet: string, seed: string, seedPnpm: string, nodeExtract: string, packedDsh: string, packedVendor: string, packedLandlock: string, downloads: string }} Target paths plus the shared immutable download cache. + * @returns {{ root: string, artifacts: string, runtime: string, packageSet: string, dsh: string, dshPnpm: string, nodeExtract: string, packedDsh: string, packedVendor: string, packedLandlock: string, downloads: string }} Target paths plus the shared immutable download cache. */ export function desktopTargetBuildPaths(target) { if (!SUPPORTED_TARGETS.has(target)) { @@ -44,8 +44,8 @@ export function desktopTargetBuildPaths(target) { artifacts: join(root, 'artifacts'), runtime: join(root, 'runtime'), packageSet: join(root, 'package-set'), - seed: join(root, 'seed'), - seedPnpm: join(root, 'seed-pnpm'), + dsh: join(root, 'dsh'), + dshPnpm: join(root, 'dsh-pnpm'), nodeExtract: join(root, 'node-extract'), packedDsh: join(packed, 'dsh'), packedVendor: join(packed, 'vendor'), diff --git a/apps/desktop/scripts/macos-runtime.ts b/apps/desktop/scripts/macos-runtime.ts new file mode 100644 index 0000000000..313c9cdf11 --- /dev/null +++ b/apps/desktop/scripts/macos-runtime.ts @@ -0,0 +1,43 @@ +/** Sign final native runtime files before the enclosing Desktop application is signed. */ + +import { createHash } from 'node:crypto' +import { closeSync, openSync, readSync } from 'node:fs' +import { join } from 'node:path' +import { inventoryDesktopRuntime } from '../src/runtime-tree.ts' +import type { MacOSSigningEnvironment } from './desktop-release-environment.mjs' +import { signMacOSRuntimeCode, verifyMacOSRuntimeCode } from './verify-macos-signature.mjs' + +const MACH_O_MAGICS = new Set(['cafebabe', 'cafebabf', 'cefaedfe', 'cffaedfe', 'feedface', 'feedfacf', 'bebafeca', 'bfbafeca']) + +function isMachO(path: string): boolean { + const descriptor = openSync(path, 'r') + try { + const header = Buffer.alloc(4) + return readSync(descriptor, header, 0, 4, 0) === 4 && MACH_O_MAGICS.has(header.toString('hex')) + } finally { closeSync(descriptor) } +} + +/** + * Sign and verify every materialized Mach-O file, awaiting all signers on failure. + * @param root - Self-contained production runtime without symlinks. + * @param appId - Release application identifier. + * @param expected - Required signing identity. + * @returns Number of signed native files. + */ +export async function signMacOSRuntime(root: string, appId: string, expected: MacOSSigningEnvironment): Promise { + const files = inventoryDesktopRuntime(root).map(file => file.path).filter(path => isMachO(join(root, path))) + let next = 0 + const workers = Array.from({ length: Math.min(4, files.length) }, async () => { + for (;;) { + const path = files[next++] + if (path === undefined) return + const identifier = `${appId}.runtime.${createHash('sha256').update(path).digest('hex')}` + await signMacOSRuntimeCode(join(root, path), identifier, expected) + verifyMacOSRuntimeCode(join(root, path), expected) + } + }) + const results = await Promise.allSettled(workers) + const errors = results.filter(result => result.status === 'rejected').map(result => result.reason as unknown) + if (errors.length > 0) throw new AggregateError(errors, 'desktop runtime: native signing failed') + return files.length +} diff --git a/apps/desktop/scripts/macos-seed-store.ts b/apps/desktop/scripts/macos-seed-store.ts deleted file mode 100644 index 1a02dfd5cc..0000000000 --- a/apps/desktop/scripts/macos-seed-store.ts +++ /dev/null @@ -1,413 +0,0 @@ -/** Sign Mach-O content in a pnpm CAS without invalidating the store index. */ - -import { createHash } from 'node:crypto' -import { - chmodSync, - closeSync, - copyFileSync, - existsSync, - mkdirSync, - mkdtempSync, - openSync, - readFileSync, - readSync, - readdirSync, - rmSync, - unlinkSync, - writeFileSync, -} from 'node:fs' -import { availableParallelism, tmpdir } from 'node:os' -import { basename, dirname, join, relative, sep } from 'node:path' -import { DatabaseSync } from 'node:sqlite' -import { Packr } from 'msgpackr' -import type { MacOSSigningEnvironment } from './desktop-release-environment.mjs' -import { signMacOSSeedCode, verifyMacOSSeedCode } from './verify-macos-signature.mjs' - -const MACH_O_MAGICS = new Set([ - 'cafebabe', - 'cafebabf', - 'cefaedfe', - 'cffaedfe', - 'feedface', - 'feedfacf', - 'bebafeca', - 'bfbafeca', -]) -const CAS_PATH_PATTERN = /^([0-9a-f]{2})\/([0-9a-f]{126})(-exec)?$/u -const MAX_CONCURRENT_CODE_SIGNERS = 4 -const packr = new Packr({ moreTypes: true, useRecords: true }) - -interface PnpmStoreFileRecord { - checkedAt: number - digest: string - mode: number - size: number -} - -interface PnpmSideEffectsRecord { - readonly added?: Map -} - -interface PnpmPackageIndexRecord { - readonly algo?: string - readonly files?: Map - readonly sideEffects?: Map -} - -interface DecodedIndexRow { - readonly key: string - readonly value: PnpmPackageIndexRecord - changed: boolean -} - -interface CasFile { - readonly path: string - readonly digest: string - readonly executable: boolean -} - -interface FileReference { - readonly row: DecodedIndexRow - readonly record: PnpmStoreFileRecord -} - -interface SigningWork { - readonly file: CasFile - readonly references: readonly FileReference[] - readonly temporaryPath: string -} - -/** Summary of native code rewritten in one pnpm store. */ -export interface MacOSSeedStoreSigningResult { - readonly signedFiles: number - readonly prunedOrphans: number - readonly updatedIndexRows: number -} - -/** A signer used to make one writable Mach-O copy release-valid. */ -export type MacOSSeedCodeSigner = (path: string, identifier: string) => Promise - -/** A verifier used to check one Mach-O file after packaging transport. */ -export type MacOSSeedCodeVerifier = (path: string) => void - -/** Optional execution controls for seed-store code signing. */ -export interface MacOSSeedStoreSigningOptions { - readonly signer?: MacOSSeedCodeSigner - readonly concurrency?: number -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null -} - -function isStoreFileRecord(value: unknown): value is PnpmStoreFileRecord { - if (!isRecord(value)) return false - return typeof value.checkedAt === 'number' - && typeof value.digest === 'string' - && /^[0-9a-f]{128}$/u.test(value.digest) - && Number.isSafeInteger(value.mode) - && Number.isSafeInteger(value.size) -} - -function packageFileMaps(value: unknown, key: string): readonly Map[] { - if (!isRecord(value)) throw new Error(`desktop seed signing: invalid pnpm index record ${key}`) - const record = value as PnpmPackageIndexRecord - if (record.algo !== undefined && record.algo !== 'sha512') { - throw new Error(`desktop seed signing: unsupported pnpm index algorithm in ${key}`) - } - const maps: Map[] = [] - if (record.files !== undefined) { - if (!(record.files instanceof Map)) throw new Error(`desktop seed signing: invalid pnpm file map in ${key}`) - maps.push(record.files) - } - if (record.sideEffects !== undefined) { - if (!(record.sideEffects instanceof Map)) { - throw new Error(`desktop seed signing: invalid pnpm side-effects map in ${key}`) - } - for (const effect of record.sideEffects.values()) { - if (!isRecord(effect)) throw new Error(`desktop seed signing: invalid pnpm side effect in ${key}`) - if (effect.added === undefined) continue - if (!(effect.added instanceof Map)) { - throw new Error(`desktop seed signing: invalid pnpm side-effect file map in ${key}`) - } - maps.push(effect.added) - } - } - for (const files of maps) { - for (const file of files.values()) { - if (!isStoreFileRecord(file)) throw new Error(`desktop seed signing: invalid pnpm file record in ${key}`) - } - } - return maps -} - -function isExecutableMode(mode: number): boolean { - return (mode & 0o111) !== 0 -} - -function referenceKey(digest: string, executable: boolean): string { - return `${digest}:${executable ? 'exec' : 'nonexec'}` -} - -function isMachO(path: string): boolean { - const descriptor = openSync(path, 'r') - try { - const header = Buffer.alloc(4) - return readSync(descriptor, header, 0, header.length, 0) === header.length - && MACH_O_MAGICS.has(header.toString('hex')) - } finally { - closeSync(descriptor) - } -} - -function visitFiles(root: string): readonly string[] { - const files: string[] = [] - const visit = (directory: string): void => { - for (const entry of readdirSync(directory, { withFileTypes: true })) { - const path = join(directory, entry.name) - if (entry.isSymbolicLink()) { - throw new Error(`desktop seed signing: pnpm store contains a symbolic link: ${relative(root, path)}`) - } - if (entry.isDirectory()) visit(path) - else if (entry.isFile()) files.push(path) - else throw new Error(`desktop seed signing: unsupported pnpm store entry: ${relative(root, path)}`) - } - } - visit(root) - return files.sort((left, right) => left.localeCompare(right)) -} - -function versionRoots(storeRoot: string): readonly string[] { - return readdirSync(storeRoot, { withFileTypes: true }) - .filter(entry => entry.isDirectory() && /^v\d+$/u.test(entry.name)) - .map(entry => join(storeRoot, entry.name)) - .filter(root => existsSync(join(root, 'files'))) - .sort((left, right) => left.localeCompare(right)) -} - -function casFiles(versionRoot: string): readonly CasFile[] { - const filesRoot = join(versionRoot, 'files') - const result: CasFile[] = [] - for (const path of visitFiles(filesRoot)) { - if (!isMachO(path)) continue - const normalized = relative(filesRoot, path).split(sep).join('/') - const match = CAS_PATH_PATTERN.exec(normalized) - if (match === null) { - throw new Error(`desktop seed signing: Mach-O content has an unsupported pnpm CAS path: ${normalized}`) - } - result.push({ - path, - digest: `${match[1]}${match[2]}`, - executable: match[3] !== undefined, - }) - } - return result -} - -function readIndexRows(database: DatabaseSync): readonly DecodedIndexRow[] { - const rows: DecodedIndexRow[] = [] - for (const row of database.prepare('SELECT key, data FROM package_index').iterate() as Iterable<{ - key: string - data: Uint8Array - }>) { - rows.push({ key: row.key, value: packr.unpack(row.data) as PnpmPackageIndexRecord, changed: false }) - } - return rows -} - -function fileReferences(rows: readonly DecodedIndexRow[]): ReadonlyMap { - const references = new Map() - for (const row of rows) { - for (const files of packageFileMaps(row.value, row.key)) { - for (const record of files.values()) { - const key = referenceKey(record.digest, isExecutableMode(record.mode)) - const values = references.get(key) ?? [] - values.push({ row, record }) - references.set(key, values) - } - } - } - return references -} - -function writeCasFile(path: string, body: Buffer, mode: number): void { - mkdirSync(dirname(path), { recursive: true }) - try { - writeFileSync(path, body, { flag: 'wx', mode }) - } catch (error) { - if (!isRecord(error) || error.code !== 'EEXIST' || !readFileSync(path).equals(body)) throw error - } - chmodSync(path, mode) -} - -function signedCasPath(versionRoot: string, digest: string, executable: boolean): string { - return join( - versionRoot, - 'files', - digest.slice(0, 2), - `${digest.slice(2)}${executable ? '-exec' : ''}`, - ) -} - -async function runConcurrent( - values: readonly T[], - concurrency: number, - run: (value: T) => Promise, -): Promise { - let next = 0 - const failure: { error?: unknown; failed: boolean } = { failed: false } - const worker = async (): Promise => { - while (!failure.failed) { - const index = next - if (index >= values.length) return - next += 1 - try { - await run(values[index] as T) - } catch (error) { - if (!failure.failed) { - failure.failed = true - failure.error = error - } - } - } - } - const workers = Array.from( - { length: Math.min(concurrency, values.length) }, - async () => worker(), - ) - await Promise.all(workers) - if (failure.failed) throw failure.error -} - -async function rewriteVersionStore( - versionRoot: string, - appId: string, - signer: MacOSSeedCodeSigner, - concurrency: number, -): Promise { - const databasePath = join(versionRoot, 'index.db') - if (!existsSync(databasePath)) { - throw new Error(`desktop seed signing: pnpm store has no package index: ${databasePath}`) - } - const database = new DatabaseSync(databasePath) - const workRoot = mkdtempSync(join(tmpdir(), 'dsh-desktop-seed-signing-')) - const obsoleteFiles = new Set() - let prunedOrphans = 0 - let rows: readonly DecodedIndexRow[] = [] - try { - rows = readIndexRows(database) - const references = fileReferences(rows) - const signingWork: SigningWork[] = [] - for (const file of casFiles(versionRoot)) { - const body = readFileSync(file.path) - const actualDigest = createHash('sha512').update(body).digest('hex') - if (actualDigest !== file.digest) { - throw new Error(`desktop seed signing: pnpm CAS digest mismatch at ${file.path}`) - } - const fileReferences = references.get(referenceKey(file.digest, file.executable)) ?? [] - if (fileReferences.length === 0) { - obsoleteFiles.add(file.path) - prunedOrphans += 1 - continue - } - const temporary = join(workRoot, `${signingWork.length.toString().padStart(4, '0')}-${basename(file.path)}`) - copyFileSync(file.path, temporary) - chmodSync(temporary, 0o755) - signingWork.push({ file, references: fileReferences, temporaryPath: temporary }) - } - await runConcurrent(signingWork, concurrency, async (work) => { - await signer(work.temporaryPath, `${appId}.seed.${work.file.digest.slice(0, 32)}`) - }) - for (const work of signingWork) { - const signedBody = readFileSync(work.temporaryPath) - if (!isMachO(work.temporaryPath)) { - throw new Error(`desktop seed signing: signer produced non-Mach-O content for ${work.file.path}`) - } - const signedDigest = createHash('sha512').update(signedBody).digest('hex') - const mode = work.file.executable ? 0o755 : 0o644 - const destination = signedCasPath(versionRoot, signedDigest, work.file.executable) - writeCasFile(destination, signedBody, mode) - const checkedAt = Date.now() - for (const reference of work.references) { - reference.record.checkedAt = checkedAt - reference.record.digest = signedDigest - reference.record.mode = mode - reference.record.size = signedBody.length - reference.row.changed = true - } - if (destination !== work.file.path) obsoleteFiles.add(work.file.path) - } - const changedRows = rows.filter(row => row.changed) - database.exec('BEGIN IMMEDIATE') - let committed = false - try { - const statement = database.prepare('INSERT OR REPLACE INTO package_index (key, data) VALUES (?, ?)') - for (const row of changedRows) statement.run(row.key, packr.pack(row.value)) - database.exec('COMMIT') - committed = true - } finally { - if (!committed) database.exec('ROLLBACK') - } - for (const path of obsoleteFiles) unlinkSync(path) - database.exec('VACUUM') - return { signedFiles: signingWork.length, prunedOrphans, updatedIndexRows: changedRows.length } - } finally { - database.close() - rmSync(workRoot, { recursive: true, force: true }) - } -} - -/** - * Replace every Mach-O CAS object with a Developer ID signed object and update pnpm's SHA-512 index. - * A signer rejection leaves the original CAS objects and package index unchanged. - * @param storeRoot - Loose pnpm store prepared for the packaged seed. - * @param appId - Electron application ID used as the signing identifier prefix. - * @param expected - Company Developer ID identity and Team ID. - * @param options - Optional signer and worker bound used by focused tests. - * @returns Counts for release diagnostics after every signer completes and the index transaction commits. - */ -export async function signMacOSSeedStore( - storeRoot: string, - appId: string, - expected: MacOSSigningEnvironment, - options: MacOSSeedStoreSigningOptions = {}, -): Promise { - const roots = versionRoots(storeRoot) - if (roots.length === 0) throw new Error(`desktop seed signing: no pnpm store versions found in ${storeRoot}`) - const concurrency = options.concurrency ?? Math.min(MAX_CONCURRENT_CODE_SIGNERS, availableParallelism()) - if (!Number.isSafeInteger(concurrency) || concurrency < 1) { - throw new Error(`desktop seed signing: concurrency must be a positive integer; received ${String(concurrency)}`) - } - const signer = options.signer ?? (async (path, identifier) => { - await signMacOSSeedCode(path, identifier, expected) - }) - const results: MacOSSeedStoreSigningResult[] = [] - for (const root of roots) results.push(await rewriteVersionStore(root, appId, signer, concurrency)) - return results.reduce((total, current) => ({ - signedFiles: total.signedFiles + current.signedFiles, - prunedOrphans: total.prunedOrphans + current.prunedOrphans, - updatedIndexRows: total.updatedIndexRows + current.updatedIndexRows, - }), { signedFiles: 0, prunedOrphans: 0, updatedIndexRows: 0 }) -} - -/** - * Verify that every Mach-O CAS object has the expected Developer ID, timestamp, and hardened runtime. - * @param storeRoot - Loose or extracted pnpm store. - * @param expected - Company Developer ID identity and Team ID. - * @param verifier - Injectable signature verifier used by focused tests. - * @returns Number of verified Mach-O files. - */ -export function verifyMacOSSeedStore( - storeRoot: string, - expected: MacOSSigningEnvironment, - verifier: MacOSSeedCodeVerifier = (path) => { verifyMacOSSeedCode(path, expected) }, -): number { - let count = 0 - for (const root of versionRoots(storeRoot)) { - for (const file of casFiles(root)) { - verifier(file.path) - count += 1 - } - } - return count -} diff --git a/apps/desktop/scripts/package-target.ts b/apps/desktop/scripts/package-target.ts index 1f32123ec0..c5b0bc2667 100644 --- a/apps/desktop/scripts/package-target.ts +++ b/apps/desktop/scripts/package-target.ts @@ -1,4 +1,4 @@ -/** Build one release target with matching Electron, Node.js, and seed architecture. */ +/** Build one release target with matching Electron, Node.js, and dsh architecture. */ import { spawn } from 'node:child_process' import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs' @@ -276,7 +276,7 @@ async function main(): Promise { ], buildEnv, REPOSITORY_ROOT) await runPnpm(['run', 'prepare:runtime'], targetEnv) await runPnpm(['run', 'prepare:packages'], targetEnv) - await runPnpm(['run', 'prepare:seed'], targetEnv) + await runPnpm(['run', 'prepare:dsh'], targetEnv) if (invocation.prepareOnly) return await runPnpm(desktopElectronBuilderArguments(target, invocation.directory), electronBuilderEnv) if (!invocation.directory) writeReleaseRecord(target, electronBuilderEnv, buildPaths.artifacts) diff --git a/apps/desktop/scripts/prepare-dsh.ts b/apps/desktop/scripts/prepare-dsh.ts new file mode 100644 index 0000000000..d030a05e1a --- /dev/null +++ b/apps/desktop/scripts/prepare-dsh.ts @@ -0,0 +1,150 @@ +/** Materialize the complete production runtime before publishing Desktop resources. */ + +import { spawn } from 'node:child_process' +import { copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { delimiter, dirname, join, resolve } from 'node:path' +import { createRuntimeProjectMetadata } from '../src/project-manager.ts' +import { DESKTOP_HOST_PROTOCOL_VERSION } from '../src/host-protocol.ts' +import { parseDesktopRelease, type DesktopRelease } from '../src/release.ts' +import { + DESKTOP_HOST_PACKAGE, + DESKTOP_HOST_RUNTIME_FILES, + DESKTOP_PACKAGES_DIR, + DESKTOP_PACKAGE_SET_FILE, + readDesktopCorePackageSet, + verifyDesktopCoreLockfile, +} from '../src/core-package-set.ts' +import { smokeDesktopRuntime } from './smoke-runtime.ts' +import { writeDesktopRuntime, verifyDesktopRuntime } from '../src/runtime-tree.ts' +import { + resolveDesktopAppId, + resolveMacOSSigningEnvironment, +} from './desktop-release-environment.mjs' +import { + signMacOSRuntime, +} from './macos-runtime.ts' +import { resolveDesktopBuildTarget, resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs' + +const APP_ROOT = resolve(import.meta.dirname, '..') +const BUILD_PATHS = resolveDesktopTargetBuildPaths() +const DSH_OUTPUT_ROOT = BUILD_PATHS.dsh +const BUILD_ROOT = mkdtempSync(join(tmpdir(), 'dsh-desktop-runtime-')) +const STORE_ROOT = join(BUILD_ROOT, 'store') +const RUNTIME_ROOT = BUILD_PATHS.runtime +const PNPM_BUILD_STATE = BUILD_PATHS.dshPnpm +const PACKAGE_SET_ROOT = BUILD_PATHS.packageSet +const NODE = join(RUNTIME_ROOT, 'node', process.platform === 'win32' ? 'node.exe' : 'node') +const PNPM = join(RUNTIME_ROOT, 'pnpm', 'bin', 'pnpm.mjs') + +function manifestVersion(path: string, subject: string): string { + const manifest = JSON.parse(readFileSync(path, 'utf8')) as { version?: unknown } + if (typeof manifest.version !== 'string') throw new Error(`desktop runtime: ${subject} has no version`) + return manifest.version +} + +function desktopRelease(): DesktopRelease { + const version = manifestVersion(join(APP_ROOT, 'package.json'), 'desktop package') + const dshVersion = manifestVersion(resolve(APP_ROOT, '..', '..', 'package.json'), 'root dsh package') + if (version !== dshVersion) { + throw new Error(`desktop runtime: Electron ${version} must bind the same version of @deepseek-ai/dsh, found ${dshVersion}`) + } + const runtime = JSON.parse(readFileSync(join(RUNTIME_ROOT, 'versions.json'), 'utf8')) as Record + return parseDesktopRelease({ + schemaVersion: 1, + version, + hostProtocolVersion: DESKTOP_HOST_PROTOCOL_VERSION, + nodeVersion: runtime.node, + pnpmVersion: runtime.pnpm, + }) +} + +function runPnpm(args: readonly string[]): Promise { + return new Promise((resolvePromise, reject) => { + const [command, ...commandArgs] = args + if (command === undefined) throw new Error('desktop runtime: pnpm command is required') + const config = join(PNPM_BUILD_STATE, 'config') + const userConfig = join(config, 'npmrc') + mkdirSync(config, { recursive: true }) + writeFileSync(userConfig, '') + const child = spawn(NODE, [ + PNPM, + '--config.registry=https://registry.npmjs.org/', + `--config.store-dir=${STORE_ROOT}`, + '--config.enable-global-virtual-store=false', + `--config.userconfig=${userConfig}`, + command, + ...commandArgs, + ], { + cwd: BUILD_ROOT, + env: { + ...Object.fromEntries(Object.entries(process.env).filter(([name]) => ( + name !== 'NODE_OPTIONS' && name !== 'NODE_PATH' && !/^DSH_DESKTOP_/u.test(name) && !/^(?:npm|pnpm|corepack)_/iu.test(name) + ))), + NPM_CONFIG_REGISTRY: 'https://registry.npmjs.org/', + NPM_CONFIG_STORE_DIR: STORE_ROOT, + NPM_CONFIG_USERCONFIG: userConfig, + PATH: `${dirname(NODE)}${delimiter}${process.env.PATH ?? ''}`, + XDG_CACHE_HOME: join(PNPM_BUILD_STATE, 'cache'), + XDG_CONFIG_HOME: config, + XDG_STATE_HOME: join(PNPM_BUILD_STATE, 'state'), + }, + stdio: 'inherit', + }) + child.once('error', reject) + child.once('close', (code, signal) => { + if (code === 0) resolvePromise() + else reject(new Error(`desktop runtime: pnpm exited with ${String(code ?? signal)}`)) + }) + }) +} + +async function main(): Promise { + rmSync(DSH_OUTPUT_ROOT, { recursive: true, force: true }) + rmSync(PNPM_BUILD_STATE, { recursive: true, force: true }) + mkdirSync(STORE_ROOT, { recursive: true }) + try { + const release = desktopRelease() + copyFileSync(join(PACKAGE_SET_ROOT, DESKTOP_PACKAGE_SET_FILE), join(BUILD_ROOT, DESKTOP_PACKAGE_SET_FILE)) + cpSync(join(PACKAGE_SET_ROOT, DESKTOP_PACKAGES_DIR), join(BUILD_ROOT, DESKTOP_PACKAGES_DIR), { recursive: true }) + createRuntimeProjectMetadata(BUILD_ROOT, release) + await runPnpm(['install', '--lockfile-only']) + verifyDesktopCoreLockfile( + readFileSync(join(BUILD_ROOT, 'pnpm-lock.yaml'), 'utf8'), + readDesktopCorePackageSet(BUILD_ROOT, release.version), + ) + await runPnpm(['install', '--prod', '--frozen-lockfile', '--trust-lockfile']) + const packageSet = readDesktopCorePackageSet(BUILD_ROOT, release.version) + mkdirSync(DSH_OUTPUT_ROOT, { recursive: true }) + cpSync(join(BUILD_ROOT, 'node_modules'), join(DSH_OUTPUT_ROOT, 'node_modules'), { + recursive: true, dereference: true, + filter: source => !source.split(/[\\/]/u).some(part => part === '.bin' || part === '.pnpm' || part === '.modules.yaml' || part === '.pnpm-workspace-state-v1.json'), + }) + writeFileSync(join(DSH_OUTPUT_ROOT, 'package.json'), `${JSON.stringify({ + name: '@deepseek-ai/dsh-desktop-runtime', private: true, version: release.version, type: 'module', + dependencies: Object.fromEntries(packageSet.packages.map(entry => [entry.name, entry.version])), + }, undefined, 2)}\n`) + for (const file of DESKTOP_HOST_RUNTIME_FILES) { + if (!existsSync(join(DSH_OUTPUT_ROOT, 'node_modules', DESKTOP_HOST_PACKAGE, file))) { + throw new Error(`desktop runtime: missing private Host file ${file}`) + } + } + if (process.platform === 'darwin') { + await signMacOSRuntime(DSH_OUTPUT_ROOT, resolveDesktopAppId(process.env), resolveMacOSSigningEnvironment(process.env)) + } + const targetName = resolveDesktopBuildTarget() + const target = { platform: process.platform, arch: targetName.endsWith('arm64') ? 'arm64' : 'x64' } + writeDesktopRuntime(DSH_OUTPUT_ROOT, release, packageSet.packages.map(entry => entry.name), target) + const descriptor = verifyDesktopRuntime(DSH_OUTPUT_ROOT, release.version, target) + await smokeDesktopRuntime(DSH_OUTPUT_ROOT, NODE, descriptor) + verifyDesktopRuntime(DSH_OUTPUT_ROOT, release.version, target) + } catch (error) { + rmSync(DSH_OUTPUT_ROOT, { recursive: true, force: true }) + throw error + } finally { + rmSync(BUILD_ROOT, { recursive: true, force: true }) + rmSync(PNPM_BUILD_STATE, { recursive: true, force: true }) + } +} + +await main() diff --git a/apps/desktop/scripts/prepare-seed.ts b/apps/desktop/scripts/prepare-seed.ts deleted file mode 100644 index 73da078ef2..0000000000 --- a/apps/desktop/scripts/prepare-seed.ts +++ /dev/null @@ -1,200 +0,0 @@ -/** Build the release seed through the same embedded pnpm used on first launch. */ - -import { spawn } from 'node:child_process' -import { createHash } from 'node:crypto' -import { copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { delimiter, dirname, join, relative, resolve, sep } from 'node:path' -import { createSeedMetadata } from '../src/project-manager.ts' -import { DESKTOP_HOST_PROTOCOL_VERSION } from '../src/host-protocol.ts' -import { parseDesktopRelease, type DesktopRelease } from '../src/release.ts' -import { - DESKTOP_HOST_PACKAGE, - DESKTOP_HOST_RUNTIME_FILES, - DESKTOP_PACKAGES_DIR, - DESKTOP_PACKAGE_SET_FILE, - readDesktopCorePackageSet, - verifyDesktopCoreLockfile, -} from '../src/core-package-set.ts' -import { - archivePnpmStore, - extractPnpmStoreArchives, - removePnpmProjectRegistrations, -} from '../src/seed-store.ts' -import { - resolveDesktopAppId, - resolveMacOSSigningEnvironment, -} from './desktop-release-environment.mjs' -import { - signMacOSSeedStore, - verifyMacOSSeedStore, -} from './macos-seed-store.ts' -import { resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs' - -const APP_ROOT = resolve(import.meta.dirname, '..') -const BUILD_PATHS = resolveDesktopTargetBuildPaths() -const SEED_OUTPUT_ROOT = BUILD_PATHS.seed -const SEED_ROOT = mkdtempSync(join(tmpdir(), 'dsh-desktop-seed-')) -const STORE_ROOT = join(SEED_ROOT, 'store') -const RUNTIME_ROOT = BUILD_PATHS.runtime -const PNPM_BUILD_STATE = BUILD_PATHS.seedPnpm -const PACKAGE_SET_ROOT = BUILD_PATHS.packageSet -const NODE = join(RUNTIME_ROOT, 'node', process.platform === 'win32' ? 'node.exe' : 'node') -const PNPM = join(RUNTIME_ROOT, 'pnpm', 'bin', 'pnpm.mjs') - -function manifestVersion(path: string, subject: string): string { - const manifest = JSON.parse(readFileSync(path, 'utf8')) as { version?: unknown } - if (typeof manifest.version !== 'string') throw new Error(`desktop seed: ${subject} has no version`) - return manifest.version -} - -function desktopRelease(): DesktopRelease { - const version = manifestVersion(join(APP_ROOT, 'package.json'), 'desktop package') - const dshVersion = manifestVersion(resolve(APP_ROOT, '..', '..', 'package.json'), 'root dsh package') - if (version !== dshVersion) { - throw new Error(`desktop seed: Electron ${version} must bind the same version of @deepseek-ai/dsh, found ${dshVersion}`) - } - const runtime = JSON.parse(readFileSync(join(RUNTIME_ROOT, 'versions.json'), 'utf8')) as Record - return parseDesktopRelease({ - schemaVersion: 1, - version, - hostProtocolVersion: DESKTOP_HOST_PROTOCOL_VERSION, - nodeVersion: runtime.node, - pnpmVersion: runtime.pnpm, - }) -} - -function runPnpm(args: readonly string[]): Promise { - return new Promise((resolvePromise, reject) => { - const [command, ...commandArgs] = args - if (command === undefined) throw new Error('desktop seed: pnpm command is required') - const config = join(PNPM_BUILD_STATE, 'config') - const userConfig = join(config, 'npmrc') - mkdirSync(config, { recursive: true }) - writeFileSync(userConfig, '') - const child = spawn(NODE, [ - PNPM, - '--config.registry=https://registry.npmjs.org/', - `--config.store-dir=${STORE_ROOT}`, - '--config.enable-global-virtual-store=false', - `--config.userconfig=${userConfig}`, - command, - ...commandArgs, - ], { - cwd: SEED_ROOT, - env: { - ...Object.fromEntries(Object.entries(process.env).filter(([name]) => ( - !/^DSH_DESKTOP_/u.test(name) && !/^(?:npm|pnpm|corepack)_/iu.test(name) - ))), - NPM_CONFIG_REGISTRY: 'https://registry.npmjs.org/', - NPM_CONFIG_STORE_DIR: STORE_ROOT, - NPM_CONFIG_USERCONFIG: userConfig, - PATH: `${dirname(NODE)}${delimiter}${process.env.PATH ?? ''}`, - XDG_CACHE_HOME: join(PNPM_BUILD_STATE, 'cache'), - XDG_CONFIG_HOME: config, - XDG_STATE_HOME: join(PNPM_BUILD_STATE, 'state'), - }, - stdio: 'inherit', - }) - child.once('error', reject) - child.once('close', (code, signal) => { - if (code === 0) resolvePromise() - else reject(new Error(`desktop seed: pnpm exited with ${String(code ?? signal)}`)) - }) - }) -} - -function inventory(root: string): readonly { path: string; bytes: number; sha256: string }[] { - const files: string[] = [] - const visit = (dir: string): void => { - for (const entry of readdirSync(dir, { withFileTypes: true })) { - const path = join(dir, entry.name) - if (entry.isDirectory()) visit(path) - else if (entry.isFile()) files.push(path) - else throw new Error(`desktop seed: unsupported filesystem entry ${relative(root, path)}`) - } - } - visit(root) - return files.sort().map((path) => { - const body = readFileSync(path) - return { - path: relative(root, path).split(sep).join('/'), - bytes: statSync(path).size, - sha256: createHash('sha256').update(body).digest('hex'), - } - }) -} - -async function verifyOfflineInstallation(release: DesktopRelease): Promise { - const installedModules = join(SEED_ROOT, 'node_modules') - try { - await runPnpm(['install', '--offline', '--frozen-lockfile', '--trust-lockfile']) - const hostRoot = join(installedModules, ...DESKTOP_HOST_PACKAGE.split('/')) - for (const file of DESKTOP_HOST_RUNTIME_FILES) { - if (!existsSync(join(hostRoot, file))) { - throw new Error(`desktop seed: local ${DESKTOP_HOST_PACKAGE}@${release.version} does not contain ${file}`) - } - } - } finally { - rmSync(installedModules, { recursive: true, force: true }) - } -} - -async function main(): Promise { - rmSync(SEED_OUTPUT_ROOT, { recursive: true, force: true }) - rmSync(PNPM_BUILD_STATE, { recursive: true, force: true }) - mkdirSync(STORE_ROOT, { recursive: true }) - try { - const release = desktopRelease() - copyFileSync(join(PACKAGE_SET_ROOT, DESKTOP_PACKAGE_SET_FILE), join(SEED_ROOT, DESKTOP_PACKAGE_SET_FILE)) - cpSync(join(PACKAGE_SET_ROOT, DESKTOP_PACKAGES_DIR), join(SEED_ROOT, DESKTOP_PACKAGES_DIR), { recursive: true }) - createSeedMetadata(SEED_ROOT, release) - await runPnpm(['install', '--lockfile-only']) - verifyDesktopCoreLockfile( - readFileSync(join(SEED_ROOT, 'pnpm-lock.yaml'), 'utf8'), - readDesktopCorePackageSet(SEED_ROOT, release.version), - ) - const installedModules = join(SEED_ROOT, 'node_modules') - await runPnpm(['install', '--prod', '--frozen-lockfile', '--trust-lockfile', '--ignore-scripts']) - rmSync(installedModules, { recursive: true, force: true }) - rmSync(PNPM_BUILD_STATE, { recursive: true, force: true }) - await verifyOfflineInstallation(release) - const targetPlatform = process.env.DSH_DESKTOP_TARGET_PLATFORM ?? process.platform - let signedMachOFiles: number | undefined - let macOSSigning: ReturnType | undefined - if (targetPlatform === 'darwin') { - macOSSigning = resolveMacOSSigningEnvironment(process.env) - const signing = await signMacOSSeedStore( - STORE_ROOT, - resolveDesktopAppId(process.env), - macOSSigning, - ) - signedMachOFiles = signing.signedFiles - process.stdout.write( - `desktop seed: signed ${signing.signedFiles} Mach-O files, updated ${signing.updatedIndexRows} pnpm index records, and pruned ${signing.prunedOrphans} native orphans\n`, - ) - await verifyOfflineInstallation(release) - } - removePnpmProjectRegistrations(STORE_ROOT) - archivePnpmStore(SEED_ROOT, STORE_ROOT) - if (macOSSigning !== undefined && signedMachOFiles !== undefined) { - const extractedStore = mkdtempSync(join(tmpdir(), 'dsh-desktop-seed-verification-')) - try { - extractPnpmStoreArchives(SEED_ROOT, extractedStore) - const verified = verifyMacOSSeedStore(extractedStore, macOSSigning) - if (verified !== signedMachOFiles) { - throw new Error(`desktop seed: archived store contains ${verified} signed Mach-O files; expected ${signedMachOFiles}`) - } - } finally { - rmSync(extractedStore, { recursive: true, force: true }) - } - } - const records = inventory(SEED_ROOT).filter(entry => entry.path !== 'integrity.json') - writeFileSync(join(SEED_ROOT, 'integrity.json'), `${JSON.stringify({ schemaVersion: 2, files: records }, undefined, 2)}\n`) - cpSync(SEED_ROOT, SEED_OUTPUT_ROOT, { recursive: true }) - } finally { - rmSync(SEED_ROOT, { recursive: true, force: true }) - } -} - -await main() diff --git a/apps/desktop/scripts/smoke-runtime.ts b/apps/desktop/scripts/smoke-runtime.ts new file mode 100644 index 0000000000..4b4103e534 --- /dev/null +++ b/apps/desktop/scripts/smoke-runtime.ts @@ -0,0 +1,58 @@ +/** Boot the materialized target runtime without access to a user's Harness profile. */ + +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { DesktopHostProcess } from '../src/host-process.ts' +import { createPluginProfile } from '../src/project-manager.ts' +import { linkDesktopHostPackages, validateDesktopPluginGraph } from '../src/profile-packages.ts' +import type { DesktopRuntimeDescriptor } from '../src/runtime-tree.ts' + +/** + * Prove the final resource tree boots and serves its matching Web frontend. + * @param root - Materialized dsh resources. + * @param node - Prepared target Node executable. + * @param runtime - Verified resource descriptor. + */ +export async function smokeDesktopRuntime(root: string, node: string, runtime: DesktopRuntimeDescriptor): Promise { + const home = mkdtempSync(join(tmpdir(), 'dsh-desktop-smoke-')) + const profile = join(home, 'profiles', 'desktop') + const host = new DesktopHostProcess(node, root, profile, undefined, { ...process.env, DSH_HOME: home }) + try { + createPluginProfile(profile) + const pluginName = 'desktop-runtime-smoke-plugin' + const plugin = join(profile, 'node_modules', pluginName) + mkdirSync(plugin, { recursive: true }) + const cordis = runtime.sharedPackages.find(entry => entry.name === '@deepseek-ai/cordis') + if (cordis === undefined) throw new Error('desktop runtime: missing shared Cordis package') + writeFileSync(join(plugin, 'package.json'), JSON.stringify({ + name: pluginName, version: '1.0.0', type: 'module', exports: './index.js', + peerDependencies: { '@deepseek-ai/cordis': cordis.version }, dsh: { bundle: { patch: './bundle.yml' } }, + })) + writeFileSync(join(plugin, 'index.js'), ` +import { Context } from '@deepseek-ai/cordis' +export function apply(ctx) { + if (!(ctx instanceof Context)) throw new Error('desktop runtime: external plugin loaded another Cordis instance') +} +`) + writeFileSync(join(plugin, 'bundle.yml'), '- insert:\n - id: desktop-runtime-smoke-plugin\n name: desktop-runtime-smoke-plugin\n') + const manifest = JSON.parse(readFileSync(join(profile, 'package.json'), 'utf8')) as { + dependencies: Record + dsh: { profile: { bundles: string[] } } + } + manifest.dependencies[pluginName] = '1.0.0' + manifest.dsh.profile.bundles.push(pluginName) + writeFileSync(join(profile, 'package.json'), JSON.stringify(manifest)) + linkDesktopHostPackages(profile, root, runtime) + validateDesktopPluginGraph(profile, root, runtime, [pluginName]) + const ready = await host.start() + if (ready.dshVersion !== runtime.release.version) throw new Error('desktop runtime: Host reported another dsh release') + const response = await host.fetch(new Request('dsh-app://app/')) + if (response.status !== 200 || !(await response.text()).includes(' /** - * Verify one Mach-O file embedded in the seed store. + * Verify one Mach-O file embedded in the runtime tree. * @param path - Mach-O file to inspect. * @param expected - Public release identity. */ -export function verifyMacOSSeedCode(path: string, expected: MacOSSigningEnvironment): void +export function verifyMacOSRuntimeCode(path: string, expected: MacOSSigningEnvironment): void /** * Verify the full application signature and its release owner. diff --git a/apps/desktop/scripts/verify-macos-signature.mjs b/apps/desktop/scripts/verify-macos-signature.mjs index 48b254dedd..af0d6196ae 100644 --- a/apps/desktop/scripts/verify-macos-signature.mjs +++ b/apps/desktop/scripts/verify-macos-signature.mjs @@ -1,4 +1,4 @@ -/** Sign seed code and verify that packaged macOS artifacts carry the company release identity. */ +/** Sign runtime code and verify that packaged macOS artifacts carry the company release identity. */ import { spawn, spawnSync } from 'node:child_process' import { resolve } from 'node:path' @@ -21,19 +21,19 @@ export function assertMacOSSignatureDetails(details, expected) { } /** - * Require the signature properties Apple validates for executable seed content. + * Require the signature properties Apple validates for executable runtime content. * @param {string} details - Output from `codesign --display --verbose=4`. * @param {{ signingIdentity: string, teamId: string }} expected - Public release identity. * @returns {void} */ -export function assertMacOSSeedSignatureDetails(details, expected) { +export function assertMacOSRuntimeSignatureDetails(details, expected) { assertMacOSSignatureDetails(details, expected) const fields = details.split(/\r?\n/u).map(line => line.trim()) if (!fields.some(line => /^Timestamp=.+/u.test(line))) { - throw new Error('desktop macOS signing: seed signature has no secure timestamp') + throw new Error('desktop macOS signing: runtime signature has no secure timestamp') } if (!fields.some(line => /\bflags=0x[0-9a-f]+\(runtime\)(?:\s|$)/iu.test(line))) { - throw new Error('desktop macOS signing: seed signature does not enable hardened runtime') + throw new Error('desktop macOS signing: runtime signature does not enable hardened runtime') } } @@ -60,7 +60,7 @@ function runAppleCommand(command, args, label) { } /** - * Execute one Apple release tool without blocking other independent seed signers. + * Execute one Apple release tool without blocking other independent runtime signers. * @param {string} command - Absolute executable path. * @param {readonly string[]} args - Tool arguments. * @param {string} label - Stable diagnostic name. @@ -106,13 +106,13 @@ function runCodeSign(args) { } /** - * Sign one Mach-O file embedded in the seed store. + * Sign one Mach-O file embedded in the runtime tree. * @param {string} path - Writable standalone Mach-O file. * @param {string} identifier - Stable code-signing identifier derived from the release app ID and CAS digest. * @param {{ signingIdentity: string, teamId: string }} expected - Public release identity. * @returns {Promise} Resolves after codesign exits successfully. */ -export async function signMacOSSeedCode(path, identifier, expected) { +export async function signMacOSRuntimeCode(path, identifier, expected) { await runAppleCommandAsync('/usr/bin/codesign', [ '--force', '--sign', expected.signingIdentity, @@ -124,15 +124,15 @@ export async function signMacOSSeedCode(path, identifier, expected) { } /** - * Verify one Mach-O file embedded in the seed store. + * Verify one Mach-O file embedded in the runtime tree. * @param {string} path - Mach-O file to inspect. * @param {{ signingIdentity: string, teamId: string }} expected - Public release identity. * @returns {void} */ -export function verifyMacOSSeedCode(path, expected) { +export function verifyMacOSRuntimeCode(path, expected) { runCodeSign(['--verify', '--strict', '--verbose=2', path]) const details = runCodeSign(['--display', '--verbose=4', path]) - assertMacOSSeedSignatureDetails(details, expected) + assertMacOSRuntimeSignatureDetails(details, expected) } /** diff --git a/apps/desktop/src/core-package-set.ts b/apps/desktop/src/core-package-set.ts index ff93bd5605..249057d647 100644 --- a/apps/desktop/src/core-package-set.ts +++ b/apps/desktop/src/core-package-set.ts @@ -124,7 +124,7 @@ export function desktopDshPackageSpec(packageSet: DesktopCorePackageSet): string /** * Verify every local tarball and reject extra package files before pnpm executes them. - * @param projectDir - Seed or profile directory containing the package set. + * @param projectDir - Build directory containing the package set. * @param expectedReleaseVersion - Exact dsh and Desktop Host version bound to Electron. * @returns The verified package set. */ diff --git a/apps/desktop/src/host-process.ts b/apps/desktop/src/host-process.ts index ed18d0c7d6..20c59cf7e1 100644 --- a/apps/desktop/src/host-process.ts +++ b/apps/desktop/src/host-process.ts @@ -86,28 +86,33 @@ export class DesktopHostProcess { /** * @param node - absolute bundled upstream Node.js executable. - * @param projectDir - active or staged desktop npm project. + * @param runtimeDir - immutable packages carried by the current application. + * @param projectDir - active or staged desktop plugin profile. * @param inspectPort - optional loopback inspector port for workspace development. + * @param environment - Child environment; runtime and package-manager overrides are removed. */ constructor( private readonly node: string, + private readonly runtimeDir: string, private readonly projectDir: string, private readonly inspectPort?: number, + private readonly environment: NodeJS.ProcessEnv = process.env, ) {} /** Start the child once and resolve only after its complete composition is active. */ async start(): Promise { if (this.child !== undefined) return this.readyPromise - const entry = join(this.projectDir, 'node_modules', '@deepseek-ai', 'dsh-desktop-host', 'lib', 'index.js') + const entry = join(this.runtimeDir, 'node_modules', '@deepseek-ai', 'dsh-desktop-host', 'lib', 'index.js') const child = spawn(this.node, [ ...(this.inspectPort === undefined ? [] : [`--inspect=127.0.0.1:${String(this.inspectPort)}`]), entry, + this.runtimeDir, this.projectDir, ...(this.inspectPort === undefined ? [] : ['--allow-linked-profile']), ], { cwd: this.projectDir, - env: Object.fromEntries(Object.entries(process.env).filter(([name]) => ( - name !== 'NODE_OPTIONS' && !/^DSH_DESKTOP_/u.test(name) && !/^(?:npm|pnpm|corepack)_/iu.test(name) + env: Object.fromEntries(Object.entries(this.environment).filter(([name]) => ( + name !== 'NODE_OPTIONS' && name !== 'NODE_PATH' && !/^DSH_DESKTOP_/u.test(name) && !/^(?:npm|pnpm|corepack)_/iu.test(name) ))), stdio: ['ignore', 'pipe', 'pipe', 'pipe', 'pipe', 'ipc'], }) diff --git a/apps/desktop/src/ipc.ts b/apps/desktop/src/ipc.ts index 4fb946a2a5..f98bf22902 100644 --- a/apps/desktop/src/ipc.ts +++ b/apps/desktop/src/ipc.ts @@ -10,6 +10,10 @@ export const DESKTOP_IPC = { pluginsAdd: 'dsh-desktop:plugins-add', pluginsRemove: 'dsh-desktop:plugins-remove', pluginsUpdate: 'dsh-desktop:plugins-update', + pluginsToggle: 'dsh-desktop:plugins-toggle', + pluginsDisableAll: 'dsh-desktop:plugins-disable-all', + backendStatus: 'dsh-desktop:backend-status', + backendRetry: 'dsh-desktop:backend-retry', updatesCheck: 'dsh-desktop:updates-check', updatesInstall: 'dsh-desktop:updates-install', updatesState: 'dsh-desktop:updates-state', @@ -31,6 +35,12 @@ export interface DshDesktopApi { add(spec: string): Promise remove(name: string): Promise update(name: string, version: string): Promise + toggle(name: string, enabled: boolean): Promise + disableAll(): Promise + } + readonly backend: { + status(): Promise<{ readonly ready: boolean; readonly error?: string }> + retry(): Promise } readonly updates: { check(): Promise diff --git a/apps/desktop/src/locale.ts b/apps/desktop/src/locale.ts index b666cfd559..65e5083313 100644 --- a/apps/desktop/src/locale.ts +++ b/apps/desktop/src/locale.ts @@ -20,6 +20,13 @@ export const en = { pluginWindowTitle: 'DeepSeek Harness — Desktop Plugins', pluginManagerDescription: 'Plugins are installed only in the Desktop node_modules and are managed by the bundled pnpm.', refresh: 'Refresh', + enable: 'Enable', + disable: 'Disable', + disabled: 'Disabled', + retry: 'Retry startup', + disableAll: 'Disable all plugins and retry', + recoveryDescription: 'The backend could not start. Update or disable incompatible plugins, then retry. Installed plugins and configuration are retained.', + changingActivation: 'Changing plugin activation…', npmPackage: 'npm package', install: 'Install', installed: 'Installed', @@ -59,6 +66,13 @@ export const zh = { pluginWindowTitle: 'DeepSeek Harness — 桌面插件', pluginManagerDescription: '插件只安装到桌面端自己的 node_modules,并由内置 pnpm 管理。', refresh: '刷新', + enable: '启用', + disable: '禁用', + disabled: '已禁用', + retry: '重试启动', + disableAll: '禁用全部插件并重试', + recoveryDescription: '后端无法启动。请更新或禁用不兼容插件,然后重试。已安装插件和配置会保留。', + changingActivation: '正在更改插件启用状态…', npmPackage: 'npm 包', install: '安装', installed: '已安装', diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 8bc9fdcefd..a995d6818e 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -49,7 +49,7 @@ const MIME: Readonly> = { interface RuntimeResources { readonly node: string readonly pnpm: string - readonly seed: string + readonly dsh: string } function runtimeResources(): RuntimeResources { @@ -58,8 +58,8 @@ function runtimeResources(): RuntimeResources { ?? join(process.resourcesPath, 'runtime', 'node', process.platform === 'win32' ? 'node.exe' : 'node') const pnpm = (development ? process.env.DSH_DESKTOP_PNPM_ENTRY : undefined) ?? join(process.resourcesPath, 'runtime', 'pnpm', 'bin', 'pnpm.mjs') - const seed = (development ? process.env.DSH_DESKTOP_SEED_DIR : undefined) ?? join(process.resourcesPath, 'seed') - return { node, pnpm, seed } + const dsh = (development ? process.env.DSH_DESKTOP_DSH_DIR : undefined) ?? join(process.resourcesPath, 'dsh') + return { node, pnpm, dsh } } function developmentProject(): string | undefined { @@ -137,8 +137,8 @@ async function main(): Promise { const activeProject = development ?? paths.profile const hostInspectPort = developmentHostInspectPort(development !== undefined) const manager = new DesktopProjectManager(paths, resources) - if (development === undefined) manager.recover() let host: DesktopHostProcess | undefined + let startupError: string | undefined let mainWindow: BrowserWindow | undefined let pluginWindow: BrowserWindow | undefined let shellInstallerOwnsQuit = false @@ -157,9 +157,15 @@ async function main(): Promise { } const startHost = async (projectDir = activeProject): Promise => { - const next = new DesktopHostProcess(resources.node, projectDir, hostInspectPort) - await next.start() - return next + if (development === undefined) manager.assertProfileRuntime(projectDir) + const next = new DesktopHostProcess(resources.node, development ?? resources.dsh, projectDir, hostInspectPort) + try { + await next.start() + return next + } catch (error) { + await next.stop() + throw error + } } const hooks: DesktopProjectHooks = { healthCheck: async (projectDir) => { @@ -202,14 +208,18 @@ async function main(): Promise { }, } - if (development === undefined) { - await manager.applyRelease(resources.seed, app.getVersion(), { - ...hooks, - beforeActivate: async () => {}, - afterActivate: async () => {}, - }) + const reconcileBackend = async (): Promise => { + try { + if (development === undefined) { + await manager.applyRelease(app.getVersion(), { ...hooks, beforeActivate: async () => {}, afterActivate: async () => {} }) + } + host = await startHost() + startupError = undefined + } catch (error) { + startupError = errorOf(error, messages.startupFailed).message + throw error + } } - host = await startHost() const updates = new DesktopUpdateCoordinator( publishUpdate, @@ -236,7 +246,9 @@ async function main(): Promise { throw new Error('dsh desktop: plugin package changes require a packaged application') } await manager.mutate(mutation, hooks) + startupError = undefined if (mainWindow !== undefined && !mainWindow.isDestroyed()) mainWindow.webContents.reload() + else focusPrimaryWindow() } ipcMain.handle(DESKTOP_IPC.localeGet, (event) => { assertDesktopSender(event, ['shell']) @@ -261,6 +273,20 @@ async function main(): Promise { } return mutate(event, { type: 'plugin-update', name, version }) }) + ipcMain.handle(DESKTOP_IPC.pluginsToggle, (event, name: unknown, enabled: unknown) => { + if (typeof name !== 'string' || typeof enabled !== 'boolean') throw new Error('dsh desktop: invalid plugin activation request') + return mutate(event, { type: 'plugin-toggle', name, enabled }) + }) + ipcMain.handle(DESKTOP_IPC.pluginsDisableAll, event => mutate(event, { type: 'plugins-disable-all' })) + ipcMain.handle(DESKTOP_IPC.backendStatus, (event) => { + assertDesktopSender(event, ['shell']) + return { ready: host !== undefined, ...(startupError === undefined ? {} : { error: startupError }) } + }) + ipcMain.handle(DESKTOP_IPC.backendRetry, async (event) => { + assertDesktopSender(event, ['shell']) + if (host === undefined) await reconcileBackend() + focusPrimaryWindow() + }) ipcMain.handle(DESKTOP_IPC.updatesCheck, async (event) => { assertDesktopSender(event, ['shell']) return updates.check() @@ -348,6 +374,7 @@ async function main(): Promise { return window } focusPrimaryWindow = () => { + if (host === undefined) { openPluginWindow(); return } const window = mainWindow if (window === undefined || window.isDestroyed()) { const replacement = createMainWindow() @@ -359,9 +386,13 @@ async function main(): Promise { window.focus() } - mainWindow = createMainWindow() - await mainWindow.loadURL(`${SCHEME}://app/index.html`) - if (development !== undefined && process.env.DSH_DESKTOP_OPEN_DEVTOOLS !== '0') { + await reconcileBackend().catch(() => undefined) + if (host === undefined) openPluginWindow() + else { + mainWindow = createMainWindow() + await mainWindow.loadURL(`${SCHEME}://app/index.html`) + } + if (mainWindow !== undefined && development !== undefined && process.env.DSH_DESKTOP_OPEN_DEVTOOLS !== '0') { mainWindow.webContents.openDevTools({ mode: 'detach' }) } publishUpdate(updateState) diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 2fc4dc8ee0..4ad4243bdb 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -10,8 +10,14 @@ const api: DshDesktopApi = { list: () => ipcRenderer.invoke(DESKTOP_IPC.pluginsList) as Promise extends Promise ? T : never>, add: spec => ipcRenderer.invoke(DESKTOP_IPC.pluginsAdd, spec) as Promise, remove: name => ipcRenderer.invoke(DESKTOP_IPC.pluginsRemove, name) as Promise, + toggle: (name, enabled) => ipcRenderer.invoke(DESKTOP_IPC.pluginsToggle, name, enabled) as Promise, + disableAll: () => ipcRenderer.invoke(DESKTOP_IPC.pluginsDisableAll) as Promise, update: (name, version) => ipcRenderer.invoke(DESKTOP_IPC.pluginsUpdate, name, version) as Promise, }, + backend: { + status: () => ipcRenderer.invoke(DESKTOP_IPC.backendStatus) as ReturnType, + retry: () => ipcRenderer.invoke(DESKTOP_IPC.backendRetry) as Promise, + }, updates: { check: () => ipcRenderer.invoke(DESKTOP_IPC.updatesCheck) as Promise, install: () => ipcRenderer.invoke(DESKTOP_IPC.updatesInstall) as Promise, diff --git a/apps/desktop/src/profile-packages.ts b/apps/desktop/src/profile-packages.ts new file mode 100644 index 0000000000..6326747b43 --- /dev/null +++ b/apps/desktop/src/profile-packages.ts @@ -0,0 +1,254 @@ +/** Desktop-owned host links and validation of the external plugin dependency graph. */ + +import { createHash } from 'node:crypto' +import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, realpathSync, readdirSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' +import { satisfies } from 'semver' +import { desktopRuntimeId, runtimePath, type DesktopRuntimeDescriptor } from './runtime-tree.ts' + +/** Applied runtime identity and the only links Desktop may replace. */ +export const DESKTOP_PROFILE_STATE = 'desktop-runtime-state.json' + +/** Durable ownership of a package-directory link. */ +export interface DesktopPackageLink { + readonly name: string + readonly target: string +} + +/** Facts from the last successfully validated profile. */ +export interface DesktopProfileState { + readonly schemaVersion: 1 + readonly runtimeId: string + readonly version: string + readonly nodeVersion: string + readonly platform: string + readonly arch: string + readonly lockHash: string + readonly links: readonly DesktopPackageLink[] +} + +const PACKAGE_NAME = /^(?:@[a-z0-9][a-z0-9._~-]*\/[a-z0-9][a-z0-9._~-]*|[a-z0-9][a-z0-9._~-]*)$/u + +function record(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function stat(path: string): ReturnType | undefined { + try { return lstatSync(path) } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + return undefined + } +} + +function inside(root: string, path: string): boolean { + const child = relative(root, path) + return child === '' || (!isAbsolute(child) && child !== '..' && !child.startsWith(`..${sep}`)) +} + +/** + * Read profile state without interpreting an unpublished predecessor format. + * @param profile - Desktop profile directory. + * @returns Validated state, or undefined for an uninitialized profile. + */ +export function readDesktopProfileState(profile: string): DesktopProfileState | undefined { + const path = join(profile, DESKTOP_PROFILE_STATE) + if (!existsSync(path)) return undefined + const value: unknown = JSON.parse(readFileSync(path, 'utf8')) + if (!record(value) || value.schemaVersion !== 1 || typeof value.runtimeId !== 'string' + || !/^[a-f0-9]{64}$/u.test(value.runtimeId) || typeof value.version !== 'string' + || typeof value.nodeVersion !== 'string' || typeof value.platform !== 'string' || typeof value.arch !== 'string' + || typeof value.lockHash !== 'string' || !Array.isArray(value.links)) { + throw new Error('desktop profile: invalid runtime state') + } + const links = value.links.map((link: unknown): DesktopPackageLink => { + if (!record(link) || typeof link.name !== 'string' || !PACKAGE_NAME.test(link.name) + || typeof link.target !== 'string' || !isAbsolute(link.target)) { + throw new Error('desktop profile: invalid managed link') + } + return { name: link.name, target: link.target } + }) + if (new Set(links.map(link => link.name)).size !== links.length) throw new Error('desktop profile: duplicate managed link') + return { schemaVersion: 1, runtimeId: value.runtimeId, version: value.version, nodeVersion: value.nodeVersion, + platform: value.platform, arch: value.arch, lockHash: value.lockHash, links } +} + +/** + * Hash the plugin lockfile, including the empty-profile case. + * @param profile - Desktop profile directory. + * @returns Lockfile content identity. + */ +export function desktopPluginLockHash(profile: string): string { + const lock = join(profile, 'pnpm-lock.yaml') + return createHash('sha256').update(existsSync(lock) ? readFileSync(lock) : '').digest('hex') +} + +/** + * Remove only recorded host links, without following even broken targets. + * @param profile - Active or copied profile. + */ +export function unlinkDesktopHostPackages(profile: string): void { + for (const link of readDesktopProfileState(profile)?.links ?? []) { + const path = join(profile, 'node_modules', link.name) + const entry = stat(path) + if (entry === undefined) continue + if (!entry.isSymbolicLink() || resolve(dirname(path), readlinkSync(path)) !== resolve(link.target)) { + throw new Error(`desktop profile: refusing to replace unowned package ${link.name}`) + } + unlinkSync(path) + } +} + +/** + * Copy plugin files without copying host packages or sharing writable hardlinks. + * @param source - Active Desktop profile. + * @param target - Empty transaction profile directory. + */ +export function copyDesktopProfile(source: string, target: string): void { + const owned = readDesktopProfileState(source)?.links ?? [] + for (const link of owned) { + const path = join(source, 'node_modules', link.name) + const entry = stat(path) + if (entry !== undefined && (!entry.isSymbolicLink() || resolve(dirname(path), readlinkSync(path)) !== resolve(link.target))) { + throw new Error(`desktop profile: refusing to copy unowned package ${link.name}`) + } + } + const links = new Set(owned.map(link => join(source, 'node_modules', link.name))) + cpSync(source, target, { recursive: true, verbatimSymlinks: true, force: false, errorOnExist: true, + filter: path => !links.has(path) }) +} + +/** + * Bind an external profile to this application's real package directories. + * @param profile - Candidate profile. + * @param root - Current immutable runtime directory. + * @param runtime - Verified release descriptor. + */ +export function linkDesktopHostPackages(profile: string, root: string, runtime: DesktopRuntimeDescriptor): void { + unlinkDesktopHostPackages(profile) + const links = runtime.sharedPackages.map(entry => ({ name: entry.name, target: runtimePath(root, entry.path) })) + for (const link of links) { + const path = join(profile, 'node_modules', link.name) + if (stat(path) !== undefined) throw new Error(`desktop profile: plugin installed reserved host package ${link.name}`) + mkdirSync(dirname(path), { recursive: true }) + symlinkSync(link.target, path, process.platform === 'win32' ? 'junction' : 'dir') + } + const state: DesktopProfileState = { schemaVersion: 1, runtimeId: desktopRuntimeId(runtime), version: runtime.release.version, + nodeVersion: runtime.release.nodeVersion, platform: runtime.platform, arch: runtime.arch, + lockHash: desktopPluginLockHash(profile), links } + writeFileSync(join(profile, DESKTOP_PROFILE_STATE), `${JSON.stringify(state, undefined, 2)}\n`, { mode: 0o600 }) +} + +interface PackageManifest { + readonly name: string + readonly version: string + readonly dependencies: Readonly> + readonly optionalDependencies: Readonly> + readonly peerDependencies: Readonly> + readonly optionalPeers: ReadonlySet +} + +function manifest(path: string): PackageManifest { + const value: unknown = JSON.parse(readFileSync(join(path, 'package.json'), 'utf8')) + if (!record(value) || typeof value.name !== 'string' || typeof value.version !== 'string') { + throw new Error(`desktop profile: invalid package manifest ${path}`) + } + const dependencies = (key: string): Record => { + const field = value[key] + if (field === undefined) return {} + if (!record(field) || Object.entries(field).some(([name, spec]) => !PACKAGE_NAME.test(name) || typeof spec !== 'string')) { + throw new Error(`desktop profile: invalid ${key} in ${path}`) + } + return field as Record + } + const optionalPeers = new Set() + if (record(value.peerDependenciesMeta)) { + for (const [name, meta] of Object.entries(value.peerDependenciesMeta)) { + if (record(meta) && meta.optional === true) optionalPeers.add(name) + } + } + return { name: value.name, version: value.version, dependencies: dependencies('dependencies'), + optionalDependencies: dependencies('optionalDependencies'), peerDependencies: dependencies('peerDependencies'), optionalPeers } +} + +function packageFrom(anchor: string, name: string): string | undefined { + if (!PACKAGE_NAME.test(name)) throw new Error(`desktop profile: invalid package name ${name}`) + for (const modules of createRequire(join(anchor, 'package.json')).resolve.paths(name) ?? []) { + const path = join(modules, name) + if (existsSync(join(path, 'package.json'))) return realpathSync(path) + } + return undefined +} + +/** + * Prove active plugin dependencies stay local and share the host's exact package instances. + * @param profile - Profile with its generated host links present. + * @param root - Immutable runtime directory. + * @param runtime - Verified shared package inventory. + * @param activePlugins - Explicit enabled plugin roots whose peer compatibility is required. + */ +export function validateDesktopPluginGraph( + profile: string, root: string, runtime: DesktopRuntimeDescriptor, activePlugins: readonly string[], +): void { + const profileRoot = realpathSync(profile) + const shared = new Map(runtime.sharedPackages.map(entry => [entry.name, realpathSync(runtimePath(root, entry.path))])) + for (const [name, path] of shared) { + if (packageFrom(profile, name) !== path) throw new Error(`desktop profile: missing or incorrect host link ${name}`) + } + const scanned = new Set() + const scan = (modules: string): void => { + if (!existsSync(modules)) return + if (lstatSync(modules).isSymbolicLink()) throw new Error(`desktop profile: linked package container ${modules}`) + const directory = realpathSync(modules) + if (scanned.has(directory)) return + scanned.add(directory) + for (const entry of readdirSync(modules, { withFileTypes: true })) { + if (entry.name.startsWith('.')) continue + const path = join(modules, entry.name) + if (entry.name.startsWith('@')) { scan(path); continue } + if (!existsSync(join(path, 'package.json'))) throw new Error(`desktop profile: invalid installed package ${path}`) + const canonical = realpathSync(path) + const info = manifest(canonical) + const host = shared.get(info.name) + if (host !== undefined) { + if (canonical !== host || path !== join(profile, 'node_modules', info.name)) { + throw new Error(`desktop profile: duplicate or aliased host package ${info.name} at ${path}`) + } + continue + } + if (entry.isSymbolicLink()) throw new Error(`desktop profile: linked private package ${path}`) + if (!inside(profileRoot, canonical)) throw new Error(`desktop profile: package resolves outside profile: ${path}`) + scan(join(path, 'node_modules')) + } + } + scan(join(profile, 'node_modules')) + const visited = new Set() + const visit = (path: string, chain: string): void => { + if (visited.has(path)) return + visited.add(path) + const info = manifest(path) + const deps = { ...info.dependencies, ...info.optionalDependencies } + for (const [name, range] of Object.entries({ ...deps, ...info.peerDependencies })) { + const peer = name in info.peerDependencies + const optional = peer ? info.optionalPeers.has(name) : name in info.optionalDependencies + const target = packageFrom(path, name) + if (target === undefined && optional) continue + if (target === undefined) throw new Error(`desktop profile: ${chain} requires missing ${name}@${range}`) + const host = shared.get(name) + if (host !== undefined && name in deps) throw new Error(`desktop profile: ${chain} must declare ${name} as a peer dependency`) + if (host !== undefined ? target !== host : !inside(profileRoot, target)) { + throw new Error(`desktop profile: ${chain} resolves ${name} outside its owned packages`) + } + const dependency = manifest(target) + if (peer && !satisfies(dependency.version, range)) { + throw new Error(`desktop profile: ${chain} requires ${name}@${range}, found ${dependency.version}`) + } + if (host === undefined) visit(target, `${chain} -> ${name}`) + } + } + for (const name of activePlugins) { + const path = packageFrom(profile, name) + if (path === undefined || !inside(profileRoot, path)) throw new Error(`desktop profile: missing local plugin ${name}`) + visit(path, name) + } +} diff --git a/apps/desktop/src/project-manager.ts b/apps/desktop/src/project-manager.ts index b9b94f7ac8..89c9e86c73 100644 --- a/apps/desktop/src/project-manager.ts +++ b/apps/desktop/src/project-manager.ts @@ -1,11 +1,9 @@ /** Transactional owner of the reserved desktop profile and its private pnpm state. */ +import { valid } from 'semver' import { spawn } from 'node:child_process' -import { createHash, randomUUID } from 'node:crypto' +import { randomUUID } from 'node:crypto' import { - constants, - copyFileSync, - cpSync, existsSync, fsyncSync, ftruncateSync, @@ -13,7 +11,6 @@ import { mkdirSync, openSync, closeSync, - readdirSync, readFileSync, renameSync, rmSync, @@ -23,31 +20,23 @@ import { } from 'node:fs' import { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' import { - DESKTOP_PACKAGES_DIR, - DESKTOP_PACKAGE_SET_FILE, DESKTOP_HOST_PACKAGE, desktopCorePackageOverrides, - desktopDshPackageSpec, - readDesktopCorePackageSet, verifyDesktopCorePackageSet, } from './core-package-set.ts' import type { DesktopPaths } from './paths.ts' -import { parseDesktopRelease, type DesktopRelease } from './release.ts' -import { extractPnpmStoreArchives, mergePnpmStore } from './seed-store.ts' - -/** Files the package transaction copies between active and staging projects. */ -const DESKTOP_PROJECT_FILES = [ - 'package.json', - 'pnpm-lock.yaml', - 'pnpm-workspace.yaml', - 'desktop-release.json', - DESKTOP_PACKAGE_SET_FILE, -] as const +import type { DesktopRelease } from './release.ts' +import { desktopRuntimeId, verifyDesktopRuntime, type DesktopRuntimeDescriptor } from './runtime-tree.ts' +import { + copyDesktopProfile, desktopPluginLockHash, linkDesktopHostPackages, readDesktopProfileState, + unlinkDesktopHostPackages, validateDesktopPluginGraph, type DesktopProfileState, +} from './profile-packages.ts' /** Desktop plugin record derived from the installed profile. */ export interface DesktopPluginRecord { readonly name: string readonly version: string + readonly enabled: boolean } /** Installed desktop project manifest slice. */ @@ -68,6 +57,8 @@ interface DesktopPendingTransaction { readonly schemaVersion: 1 readonly id: string readonly stagingProfile: string + readonly fromRuntimeId: string | null + readonly toRuntimeId: string readonly step: 'prepared' | 'active-moved' | 'staging-activated' } @@ -75,6 +66,7 @@ interface DesktopPendingTransaction { export interface DesktopRuntimeExecutables { readonly node: string readonly pnpm: string + readonly dsh: string } /** Hooks that bind project replacement to backend lifecycle and health. */ @@ -92,12 +84,8 @@ export type DesktopProjectMutation = | { readonly type: 'plugin-add'; readonly spec: string } | { readonly type: 'plugin-remove'; readonly name: string } | { readonly type: 'plugin-update'; readonly name: string; readonly version: string } - -interface DesktopSeedIntegrityRecord { - readonly path: string - readonly bytes: number - readonly sha256: string -} + | { readonly type: 'plugin-toggle'; readonly name: string; readonly enabled: boolean } + | { readonly type: 'plugins-disable-all' } const PROJECT_NAME = '@deepseek-ai/dsh-desktop-runtime' const DSH_PACKAGE = '@deepseek-ai/dsh' @@ -133,10 +121,6 @@ function workspaceFile(overrides: Readonly> = {}): string return `packages:\n - .\n\n${overrideSection}${WORKSPACE_SETTINGS}allowBuilds:\n node-pty: true\n koffi: true\n fs-ext: true\n ${JSON.stringify(coreBuildKey)}: true\n '@google/genai': false\n protobufjs: false\n node-addon-require-builtin: false\n` } -function releaseFile(projectDir: string): DesktopRelease { - return parseDesktopRelease(readJson(join(projectDir, 'desktop-release.json'))) -} - function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null } @@ -190,83 +174,20 @@ function removeOwnedDirectory(path: string): void { rmSync(path, { recursive: true }) } -function copyMetadata(source: string, target: string): void { - mkdirSync(target, { recursive: true, mode: 0o700 }) - for (const filename of DESKTOP_PROJECT_FILES) { - const from = join(source, filename) - if (existsSync(from)) copyFileSync(from, join(target, filename), constants.COPYFILE_EXCL) - } - cpSync(join(source, DESKTOP_PACKAGES_DIR), join(target, DESKTOP_PACKAGES_DIR), { - recursive: true, - force: false, - errorOnExist: true, - }) -} - -function seedFiles(root: string): readonly DesktopSeedIntegrityRecord[] { - const files: DesktopSeedIntegrityRecord[] = [] - const visit = (directory: string): void => { - for (const entry of readdirSync(directory, { withFileTypes: true })) { - const path = join(directory, entry.name) - const relativePath = path.slice(root.length + 1).split(sep).join('/') - if (relativePath === 'integrity.json') continue - if (entry.isSymbolicLink()) throw new Error(`desktop seed: symbolic link is not allowed: ${relativePath}`) - if (entry.isDirectory()) { - visit(path) - continue - } - if (!entry.isFile()) throw new Error(`desktop seed: unsupported file type: ${relativePath}`) - const body = readFileSync(path) - files.push({ - path: relativePath, - bytes: body.byteLength, - sha256: createHash('sha256').update(body).digest('hex'), - }) - } - } - visit(root) - return files.sort((left, right) => left.path.localeCompare(right.path)) -} - -/** Verify the packaged offline seed before any content enters writable desktop state. */ -export function verifySeedIntegrity(seedDir: string): void { - const integrityPath = join(seedDir, 'integrity.json') - const integrity = readJson(integrityPath) - if (!isRecord(integrity) || integrity.schemaVersion !== 2 || !Array.isArray(integrity.files)) { - throw new Error(`desktop seed: invalid integrity inventory ${integrityPath}`) - } - const expected: DesktopSeedIntegrityRecord[] = integrity.files.map((record) => { - if (!isRecord(record) || typeof record.path !== 'string' || record.path === '' || record.path.startsWith('/') - || record.path.split('/').includes('..') || typeof record.bytes !== 'number' - || !Number.isSafeInteger(record.bytes) || record.bytes < 0 - || typeof record.sha256 !== 'string' || !/^[a-f0-9]{64}$/u.test(record.sha256)) { - throw new Error(`desktop seed: invalid integrity record in ${integrityPath}`) - } - return { path: record.path, bytes: record.bytes, sha256: record.sha256 } - }).sort((left, right) => left.path.localeCompare(right.path)) - const actual = seedFiles(seedDir) - if (JSON.stringify(actual) !== JSON.stringify(expected)) { - throw new Error('desktop seed: integrity verification failed') - } -} - function projectManifest(projectDir: string): DesktopProjectManifest { const path = join(projectDir, 'package.json') const value = readJson(path) const dsh = isRecord(value) && isRecord(value.dsh) ? value.dsh : undefined const profile = isRecord(dsh?.profile) ? dsh.profile : undefined if (!isRecord(value) || value.name !== PROJECT_NAME || value.private !== true - || typeof value.version !== 'string' || !isRecord(value.dependencies) + || typeof value.version !== 'string' || (value.dependencies !== undefined && !isRecord(value.dependencies)) || !Array.isArray(profile?.bundles) || !profile.bundles.every(bundle => typeof bundle === 'string')) { throw new Error(`desktop project: invalid desktop profile manifest ${path}`) } - const manifest = value as unknown as DesktopProjectManifest - const packageSet = readDesktopCorePackageSet(projectDir, releaseFile(projectDir).version) - const expectedOverrides = desktopCorePackageOverrides(packageSet) - if (manifest.dependencies[DSH_PACKAGE] !== desktopDshPackageSpec(packageSet) - || Object.entries(expectedOverrides).some(([name, spec]) => manifest.dependencies[name] !== spec) - || readFileSync(join(projectDir, 'pnpm-workspace.yaml'), 'utf8') !== workspaceFile(expectedOverrides)) { - throw new Error(`desktop project: core package mapping does not match ${DESKTOP_PACKAGE_SET_FILE}`) + const manifest = { ...value, dependencies: value.dependencies ?? {} } as unknown as DesktopProjectManifest + if (Object.entries(manifest.dependencies).some(([name, version]) => !PACKAGE_NAME_PATTERN.test(name) + || typeof version !== 'string' || valid(version) !== version)) { + throw new Error('desktop project: plugin dependencies must use exact registry versions') } return manifest } @@ -285,7 +206,7 @@ function profilePluginNames(projectDir: string): readonly string[] { } function pluginRecords(projectDir: string): readonly DesktopPluginRecord[] { - return profilePluginNames(projectDir).map(name => inspectPlugin(projectDir, name)) + return Object.keys(projectManifest(projectDir).dependencies).sort().map(name => inspectPlugin(projectDir, name)) } function writeProfilePlugins(projectDir: string, plugins: readonly DesktopPluginRecord[]): void { @@ -296,7 +217,7 @@ function writeProfilePlugins(projectDir: string, plugins: readonly DesktopPlugin ...manifest.dsh, profile: { ...manifest.dsh.profile, - bundles: [...DESKTOP_PROFILE_BUNDLES, ...plugins.map(plugin => plugin.name)], + bundles: [...DESKTOP_PROFILE_BUNDLES, ...plugins.filter(plugin => plugin.enabled).map(plugin => plugin.name)], }, }, } satisfies DesktopProjectManifest) @@ -322,12 +243,13 @@ function inspectPlugin(projectDir: string, requestedName: string): DesktopPlugin if ((patchPath !== packageDir && !patchPath.startsWith(packageDir + sep)) || !existsSync(patchPath)) { throw new Error(`desktop project: ${requestedName}@${manifest.version} declares an invalid bundle patch`) } - return { name: requestedName, version: manifest.version } + return { name: requestedName, version: manifest.version, enabled: profilePluginNames(projectDir).includes(requestedName) } } /** Transactional desktop npm project manager. */ export class DesktopProjectManager { private lockDescriptor: number | undefined + private descriptor: DesktopRuntimeDescriptor | undefined /** * @param paths - Electron-owned package state and reserved desktop profile paths. @@ -345,6 +267,9 @@ export class DesktopProjectManager { if (!isRecord(value) || value.schemaVersion !== 1 || typeof value.id !== 'string' || typeof value.stagingProfile !== 'string' || !isDescendant(this.paths.staging, value.stagingProfile) + || value.stagingProfile !== join(this.paths.staging, value.id, 'profile') + || (value.fromRuntimeId !== null && (typeof value.fromRuntimeId !== 'string' || !/^[a-f0-9]{64}$/u.test(value.fromRuntimeId))) + || typeof value.toRuntimeId !== 'string' || !/^[a-f0-9]{64}$/u.test(value.toRuntimeId) || (value.step !== 'prepared' && value.step !== 'active-moved' && value.step !== 'staging-activated')) { throw new Error(`desktop project: invalid activation journal ${this.paths.pending}`) } @@ -352,9 +277,20 @@ export class DesktopProjectManager { schemaVersion: 1, id: value.id, stagingProfile: value.stagingProfile, + fromRuntimeId: value.fromRuntimeId, + toRuntimeId: value.toRuntimeId, step: value.step, } - if (!existsSync(this.paths.profile) && existsSync(this.paths.rollback)) { + if (pending.step === 'staging-activated' && !existsSync(pending.stagingProfile) && existsSync(this.paths.profile)) { + if (readDesktopProfileState(this.paths.profile)?.runtimeId !== pending.toRuntimeId) { + throw new Error('desktop project: activated profile does not match its journal') + } + removeOwnedDirectory(this.paths.profile) + } + if (!existsSync(this.paths.profile) && pending.fromRuntimeId !== null && existsSync(this.paths.rollback)) { + if (readDesktopProfileState(this.paths.rollback)?.runtimeId !== pending.fromRuntimeId) { + throw new Error('desktop project: rollback profile does not match its journal') + } mkdirSync(dirname(this.paths.profile), { recursive: true }) renameSync(this.paths.rollback, this.paths.profile) } @@ -368,64 +304,59 @@ export class DesktopProjectManager { return pluginRecords(this.paths.profile) } - /** Read the exact dsh version installed in the active desktop project. */ + /** Read the dsh version supplied by this application's verified resources. */ dshVersion(): string { - if (!existsSync(this.paths.profile)) throw new Error('desktop project: active profile is not installed') - return this.installedPackageVersion(DSH_PACKAGE) + return this.currentRuntime().release.version } - private installedPackageVersion(packageName: string): string { - const manifestPath = join(this.paths.profile, 'node_modules', ...packageName.split('/'), 'package.json') - const manifest = readJson(manifestPath) - if (!isRecord(manifest) || typeof manifest.version !== 'string') { - throw new Error(`desktop project: installed ${packageName} package has no version`) - } - assertVersion(manifest.version) - return manifest.version - } - - /** Read the release version applied to the active desktop project. */ + /** Read the release most recently applied to the active profile. */ releaseVersion(): string { - if (!existsSync(this.paths.profile)) throw new Error('desktop project: active profile is not installed') - return releaseFile(this.paths.profile).version + const state = readDesktopProfileState(this.paths.profile) + if (state === undefined) throw new Error('desktop project: active profile has no runtime state') + return state.version } - /** Install or reconcile the active project to the Electron package's exact release. */ - async applyRelease(seedDir: string, electronVersion: string, hooks: DesktopProjectHooks): Promise { + /** Reject a profile whose dependency links were prepared for another runtime. */ + assertProfileRuntime(projectDir: string): void { + if (readDesktopProfileState(projectDir)?.runtimeId !== desktopRuntimeId(this.currentRuntime())) { + throw new Error('desktop project: profile does not match this application runtime') + } + } + + private currentRuntime(): DesktopRuntimeDescriptor { + if (this.descriptor === undefined) throw new Error('desktop project: runtime has not been verified') + return this.descriptor + } + + private prepareProfile(projectDir: string): void { + const runtime = this.currentRuntime() + linkDesktopHostPackages(projectDir, this.runtime.dsh, runtime) + validateDesktopPluginGraph(projectDir, this.runtime.dsh, runtime, profilePluginNames(projectDir)) + } + + /** Verify this release and reconcile its external profile without installing core packages. */ + async applyRelease(electronVersion: string, hooks: DesktopProjectHooks): Promise { return this.withLock(async () => { this.recover() - verifySeedIntegrity(seedDir) - const target = releaseFile(seedDir) - verifyDesktopCorePackageSet(seedDir, target.version) - if (target.version !== electronVersion) { - throw new Error(`desktop project: seed ${target.version} does not match Electron ${electronVersion}`) + const target = verifyDesktopRuntime(this.runtime.dsh, electronVersion) + this.descriptor = target + const previous = readDesktopProfileState(this.paths.profile) + if (existsSync(this.paths.profile) && previous === undefined) { + throw new Error('desktop project: existing profile is not a Desktop plugin profile') } - if (existsSync(this.paths.profile) && this.releaseVersion() === target.version - && this.dshVersion() === target.version - && this.installedPackageVersion(DESKTOP_HOST_PACKAGE) === target.version) { - verifyDesktopCorePackageSet(this.paths.profile, target.version) + if (previous?.runtimeId === desktopRuntimeId(target) + && previous.lockHash === desktopPluginLockHash(this.paths.profile) + && previous.links.length === target.sharedPackages.length + && previous.links.every(link => link.target === join(this.runtime.dsh, 'node_modules', link.name) + && existsSync(join(this.paths.profile, 'node_modules', link.name)))) { + validateDesktopPluginGraph(this.paths.profile, this.runtime.dsh, target, profilePluginNames(this.paths.profile)) return false } - this.mergeSeedPnpmState(seedDir) const stagingProfile = this.newStagingProfile() try { - if (existsSync(this.paths.profile)) { - const plugins = pluginRecords(this.paths.profile) - copyMetadata(seedDir, stagingProfile) - await this.runPnpm(stagingProfile, ['install', '--offline', '--frozen-lockfile', '--trust-lockfile']) - if (plugins.length > 0) { - await this.runPnpm(stagingProfile, [ - 'add', - ...plugins.map(plugin => `${plugin.name}@${plugin.version}`), - '--save-exact', - '--offline', - ]) - writeProfilePlugins(stagingProfile, plugins) - } - } else { - copyMetadata(seedDir, stagingProfile) - await this.runPnpm(stagingProfile, ['install', '--offline', '--frozen-lockfile', '--trust-lockfile']) - } + if (previous === undefined) createPluginProfile(stagingProfile) + else copyDesktopProfile(this.paths.profile, stagingProfile) + await this.reconcileProfile(stagingProfile, previous) await hooks.healthCheck(stagingProfile) await this.activate(stagingProfile, hooks) return true @@ -436,16 +367,18 @@ export class DesktopProjectManager { }) } - /** Apply one exact dependency mutation through a staging project. */ + /** Apply an exact plugin dependency or activation change through a staging project. */ async mutate(mutation: DesktopProjectMutation, hooks: DesktopProjectHooks): Promise { await this.withLock(async () => { this.recover() + this.currentRuntime() if (!existsSync(this.paths.profile)) throw new Error('desktop project: active profile is not installed') - verifyDesktopCorePackageSet(this.paths.profile, this.releaseVersion()) const stagingProfile = this.newStagingProfile() try { - copyMetadata(this.paths.profile, stagingProfile) + copyDesktopProfile(this.paths.profile, stagingProfile) await this.applyMutation(stagingProfile, mutation) + await this.reconcileProfile(stagingProfile, readDesktopProfileState(stagingProfile), + mutation.type !== 'plugin-toggle' && mutation.type !== 'plugins-disable-all') await hooks.healthCheck(stagingProfile) await this.activate(stagingProfile, hooks) } catch (error) { @@ -455,6 +388,25 @@ export class DesktopProjectManager { }) } + private async reconcileProfile(projectDir: string, previous: DesktopProfileState | undefined, packagesChanged = false): Promise { + const target = this.currentRuntime() + this.prepareProfile(projectDir) + const rebuild = previous !== undefined && pluginRecords(projectDir).length > 0 + && (previous.nodeVersion !== target.release.nodeVersion || previous.platform !== target.platform || previous.arch !== target.arch) + if (rebuild) { + unlinkDesktopHostPackages(projectDir) + removeOwnedDirectory(join(projectDir, 'node_modules')) + await this.runPnpm(projectDir, ['install', '--frozen-lockfile', '--ignore-scripts']) + } + if (rebuild || packagesChanged) await this.finishPackageOperation(projectDir) + } + + private async finishPackageOperation(projectDir: string): Promise { + this.prepareProfile(projectDir) + await this.runPnpm(projectDir, ['rebuild', '--pending']) + this.prepareProfile(projectDir) + } + private newStagingProfile(): string { const path = join(this.paths.staging, randomUUID(), 'profile') mkdirSync(path, { recursive: true, mode: 0o700 }) @@ -466,8 +418,11 @@ export class DesktopProjectManager { case 'plugin-add': { const requestedName = packageNameFromSpec(mutation.spec) if (requestedName === undefined) throw new Error('desktop project: plugin package name is required') - await this.runPnpm(projectDir, ['add', mutation.spec, '--save-exact']) - const installed = inspectPlugin(projectDir, requestedName) + if (this.currentRuntime().sharedPackages.some(entry => entry.name === requestedName)) { + throw new Error(`desktop project: cannot install host-owned package ${requestedName}`) + } + await this.runPnpm(projectDir, ['add', mutation.spec, '--save-exact', '--ignore-scripts']) + const installed = { ...inspectPlugin(projectDir, requestedName), enabled: true } const current = pluginRecords(projectDir).filter(plugin => plugin.name !== installed.name) writeProfilePlugins( projectDir, @@ -477,21 +432,21 @@ export class DesktopProjectManager { } case 'plugin-remove': { assertPackageName(mutation.name) - if (!profilePluginNames(projectDir).includes(mutation.name)) { + if (!Object.hasOwn(projectManifest(projectDir).dependencies, mutation.name)) { throw new Error(`desktop project: plugin ${JSON.stringify(mutation.name)} is not installed`) } const remaining = pluginRecords(projectDir).filter(plugin => plugin.name !== mutation.name) - await this.runPnpm(projectDir, ['remove', mutation.name]) + await this.runPnpm(projectDir, ['remove', mutation.name, '--config.ignore-scripts=true']) writeProfilePlugins(projectDir, remaining) return } case 'plugin-update': assertPackageName(mutation.name) assertVersion(mutation.version) - if (!profilePluginNames(projectDir).includes(mutation.name)) { + if (!Object.hasOwn(projectManifest(projectDir).dependencies, mutation.name)) { throw new Error(`desktop project: plugin ${JSON.stringify(mutation.name)} is not installed`) } - await this.runPnpm(projectDir, ['add', `${mutation.name}@${mutation.version}`, '--save-exact']) + await this.runPnpm(projectDir, ['add', `${mutation.name}@${mutation.version}`, '--save-exact', '--ignore-scripts']) { const installed = inspectPlugin(projectDir, mutation.name) writeProfilePlugins( @@ -500,33 +455,37 @@ export class DesktopProjectManager { ) } return + case 'plugins-disable-all': + writeProfilePlugins(projectDir, pluginRecords(projectDir).map(plugin => ({ ...plugin, enabled: false }))) + return + case 'plugin-toggle': { + assertPackageName(mutation.name) + const plugins = pluginRecords(projectDir) + if (!plugins.some(plugin => plugin.name === mutation.name)) throw new Error(`desktop project: plugin ${mutation.name} is not installed`) + writeProfilePlugins(projectDir, plugins.map(plugin => ( + plugin.name === mutation.name ? { ...plugin, enabled: mutation.enabled } : plugin + ))) + return + } default: mutation satisfies never } } - private mergeSeedPnpmState(seedDir: string): void { - const transactionRoot = join(this.paths.staging, randomUUID()) - const extractedStore = join(transactionRoot, 'store') - try { - extractPnpmStoreArchives(seedDir, extractedStore) - mergePnpmStore(extractedStore, this.paths.pnpm.store) - } finally { - removeOwnedDirectory(transactionRoot) - } - } - private async activate(stagingProfile: string, hooks: DesktopProjectHooks): Promise { const pending: DesktopPendingTransaction = { schemaVersion: 1, id: basename(dirname(stagingProfile)), stagingProfile, + fromRuntimeId: readDesktopProfileState(this.paths.profile)?.runtimeId ?? null, + toRuntimeId: desktopRuntimeId(this.currentRuntime()), step: 'prepared', } writeJson(this.paths.pending, pending) - await hooks.beforeActivate() let activeMoved = false + let stagingActivated = false try { + await hooks.beforeActivate() removeOwnedDirectory(this.paths.rollback) mkdirSync(dirname(this.paths.rollback), { recursive: true, mode: 0o700 }) writeJson(this.paths.pending, { ...pending, step: 'active-moved' } satisfies DesktopPendingTransaction) @@ -537,10 +496,11 @@ export class DesktopProjectManager { mkdirSync(dirname(this.paths.profile), { recursive: true, mode: 0o700 }) writeJson(this.paths.pending, { ...pending, step: 'staging-activated' } satisfies DesktopPendingTransaction) renameSync(stagingProfile, this.paths.profile) + stagingActivated = true await hooks.afterActivate() unlinkSync(this.paths.pending) } catch (error) { - if (existsSync(this.paths.profile)) removeOwnedDirectory(this.paths.profile) + if (stagingActivated) removeOwnedDirectory(this.paths.profile) if (activeMoved && existsSync(this.paths.rollback)) renameSync(this.paths.rollback, this.paths.profile) if (existsSync(this.paths.pending)) unlinkSync(this.paths.pending) await hooks.afterActivate().catch(() => undefined) @@ -558,7 +518,7 @@ export class DesktopProjectManager { const npmrc = join(this.paths.pnpm.config, 'npmrc') if (!existsSync(npmrc)) writeFileSync(npmrc, '', { mode: 0o600 }) const inherited = Object.fromEntries(Object.entries(process.env).filter(([name]) => ( - !/^DSH_DESKTOP_/u.test(name) && !/^(?:npm|pnpm|corepack)_/iu.test(name) + name !== 'NODE_OPTIONS' && name !== 'NODE_PATH' && !/^DSH_DESKTOP_/u.test(name) && !/^(?:npm|pnpm|corepack)_/iu.test(name) ))) await new Promise((settle, reject) => { const child = spawn(this.runtime.node, [ @@ -585,19 +545,7 @@ export class DesktopProjectManager { }, stdio: ['ignore', 'pipe', 'pipe'], }) - const childPid = child.pid - if (childPid === undefined) { - child.kill('SIGKILL') - reject(new Error('desktop project: pnpm did not report a process id')) - return - } - try { - this.writeLockOwner(childPid) - } catch (error) { - child.kill('SIGKILL') - reject(errorOf(error, 'desktop project: failed to assign the package transaction lock to pnpm')) - return - } + let failure: Error | undefined let diagnostics = '' let completed = false const appendDiagnostics = (chunk: string): void => { @@ -618,9 +566,10 @@ export class DesktopProjectManager { } settleChild() } - child.once('error', (error) => { complete(() => { reject(error) }) }) + child.once('error', (error) => { failure = error }) child.once('close', (code, signal) => { complete(() => { + if (failure !== undefined) { reject(failure); return } if (code === 0) { settle() return @@ -630,6 +579,13 @@ export class DesktopProjectManager { )) }) }) + try { + if (child.pid === undefined) throw new Error('desktop project: pnpm did not report a process id') + this.writeLockOwner(child.pid) + } catch (error) { + failure = errorOf(error, 'desktop project: failed to assign the package transaction lock to pnpm') + child.kill('SIGKILL') + } }) } @@ -682,10 +638,10 @@ export class DesktopProjectManager { } } -/** Create seed metadata for one exact Electron and dsh release. */ -export function createSeedMetadata(seedDir: string, release: DesktopRelease): void { - mkdirSync(seedDir, { recursive: true, mode: 0o700 }) - const packageSet = verifyDesktopCorePackageSet(seedDir, release.version) +/** Create build-only project metadata for materializing the signed runtime. */ +export function createRuntimeProjectMetadata(projectDir: string, release: DesktopRelease): void { + mkdirSync(projectDir, { recursive: true, mode: 0o700 }) + const packageSet = verifyDesktopCorePackageSet(projectDir, release.version) const manifest: DesktopProjectManifest = { name: PROJECT_NAME, private: true, @@ -693,13 +649,13 @@ export function createSeedMetadata(seedDir: string, release: DesktopRelease): vo dependencies: desktopCorePackageOverrides(packageSet), dsh: { profile: { bundles: [...DESKTOP_PROFILE_BUNDLES] } }, } - writeJson(join(seedDir, 'package.json'), manifest) + writeJson(join(projectDir, 'package.json'), manifest) writeFileSync( - join(seedDir, 'pnpm-workspace.yaml'), + join(projectDir, 'pnpm-workspace.yaml'), workspaceFile(desktopCorePackageOverrides(packageSet)), { mode: 0o600 }, ) - writeJson(join(seedDir, 'desktop-release.json'), release) + writeJson(join(projectDir, 'desktop-release.json'), release) } /** @@ -723,3 +679,13 @@ export function createDevelopmentProjectMetadata(projectDir: string, release: De writeFileSync(join(projectDir, 'pnpm-workspace.yaml'), workspaceFile(), { mode: 0o600 }) writeJson(join(projectDir, 'desktop-release.json'), release) } + +/** Create the first external plugin profile without running a package manager. */ +export function createPluginProfile(projectDir: string): void { + mkdirSync(projectDir, { recursive: true, mode: 0o700 }) + writeJson(join(projectDir, 'package.json'), { + name: PROJECT_NAME, private: true, version: '0.0.0', dependencies: {}, + dsh: { profile: { bundles: [...DESKTOP_PROFILE_BUNDLES] } }, + } satisfies DesktopProjectManifest) + writeFileSync(join(projectDir, 'pnpm-workspace.yaml'), workspaceFile(), { mode: 0o600 }) +} diff --git a/apps/desktop/src/release.ts b/apps/desktop/src/release.ts index f78d922e8c..88050120e5 100644 --- a/apps/desktop/src/release.ts +++ b/apps/desktop/src/release.ts @@ -1,9 +1,9 @@ -/** Immutable version identity shared by one Electron shell and its dsh seed. */ +/** Immutable version identity shared by one Electron shell and its bundled dsh runtime. */ import { valid } from 'semver' import { DESKTOP_HOST_PROTOCOL_VERSION } from './host-protocol.ts' -/** Release facts embedded in the seed and copied into the active desktop project. */ +/** Release facts embedded in the bundled runtime descriptor. */ export interface DesktopRelease { readonly schemaVersion: 1 /** Exact version used by both Electron and `@deepseek-ai/dsh`. */ diff --git a/apps/desktop/src/runtime-tree.ts b/apps/desktop/src/runtime-tree.ts new file mode 100644 index 0000000000..471d603964 --- /dev/null +++ b/apps/desktop/src/runtime-tree.ts @@ -0,0 +1,180 @@ +/** Relocatable, integrity-recorded production packages carried by one Desktop release. */ + +import { createHash } from 'node:crypto' +import { lstatSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' +import { isAbsolute, join, relative, sep } from 'node:path' +import { valid } from 'semver' +import { DESKTOP_HOST_PACKAGE, DESKTOP_HOST_RUNTIME_FILES } from './core-package-set.ts' +import { parseDesktopRelease, type DesktopRelease } from './release.ts' + +/** Descriptor at the root of the immutable Desktop resource tree. */ +export const DESKTOP_RUNTIME_FILE = 'desktop-runtime.json' + +/** Host-owned package available to external plugins through a directory link. */ +export interface DesktopSharedPackage { + readonly name: string + readonly version: string + readonly path: string +} + +/** Final bytes and executable permissions of a runtime file. */ +export interface DesktopRuntimeFile { + readonly path: string + readonly bytes: number + readonly sha256: string + readonly executable: boolean +} + +/** One signed application's production dependency tree. */ +export interface DesktopRuntimeDescriptor { + readonly schemaVersion: 1 + readonly release: DesktopRelease + readonly platform: NodeJS.Platform + readonly arch: string + readonly sharedPackages: readonly DesktopSharedPackage[] + readonly files: readonly DesktopRuntimeFile[] +} + +const PACKAGE_NAME = /^(?:@[a-z0-9][a-z0-9._~-]*\/[a-z0-9][a-z0-9._~-]*|[a-z0-9][a-z0-9._~-]*)$/u + +function record(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** + * Resolve one portable resource path without permitting traversal or absolute paths. + * @param root - Runtime root. + * @param path - Slash-separated relative path from durable metadata. + * @returns Absolute resource path. + */ +export function runtimePath(root: string, path: string): string { + if (path === '' || isAbsolute(path) || path.includes('\\') || path.includes(':') + || path.split('/').some(part => part === '' || part === '.' || part === '..')) { + throw new Error(`desktop runtime: invalid relative path ${JSON.stringify(path)}`) + } + return join(root, ...path.split('/')) +} + +/** + * Inventory a materialized runtime without following links or including its descriptor. + * @param root - Self-contained runtime directory. + * @returns Sorted final-file inventory. + */ +export function inventoryDesktopRuntime(root: string): DesktopRuntimeFile[] { + const files: DesktopRuntimeFile[] = [] + const visit = (directory: string): void => { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const path = join(directory, entry.name) + const name = relative(root, path).split(sep).join('/') + if (name === DESKTOP_RUNTIME_FILE) continue + if (entry.isDirectory()) visit(path) + else if (entry.isFile()) { + const body = readFileSync(path) + files.push({ path: name, bytes: body.byteLength, sha256: createHash('sha256').update(body).digest('hex'), + executable: (lstatSync(path).mode & 0o111) !== 0 }) + } else throw new Error(`desktop runtime: unsupported filesystem entry ${name}`) + } + } + visit(root) + return files.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0) +} + +/** + * Seal the final runtime tree after materialization and native signing. + * @param root - Runtime output directory. + * @param release - Matching shell, dsh, Host, and executable versions. + * @param sharedNames - Release-owned packages supplied to plugins. + * @param target - Platform and architecture selected by runtime preparation. + * @returns Descriptor written beside the production packages. + */ +export function writeDesktopRuntime( + root: string, release: DesktopRelease, sharedNames: readonly string[], + target: { platform: NodeJS.Platform; arch: string } = process, +): DesktopRuntimeDescriptor { + const sharedPackages = [...new Set(sharedNames)].sort().map((name) => { + if (!PACKAGE_NAME.test(name)) throw new Error(`desktop runtime: invalid shared package ${name}`) + const path = `node_modules/${name}` + const manifest = JSON.parse(readFileSync(join(runtimePath(root, path), 'package.json'), 'utf8')) as unknown + if (!record(manifest) || manifest.name !== name || typeof manifest.version !== 'string' || valid(manifest.version) === null) { + throw new Error(`desktop runtime: invalid shared package manifest ${name}`) + } + return { name, version: manifest.version, path } + }) + const descriptor: DesktopRuntimeDescriptor = { + schemaVersion: 1, release, platform: target.platform, arch: target.arch, + sharedPackages, files: inventoryDesktopRuntime(root), + } + writeFileSync(join(root, DESKTOP_RUNTIME_FILE), `${JSON.stringify(descriptor, undefined, 2)}\n`) + return descriptor +} + +/** + * Verify the runtime before linking any of its packages into writable state. + * @param root - Current application's runtime resources. + * @param electronVersion - Expected shell version. + * @param target - Required execution target; defaults to the current process. + * @returns Validated runtime descriptor. + */ +export function verifyDesktopRuntime( + root: string, electronVersion: string, target: { platform: NodeJS.Platform; arch: string } = process, +): DesktopRuntimeDescriptor { + const value: unknown = JSON.parse(readFileSync(join(root, DESKTOP_RUNTIME_FILE), 'utf8')) + if (!record(value) || value.schemaVersion !== 1 || value.platform !== target.platform || value.arch !== target.arch + || !Array.isArray(value.sharedPackages) || !Array.isArray(value.files)) { + throw new Error('desktop runtime: invalid descriptor or incompatible platform/architecture') + } + const release = parseDesktopRelease(value.release) + if (release.version !== electronVersion) throw new Error(`desktop runtime: ${release.version} does not match Electron ${electronVersion}`) + const sharedPackages = value.sharedPackages.map((entry: unknown): DesktopSharedPackage => { + if (!record(entry) || typeof entry.name !== 'string' || !PACKAGE_NAME.test(entry.name) + || typeof entry.version !== 'string' || valid(entry.version) === null || entry.path !== `node_modules/${entry.name}`) { + throw new Error('desktop runtime: invalid shared package record') + } + return { name: entry.name, version: entry.version, path: entry.path } + }) + if (new Set(sharedPackages.map(entry => entry.name)).size !== sharedPackages.length) { + throw new Error('desktop runtime: duplicate shared package') + } + const files = value.files.map((entry: unknown): DesktopRuntimeFile => { + if (!record(entry) || typeof entry.path !== 'string' || typeof entry.bytes !== 'number' + || !Number.isSafeInteger(entry.bytes) || entry.bytes < 0 || typeof entry.executable !== 'boolean' + || typeof entry.sha256 !== 'string' || !/^[a-f0-9]{64}$/u.test(entry.sha256)) { + throw new Error('desktop runtime: invalid file inventory') + } + runtimePath(root, entry.path) + return { path: entry.path, bytes: entry.bytes, sha256: entry.sha256, executable: entry.executable } + }).sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0) + const actual = inventoryDesktopRuntime(root) + // Windows has no portable Unix executable permission bits. + const comparable = (items: readonly DesktopRuntimeFile[]): unknown => process.platform === 'win32' + ? items.map(({ executable: _executable, ...item }) => item) : items + if (JSON.stringify(comparable(files)) !== JSON.stringify(comparable(actual))) { + throw new Error('desktop runtime: integrity verification failed') + } + for (const entry of sharedPackages) { + const manifest: unknown = JSON.parse(readFileSync(join(runtimePath(root, entry.path), 'package.json'), 'utf8')) + if (!record(manifest) || manifest.name !== entry.name || manifest.version !== entry.version) { + throw new Error(`desktop runtime: shared package metadata mismatch for ${entry.name}`) + } + } + for (const name of ['@deepseek-ai/dsh', DESKTOP_HOST_PACKAGE]) { + if (sharedPackages.find(entry => entry.name === name)?.version !== release.version) { + throw new Error(`desktop runtime: missing or mismatched ${name}`) + } + } + for (const file of DESKTOP_HOST_RUNTIME_FILES) { + if (!files.some(entry => entry.path === `node_modules/${DESKTOP_HOST_PACKAGE}/${file}`)) { + throw new Error(`desktop runtime: missing Host file ${file}`) + } + } + return { schemaVersion: 1, release, platform: target.platform, arch: target.arch, sharedPackages, files } +} + +/** + * Identify exact runtime content independently of its installation path. + * @param descriptor - Validated runtime metadata. + * @returns SHA-256 runtime identity. + */ +export function desktopRuntimeId(descriptor: DesktopRuntimeDescriptor): string { + return createHash('sha256').update(JSON.stringify(descriptor)).digest('hex') +} diff --git a/apps/desktop/src/seed-store.ts b/apps/desktop/src/seed-store.ts deleted file mode 100644 index e4b4759fd1..0000000000 --- a/apps/desktop/src/seed-store.ts +++ /dev/null @@ -1,271 +0,0 @@ -/** Deterministic archive transport for the desktop seed's pnpm store. */ - -import { createHash } from 'node:crypto' -import { - chmodSync, - copyFileSync, - cpSync, - existsSync, - mkdirSync, - readdirSync, - readFileSync, - rmSync, - writeFileSync, -} from 'node:fs' -import { join, relative, sep } from 'node:path' -import { DatabaseSync } from 'node:sqlite' -import { create, extract, list } from 'tar' - -/** Directory containing the seed's uncompressed pnpm store archives. */ -export const SEED_STORE_ARCHIVE_DIR = 'store-archives' - -/** Manifest describing the deterministic pnpm store archive set. */ -export const SEED_STORE_ARCHIVE_MANIFEST = 'store-archives.json' - -const DEFAULT_SHARD_COUNT = 16 -const ARCHIVE_NAME_PATTERN = /^store-[0-9a-f]{2}\.tar$/u -const STORE_VERSION_PATTERN = /^v\d+$/u - -interface SeedStoreArchiveRecord { - readonly file: string - readonly entries: number -} - -interface SeedStoreArchiveManifest { - readonly schemaVersion: 1 - readonly shardCount: number - readonly archives: readonly SeedStoreArchiveRecord[] -} - -function storeFiles(storeRoot: string): readonly string[] { - const files: string[] = [] - const visit = (directory: string): void => { - for (const entry of readdirSync(directory, { withFileTypes: true })) { - const path = join(directory, entry.name) - if (entry.isSymbolicLink()) { - throw new Error(`desktop seed: pnpm store contains a symbolic link: ${relative(storeRoot, path)}`) - } - if (entry.isDirectory()) { - visit(path) - continue - } - if (!entry.isFile()) { - throw new Error(`desktop seed: pnpm store contains an unsupported file: ${relative(storeRoot, path)}`) - } - files.push(relative(storeRoot, path).split(sep).join('/')) - } - } - visit(storeRoot) - return files.sort((left, right) => left.localeCompare(right)) -} - -function shardFor(path: string, shardCount: number): number { - return createHash('sha256').update(path).digest().readUInt32BE(0) % shardCount -} - -function readArchiveManifest(seedRoot: string): SeedStoreArchiveManifest { - const path = join(seedRoot, SEED_STORE_ARCHIVE_MANIFEST) - const value = JSON.parse(readFileSync(path, 'utf8')) as unknown - if (typeof value !== 'object' || value === null) { - throw new Error(`desktop seed: invalid pnpm store archive manifest ${path}`) - } - const candidate = value as Record - if (candidate.schemaVersion !== 1 || !Number.isSafeInteger(candidate.shardCount) - || (candidate.shardCount as number) < 1 || (candidate.shardCount as number) > 256 - || !Array.isArray(candidate.archives) || candidate.archives.length === 0) { - throw new Error(`desktop seed: invalid pnpm store archive manifest ${path}`) - } - const names = new Set() - const archives = candidate.archives.map((entry): SeedStoreArchiveRecord => { - if (typeof entry !== 'object' || entry === null) { - throw new Error(`desktop seed: invalid pnpm store archive record in ${path}`) - } - const record = entry as Record - if (typeof record.file !== 'string' || !ARCHIVE_NAME_PATTERN.test(record.file) - || names.has(record.file) || !Number.isSafeInteger(record.entries) || (record.entries as number) < 1) { - throw new Error(`desktop seed: invalid pnpm store archive record in ${path}`) - } - const shard = Number.parseInt(record.file.slice('store-'.length, -'.tar'.length), 16) - if (shard >= (candidate.shardCount as number)) { - throw new Error(`desktop seed: pnpm store archive shard is outside the manifest range in ${path}`) - } - names.add(record.file) - return { file: record.file, entries: record.entries as number } - }) - return { - schemaVersion: 1, - shardCount: candidate.shardCount as number, - archives, - } -} - -function assertArchivePath(path: string): void { - if (path === '' || path.startsWith('/') || path.includes('\\') || path.includes('\0') - || path.split('/').some(part => part === '' || part === '.' || part === '..')) { - throw new Error(`desktop seed: unsafe pnpm store archive path ${JSON.stringify(path)}`) - } -} - -/** - * Remove pnpm's registrations for projects that populated the seed store. - * @param storeRoot - pnpm store directory included in the desktop seed. - */ -export function removePnpmProjectRegistrations(storeRoot: string): void { - for (const entry of readdirSync(storeRoot, { withFileTypes: true })) { - if (!entry.isDirectory() || !/^v\d+$/u.test(entry.name)) continue - rmSync(join(storeRoot, entry.name, 'projects'), { recursive: true, force: true }) - } -} - -function mergeStoreIndex(source: string, destination: string): void { - if (!existsSync(destination)) { - copyFileSync(source, destination) - return - } - const database = new DatabaseSync(destination) - let attached = false - try { - database.exec('PRAGMA busy_timeout=5000') - database.prepare('ATTACH DATABASE ? AS seed').run(source) - attached = true - database.exec('BEGIN IMMEDIATE') - let committed = false - try { - database.exec('INSERT OR REPLACE INTO package_index (key, data) SELECT key, data FROM seed.package_index') - database.exec('COMMIT') - committed = true - } finally { - if (!committed) database.exec('ROLLBACK') - } - } finally { - if (attached) database.exec('DETACH DATABASE seed') - database.close() - } -} - -/** - * Merge a completely extracted seed store into Desktop's persistent pnpm store. - * @param source - Verified temporary store extraction. - * @param destination - Desktop-owned persistent pnpm store. - */ -export function mergePnpmStore(source: string, destination: string): void { - mkdirSync(destination, { recursive: true, mode: 0o700 }) - const indexPaths = readdirSync(source, { withFileTypes: true }) - .filter(entry => entry.isDirectory() && STORE_VERSION_PATTERN.test(entry.name) - && existsSync(join(source, entry.name, 'index.db'))) - .map(entry => `${entry.name}/index.db`) - const indexes = new Set(indexPaths) - cpSync(source, destination, { - recursive: true, - force: true, - filter: path => !indexes.has(relative(source, path).split(sep).join('/')), - }) - for (const path of indexPaths) { - mergeStoreIndex(join(source, ...path.split('/')), join(destination, ...path.split('/'))) - } -} - -/** - * Replace a prepared loose pnpm store with deterministic uncompressed archive shards. - * @param seedRoot - seed directory that owns the archive output. - * @param storeRoot - populated pnpm store to archive and remove after success. - * @param shardCount - stable shard count used to limit update churn. - */ -export function archivePnpmStore( - seedRoot: string, - storeRoot: string, - shardCount = DEFAULT_SHARD_COUNT, -): void { - if (!Number.isSafeInteger(shardCount) || shardCount < 1 || shardCount > 256) { - throw new Error(`desktop seed: invalid pnpm store shard count ${shardCount}`) - } - const archiveRoot = join(seedRoot, SEED_STORE_ARCHIVE_DIR) - const manifestPath = join(seedRoot, SEED_STORE_ARCHIVE_MANIFEST) - rmSync(archiveRoot, { recursive: true, force: true }) - rmSync(manifestPath, { force: true }) - mkdirSync(archiveRoot, { recursive: true }) - const shards = Array.from({ length: shardCount }, (): string[] => []) - for (const path of storeFiles(storeRoot)) (shards[shardFor(path, shardCount)] as string[]).push(path) - const archives: SeedStoreArchiveRecord[] = [] - for (const [index, paths] of shards.entries()) { - if (paths.length === 0) continue - const file = `store-${index.toString(16).padStart(2, '0')}.tar` - create({ - cwd: storeRoot, - file: join(archiveRoot, file), - noDirRecurse: true, - noMtime: true, - portable: true, - sync: true, - }, paths) - chmodSync(join(archiveRoot, file), 0o644) - archives.push({ file, entries: paths.length }) - } - if (archives.length === 0) throw new Error('desktop seed: pnpm store is empty') - writeFileSync(manifestPath, `${JSON.stringify({ schemaVersion: 1, shardCount, archives }, undefined, 2)}\n`) - rmSync(storeRoot, { recursive: true }) -} - -/** - * Validate and extract a packaged pnpm store archive set into an empty directory. - * @param seedRoot - verified packaged seed directory. - * @param destination - empty Desktop-owned temporary extraction directory. - */ -export function extractPnpmStoreArchives(seedRoot: string, destination: string): void { - const manifest = readArchiveManifest(seedRoot) - const archiveRoot = join(seedRoot, SEED_STORE_ARCHIVE_DIR) - const actualFiles = readdirSync(archiveRoot, { withFileTypes: true }).map((entry) => { - if (!entry.isFile() || entry.isSymbolicLink()) { - throw new Error(`desktop seed: invalid pnpm store archive entry ${entry.name}`) - } - return entry.name - }).sort() - const expectedFiles = manifest.archives.map(archive => archive.file).sort() - if (JSON.stringify(actualFiles) !== JSON.stringify(expectedFiles)) { - throw new Error('desktop seed: pnpm store archive set does not match its manifest') - } - if (existsSync(destination) && readdirSync(destination).length !== 0) { - throw new Error(`desktop seed: pnpm store extraction directory is not empty: ${destination}`) - } - mkdirSync(destination, { recursive: true, mode: 0o700 }) - const paths = new Set() - for (const archive of manifest.archives) { - const archivePath = join(archiveRoot, archive.file) - const archiveShard = Number.parseInt(archive.file.slice('store-'.length, -'.tar'.length), 16) - let entries = 0 - list({ - file: archivePath, - onReadEntry: (entry) => { - if (entry.type !== 'File' && entry.type !== 'OldFile') { - throw new Error(`desktop seed: unsupported pnpm store archive entry type ${entry.type}`) - } - assertArchivePath(entry.path) - if (shardFor(entry.path, manifest.shardCount) !== archiveShard) { - throw new Error(`desktop seed: pnpm store path is assigned to the wrong archive shard: ${entry.path}`) - } - if (paths.has(entry.path)) { - throw new Error(`desktop seed: duplicate pnpm store archive path ${entry.path}`) - } - paths.add(entry.path) - entries += 1 - }, - strict: true, - sync: true, - }) - if (entries !== archive.entries) { - throw new Error(`desktop seed: pnpm store archive ${archive.file} has an unexpected entry count`) - } - } - for (const archive of manifest.archives) { - extract({ - chmod: true, - cwd: destination, - file: join(archiveRoot, archive.file), - noMtime: true, - preservePaths: false, - processUmask: 0, - strict: true, - sync: true, - }) - } -} diff --git a/apps/desktop/src/update-coordinator.ts b/apps/desktop/src/update-coordinator.ts index c31dcc3093..be629228d8 100644 --- a/apps/desktop/src/update-coordinator.ts +++ b/apps/desktop/src/update-coordinator.ts @@ -1,4 +1,4 @@ -/** One Electron release stream for the version-bound shell and dsh seed. */ +/** One Electron release stream for the version-bound shell and bundled dsh runtime. */ import { existsSync } from 'node:fs' import { join } from 'node:path' diff --git a/apps/desktop/tests/desktop-build-paths.spec.ts b/apps/desktop/tests/desktop-build-paths.spec.ts index ac1618baef..1a309a2851 100644 --- a/apps/desktop/tests/desktop-build-paths.spec.ts +++ b/apps/desktop/tests/desktop-build-paths.spec.ts @@ -15,8 +15,8 @@ describe('desktop build paths', () => { 'artifacts', 'runtime', 'packageSet', - 'seed', - 'seedPnpm', + 'dsh', + 'dshPnpm', 'nodeExtract', 'packedDsh', 'packedVendor', @@ -27,7 +27,7 @@ describe('desktop build paths', () => { expect(new Set([arm64[key], x64[key], windows[key]]).size).toBe(3) } expect(arm64.artifacts).toContain(join('targets', 'mac-arm64', 'artifacts')) - expect(x64.seed).toContain(join('targets', 'mac-x64', 'seed')) + expect(x64.dsh).toContain(join('targets', 'mac-x64', 'dsh')) expect(windows.runtime).toContain(join('targets', 'win-x64', 'runtime')) }) diff --git a/apps/desktop/tests/host-process.spec.ts b/apps/desktop/tests/host-process.spec.ts index d3a08d78e0..9a9a2d4730 100644 --- a/apps/desktop/tests/host-process.spec.ts +++ b/apps/desktop/tests/host-process.spec.ts @@ -1,4 +1,4 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' @@ -75,6 +75,27 @@ afterEach(() => { }) describe('desktop host process', () => { + it('loads the resource entry with a separate profile and scrubs Node resolution overrides', async () => { + const runtime = projectWithHost(` +process.send({ type: 'ready', protocolVersion: 3, dshVersion: 'split-runtime' }) +function onRequestFrame(frame) { + if (frame.type !== 1) return + responseStart(frame.streamId) + responseData(frame.streamId, JSON.stringify({runtime: process.argv[2], profile: process.argv[3], cwd: process.cwd(), nodePath: process.env.NODE_PATH})) + responseEnd(frame.streamId) +} +`) + const profile = mkdtempSync(join(tmpdir(), 'desktop-external-profile-')) + roots.push(profile) + const host = new DesktopHostProcess(process.execPath, runtime, profile, undefined, { + ...process.env, NODE_OPTIONS: '--invalid-desktop-test-option', NODE_PATH: '/unowned', + }) + try { + const response = await host.fetch(new Request('dsh-app://app/environment')) + expect(await response.json()).toEqual({ runtime, profile, cwd: realpathSync(profile) }) + } finally { await host.stop() } + }) + it('carries raw request and response bytes and shuts the child down cleanly', async () => { const project = projectWithHost(` const bodies = new Map() @@ -98,7 +119,7 @@ function answer(streamId) { `) const previous = process.env.NODE_OPTIONS process.env.NODE_OPTIONS = '--require /path/that-must-not-reach-the-child' - const host = new DesktopHostProcess(process.execPath, project) + const host = new DesktopHostProcess(process.execPath, project, project) try { await expect(host.start()).resolves.toMatchObject({ dshVersion: 'clean' }) const response = await host.fetch(new Request('dsh-app://app/example', { method: 'POST', body: 'request' })) @@ -124,7 +145,7 @@ function onRequestFrame(frame) { responseEnd(frame.streamId) } `) - const host = new DesktopHostProcess(process.execPath, project) + const host = new DesktopHostProcess(process.execPath, project, project) try { const response = await host.fetch(new Request('dsh-app://app/large')) const body = new Uint8Array(await response.arrayBuffer()) @@ -151,7 +172,7 @@ function onRequestFrame(frame) { start(controller) { controller.enqueue(Buffer.from('first')) }, cancel() { canceled = true }, }) - const host = new DesktopHostProcess(process.execPath, project) + const host = new DesktopHostProcess(process.execPath, project, project) try { const request = new Request('dsh-app://app/early', { method: 'POST', @@ -184,7 +205,7 @@ function onRequestFrame(frame) { } } `) - const host = new DesktopHostProcess(process.execPath, project) + const host = new DesktopHostProcess(process.execPath, project, project) try { const canceled = await host.fetch(new Request('dsh-app://app/cancel')) await canceled.body?.cancel() @@ -202,7 +223,7 @@ process.send({ type: 'ready', protocolVersion: 3, dshVersion: 'invalid-frame' }) function onRequestFrame(frame) { if (frame.type === 1) responsePipe.write(Buffer.alloc(13)) } -`)) +`), projectWithHost('')) await invalid.start() await expect(invalid.fetch(new Request('dsh-app://app/invalid'))).rejects.toThrow(/invalid Host response frame marker/u) await invalid.stop().catch(() => undefined) @@ -210,7 +231,7 @@ function onRequestFrame(frame) { const earlyExit = new DesktopHostProcess(process.execPath, projectWithHost(` function onRequestFrame() {} process.exit(0) -`)) +`), projectWithHost('')) await expect(earlyExit.start()).rejects.toThrow(/response pipe ended/u) }) }) diff --git a/apps/desktop/tests/macos-runtime.spec.ts b/apps/desktop/tests/macos-runtime.spec.ts new file mode 100644 index 0000000000..c33b8d8cfb --- /dev/null +++ b/apps/desktop/tests/macos-runtime.spec.ts @@ -0,0 +1,48 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { signMacOSRuntime } from '../scripts/macos-runtime.ts' +import { signMacOSRuntimeCode, verifyMacOSRuntimeCode } from '../scripts/verify-macos-signature.mjs' + +vi.mock('../scripts/verify-macos-signature.mjs', () => ({ signMacOSRuntimeCode: vi.fn(), verifyMacOSRuntimeCode: vi.fn() })) +const roots: string[] = [] +function root(): string { + const path = mkdtempSync(join(tmpdir(), 'desktop-signing-')) + roots.push(path) + return path +} +const identity = { signingIdentity: 'Example (TEAMID1234)', teamId: 'TEAMID1234' } +afterEach(() => { + vi.resetAllMocks() + for (const path of roots.splice(0)) rmSync(path, { recursive: true, force: true }) +}) +it('signs Mach-O files in their final locations and verifies each signature', async () => { + const path = root() + writeFileSync(join(path, 'addon.node'), Buffer.from('cffaedfe00000000', 'hex')) + writeFileSync(join(path, 'source.js'), 'export {}') + await expect(signMacOSRuntime(path, 'com.example.app', identity)).resolves.toBe(1) + expect(signMacOSRuntimeCode).toHaveBeenCalledWith(join(path, 'addon.node'), expect.stringMatching(/^com\.example\.app\.runtime\.[a-f0-9]{64}$/u), identity) + expect(verifyMacOSRuntimeCode).toHaveBeenCalledWith(join(path, 'addon.node'), identity) +}) +it('awaits other signers before rejecting and permitting output cleanup', async () => { + const path = root() + for (const name of ['a.node', 'b.node']) writeFileSync(join(path, name), Buffer.from('cffaedfe00000000', 'hex')) + let release!: () => void + const barrier = new Promise((resolve) => { release = resolve }) + let started!: () => void + const ready = new Promise((resolve) => { started = resolve }) + vi.mocked(signMacOSRuntimeCode).mockImplementation(async (file) => { + if (file.endsWith('a.node')) throw new Error('sign failure') + started() + await barrier + }) + let completed = false + const result = signMacOSRuntime(path, 'com.example.app', identity).catch((error: unknown) => { completed = true; return error }) + try { + await ready + expect(completed).toBe(false) + } finally { release() } + expect(await result).toBeInstanceOf(AggregateError) + expect(verifyMacOSRuntimeCode).toHaveBeenCalledWith(join(path, 'b.node'), identity) +}) diff --git a/apps/desktop/tests/macos-seed-store.spec.ts b/apps/desktop/tests/macos-seed-store.spec.ts deleted file mode 100644 index 2155389262..0000000000 --- a/apps/desktop/tests/macos-seed-store.spec.ts +++ /dev/null @@ -1,245 +0,0 @@ -import { createHash } from 'node:crypto' -import { - appendFileSync, - existsSync, - mkdirSync, - mkdtempSync, - readFileSync, - rmSync, - writeFileSync, -} from 'node:fs' -import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' -import { DatabaseSync } from 'node:sqlite' -import { Packr } from 'msgpackr' -import { afterEach, describe, expect, it } from 'vitest' -import { - signMacOSSeedStore, - verifyMacOSSeedStore, -} from '../scripts/macos-seed-store.ts' - -const temporaryRoots: string[] = [] -const packr = new Packr({ moreTypes: true, useRecords: true }) -const SIGNING_ENVIRONMENT = { - signingIdentity: 'Example Company (TEAMID1234)', - teamId: 'TEAMID1234', -} - -function temporaryRoot(): string { - const root = mkdtempSync(join(tmpdir(), 'dsh-desktop-seed-signing-test-')) - temporaryRoots.push(root) - return root -} - -function casPath(store: string, body: Buffer, executable = false): { digest: string; path: string } { - const digest = createHash('sha512').update(body).digest('hex') - return { - digest, - path: join(store, 'v11', 'files', digest.slice(0, 2), `${digest.slice(2)}${executable ? '-exec' : ''}`), - } -} - -function createStoreFile(path: string, body: Buffer): void { - mkdirSync(dirname(path), { recursive: true }) - writeFileSync(path, body) -} - -afterEach(() => { - for (const root of temporaryRoots.splice(0)) rmSync(root, { recursive: true, force: true }) -}) - -describe('desktop macOS seed store signing', () => { - it('rehashes signed Mach-O content, rewrites every package reference, and prunes native orphans', async () => { - const store = temporaryRoot() - const native = Buffer.concat([Buffer.from('cffaedfe', 'hex'), Buffer.from('native-code')]) - const nativeCas = casPath(store, native) - createStoreFile(nativeCas.path, native) - const orphan = Buffer.concat([Buffer.from('cafebabe', 'hex'), Buffer.from('orphan')]) - const orphanCas = casPath(store, orphan) - createStoreFile(orphanCas.path, orphan) - const plain = Buffer.from('plain package content') - const plainCas = casPath(store, plain) - createStoreFile(plainCas.path, plain) - - const database = new DatabaseSync(join(store, 'v11', 'index.db')) - database.exec('CREATE TABLE package_index (key TEXT PRIMARY KEY, data BLOB NOT NULL) WITHOUT ROWID') - const insert = database.prepare('INSERT INTO package_index (key, data) VALUES (?, ?)') - for (const key of ['package-a', 'package-b']) { - insert.run(key, packr.pack({ - algo: 'sha512', - files: new Map([ - ['native.node', { checkedAt: 1, digest: nativeCas.digest, mode: 0o644, size: native.length }], - ['index.js', { checkedAt: 1, digest: plainCas.digest, mode: 0o644, size: plain.length }], - ]), - sideEffects: key === 'package-b' - ? new Map([['build', { added: new Map([ - ['built/native.node', { checkedAt: 1, digest: nativeCas.digest, mode: 0o644, size: native.length }], - ]) }]]) - : undefined, - })) - } - database.close() - - const result = await signMacOSSeedStore( - store, - 'com.example.desktop', - SIGNING_ENVIRONMENT, - { - signer: async (path, identifier) => { - expect(identifier).toBe(`com.example.desktop.seed.${nativeCas.digest.slice(0, 32)}`) - appendFileSync(path, 'signed') - }, - }, - ) - - expect(result).toEqual({ signedFiles: 1, prunedOrphans: 1, updatedIndexRows: 2 }) - expect(existsSync(nativeCas.path)).toBe(false) - expect(existsSync(orphanCas.path)).toBe(false) - expect(readFileSync(plainCas.path)).toEqual(plain) - - const updated = new DatabaseSync(join(store, 'v11', 'index.db'), { readOnly: true }) - const digests = [...updated.prepare('SELECT data FROM package_index').iterate() as Iterable<{ data: Uint8Array }>] - .flatMap((row) => { - const record = packr.unpack(row.data) as { - files: Map - sideEffects?: Map }> - } - return [ - record.files.get('native.node')?.digest, - ...[...(record.sideEffects?.values() ?? [])].map(effect => effect.added.get('built/native.node')?.digest), - ].filter((digest): digest is string => digest !== undefined) - }) - updated.close() - expect(new Set(digests).size).toBe(1) - expect(digests).toHaveLength(3) - expect(digests[0]).not.toBe(nativeCas.digest) - - const verified: string[] = [] - expect(verifyMacOSSeedStore(store, SIGNING_ENVIRONMENT, (path) => { verified.push(path) })).toBe(1) - expect(verified).toHaveLength(1) - }) - - it('bounds concurrent signing while allowing independent Mach-O files to overlap', async () => { - const store = temporaryRoot() - const files = new Map() - for (let index = 0; index < 6; index += 1) { - const body = Buffer.concat([Buffer.from('feedfacf', 'hex'), Buffer.from(`native-${index}`)]) - const nativeCas = casPath(store, body) - createStoreFile(nativeCas.path, body) - files.set(`native-${index}.node`, { - checkedAt: 1, - digest: nativeCas.digest, - mode: 0o644, - size: body.length, - }) - } - const database = new DatabaseSync(join(store, 'v11', 'index.db')) - database.exec('CREATE TABLE package_index (key TEXT PRIMARY KEY, data BLOB NOT NULL) WITHOUT ROWID') - database.prepare('INSERT INTO package_index (key, data) VALUES (?, ?)') - .run('package', packr.pack({ algo: 'sha512', files })) - database.close() - - let started = 0 - let active = 0 - let maximumActive = 0 - let releaseSigning = (): void => {} - const signingReleased = new Promise((resolve) => { releaseSigning = resolve }) - let markFirstWaveReady = (): void => {} - const firstWaveReady = new Promise((resolve) => { markFirstWaveReady = resolve }) - const signing = signMacOSSeedStore(store, 'com.example.desktop', SIGNING_ENVIRONMENT, { - concurrency: 4, - signer: async () => { - started += 1 - active += 1 - maximumActive = Math.max(maximumActive, active) - if (started === 4) markFirstWaveReady() - try { - await signingReleased - } finally { - active -= 1 - } - }, - }) - - await firstWaveReady - expect({ started, active, maximumActive }).toEqual({ started: 4, active: 4, maximumActive: 4 }) - releaseSigning() - await expect(signing).resolves.toMatchObject({ signedFiles: 6, updatedIndexRows: 1 }) - expect({ started, active, maximumActive }).toEqual({ started: 6, active: 0, maximumActive: 4 }) - }) - - it('awaits active signers and preserves the store when one signer fails', async () => { - const store = temporaryRoot() - const originals: { digest: string; path: string }[] = [] - const files = new Map() - for (let index = 0; index < 2; index += 1) { - const body = Buffer.concat([Buffer.from('feedfacf', 'hex'), Buffer.from(`native-${index}`)]) - const nativeCas = casPath(store, body) - originals.push(nativeCas) - createStoreFile(nativeCas.path, body) - files.set(`native-${index}.node`, { - checkedAt: 1, - digest: nativeCas.digest, - mode: 0o644, - size: body.length, - }) - } - const databasePath = join(store, 'v11', 'index.db') - const database = new DatabaseSync(databasePath) - database.exec('CREATE TABLE package_index (key TEXT PRIMARY KEY, data BLOB NOT NULL) WITHOUT ROWID') - database.prepare('INSERT INTO package_index (key, data) VALUES (?, ?)') - .run('package', packr.pack({ algo: 'sha512', files })) - database.close() - const originalIndex = readFileSync(databasePath) - - let started = 0 - let settled = 0 - let releaseSigning = (): void => {} - const signingReleased = new Promise((resolve) => { releaseSigning = resolve }) - let markBothReady = (): void => {} - const bothReady = new Promise((resolve) => { markBothReady = resolve }) - const signing = signMacOSSeedStore(store, 'com.example.desktop', SIGNING_ENVIRONMENT, { - concurrency: 2, - signer: async () => { - started += 1 - const call = started - if (started === 2) markBothReady() - try { - await signingReleased - if (call === 1) throw new Error('signing failed') - } finally { - settled += 1 - } - }, - }) - - await bothReady - releaseSigning() - await expect(signing).rejects.toThrow(/signing failed/u) - expect({ started, settled }).toEqual({ started: 2, settled: 2 }) - expect(readFileSync(databasePath)).toEqual(originalIndex) - for (const original of originals) expect(existsSync(original.path)).toBe(true) - }) - - it('rejects an invalid signing worker bound before starting a signer', async () => { - const store = temporaryRoot() - mkdirSync(join(store, 'v11', 'files'), { recursive: true }) - await expect(signMacOSSeedStore(store, 'com.example.desktop', SIGNING_ENVIRONMENT, { - concurrency: 0, - signer: async () => {}, - })).rejects.toThrow(/positive integer/u) - }) - - it('propagates a signature-verification failure', () => { - const store = temporaryRoot() - const native = Buffer.concat([Buffer.from('feedfacf', 'hex'), Buffer.from('native-code')]) - const nativeCas = casPath(store, native) - createStoreFile(nativeCas.path, native) - - expect(() => { - verifyMacOSSeedStore(store, SIGNING_ENVIRONMENT, () => { - throw new Error('invalid signature') - }) - }).toThrow(/invalid signature/u) - }) -}) diff --git a/apps/desktop/tests/macos-signature.spec.ts b/apps/desktop/tests/macos-signature.spec.ts index 827de532e9..09adb91bf1 100644 --- a/apps/desktop/tests/macos-signature.spec.ts +++ b/apps/desktop/tests/macos-signature.spec.ts @@ -1,3 +1,10 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, relative } from 'node:path' +import { createRequire } from 'node:module' +import { FileMatcher } from 'app-builder-lib/out/fileMatcher.js' +import { runtimeFixture } from './runtime-fixture.ts' +import { verifyDesktopRuntime } from '../src/runtime-tree.ts' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' import type { NotarizeOptions } from '@electron/notarize' import { @@ -7,10 +14,15 @@ import { } from '../scripts/desktop-release-environment.mjs' import { notarizeMacOSDiskImageArtifact } from '../scripts/notarize-macos-disk-images.mjs' import { - assertMacOSSeedSignatureDetails, + assertMacOSRuntimeSignatureDetails, assertMacOSSignatureDetails, } from '../scripts/verify-macos-signature.mjs' +// app-builder-lib omits this internal copier from its declarations; the regression exercises its actual file filter. +const { copyFiles } = createRequire(import.meta.url)('app-builder-lib/out/fileMatcher.js') as { + copyFiles: (matchers: FileMatcher[]) => Promise +} + const RELEASE_ENVIRONMENT = { DSH_DESKTOP_APP_ID: 'com.example.desktop', DSH_DESKTOP_TARGET_PLATFORM: 'darwin', @@ -40,17 +52,18 @@ describe('desktop macOS release signature', () => { const { createElectronBuilderConfig } = await import('../electron-builder.config.mjs') const config = createElectronBuilderConfig(RELEASE_ENVIRONMENT, 'darwin', 'arm64') expect(portablePath(config.directories.output)).toContain('/.desktop-build/targets/mac-arm64/artifacts') - expect(config.extraResources).toHaveLength(2) + expect(config.extraResources).toHaveLength(3) expect(config.extraResources[0]?.to).toBe('runtime') - expect(config.extraResources[1]?.to).toBe('seed') + expect(config.extraResources[1]?.to).toBe('dsh') expect(portablePath(config.extraResources[0]?.from ?? '')).toContain('/.desktop-build/targets/mac-arm64/runtime') - expect(portablePath(config.extraResources[1]?.from ?? '')).toContain('/.desktop-build/targets/mac-arm64/seed') + expect(portablePath(config.extraResources[1]?.from ?? '')).toContain('/.desktop-build/targets/mac-arm64/dsh') expect(config).toMatchObject({ appId: RELEASE_ENVIRONMENT.DSH_DESKTOP_APP_ID, mac: { identity: RELEASE_ENVIRONMENT.DSH_DESKTOP_MACOS_SIGNING_IDENTITY, forceCodeSigning: true, notarize: true, + signIgnore: ['/Contents/Resources/dsh(?:/|$)'], }, dmg: { sign: true, @@ -64,6 +77,26 @@ describe('desktop macOS release signature', () => { expect(typeof config.artifactBuildCompleted).toBe('function') }) + it('copies the complete runtime despite electron-builder excluding root node_modules', async () => { + const { createElectronBuilderConfig } = await import('../electron-builder.config.mjs') + const config = createElectronBuilderConfig(RELEASE_ENVIRONMENT, 'darwin', 'arm64') + const root = mkdtempSync(join(tmpdir(), 'desktop-resource-copy-')) + try { + const source = join(root, 'source') + const destination = join(root, 'resources') + runtimeFixture(source) + const sourceRoot = config.extraResources[1].from + const matchers = config.extraResources.slice(1).map(entry => new FileMatcher( + join(source, relative(sourceRoot, entry.from)), join(destination, entry.to), value => value, + )) + await copyFiles(matchers.slice(0, 1)) + expect(() => verifyDesktopRuntime(join(destination, 'dsh'), '1.0.0')).toThrow(/integrity/u) + rmSync(destination, { recursive: true }) + await copyFiles(matchers) + expect(() => verifyDesktopRuntime(join(destination, 'dsh'), '1.0.0')).not.toThrow() + } finally { rmSync(root, { recursive: true, force: true }) } + }) + it('validates Windows signing without requiring macOS identifiers for a Windows target', async () => { const { createElectronBuilderConfig } = await import('../electron-builder.config.mjs') expect(() => createElectronBuilderConfig({ @@ -82,7 +115,7 @@ describe('desktop macOS release signature', () => { }).not.toThrow() }) - it('requires a secure timestamp and hardened runtime for seed code', () => { + it('requires a secure timestamp and hardened runtime for runtime code', () => { const expected = resolveMacOSSigningEnvironment(RELEASE_ENVIRONMENT) const details = [ `Authority=Developer ID Application: ${expected.signingIdentity}`, @@ -90,12 +123,12 @@ describe('desktop macOS release signature', () => { 'Timestamp=31 Aug 2026 at 20:00:00', 'CodeDirectory v=20500 size=773 flags=0x10000(runtime) hashes=13+7 location=embedded', ].join('\n') - expect(() => { assertMacOSSeedSignatureDetails(details, expected) }).not.toThrow() + expect(() => { assertMacOSRuntimeSignatureDetails(details, expected) }).not.toThrow() expect(() => { - assertMacOSSeedSignatureDetails(details.replace(/^Timestamp=.*\n/um, ''), expected) + assertMacOSRuntimeSignatureDetails(details.replace(/^Timestamp=.*\n/um, ''), expected) }).toThrow(/secure timestamp/u) expect(() => { - assertMacOSSeedSignatureDetails(details.replace('flags=0x10000(runtime)', 'flags=0x0(none)'), expected) + assertMacOSRuntimeSignatureDetails(details.replace('flags=0x10000(runtime)', 'flags=0x0(none)'), expected) }).toThrow(/hardened runtime/u) }) diff --git a/apps/desktop/tests/package-target.spec.ts b/apps/desktop/tests/package-target.spec.ts index e822f212f5..ae5e0ca800 100644 --- a/apps/desktop/tests/package-target.spec.ts +++ b/apps/desktop/tests/package-target.spec.ts @@ -56,7 +56,7 @@ describe('desktop package target', () => { expect(desktopElectronBuilderArguments(target, true)).toContain('--dir') }) - it('keeps Windows signing fields out of build and seed preparation subprocesses', () => { + it('keeps Windows signing fields out of build and runtime preparation subprocesses', () => { expect(withoutWindowsSigningEnvironment({ DSH_DESKTOP_WINDOWS_CER_FILE: 'C:\\release\\server.cer', DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'token-secret', diff --git a/apps/desktop/tests/plugin-manager.spec.ts b/apps/desktop/tests/plugin-manager.spec.ts new file mode 100644 index 0000000000..376b523f58 --- /dev/null +++ b/apps/desktop/tests/plugin-manager.spec.ts @@ -0,0 +1,33 @@ +import { readFileSync } from 'node:fs' +import { runInContext } from 'node:vm' +import { JSDOM } from 'jsdom' +import { expect, it, vi } from 'vitest' +import { resolveDesktopLocale } from '../src/locale.ts' + +it('keeps disabled packages visible and offers recovery without a running backend', async () => { + const dom = new JSDOM(readFileSync(new URL('../renderer/plugin-manager.html', import.meta.url), 'utf8'), { runScripts: 'outside-only' }) + let enabled = true + let ready = false + const disableAll = vi.fn(async () => { enabled = false; ready = true }) + const toggle = vi.fn(async (_name: string, active: boolean) => { enabled = active }) + const api = { + locale: async () => resolveDesktopLocale('en'), + backend: { status: async () => ({ ready, error: ready ? undefined : 'plugin requires Cordis ^2.0.0' }), retry: vi.fn() }, + plugins: { list: async () => [{ name: 'example-plugin', version: '1.0.0', enabled }], disableAll, toggle }, + } + Object.defineProperty(dom.window, 'dshDesktop', { value: api }) + try { + runInContext(readFileSync(new URL('../renderer/plugin-manager.js', import.meta.url), 'utf8'), dom.getInternalVMContext()) + const document = dom.window.document + await expect.poll(() => document.querySelector('#plugins li')?.textContent).toContain('example-plugin') + expect(document.querySelector('#recovery')?.hidden).toBe(false) + expect(document.querySelector('#startup-error')?.textContent).toBe('plugin requires Cordis ^2.0.0') + document.querySelector('#disable-all')?.click() + await expect.poll(() => document.querySelector('#recovery')?.hidden).toBe(true) + expect(disableAll).toHaveBeenCalledOnce() + expect(document.querySelector('#plugins li')?.textContent).toMatchInlineSnapshot('"example-plugin1.0.0 · DisabledEnableUpdateRemove"') + document.querySelector('#plugins li button')?.click() + await expect.poll(() => toggle.mock.calls).toEqual([['example-plugin', true]]) + await expect.poll(() => document.querySelector('#plugins li')?.textContent).toBe('example-plugin1.0.0DisableUpdateRemove') + } finally { dom.window.close() } +}) diff --git a/apps/desktop/tests/plugin-pnpm.spec.ts b/apps/desktop/tests/plugin-pnpm.spec.ts new file mode 100644 index 0000000000..1a455e3453 --- /dev/null +++ b/apps/desktop/tests/plugin-pnpm.spec.ts @@ -0,0 +1,72 @@ +import { createHash } from 'node:crypto' +import { execFileSync } from 'node:child_process' +import { createServer } from 'node:http' +import { mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { c } from 'tar' +import { expect, it } from 'vitest' +import { DesktopProjectManager, type DesktopProjectHooks } from '../src/project-manager.ts' +import { resolveDesktopPaths } from '../src/paths.ts' +import { runtimeFixture, writePackage } from './runtime-fixture.ts' + +it('installs a real pnpm graph, then executes approved scripts with the shared host instance', async () => { + const root = realpathSync(mkdtempSync(join(tmpdir(), 'desktop-real-pnpm-'))) + const server = createServer() + const archives = new Map() + try { + for (const name of ['fixture-plugin', 'node-pty']) { + const path = writePackage(join(root, 'packages'), name, name === 'fixture-plugin' + ? { dependencies: { 'node-pty': '1.0.0' }, peerDependencies: { '@deepseek-ai/cordis': '^1.0.0' }, dsh: { bundle: { patch: 'bundle.yml' } } } + : { scripts: { install: 'node install.cjs' } }, 'export {identity} from "@deepseek-ai/cordis"') + writeFileSync(join(path, 'bundle.yml'), '[]\n') + writeFileSync(join(path, 'install.cjs'), 'require("node:fs").writeFileSync("built.json", JSON.stringify({node:process.execPath, host:require.resolve("@deepseek-ai/cordis")}))') + const tarball = join(root, `${name}.tgz`) + await c({ file: tarball, cwd: join(path, '..'), gzip: true }, [name]) + archives.set(name, readFileSync(tarball)) + } + await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve) }) + const address = server.address() + if (address === null || typeof address === 'string') throw new Error('fixture registry has no TCP address') + const origin = `http://127.0.0.1:${address.port}` + server.on('request', (request, response) => { + const name = request.url?.slice(1).replace(/\.tgz$/u, '') ?? '' + const archive = archives.get(name) + if (archive === undefined) { response.writeHead(404); response.end(); return } + if (request.url?.endsWith('.tgz')) { response.end(archive); return } + response.setHeader('content-type', 'application/json') + response.end(JSON.stringify({ name, 'dist-tags': { latest: '1.0.0' }, versions: { '1.0.0': { + name, version: '1.0.0', dist: { tarball: `${origin}/${name}.tgz`, integrity: `sha512-${createHash('sha512').update(archive).digest('base64')}` }, + ...(name === 'fixture-plugin' ? { dependencies: { 'node-pty': '1.0.0' }, peerDependencies: { '@deepseek-ai/cordis': '^1.0.0' } } : {}), + } }, time: { '1.0.0': '2020-01-01T00:00:00.000Z' } })) + }) + const dsh = join(root, 'dsh') + runtimeFixture(dsh) + const pnpm = join(root, 'pnpm.mjs') + const realPnpm = join(import.meta.dirname, '../node_modules/pnpm/bin/pnpm.mjs') + writeFileSync(pnpm, `process.argv = process.argv.map(arg => arg === '--config.registry=https://registry.npmjs.org/' ? ${JSON.stringify(`--config.registry=${origin}`)} : arg); await import(${JSON.stringify(pathToFileURL(realPnpm).href)})`) + const manager = new DesktopProjectManager(resolveDesktopPaths(join(root, '.dsh')), { node: process.execPath, pnpm, dsh }) + const hooks: DesktopProjectHooks = { healthCheck: async () => {}, beforeActivate: async () => {}, afterActivate: async () => {} } + await manager.applyRelease('1.0.0', hooks) + await manager.mutate({ type: 'plugin-add', spec: 'fixture-plugin@1.0.0' }, hooks) + expect(manager.listPlugins()).toEqual([{ name: 'fixture-plugin', version: '1.0.0', enabled: true }]) + const built = JSON.parse(readFileSync(join(manager.paths.profile, 'node_modules/node-pty/built.json'), 'utf8')) as { node: string; host: string } + expect(realpathSync(built.node)).toBe(realpathSync(process.execPath)) + expect(built.host).toBe(join(dsh, 'node_modules/@deepseek-ai/cordis/index.js')) + const entry = join(dsh, 'identity.mjs') + writeFileSync(entry, `import {identity} from '@deepseek-ai/cordis'; import {identity as plugin} from ${JSON.stringify(pathToFileURL(join(manager.paths.profile, 'node_modules/fixture-plugin/index.js')).href)}; console.log(identity === plugin)`) + expect(execFileSync(process.execPath, [entry], { encoding: 'utf8' }).trim()).toBe('true') + await manager.mutate({ type: 'plugin-remove', name: 'fixture-plugin' }, hooks) + expect(manager.listPlugins()).toEqual([]) + } finally { + server.closeAllConnections() + if (server.listening) await new Promise((resolve, reject) => { + server.close((error) => { + if (error !== undefined) reject(error) + else resolve() + }) + }) + rmSync(root, { recursive: true, force: true }) + } +}, 30_000) diff --git a/apps/desktop/tests/profile-packages.spec.ts b/apps/desktop/tests/profile-packages.spec.ts new file mode 100644 index 0000000000..4f3fe1308a --- /dev/null +++ b/apps/desktop/tests/profile-packages.spec.ts @@ -0,0 +1,81 @@ +import { execFileSync } from 'node:child_process' +import { mkdtempSync, readFileSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { afterEach, expect, it } from 'vitest' +import { createPluginProfile } from '../src/project-manager.ts' +import { copyDesktopProfile, linkDesktopHostPackages, unlinkDesktopHostPackages, validateDesktopPluginGraph } from '../src/profile-packages.ts' +import { runtimeFixture, writePackage } from './runtime-fixture.ts' + +const roots: string[] = [] +function fixture() { + const root = mkdtempSync(join(tmpdir(), 'desktop-profile-')) + roots.push(root) + const dsh = join(root, 'dsh') + const runtime = runtimeFixture(dsh) + const profile = join(root, 'profile') + createPluginProfile(profile) + linkDesktopHostPackages(profile, dsh, runtime) + return { root, dsh, runtime, profile } +} +afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) }) + +it('loads one shared ESM instance from both host and external plugin while keeping ordinary dependencies private', () => { + const { dsh, runtime, profile } = fixture() + writePackage(join(dsh, 'node_modules'), 'ordinary', {}, 'export default "host"') + writePackage(join(profile, 'node_modules'), 'ordinary', {}, 'export default "plugin"') + const plugin = writePackage(join(profile, 'node_modules'), 'plugin', { + peerDependencies: { '@deepseek-ai/cordis': '^1.0.0' }, dependencies: { ordinary: '1.0.0' }, + }, 'export { identity } from "@deepseek-ai/cordis"; export { default as ordinary } from "ordinary"') + validateDesktopPluginGraph(profile, dsh, runtime, ['plugin']) + const entry = join(dsh, 'check.mjs') + writeFileSync(entry, `import {identity} from '@deepseek-ai/cordis'; import ordinary from 'ordinary'; import * as plugin from ${JSON.stringify(pathToFileURL(join(plugin, 'index.js')).href)}; console.log(JSON.stringify({same:identity===plugin.identity, host:ordinary, plugin:plugin.ordinary}))`) + const output = execFileSync(process.execPath, [entry], { encoding: 'utf8', env: { ...process.env, NODE_OPTIONS: '', NODE_PATH: '' } }) + expect(JSON.parse(output)).toEqual({ same: true, host: 'host', plugin: 'plugin' }) +}) +it.each(['nested', 'alias'])('rejects a %s second copy of a host package', (placement) => { + const { dsh, runtime, profile } = fixture() + const plugin = writePackage(join(profile, 'node_modules'), 'plugin') + if (placement === 'nested') writePackage(join(plugin, 'node_modules'), '@deepseek-ai/cordis') + else writePackage(join(profile, 'node_modules'), 'alias', { name: '@deepseek-ai/cordis' }) + expect(() =>{ validateDesktopPluginGraph(profile, dsh, runtime, ['plugin']) }).toThrow(/duplicate or aliased/u) +}) +it('rejects a host package declared as an ordinary dependency', () => { + const { dsh, runtime, profile } = fixture() + writePackage(join(profile, 'node_modules'), 'plugin', { dependencies: { '@deepseek-ai/cordis': '^1.0.0' } }) + expect(() =>{ validateDesktopPluginGraph(profile, dsh, runtime, ['plugin']) }).toThrow(/peer dependency/u) +}) +it('rejects incompatible peers only when the plugin is enabled', () => { + const { dsh, runtime, profile } = fixture() + writePackage(join(profile, 'node_modules'), 'plugin', { peerDependencies: { '@deepseek-ai/cordis': '^2.0.0' } }) + expect(() =>{ validateDesktopPluginGraph(profile, dsh, runtime, ['plugin']) }).toThrow(/found 1.0.0/u) + expect(() =>{ validateDesktopPluginGraph(profile, dsh, runtime, []) }).not.toThrow() +}) +it('refuses to satisfy a plugin dependency from an ancestor CLI project', () => { + const { root, dsh, runtime, profile } = fixture() + writePackage(join(root, 'node_modules'), 'ambient') + writePackage(join(profile, 'node_modules'), 'plugin', { dependencies: { ambient: '1.0.0' } }) + expect(() =>{ validateDesktopPluginGraph(profile, dsh, runtime, ['plugin']) }).toThrow(/outside its owned packages/u) +}) +it('copies writable plugin bytes independently and removes broken owned links without following them', () => { + const { root, profile } = fixture() + writePackage(join(profile, 'node_modules'), 'plugin') + copyDesktopProfile(profile, join(root, 'copy')) + writeFileSync(join(root, 'copy/node_modules/plugin/index.js'), 'changed') + expect(readFileSync(join(profile, 'node_modules/plugin/index.js'), 'utf8')).toContain('identity') + rmSync(join(root, 'dsh'), { recursive: true }) + expect(() =>{ unlinkDesktopHostPackages(profile) }).not.toThrow() +}) +it('refuses to replace an unowned package at a managed name', () => { + const { profile } = fixture() + unlinkSync(join(profile, 'node_modules/@deepseek-ai/cordis')) + writePackage(join(profile, 'node_modules'), '@deepseek-ai/cordis') + expect(() =>{ unlinkDesktopHostPackages(profile) }).toThrow(/unowned package/u) +}) +it('rejects private package links instead of following cycles or old transaction paths', () => { + const { dsh, runtime, profile } = fixture() + const plugin = writePackage(join(profile, 'node_modules'), 'plugin') + symlinkSync(plugin, join(profile, 'node_modules/alias'), process.platform === 'win32' ? 'junction' : 'dir') + expect(() =>{ validateDesktopPluginGraph(profile, dsh, runtime, ['plugin']) }).toThrow(/linked private package/u) +}) diff --git a/apps/desktop/tests/project-manager.spec.ts b/apps/desktop/tests/project-manager.spec.ts index f5320b47bd..18e5c26c7a 100644 --- a/apps/desktop/tests/project-manager.spec.ts +++ b/apps/desktop/tests/project-manager.spec.ts @@ -1,392 +1,217 @@ -import { createHash } from 'node:crypto' -import { existsSync, mkdtempSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { existsSync, mkdtempSync, mkdirSync, readFileSync, renameSync, rmSync, unlinkSync, realpathSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' -import { dirname, join, relative, sep } from 'node:path' +import { dirname, join } from 'node:path' import { pathToFileURL } from 'node:url' import { afterEach, describe, expect, it } from 'vitest' import { resolveDesktopPaths } from '../src/paths.ts' -import { - createSeedMetadata, - DesktopProjectManager, - packageNameFromSpec, - verifySeedIntegrity, - type DesktopProjectHooks, -} from '../src/project-manager.ts' -import { DESKTOP_HOST_PROTOCOL_VERSION } from '../src/host-protocol.ts' -import { DESKTOP_PACKAGES_DIR, DESKTOP_PACKAGE_SET_FILE } from '../src/core-package-set.ts' -import type { DesktopRelease } from '../src/release.ts' -import { archivePnpmStore } from '../src/seed-store.ts' +import { DesktopProjectManager, packageNameFromSpec, type DesktopProjectHooks } from '../src/project-manager.ts' +import { readDesktopProfileState } from '../src/profile-packages.ts' +import { runtimeFixture } from './runtime-fixture.ts' const roots: string[] = [] - function temporaryRoot(): string { const root = mkdtempSync(join(tmpdir(), 'dsh-desktop-test-')) roots.push(root) return root } - -function writeIntegrity(seed: string): void { - const paths: string[] = [] - const visit = (directory: string): void => { - for (const entry of readdirSync(directory, { withFileTypes: true })) { - const path = join(directory, entry.name) - if (entry.isDirectory()) visit(path) - else if (entry.name !== 'integrity.json') paths.push(path) - } - } - visit(seed) - const files = paths.sort().map((path) => { - const body = readFileSync(path) - return { - path: relative(seed, path).split(sep).join('/'), - bytes: statSync(path).size, - sha256: createHash('sha256').update(body).digest('hex'), - } - }) - writeFileSync(join(seed, 'integrity.json'), `${JSON.stringify({ schemaVersion: 2, files })}\n`) -} - -function archiveStore(seed: string): void { - const store = join(seed, 'store') - mkdirSync(store, { recursive: true }) - if (readdirSync(store).length === 0) writeFileSync(join(store, 'test-entry'), 'content') - archivePnpmStore(seed, store) -} - -function writeCorePackageSet(seed: string, version: string): void { - const packages = [ - { name: '@deepseek-ai/dsh', file: `deepseek-ai-dsh-${version}.tgz`, body: Buffer.from(`dsh-${version}`) }, - { - name: '@deepseek-ai/dsh-desktop-host', - file: `deepseek-ai-dsh-desktop-host-${version}.tgz`, - body: Buffer.from(`desktop-host-${version}`), - }, - ] - mkdirSync(join(seed, DESKTOP_PACKAGES_DIR), { recursive: true }) - for (const entry of packages) writeFileSync(join(seed, DESKTOP_PACKAGES_DIR, entry.file), entry.body) - writeFileSync(join(seed, DESKTOP_PACKAGE_SET_FILE), `${JSON.stringify({ - schemaVersion: 1, - packages: packages.map(({ name, file, body }) => ({ - name, - version, - file, - bytes: body.byteLength, - integrity: `sha512-${createHash('sha512').update(body).digest('base64')}`, - })), - })}\n`) -} - -function createTestSeedMetadata(seed: string, desktopRelease: DesktopRelease): void { - writeCorePackageSet(seed, desktopRelease.version) - createSeedMetadata(seed, desktopRelease) -} - function writeFakePnpm(root: string): string { const path = join(root, 'pnpm.mjs') - writeFileSync(path, String.raw` -import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { dirname, join } from 'node:path' + writeFileSync(path, ` +import { appendFileSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' const args = process.argv.slice(2) const project = process.cwd() -const command = args.find(value => value === 'install' || value === 'add' || value === 'remove') -const manifestPath = join(project, 'package.json') -const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) -const packageName = spec => spec.startsWith('@') - ? spec.slice(0, spec.indexOf('@', spec.indexOf('/') + 1) === -1 ? undefined : spec.indexOf('@', spec.indexOf('/') + 1)) - : spec.split('@')[0] -const packageVersion = spec => { - const index = spec.startsWith('@') ? spec.indexOf('@', spec.indexOf('/') + 1) : spec.indexOf('@') - return index === -1 ? '1.0.0' : spec.slice(index + 1) -} - -if (command === 'add') { - const spec = args[args.indexOf('add') + 1] - manifest.dependencies[packageName(spec)] = packageVersion(spec) -} -if (command === 'remove') delete manifest.dependencies[args[args.indexOf('remove') + 1]] -writeFileSync(manifestPath, JSON.stringify(manifest)) -rmSync(join(project, 'node_modules'), { recursive: true, force: true }) -for (const [name, version] of Object.entries(manifest.dependencies)) { - const packageRoot = join(project, 'node_modules', ...name.split('/')) - mkdirSync(packageRoot, { recursive: true }) - const core = name === '@deepseek-ai/dsh' || name === '@deepseek-ai/dsh-desktop-host' - const plugin = !core - const installedVersion = plugin - ? version - : JSON.parse(readFileSync(join(project, 'desktop-release.json'), 'utf8')).version - writeFileSync(join(packageRoot, 'package.json'), JSON.stringify({ - name, version: installedVersion, - ...(plugin ? { dsh: { bundle: { patch: './bundle.yml' } } } : {}), - })) - if (plugin) writeFileSync(join(packageRoot, 'bundle.yml'), '[]\n') - else if (name === '@deepseek-ai/dsh-desktop-host') { - mkdirSync(join(packageRoot, 'lib'), { recursive: true }) - writeFileSync(join(packageRoot, 'lib', 'index.js'), '') +const command = args.find(value => ['install', 'add', 'remove', 'rebuild'].includes(value)) +appendFileSync(${JSON.stringify(join(root, 'pnpm-log.jsonl'))}, JSON.stringify({args, registry: process.env.NPM_CONFIG_REGISTRY}) + '\\n') +if (command !== 'rebuild') { + const manifestPath = join(project, 'package.json') + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) + if (command === 'add') { + const spec = args[args.indexOf(command) + 1] + const index = spec.lastIndexOf('@') + const name = index > 0 ? spec.slice(0, index) : spec + manifest.dependencies[name] = index > 0 ? spec.slice(index + 1) : '1.0.0' } + if (command === 'remove') delete manifest.dependencies[args[args.indexOf(command) + 1]] + writeFileSync(manifestPath, JSON.stringify(manifest)) + rmSync(join(project, 'node_modules'), { recursive: true, force: true }) + for (const [name, version] of Object.entries(manifest.dependencies)) { + const packageRoot = join(project, 'node_modules', name) + mkdirSync(packageRoot, { recursive: true }) + writeFileSync(join(packageRoot, 'package.json'), JSON.stringify({name, version, + peerDependencies: {'@deepseek-ai/cordis': '^1.0.0'}, dsh: {bundle: {patch: './bundle.yml'}}})) + writeFileSync(join(packageRoot, 'bundle.yml'), '[]\\n') + } + writeFileSync(join(project, 'pnpm-lock.yaml'), JSON.stringify(manifest.dependencies)) } -writeFileSync(join(project, 'pnpm-lock.yaml'), 'lockfileVersion: 9\n') -if (process.env.TEST_PNPM_LOG) writeFileSync(process.env.TEST_PNPM_LOG, JSON.stringify({ args, env: process.env })) `) return path } - -function writeBlockingFakePnpm(root: string, ready: string, release: string): string { - const path = join(root, 'blocking-pnpm.mjs') - const delegate = writeFakePnpm(root) - writeFileSync(path, ` -import { existsSync, writeFileSync } from 'node:fs' -import { setTimeout as sleep } from 'node:timers/promises' -writeFileSync(${JSON.stringify(ready)}, String(process.pid)) -while (!existsSync(${JSON.stringify(release)})) await sleep(10) -await import(${JSON.stringify(pathToFileURL(delegate).href)}) -`) - return path -} - function hooks(overrides: Partial = {}): DesktopProjectHooks { - return { - healthCheck: async () => {}, - beforeActivate: async () => {}, - afterActivate: async () => {}, - ...overrides, - } + return { healthCheck: async () => {}, beforeActivate: async () => {}, afterActivate: async () => {}, ...overrides } } - -function release(version = '1.0.0'): DesktopRelease { - return { - schemaVersion: 1, - version, - hostProtocolVersion: DESKTOP_HOST_PROTOCOL_VERSION, - nodeVersion: '24.17.0', - pnpmVersion: '11.7.0', - } +function setup(): { root: string; manager: DesktopProjectManager } { + const root = temporaryRoot() + const dsh = join(root, 'resources', 'dsh') + runtimeFixture(dsh) + return { root, manager: new DesktopProjectManager(resolveDesktopPaths(join(root, '.dsh')), { node: process.execPath, pnpm: writeFakePnpm(root), dsh }) } } +function calls(root: string): { args: string[]; registry: string }[] { + const path = join(root, 'pnpm-log.jsonl') + return existsSync(path) ? readFileSync(path, 'utf8').trim().split('\n').map(line => JSON.parse(line) as { args: string[]; registry: string }) : [] +} +afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) }) -afterEach(() => { - for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) -}) - -describe('desktop package policy', () => { - it('accepts registry package specs but rejects alternate sources and flags', () => { +describe('desktop external plugin profile', () => { + it('accepts registry names and tags but rejects alternate sources and flags', () => { expect(packageNameFromSpec('@scope/plugin@1.2.3')).toBe('@scope/plugin') expect(packageNameFromSpec('plugin@next')).toBe('plugin') - expect(() => packageNameFromSpec('file:../plugin')).toThrow(/unsupported npm package spec/u) - expect(() => packageNameFromSpec('--registry=evil')).toThrow(/unsupported npm package spec/u) - expect(() => packageNameFromSpec('https://example.test/plugin.tgz')).toThrow(/unsupported npm package spec/u) - }) - - it('rejects any seed content changed after release inventory generation', () => { - const seed = join(temporaryRoot(), 'seed') - createTestSeedMetadata(seed, release()) - writeFileSync(join(seed, 'pnpm-lock.yaml'), 'lockfileVersion: 9\n') - writeIntegrity(seed) - expect(() => { verifySeedIntegrity(seed) }).not.toThrow() - writeFileSync(join(seed, 'package.json'), '{}\n') - expect(() => { verifySeedIntegrity(seed) }).toThrow(/integrity verification failed/u) - }) -}) - -describe('desktop project transactions', () => { - it('installs the offline seed and reconciles a mismatched private Host', async () => { - const root = temporaryRoot() - const seed = join(root, 'seed') - const log = join(root, 'pnpm-log.json') - createTestSeedMetadata(seed, release()) - writeFileSync(join(seed, 'pnpm-lock.yaml'), 'lockfileVersion: 9\n') - mkdirSync(join(seed, 'store'), { recursive: true }) - writeFileSync(join(seed, 'store', 'seed-entry'), 'content') - archiveStore(seed) - writeIntegrity(seed) - const paths = resolveDesktopPaths(join(root, '.dsh')) - const manager = new DesktopProjectManager(paths, { node: process.execPath, pnpm: writeFakePnpm(root) }) - const previousLog = process.env.TEST_PNPM_LOG - const previousRegistry = process.env.npm_config_registry - process.env.TEST_PNPM_LOG = log - process.env.npm_config_registry = 'https://user-registry.invalid' - try { - await expect(manager.applyRelease(seed, '2.0.0', hooks())).rejects.toThrow(/does not match Electron/u) - await manager.applyRelease(seed, '1.0.0', hooks()) - writeFileSync( - join(paths.profile, 'node_modules', '@deepseek-ai', 'dsh-desktop-host', 'package.json'), - '{"name":"@deepseek-ai/dsh-desktop-host","version":"0.9.0"}\n', - ) - await expect(manager.applyRelease(seed, '1.0.0', hooks())).resolves.toBe(true) - } finally { - if (previousLog === undefined) delete process.env.TEST_PNPM_LOG - else process.env.TEST_PNPM_LOG = previousLog - if (previousRegistry === undefined) delete process.env.npm_config_registry - else process.env.npm_config_registry = previousRegistry + for (const spec of ['file:../plugin', '--registry=evil', 'https://example.test/plugin.tgz']) { + expect(() => packageNameFromSpec(spec)).toThrow(/unsupported npm package spec/u) } - expect(manager.dshVersion()).toBe('1.0.0') - expect(manager.releaseVersion()).toBe('1.0.0') - expect(paths.profile).toBe(join(root, '.dsh', 'profiles', 'desktop')) - expect(existsSync(join(paths.profile, 'node_modules', '@deepseek-ai', 'dsh'))).toBe(true) - const installedHost = JSON.parse(readFileSync( - join(paths.profile, 'node_modules', '@deepseek-ai', 'dsh-desktop-host', 'package.json'), - 'utf8', - )) as { version: string } - expect(installedHost.version).toBe('1.0.0') - expect(existsSync(join(paths.profile, 'desktop-plugins.json'))).toBe(false) - expect(readFileSync(join(paths.pnpm.store, 'seed-entry'), 'utf8')).toBe('content') - const invocation = JSON.parse(readFileSync(log, 'utf8')) as { args: string[]; env: Record } - expect(invocation.args).toContain('--offline') - expect(invocation.args).toContain('--trust-lockfile') - expect(invocation.args).toContain(`--config.store-dir=${paths.pnpm.store}`) - expect(invocation.args).toContain('--config.enable-global-virtual-store=false') - expect(invocation.args).toContain('--config.registry=https://registry.npmjs.org/') - expect(invocation.env.NPM_CONFIG_REGISTRY).toBe('https://registry.npmjs.org/') - expect(invocation.env.NPM_CONFIG_STORE_DIR).toBe(paths.pnpm.store) - expect(invocation.env.NPM_CONFIG_USERCONFIG).toBe(join(paths.pnpm.config, 'npmrc')) - expect(invocation.env.npm_config_registry).toBeUndefined() }) - it('restores the active project when the replacement backend cannot start', async () => { - const root = temporaryRoot() - const seed = join(root, 'seed') - createTestSeedMetadata(seed, release()) - writeFileSync(join(seed, 'pnpm-lock.yaml'), 'lockfileVersion: 9\n') - archiveStore(seed) - writeIntegrity(seed) - const paths = resolveDesktopPaths(join(root, '.dsh')) - const manager = new DesktopProjectManager(paths, { node: process.execPath, pnpm: writeFakePnpm(root) }) - await manager.applyRelease(seed, '1.0.0', hooks()) - let starts = 0 - await expect(manager.mutate({ type: 'plugin-add', spec: '@scope/plugin@2.0.0' }, hooks({ - afterActivate: async () => { - starts += 1 - if (starts === 1) throw new Error('backend rejected staged graph') - }, - }))).rejects.toThrow(/backend rejected staged graph/u) + it('initializes and restarts offline without executing pnpm', async () => { + const { root, manager } = setup() + await expect(manager.applyRelease('2.0.0', hooks())).rejects.toThrow(/does not match Electron/u) + await expect(manager.applyRelease('1.0.0', hooks())).resolves.toBe(true) + await expect(manager.applyRelease('1.0.0', hooks())).resolves.toBe(false) expect(manager.listPlugins()).toEqual([]) - expect(manager.dshVersion()).toBe('1.0.0') - expect(starts).toBe(2) + expect(calls(root)).toEqual([]) + expect(existsSync(manager.paths.pnpm.store)).toBe(false) + expect(realpathSync(join(manager.paths.profile, 'node_modules/@deepseek-ai/cordis'))).toBe(realpathSync(join(manager.runtime.dsh, 'node_modules/@deepseek-ai/cordis'))) + expect(JSON.parse(readFileSync(join(manager.paths.profile, 'package.json'), 'utf8'))).toMatchObject({ dependencies: {} }) }) - it('restores rollback when the active move completed before its journal update', async () => { - const root = temporaryRoot() - const seed = join(root, 'seed') - createTestSeedMetadata(seed, release()) - writeFileSync(join(seed, 'pnpm-lock.yaml'), 'lockfileVersion: 9\n') - archiveStore(seed) - writeIntegrity(seed) - const paths = resolveDesktopPaths(join(root, '.dsh')) - const manager = new DesktopProjectManager(paths, { node: process.execPath, pnpm: writeFakePnpm(root) }) - await manager.applyRelease(seed, '1.0.0', hooks()) + it('repairs a removed managed link without running pnpm', async () => { + const { root, manager } = setup() + await manager.applyRelease('1.0.0', hooks()) + unlinkSync(join(manager.paths.profile, 'node_modules/@deepseek-ai/cordis')) + await expect(manager.applyRelease('1.0.0', hooks())).resolves.toBe(true) + expect(calls(root)).toEqual([]) + }) + + it('installs only plugins and checks the graph before running lifecycle scripts', async () => { + const { root, manager } = setup() + await manager.applyRelease('1.0.0', hooks()) await manager.mutate({ type: 'plugin-add', spec: '@scope/plugin@2.0.0' }, hooks()) - const stagingProfile = join(paths.staging, 'interrupted', 'profile') - mkdirSync(stagingProfile, { recursive: true }) - writeFileSync(join(stagingProfile, 'marker'), 'staging') - rmSync(paths.rollback, { recursive: true, force: true }) - mkdirSync(dirname(paths.rollback), { recursive: true }) - renameSync(paths.profile, paths.rollback) - writeFileSync(paths.pending, `${JSON.stringify({ - schemaVersion: 1, - id: 'interrupted', - stagingProfile, - step: 'prepared', - })}\n`) - - manager.recover() - - expect(manager.listPlugins()).toEqual([{ name: '@scope/plugin', version: '2.0.0' }]) - expect(existsSync(stagingProfile)).toBe(false) - expect(existsSync(paths.pending)).toBe(false) - }) - - it('records the live pnpm worker as transaction owner until it exits', async () => { - const root = temporaryRoot() - const seed = join(root, 'seed') - const ready = join(root, 'pnpm-ready') - const releaseWorker = join(root, 'pnpm-release') - createTestSeedMetadata(seed, release()) - writeFileSync(join(seed, 'pnpm-lock.yaml'), 'lockfileVersion: 9\n') - archiveStore(seed) - writeIntegrity(seed) - const paths = resolveDesktopPaths(join(root, '.dsh')) - const runtime = { node: process.execPath, pnpm: writeBlockingFakePnpm(root, ready, releaseWorker) } - const manager = new DesktopProjectManager(paths, runtime) - const installing = manager.applyRelease(seed, '1.0.0', hooks()) - await expect.poll(() => existsSync(ready)).toBe(true) - const workerPid = Number.parseInt(readFileSync(ready, 'utf8'), 10) - expect(readFileSync(paths.lock, 'utf8')).toBe(`${String(workerPid)}\n`) - const competing = new DesktopProjectManager(paths, runtime) - await expect(competing.applyRelease(seed, '1.0.0', hooks())).rejects.toThrow(/another package transaction is active/u) - writeFileSync(releaseWorker, 'continue') - await expect(installing).resolves.toBe(true) - expect(existsSync(paths.lock)).toBe(false) - }) - - it('keeps core packages local while installing plugins from the desktop registry', async () => { - const root = temporaryRoot() - const seed = join(root, 'seed') - const log = join(root, 'pnpm-log.json') - createTestSeedMetadata(seed, release()) - writeFileSync(join(seed, 'pnpm-lock.yaml'), 'lockfileVersion: 9\n') - archiveStore(seed) - writeIntegrity(seed) - const paths = resolveDesktopPaths(join(root, '.dsh')) - const manager = new DesktopProjectManager(paths, { node: process.execPath, pnpm: writeFakePnpm(root) }) - await manager.applyRelease(seed, '1.0.0', hooks()) - const previousLog = process.env.TEST_PNPM_LOG - process.env.TEST_PNPM_LOG = log - try { - await manager.mutate({ type: 'plugin-add', spec: '@scope/plugin@2.0.0' }, hooks()) - } finally { - if (previousLog === undefined) delete process.env.TEST_PNPM_LOG - else process.env.TEST_PNPM_LOG = previousLog - } - - const manifest = JSON.parse(readFileSync(join(paths.profile, 'package.json'), 'utf8')) as { - dependencies: Record - } - const coreSpec = manifest.dependencies['@deepseek-ai/dsh'] - expect(coreSpec).toMatch(/^file:\.\/desktop-packages\//u) - expect(readFileSync(join(paths.profile, 'pnpm-workspace.yaml'), 'utf8')) - .toContain(`${JSON.stringify('@deepseek-ai/dsh')}: ${JSON.stringify(coreSpec)}`) - expect(manifest.dependencies['@scope/plugin']).toBe('2.0.0') - const invocation = JSON.parse(readFileSync(log, 'utf8')) as { args: string[]; env: Record } - expect(invocation.args).toContain('add') - expect(invocation.args).toContain('@scope/plugin@2.0.0') - expect(invocation.args).toContain('--config.registry=https://registry.npmjs.org/') - expect(invocation.env.NPM_CONFIG_REGISTRY).toBe('https://registry.npmjs.org/') - }) - - it('reconciles dsh to the packaged release without removing desktop plugins', async () => { - const root = temporaryRoot() - const paths = resolveDesktopPaths(join(root, '.dsh')) - const manager = new DesktopProjectManager(paths, { node: process.execPath, pnpm: writeFakePnpm(root) }) - const firstSeed = join(root, 'seed-1') - createTestSeedMetadata(firstSeed, release('1.0.0')) - writeFileSync(join(firstSeed, 'pnpm-lock.yaml'), 'lockfileVersion: 9\n') - mkdirSync(join(firstSeed, 'store'), { recursive: true }) - writeFileSync(join(firstSeed, 'store', 'release-1'), 'one') - archiveStore(firstSeed) - writeIntegrity(firstSeed) - await manager.applyRelease(firstSeed, '1.0.0', hooks()) - await manager.mutate({ type: 'plugin-add', spec: '@scope/plugin@2.0.0' }, hooks()) - - const nextSeed = join(root, 'seed-2') - createTestSeedMetadata(nextSeed, release('1.1.0')) - writeFileSync(join(nextSeed, 'pnpm-lock.yaml'), 'lockfileVersion: 9\n') - mkdirSync(join(nextSeed, 'store'), { recursive: true }) - writeFileSync(join(nextSeed, 'store', 'release-2'), 'two') - archiveStore(nextSeed) - writeIntegrity(nextSeed) - - await expect(manager.applyRelease(nextSeed, '1.1.0', hooks())).resolves.toBe(true) - expect(manager.releaseVersion()).toBe('1.1.0') - expect(manager.dshVersion()).toBe('1.1.0') - expect(manager.listPlugins()).toEqual([{ name: '@scope/plugin', version: '2.0.0' }]) - const profile = JSON.parse(readFileSync(join(paths.profile, 'package.json'), 'utf8')) as { - dsh: { profile: { bundles: string[] } } - } - expect(profile.dsh.profile.bundles).toEqual([ - '@deepseek-ai/dsh-base', - '@deepseek-ai/dsh-web-app', - '@scope/plugin', + expect(manager.listPlugins()).toEqual([{ name: '@scope/plugin', version: '2.0.0', enabled: true }]) + expect(calls(root).map(call => call.args.filter(arg => !arg.startsWith('--config.')))).toEqual([ + ['add', '@scope/plugin@2.0.0', '--save-exact', '--ignore-scripts'], ['rebuild', '--pending'], ]) - expect(readFileSync(join(paths.pnpm.store, 'release-1'), 'utf8')).toBe('one') - expect(readFileSync(join(paths.pnpm.store, 'release-2'), 'utf8')).toBe('two') - await expect(manager.applyRelease(nextSeed, '1.1.0', hooks())).resolves.toBe(false) + expect(calls(root).every(call => call.registry === 'https://registry.npmjs.org/')).toBe(true) + expect(JSON.parse(readFileSync(join(manager.paths.profile, 'package.json'), 'utf8'))).toMatchObject({ dependencies: { '@scope/plugin': '2.0.0' } }) + await expect(manager.mutate({ type: 'plugin-add', spec: '@deepseek-ai/cordis' }, hooks())).rejects.toThrow(/host-owned/u) + }) + + it('retains disabled plugin versions through updates and enables them explicitly', async () => { + const { root, manager } = setup() + await manager.applyRelease('1.0.0', hooks()) + await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks()) + await manager.mutate({ type: 'plugins-disable-all' }, hooks()) + expect(calls(root)).toHaveLength(2) + expect(manager.listPlugins()).toEqual([{ name: 'plugin', version: '1.0.0', enabled: false }]) + await manager.mutate({ type: 'plugin-update', name: 'plugin', version: '1.1.0' }, hooks()) + expect(manager.listPlugins()).toEqual([{ name: 'plugin', version: '1.1.0', enabled: false }]) + await manager.mutate({ type: 'plugin-toggle', name: 'plugin', enabled: true }, hooks()) + expect(manager.listPlugins()[0]?.enabled).toBe(true) + await manager.mutate({ type: 'plugin-remove', name: 'plugin' }, hooks()) + expect(manager.listPlugins()).toEqual([]) + }) + + it('keeps plugin files and patches through a compatible release and application relocation', async () => { + const { root, manager } = setup() + await manager.applyRelease('1.0.0', hooks()) + await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks()) + writeFileSync(join(manager.paths.profile, 'cordis.patch.yml'), '[]\n') + const nextRoot = join(root, 'relocated', 'dsh') + runtimeFixture(nextRoot, '1.1.0') + const next = new DesktopProjectManager(manager.paths, { ...manager.runtime, dsh: nextRoot }) + await expect(next.applyRelease('1.1.0', hooks())).resolves.toBe(true) + expect(next.listPlugins()).toEqual(manager.listPlugins()) + expect(next.releaseVersion()).toBe('1.1.0') + expect(readFileSync(join(manager.paths.profile, 'cordis.patch.yml'), 'utf8')).toBe('[]\n') + expect(calls(root)).toHaveLength(2) + expect(realpathSync(join(manager.paths.profile, 'node_modules/@deepseek-ai/cordis'))).toBe(realpathSync(join(nextRoot, 'node_modules/@deepseek-ai/cordis'))) + writeFileSync(join(manager.paths.rollback, 'node_modules/plugin/bundle.yml'), 'rollback only') + expect(readFileSync(join(manager.paths.profile, 'node_modules/plugin/bundle.yml'), 'utf8')).toBe('[]\n') + }) + + it('reinstalls the locked plugin graph when bundled Node changes', async () => { + const { root, manager } = setup() + await manager.applyRelease('1.0.0', hooks()) + await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks()) + const dsh = join(root, 'new-node') + runtimeFixture(dsh, '1.1.0', '24.18.0') + const next = new DesktopProjectManager(manager.paths, { ...manager.runtime, dsh }) + await next.applyRelease('1.1.0', hooks()) + expect(calls(root).slice(2).map(call => call.args.filter(arg => !arg.startsWith('--config.')))).toEqual([ + ['install', '--frozen-lockfile', '--ignore-scripts'], ['rebuild', '--pending'], + ]) + expect(next.listPlugins()).toEqual([{ name: 'plugin', version: '1.0.0', enabled: true }]) + }) + + it('allows incompatible plugins to be disabled in recovery without deleting them', async () => { + const { root, manager } = setup() + await manager.applyRelease('1.0.0', hooks()) + await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks()) + const dsh = join(root, 'next-major') + runtimeFixture(dsh, '2.0.0') + const next = new DesktopProjectManager(manager.paths, { ...manager.runtime, dsh }) + await expect(next.applyRelease('2.0.0', hooks())).rejects.toThrow(/requires @deepseek-ai\/cordis/u) + expect(next.releaseVersion()).toBe('1.0.0') + expect(() =>{ next.assertProfileRuntime(manager.paths.profile) }).toThrow(/does not match/u) + await next.mutate({ type: 'plugins-disable-all' }, hooks()) + expect(next.releaseVersion()).toBe('2.0.0') + expect(next.listPlugins()).toEqual([{ name: 'plugin', version: '1.0.0', enabled: false }]) + }) + + it.each(['health', 'before', 'after'] as const)('keeps the active profile when %s activation fails', async (phase) => { + const { manager } = setup() + await manager.applyRelease('1.0.0', hooks()) + let starts = 0 + await expect(manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks({ + healthCheck: async () => { if (phase === 'health') throw new Error('health failed') }, + beforeActivate: async () => { if (phase === 'before') throw new Error('before failed') }, + afterActivate: async () => { if (phase === 'after' && starts++ === 0) throw new Error('after failed') }, + }))).rejects.toThrow(`${phase} failed`) + expect(manager.listPlugins()).toEqual([]) + expect(existsSync(manager.paths.pending)).toBe(false) + }) + + it('recovers a directory move using recorded runtime identities', async () => { + const { manager } = setup() + await manager.applyRelease('1.0.0', hooks()) + const id = readDesktopProfileState(manager.paths.profile)?.runtimeId + const stagingProfile = join(manager.paths.staging, 'interrupted', 'profile') + mkdirSync(stagingProfile, { recursive: true }) + mkdirSync(dirname(manager.paths.rollback), { recursive: true }) + renameSync(manager.paths.profile, manager.paths.rollback) + writeFileSync(manager.paths.pending, JSON.stringify({ schemaVersion: 1, id: 'interrupted', stagingProfile, fromRuntimeId: id, toRuntimeId: id, step: 'active-moved' })) + manager.recover() + expect(manager.releaseVersion()).toBe('1.0.0') + expect(existsSync(stagingProfile)).toBe(false) + expect(existsSync(manager.paths.pending)).toBe(false) + }) + + it('holds the transaction lock until the pnpm worker exits', async () => { + const { root, manager } = setup() + await manager.applyRelease('1.0.0', hooks()) + const ready = join(root, 'ready') + const release = join(root, 'release') + const blocker = join(root, 'blocking.mjs') + writeFileSync(blocker, `import {existsSync, writeFileSync} from 'node:fs'; import {setTimeout as sleep} from 'node:timers/promises'; writeFileSync(${JSON.stringify(ready)}, String(process.pid)); while (!existsSync(${JSON.stringify(release)})) await sleep(10); await import(${JSON.stringify(pathToFileURL(manager.runtime.pnpm).href)})`) + const worker = new DesktopProjectManager(manager.paths, { ...manager.runtime, pnpm: blocker }) + await worker.applyRelease('1.0.0', hooks()) + const pending = worker.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks()) + try { + await expect.poll(() => existsSync(ready)).toBe(true) + expect(readFileSync(manager.paths.lock, 'utf8').trim()).toBe(readFileSync(ready, 'utf8')) + await expect(manager.applyRelease('1.0.0', hooks())).rejects.toThrow(/another package transaction/u) + } finally { + writeFileSync(release, 'continue') + await pending + } + expect(existsSync(manager.paths.lock)).toBe(false) }) }) diff --git a/apps/desktop/tests/runtime-fixture.ts b/apps/desktop/tests/runtime-fixture.ts new file mode 100644 index 0000000000..81648dfc5f --- /dev/null +++ b/apps/desktop/tests/runtime-fixture.ts @@ -0,0 +1,42 @@ +/** Temporary materialized packages for Desktop resource and profile behavior tests. */ + +import { mkdirSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { DESKTOP_HOST_RUNTIME_FILES } from '../src/core-package-set.ts' +import { DESKTOP_HOST_PROTOCOL_VERSION } from '../src/host-protocol.ts' +import { writeDesktopRuntime, type DesktopRuntimeDescriptor } from '../src/runtime-tree.ts' + +/** + * Write a package fixture with explicit runtime exports. + * @param modules - Owning node_modules directory. + * @param name - Package name. + * @param fields - Manifest fields. + * @param source - ESM entry contents. + * @returns Installed package directory. + */ +export function writePackage(modules: string, name: string, fields: Record = {}, source = 'export const identity = {}\n'): string { + const path = join(modules, name) + mkdirSync(path, { recursive: true }) + writeFileSync(join(path, 'package.json'), JSON.stringify({ name, version: '1.0.0', type: 'module', exports: './index.js', ...fields })) + writeFileSync(join(path, 'index.js'), source) + return path +} + +/** + * Seal a minimal release containing Host entry files and a shared Cordis package. + * @param root - New runtime directory. + * @param version - Shell and dsh version. + * @param nodeVersion - Bundled Node version used for native rebuild selection. + * @returns Sealed runtime metadata. + */ +export function runtimeFixture(root: string, version = '1.0.0', nodeVersion = '24.17.0'): DesktopRuntimeDescriptor { + const names = ['@deepseek-ai/dsh', '@deepseek-ai/dsh-desktop-host', '@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', '@deepseek-ai/cordis'] + for (const name of names) writePackage(join(root, 'node_modules'), name, { version }) + for (const file of DESKTOP_HOST_RUNTIME_FILES) { + const path = join(root, 'node_modules', '@deepseek-ai/dsh-desktop-host', file) + mkdirSync(join(path, '..'), { recursive: true }) + writeFileSync(path, '') + } + writeFileSync(join(root, 'package.json'), '{"type":"module"}\n') + return writeDesktopRuntime(root, { schemaVersion: 1, version, nodeVersion, pnpmVersion: '11.7.0', hostProtocolVersion: DESKTOP_HOST_PROTOCOL_VERSION }, names) +} diff --git a/apps/desktop/tests/runtime-tree.spec.ts b/apps/desktop/tests/runtime-tree.spec.ts new file mode 100644 index 0000000000..4c4dddf52e --- /dev/null +++ b/apps/desktop/tests/runtime-tree.spec.ts @@ -0,0 +1,46 @@ +import { cpSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it } from 'vitest' +import { DESKTOP_RUNTIME_FILE, desktopRuntimeId, runtimePath, verifyDesktopRuntime } from '../src/runtime-tree.ts' +import { runtimeFixture } from './runtime-fixture.ts' + +const roots: string[] = [] +function fixture(): string { + const root = mkdtempSync(join(tmpdir(), 'desktop-runtime-')) + roots.push(root) + runtimeFixture(join(root, 'dsh')) + return root +} +afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) }) + +it('verifies a runtime after relocation without depending on build paths', () => { + const root = fixture() + const before = verifyDesktopRuntime(join(root, 'dsh'), '1.0.0') + cpSync(join(root, 'dsh'), join(root, 'moved'), { recursive: true }) + expect(desktopRuntimeId(verifyDesktopRuntime(join(root, 'moved'), '1.0.0'))).toBe(desktopRuntimeId(before)) +}) +it.each(['changed', 'extra', 'missing'])('rejects %s runtime bytes', (operation) => { + const dsh = join(fixture(), 'dsh') + if (operation === 'changed') writeFileSync(join(dsh, 'package.json'), '{}') + if (operation === 'extra') writeFileSync(join(dsh, 'extra'), '') + if (operation === 'missing') rmSync(join(dsh, 'package.json')) + expect(() => verifyDesktopRuntime(dsh, '1.0.0')).toThrow(/integrity/u) +}) +it('rejects filesystem links and incompatible targets', () => { + const dsh = join(fixture(), 'dsh') + expect(() => verifyDesktopRuntime(dsh, '1.0.0', { platform: process.platform, arch: 'wrong' })).toThrow(/incompatible/u) + symlinkSync(join(dsh, 'node_modules'), join(dsh, 'outside'), process.platform === 'win32' ? 'junction' : 'dir') + expect(() => verifyDesktopRuntime(dsh, '1.0.0')).toThrow(/unsupported filesystem/u) +}) +it('rejects a descriptor that maps a shared package outside node_modules', () => { + const dsh = join(fixture(), 'dsh') + const path = join(dsh, DESKTOP_RUNTIME_FILE) + const descriptor = JSON.parse(readFileSync(path, 'utf8')) as { sharedPackages: { path: string }[] } + descriptor.sharedPackages[0]!.path = '../outside' + writeFileSync(path, JSON.stringify(descriptor)) + expect(() => verifyDesktopRuntime(dsh, '1.0.0')).toThrow(/shared package record/u) +}) +it.each(['../outside', '/absolute', 'C:/absolute', 'a\\b', 'a//b', './a'])('rejects nonportable path %s', (path) => { + expect(() => runtimePath('/runtime', path)).toThrow(/invalid relative path/u) +}) diff --git a/apps/desktop/tests/seed-store.spec.ts b/apps/desktop/tests/seed-store.spec.ts deleted file mode 100644 index b2f5fc614a..0000000000 --- a/apps/desktop/tests/seed-store.spec.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { - chmodSync, - existsSync, - mkdirSync, - mkdtempSync, - readFileSync, - readdirSync, - rmSync, - statSync, - utimesSync, - writeFileSync, -} from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { DatabaseSync } from 'node:sqlite' -import { afterEach, describe, expect, it } from 'vitest' -import { - archivePnpmStore, - extractPnpmStoreArchives, - mergePnpmStore, - removePnpmProjectRegistrations, - SEED_STORE_ARCHIVE_DIR, - SEED_STORE_ARCHIVE_MANIFEST, -} from '../src/seed-store.ts' - -const temporaryRoots: string[] = [] - -function temporaryRoot(): string { - const root = mkdtempSync(join(tmpdir(), 'dsh-desktop-store-')) - temporaryRoots.push(root) - return root -} - -function archiveBytes(seed: string): readonly { path: string; body: Buffer }[] { - return [SEED_STORE_ARCHIVE_MANIFEST, ...readdirSync(join(seed, SEED_STORE_ARCHIVE_DIR))] - .map(path => ({ - path, - body: readFileSync(path === SEED_STORE_ARCHIVE_MANIFEST - ? join(seed, path) - : join(seed, SEED_STORE_ARCHIVE_DIR, path)), - })) -} - -afterEach(() => { - for (const root of temporaryRoots.splice(0)) rmSync(root, { recursive: true, force: true }) -}) - -describe('desktop seed store cleanup', () => { - it('removes project registrations without removing package data', () => { - const storeRoot = temporaryRoot() - mkdirSync(join(storeRoot, 'v11', 'projects', 'temporary-project'), { recursive: true }) - mkdirSync(join(storeRoot, 'v12', 'projects'), { recursive: true }) - mkdirSync(join(storeRoot, 'metadata', 'projects'), { recursive: true }) - writeFileSync(join(storeRoot, 'v11', 'package-data'), 'package') - - removePnpmProjectRegistrations(storeRoot) - - expect(existsSync(join(storeRoot, 'v11', 'projects'))).toBe(false) - expect(existsSync(join(storeRoot, 'v12', 'projects'))).toBe(false) - expect(existsSync(join(storeRoot, 'v11', 'package-data'))).toBe(true) - expect(existsSync(join(storeRoot, 'metadata', 'projects'))).toBe(true) - }) -}) - -describe('desktop seed store merge', () => { - it('preserves installed package records while the verified seed replaces matching records and files', () => { - const root = temporaryRoot() - const source = join(root, 'source') - const destination = join(root, 'destination') - for (const store of [source, destination]) { - mkdirSync(join(store, 'v11', 'files'), { recursive: true }) - const database = new DatabaseSync(join(store, 'v11', 'index.db')) - database.exec('CREATE TABLE package_index (key TEXT PRIMARY KEY, data BLOB NOT NULL) WITHOUT ROWID') - const insert = database.prepare('INSERT INTO package_index (key, data) VALUES (?, ?)') - if (store === source) { - insert.run('seed-only', Buffer.from('seed')) - insert.run('shared', Buffer.from('new')) - } else { - insert.run('plugin-only', Buffer.from('plugin')) - insert.run('shared', Buffer.from('old')) - } - database.close() - } - writeFileSync(join(source, 'v11', 'files', 'shared'), 'new') - writeFileSync(join(destination, 'v11', 'files', 'shared'), 'old') - writeFileSync(join(destination, 'v11', 'files', 'plugin'), 'plugin') - - mergePnpmStore(source, destination) - - const database = new DatabaseSync(join(destination, 'v11', 'index.db'), { readOnly: true }) - const records = database.prepare('SELECT key, data FROM package_index ORDER BY key').all() as { - key: string - data: Uint8Array - }[] - database.close() - expect(records.map(record => [record.key, Buffer.from(record.data).toString()])).toEqual([ - ['plugin-only', 'plugin'], - ['seed-only', 'seed'], - ['shared', 'new'], - ]) - expect(readFileSync(join(destination, 'v11', 'files', 'shared'), 'utf8')).toBe('new') - expect(readFileSync(join(destination, 'v11', 'files', 'plugin'), 'utf8')).toBe('plugin') - }) -}) - -describe('desktop seed store archives', () => { - it('extracts package bytes and executable modes without retaining loose seed files', () => { - const root = temporaryRoot() - const seed = join(root, 'seed') - const store = join(seed, 'store') - const executable = join(store, 'v10', 'files', 'native-addon') - mkdirSync(join(store, 'v10', 'files'), { recursive: true }) - writeFileSync(executable, 'native') - chmodSync(executable, 0o755) - writeFileSync(join(store, 'v10', 'files', 'package-data'), 'package') - - archivePnpmStore(seed, store) - const destination = join(root, 'extracted') - extractPnpmStoreArchives(seed, destination) - - expect(existsSync(store)).toBe(false) - expect(readFileSync(join(destination, 'v10', 'files', 'package-data'), 'utf8')).toBe('package') - if (process.platform !== 'win32') { - expect(statSync(join(destination, 'v10', 'files', 'native-addon')).mode & 0o111).toBe(0o111) - } - }) - - it('produces identical shards for identical paths, bytes, and modes', () => { - const root = temporaryRoot() - const seeds = [join(root, 'first'), join(root, 'second')] - for (const [index, seed] of seeds.entries()) { - const store = join(seed, 'store') - mkdirSync(join(store, 'nested'), { recursive: true }) - const paths = index === 0 ? ['alpha', 'nested/beta'] : ['nested/beta', 'alpha'] - for (const path of paths) { - const target = join(store, path) - writeFileSync(target, path) - utimesSync(target, new Date(index * 10_000), new Date(index * 20_000)) - } - archivePnpmStore(seed, store) - } - - const first = archiveBytes(seeds[0] as string) - const second = archiveBytes(seeds[1] as string) - expect(second.map(entry => entry.path)).toEqual(first.map(entry => entry.path)) - expect(second.map(entry => entry.body)).toEqual(first.map(entry => entry.body)) - }) - - it('rejects an archive whose entry count differs from the manifest', () => { - const root = temporaryRoot() - const seed = join(root, 'seed') - const store = join(seed, 'store') - mkdirSync(store, { recursive: true }) - writeFileSync(join(store, 'package-data'), 'package') - archivePnpmStore(seed, store) - const manifestPath = join(seed, SEED_STORE_ARCHIVE_MANIFEST) - const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as { - archives: { entries: number }[] - } - const archive = manifest.archives[0] - if (archive === undefined) throw new Error('test seed has no archive') - archive.entries += 1 - writeFileSync(manifestPath, JSON.stringify(manifest)) - - expect(() => { extractPnpmStoreArchives(seed, join(root, 'extracted')) }).toThrow(/unexpected entry count/u) - }) -}) diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index fd58c7ae00..be8ba71a94 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/architecture.md -architecture.md: a77dfd06c61cbb12d7008133a7e8d515a3449a02 -architecture.zh.md: 83fa958609500cc85af9c0d1f7e339104f6c18c7 +architecture.md: cdd9686261221ffcba7e4991ec4f93bc4e4559f1 +architecture.zh.md: 2cd1b0781e1a56f05d53ba9035542102b15971a9 diff --git a/docs/architecture.md b/docs/architecture.md index a77dfd06c6..cdd9686261 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -48,9 +48,9 @@ The Python SDK follows the same application architecture. Its runtime wheel pack ## Desktop application -The [Electron desktop application](../apps/desktop/README.md) owns the reserved `$DSH_HOME/profiles/desktop` npm project. Each signed Electron release binds one exact dsh version and carries a first-party offline seed; startup installs that version into the writable profile with the bundled pnpm, while retaining exact desktop-plugin versions from the previous profile. CLI profiles share supported product data under `$DSH_HOME`, but never executable packages, plugin activation, lockfiles, or `node_modules` with Desktop. +The [Electron desktop application](../apps/desktop/README.md) carries its exact dsh production runtime in signed application resources. The reserved `$DSH_HOME/profiles/desktop` contains external plugins and links to host-owned packages; compatible upgrades retain plugin files and refresh these links without installing core dependencies. CLI profiles share supported product data under `$DSH_HOME`, while executable packages, plugin activation, lockfiles, and package-manager state remain separate. -Electron starts the private Desktop Host package under its bundled upstream Node.js process; that package loads the installed dsh backend and matching client graph from the reserved profile. Unary RPC, Remote streams, and version-matched client assets cross versioned framed byte pipes with Node IPC reserved for lifecycle control, then reach the renderer through the secure `dsh-app://` protocol; the desktop composition opens no Web server or loopback port. Only shell-owned UI can run plugin transactions through the bundled pnpm and its private `$DSH_HOME/desktop/pnpm/store`. +Electron starts the private Desktop Host package under its bundled upstream Node.js process; that package loads the bundled dsh backend and matching client graph together with enabled profile plugins. Unary RPC, Remote streams, and version-matched client assets cross versioned framed byte pipes with Node IPC reserved for lifecycle control, then reach the renderer through the secure `dsh-app://` protocol; the desktop composition opens no Web server or loopback port. Only shell-owned UI can run plugin transactions through the bundled pnpm and its private `$DSH_HOME/desktop/pnpm/store`. ## Core packages diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 83fa958609..2cd1b0781e 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -48,9 +48,9 @@ Python SDK 遵循相同的应用架构。其运行时 wheel 把普通 `dsh` CLI ## 桌面应用 -[Electron 桌面应用](../apps/desktop/README.zh.md)持有保留的 `$DSH_HOME/profiles/desktop` npm 项目。每个签名 Electron 发行版绑定一个确切 dsh 版本并携带第一方离线 seed;启动时通过内置 pnpm 把该版本安装进可写 profile,同时保留旧 profile 中桌面插件的确切版本。CLI profile 与 Desktop 共享 `$DSH_HOME` 下受支持的产品数据,但绝不共享可执行包、插件激活、lockfile 或 `node_modules`。 +[Electron 桌面应用](../apps/desktop/README.zh.md)在签名应用资源中携带精确版本的 dsh 生产运行时。保留的 `$DSH_HOME/profiles/desktop` 保存外部插件和指向宿主拥有包的链接;兼容升级保留插件文件并刷新这些链接,无需安装核心依赖。CLI profile 共享 `$DSH_HOME` 下受支持的产品数据,而可执行包、插件激活、锁文件和包管理器状态保持独立。 -Electron 通过内置的上游 Node.js 进程启动私有 Desktop Host 包;该包从保留 profile 加载已安装的 dsh 后端与匹配的客户端图。一元 RPC、Remote stream 与版本匹配的客户端资源经带版本的分帧字节管道传输,Node IPC 只保留生命周期控制,再通过安全的 `dsh-app://` 协议到达渲染进程;因此桌面组合不会开放 Web server 或 loopback 端口。只有壳自有 UI 能通过内置 pnpm 及其私有 `$DSH_HOME/desktop/pnpm/store` 执行插件事务。 +Electron 通过内置的上游 Node.js 进程启动私有 Desktop Host 包;该包加载内置 dsh 后端、匹配的客户端图和已启用的 profile 插件。一元 RPC、Remote stream 与版本匹配的客户端资源经带版本的分帧字节管道传输,Node IPC 只保留生命周期控制,再通过安全的 `dsh-app://` 协议到达渲染进程;因此桌面组合不会开放 Web server 或 loopback 端口。只有壳自有 UI 能通过内置 pnpm 及其私有 `$DSH_HOME/desktop/pnpm/store` 执行插件事务。 ## 核心包 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3dc0fc3a72..a2c6b9edb8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -525,9 +525,6 @@ importers: js-yaml: specifier: ^4.2.0 version: 4.3.1 - msgpackr: - specifier: 2.0.4 - version: 2.0.4 pnpm: specifier: 11.7.0 version: 11.7.0 @@ -12688,36 +12685,6 @@ packages: resolution: {integrity: sha512-Mmjg4anFBD5OzbPnGJOA0jPPN8645ERhQk38HQLpSenx1ox9bfdPkmAzUnNjeQtqQGFLtKe13J20RtLBmUKMZA==} hasBin: true - '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': - resolution: {integrity: sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==} - cpu: [arm64] - os: [darwin] - - '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4': - resolution: {integrity: sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==} - cpu: [x64] - os: [darwin] - - '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4': - resolution: {integrity: sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==} - cpu: [arm64] - os: [linux] - - '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4': - resolution: {integrity: sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==} - cpu: [arm] - os: [linux] - - '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4': - resolution: {integrity: sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==} - cpu: [x64] - os: [linux] - - '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': - resolution: {integrity: sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==} - cpu: [x64] - os: [win32] - '@napi-rs/wasm-runtime@1.1.5': resolution: {integrity: sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==} peerDependencies: @@ -16331,13 +16298,6 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - msgpackr-extract@3.0.4: - resolution: {integrity: sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==} - hasBin: true - - msgpackr@2.0.4: - resolution: {integrity: sha512-o1C5KRmuRt+apqMr1HuGSqWStZoRBUpEsCsl15uM9VdAF1qHLtvMOU2En747EnTyEl6c4pzPewRMFF31s1CNbA==} - multistream@4.1.0: resolution: {integrity: sha512-J1XDiAmmNpRCBfIWJv+n0ymC4ABcf/Pl+5YvC5B/D2f/2+8PtHvCNxMPKiQcZyi922Hq69J2YOpb1pTywfifyw==} @@ -16438,10 +16398,6 @@ packages: resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - node-gyp-build-optional-packages@5.2.2: - resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} - hasBin: true - node-gyp@12.4.0: resolution: {integrity: sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw==} engines: {node: ^20.17.0 || >=22.9.0} @@ -17149,13 +17105,13 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + standardwebhooks@1.1.1: + resolution: {integrity: sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==} + stat-mode@1.0.0: resolution: {integrity: sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==} engines: {node: '>= 6'} - standardwebhooks@1.1.1: - resolution: {integrity: sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==} - statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} @@ -19433,24 +19389,6 @@ snapshots: - supports-color - zod - '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': - optional: true - - '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4': - optional: true - - '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4': - optional: true - - '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4': - optional: true - - '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4': - optional: true - - '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': - optional: true - '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@emnapi/core': 1.10.0 @@ -23321,22 +23259,6 @@ snapshots: ms@2.1.3: {} - msgpackr-extract@3.0.4: - dependencies: - node-gyp-build-optional-packages: 5.2.2 - optionalDependencies: - '@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.4 - '@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.4 - '@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.4 - '@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.4 - '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.4 - '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.4 - optional: true - - msgpackr@2.0.4: - optionalDependencies: - msgpackr-extract: 3.0.4 - multistream@4.1.0: dependencies: once: 1.4.0 @@ -23425,11 +23347,6 @@ snapshots: fetch-blob: 3.2.0 formdata-polyfill: 4.0.10 - node-gyp-build-optional-packages@5.2.2: - dependencies: - detect-libc: 2.1.2 - optional: true - node-gyp@12.4.0: dependencies: env-paths: 2.2.1 From 8874a23b9eaa93d2ed39effdce1dfbef0cf6b376 Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Tue, 8 Sep 2026 17:44:05 +0800 Subject: [PATCH 005/111] =?UTF-8?q?feat:=20=E5=8A=A0=E9=80=9F=20mac=20sign?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...on-desktop-packaging-and-updates.i18n.yaml | 4 +-- ...-electron-desktop-packaging-and-updates.md | 2 ++ ...ectron-desktop-packaging-and-updates.zh.md | 2 ++ apps/desktop/README.i18n.yaml | 4 +-- apps/desktop/README.md | 2 ++ apps/desktop/README.zh.md | 2 ++ apps/desktop/electron-builder.config.mjs | 4 +-- apps/desktop/tests/macos-signature.spec.ts | 17 ++++++++- apps/desktop/tests/macos-signing-walk.spec.ts | 36 +++++++++++++++++++ patches/@electron__osx-sign@1.3.3.patch | 26 ++++++++++++++ pnpm-lock.yaml | 5 +-- pnpm-workspace.yaml | 1 + 12 files changed, 96 insertions(+), 9 deletions(-) create mode 100644 apps/desktop/tests/macos-signing-walk.spec.ts create mode 100644 patches/@electron__osx-sign@1.3.3.patch diff --git a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml index e0081b64f5..fe1bf84c1b 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md -2026-08-25-electron-desktop-packaging-and-updates.md: fea2c419d73e4eff66de08483733cbc336b6f9af -2026-08-25-electron-desktop-packaging-and-updates.zh.md: 88e2875cd35909c0111926377df83c9fe21841dc +2026-08-25-electron-desktop-packaging-and-updates.md: d7d41aa4d6154f251b56faaa29065db3ee71071f +2026-08-25-electron-desktop-packaging-and-updates.zh.md: 952ed4fc5546524bc088f58e62faccd576194bc7 diff --git a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md index fea2c419d7..d7d41aa4d6 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md +++ b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md @@ -85,6 +85,8 @@ Core dsh and the private Desktop Host come only from the signed application reso Electron artifacts are signed; macOS artifacts are notarized. Release automation must supply the application ID, macOS Developer ID qualifier, expected Team ID, and one complete notarytool credential strategy through explicit environment variables. Configuration loading rejects missing or malformed identifiers and incomplete notarization credentials, while macOS packaging requires signing so certificate discovery cannot silently select another installed identity or emit an unsigned release. Runtime preparation verifies the exact Authority and Team ID plus the timestamp and hardened-runtime flags on every embedded Mach-O file. An after-sign hook performs Apple's deep strict application verification and requires the same leaf Authority and Team ID before artifact creation continues. Electron-builder then notarizes and staples the application and signs the DMG. The DMG artifact-completion hook separately notarizes and staples every DMG before requiring the configured identity, a valid ticket, and Gatekeeper acceptance; the upload event runs only after that hook succeeds. DMG blockmaps are disabled because macOS updates consume the signed ZIP, and stapling would otherwise invalidate an already-generated DMG blockmap. The custom protocol serves the installed frontend distribution plus client files named by the active module graph and rejects traversal or access outside those roots. The plugin installer API is available only to the Electron-owned management GUI and is absent from the browser application and backend RPC. +The [pinned osx-sign patch](../../../../patches/@electron__osx-sign@1.3.3.patch) uses `lstat` in both published module builds, so Framework file and directory aliases do not trigger duplicate signing. The patch remains necessary until the selected upstream release skips those aliases. PAK files are resources sealed by the enclosing bundle; individual signatures add serial timestamp requests without additional resource integrity. Desktop preserves all locale files and skips only their standalone signatures. Executable code retains Developer ID signatures, secure timestamps, and hardened runtime. The [signer traversal regression](../../../../apps/desktop/tests/macos-signing-walk.spec.ts) exercises the installed dependency with real Framework aliases; release qualification still requires strict application verification, notarization, and startup. + Windows release packaging supplies the public EV leaf certificate named by `DSH_DESKTOP_WINDOWS_CER_FILE` to the configured SafeNet-compatible SignTool through `/f` and identifies its matching private key through the required `DSH_DESKTOP_WINDOWS_KEY_CONTAINER`. The certificate file remains outside source control, and the private key remains on the USB token. The electron-builder hook passes each artifact to the CRLF `windows-sign.cmd`, whose single SignTool invocation uses the SafeNet `/kc "[{{PIN}}]=container"` value and CSP, a SHA-256 file digest, and a DigiCert SHA-256 RFC 3161 timestamp. The hook never substitutes another SignTool and never retries a failed request. Package orchestration withholds every `DSH_DESKTOP_WINDOWS_*` field from build and runtime-preparation children and passes only the certificate path, SignTool path, key container, and PIN into electron-builder. The signer supplies only validated signing fields in an otherwise scrubbed CMD environment; the CMD disables delayed expansion, clears those fields before SignTool starts, and preserves the PIN only in the required SignTool command line. Every surfaced diagnostic replaces the PIN, and only the dedicated build account and administrators may inspect the runner. The signer signs electron-builder's temporary NSIS bootstrap before enterprise Code Integrity evaluates that executable and clears a generated executable's certificate-table entry only when it points beyond the file before applying the final signature. Packaging fails before producing unsigned artifacts when the SignTool, certificate, container, PIN, token, or signature is unavailable. The custom protocol serves the installed frontend distribution plus client files named by the active module graph and rejects traversal or access outside those roots. The plugin installer API is available only to the Electron-owned management GUI and is absent from the browser application and backend RPC. Packaged applications ignore development resource and project environment overrides. Only an unpackaged Electron process can replace the Node.js binary, pnpm entry, dsh resources, or active project. diff --git a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md index 88e2875cd3..952ed4fc55 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md @@ -85,6 +85,8 @@ Electron 更新只使用一个 `electron-updater` 发布流和签名 `electron-b Electron 产物必须签名;macOS 产物必须公证。发布自动化必须通过明确的环境变量提供应用 ID、macOS Developer ID 限定名、预期 Team ID 与一套完整的 notarytool 凭据。配置加载会拒绝缺失或格式错误的标识符和不完整的公证凭据,macOS 打包还会强制签名,避免证书发现过程静默选择其他已安装身份或生成未签名发布。运行时准备会验证每个内嵌 Mach-O 文件的精确 Authority 与 Team ID,以及时间戳和 hardened-runtime 标记。签名后钩子会执行 Apple 的深度严格应用验证,并要求同一叶证书 Authority 与 Team ID 完全匹配,验证通过后才继续生成产物。Electron-builder 随后公证应用并钉票、签署 DMG。DMG 的 artifact-completion hook 会单独公证每个 DMG 并钉票,再要求其使用配置的身份、具备有效票据并通过 Gatekeeper;只有该 hook 成功,上传事件才会执行。macOS 更新使用签名 ZIP,因此 DMG 不生成 blockmap;否则钉票会让已经生成的 DMG blockmap 失效。自定义协议提供已安装的前端分发目录和活跃模块图点名的客户端文件,并拒绝路径穿越或访问这些根目录之外的内容。插件安装器 API 只对 Electron 拥有的管理 GUI 可用,不存在于浏览器应用或后端 RPC 中。 +[固定版本的 osx-sign 补丁](../../../../patches/@electron__osx-sign@1.3.3.patch)在两种已发布模块构建中使用 `lstat`,因此 Framework 的文件和目录别名不会触发重复签名。选定的上游版本能够跳过这些别名前,仍需保留该补丁。PAK 文件由外层 bundle 签名记录完整性;逐个签名会增加串行时间戳请求,但不会增加资源完整性保护。Desktop 保留全部语言文件,只跳过其单独签名。可执行代码仍使用 Developer ID 签名、安全时间戳和 hardened runtime。[签名器遍历回归测试](../../../../apps/desktop/tests/macos-signing-walk.spec.ts)使用真实 Framework 别名执行已安装依赖;发布验收仍要求严格应用验证、公证和启动。 + Windows 发布打包通过 `/f` 向已配置且与 SafeNet 兼容的 SignTool 提供 `DSH_DESKTOP_WINDOWS_CER_FILE` 指定的公开 EV 叶证书,并通过必需的 `DSH_DESKTOP_WINDOWS_KEY_CONTAINER` 标识匹配的私钥。证书文件保留在源码仓库之外,私钥仍留在 USB Token 上。electron-builder hook 把每个产物交给采用 CRLF 的 `windows-sign.cmd`;该 CMD 只调用一次 SignTool,并指定 SafeNet `/kc "[{{PIN}}]=容器"` 值与 CSP、SHA-256 文件摘要和 DigiCert SHA-256 RFC 3161 时间戳。hook 不会改用其他 SignTool,也不会重试失败的请求。打包编排不会把任何 `DSH_DESKTOP_WINDOWS_*` 字段传给构建与 运行时准备子进程,只会把证书路径、SignTool 路径、密钥容器和 PIN 传入 electron-builder。签名器在已清理的 CMD 环境中只提供经过校验的签名字段;CMD 会禁用延迟展开,在 SignTool 启动前清除这些字段,并仅在 SignTool 必需的命令行中保留 PIN。所有对外诊断都会替换 PIN,而且只能允许专用构建账号和管理员检查该 runner。签名器会在企业 Code Integrity 检查 electron-builder 的临时 NSIS bootstrap 前先为该可执行文件签名;对于生成的可执行文件,只有证书表条目指向文件末尾之外时,才会在最终签名前清除该条目。SignTool、证书、容器、PIN、Token 或签名不可用时,打包会在产生未签名产物前失败。自定义协议提供已安装的前端分发目录和活跃模块图点名的客户端文件,并拒绝路径穿越或访问这些根目录之外的内容。插件安装器 API 只对 Electron 持有的管理 GUI 可用,不存在于浏览器应用或后端 RPC 中。 打包应用会忽略开发资源和项目环境变量覆盖。只有未打包的 Electron 进程可以替换 Node.js 可执行文件、pnpm 入口、dsh 资源 或活跃项目。 diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 123b44f866..30b7fcabe7 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/desktop/README.md -README.md: b2a51010dc37bcca7c7846819b72ababdc227496 -README.zh.md: 241a1e1d900fef49042a7526d29fc473ef568088 +README.md: 6ef18d302531beb80877f23d657d8524ff396b2b +README.zh.md: 94669b2697af894f91f33068526a0c61d6ed7a8f diff --git a/apps/desktop/README.md b/apps/desktop/README.md index b2a51010dc..6ef18d3025 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -117,6 +117,8 @@ Set `DSH_DESKTOP_AUTO_UPDATE_ENV=production` before packaging, then provide `DOW The macOS configuration uses the required release environment instead of accepting whichever certificate appears first in a keychain. It rejects empty values, a malformed Team ID, a signing identity that includes electron-builder's unsupported `Developer ID Application:` prefix, and incomplete notarization credentials. macOS packaging requires the configured identity and its private key. Runtime preparation applies that identity, a secure timestamp, and hardened runtime to every embedded Mach-O file; after signing the application, a deep strict check rejects any other leaf authority or Team ID before artifact creation. Electron-builder notarizes and staples the application before packaging and signs the DMG. The DMG artifact-completion hook then notarizes and staples it before requiring its exact identity, ticket, and Gatekeeper acceptance; only after the hook succeeds can electron-builder publish the file. The private key can come from the login keychain or electron-builder's standard `CSC_LINK` input; ambient `CSC_NAME` and certificate discovery order do not select the release owner. Notary credentials may instead use electron-builder's complete Apple ID or keychain-profile strategy. The two macOS identity variables are also required when repeating the application check manually with `pnpm --dir apps/desktop run verify:mac-signature -- `. +macOS signing visits real files without following Framework symlink aliases. PAK resources retain all shipped languages and are sealed by the enclosing Framework or application signature instead of receiving individual signatures. The [release policy](../../.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md) owns the dependency patch and verification requirements. + ### Windows EV signing Windows release packaging requires `DSH_DESKTOP_WINDOWS_CER_FILE` to identify the public GlobalSign EV leaf certificate, `DSH_DESKTOP_WINDOWS_SIGNTOOL` to identify the SafeNet-compatible SignTool executable, `DSH_DESKTOP_WINDOWS_KEY_CONTAINER` to identify the matching private-key container, and `DSH_DESKTOP_WINDOWS_TOKEN_PIN` to contain the SafeNet Token Password. The certificate file remains outside source control, and the matching private key stays on the USB token. Set the four inputs before running the fixed Windows target: diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 241a1e1d90..94669b2697 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -117,6 +117,8 @@ pnpm run upload:mac:arm64 macOS 配置使用必填发布环境,不会接受钥匙串中最先发现的证书。空值、格式错误的 Team ID、包含 electron-builder 不支持的 `Developer ID Application:` 前缀的签名身份,以及不完整的公证凭据都会被拒绝。macOS 打包要求已配置的身份及其私钥可用。运行时准备会把该身份、安全时间戳与 hardened runtime 应用到每个内嵌 Mach-O 文件;应用签名完成后,深度严格检查会拒绝其他叶证书 Authority 或 Team ID,验证通过才生成发布产物。Electron-builder 会在封装前公证应用并钉票,然后签署 DMG。DMG 的 artifact-completion hook 随后会公证它并钉票,再要求其身份、票据与 Gatekeeper 验证全部通过;只有 hook 成功,electron-builder 才能发布该文件。私钥可以来自登录钥匙串或 electron-builder 的标准 `CSC_LINK` 输入;环境中的 `CSC_NAME` 与证书发现顺序都不能选择发布所有者。公证凭据也可以使用 electron-builder 支持的完整 Apple ID 或钥匙串 profile 方式。手动执行 `pnpm --dir apps/desktop run verify:mac-signature -- ` 重复应用检查时,也必须提供两个 macOS 身份变量。 +macOS 签名遍历真实文件,不跟随 Framework 的软链接别名。PAK 资源保留全部随附语言,由外层 Framework 或应用签名记录完整性,不逐个签名。[发布策略](../../.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md)负责依赖补丁和验证要求。 + ### Windows EV 签名 Windows 发布打包要求 `DSH_DESKTOP_WINDOWS_CER_FILE` 标识公开的 GlobalSign EV 叶证书,要求 `DSH_DESKTOP_WINDOWS_SIGNTOOL` 标识与 SafeNet 兼容的 SignTool 可执行文件,要求 `DSH_DESKTOP_WINDOWS_KEY_CONTAINER` 标识匹配的私钥容器,并要求 `DSH_DESKTOP_WINDOWS_TOKEN_PIN` 包含 SafeNet Token Password。证书文件保留在源码仓库之外,匹配的私钥仍位于 USB Token。运行固定 Windows 目标前设置这四个输入: diff --git a/apps/desktop/electron-builder.config.mjs b/apps/desktop/electron-builder.config.mjs index d082658695..aee7a5b375 100644 --- a/apps/desktop/electron-builder.config.mjs +++ b/apps/desktop/electron-builder.config.mjs @@ -69,8 +69,8 @@ export function createElectronBuilderConfig( identity: macOSSigning?.signingIdentity, forceCodeSigning: true, hardenedRuntime: true, - // Runtime files are signed before their integrity inventory is sealed. - signIgnore: ['/Contents/Resources/dsh(?:/|$)'], + // Native runtime files are pre-signed; PAK resources are sealed by their enclosing bundle. + signIgnore: ['/Contents/Resources/dsh(?:/|$)', '\\.pak$'], notarize: true, target: ['dmg', 'zip'], }, diff --git a/apps/desktop/tests/macos-signature.spec.ts b/apps/desktop/tests/macos-signature.spec.ts index 09adb91bf1..ada386cc38 100644 --- a/apps/desktop/tests/macos-signature.spec.ts +++ b/apps/desktop/tests/macos-signature.spec.ts @@ -63,7 +63,7 @@ describe('desktop macOS release signature', () => { identity: RELEASE_ENVIRONMENT.DSH_DESKTOP_MACOS_SIGNING_IDENTITY, forceCodeSigning: true, notarize: true, - signIgnore: ['/Contents/Resources/dsh(?:/|$)'], + signIgnore: ['/Contents/Resources/dsh(?:/|$)', '\\.pak$'], }, dmg: { sign: true, @@ -77,6 +77,21 @@ describe('desktop macOS release signature', () => { expect(typeof config.artifactBuildCompleted).toBe('function') }) + it('seals PAK resources with their enclosing bundle while signing executable code', async () => { + const { createElectronBuilderConfig } = await import('../electron-builder.config.mjs') + const config = createElectronBuilderConfig(RELEASE_ENVIRONMENT, 'darwin', 'arm64') + const ignored = (path: string): boolean => config.mac.signIgnore.some(pattern => new RegExp(pattern).test(path)) + expect(ignored('/App.app/Contents/Frameworks/Electron.framework/Versions/A/Resources/en.lproj/locale.pak')).toBe(true) + expect(ignored('/App.app/Contents/Frameworks/Electron.framework/Versions/A/Resources/resources.pak')).toBe(true) + for (const path of [ + '/App.app/Contents/Resources/runtime/node/node', + '/App.app/Contents/Resources/runtime/pnpm/addon.node', + '/App.app/Contents/Frameworks/Electron.framework/Versions/A/library.dylib', + '/App.app/Contents/Frameworks/Electron.framework', + '/App.app', + ]) expect(ignored(path)).toBe(false) + }) + it('copies the complete runtime despite electron-builder excluding root node_modules', async () => { const { createElectronBuilderConfig } = await import('../electron-builder.config.mjs') const config = createElectronBuilderConfig(RELEASE_ENVIRONMENT, 'darwin', 'arm64') diff --git a/apps/desktop/tests/macos-signing-walk.spec.ts b/apps/desktop/tests/macos-signing-walk.spec.ts new file mode 100644 index 0000000000..8dccc4645b --- /dev/null +++ b/apps/desktop/tests/macos-signing-walk.spec.ts @@ -0,0 +1,36 @@ +/** Exercise the installed macOS signer against Framework file and directory aliases. */ + +import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { expect, it } from 'vitest' + +const builderRequire = createRequire(createRequire(import.meta.url).resolve('app-builder-lib')) +const { walkAsync } = builderRequire(join(dirname(builderRequire.resolve('@electron/osx-sign')), 'util.js')) as { + walkAsync: (path: string) => Promise +} + +// macOS Framework aliases use POSIX file and directory symlinks, unavailable on unprivileged Windows runners. +it.skipIf(process.platform === 'win32')('signs each real Framework file once and retains nested bundle signing', async () => { + const root = mkdtempSync(join(tmpdir(), 'desktop-signing-walk-')) + try { + const contents = join(root, 'Contents') + const framework = join(contents, 'Frameworks', 'Test.framework') + const version = join(framework, 'Versions', 'A') + const resource = join(version, 'Resources', 'en.lproj', 'locale.pak') + const library = join(version, 'library.dylib') + const helper = join(contents, 'Frameworks', 'Helper.app') + const executable = join(helper, 'Contents', 'MacOS', 'helper') + for (const file of [resource, library, executable]) { + mkdirSync(dirname(file), { recursive: true }) + writeFileSync(file, Buffer.from([0xcf, 0xfa, 0xed, 0xfe, 0, 0, 0, 0])) + } + symlinkSync('A', join(framework, 'Versions', 'Current')) + symlinkSync('Versions/Current/Resources', join(framework, 'Resources')) + symlinkSync('Versions/Current/library.dylib', join(framework, 'library.dylib')) + const files = await walkAsync(contents) + expect(files.sort()).toEqual([resource, library, executable, framework, helper].sort()) + expect(new Set(files.map(file => realpathSync(file))).size).toBe(files.length) + } finally { rmSync(root, { recursive: true, force: true }) } +}) diff --git a/patches/@electron__osx-sign@1.3.3.patch b/patches/@electron__osx-sign@1.3.3.patch new file mode 100644 index 0000000000..791434536c --- /dev/null +++ b/patches/@electron__osx-sign@1.3.3.patch @@ -0,0 +1,26 @@ +diff --git a/dist/cjs/util.js b/dist/cjs/util.js +index 77e64b56872bc65bcb0f60ecfbec84eb3da02c6f..8d5b81eea636c3f1672cfe2c66602078bda1e847 100644 +--- a/dist/cjs/util.js ++++ b/dist/cjs/util.js +@@ -153,7 +153,7 @@ async function walkAsync(dirPath) { + const children = await fs.readdir(dirPath); + return await Promise.all(children.map(async (child) => { + const filePath = path.resolve(dirPath, child); +- const stat = await fs.stat(filePath); ++ const stat = await fs.lstat(filePath); + if (stat.isFile()) { + switch (path.extname(filePath)) { + case '.cstemp': // Temporary file generated from past codesign +diff --git a/dist/esm/util.js b/dist/esm/util.js +index d3f66d54b1a88e7410d78affe17de87302db02a3..e86b066d1d21628908ecc848f5bb29a7598605a7 100644 +--- a/dist/esm/util.js ++++ b/dist/esm/util.js +@@ -117,7 +117,7 @@ export async function walkAsync(dirPath) { + const children = await fs.readdir(dirPath); + return await Promise.all(children.map(async (child) => { + const filePath = path.resolve(dirPath, child); +- const stat = await fs.stat(filePath); ++ const stat = await fs.lstat(filePath); + if (stat.isFile()) { + switch (path.extname(filePath)) { + case '.cstemp': // Temporary file generated from past codesign diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a2c6b9edb8..3610e0fe2b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,6 +9,7 @@ overrides: '@deepseek-ai/schemastery': link:vendor/schemastery patchedDependencies: + '@electron/osx-sign@1.3.3': 280e7b2816caf01423097d24e79107a66afeb563a348630ce9928ae8499a5861 '@yao-pkg/pkg@6.21.0': 28edd2180c36691c481522ef81f6f6614505f45e491aad542ac4663d4e6b3ff8 node-pty@1.2.0-beta.15: b40ae545608897914bd25fb009c97eeac478c34e8a910298ddcb01b746534bb0 @@ -18637,7 +18638,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@electron/osx-sign@1.3.3': + '@electron/osx-sign@1.3.3(patch_hash=280e7b2816caf01423097d24e79107a66afeb563a348630ce9928ae8499a5861)': dependencies: compare-version: 0.1.2 debug: 4.4.3(supports-color@9.4.0) @@ -20811,7 +20812,7 @@ snapshots: '@electron/fuses': 1.8.0 '@electron/get': 3.1.0 '@electron/notarize': 2.5.0 - '@electron/osx-sign': 1.3.3 + '@electron/osx-sign': 1.3.3(patch_hash=280e7b2816caf01423097d24e79107a66afeb563a348630ce9928ae8499a5861) '@electron/rebuild': 4.2.0 '@electron/universal': 2.0.3 '@malept/flatpak-bundler': 0.4.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 7cb1746e11..d0b4c0a6e5 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -87,5 +87,6 @@ minimumReleaseAgeExclude: - '@openai/codex@0.149.1||0.149.1-darwin-arm64||0.149.1-darwin-x64||0.149.1-linux-arm64||0.149.1-linux-x64||0.149.1-win32-arm64||0.149.1-win32-x64' patchedDependencies: + '@electron/osx-sign@1.3.3': patches/@electron__osx-sign@1.3.3.patch '@yao-pkg/pkg@6.21.0': patches/@yao-pkg__pkg@6.21.0.patch node-pty@1.2.0-beta.15: patches/node-pty@1.2.0-beta.15.patch From 594305ce19765086472c6ef95c09c3f2a70ce37c Mon Sep 17 00:00:00 2001 From: gengruilin Date: Tue, 8 Sep 2026 23:33:23 +0800 Subject: [PATCH 006/111] fix(mcp): reject repeated tool discovery cursors Refs: https://github.com/deepseek-ai/deepseek-harness/discussions/3660 --- .../2026-07-07-mcp-client-plugin.i18n.yaml | 4 +- .../feature/2026-07-07-mcp-client-plugin.md | 4 +- .../2026-07-07-mcp-client-plugin.zh.md | 4 +- .../expected/mcp-pagination/stderr-cause.txt | 1 + .../tests/mcp-pagination.expected.e2e.ts | 30 ++++++++++++ packages/mcp/mcp-client/README.i18n.yaml | 4 +- packages/mcp/mcp-client/README.md | 1 + packages/mcp/mcp-client/README.zh.md | 1 + packages/mcp/mcp-client/src/tools.ts | 13 ++++- packages/mcp/mcp-client/tests/apply.spec.ts | 47 +++++++++++++++++++ .../tests/fixtures/repeated-cursor-server.ts | 18 +++++++ .../tests/fixtures/repeated-cursor.patch.yml | 17 +++++++ .../mcp/mcp-client/tests/mcp-client.spec.ts | 44 +++++++++++++++++ 13 files changed, 180 insertions(+), 8 deletions(-) create mode 100644 apps/cli/tests/profiles/headless/tests/expected/mcp-pagination/stderr-cause.txt create mode 100644 apps/cli/tests/profiles/headless/tests/mcp-pagination.expected.e2e.ts create mode 100644 packages/mcp/mcp-client/tests/fixtures/repeated-cursor-server.ts create mode 100644 packages/mcp/mcp-client/tests/fixtures/repeated-cursor.patch.yml diff --git a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.i18n.yaml b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.i18n.yaml index 67227acc29..c5435129be 100644 --- a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.md -2026-07-07-mcp-client-plugin.md: 1c451e14ea2a7c494454956ce9bb09d97d4e1e1d -2026-07-07-mcp-client-plugin.zh.md: 78a086d6c218a254e850777fe4024973e8848e17 +2026-07-07-mcp-client-plugin.md: 91c1bf428973eae7935e9c2a14dda7028a734313 +2026-07-07-mcp-client-plugin.zh.md: c0c2dbae5b854734240f8035edfc8d0b5a33408f diff --git a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.md b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.md index 1c451e14ea..91c1bf4289 100644 --- a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.md +++ b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.md @@ -96,12 +96,14 @@ Every MCP tool has two names: This server-qualified shape is the de-facto standard among multi-server agent clients — every surveyed end-user product qualifies MCP tools by server ([Claude Code](https://code.claude.com/docs/en/agent-sdk/mcp#tool-naming-convention) `mcp__github__list_issues`, [Codex](https://openai.com/index/unrolling-the-codex-agent-loop/) `mcp__weather__get-forecast`, [Gemini CLI](https://geminicli.com/docs/tools/mcp-server/#3-tool-naming-and-namespaces), [VS Code](https://github.com/microsoft/vscode/blob/ab9ec62c6a61e429a9abd612ff220c3f4834c9ea/src/vs/workbench/contrib/mcp/common/mcpServer.ts#L217-L260), [Cline](https://github.com/cline/cline/blob/52fdbb1d72f7324a28142a7ba7678d4b53c902f4/sdk/packages/core/src/extensions/mcp/name-transform.ts#L20-L35), [Roo Code](https://github.com/RooCodeInc/Roo-Code/blob/b867ec9145750d0ae1ff7f02d35406e9bf2a0b16/src/utils/mcp-name.ts#L117-L140), [Goose](https://github.com/block/goose/blob/b3a012cbdde854b0fe14f95b1c48543bf6517c0a/crates/goose/src/agents/extension_manager.rs#L1391-L1441), [OpenCode](https://github.com/anomalyco/opencode/blob/d199b1bff90282a4f9cd6251b5fc7b16875a52f6/packages/opencode/src/mcp/catalog.ts#L117-L120)); the exact `mcp____` spelling follows Claude Code and Codex. The `mcp__` marker keeps MCP registrations out of the native tools' namespace and gives permission/telemetry rules a stable shape (`mcp__*`, `mcp__github__*`). -1. On connect: drain `client.listTools()` pagination, derive every tool's `publicName`, then register each as a raw `ToolDefinition` via `ctx.tools.register()`. The MCP JSON Schema and description pass through unchanged (no `defineTool` DSL conversion); only the model-facing `name` is replaced. +1. On connect: drain uncached `tools/list` pagination, derive every tool's `publicName`, then register each as a raw `ToolDefinition` via `ctx.tools.register()`. The MCP JSON Schema and description pass through unchanged (no `defineTool` DSL conversion); only the model-facing `name` is replaced. 2. Listen for `notifications/tools/list_changed` → re-run the same sync (dispose previous generation, register new). Deterministic names mean unchanged tools keep their names across re-syncs. 3. The executor closes over `rawName`; the public name is never sent to the server and never parsed to recover the raw name. 4. No `presentCall`/`presentResult` — UI consumers use the provider-neutral generic-card fallback. 5. Tools are transparent in the system prompt — no "[via MCP]" annotation beyond the name itself. +Each synchronization rejects a repeated non-empty continuation cursor before requesting another page, retaining the previous tool generation. Empty pages cannot establish progress through tool-name uniqueness, so cursor history also detects cycles spanning several pages ([reported failure](https://github.com/deepseek-ai/deepseek-harness/discussions/3660)). Cursor history belongs to one synchronization: a later update may reuse the same cursors. Focused bridge and lifecycle tests cover cycle rejection, retained callable tools, strict startup failure, and notification recovery. This detects repeated cursors; it does not bound a server that continually returns distinct cursors. + ### Public name normalization MCP allows tool names up to 128 characters including `.`; the DeepSeek function-name contract allows `[A-Za-z0-9_-]` and at most 64. Public names are normalized deterministically: invalid characters become `_`, and when replacement or truncation changed the name, a 12-hex-char SHA-256 hash of the `(serverName, rawName)` identity is appended so distinct MCP identities can never collapse into the same public name: diff --git a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.zh.md b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.zh.md index 78a086d6c2..c0c2dbae5b 100644 --- a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.zh.md +++ b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.zh.md @@ -96,12 +96,14 @@ type Config = StdioConfig | StreamableHttpConfig 这种按服务器限定的形式是多服务器 agent 客户端事实上的标准——所有被调研的终端用户产品都按服务器限定 MCP 工具名([Claude Code](https://code.claude.com/docs/en/agent-sdk/mcp#tool-naming-convention) `mcp__github__list_issues`、[Codex](https://openai.com/index/unrolling-the-codex-agent-loop/) `mcp__weather__get-forecast`、[Gemini CLI](https://geminicli.com/docs/tools/mcp-server/#3-tool-naming-and-namespaces)、[VS Code](https://github.com/microsoft/vscode/blob/ab9ec62c6a61e429a9abd612ff220c3f4834c9ea/src/vs/workbench/contrib/mcp/common/mcpServer.ts#L217-L260)、[Cline](https://github.com/cline/cline/blob/52fdbb1d72f7324a28142a7ba7678d4b53c902f4/sdk/packages/core/src/extensions/mcp/name-transform.ts#L20-L35)、[Roo Code](https://github.com/RooCodeInc/Roo-Code/blob/b867ec9145750d0ae1ff7f02d35406e9bf2a0b16/src/utils/mcp-name.ts#L117-L140)、[Goose](https://github.com/block/goose/blob/b3a012cbdde854b0fe14f95b1c48543bf6517c0a/crates/goose/src/agents/extension_manager.rs#L1391-L1441)、[OpenCode](https://github.com/anomalyco/opencode/blob/d199b1bff90282a4f9cd6251b5fc7b16875a52f6/packages/opencode/src/mcp/catalog.ts#L117-L120));`mcp____` 的拼写方式与 Claude Code 和 Codex 一致。`mcp__` 前缀将 MCP 注册与原生工具的命名空间隔离,并为权限/遥测规则提供稳定的匹配模式(`mcp__*`、`mcp__github__*`)。 -1. 连接时:遍历 `client.listTools()` 的分页结果,推导每个工具的 `publicName`,然后通过 `ctx.tools.register()` 将其注册为原始 `ToolDefinition`。MCP 的 JSON Schema 和描述原样透传(不做 `defineTool` DSL 转换);仅替换模型可见的 `name`。 +1. 连接时:遍历未缓存的 `tools/list` 分页结果,推导每个工具的 `publicName`,然后通过 `ctx.tools.register()` 将其注册为原始 `ToolDefinition`。MCP 的 JSON Schema 和描述原样透传(不做 `defineTool` DSL 转换);仅替换模型可见的 `name`。 2. 监听 `notifications/tools/list_changed` → 重新执行同步(dispose 上一代、注册新一代)。确定性命名意味着未变化的工具在重新同步后保持原名。 3. 执行器闭包持有 `rawName`;公开名称永远不发送给服务器,也永远不被解析以还原原始名称。 4. 无 `presentCall`/`presentResult`——UI 消费方使用提供方无关的通用卡片兜底。 5. 工具在系统提示词中是透明的——除名称本身外不附加「[via MCP]」标注。 +每次同步在请求下一页前拒绝重复的非空续传游标,并保留上一代工具。空页无法通过工具名唯一性证明分页在前进,因此游标记录还会检测跨越多页的循环([问题报告](https://github.com/deepseek-ai/deepseek-harness/discussions/3660))。游标记录只属于一次同步:后续更新可以复用相同游标。定向桥接与生命周期测试覆盖循环拒绝、保留可调用工具、严格启动失败及通知恢复。此机制检测重复游标;它不限制持续返回不同游标的服务器。 + ### 公开名称规范化 MCP 允许工具名最长 128 字符且可包含 `.`;DeepSeek 的函数名约定允许 `[A-Za-z0-9_-]` 且最多 64 字符。公开名称按确定性规则规范化:非法字符替换为 `_`,当替换或截断改变了名称时,追加 `(serverName, rawName)` 标识的 12 位十六进制 SHA-256 hash,确保不同的 MCP 标识永远不会坍缩为同一个公开名称: diff --git a/apps/cli/tests/profiles/headless/tests/expected/mcp-pagination/stderr-cause.txt b/apps/cli/tests/profiles/headless/tests/expected/mcp-pagination/stderr-cause.txt new file mode 100644 index 0000000000..19a6f28bec --- /dev/null +++ b/apps/cli/tests/profiles/headless/tests/expected/mcp-pagination/stderr-cause.txt @@ -0,0 +1 @@ +Error: mcp-client(pagination-cycle): server repeated a tools/list continuation cursor — invalid tool list diff --git a/apps/cli/tests/profiles/headless/tests/mcp-pagination.expected.e2e.ts b/apps/cli/tests/profiles/headless/tests/mcp-pagination.expected.e2e.ts new file mode 100644 index 0000000000..e9e08bb302 --- /dev/null +++ b/apps/cli/tests/profiles/headless/tests/mcp-pagination.expected.e2e.ts @@ -0,0 +1,30 @@ +/** Startup diagnostics through the shipped headless profile and real MCP stdio transport. */ + +import { fileURLToPath } from 'node:url' +import { expect, it } from 'vitest' +import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' + +const fixtureRoot = new URL('../../../../../../packages/mcp/mcp-client/tests/fixtures/', import.meta.url) +const configPath = fileURLToPath(new URL('repeated-cursor.patch.yml', fixtureRoot)) +const expectedPath = fileURLToPath(new URL('./expected/mcp-pagination/stderr-cause.txt', import.meta.url)) + +it('reports a repeated MCP discovery cursor and exits before starting a turn', async () => { + const { stdout, stderr } = await runLoaderSmoke({ + label: 'MCP discovery pagination cycle', + tempDirPrefix: 'dsh-mcp-pagination-', + binScript: fileURLToPath(new URL('../../../../src/bin.ts', import.meta.url)), + libBinScript: fileURLToPath(new URL('../../../../lib/bin.js', import.meta.url)), + configPath, + binArgs: ['--profile', 'headless', '--patch', configPath, 'unreachable task'], + tsconfigPath: fileURLToPath(new URL('../../../../../../tsconfig.json', import.meta.url)), + expectedExitCode: 1, + env: { + DSH_MCP_PAGINATION_FIXTURE: fileURLToPath(new URL('repeated-cursor-server.ts', fixtureRoot)), + DSH_TELEMETRY_DISABLED: '1', + }, + }) + expect(stdout).toBe('') + expect(stderr).toContain('initial connection or tool synchronization failed') + const cause = stderr.split('\n').find(line => line.startsWith('Error: mcp-client(pagination-cycle):')) + await expect(`${cause}\n`).toMatchFileSnapshot(expectedPath) +}, LOADER_SMOKE_TEST_TIMEOUT_MS) diff --git a/packages/mcp/mcp-client/README.i18n.yaml b/packages/mcp/mcp-client/README.i18n.yaml index 370f6a1e7f..a3f68e19e5 100644 --- a/packages/mcp/mcp-client/README.i18n.yaml +++ b/packages/mcp/mcp-client/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/mcp/mcp-client/README.md -README.md: 112f477556d1b8ee2788c9b96251b6969c617f62 -README.zh.md: e642c7e130510cd717342a2e879c26396c77d55d +README.md: de646256888f1c271ad94a4c863338dfb02551e6 +README.zh.md: 2c71060ec309e823ff3456207881e27ab9aca553 diff --git a/packages/mcp/mcp-client/README.md b/packages/mcp/mcp-client/README.md index 112f477556..de64625688 100644 --- a/packages/mcp/mcp-client/README.md +++ b/packages/mcp/mcp-client/README.md @@ -76,6 +76,7 @@ The model sees each tool under a stable server-qualified name: `mcp____` namespace — the partial generation is @@ -149,6 +149,7 @@ export async function syncTools( ): Promise { // Phase 1: fetch and build the next generation without touching the registry. const definitions = new Map() + const seenCursors = new Set() let cursor: string | undefined do { const response = await listToolsUncached(client, cursor) @@ -172,6 +173,14 @@ export async function syncTools( )) } cursor = response.nextCursor + if (cursor) { + if (seenCursors.has(cursor)) { + throw new Error( + `mcp-client(${opts.serverName}): server repeated a tools/list continuation cursor — invalid tool list`, + ) + } + seenCursors.add(cursor) + } } while (cursor) // Phase 2: swap generations. diff --git a/packages/mcp/mcp-client/tests/apply.spec.ts b/packages/mcp/mcp-client/tests/apply.spec.ts index 1b1716e51b..5dee651d80 100644 --- a/packages/mcp/mcp-client/tests/apply.spec.ts +++ b/packages/mcp/mcp-client/tests/apply.spec.ts @@ -303,6 +303,28 @@ describe('apply (plugin lifecycle)', () => { expect(mockClose).toHaveBeenCalled() }) + it('rejects strict startup on a repeated discovery cursor and closes the client', async () => { + mockListTools + .mockResolvedValueOnce({ tools: [], nextCursor: 'cursor1' }) + .mockResolvedValueOnce({ tools: [], nextCursor: 'cursor1' }) + .mockRejectedValue(new Error('pagination continued after the repeated cursor')) + try { + await expect(apply(ctx, { + ...stdioConfig, + failOnStartupError: true, + reconnect: { enabled: false }, + })).rejects.toMatchObject({ + message: 'mcp-client(srv): initial connection or tool synchronization failed', + cause: new Error('mcp-client(srv): server repeated a tools/list continuation cursor — invalid tool list'), + }) + expect(mockListTools).toHaveBeenCalledTimes(2) + expect(mockClose).toHaveBeenCalledTimes(1) + expect(ctx.tools.schemas()).toEqual([]) + } finally { + await ctx.fiber.dispose() + } + }) + it('preserves strict startup registration when list_changed arrives before connect resolves', async () => { ctx.tools.register({ name: 'mcp__srv__remote', @@ -358,6 +380,31 @@ describe('apply (plugin lifecycle)', () => { expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() }) + it('continues notification synchronization after rejecting a pagination cycle', async () => { + try { + await apply(ctx, stdioConfig) + const handler = mockSetNotificationHandler.mock.calls[0]![1] as () => Promise + mockListTools + .mockResolvedValueOnce({ tools: [], nextCursor: 'cursor1' }) + .mockResolvedValueOnce({ tools: [], nextCursor: 'cursor1' }) + .mockRejectedValue(new Error('pagination continued after the repeated cursor')) + + await handler() + expect(mockListTools).toHaveBeenCalledTimes(3) + expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() + + mockListTools + .mockResolvedValueOnce({ tools: [], nextCursor: 'cursor1' }) + .mockResolvedValueOnce({ tools: [{ name: 'updated', inputSchema: { type: 'object' } }], nextCursor: undefined }) + await handler() + expect(ctx.tools.get('mcp__srv__remote')).toBeUndefined() + expect(ctx.tools.get('mcp__srv__updated')).toBeDefined() + } finally { + await ctx.fiber.dispose() + } + expect(mockClose).toHaveBeenCalledTimes(1) + }) + it('effect disposer unregisters the CURRENT generation and closes client', async () => { // Load through ctx.plugin so ONLY the plugin's fiber is disposed — the // registry must survive to observe the unregistration. diff --git a/packages/mcp/mcp-client/tests/fixtures/repeated-cursor-server.ts b/packages/mcp/mcp-client/tests/fixtures/repeated-cursor-server.ts new file mode 100644 index 0000000000..d7c71f6394 --- /dev/null +++ b/packages/mcp/mcp-client/tests/fixtures/repeated-cursor-server.ts @@ -0,0 +1,18 @@ +/** MCP wire fixture returning a repeated discovery cursor over empty pages. */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' +import { ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js' + +const server = new McpServer( + { name: 'repeated-cursor', version: '1.0.0' }, + { capabilities: { tools: {} } }, +) +let requests = 0 +server.server.setRequestHandler(ListToolsRequestSchema, () => { + // Bound the broken-client path without relying on a test timeout to kill it. + if (++requests > 2) throw new Error('pagination continued after the repeated cursor') + return { tools: [], nextCursor: 'same-cursor' } +}) + +await server.connect(new StdioServerTransport()) diff --git a/packages/mcp/mcp-client/tests/fixtures/repeated-cursor.patch.yml b/packages/mcp/mcp-client/tests/fixtures/repeated-cursor.patch.yml new file mode 100644 index 0000000000..1a4d033b5b --- /dev/null +++ b/packages/mcp/mcp-client/tests/fixtures/repeated-cursor.patch.yml @@ -0,0 +1,17 @@ +- id: headless-startup + disabled: true + +- id: headless-runner + disabled: true + +- insert: + - id: mcp-pagination-cycle + name: '@deepseek-ai/dsh-mcp-client' + config: + transport: stdio + serverName: pagination-cycle + command: !!js process.execPath + args: !!js '[process.env.DSH_MCP_PAGINATION_FIXTURE]' + failOnStartupError: true + reconnect: + enabled: false diff --git a/packages/mcp/mcp-client/tests/mcp-client.spec.ts b/packages/mcp/mcp-client/tests/mcp-client.spec.ts index 605a31ece1..929331b98c 100644 --- a/packages/mcp/mcp-client/tests/mcp-client.spec.ts +++ b/packages/mcp/mcp-client/tests/mcp-client.spec.ts @@ -309,6 +309,50 @@ describe('syncTools', () => { expect(ctx.tools.get('mcp__srv__page2')).toBeDefined() }) + it.each([ + ['immediate', ['cursor1', 'cursor1']], + ['multi-page', ['cursor1', 'cursor2', 'cursor1']], + ])('rejects a pagination cycle through empty pages (%s)', async (_kind, cursors) => { + const client = createMockClient([]) + client.listTools.mockRejectedValue(new Error('pagination continued after the repeated cursor')) + for (const nextCursor of cursors) { + client.listTools.mockResolvedValueOnce({ tools: [], nextCursor }) + } + + await expect(syncTools(client as never, ctx, defaultOpts, new Map())) + .rejects.toThrow('mcp-client(srv): server repeated a tools/list continuation cursor — invalid tool list') + expect(client.listTools).toHaveBeenCalledTimes(cursors.length) + expect(ctx.tools.schemas()).toEqual([]) + }) + + it('keeps callable tools after a pagination cycle and accepts a later complete list', async () => { + const client = createMockClient([{ name: 'stable', inputSchema: { type: 'object' } }]) + const previous = await syncTools(client as never, ctx, defaultOpts, new Map()) + const stable = ctx.tools.get('mcp__srv__stable') + client.listTools + .mockResolvedValueOnce({ tools: [{ name: 'partial', inputSchema: { type: 'object' } }], nextCursor: 'cursor1' }) + .mockResolvedValueOnce({ tools: [], nextCursor: 'cursor1' }) + .mockRejectedValue(new Error('pagination continued after the repeated cursor')) + + await expect(syncTools(client as never, ctx, defaultOpts, previous)).rejects.toThrow(/repeated.*cursor/) + expect(client.listTools).toHaveBeenCalledTimes(3) + expect(ctx.tools.get('mcp__srv__stable')).toBe(stable) + expect(ctx.tools.get('mcp__srv__partial')).toBeUndefined() + const result = await ctx.tools.execute({ + signal: testToolSignal, callId: ToolCallId('pagination-retained'), name: 'mcp__srv__stable', arguments: {}, + }) + expect(result.isError).toBe(false) + expect(result.content).toEqual([{ type: 'text', text: 'ok' }]) + + client.listTools + .mockResolvedValueOnce({ tools: [], nextCursor: 'cursor1' }) + .mockResolvedValueOnce({ tools: [{ name: 'recovered', inputSchema: { type: 'object' } }], nextCursor: undefined }) + const recovered = await syncTools(client as never, ctx, defaultOpts, previous) + expect([...recovered.keys()]).toEqual(['mcp__srv__recovered']) + expect(ctx.tools.get('mcp__srv__stable')).toBeUndefined() + expect(client.listTools).toHaveBeenLastCalledWith({ cursor: 'cursor1' }) + }) + it('owns output validation independently of the SDK per-page cache', async () => { const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() serverTransport.onmessage = (message) => { From 00c2c47e19f6f5af388712c0c6b75447e99fc860 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 9 Sep 2026 12:52:09 +0800 Subject: [PATCH 007/111] 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 008/111] 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 009/111] 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 010/111] 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 4158a96e14ecb615e4f3d3f7c31c347bb45ee5dd Mon Sep 17 00:00:00 2001 From: yudshj Date: Wed, 9 Sep 2026 11:27:02 +0800 Subject: [PATCH 011/111] feat(web): add native file actions to artifact cards --- ...8-present-workspace-source-files.i18n.yaml | 4 +- ...26-09-08-present-workspace-source-files.md | 2 + ...09-08-present-workspace-source-files.zh.md | 2 + apps/web/tests/present.e2e.ts | 20 +++- apps/web/tests/present.overlay.yml | 4 + docs/subsystems/session.i18n.yaml | 4 +- docs/subsystems/session.md | 8 +- docs/subsystems/session.zh.md | 8 +- .../api/session-controller/README.i18n.yaml | 4 +- packages/api/session-controller/README.md | 2 +- packages/api/session-controller/README.zh.md | 2 +- packages/api/session-controller/src/index.ts | 19 +++- packages/api/session-controller/src/types.ts | 2 + .../session-open-workspace-path.host.spec.ts | 32 +++++++ .../session-controller/tests/test-remote.ts | 2 + .../client/ui-deliverables/README.i18n.yaml | 4 +- packages/client/ui-deliverables/README.md | 6 +- packages/client/ui-deliverables/README.zh.md | 6 +- .../src/client/Deliverables.module.css | 28 ++++-- .../src/client/Deliverables.tsx | 43 +++++---- .../src/client/PresentedFileCard.tsx | 71 ++++++++++++++ .../ui-deliverables/src/client/index.ts | 6 +- .../ui-deliverables/src/client/locales.ts | 34 +++++++ .../src/client/present-open.ts | 57 ++++++++--- .../ui-deliverables/src/present-open.ts | 12 ++- .../client/ui-deliverables/src/presented.ts | 26 +++++ .../tests/present-open.client.spec.ts | 55 +++++++++++ .../tests/present-open.host.spec.ts | 27 +++++- .../tests/presented-file-card.client.spec.tsx | 95 +++++++++++++++++++ .../tests/produced-files.client.spec.tsx | 28 +++++- .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 2 +- .../ui-primitives/src/Button.module.css | 3 + packages/client/ui-primitives/src/Button.tsx | 6 +- packages/client/ui-primitives/src/Menu.tsx | 23 ++++- .../extensions/tool-cordis/src/api-catalog.ts | 8 +- packages/util/native-command/README.i18n.yaml | 4 +- packages/util/native-command/README.md | 2 + packages/util/native-command/README.zh.md | 2 + packages/util/native-command/src/index.ts | 3 + .../util/native-command/src/path-opener.ts | 53 ++++++++++- .../native-command/tests/path-opener.spec.ts | 54 ++++++++++- snapshots/web/present/ui.expected.md | 17 +++- 44 files changed, 702 insertions(+), 94 deletions(-) create mode 100644 apps/web/tests/present.overlay.yml create mode 100644 packages/client/ui-deliverables/src/client/PresentedFileCard.tsx create mode 100644 packages/client/ui-deliverables/tests/presented-file-card.client.spec.tsx diff --git a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.i18n.yaml b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.i18n.yaml index 9d992637b1..45b58e1d36 100644 --- a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.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-present-workspace-source-files.md -2026-09-08-present-workspace-source-files.md: 6a8ebfcf1fd7be22a5bda5a209d0fdc3586931bb -2026-09-08-present-workspace-source-files.zh.md: d9361f2281eeb027e4c44b84c7f01af1639b66f2 +2026-09-08-present-workspace-source-files.md: 525a470d09cb5cf1d5bdbeef26b2414e31e900c9 +2026-09-08-present-workspace-source-files.zh.md: 3d969d0f4488b19f4c62d4d2e867b84519000f0a diff --git a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md index 6a8ebfcf1f..525a470d09 100644 --- a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md +++ b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md @@ -18,6 +18,8 @@ The tool remains an ordinary package with shared filesystem and tool error class An authenticated POST selects a declaration by viewed Session, event sequence, and original file index. The event carries no owning Session ID; relative paths in inherited history resolve against the viewed Session's workspace. The Host rechecks canonical workspace containment and regular-file existence before native opening. Route disposal cancels and awaits pending commands. The “Files changed” row lists successful file-tool mutations and retains its separate text-preview behavior. Its Chinese label is “本轮文件改动”; neither label implies final delivery. +File cards separate default-app opening from file-manager navigation. The Host selects the file in Finder or Explorer, or opens its containing folder through the default Linux file manager. Both actions resolve the same saved declaration and recheck workspace containment; neither accepts a browser-supplied replacement path. Host-derived desktop metadata keeps remote-browser labels and availability honest, and the route enforces the configured availability on each gesture. A split button preserves one-click default-app opening while keeping the folder action explicit. + ## Alternatives considered **Immutable attachment snapshots and editable temporary copies** preserve delivered versions after source edits or deletion, but make desktop edits diverge from workspace files and introduce retention work without a current product requirement. This decision supersedes the [snapshot-delivery design](../../archived/feature/2026-09-08-web-explicit-file-delivery.md). Neither a download endpoint nor a fallback copy remains; both require an explicit future product decision. diff --git a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md index d9361f2281..3d969d0f44 100644 --- a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md +++ b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md @@ -18,6 +18,8 @@ Status: implemented 经过认证的 POST 按当前查看的 Session、事件序号和原始文件索引选择声明。事件不携带所属 Session ID;继承历史中的相对路径按当前查看的 Session 工作区解析。Host 在原生打开前重新检查规范路径的工作区包含关系和普通文件是否存在。路由释放时取消并等待进行中的命令。“本轮文件改动”行列出成功的文件工具修改,并保留独立的文本预览行为。其英文标签为“Files changed”;两个标签均不表示最终交付。 +文件卡片区分默认应用打开与文件管理器导航。Host 在 Finder 或文件资源管理器中选中文件,或通过 Linux 默认文件管理器打开所在文件夹。两个操作都解析同一份已保存声明并重新检查工作区包含关系;均不接受浏览器提供的替代路径。来自 Host 的桌面信息使远程浏览器中的文案和可用性保持准确,路由在每次操作时执行配置的可用性检查。分段按钮保留一键默认应用打开,同时提供明确的文件夹操作。 + ## 考虑过的替代方案 **不可变附件快照和可编辑临时副本**可在源文件编辑或删除后保留交付版本,但会使桌面编辑与工作区文件分离,并在缺少当前产品需求时引入保留工作。本决策取代[快照交付设计](../../archived/feature/2026-09-08-web-explicit-file-delivery.md)。不保留下载端点或回退副本;两者都需要未来明确的产品决策。 diff --git a/apps/web/tests/present.e2e.ts b/apps/web/tests/present.e2e.ts index 52aee329fa..802e5eed1d 100644 --- a/apps/web/tests/present.e2e.ts +++ b/apps/web/tests/present.e2e.ts @@ -35,7 +35,7 @@ describe.skipIf(process.platform === 'win32' || release().toLowerCase().includes const events: SessionEvent[] = [] let nativeRoot: string | undefined let openLog: string - const opened = async (): Promise> => (await readFile(openLog, 'utf8')).split('\n').filter(Boolean).map(line => JSON.parse(line) as { path: string; content: string }) + const opened = async (): Promise> => (await readFile(openLog, 'utf8')).split('\n').filter(Boolean).map(line => JSON.parse(line) as { path: string; content: string | null; action: 'open' | 'reveal' }) const downloads: string[] = [] beforeAll(async () => { @@ -46,11 +46,14 @@ describe.skipIf(process.platform === 'win32' || release().toLowerCase().includes const command = process.platform === 'darwin' ? 'open' : 'xdg-open' await writeFile(join(nativeRoot, command), `#!${process.execPath} const fs = require('node:fs'); -fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path: process.argv[2], content: fs.readFileSync(process.argv[2], 'utf8') }) + '\\n'); +const path = process.argv[2] === '-R' ? process.argv[3] : process.argv[2]; +const action = process.argv[2] === '-R' || fs.statSync(path).isDirectory() ? 'reveal' : 'open'; +fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, content: action === 'open' ? fs.readFileSync(path, 'utf8') : null }) + '\\n'); `, { mode: 0o700 }) vi.stubEnv('PATH', `${nativeRoot}${delimiter}${process.env.PATH ?? ''}`) await mkdir(DIR, { recursive: true }) scaffold = await launchWebScaffold({ + extraOverlayPath: fileURLToPath(new URL('./present.overlay.yml', import.meta.url)), agentPresets: { roots: [], default: 'ptc' }, compareReplaySession: true, ...(MODE === 'record' ? {} : { replayFixture: FIXTURE }), }) @@ -118,7 +121,14 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path: process.arg } const row = page.locator('[data-presented-files-row]') await row.waitFor() - expect(await row.getByRole('button').count()).toBe(2) + expect(await row.getByRole('button').count()).toBe(4) + const beforeReveal = (await opened()).length + await row.getByRole('button', { name: 'More file actions for report.txt', exact: true }).click() + const revealResponse = page.waitForResponse(response => response.url().includes('action=reveal') && response.request().method() === 'POST') + await page.getByRole('menuitem', { name: process.platform === 'darwin' ? /Show in Finder/ : /Open containing folder/ }).click() + expect((await revealResponse).status()).toBe(204) + await expect.poll(opened).toHaveLength(beforeReveal + 1) + expect((await opened()).at(-1)).toEqual({ action: 'reveal', content: null, path: await realpath(process.platform === 'darwin' ? join(cwd, 'report.txt') : cwd) }) for (const [name, bytes] of [['report.txt', 'EDITED_REPORT\n'], ['说明.txt', 'EDITED_NOTE\n']] as const) { const count = (await opened()).length const response = page.waitForResponse(response => response.url().includes('/api/present.open?') && response.request().method() === 'POST') @@ -126,7 +136,7 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path: process.arg expect((await response).status()).toBe(204) await page.waitForFunction(() => document.querySelector('[data-presented-files-row] button:disabled') === null) expect(await opened()).toHaveLength(count + 1) - expect((await opened()).at(-1)).toEqual({ path: await realpath(join(cwd, name)), content: bytes }) + expect((await opened()).at(-1)).toEqual({ action: 'open', path: await realpath(join(cwd, name)), content: bytes }) } } const count = (await opened()).length @@ -135,7 +145,7 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path: process.arg await page.waitForFunction(() => document.querySelector('[data-presented-files-row] button:disabled') === null) expect((await openedResponse).status()).toBe(204) expect(await opened()).toHaveLength(count + 1) - expect((await opened()).at(-1)).toEqual({ path: await realpath(join(cwd, 'report.txt')), content: 'EDITED_REPORT\n' }) + expect((await opened()).at(-1)).toEqual({ action: 'open', path: await realpath(join(cwd, 'report.txt')), content: 'EDITED_REPORT\n' }) expect(downloads).toEqual([]) const response = await page.request.get(new URL(`/api/session.export?sessionId=${sessionId}`, scaffold.authenticatedUrl).href) expect(response.status()).toBe(200) diff --git a/apps/web/tests/present.overlay.yml b/apps/web/tests/present.overlay.yml new file mode 100644 index 0000000000..a39a66dad9 --- /dev/null +++ b/apps/web/tests/present.overlay.yml @@ -0,0 +1,4 @@ +# Native commands are owned fixtures; desktop availability must be identical in headless CI. +- id: session-controller + config: + nativeOpen: true diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index 26fee2e323..e8789c3d41 100644 --- a/docs/subsystems/session.i18n.yaml +++ b/docs/subsystems/session.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/session.md -session.md: de0930ea7effcba69bc1f9a4dd405ceda23919d7 -session.zh.md: ec15dbdec3a8887d8fa87c9698b8ad14699a534b +session.md: 8b4da0f7969fad652ac6ea2288746198f9813954 +session.zh.md: ae554ebaf620d5182804cf4604498d843dbeadaf diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index de0930ea7e..8b4da0f796 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -680,7 +680,7 @@ The backends that consume this contract are on [persistence.md](persistence.md). `ModelCatalog` is the Host-generation model directory returned by `session/modelCatalog`: it carries the deployment default, routable provider ids, successful provider groups, and isolated provider failures. It is not derived from one Session and remains separate from Session projections. -`SessionOpenWorkspacePathRequest` carries an absolute or workspace-resolved `path`. `SessionOpenWorkspacePathValue` confirms that the Host accepted the native handoff. A Session-aware Client resolves relative paths against its current Session cwd when known; the controller hands the path to the opener unchanged and reports invalid requests, cancellation, and opener failures through the Session Remote error vocabulary. +`SessionOpenWorkspacePathRequest` carries an absolute or workspace-resolved `path`; optional `action: "reveal"` selects file-manager navigation instead of default-application opening. `SessionOpenWorkspacePathValue` confirms that the Host accepted the native handoff. A Session-aware Client resolves relative paths against its current Session cwd when known; the controller hands the path to the opener unchanged and reports invalid requests, cancellation, and opener failures through the Session Remote error vocabulary. @@ -754,6 +754,12 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise @@ -758,6 +758,12 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise Promise + /** Native file-manager handoff. */ + readonly revealPath?: (path: string, signal: AbortSignal) => Promise /** Native handoff availability probe. */ readonly canOpenPath?: () => boolean } @@ -105,6 +108,7 @@ export class SessionController extends TypertRemoteService { private readonly history: SessionHistoryController private readonly listState: ApiSessionList private readonly openPath: (path: string, signal: AbortSignal) => Promise + private readonly revealPath: (path: string, signal: AbortSignal) => Promise private readonly canOpenPath: () => boolean private readonly promotions = new Set>() @@ -132,6 +136,7 @@ export class SessionController extends TypertRemoteService { this.history = new SessionHistoryController(ctx, (observation) => { this.promote(observation) }) this.listState = new ApiSessionList(ctx) this.openPath = internals.openPath ?? openNativePath + this.revealPath = internals.revealPath ?? revealNativePath this.canOpenPath = internals.canOpenPath ?? (() => config.nativeOpen ?? (internals.openPath !== undefined || canOpenNativePath())) ctx.plugin(SessionFileReferences) @@ -268,6 +273,15 @@ export class SessionController extends TypertRemoteService { return this.canOpenPath() } + /** + * Describe the serving desktop for authenticated file-action routes. + * @returns Host name, configured availability, and platform-specific file-manager behavior. + */ + workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' | 'explorer' | 'directory' | null } { + const fileManager = nativeFileManager() + return { name: hostname(), available: fileManager !== null && this.canOpenPath(), fileManager } + } + /** * Open one path prepared by a Session-aware caller on the Host desktop. * @param request - path after best-effort Session workspace resolution. @@ -289,7 +303,8 @@ export class SessionController extends TypertRemoteService { } signal.throwIfAborted() try { - await this.openPath(request.path, signal) + if (request.action === 'reveal') await this.revealPath(request.path, signal) + else await this.openPath(request.path, signal) return { opened: true } } catch (error: unknown) { if (signal.aborted) throw new RemoteError('gateway/cancelled', 'path open was aborted', {}) diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts index 607baed3cf..60f7a93e56 100644 --- a/packages/api/session-controller/src/types.ts +++ b/packages/api/session-controller/src/types.ts @@ -358,6 +358,8 @@ export interface SessionCancelValue { /** Request to open one path prepared by a Session-aware caller on the Host desktop. */ export interface SessionOpenWorkspacePathRequest { + /** File-manager navigation when requested; omission uses the default application. */ + readonly action?: 'reveal' /** Path after best-effort Session workspace resolution, in Host filesystem syntax. */ readonly path: string } diff --git a/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts b/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts index ee199ddcdd..eb789cb374 100644 --- a/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts +++ b/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts @@ -1,3 +1,4 @@ +import * as nativeCommand from '@deepseek-ai/dsh-native-command' import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' import SessionStore from '@deepseek-ai/dsh-session' @@ -138,3 +139,34 @@ describe('session/openWorkspacePath', () => { }) }) }) + + +it('reports Host file-manager metadata and dispatches reveal separately from default-app open', async () => { + const ctx = await context() + const revealPath = vi.fn(async (_path: string, _signal: AbortSignal) => {}) + const openPath = vi.fn(async (_path: string, _signal: AbortSignal) => {}) + const controller = createSessionTestController(ctx, { + defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', openPath, revealPath, + }) + try { + expect(controller.workspaceDesktop()).toMatchObject({ available: true, name: expect.any(String) as string }) + const signal = new AbortController().signal + await controller.openWorkspacePath({ path: '/workspace/report.txt', action: 'reveal' }, signal) + expect(revealPath).toHaveBeenCalledWith('/workspace/report.txt', signal) + expect(openPath).not.toHaveBeenCalled() + } finally { await ctx.fiber.dispose() } +}) + +it('uses the native reveal adapter without a test override and respects unsupported desktop metadata', async () => { + const ctx = await context() + const reveal = vi.spyOn(nativeCommand, 'revealNativePath').mockResolvedValue(undefined) + const manager = vi.spyOn(nativeCommand, 'nativeFileManager').mockReturnValue(null) + try { + const controller = createSessionTestController(ctx, { + defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', nativeOpen: true, + }) + expect(controller.workspaceDesktop()).toMatchObject({ available: false, fileManager: null }) + await controller.openWorkspacePath({ path: '/report.txt', action: 'reveal' }, new AbortController().signal) + expect(reveal).toHaveBeenCalledOnce() + } finally { manager.mockRestore(); reveal.mockRestore(); await ctx.fiber.dispose() } +}) diff --git a/packages/api/session-controller/tests/test-remote.ts b/packages/api/session-controller/tests/test-remote.ts index ac992baced..721288f7dc 100644 --- a/packages/api/session-controller/tests/test-remote.ts +++ b/packages/api/session-controller/tests/test-remote.ts @@ -91,6 +91,7 @@ export interface TestSessionRemoteDefaults { readonly nativeOpen?: boolean readonly saveDefaultModelSelection?: (selection: AgentModelSelection) => void | Promise readonly openPath?: (path: string, signal: AbortSignal) => Promise + readonly revealPath?: (path: string, signal: AbortSignal) => Promise readonly canOpenPath?: () => boolean } @@ -284,6 +285,7 @@ function installControllers( }, { ...defaults.openPath === undefined ? {} : { openPath: defaults.openPath }, + ...defaults.revealPath === undefined ? {} : { revealPath: defaults.revealPath }, ...defaults.canOpenPath === undefined ? {} : { canOpenPath: defaults.canOpenPath }, }, ) diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml index a72595b84a..654494e779 100644 --- a/packages/client/ui-deliverables/README.i18n.yaml +++ b/packages/client/ui-deliverables/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-deliverables/README.md -README.md: 4886a5817e6118ef76d5e7b49ed7fe3ed319650f -README.zh.md: 43c68d4563f9ec32bb15fdd3df9a2e21225ebc13 +README.md: 3196202a053ae35bb3055666a680c9d6cfc2a2f8 +README.zh.md: bb394ae756820d7b536ea9326c0837cf59a85a01 diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md index 4886a5817e..3196202a05 100644 --- a/packages/client/ui-deliverables/README.md +++ b/packages/client/ui-deliverables/README.md @@ -30,9 +30,9 @@ Mount this plugin alongside `ui-conversation`; a finished turn then ends with th ### Explicit deliveries -The Web `standard`, `ptc`, and `cordis` presets expose `present` for final workspace files, including files created through Bash. Call it with `files: [{ path, description? }]` after creating the files. The [present tool](../../fs/tool-present/README.md) owns file-count limits and Session declarations. The closing turn shows responsive cards with file names, types, descriptions, and buttons that open the source in the Host’s default application. Matching inline-code references open the same source files without starting a browser download. Repeated declaration of a path selects its latest description before the closing reply. +The Web `standard`, `ptc`, and `cordis` presets expose `present` for final workspace files, including files created through Bash. Call it with `files: [{ path, description? }]` after creating the files. The [present tool](../../fs/tool-present/README.md) owns file-count limits and Session declarations. The closing turn shows responsive cards with file names, types, descriptions, workspace paths, and a split Open button. Its primary action opens the source in the Host’s default application; the menu offers Show in Finder on macOS, Show in File Explorer on Windows and WSL, or Open containing folder through the default Linux file manager. The menu names the serving Host, independently of the browser’s operating system. Matching inline-code references open the same source files without starting a browser download. Repeated declaration of a path selects its latest description before the closing reply. -The `present` tool row shows running, delivered, failed, or interrupted status; expanding a settled row reveals its recorded result. File cards include every delivered file. Opening shows progress, confirmation, or a retryable error on the card. It requires a desktop and a suitable default application on the serving Host; a remote browser does not open applications on its own device. +The `present` tool row shows running, delivered, failed, or interrupted status; expanding a settled row reveals its recorded result. File cards include every delivered file. Both actions share pending state and show progress, acknowledgement, or an action-specific retryable error. A missing desktop disables both actions; a failed desktop-information read offers Retry. It requires a desktop and a suitable default application on the serving Host; a remote browser does not open applications on its own device. ### The row @@ -52,7 +52,7 @@ The closing prose carries the same vocabulary: an inline-code token resolves by The Node half registers the static `ui:deliverable-file-references` system-prompt section asking the model to mention primary files from successful creation or modification calls and to write those and any other changed-file references as Markdown inline code. The browser half registers a wrapper around `ProducedFiles` and explicit deliveries into the chat view's `conversation.chat.turnTail` hole. `deliverablesDefinition` folds each Turn's successful first-party mutation calls into `DeliverablesTurnData` from the validated raw arguments of `write`, `edit`, and mutating `str_replace_editor` commands. Reads, deletes, unsupported tools, malformed calls, and failed results contribute nothing. A new mutation tool needs an explicit Client contribution before it joins the list. The package also provides the `chatFileMentions` service the chat view consults per closing message; composing the plugin out removes both surfaces and leaves the view's empty chain at zero cost. -Native opening uses an authenticated POST addressed by the viewed Session, event sequence, and original file index. The Host resolves the declaration against that Session’s workspace and checks the current file exists within it before launching the default application. Edits affect subsequent opens; deletion returns an error. No file-content copy or attachment is created. Plugin disposal cancels and awaits pending native-open requests. +Native opening uses an authenticated POST addressed by the viewed Session, event sequence, and original file index. The Host resolves the declaration against that Session’s workspace and checks the current file exists within it before launching either native action. The same configured desktop availability governs metadata and execution. Edits affect subsequent opens; deletion returns an error. No file-content copy or attachment is created. Plugin disposal cancels and awaits pending native-open requests. diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index 43c68d4563..bb394ae756 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -30,9 +30,9 @@ kind: "package-reference" ### 显式交付 -Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交付最终工作区文件,包括通过 Bash 创建的文件。创建文件后,以 `files: [{ path, description? }]` 调用。[present 工具](../../fs/tool-present/README.zh.md)拥有文件数量限制和 Session 声明。收尾 turn 显示响应式卡片,包含文件名称、类型、说明和在 Host 默认应用中打开源文件的按钮。匹配的行内代码引用打开相同源文件,不触发浏览器下载。同一路径重复声明时,选择收尾回复之前最近一次的说明。 +Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交付最终工作区文件,包括通过 Bash 创建的文件。创建文件后,以 `files: [{ path, description? }]` 调用。[present 工具](../../fs/tool-present/README.zh.md)拥有文件数量限制和 Session 声明。收尾 turn 显示响应式卡片,包含文件名称、类型、说明、工作区路径和分段“打开”按钮。主按钮在 Host 默认应用中打开源文件;菜单在 macOS 上提供“在 Finder 中显示”,在 Windows 和 WSL 上提供“在文件资源管理器中显示”,在 Linux 上通过默认文件管理器“打开所在文件夹”。菜单显示实际提供服务的 Host 名称,不依赖浏览器的操作系统。匹配的行内代码引用打开相同源文件,不触发浏览器下载。同一路径重复声明时,选择收尾回复之前最近一次的说明。 -`present` 工具行显示正在交付、已交付、失败或中断状态;展开已结束的调用可查看其记录的结果。文件卡片展示全部交付文件。打开时,卡片显示进度、成功确认或可重试的错误。服务 Host 必须具备桌面和合适的默认应用;远程浏览器不会打开其所在设备上的应用。 +`present` 工具行显示正在交付、已交付、失败或中断状态;展开已结束的调用可查看其记录的结果。文件卡片展示全部交付文件。两个操作共享等待状态,并显示进度、请求确认或各自可重试的错误。Host 没有桌面时禁用两个操作;桌面信息读取失败时提供“重试”。服务 Host 必须具备桌面和合适的默认应用;远程浏览器不会打开其所在设备上的应用。 ### 该行 @@ -52,7 +52,7 @@ Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交 Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段,要求模型点名成功创建或修改的主要文件,并把这些文件以及正文中提到的其他本轮变更文件写成 Markdown 行内代码。浏览器半部把组合 `ProducedFiles` 与显式交付的包装组件注册进 chat 视图的 `conversation.chat.turnTail` 洞。`deliverablesDefinition` 根据 `write`、`edit` 和有修改作用的 `str_replace_editor` 命令中经过校验的原始参数,把每个轮次成功的第一方修改调用折叠进 `DeliverablesTurnData`。读取、删除、不受支持的工具、格式错误的调用和失败结果不贡献任何条目。新的修改工具必须增加显式 Client contribution 才能加入列表。本包还提供 chat 视图按收尾消息查询的 `chatFileMentions` 服务;把插件组合出去会同时移除两个表面,视图的空链以零成本留下。 -原生打开使用经过认证的 POST,通过当前查看的 Session、事件序号和原始文件索引定位声明。Host 按该 Session 的工作区解析路径,检查当前文件存在且位于工作区内,再启动默认应用。编辑会影响后续打开的内容;删除后返回错误。不创建文件内容副本或附件。插件释放时取消并等待进行中的原生打开请求。 +原生打开使用经过认证的 POST,通过当前查看的 Session、事件序号和原始文件索引定位声明。Host 按该 Session 的工作区解析路径,检查当前文件存在且位于工作区内,再启动所选原生操作。同一份桌面可用性配置同时约束信息查询和实际执行。编辑会影响后续打开的内容;删除后返回错误。不创建文件内容副本或附件。插件释放时取消并等待进行中的原生打开请求。 diff --git a/packages/client/ui-deliverables/src/client/Deliverables.module.css b/packages/client/ui-deliverables/src/client/Deliverables.module.css index 97befd2946..e76ca5dff1 100644 --- a/packages/client/ui-deliverables/src/client/Deliverables.module.css +++ b/packages/client/ui-deliverables/src/client/Deliverables.module.css @@ -1,14 +1,24 @@ -/** Immutable file deliveries at the end of a turn. */ +/** File metadata and joined native actions remain readable in narrow conversation columns. */ .root { display: flex; flex-direction: column; gap: 8px; min-width: 0; margin-top: 12px; } -.label { font-size: 12px; color: var(--dsw-alias-label-secondary); } +.label { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 8px; font-size: 12px; color: var(--dsw-alias-label-secondary); } +.hostStatus { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--dsw-alias-label-secondary); } .presented { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr)); gap: 8px; } -.file { display: flex; align-items: center; gap: 12px; min-width: 0; padding: 14px; border: 0.5px solid var(--dsw-alias-border-l2); border-radius: 12px; background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary); text-decoration: none; font: inherit; text-align: left; cursor: pointer; } -.file:hover { background: var(--dsw-alias-bg-layer-2); border-color: var(--dsw-alias-border-l3); } -.file:focus-visible { outline: 2px solid var(--dsw-alias-link); outline-offset: 2px; } -.fileIcon { flex: 0 0 auto; width: 24px; height: 24px; } +.file { display: flex; flex-direction: column; gap: 16px; min-width: 0; padding: 16px; border: 0.5px solid var(--dsw-alias-border-l2); border-radius: 14px; background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary); } +.heading { display: flex; align-items: center; gap: 12px; min-width: 0; } +.fileIcon { flex: 0 0 auto; display: grid; place-items: center; width: 40px; height: 46px; border: 0.5px solid var(--dsw-alias-border-l2); border-radius: 8px; background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-link); } +.fileIcon svg { width: 22px; height: 22px; } .details { display: flex; flex-direction: column; gap: 4px; min-width: 0; flex: 1; } +.nameRow { display: flex; align-items: center; gap: 8px; min-width: 0; } .fileName { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; font-weight: 500; } -.metadata { color: var(--dsw-alias-label-tertiary); font-size: 11px; } +.metadata { flex-shrink: 0; color: var(--dsw-alias-label-secondary); background: var(--dsw-alias-interactive-bg-hover); border-radius: 4px; padding: 1px 4px; font-size: 11px; } .description { color: var(--dsw-alias-label-secondary); font-size: 12px; overflow-wrap: anywhere; } -.open { display: flex; flex-direction: column; align-items: center; gap: 4px; flex: 0 0 auto; color: var(--dsw-alias-link); font-size: 11px; } -.file:disabled { cursor: wait; opacity: 0.65; } +.footer { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; margin-top: auto; } +.path { display: flex; align-items: center; gap: 6px; min-width: 0; color: var(--dsw-alias-label-secondary); font-size: 12px; } +.path svg { flex-shrink: 0; } +.path span { overflow-wrap: anywhere; } +.split { display: inline-flex; align-items: stretch; flex-shrink: 0; margin-left: auto; border: 0.5px solid var(--dsw-alias-border-l2); border-radius: 8px; } +.open { color: var(--dsw-alias-link); font-weight: 500; } +.chevron { border-left: 0.5px solid var(--dsw-alias-border-l2); } +.menuLabel { display: flex; flex-direction: column; gap: 2px; } +.menuDetail { color: var(--dsw-alias-label-secondary); font-size: 12px; line-height: 18px; } +@media (pointer: coarse) { .open, .chevron { min-height: 44px; min-width: 44px; } } diff --git a/packages/client/ui-deliverables/src/client/Deliverables.tsx b/packages/client/ui-deliverables/src/client/Deliverables.tsx index 2faf259ad4..420741512a 100644 --- a/packages/client/ui-deliverables/src/client/Deliverables.tsx +++ b/packages/client/ui-deliverables/src/client/Deliverables.tsx @@ -1,20 +1,25 @@ /** Existing changed-file chips and explicitly declared files for a closing turn. */ import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client' -import { LinkIcon, classifyLinkPath, IconRightUpOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import { Button } from '@deepseek-ai/dsh-client-ui-primitives' import type { InjectFace, PropsLocale, SessionStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { PresentedOpenController } from './present-open.ts' import { ProducedFiles } from './ProducedFiles.tsx' -import { basename, presentedForClosing, selectProducedFiles, type PresentedPath } from './turn-deliverables.ts' +import { presentedForClosing, selectProducedFiles, type PresentedPath } from './turn-deliverables.ts' import type { NS } from './locales.ts' import { presentedFileUrl } from '../presented.ts' +import { PresentedFileCard } from './PresentedFileCard.tsx' import css from './Deliverables.module.css' interface DeliverablesMatch { produced: readonly string[]; presented: readonly PresentedPath[] } /** Native-open callbacks and shared gesture status supplied by the plugin. */ export interface DeliverablesInjected { - hooks: { presentedOpen: ObservableSnapshot> } + hooks: { + presentedOpen: ObservableSnapshot> + presentedHost: ObservableSnapshot> + } + reloadPresentedHost: PresentedOpenController['loadHost'] openPresented: PresentedOpenController['open'] } @@ -34,30 +39,28 @@ export function selectDeliverables(owner: TurnTailOwnerProps): DeliverablesMatch * @param props - matched files, workspace opener, and localized copy. * @returns the closing turn's file rows. */ -export function Deliverables({ matched, openFile, t, sessionId, openPresented, usePresentedOpen }: Pick & { +export function Deliverables({ matched, openFile, t, sessionId, openPresented, usePresentedOpen, usePresentedHost, reloadPresentedHost }: Pick & { matched: DeliverablesMatch } & PropsLocale & Pick & InjectFace) { const states = usePresentedOpen(value => value) + const host = usePresentedHost(value => value) return <> {matched.produced.length > 0 && } {matched.presented.length > 0 &&
- {t('presented.label')} +
+ {t('presented.label')} + {host !== null && host !== 'error' && {t('presented.target')}} +
+ {host === 'error' &&
+ {t('presented.hostError')} + +
} + {host !== null && host !== 'error' && !host.available && {t('presented.unavailable')}}
- {matched.presented.map((file) => { - const phase = states[presentedFileUrl(sessionId, file.seq, file.index)] - return })} + {matched.presented.map(file => { void openPresented(sessionId, file.seq, file.index, action) }} />)}
} diff --git a/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx b/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx new file mode 100644 index 0000000000..d3af07034a --- /dev/null +++ b/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx @@ -0,0 +1,71 @@ +/** File identity and explicit default-app or file-manager actions for one delivery. */ +import { useState } from 'react' +import { + Button, Menu, LinkIcon, classifyLinkPath, IconRightUpOutline16, + IconChevronDownOutline14, IconFolderOpenOutline16, +} from '@deepseek-ai/dsh-client-ui-primitives' +import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' +import type { PresentedAction, PresentedHost } from '../presented.ts' +import type { PresentedOpenPhase } from './present-open.ts' +import { basename, type PresentedPath } from './turn-deliverables.ts' +import type { NS } from './locales.ts' +import css from './Deliverables.module.css' + +/** + * Render independent file actions without nesting buttons inside a clickable card. + * @param props - durable file metadata, Host capabilities, gesture status, and localized copy. + * @returns the file card and its anchored action menu. + */ +export function PresentedFileCard({ file, phase, host, onAction, t }: { + file: PresentedPath + phase: PresentedOpenPhase | undefined + host: PresentedHost | null + onAction: (action: PresentedAction) => void +} & PropsLocale) { + const [menuOpen, setMenuOpen] = useState(false) + const pending = phase === 'opening' || phase === 'revealing' + const disabled = pending || host === null || !host.available + const reveal = host?.fileManager ?? 'directory' + const act = (action: PresentedAction) => { setMenuOpen(false); onAction(action) } + const description = (title: string, detail: string) => + {title}{detail} + + return
+
+ +
+
+ {basename(file.path)} + {basename(file.path).match(/\.([^.]+)$/)?.[1]?.toUpperCase() ?? t('presented.file')} +
+ {file.description && {file.description}} +
+
+
+ {file.path} +
+ + { setMenuOpen(false) }} + anchor={} + items={[ + { type: 'label', id: 'host', text: t('presented.host', { name: host?.name ?? '' }) }, + { id: 'open', icon: , + label: description(t('presented.defaultApp'), t('presented.openDetail')) }, + { type: 'separator', id: 'separator' }, + { id: 'reveal', icon: , + label: description(t(`presented.${reveal}`), t(reveal === 'directory' ? 'presented.directoryDetail' : 'presented.revealDetail')) }, + ]} + onSelect={(id) => { act(id === 'reveal' ? 'reveal' : 'open') }} /> +
+
+ {phase !== undefined && + {t(phase === 'revealed' && reveal === 'directory' ? 'presented.directoryOpened' : `presented.${phase}`)} + } +
+} diff --git a/packages/client/ui-deliverables/src/client/index.ts b/packages/client/ui-deliverables/src/client/index.ts index 09156448b4..86ba4e57e4 100644 --- a/packages/client/ui-deliverables/src/client/index.ts +++ b/packages/client/ui-deliverables/src/client/index.ts @@ -41,6 +41,7 @@ export const inject = ['slots', 'locale', 'uiConversation', 'remote', 'remote.se export function apply(ctx: ClientContext): void { const opener = new PresentedOpenController() ctx.effect(() => () => opener.dispose()) + void opener.loadHost() ctx.uiConversation.events.register(deliverablesDefinition) ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-deliverables: dictionaries') ctx.slots.inject( @@ -50,8 +51,9 @@ export function apply(ctx: ClientContext): void { select: selectDeliverables, locale: NS, inject: (): DeliverablesInjected => ({ - hooks: { presentedOpen: opener.state }, - openPresented: (sessionId, seq, index) => opener.open(sessionId, seq, index), + hooks: { presentedOpen: opener.state, presentedHost: opener.host }, + reloadPresentedHost: () => opener.loadHost(), + openPresented: (sessionId, seq, index, action) => opener.open(sessionId, seq, index, action), }), }, Deliverables), ) diff --git a/packages/client/ui-deliverables/src/client/locales.ts b/packages/client/ui-deliverables/src/client/locales.ts index c206f2d23a..7dec4ffdbf 100644 --- a/packages/client/ui-deliverables/src/client/locales.ts +++ b/packages/client/ui-deliverables/src/client/locales.ts @@ -6,6 +6,23 @@ export const NS = 'deliverables' /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { 'presented.label': '交付文件', + 'presented.revealError': '无法打开所在文件夹,请重试', + 'presented.directoryOpened': '已请求打开所在文件夹', + 'presented.revealed': '已请求在文件管理器中显示', + 'presented.revealing': '正在打开所在文件夹…', + 'presented.unavailable': '此主机没有可用的桌面,无法打开文件或文件夹', + 'presented.retry': '重试', + 'presented.hostError': '无法读取主机桌面信息', + 'presented.host': '操作主机:{name}', + 'presented.target': '在 DSH 主机上打开', + 'presented.directoryDetail': '使用系统默认文件管理器', + 'presented.revealDetail': '打开所在文件夹并选中文件', + 'presented.directory': '打开所在文件夹', + 'presented.explorer': '在文件资源管理器中显示', + 'presented.finder': '在 Finder 中显示', + 'presented.openDetail': '打开此文件', + 'presented.defaultApp': '用默认应用打开', + 'presented.more': '{name} 的更多文件操作', 'presented.action': '打开', 'presented.opening': '正在打开…', 'presented.opened': '已在默认程序中打开', @@ -27,6 +44,23 @@ export const zh = { /** English dictionary (same key set). */ export const en: Record = { 'presented.label': 'Deliverables', + 'presented.revealError': 'Could not open containing folder. Try again.', + 'presented.directoryOpened': 'Requested opening containing folder', + 'presented.revealed': 'Requested display in file manager', + 'presented.revealing': 'Opening containing folder…', + 'presented.unavailable': 'This Host has no desktop available to open files or folders', + 'presented.retry': 'Retry', + 'presented.hostError': 'Could not read the Host desktop information', + 'presented.host': 'Opens on {name}', + 'presented.target': 'Opens on the DSH Host', + 'presented.directoryDetail': 'Use the system default file manager', + 'presented.revealDetail': 'Open its folder and select the file', + 'presented.directory': 'Open containing folder', + 'presented.explorer': 'Show in File Explorer', + 'presented.finder': 'Show in Finder', + 'presented.openDetail': 'Open this file', + 'presented.defaultApp': 'Open in default app', + 'presented.more': 'More file actions for {name}', 'presented.action': 'Open', 'presented.opening': 'Opening…', 'presented.opened': 'Opened in default app', diff --git a/packages/client/ui-deliverables/src/client/present-open.ts b/packages/client/ui-deliverables/src/client/present-open.ts index 5b49941a57..8d74c1615f 100644 --- a/packages/client/ui-deliverables/src/client/present-open.ts +++ b/packages/client/ui-deliverables/src/client/present-open.ts @@ -1,15 +1,18 @@ /** Shared native-open status for delivery cards and closing-message file mentions. */ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { SessionId } from '@deepseek-ai/dsh-session/types' -import { presentedFileUrl } from '../presented.ts' +import { presentedFileUrl, PRESENT_HOST_PATH, isPresentedHost, type PresentedAction, type PresentedHost } from '../presented.ts' /** State of the latest explicit open gesture for one saved file. */ -export type PresentedOpenPhase = 'opening' | 'opened' | 'error' +export type PresentedOpenPhase = 'opening' | 'opened' | 'revealing' | 'revealed' | 'error' | 'revealError' /** One browser plugin's file-open requests, cancelled when that plugin is disposed. */ export class PresentedOpenController { /** File action URLs key the state across Sessions, turns, and both clickable surfaces. */ readonly state = createSnapshotStore>({}) + /** Native destination metadata, or a retryable read failure. */ + readonly host = createSnapshotStore(null) + private loading: Promise | undefined private readonly lifetime = new AbortController() private readonly pending = new Set>() @@ -19,13 +22,15 @@ export class PresentedOpenController { * @param sessionId - viewed Session, including a fork's own identity. * @param seq - durable delivery event sequence. * @param index - original file index within that event. + * @param action - default application open or file-manager reveal. * @returns after the Host acknowledges opening or the error state is published. */ - async open(sessionId: SessionId, seq: number, index: number): Promise { + async open(sessionId: SessionId, seq: number, index: number, action: PresentedAction = 'open'): Promise { const url = presentedFileUrl(sessionId, seq, index) - if (this.lifetime.signal.aborted || this.state.getSnapshot()[url] === 'opening') return - this.state.update((state) => { state[url] = 'opening' }) - const task = this.request(url) + const phase = this.state.getSnapshot()[url] + if (this.lifetime.signal.aborted || phase === 'opening' || phase === 'revealing') return + this.state.update((state) => { state[url] = action === 'open' ? 'opening' : 'revealing' }) + const task = this.request(url, action) this.pending.add(task) try { await task @@ -34,20 +39,50 @@ export class PresentedOpenController { } } + /** + * Read the serving desktop metadata, coalescing concurrent reads; a later call retries failure. + * @returns after metadata or a retryable error is published. + */ + async loadHost(): Promise { + if (this.lifetime.signal.aborted) return + if (this.loading !== undefined) return this.loading + this.host.set(null) + const task = this.readHost() + this.loading = task + this.pending.add(task) + try { await task } + finally { this.loading = undefined; this.pending.delete(task) } + } + + private async readHost(): Promise { + let host: PresentedHost | 'error' = 'error' + try { + const response = await fetch(PRESENT_HOST_PATH, { signal: this.lifetime.signal }) + if (response.ok) { + const value: unknown = await response.json() + if (isPresentedHost(value)) host = value + } + } catch { + // HTTP, JSON, and transport failures leave a retryable metadata read. + } + if (!this.lifetime.signal.aborted) this.host.set(host) + } + /** Cancel outstanding requests and wait until no request can publish state. */ async dispose(): Promise { this.lifetime.abort() await Promise.all(this.pending) } - private async request(url: string): Promise { - let phase: PresentedOpenPhase = 'opened' + private async request(url: string, action: PresentedAction): Promise { + const failure = action === 'open' ? 'error' : 'revealError' + let phase: PresentedOpenPhase = action === 'open' ? 'opened' : 'revealed' try { - const response = await fetch(url, { method: 'POST', signal: this.lifetime.signal }) - if (!response.ok) phase = 'error' + const response = await fetch(action === 'open' ? url : `${url}&action=reveal`, { method: 'POST', signal: this.lifetime.signal }) + if (!response.ok) phase = failure } catch { // Transport failures share the retryable card state with Host open failures. - phase = 'error' + phase = failure } if (!this.lifetime.signal.aborted) this.state.update((state) => { state[url] = phase }) } diff --git a/packages/client/ui-deliverables/src/present-open.ts b/packages/client/ui-deliverables/src/present-open.ts index beac77657a..ba5880a0e9 100644 --- a/packages/client/ui-deliverables/src/present-open.ts +++ b/packages/client/ui-deliverables/src/present-open.ts @@ -6,13 +6,18 @@ import type {} from '@deepseek-ai/dsh-api-session-controller' import type {} from '@deepseek-ai/dsh-client-connection' import type {} from '@deepseek-ai/dsh-session-query' import type { SessionId, SessionSeq } from '@deepseek-ai/dsh-session' -import { isPresentedData, isPresentedFile, PRESENT_OPEN_PATH } from './presented.ts' +import { isPresentedData, isPresentedFile, PRESENT_OPEN_PATH, PRESENT_HOST_PATH, type PresentedHost } from './presented.ts' /** * Register native opening inside Connection's authentication fence. * @param ctx - Session lookup, native opener, and route lifetime. */ export function registerPresentOpen(ctx: Context): void { + ctx.connection.fetch.register({ + path: PRESENT_HOST_PATH, methods: ['GET'], requestBody: 'buffered', + fetch: () => Promise.resolve(Response.json(ctx.sessionController.workspaceDesktop() satisfies PresentedHost, + { headers: { 'cache-control': 'no-store' } })), + }) const lifetime = new AbortController() const pending = new Set>() ctx.effect(() => async () => { @@ -36,6 +41,8 @@ export function registerPresentOpen(ctx: Context): void { async function handlePresentOpen(ctx: Context, request: Request): Promise { const query = new URL(request.url).searchParams + const action = query.get('action') ?? 'open' + if (action !== 'open' && action !== 'reveal') return new Response('Invalid file action.', { status: 400 }) const id = query.get('sessionId') const seq = query.get('seq') const index = query.get('index') @@ -45,6 +52,7 @@ async function handlePresentOpen(ctx: Context, request: Request): Promise + return typeof host.name === 'string' && typeof host.available === 'boolean' + && (host.fileManager === null || host.fileManager === 'finder' + || host.fileManager === 'explorer' || host.fileManager === 'directory') +} + /** * Validate a file declaration read from a Session log. * @param value - decoded durable data. diff --git a/packages/client/ui-deliverables/tests/present-open.client.spec.ts b/packages/client/ui-deliverables/tests/present-open.client.spec.ts index b201895445..aa022adf4c 100644 --- a/packages/client/ui-deliverables/tests/present-open.client.spec.ts +++ b/packages/client/ui-deliverables/tests/present-open.client.spec.ts @@ -61,3 +61,58 @@ it('awaits cancellation and prevents late state publication or new requests afte await controller.open(id, 2, 1) expect(fetcher).toHaveBeenCalledOnce() }) + + +it('shares pending state across open and reveal and retries the selected action', async () => { + const reply = Promise.withResolvers() + const fetcher = vi.fn().mockReturnValueOnce(reply.promise).mockResolvedValue(new Response(null, { status: 204 })) + vi.stubGlobal('fetch', fetcher) + const controller = new PresentedOpenController() + const revealing = controller.open(id, 2, 1, 'reveal') + await controller.open(id, 2, 1) + expect(controller.state.getSnapshot()[url]).toBe('revealing') + expect(fetcher).toHaveBeenCalledOnce() + expect(fetcher.mock.calls[0]?.[0]).toBe(`${url}&action=reveal`) + reply.resolve(new Response(null, { status: 500 })) + await revealing + expect(controller.state.getSnapshot()[url]).toBe('revealError') + await controller.open(id, 2, 1, 'reveal') + expect(controller.state.getSnapshot()[url]).toBe('revealed') + await controller.dispose() +}) + +it.each([null, {}, { name: 'host', available: 'yes', fileManager: 'finder' }, + { name: 'host', available: true, fileManager: 'unknown' }, 'invalid json', 'http', 'network', +])('makes invalid Host metadata retryable: %j', async (value) => { + const host = { name: 'linux-host', available: true, fileManager: 'directory' } + const fetcher = vi.fn() + if (value === 'network') fetcher.mockRejectedValueOnce(new Error('offline')) + else if (value === 'http') fetcher.mockResolvedValueOnce(new Response(null, { status: 500 })) + else if (value === 'invalid json') fetcher.mockResolvedValueOnce(new Response('bad JSON')) + else fetcher.mockResolvedValueOnce(Response.json(value)) + fetcher.mockResolvedValueOnce(Response.json(host)) + vi.stubGlobal('fetch', fetcher) + const controller = new PresentedOpenController() + await controller.loadHost() + expect(controller.host.getSnapshot()).toBe('error') + await controller.loadHost() + expect(controller.host.getSnapshot()).toEqual(host) + await controller.dispose() + await controller.loadHost() + expect(fetcher).toHaveBeenCalledTimes(2) +}) + +it('coalesces metadata reads and suppresses their publication after disposal', async () => { + const reply = Promise.withResolvers() + const fetcher = vi.fn().mockReturnValue(reply.promise) + vi.stubGlobal('fetch', fetcher) + const controller = new PresentedOpenController() + const first = controller.loadHost() + const second = controller.loadHost() + expect(fetcher).toHaveBeenCalledOnce() + const disposal = controller.dispose() + expect((fetcher.mock.calls[0]?.[1] as RequestInit).signal?.aborted).toBe(true) + reply.resolve(Response.json({ name: 'host', available: false, fileManager: null })) + await Promise.all([first, second, disposal]) + expect(controller.host.getSnapshot()).toBeNull() +}) diff --git a/packages/client/ui-deliverables/tests/present-open.host.spec.ts b/packages/client/ui-deliverables/tests/present-open.host.spec.ts index b9319587b6..fc12bc71d3 100644 --- a/packages/client/ui-deliverables/tests/present-open.host.spec.ts +++ b/packages/client/ui-deliverables/tests/present-open.host.spec.ts @@ -36,8 +36,8 @@ async function fixture() { return { session, target: { type: 'deliverables/presented', data: { turn: 1, callId: 'present-call', files: [file] } } as SessionEvent } }) ctx.provide('sessionQuery', { readEvent } as never) - const opener = vi.fn(async (_request: { path: string }, _signal: AbortSignal) => ({ opened: true as const })) - ctx.provide('sessionController', { openWorkspacePath: opener } as never) + const opener = vi.fn(async (_request: { path: string; action?: 'reveal' }, _signal: AbortSignal) => ({ opened: true as const })) + ctx.provide('sessionController', { openWorkspacePath: opener, workspaceDesktop: () => ({ name: 'desktop', available: true, fileManager: 'finder' }) } as never) const connection = new HostConnectionService(ctx, [], {} as BrowserAuth) const fiber = ctx.plugin({ inject: ['connection', 'sessionQuery', 'sessionController'], apply: registerPresentOpen }) await fiber @@ -176,3 +176,26 @@ describe('Presented workspace file native open route', () => { await Promise.all([request, disposal]) }) }) + + +it('reports the serving desktop and reveals only an authorized declared source', async () => { + const { cwd, file, open, opener, handler } = await fixture() + const info = await handler.fetch(new Request('http://localhost/api/present.host')) + expect(await info.json()).toEqual({ name: 'desktop', available: true, fileManager: 'finder' }) + expect((await open('?sessionId=owner&seq=7&index=0&action=reveal')).status).toBe(204) + expect(opener).toHaveBeenCalledWith({ path: await realpath(join(cwd, file.path)), action: 'reveal' }, expect.any(AbortSignal)) + expect((await open('?sessionId=owner&seq=7&index=0&action=delete')).status).toBe(400) + file.path = '..' + expect((await open('?sessionId=owner&seq=7&index=0&action=reveal')).status).toBe(403) + expect(opener).toHaveBeenCalledOnce() +}) + +it('refuses native actions when the configured Host desktop is unavailable', async () => { + const { ctx, open, opener, handler } = await fixture() + vi.spyOn(ctx.sessionController, 'workspaceDesktop').mockReturnValue({ name: 'desktop', available: false, fileManager: 'finder' }) + expect(await (await handler.fetch(new Request('http://localhost/api/present.host'))).json()).toMatchObject({ available: false }) + for (const action of ['open', 'reveal']) { + expect((await open(`?sessionId=owner&seq=7&index=0&action=${action}`)).status).toBe(409) + } + expect(opener).not.toHaveBeenCalled() +}) diff --git a/packages/client/ui-deliverables/tests/presented-file-card.client.spec.tsx b/packages/client/ui-deliverables/tests/presented-file-card.client.spec.tsx new file mode 100644 index 0000000000..69a31fc224 --- /dev/null +++ b/packages/client/ui-deliverables/tests/presented-file-card.client.spec.tsx @@ -0,0 +1,95 @@ +// @vitest-environment jsdom +/** Explicit file actions preserve their destination, availability, and independent failure state. */ +import { cleanup, fireEvent, render } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { PresentedFileCard } from '../src/client/PresentedFileCard.tsx' +import { en, zh } from '../src/client/locales.ts' + +afterEach(cleanup) +const props = () => ({ + file: { path: 'out/report.pdf', description: 'Final report', seq: 4, index: 1 }, + host: { name: 'remote-desktop', available: true, fileManager: 'finder' as const }, + phase: undefined, + onAction: vi.fn(), + t: makeTranslate(en), +}) + +it.each([ + ['finder', 'Show in Finder'], ['explorer', 'Show in File Explorer'], ['directory', 'Open containing folder'], +] as const)('uses the Host %s action and closes the menu after selection', (fileManager, label) => { + const p = props() + const view = render() + fireEvent.click(view.getByRole('button', { name: 'More file actions for out/report.pdf' })) + expect(view.getByText('Opens on remote-desktop')).toBeTruthy() + fireEvent.click(view.getByRole('menuitem', { name: new RegExp(label) })) + expect(p.onAction).toHaveBeenCalledWith('reveal') + expect(view.queryByRole('menu')).toBeNull() + fireEvent.click(view.getByRole('button', { name: 'Open out/report.pdf in default app' })) + expect(p.onAction).toHaveBeenLastCalledWith('open') + fireEvent.click(view.getByRole('button', { name: 'More file actions for out/report.pdf' })) + fireEvent.click(view.getByRole('menuitem', { name: /Open in default app/ })) + expect(p.onAction).toHaveBeenCalledTimes(3) +}) + +it('dismisses the menu with Escape or an outside click without launching anything', () => { + const p = props() + const view = render() + const trigger = view.getByRole('button', { name: 'More file actions for out/report.pdf' }) + fireEvent.click(trigger) + fireEvent.keyDown(document, { key: 'Escape' }) + expect(view.queryByRole('menu')).toBeNull() + fireEvent.click(trigger) + fireEvent.pointerDown(document.body) + expect(view.queryByRole('menu')).toBeNull() + expect(p.onAction).not.toHaveBeenCalled() +}) + +it.each(['opening', 'revealing'] as const)('disables both gestures while %s', (phase) => { + const p = props() + const view = render() + for (const button of view.getAllByRole('button')) { + expect((button as HTMLButtonElement).disabled).toBe(true) + fireEvent.click(button) + } + expect(p.onAction).not.toHaveBeenCalled() +}) + +it('keeps actions disabled until a desktop is available', () => { + const p = props() + const view = render() + expect(view.getAllByRole('button').every(button => (button as HTMLButtonElement).disabled)).toBe(true) + view.rerender() + expect(view.getAllByRole('button').every(button => (button as HTMLButtonElement).disabled)).toBe(true) +}) + +it('localizes reveal failures and accurately reports a directory-only action', () => { + const p = props() + const view = render() + expect(view.getByRole('status').textContent).toBe(zh['presented.revealError']) + view.rerender() + expect(view.getByRole('status').textContent).toBe(en['presented.directoryOpened']) + view.rerender() + expect(view.getByRole('status').textContent).toBe(en['presented.revealed']) +}) + + +it('supports keyboard selection and returns focus to the trigger on Escape', () => { + const view = render() + const trigger = view.getByRole('button', { name: 'More file actions for out/report.pdf' }) + fireEvent.click(trigger) + const items = view.getAllByRole('menuitem') + expect(document.activeElement).toBe(items[0]) + fireEvent.keyDown(document.activeElement!, { key: 'ArrowDown' }) + expect(document.activeElement).toBe(items[1]) + fireEvent.keyDown(document.activeElement!, { key: 'ArrowDown' }) + expect(document.activeElement).toBe(items[0]) + fireEvent.keyDown(document.activeElement!, { key: 'End' }) + expect(document.activeElement).toBe(items[1]) + fireEvent.keyDown(document.activeElement!, { key: 'ArrowUp' }) + expect(document.activeElement).toBe(items[0]) + fireEvent.keyDown(document.activeElement!, { key: 'Home' }) + expect(document.activeElement).toBe(items[0]) + fireEvent.keyDown(document.activeElement!, { key: 'Escape' }) + expect(document.activeElement).toBe(trigger) +}) diff --git a/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx b/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx index 15c95017f3..a2773f6dab 100644 --- a/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx @@ -34,7 +34,11 @@ import { SessionId } from '@deepseek-ai/dsh-session/types' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' function openProps(controller = new PresentedOpenController()) { + controller.host.set({ name: 'desktop', available: true, fileManager: 'finder' }) return { + reloadPresentedHost: vi.fn(() => controller.loadHost()), + usePresentedHost: (select: (state: ReturnType) => T): T => + select(controller.host.getSnapshot()), openPresented: vi.fn((...args: Parameters) => controller.open(...args)), usePresentedOpen: (select: (state: ReturnType) => T): T => select(controller.state.getSnapshot()), @@ -549,6 +553,7 @@ describe('plugin registration', () => { service?.forClosing(delivered, SessionId('child-session'))?.resolve('report.docx')?.open() expect(fetcher).toHaveBeenCalledWith('/api/present.open?sessionId=child-session&seq=2&index=0', { method: 'POST', signal: expect.any(AbortSignal) as AbortSignal }) const face = entry!.inject!(SessionId('child-session') as never) as unknown as DeliverablesInjected + await face.reloadPresentedHost() await face.openPresented(SessionId('child-session'), 2, 0) expect(face.hooks.presentedOpen.getSnapshot()['/api/present.open?sessionId=child-session&seq=2&index=0']).toBe('opened') // A turn that produced nothing yields no vocabulary at all. @@ -591,10 +596,10 @@ describe('presented files', () => { const props = openProps() props.openPresented.mockResolvedValue(undefined) const view = render() - expect(view.getAllByRole('button')).toHaveLength(8) + expect(view.getAllByRole('button')).toHaveLength(16) expect(view.queryByRole('link')).toBeNull() fireEvent.click(view.getByRole('button', { name: 'Open report-0.docx in default app' })) - expect(props.openPresented).toHaveBeenCalledWith('child-session', 2, 0) + expect(props.openPresented).toHaveBeenCalledWith('child-session', 2, 0, 'open') expect(view.queryByText('Files changed')).toBeNull() }) }) @@ -629,7 +634,7 @@ it('shows file metadata and descriptions without hiding extensionless deliveries expect(view.getByText('Quarterly summary')).toBeTruthy() expect(view.getByText('TXT')).toBeTruthy() expect(view.getByText('File')).toBeTruthy() - expect(view.getByRole('button', { name: 'Open out/report.txt in default app' }).getAttribute('title')).toBe('Open out/report.txt in default app') + expect(view.getByText('out/report.txt')).toBeTruthy() }) @@ -641,5 +646,20 @@ it.each(['opening', 'opened', 'error'] as const)('shows the %s state and permits { path: 'report.txt', seq: 2, index: 0 }, ] }} openFile={() => {}} sessionId={SessionId('session')} t={makeTranslate(en)} />) expect(view.getByRole('status').textContent).toBe(en[`presented.${phase}`]) - expect((view.getByRole('button') as HTMLButtonElement).disabled).toBe(phase === 'opening') + expect((view.getByRole('button', { name: 'Open report.txt in default app' }) as HTMLButtonElement).disabled).toBe(phase === 'opening') +}) + + +it('explains a missing desktop and retries failed Host metadata', () => { + const controller = new PresentedOpenController() + const props = openProps(controller) + const matched = { produced: [], presented: [{ path: 'file.txt', seq: 2, index: 0 }] } + controller.host.set('error') + const view = render( {}} sessionId={SessionId('session')} t={makeTranslate(en)} />) + props.reloadPresentedHost.mockResolvedValue(undefined) + fireEvent.click(view.getByRole('button', { name: 'Retry' })) + expect(props.reloadPresentedHost).toHaveBeenCalledOnce() + controller.host.set({ name: 'server', available: false, fileManager: null }) + view.rerender( {}} sessionId={SessionId('session')} t={makeTranslate(en)} />) + expect(view.getByText(en['presented.unavailable'])).toBeTruthy() }) diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 138d94440f..fc91ac631b 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md -README.md: 8fd59c5ecc98a609f1383e428701cf590da95e73 -README.zh.md: 2478330825681f65aa83415c698cf0edc54fdad6 +README.md: 194fc2a3ac97a8b40bb6c385af3b4ee6b3930b52 +README.zh.md: e2dcea7a7c03556fef789db21cef8187c8b72d66 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 8fd59c5ecc..194fc2a3ac 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -67,7 +67,7 @@ Writing your own component in your own package is fine when the need is genuinel ### Controls and icons -The catalog above lists what each export is for; this section covers the behavior that props alone do not show. The `ic_ds_*` icon set and `FishLogo`/`BrandWordmark` marks fill brand and inline-icon slots. `LinkIcon` draws the leading category glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, all riding `currentColor` — and `classifyLinkPath` derives a file path's category from its extension. `FileTypeIcon` is the one glyph that does not ride `currentColor`: its sheet colour is the type's identity, so a consumer that wants it muted (an empty state) applies `filter: grayscale(1)` itself. `classifyFileType` and `classifyLinkPath` read one extension vocabulary (`file-extensions.ts`), so a path classifies consistently on a link and on a sheet; the sheet is the finer of the two. `ConnectionIndicator` renders a warning-colored disconnected action, a connecting label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Hover or keyboard focus shows only the reconnect action label, including while the connecting dots animate. Every state reserves the widest supplied label and uses fixed icon and text columns, so copy changes do not move or resize the control. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. `Toast` holds for the window its owner names through `holdMs`, because how long a banner has to stay depends on how much there is to read; the same value drives its unmount timer and the stylesheet's fade delay, so the two cannot disagree. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order. +The catalog above lists what each export is for; this section covers the behavior that props alone do not show. The `ic_ds_*` icon set and `FishLogo`/`BrandWordmark` marks fill brand and inline-icon slots. `LinkIcon` draws the leading category glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, all riding `currentColor` — and `classifyLinkPath` derives a file path's category from its extension. `FileTypeIcon` is the one glyph that does not ride `currentColor`: its sheet colour is the type's identity, so a consumer that wants it muted (an empty state) applies `filter: grayscale(1)` itself. `classifyFileType` and `classifyLinkPath` read one extension vocabulary (`file-extensions.ts`), so a path classifies consistently on a link and on a sheet; the sheet is the finer of the two. `ConnectionIndicator` renders a warning-colored disconnected action, a connecting label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Hover or keyboard focus shows only the reconnect action label, including while the connecting dots animate. Every state reserves the widest supplied label and uses fixed icon and text columns, so copy changes do not move or resize the control. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. `Toast` holds for the window its owner names through `holdMs`, because how long a banner has to stay depends on how much there is to read; the same value drives its unmount timer and the stylesheet's fade delay, so the two cannot disagree. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order. `Button.segment` joins the start and end controls of a split action without changing standalone button geometry. `Menu.autoFocus` focuses its first enabled item, supports Arrow Up/Down and Home/End navigation, and restores the trigger on Escape; it is opt-in for action menus. ### Rendering agent output diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 2478330825..e2dcea7a7c 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -67,7 +67,7 @@ kind: "package-library" ### 控件与图标 -上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。`ic_ds_*` 图标集与 `FishLogo`/`BrandWordmark` 标记填充品牌与行内图标 slot。`LinkIcon` 为可点击产物链接绘制前置分类图形——地球、文件夹、代码、图片、文档或纸张,全部随 `currentColor`——`classifyLinkPath` 按扩展名推导文件路径的类别。`FileTypeIcon` 是唯一不随 `currentColor` 的字形:纸片颜色就是类型的身份,需要压灰的使用方(空状态)自行加 `filter: grayscale(1)`。`classifyFileType` 与 `classifyLinkPath` 读同一份扩展名词表(`file-extensions.ts`),一条路径在链接上与纸片上的归类一致;纸片分得更细。`ConnectionIndicator` 可渲染警告色的断联操作、以独立于 retry 时序的 500ms 节奏推进一至三个点的连接中状态,或成功色的恢复状态。悬停或键盘聚焦时只显示重连操作文案,连接中的圆点动画也保持隐藏。所有状态都为最长的输入 label 预留空间,并使用固定的图标列和文字列,因此文案变化不会移动控件或改变其宽度。它的 owner 提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。 `Toast` 的停留时长由使用方通过 `holdMs` 指定,因为横幅该留多久取决于有多少内容要读;同一个值同时驱动它的卸载定时器与样式表的淡出延迟,两者不可能再错位。 `rankByName` 是 `/` 菜单命令源与 skill 源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 +上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。`ic_ds_*` 图标集与 `FishLogo`/`BrandWordmark` 标记填充品牌与行内图标 slot。`LinkIcon` 为可点击产物链接绘制前置分类图形——地球、文件夹、代码、图片、文档或纸张,全部随 `currentColor`——`classifyLinkPath` 按扩展名推导文件路径的类别。`FileTypeIcon` 是唯一不随 `currentColor` 的字形:纸片颜色就是类型的身份,需要压灰的使用方(空状态)自行加 `filter: grayscale(1)`。`classifyFileType` 与 `classifyLinkPath` 读同一份扩展名词表(`file-extensions.ts`),一条路径在链接上与纸片上的归类一致;纸片分得更细。`ConnectionIndicator` 可渲染警告色的断联操作、以独立于 retry 时序的 500ms 节奏推进一至三个点的连接中状态,或成功色的恢复状态。悬停或键盘聚焦时只显示重连操作文案,连接中的圆点动画也保持隐藏。所有状态都为最长的输入 label 预留空间,并使用固定的图标列和文字列,因此文案变化不会移动控件或改变其宽度。它的 owner 提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。 `Toast` 的停留时长由使用方通过 `holdMs` 指定,因为横幅该留多久取决于有多少内容要读;同一个值同时驱动它的卸载定时器与样式表的淡出延迟,两者不可能再错位。 `rankByName` 是 `/` 菜单命令源与 skill 源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 `Button.segment` 将分段操作的首尾按钮连接起来,不改变独立按钮的几何样式。`Menu.autoFocus` 聚焦首个启用项,支持上下方向键与 Home/End 导航,并在 Escape 时将焦点还给触发按钮;操作菜单可显式启用。 ### 渲染 agent 输出 diff --git a/packages/client/ui-primitives/src/Button.module.css b/packages/client/ui-primitives/src/Button.module.css index 9fa1712a66..b8efa4391d 100644 --- a/packages/client/ui-primitives/src/Button.module.css +++ b/packages/client/ui-primitives/src/Button.module.css @@ -77,3 +77,6 @@ align-items: center; justify-content: center; } + +.start { border-radius: 8px 0 0 8px; } +.end { border-radius: 0 8px 8px 0; } diff --git a/packages/client/ui-primitives/src/Button.tsx b/packages/client/ui-primitives/src/Button.tsx index d2e39dbf23..9519388d4d 100644 --- a/packages/client/ui-primitives/src/Button.tsx +++ b/packages/client/ui-primitives/src/Button.tsx @@ -12,18 +12,20 @@ export type ButtonVariant = 'primary' | 'ghost' | 'outline' | 'toolbar' * Render a button. * @param props.variant - visual family (default 'ghost'). * @param props.size - 'md' 36px capsule (figma Button) or 'sm' 28px compact. + * @param props.segment - joined button edge within an owner-rendered split control. * @param props.icon - optional leading 16px icon node. * @returns the button element; native button attributes pass through. */ -export function Button({ variant = 'ghost', size = 'md', icon, className, children, ...rest }: { +export function Button({ variant = 'ghost', size = 'md', icon, segment, className, children, ...rest }: { variant?: ButtonVariant size?: 'md' | 'sm' + segment?: 'start' | 'end' icon?: ReactNode className?: string | undefined children?: ReactNode } & ButtonHTMLAttributes) { return ( - diff --git a/packages/client/ui-primitives/src/Menu.tsx b/packages/client/ui-primitives/src/Menu.tsx index 3130086777..ed19fbb70c 100644 --- a/packages/client/ui-primitives/src/Menu.tsx +++ b/packages/client/ui-primitives/src/Menu.tsx @@ -48,6 +48,7 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 } /** * Render an anchored dropdown menu. + * @param props.autoFocus - focus the first item on open and enable arrow-key navigation; Escape restores the trigger. * @param props.open - whether the list is showing (owner-controlled). * @param props.anchor - the trigger element (rendered in place). * @param props.items - selectable rows and optional separators. @@ -81,8 +82,9 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 } * crowds the cell). * @returns anchor wrapper with the conditional list. */ -export function Menu({ open, anchor, items, selectedId, selectedIds, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, dense = false, compact = false, selection = 'check', getAnchorRect, footer, className }: { +export function Menu({ open, anchor, items, selectedId, selectedIds, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, dense = false, compact = false, autoFocus = false, selection = 'check', getAnchorRect, footer, className }: { open: boolean + autoFocus?: boolean anchor: ReactNode items: readonly MenuEntry[] footer?: readonly MenuEntry[] @@ -159,6 +161,10 @@ export function Menu({ open, anchor, items, selectedId, selectedIds, onSelect, o } }, [open, portal, align, side, getAnchorRect]) + useEffect(() => { + if (open && autoFocus) listRef.current?.querySelector('button:not(:disabled)')?.focus() + }, [open, autoFocus]) + useEffect(() => { if (!open) { setOpenSubmenuId(null) @@ -172,7 +178,18 @@ export function Menu({ open, anchor, items, selectedId, selectedIds, onSelect, o onClose() } const onKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') onClose() + if (e.key === 'Escape') { + onClose() + if (autoFocus) rootRef.current?.querySelector('button')?.focus() + } + if (!autoFocus || !['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(e.key)) return + const buttons = Array.from(listRef.current?.querySelectorAll('button:not(:disabled)') ?? []) + const index = buttons.indexOf(document.activeElement as HTMLButtonElement) + if (index < 0) return + e.preventDefault() + const next = e.key === 'Home' ? 0 : e.key === 'End' ? buttons.length - 1 + : (index + (e.key === 'ArrowDown' ? 1 : -1) + buttons.length) % buttons.length + buttons[next]?.focus() } document.addEventListener('pointerdown', onPointerDown) document.addEventListener('keydown', onKeyDown) @@ -180,7 +197,7 @@ export function Menu({ open, anchor, items, selectedId, selectedIds, onSelect, o document.removeEventListener('pointerdown', onPointerDown) document.removeEventListener('keydown', onKeyDown) } - }, [open, onClose]) + }, [open, onClose, autoFocus]) // A close from selection/Escape/outside click outruns a pending grace close; // left armed it would shut a list reopened inside the grace window. Its own diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 4606b99848..1e3ce94363 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1493,6 +1493,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [], returns: 'true when the matching open operation is available.', }, + { + signature: 'workspaceDesktop(): { name: string; available: boolean; fileManager: \'finder\' | \'explorer\' | \'directory\' | null }', + description: 'Describe the serving desktop for authenticated file-action routes.', + parameters: [], + returns: 'Host name, configured availability, and platform-specific file-manager behavior.', + }, { signature: '@Remote(\'openWorkspacePath\') async openWorkspacePath( request: SessionOpenWorkspacePathRequest, signal: AbortSignal, ): Promise', description: 'Open one path prepared by a Session-aware caller on the Host desktop.', @@ -5266,7 +5272,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionOpenWorkspacePathRequest', - declaration: 'export interface SessionOpenWorkspacePathRequest {\n readonly path: string;\n}', + declaration: 'export interface SessionOpenWorkspacePathRequest {\n readonly action?: \'reveal\';\n readonly path: string;\n}', }, { name: 'SessionOpenWorkspacePathValue', diff --git a/packages/util/native-command/README.i18n.yaml b/packages/util/native-command/README.i18n.yaml index 46c49a2810..5dba1e1698 100644 --- a/packages/util/native-command/README.i18n.yaml +++ b/packages/util/native-command/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/native-command/README.md -README.md: 06baa6d631198e42c6de3e20c8a0bd562564b779 -README.zh.md: 6df6ee25e32e5f7290af1c4af580fe108a0d3b9f +README.md: b127c1f03fc91282cd2a847af84f8183f147f9ac +README.zh.md: 7ff612fe9bead184ace2b5ee2c54700c8d673df0 diff --git a/packages/util/native-command/README.md b/packages/util/native-command/README.md index 06baa6d631..b127c1f03f 100644 --- a/packages/util/native-command/README.md +++ b/packages/util/native-command/README.md @@ -47,6 +47,8 @@ The `NativeCommandRunner` type is the injectable command boundary for host integ `openNativePath(path, signal)` hands a path to the default application and prefers the named default browser for HTML and SVG where the platform can identify one. `openNativeTextFile(path, signal)` selects text-editor intent; on macOS it uses `open -t`. WSL paths are translated with `wslpath -w` before the Windows desktop receives them. `canOpenNativePath()` reports whether the current Host plausibly has a desktop target. +`revealNativePath(path, signal)` selects the file in Finder or Explorer, including WSL path translation, and opens its parent directory through `xdg-open` on desktop Linux. `nativeFileManager()` identifies that action for Host-derived UI labels; desktop availability remains a separate `canOpenNativePath()` check. Callers must authorize the absolute file path before invoking either operation. Platform dispatch is covered by injected-runner tests; native desktop verification belongs to the corresponding platform. + ----- diff --git a/packages/util/native-command/README.zh.md b/packages/util/native-command/README.zh.md index 6df6ee25e3..7ff612fe9b 100644 --- a/packages/util/native-command/README.zh.md +++ b/packages/util/native-command/README.zh.md @@ -47,6 +47,8 @@ const { stdout, stderr } = await runNativeCommand('osascript', ['-e', script], s `openNativePath(path, signal)` 将路径交给默认应用;平台能够确定默认浏览器时,HTML 与 SVG 会优先交给该浏览器。`openNativeTextFile(path, signal)` 选择文本编辑器意图;macOS 使用 `open -t`。WSL 路径先通过 `wslpath -w` 转换,再交给 Windows 桌面。`canOpenNativePath()` 报告当前 Host 是否可能具备桌面目标。 +`revealNativePath(path, signal)` 在 Finder 或文件资源管理器中选中文件,包含 WSL 路径转换;在桌面 Linux 上通过 `xdg-open` 打开上层目录。`nativeFileManager()` 标识该操作,供 UI 根据 Host 选择文案;桌面是否可用仍由独立的 `canOpenNativePath()` 检查决定。调用方必须先授权绝对文件路径,再执行操作。平台分派由注入运行器的测试覆盖;原生桌面验证由对应平台负责。 + ----- diff --git a/packages/util/native-command/src/index.ts b/packages/util/native-command/src/index.ts index 14100af799..a2e23a35be 100644 --- a/packages/util/native-command/src/index.ts +++ b/packages/util/native-command/src/index.ts @@ -7,10 +7,13 @@ export { runNativeCommand } from './runner.ts' export type { NativeCommandRunner } from './runner.ts' export { canOpenNativePath, + nativeFileManager, + revealNativePath, openNativePath, openNativeTextFile, } from './path-opener.ts' export type { + NativeFileManager, PathOpenerInternals, PathOpenerRunner, } from './path-opener.ts' diff --git a/packages/util/native-command/src/path-opener.ts b/packages/util/native-command/src/path-opener.ts index 97d2084e61..0d8dcb32da 100644 --- a/packages/util/native-command/src/path-opener.ts +++ b/packages/util/native-command/src/path-opener.ts @@ -10,7 +10,7 @@ */ import { release as osRelease } from 'node:os' -import { extname } from 'node:path' +import { dirname, extname } from 'node:path' import { runNativeCommand, type NativeCommandRunner } from './runner.ts' /** Testable command boundary; native implementations never invoke a shell. */ @@ -201,3 +201,54 @@ export function openNativeTextFile( ): Promise { return openNativePathWithIntent(path, signal, 'text-editor', internals) } + +/** File-manager behavior available on the serving Host, including WSL's Windows desktop. */ +export type NativeFileManager = 'finder' | 'explorer' | 'directory' + +/** + * Identify the native file-manager action without inspecting the browser's platform. + * @param internals - platform and WSL facts. + * @returns the supported file-manager action, or null on unsupported platforms. + */ +export function nativeFileManager(internals: PathOpenerInternals = {}): NativeFileManager | null { + const platform = internals.platform ?? process.platform + if (platform === 'darwin') return 'finder' + if (platform === 'win32' || (platform === 'linux' && isWsl(internals))) return 'explorer' + return platform === 'linux' ? 'directory' : null +} + +/** + * Reveal a file in Finder or Explorer, or open its parent in the Linux default file manager. + * @param path - absolute file path already authorized by the caller. + * @param signal - caller lifetime; abort terminates the native command. + * @param internals - platform, environment, and command runner for adapter tests. + * @returns after the file-manager command accepts the request; launch failures reject. + */ +export async function revealNativePath( + path: string, signal: AbortSignal, internals: PathOpenerInternals = {}, +): Promise { + signal.throwIfAborted() + const platform = internals.platform ?? process.platform + const run = internals.run ?? runNativeCommand + const manager = nativeFileManager({ ...internals, platform }) + if (manager === 'finder') { + await run('open', ['-R', path], signal) + return + } + if (manager === 'explorer') { + let windowsPath = path + if (platform === 'linux') { + const translated = await run('wslpath', ['-w', path], signal) + signal.throwIfAborted() + windowsPath = translated.stdout.replace(/[\r\n]+$/, '') + if (windowsPath === '') throw new Error('wslpath returned no Windows path') + } + await run('explorer.exe', [`/select,${windowsPath}`], signal) + return + } + if (manager === 'directory') { + await run('xdg-open', [dirname(path)], signal) + return + } + throw new Error(`native file manager is unsupported on ${platform}`) +} diff --git a/packages/util/native-command/tests/path-opener.spec.ts b/packages/util/native-command/tests/path-opener.spec.ts index d5cd1bb751..347588095d 100644 --- a/packages/util/native-command/tests/path-opener.spec.ts +++ b/packages/util/native-command/tests/path-opener.spec.ts @@ -17,7 +17,7 @@ vi.mock('node:child_process', () => ({ execFile: execFileMock })) import { release as osRelease } from 'node:os' import { describe, expect, it, vi } from 'vitest' -import { canOpenNativePath, openNativePath, openNativeTextFile, type PathOpenerRunner } from '../src/index.ts' +import { canOpenNativePath, nativeFileManager, revealNativePath, openNativePath, openNativeTextFile, type PathOpenerRunner } from '../src/index.ts' const signal = () => new AbortController().signal @@ -326,3 +326,55 @@ describe('canOpenNativePath', () => { expect(canOpenNativePath()).toBe(canOpenNativePath({ platform: process.platform })) }) }) + + +describe('native file manager', () => { + it.each([ + ['darwin', 'finder', '/tmp/my report.txt', 'open', ['-R', '/tmp/my report.txt']], + ['win32', 'explorer', 'C:\\work\\my report.txt', 'explorer.exe', ['/select,C:\\work\\my report.txt']], + ['linux', 'directory', '/tmp/a $b; report.txt', 'xdg-open', ['/tmp']], + ] as const)('reveals through %s without opening the file association', async (platform, manager, path, command, args) => { + const run = vi.fn(async () => ({ stdout: '', stderr: '' })) + const internals = { platform, env: {}, osRelease: 'generic', run } + expect(nativeFileManager(internals)).toBe(manager) + await revealNativePath(path, signal(), internals) + expect(run).toHaveBeenCalledExactlyOnceWith(command, args, expect.any(AbortSignal)) + }) + + it('selects a translated WSL path in Explorer and never starts a Linux file manager', async () => { + const run = vi.fn(async () => ({ stdout: 'C:\\work\\报告.txt\r\n', stderr: '' })) + const internals = { platform: 'linux' as const, env: { WSL_DISTRO_NAME: 'Ubuntu' }, run } + expect(nativeFileManager(internals)).toBe('explorer') + await revealNativePath('/mnt/c/work/报告.txt', signal(), internals) + expect(run.mock.calls.map(([cmd, args]) => [cmd, args])).toEqual([ + ['wslpath', ['-w', '/mnt/c/work/报告.txt']], ['explorer.exe', ['/select,C:\\work\\报告.txt']], + ]) + }) + + it('refuses empty WSL translations and cancelled translation without launching Explorer', async () => { + const abort = new AbortController() + const run = vi.fn(async () => ({ stdout: '', stderr: '' })) + const internals = { platform: 'linux' as const, env: {}, osRelease: 'microsoft', run } + await expect(revealNativePath('/file', signal(), internals)).rejects.toThrow('no Windows path') + run.mockImplementationOnce(async () => { abort.abort(new Error('stopped')); return { stdout: 'C:\\file', stderr: '' } }) + await expect(revealNativePath('/file', abort.signal, internals)).rejects.toThrow('stopped') + expect(run.mock.calls.every(([cmd]) => cmd === 'wslpath')).toBe(true) + }) + + it('rejects unsupported platforms, cancellation, and launcher errors', async () => { + const run = vi.fn().mockRejectedValue(new Error('desktop failed')) + expect(nativeFileManager({ platform: 'aix' })).toBeNull() + await expect(revealNativePath('/file', signal(), { platform: 'aix', run })).rejects.toThrow('unsupported') + await expect(revealNativePath('/file', AbortSignal.abort(new Error('cancelled')), { run })).rejects.toThrow('cancelled') + expect(run).not.toHaveBeenCalled() + await expect(revealNativePath('/file', signal(), { platform: 'darwin', run })).rejects.toThrow('desktop failed') + expect(nativeFileManager()).toBe(process.platform === 'darwin' ? 'finder' : process.platform === 'win32' ? 'explorer' : 'directory') + }) +}) + + +it('uses the native runner for a file-manager handoff when none is injected', async () => { + execFileMock.mockImplementation((_command, _args, _options, callback) => { callback(null, '', '') }) + await revealNativePath('/tmp/report.txt', signal()) + expect(execFileMock).toHaveBeenCalled() +}) diff --git a/snapshots/web/present/ui.expected.md b/snapshots/web/present/ui.expected.md index 1a79b00463..1346420ed9 100644 --- a/snapshots/web/present/ui.expected.md +++ b/snapshots/web/present/ui.expected.md @@ -63,17 +63,24 @@ - code: AFTER_PRESENT - text: — no retries, no extra files. - paragraph: PRESENT_DONE -- text: Deliverables +- text: Deliverables Opens on the DSH Host report.txt TXT delivered report +- img +- text: report.txt - button "Open report.txt in default app": - - text: report.txt TXT delivered report - - status: Opened in default app - img - text: Open +- button "More file actions for report.txt": + - img +- status: Opened in default app +- text: 说明.txt TXT delivered note +- img +- text: 说明.txt - button "Open 说明.txt in default app": - - text: 说明.txt TXT delivered note - - status: Opened in default app - img - text: Open +- button "More file actions for 说明.txt": + - img +- status: Opened in default app - button "Copy": - img - button "Good response": From b27d861741d3258de1fd5f480ed80f73d191bdf6 Mon Sep 17 00:00:00 2001 From: yudshj Date: Wed, 9 Sep 2026 11:28:17 +0800 Subject: [PATCH 012/111] docs: refresh native file action catalog references --- docs/config-catalog.i18n.yaml | 2 +- docs/config-catalog.md | 2 +- docs/event-producer-consumer.i18n.yaml | 4 ++-- docs/event-producer-consumer.md | 10 +++++----- docs/event-producer-consumer.zh.md | 10 +++++----- 5 files changed, 14 insertions(+), 14 deletions(-) diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 2f4b0e6e79..6462aa0d55 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 4e92bbfcf4bcf190b9b17436eefc3255dda9132a +config-catalog.md: 7b677ae08d84c9ad94f0ecd59820f52202b90e93 config-catalog.zh.md: df4b107f28040962f38802a77f8b91c47ad2931d diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 4e92bbfcf4..7b677ae08d 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -213,7 +213,7 @@ export interface Config { } ``` -Source: [`packages/api/session-controller/src/index.ts:70`](../packages/api/session-controller/src/index.ts) +Source: [`packages/api/session-controller/src/index.ts:71`](../packages/api/session-controller/src/index.ts) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 09eecc6d42..7f7e38eff1 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/event-producer-consumer.md -event-producer-consumer.md: 2239c9786f9e18b6a8e9514bfd53da7f87cfb8a4 -event-producer-consumer.zh.md: e43d48e2ee163843752bcda9fe3578839e05252b +event-producer-consumer.md: 323e9f3eea8703c46f3be082db6ab67e9339084b +event-producer-consumer.zh.md: 1f34a6f3d53bfd6ddf6c19adb0a3c7cb7043c6ab diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 2239c9786f..323e9f3eea 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -22,11 +22,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:316`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:277`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:391`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:597`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:577`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:604`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:583`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:590`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:599`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:579`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:606`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:585`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:592`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:87`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index e43d48e2ee..1f34a6f3d5 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -24,11 +24,11 @@ | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:316`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:277`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:391`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:597`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:577`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:604`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:583`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:590`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:599`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:579`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:606`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:585`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:592`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:87`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | From 5b671d7df3df08b3dee8cb681d8977cfd2f61fee Mon Sep 17 00:00:00 2001 From: yudshj Date: Wed, 9 Sep 2026 12:00:24 +0800 Subject: [PATCH 013/111] fix(web): show artifact paths relative to the workspace --- apps/web/tests/present.e2e.ts | 1 + .../client/ui-deliverables/README.i18n.yaml | 4 ++-- packages/client/ui-deliverables/README.md | 2 +- packages/client/ui-deliverables/README.zh.md | 2 +- packages/client/ui-deliverables/package.json | 3 ++- .../src/client/Deliverables.tsx | 9 +++++---- .../src/client/PresentedFileCard.tsx | 10 +++++++--- .../tests/presented-file-card.client.spec.tsx | 13 +++++++++++++ .../tests/produced-files.client.spec.tsx | 4 +++- .../ui-deliverables/tsconfig.client.json | 3 +++ .../client/tool/models/image-card-model.ts | 4 ++-- .../src/client/tool/models/read-card-model.ts | 4 ++-- .../src/client/tool/models/tool-call-model.ts | 14 +------------- packages/util/workspace-path/README.i18n.yaml | 4 ++-- packages/util/workspace-path/README.md | 2 +- packages/util/workspace-path/README.zh.md | 2 +- packages/util/workspace-path/src/index.ts | 13 +++++++++++++ .../util/workspace-path/tests/index.spec.ts | 19 +++++++++++++++++-- pnpm-lock.yaml | 3 +++ 19 files changed, 80 insertions(+), 36 deletions(-) diff --git a/apps/web/tests/present.e2e.ts b/apps/web/tests/present.e2e.ts index 802e5eed1d..7ccaa42b68 100644 --- a/apps/web/tests/present.e2e.ts +++ b/apps/web/tests/present.e2e.ts @@ -122,6 +122,7 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con const row = page.locator('[data-presented-files-row]') await row.waitFor() expect(await row.getByRole('button').count()).toBe(4) + expect(await row.getByTitle(join(cwd, 'report.txt'), { exact: true }).innerText()).toBe('report.txt') const beforeReveal = (await opened()).length await row.getByRole('button', { name: 'More file actions for report.txt', exact: true }).click() const revealResponse = page.waitForResponse(response => response.url().includes('action=reveal') && response.request().method() === 'POST') diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml index 654494e779..5303dce4bb 100644 --- a/packages/client/ui-deliverables/README.i18n.yaml +++ b/packages/client/ui-deliverables/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-deliverables/README.md -README.md: 3196202a053ae35bb3055666a680c9d6cfc2a2f8 -README.zh.md: bb394ae756820d7b536ea9326c0837cf59a85a01 +README.md: 3ff1f50999ee7efbf8c3e3c2e996d1ac208db5d4 +README.zh.md: 23b7ca1275c4a4315ef890627d745cf5157d0660 diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md index 3196202a05..3ff1f50999 100644 --- a/packages/client/ui-deliverables/README.md +++ b/packages/client/ui-deliverables/README.md @@ -30,7 +30,7 @@ Mount this plugin alongside `ui-conversation`; a finished turn then ends with th ### Explicit deliveries -The Web `standard`, `ptc`, and `cordis` presets expose `present` for final workspace files, including files created through Bash. Call it with `files: [{ path, description? }]` after creating the files. The [present tool](../../fs/tool-present/README.md) owns file-count limits and Session declarations. The closing turn shows responsive cards with file names, types, descriptions, workspace paths, and a split Open button. Its primary action opens the source in the Host’s default application; the menu offers Show in Finder on macOS, Show in File Explorer on Windows and WSL, or Open containing folder through the default Linux file manager. The menu names the serving Host, independently of the browser’s operating system. Matching inline-code references open the same source files without starting a browser download. Repeated declaration of a path selects its latest description before the closing reply. +The Web `standard`, `ptc`, and `cordis` presets expose `present` for final workspace files, including files created through Bash. Call it with `files: [{ path, description? }]` after creating the files. The [present tool](../../fs/tool-present/README.md) owns file-count limits and Session declarations. The closing turn shows responsive cards with file names, types, descriptions, workspace-relative paths, and a split Open button. Hovering the path shows its full location. Its primary action opens the source in the Host’s default application; the menu offers Show in Finder on macOS, Show in File Explorer on Windows and WSL, or Open containing folder through the default Linux file manager. The menu names the serving Host, independently of the browser’s operating system. Matching inline-code references open the same source files without starting a browser download. Repeated declaration of a path selects its latest description before the closing reply. The `present` tool row shows running, delivered, failed, or interrupted status; expanding a settled row reveals its recorded result. File cards include every delivered file. Both actions share pending state and show progress, acknowledgement, or an action-specific retryable error. A missing desktop disables both actions; a failed desktop-information read offers Retry. It requires a desktop and a suitable default application on the serving Host; a remote browser does not open applications on its own device. diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index bb394ae756..23b7ca1275 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -30,7 +30,7 @@ kind: "package-reference" ### 显式交付 -Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交付最终工作区文件,包括通过 Bash 创建的文件。创建文件后,以 `files: [{ path, description? }]` 调用。[present 工具](../../fs/tool-present/README.zh.md)拥有文件数量限制和 Session 声明。收尾 turn 显示响应式卡片,包含文件名称、类型、说明、工作区路径和分段“打开”按钮。主按钮在 Host 默认应用中打开源文件;菜单在 macOS 上提供“在 Finder 中显示”,在 Windows 和 WSL 上提供“在文件资源管理器中显示”,在 Linux 上通过默认文件管理器“打开所在文件夹”。菜单显示实际提供服务的 Host 名称,不依赖浏览器的操作系统。匹配的行内代码引用打开相同源文件,不触发浏览器下载。同一路径重复声明时,选择收尾回复之前最近一次的说明。 +Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交付最终工作区文件,包括通过 Bash 创建的文件。创建文件后,以 `files: [{ path, description? }]` 调用。[present 工具](../../fs/tool-present/README.zh.md)拥有文件数量限制和 Session 声明。收尾 turn 显示响应式卡片,包含文件名称、类型、说明、工作区相对路径和分段“打开”按钮。悬停路径可查看完整位置。主按钮在 Host 默认应用中打开源文件;菜单在 macOS 上提供“在 Finder 中显示”,在 Windows 和 WSL 上提供“在文件资源管理器中显示”,在 Linux 上通过默认文件管理器“打开所在文件夹”。菜单显示实际提供服务的 Host 名称,不依赖浏览器的操作系统。匹配的行内代码引用打开相同源文件,不触发浏览器下载。同一路径重复声明时,选择收尾回复之前最近一次的说明。 `present` 工具行显示正在交付、已交付、失败或中断状态;展开已结束的调用可查看其记录的结果。文件卡片展示全部交付文件。两个操作共享等待状态,并显示进度、请求确认或各自可重试的错误。Host 没有桌面时禁用两个操作;桌面信息读取失败时提供“重试”。服务 Host 必须具备桌面和合适的默认应用;远程浏览器不会打开其所在设备上的应用。 diff --git a/packages/client/ui-deliverables/package.json b/packages/client/ui-deliverables/package.json index 67970786a0..ca55e77f16 100644 --- a/packages/client/ui-deliverables/package.json +++ b/packages/client/ui-deliverables/package.json @@ -67,7 +67,8 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-client-ui-tool": "workspace:^", "@deepseek-ai/dsh-tool-present": "workspace:^", - "@deepseek-ai/dsh-api-session-controller": "workspace:^" + "@deepseek-ai/dsh-api-session-controller": "workspace:^", + "@deepseek-ai/dsh-util-workspace-path": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-deliverables/src/client/Deliverables.tsx b/packages/client/ui-deliverables/src/client/Deliverables.tsx index 420741512a..ab83af8f4e 100644 --- a/packages/client/ui-deliverables/src/client/Deliverables.tsx +++ b/packages/client/ui-deliverables/src/client/Deliverables.tsx @@ -1,7 +1,7 @@ /** Existing changed-file chips and explicitly declared files for a closing turn. */ import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client' import { Button } from '@deepseek-ai/dsh-client-ui-primitives' -import type { InjectFace, PropsLocale, SessionStandardProps } from '@deepseek-ai/dsh-client-ui-slots' +import type { GlobalStandardProps, InjectFace, PropsLocale, SessionStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { PresentedOpenController } from './present-open.ts' import { ProducedFiles } from './ProducedFiles.tsx' @@ -39,9 +39,10 @@ export function selectDeliverables(owner: TurnTailOwnerProps): DeliverablesMatch * @param props - matched files, workspace opener, and localized copy. * @returns the closing turn's file rows. */ -export function Deliverables({ matched, openFile, t, sessionId, openPresented, usePresentedOpen, usePresentedHost, reloadPresentedHost }: Pick & { +export function Deliverables({ matched, openFile, t, sessionId, useSessions, openPresented, usePresentedOpen, usePresentedHost, reloadPresentedHost }: Pick & { matched: DeliverablesMatch -} & PropsLocale & Pick & InjectFace) { +} & PropsLocale & Pick & Pick & InjectFace) { + const cwd = useSessions(state => state.byId[sessionId]?.cwd) const states = usePresentedOpen(value => value) const host = usePresentedHost(value => value) return <> @@ -57,7 +58,7 @@ export function Deliverables({ matched, openFile, t, sessionId, openPresented, u
} {host !== null && host !== 'error' && !host.available && {t('presented.unavailable')}}
- {matched.presented.map(file => { void openPresented(sessionId, file.seq, file.index, action) }} />)} diff --git a/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx b/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx index d3af07034a..65db02e5ca 100644 --- a/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx +++ b/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx @@ -1,5 +1,6 @@ /** File identity and explicit default-app or file-manager actions for one delivery. */ import { useState } from 'react' +import { relativizeToCwd, resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path' import { Button, Menu, LinkIcon, classifyLinkPath, IconRightUpOutline16, IconChevronDownOutline14, IconFolderOpenOutline16, @@ -13,11 +14,12 @@ import css from './Deliverables.module.css' /** * Render independent file actions without nesting buttons inside a clickable card. - * @param props - durable file metadata, Host capabilities, gesture status, and localized copy. + * @param props - durable file metadata, Session workspace root, Host capabilities, gesture status, and localized copy. * @returns the file card and its anchored action menu. */ -export function PresentedFileCard({ file, phase, host, onAction, t }: { +export function PresentedFileCard({ file, cwd, phase, host, onAction, t }: { file: PresentedPath + cwd: string | undefined phase: PresentedOpenPhase | undefined host: PresentedHost | null onAction: (action: PresentedAction) => void @@ -42,7 +44,9 @@ export function PresentedFileCard({ file, phase, host, onAction, t }: {
- {file.path} + + {relativizeToCwd(file.path, cwd)} +
} {host !== null && host !== 'error' && !host.available && {t('presented.unavailable')}} -
- {matched.presented.map(file => + {presented.map(file => { openFile(file.path) }} onAction={(action) => { void openPresented(sessionId, file.seq, file.index, action) }} />)}
+ {collapsible && }
} } diff --git a/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx b/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx index 65db02e5ca..2ca532182f 100644 --- a/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx +++ b/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx @@ -1,8 +1,8 @@ /** File identity and explicit default-app or file-manager actions for one delivery. */ import { useState } from 'react' -import { relativizeToCwd, resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path' +import { resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path' import { - Button, Menu, LinkIcon, classifyLinkPath, IconRightUpOutline16, + Menu, LinkIcon, classifyLinkPath, IconRightUpOutline16, IconChevronDownOutline14, IconFolderOpenOutline16, } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' @@ -12,64 +12,66 @@ import { basename, type PresentedPath } from './turn-deliverables.ts' import type { NS } from './locales.ts' import css from './Deliverables.module.css' +function cardDescription(description: string | undefined, fallback: string): string { + const trimmed = description?.replace(/\s*(?:\([^()]*\)|([^()]*))\s*$/u, '').trim() + return trimmed === undefined || trimmed === '' ? fallback : trimmed +} + /** * Render independent file actions without nesting buttons inside a clickable card. - * @param props - durable file metadata, Session workspace root, Host capabilities, gesture status, and localized copy. + * @param props - durable file metadata, Sidebar preview, Host capabilities, gesture status, and localized copy. * @returns the file card and its anchored action menu. */ -export function PresentedFileCard({ file, cwd, phase, host, onAction, t }: { +export function PresentedFileCard({ file, cwd, phase, host, onPreview, onAction, t }: { file: PresentedPath cwd: string | undefined phase: PresentedOpenPhase | undefined host: PresentedHost | null + onPreview: () => void onAction: (action: PresentedAction) => void } & PropsLocale) { const [menuOpen, setMenuOpen] = useState(false) const pending = phase === 'opening' || phase === 'revealing' - const disabled = pending || host === null || !host.available + const menuDisabled = pending || host === null || !host.available const reveal = host?.fileManager ?? 'directory' const act = (action: PresentedAction) => { setMenuOpen(false); onAction(action) } - const description = (title: string, detail: string) => - {title}{detail} - + const name = basename(file.path) + const metadata = name.match(/\.([^.]+)$/)?.[1]?.toUpperCase() ?? t('presented.file') + const status = phase === undefined + ? cardDescription(file.description, metadata) + : t(phase === 'revealed' && reveal === 'directory' ? 'presented.directoryOpened' : `presented.${phase}`) return
-
- + - { setMenuOpen(false) }} - anchor={ + { setMenuOpen(false) }} + anchor={} + onClick={() => { setMenuOpen(value => !value) }}> + + } items={[ - { type: 'label', id: 'host', text: t('presented.host', { name: host?.name ?? '' }) }, - { id: 'open', icon: , - label: description(t('presented.defaultApp'), t('presented.openDetail')) }, - { type: 'separator', id: 'separator' }, + { id: 'open', icon: , + label: t('presented.defaultApp') }, { id: 'reveal', icon: , - label: description(t(`presented.${reveal}`), t(reveal === 'directory' ? 'presented.directoryDetail' : 'presented.revealDetail')) }, + label: t(`presented.${reveal}`) }, ]} onSelect={(id) => { act(id === 'reveal' ? 'reveal' : 'open') }} />
- {phase !== undefined && - {t(phase === 'revealed' && reveal === 'directory' ? 'presented.directoryOpened' : `presented.${phase}`)} - }
} diff --git a/packages/client/ui-deliverables/src/client/locales.ts b/packages/client/ui-deliverables/src/client/locales.ts index 7dec4ffdbf..de5310bbf0 100644 --- a/packages/client/ui-deliverables/src/client/locales.ts +++ b/packages/client/ui-deliverables/src/client/locales.ts @@ -5,7 +5,6 @@ export const NS = 'deliverables' /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { - 'presented.label': '交付文件', 'presented.revealError': '无法打开所在文件夹,请重试', 'presented.directoryOpened': '已请求打开所在文件夹', 'presented.revealed': '已请求在文件管理器中显示', @@ -13,17 +12,19 @@ export const zh = { 'presented.unavailable': '此主机没有可用的桌面,无法打开文件或文件夹', 'presented.retry': '重试', 'presented.hostError': '无法读取主机桌面信息', - 'presented.host': '操作主机:{name}', - 'presented.target': '在 DSH 主机上打开', - 'presented.directoryDetail': '使用系统默认文件管理器', - 'presented.revealDetail': '打开所在文件夹并选中文件', 'presented.directory': '打开所在文件夹', 'presented.explorer': '在文件资源管理器中显示', 'presented.finder': '在 Finder 中显示', - 'presented.openDetail': '打开此文件', 'presented.defaultApp': '用默认应用打开', 'presented.more': '{name} 的更多文件操作', 'presented.action': '打开', + 'presented.preview': '在侧边栏预览', + 'presented.previewButton': '在侧边栏打开 {name}', + 'presented.previewCard': '在侧边栏预览 {name}', + 'presented.all': '全部 {count} 个文件', + 'presented.expandAria': '展开全部 {count} 个交付文件', + 'presented.collapse': '收起', + 'presented.collapseAria': '收起交付文件列表', 'presented.opening': '正在打开…', 'presented.opened': '已在默认程序中打开', 'presented.error': '打开失败,点击重试', @@ -43,7 +44,6 @@ export const zh = { /** English dictionary (same key set). */ export const en: Record = { - 'presented.label': 'Deliverables', 'presented.revealError': 'Could not open containing folder. Try again.', 'presented.directoryOpened': 'Requested opening containing folder', 'presented.revealed': 'Requested display in file manager', @@ -51,17 +51,19 @@ export const en: Record = { 'presented.unavailable': 'This Host has no desktop available to open files or folders', 'presented.retry': 'Retry', 'presented.hostError': 'Could not read the Host desktop information', - 'presented.host': 'Opens on {name}', - 'presented.target': 'Opens on the DSH Host', - 'presented.directoryDetail': 'Use the system default file manager', - 'presented.revealDetail': 'Open its folder and select the file', 'presented.directory': 'Open containing folder', 'presented.explorer': 'Show in File Explorer', 'presented.finder': 'Show in Finder', - 'presented.openDetail': 'Open this file', 'presented.defaultApp': 'Open in default app', 'presented.more': 'More file actions for {name}', 'presented.action': 'Open', + 'presented.preview': 'Preview in sidebar', + 'presented.previewButton': 'Open {name} in sidebar', + 'presented.previewCard': 'Preview {name} in sidebar', + 'presented.all': 'All {count} files', + 'presented.expandAria': 'Show all {count} delivered files', + 'presented.collapse': 'Collapse', + 'presented.collapseAria': 'Collapse delivered files', 'presented.opening': 'Opening…', 'presented.opened': 'Opened in default app', 'presented.error': 'Could not open. Click to retry.', diff --git a/packages/client/ui-deliverables/tests/presented-file-card.client.spec.tsx b/packages/client/ui-deliverables/tests/presented-file-card.client.spec.tsx index 3b9a96af65..0f060643b4 100644 --- a/packages/client/ui-deliverables/tests/presented-file-card.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/presented-file-card.client.spec.tsx @@ -12,6 +12,7 @@ const props = () => ({ file: { path: 'out/report.pdf', description: 'Final report', seq: 4, index: 1 }, host: { name: 'remote-desktop', available: true, fileManager: 'finder' as const }, phase: undefined, + onPreview: vi.fn(), onAction: vi.fn(), t: makeTranslate(en), }) @@ -22,15 +23,13 @@ it.each([ const p = props() const view = render() fireEvent.click(view.getByRole('button', { name: 'More file actions for out/report.pdf' })) - expect(view.getByText('Opens on remote-desktop')).toBeTruthy() fireEvent.click(view.getByRole('menuitem', { name: new RegExp(label) })) expect(p.onAction).toHaveBeenCalledWith('reveal') expect(view.queryByRole('menu')).toBeNull() - fireEvent.click(view.getByRole('button', { name: 'Open out/report.pdf in default app' })) - expect(p.onAction).toHaveBeenLastCalledWith('open') fireEvent.click(view.getByRole('button', { name: 'More file actions for out/report.pdf' })) fireEvent.click(view.getByRole('menuitem', { name: /Open in default app/ })) - expect(p.onAction).toHaveBeenCalledTimes(3) + expect(p.onAction).toHaveBeenLastCalledWith('open') + expect(p.onAction).toHaveBeenCalledTimes(2) }) it('dismisses the menu with Escape or an outside click without launching anything', () => { @@ -46,32 +45,42 @@ it('dismisses the menu with Escape or an outside click without launching anythin expect(p.onAction).not.toHaveBeenCalled() }) -it.each(['opening', 'revealing'] as const)('disables both gestures while %s', (phase) => { +it.each(['opening', 'revealing'] as const)('keeps sidebar previews available while the native action is %s', (phase) => { const p = props() const view = render() - for (const button of view.getAllByRole('button')) { - expect((button as HTMLButtonElement).disabled).toBe(true) - fireEvent.click(button) - } + expect((view.getByRole('button', { name: 'More file actions for out/report.pdf' }) as HTMLButtonElement).disabled).toBe(true) + fireEvent.click(view.getByRole('button', { name: 'Preview out/report.pdf in sidebar' })) + fireEvent.click(view.getByRole('button', { name: 'Open out/report.pdf in sidebar' })) + expect(p.onPreview).toHaveBeenCalledTimes(2) expect(p.onAction).not.toHaveBeenCalled() }) -it('keeps actions disabled until a desktop is available', () => { +it('keeps the native menu disabled until a desktop is available', () => { const p = props() const view = render() - expect(view.getAllByRole('button').every(button => (button as HTMLButtonElement).disabled)).toBe(true) + expect((view.getByRole('button', { name: 'More file actions for out/report.pdf' }) as HTMLButtonElement).disabled).toBe(true) + expect((view.getByRole('button', { name: 'Open out/report.pdf in sidebar' }) as HTMLButtonElement).disabled).toBe(false) view.rerender() - expect(view.getAllByRole('button').every(button => (button as HTMLButtonElement).disabled)).toBe(true) + expect((view.getByRole('button', { name: 'More file actions for out/report.pdf' }) as HTMLButtonElement).disabled).toBe(true) +}) + +it('opens the right sidebar from either the card or its primary button', () => { + const p = props() + const view = render() + fireEvent.click(view.getByRole('button', { name: 'Preview out/report.pdf in sidebar' })) + fireEvent.click(view.getByRole('button', { name: 'Open out/report.pdf in sidebar' })) + expect(p.onPreview).toHaveBeenCalledTimes(2) + expect(p.onAction).not.toHaveBeenCalled() }) it('localizes reveal failures and accurately reports a directory-only action', () => { const p = props() const view = render() - expect(view.getByRole('status').textContent).toBe(zh['presented.revealError']) + expect(view.getByText(zh['presented.revealError'])).toBeTruthy() view.rerender() - expect(view.getByRole('status').textContent).toBe(en['presented.directoryOpened']) + expect(view.getByText(en['presented.directoryOpened'])).toBeTruthy() view.rerender() - expect(view.getByRole('status').textContent).toBe(en['presented.revealed']) + expect(view.getByText(en['presented.revealed'])).toBeTruthy() }) @@ -96,13 +105,26 @@ it('supports keyboard selection and returns focus to the trigger on Escape', () }) -it('shortens the workspace prefix while retaining the full location on hover and in action labels', () => { +it('shows the basename while retaining the full location for hover and actions', () => { const p = props() const path = '/work/reports/result.pdf' const view = render() - expect(view.getByTitle(path).textContent).toBe('reports/result.pdf') - fireEvent.click(view.getByRole('button', { name: `Open ${path} in default app` })) + expect(view.getByTitle(path)).toBeTruthy() + expect(view.getByText('result.pdf')).toBeTruthy() + fireEvent.click(view.getByRole('button', { name: `More file actions for ${path}` })) + fireEvent.click(view.getByRole('menuitem', { name: 'Open in default app' })) expect(p.onAction).toHaveBeenCalledWith('open') view.rerender() - expect(view.getByTitle('/work/out/report.pdf').textContent).toBe('out/report.pdf') + expect(view.getByTitle('/work/out/report.pdf')).toBeTruthy() + expect(view.getByText('report.pdf')).toBeTruthy() +}) + +it.each([ + ['Quarterly summary (.pdf)', 'Quarterly summary'], + ['季度总结(PDF)', '季度总结'], +] as const)('omits a trailing parenthesized file suffix from %s', (description, expected) => { + const p = props() + const view = render() + expect(view.getByText(expected)).toBeTruthy() + expect(view.queryByText(description)).toBeNull() }) diff --git a/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx b/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx index 9f1537e3ea..3c72544468 100644 --- a/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx @@ -588,20 +588,33 @@ describe('presented files', () => { expect(selectDeliverables(tailOwner(deliverablesOf(value, 2), 9))).toBeNull() }) - it('uses the viewed fork Session in every open action and retains all delivered files', () => { + it('uses the viewed fork Session in every open action and expands all delivered files', () => { const value = assembler([ at(1, 'turn/start', { turn: 1 }), at(2, 'deliverables/presented', { turn: 1, callId: 'nested', files: Array.from({ length: 8 }, (_, i) => file(`report-${i}.docx`)) }), ]) - const owner = tailOwner(deliverablesOf(value), 3) + const preview = vi.fn() + const owner = tailOwner(deliverablesOf(value), 3, preview) const matched = selectDeliverables(owner)! const props = openProps() props.openPresented.mockResolvedValue(undefined) const view = render() - expect(view.getAllByRole('button')).toHaveLength(16) + expect(view.container.querySelectorAll('[data-presented-file]')).toHaveLength(4) + const expand = view.getByRole('button', { name: 'Show all 8 delivered files' }) + expect(expand.getAttribute('aria-expanded')).toBe('false') + fireEvent.click(expand) + expect(view.container.querySelectorAll('[data-presented-file]')).toHaveLength(8) + expect(view.getByRole('button', { name: 'Collapse delivered files' }).getAttribute('aria-expanded')).toBe('true') expect(view.queryByRole('link')).toBeNull() - fireEvent.click(view.getByRole('button', { name: 'Open report-0.docx in default app' })) + fireEvent.click(view.getByRole('button', { name: 'Preview report-0.docx in sidebar' })) + fireEvent.click(view.getByRole('button', { name: 'Open report-0.docx in sidebar' })) + expect(preview).toHaveBeenCalledTimes(2) + expect(preview).toHaveBeenLastCalledWith('report-0.docx') + fireEvent.click(view.getByRole('button', { name: 'More file actions for report-0.docx' })) + fireEvent.click(view.getByRole('menuitem', { name: 'Open in default app' })) expect(props.openPresented).toHaveBeenCalledWith('child-session', 2, 0, 'open') + fireEvent.click(view.getByRole('button', { name: 'Collapse delivered files' })) + expect(view.container.querySelectorAll('[data-presented-file]')).toHaveLength(4) expect(view.queryByText('Files changed')).toBeNull() }) }) @@ -628,15 +641,23 @@ it.each([{}, { turn: '1', callId: 'bad', files: [] }, expect(view.queryByText('Deliverables')).toBeNull() }) -it('shows file metadata and descriptions without hiding extensionless deliveries', () => { +it('shows descriptions and falls back to file metadata without hiding extensionless deliveries', () => { const view = render( {}} sessionId={SessionId('session')} t={makeTranslate(en)} />) expect(view.getByText('Quarterly summary')).toBeTruthy() - expect(view.getByText('TXT')).toBeTruthy() expect(view.getByText('File')).toBeTruthy() - expect(view.getByText('out/report.txt')).toBeTruthy() + expect(view.getByTitle('out/report.txt')).toBeTruthy() + expect(view.getByText('report.txt')).toBeTruthy() +}) + +it('lets one delivered file span the complete row without an expansion control', () => { + const view = render( {}} sessionId={SessionId('session')} t={makeTranslate(en)} />) + expect(view.container.querySelector('[data-presented-files-row]')?.getAttribute('data-single')).toBe('true') + expect(view.queryByRole('button', { name: /delivered files/ })).toBeNull() }) @@ -647,8 +668,8 @@ it.each(['opening', 'opened', 'error'] as const)('shows the %s state and permits const view = render( {}} sessionId={SessionId('session')} t={makeTranslate(en)} />) - expect(view.getByRole('status').textContent).toBe(en[`presented.${phase}`]) - expect((view.getByRole('button', { name: 'Open report.txt in default app' }) as HTMLButtonElement).disabled).toBe(phase === 'opening') + expect(view.getByText(en[`presented.${phase}`])).toBeTruthy() + expect((view.getByRole('button', { name: 'More file actions for report.txt' }) as HTMLButtonElement).disabled).toBe(phase === 'opening') }) diff --git a/packages/client/ui-primitives/src/icons/index.tsx b/packages/client/ui-primitives/src/icons/index.tsx index 23ad8cebb9..f5112b64f1 100644 --- a/packages/client/ui-primitives/src/icons/index.tsx +++ b/packages/client/ui-primitives/src/icons/index.tsx @@ -462,7 +462,7 @@ export const IconRightUpOutline14 = ({ size = 8, className }: IconProps) => ( export const IconRightUpOutline16 = ({ size = 16, className }: IconProps) => ( diff --git a/snapshots/web/present/ui.expected.md b/snapshots/web/present/ui.expected.md index 1346420ed9..534e366533 100644 --- a/snapshots/web/present/ui.expected.md +++ b/snapshots/web/present/ui.expected.md @@ -63,24 +63,18 @@ - code: AFTER_PRESENT - text: — no retries, no extra files. - paragraph: PRESENT_DONE -- text: Deliverables Opens on the DSH Host report.txt TXT delivered report -- img +- button "Preview report.txt in sidebar" - text: report.txt -- button "Open report.txt in default app": - - img - - text: Open +- status: Opened in default app +- button "Open report.txt in sidebar": Open - button "More file actions for report.txt": - img -- status: Opened in default app -- text: 说明.txt TXT delivered note -- img +- button "Preview 说明.txt in sidebar" - text: 说明.txt -- button "Open 说明.txt in default app": - - img - - text: Open +- status: Opened in default app +- button "Open 说明.txt in sidebar": Open - button "More file actions for 说明.txt": - img -- status: Opened in default app - button "Copy": - img - button "Good response": From a43fa81f82ec383cc878a775c0cb95b0e19e7a34 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 9 Sep 2026 16:08:19 +0800 Subject: [PATCH 018/111] 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 aed212657a3e9ca3a89f3b28913148b63c95f600 Mon Sep 17 00:00:00 2001 From: fz Date: Wed, 9 Sep 2026 16:13:09 +0800 Subject: [PATCH 019/111] docs: clarify scoped guidance and file observation policy --- packages/core/system-prompt/README.i18n.yaml | 4 ++-- packages/core/system-prompt/README.md | 2 +- packages/core/system-prompt/README.zh.md | 2 +- packages/fs/tool-fs-search/README.i18n.yaml | 4 ++-- packages/fs/tool-fs-search/README.md | 2 +- packages/fs/tool-fs-search/README.zh.md | 2 +- packages/fs/tool-fs/README.i18n.yaml | 4 ++-- packages/fs/tool-fs/README.md | 2 +- packages/fs/tool-fs/README.zh.md | 2 +- packages/subagent/subagent-in-process-driver/README.i18n.yaml | 4 ++-- packages/subagent/subagent-in-process-driver/README.md | 4 ++-- packages/subagent/subagent-in-process-driver/README.zh.md | 4 ++-- 12 files changed, 18 insertions(+), 18 deletions(-) diff --git a/packages/core/system-prompt/README.i18n.yaml b/packages/core/system-prompt/README.i18n.yaml index afa3965820..4ea091a963 100644 --- a/packages/core/system-prompt/README.i18n.yaml +++ b/packages/core/system-prompt/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/core/system-prompt/README.md -README.md: e98c1be171d7dbb16cabf1289a20be8922e5076b -README.zh.md: 2408ec1dbb730bd5f085f3467a5359f5900efebc +README.md: 7e4826c4fb51873db02fd80eca632c3d1dd8d550 +README.zh.md: 55730f11fffdc6ca92ce83613c9af3b1331351b1 diff --git a/packages/core/system-prompt/README.md b/packages/core/system-prompt/README.md index e98c1be171..7e4826c4fb 100644 --- a/packages/core/system-prompt/README.md +++ b/packages/core/system-prompt/README.md @@ -152,7 +152,7 @@ Prefix-stable while identity, persona, variables, section text, and order render #### What the model sees -For shipped tools, the model receives the per-agent-visible subset of the [generated tool schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tools), ordered by configuration or lexicographically after restrictions and assembly interception. Extensions can contribute additional definitions through the same registry. Sections and schema providers are separate assembly inputs, so a tool restriction does not remove independently registered guidance. +For shipped tools, the model receives the per-agent-visible subset of the [generated tool schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tools), ordered by configuration or lexicographically after restrictions and assembly interception. Extensions can contribute additional definitions through the same registry. Sections and schema providers are separate assembly inputs. A restriction does not remove a section registration: tool-guidance plugins use `text({ scope })` and `ctx.tools.get(name, scope)` to return empty text or select applicable fragments. Arbitrary static sections are not automatically rewritten. #### Token effect diff --git a/packages/core/system-prompt/README.zh.md b/packages/core/system-prompt/README.zh.md index 2408ec1dbb..55730f11ff 100644 --- a/packages/core/system-prompt/README.zh.md +++ b/packages/core/system-prompt/README.zh.md @@ -152,7 +152,7 @@ You are an AI agent powered by DeepSeek Harness. #### 模型看到什么 -对于已交付工具,模型会收到[生成工具 schema](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tools)中对每个 agent 可见的子集;限制与组装拦截完成后,按配置或字典序排列。扩展可以通过同一注册表贡献其他定义。段与 schema 提供方是独立的组装输入,因此工具限制不会移除独立注册的引导。 +对于已交付工具,模型会收到[生成工具 schema](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tools)中对每个 agent 可见的子集;限制与组装拦截完成后,按配置或字典序排列。扩展可以通过同一注册表贡献其他定义。段与 schema 提供方是独立的组装输入。限制不会移除段落注册:工具指导插件通过 `text({ scope })` 与 `ctx.tools.get(name, scope)` 返回空文本或选择适用片段。任意静态段落不会被自动改写。 #### Token 影响 diff --git a/packages/fs/tool-fs-search/README.i18n.yaml b/packages/fs/tool-fs-search/README.i18n.yaml index 990e8fe66f..10db3284a7 100644 --- a/packages/fs/tool-fs-search/README.i18n.yaml +++ b/packages/fs/tool-fs-search/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/fs/tool-fs-search/README.md -README.md: 1f3fd4a0cf547915254b3f1a85d6c2c94663c657 -README.zh.md: 61be36c240e890068759d485b357f0123adf5787 +README.md: 83b1d89b3276f3f6fabbe501017af2df8ec1868a +README.zh.md: 6ef5ac731fdec6fd04b8bdd0b30bee9086e232f1 diff --git a/packages/fs/tool-fs-search/README.md b/packages/fs/tool-fs-search/README.md index 1f3fd4a0cf..83b1d89b32 100644 --- a/packages/fs/tool-fs-search/README.md +++ b/packages/fs/tool-fs-search/README.md @@ -134,7 +134,7 @@ Read these pages when the package-level contract is not enough. They move from t #### What the model sees -At assembly time, each section checks `ctx.tools.get(name, scope)` and renders only while its tool is visible. The grep paragraph includes its read follow-up sentence only while read is visible. The original text and section order stay unchanged for the same supported tool set, including PTC capabilities behind `run_code`. +At assembly time, each section checks `ctx.tools.get(name, scope)` and renders only while its tool is visible. The grep paragraph includes its read follow-up sentence only while read is visible. The original text and section order stay unchanged for the same supported tool set, including PTC capabilities behind `run_code`. This scope-dependent text selection applies to system-prompt sections. Tool schema descriptions remain registration-time text; in particular, the grep schema still recommends read even in a scope that hides read. Scope-dependent schema wording is not implemented. ##### Glob guidance with `sampleOverCapGlobResults: true` diff --git a/packages/fs/tool-fs-search/README.zh.md b/packages/fs/tool-fs-search/README.zh.md index 61be36c240..6ef5ac731f 100644 --- a/packages/fs/tool-fs-search/README.zh.md +++ b/packages/fs/tool-fs-search/README.zh.md @@ -134,7 +134,7 @@ Node 部署在受支持的 macOS、Linux 与 Windows 目标上获得 `@vscode/ri #### 模型看到的内容 -组装时,每个段落通过 `ctx.tools.get(name, scope)` 检查对应工具,仅在其可见时输出。grep 段落仅在 read 可见时包含后续使用 read 的句子。同一受支持工具集合下,原文和段落顺序保持不变,包括通过 `run_code` 暴露的 PTC 能力。 +组装时,每个段落通过 `ctx.tools.get(name, scope)` 检查对应工具,仅在其可见时输出。grep 段落仅在 read 可见时包含后续使用 read 的句子。同一受支持工具集合下,原文和段落顺序保持不变,包括通过 `run_code` 暴露的 PTC 能力。 这种按 scope 选择文本的机制适用于系统提示词段落。工具 schema 描述仍是注册时的文本;具体而言,即使 scope 隐藏了 read,grep 的 schema 仍会推荐 read。尚未实现按 scope 改变 schema 措辞。 ##### 启用 `sampleOverCapGlobResults: true` 时的 Glob 指导 diff --git a/packages/fs/tool-fs/README.i18n.yaml b/packages/fs/tool-fs/README.i18n.yaml index 78e087859e..7b8246dbbe 100644 --- a/packages/fs/tool-fs/README.i18n.yaml +++ b/packages/fs/tool-fs/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/fs/tool-fs/README.md -README.md: b51fd89de18a2a71dc7dd6b6fbde4bc1e9364dd6 -README.zh.md: 088bfeca8dec3343c86ca9dcda64eb804bfc1d28 +README.md: 9d338a6227b7f02ef43fcbe97900b166cb722817 +README.zh.md: 0c34f840ad449847a649df8abdadec01f772f37b diff --git a/packages/fs/tool-fs/README.md b/packages/fs/tool-fs/README.md index b51fd89de1..9d338a6227 100644 --- a/packages/fs/tool-fs/README.md +++ b/packages/fs/tool-fs/README.md @@ -133,7 +133,7 @@ Read these pages when the package-level contract is not enough. They move from t #### What the model sees -At assembly time, each guidance section checks `ctx.tools.get(name, scope)` and renders only while its tool is visible to that agent. The write paragraph recommends edit only while edit is visible. The text below is unchanged when all three tools are available; restrictions, their removal, and tool registration changes take effect on the next assembly. The same check works for direct agent restrictions and subagent `toolFilter`, including PTC capabilities behind `run_code`. +At assembly time, each guidance section checks `ctx.tools.get(name, scope)` and renders only while its tool is visible to that agent. The write paragraph recommends edit only while edit is visible. The text below is unchanged when all three tools are available; restrictions, their removal, and tool registration changes take effect on the next assembly. The same check works for direct agent restrictions and subagent `toolFilter`, including PTC capabilities behind `run_code`. The read-before-mutation sentences in write/edit describe the observation policy, not a requirement to invoke the tool named `read`. They remain when `read` is hidden: the policy still guards mutations, and another observing operation, such as `str_replace_editor` with `command: view`, can establish the same file observation. Tool visibility does not disable that precondition. ##### Read guidance diff --git a/packages/fs/tool-fs/README.zh.md b/packages/fs/tool-fs/README.zh.md index 088bfeca8d..0c34f840ad 100644 --- a/packages/fs/tool-fs/README.zh.md +++ b/packages/fs/tool-fs/README.zh.md @@ -133,7 +133,7 @@ kind: "package-reference" #### 模型看到的内容 -组装时,每个指导段落通过 `ctx.tools.get(name, scope)` 检查对应工具,仅在该 agent 可见时输出。write 段落仅在 edit 可见时推荐 edit。三个工具都可用时,下方原文保持不变;限制的施加、解除和工具注册变化在下次组装时生效。同一检查适用于直接限制 agent 和 subagent 的 `toolFilter`,也适用于通过 `run_code` 暴露的 PTC 能力。 +组装时,每个指导段落通过 `ctx.tools.get(name, scope)` 检查对应工具,仅在该 agent 可见时输出。write 段落仅在 edit 可见时推荐 edit。三个工具都可用时,下方原文保持不变;限制的施加、解除和工具注册变化在下次组装时生效。同一检查适用于直接限制 agent 和 subagent 的 `toolFilter`,也适用于通过 `run_code` 暴露的 PTC 能力。 write/edit 中的先读后改句子描述观察策略,并非要求调用名为 `read` 的工具。隐藏 `read` 时仍保留这些句子:策略继续保护修改操作,其他产生观察记录的操作(例如 `str_replace_editor` 的 `command: view`)也能建立同一文件观察记录。工具可见性不会禁用该前置条件。 ##### Read 指导 diff --git a/packages/subagent/subagent-in-process-driver/README.i18n.yaml b/packages/subagent/subagent-in-process-driver/README.i18n.yaml index 0a7c5e20f3..4583465661 100644 --- a/packages/subagent/subagent-in-process-driver/README.i18n.yaml +++ b/packages/subagent/subagent-in-process-driver/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/subagent/subagent-in-process-driver/README.md -README.md: 7c8e0b896637d6b5afd1de628db56d1f5224486d -README.zh.md: f37cfe3e3a15697f4286a40ab203fa8ac87746f0 +README.md: 03b72c2f9f02dc7ef2db29f57d4fdd1b5ce83027 +README.zh.md: a75347002a3d2d66e37461dd5e14d140c8f8fc45 diff --git a/packages/subagent/subagent-in-process-driver/README.md b/packages/subagent/subagent-in-process-driver/README.md index 7c8e0b8966..03b72c2f9f 100644 --- a/packages/subagent/subagent-in-process-driver/README.md +++ b/packages/subagent/subagent-in-process-driver/README.md @@ -98,11 +98,11 @@ Read these pages when the package-level contract is not enough; they move from t #### What the model sees -The shared driver sends the task verbatim as the child's user message and, when requested, shadows the persona and restricts global tool schemas, lookup, execution, and PTC mode SDK bindings in the unpublished child's fresh scope; parent restrictions are not inherited, and standalone tool-guidance sections remain. Spawn supplies no history; fork supplies its balanced seed. +The shared driver sends the task verbatim as the child's user message and, when requested, shadows the persona and restricts global tool schemas, lookup, execution, and PTC mode SDK bindings in the unpublished child's fresh scope; parent restrictions are not inherited. Tool-guidance plugins can use the assembly scope to omit unavailable guidance; arbitrary static sections are not rewritten by the driver. Spawn supplies no history; fork supplies its balanced seed. #### Token effect -Child input is isolated from the parent and grows through the child's own steps. A persona changes repeated prompt text; filtering changes schema or generated SDK cost but not independently registered guidance. +Child input is isolated from the parent and grows through the child's own steps. A persona changes repeated prompt text; filtering changes schema or generated SDK cost, and scope-aware guidance changes with the visible capabilities. #### KV Cache effect diff --git a/packages/subagent/subagent-in-process-driver/README.zh.md b/packages/subagent/subagent-in-process-driver/README.zh.md index f37cfe3e3a..a75347002a 100644 --- a/packages/subagent/subagent-in-process-driver/README.zh.md +++ b/packages/subagent/subagent-in-process-driver/README.zh.md @@ -98,11 +98,11 @@ kind: "package-library" #### 模型看到什么 -共享驱动器把任务逐字作为子 agent 的用户消息发送;若有请求,还会在未发布子 agent 的全新作用域中遮蔽 persona,并限制全局工具 schema、查找、执行与 PTC mode SDK 绑定。父级限制不会被继承,独立的工具指导段仍会保留。spawn 不提供历史;fork 提供其已配平的初始内容。 +共享驱动器把任务逐字作为子 agent 的用户消息发送;若有请求,还会在未发布子 agent 的全新作用域中遮蔽 persona,并限制全局工具 schema、查找、执行与 PTC mode SDK 绑定。父级限制不会被继承。工具指导插件可以使用组装 scope 省略不可用工具的指导;驱动器不会改写任意静态段落。spawn 不提供历史;fork 提供其已配平的初始内容。 #### Token 影响 -子 agent 输入与父级隔离,并随子 agent 自身的步骤增长。persona 会改变重复提示词文本;过滤会改变 schema 或生成 SDK 的成本,但不影响独立注册的指导内容。 +子 agent 输入与父级隔离,并随子 agent 自身的步骤增长。persona 会改变重复提示词文本;过滤会改变 schema 或生成 SDK 的成本,使用 scope 的指导内容也会随可见能力变化。 #### KV Cache 影响 From 74bf7aaa2fdd56c419e46292215d15d1a3efee63 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 9 Sep 2026 16:18:24 +0800 Subject: [PATCH 020/111] fix(web): preserve artifact actions across reconnects and keyboard use --- ...8-present-workspace-source-files.i18n.yaml | 4 +- ...26-09-08-present-workspace-source-files.md | 2 +- ...09-08-present-workspace-source-files.zh.md | 2 +- apps/web/tests/present.e2e.ts | 2 + .../client/ui-deliverables/README.i18n.yaml | 4 +- packages/client/ui-deliverables/README.md | 2 +- packages/client/ui-deliverables/README.zh.md | 2 +- .../src/client/PresentedFileCard.tsx | 16 ++++++-- .../ui-deliverables/src/client/index.ts | 2 + .../ui-deliverables/src/client/locales.ts | 12 ++++-- .../src/client/present-open.ts | 26 ++++++++++--- .../tests/present-open.client.spec.ts | 36 +++++++++++++++++ .../tests/presented-file-card.client.spec.tsx | 36 +++++++++++++++++ .../tests/produced-files.client.spec.tsx | 11 ++++++ .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 2 +- packages/client/ui-primitives/src/Menu.tsx | 2 +- packages/util/native-command/README.i18n.yaml | 4 +- packages/util/native-command/README.md | 2 +- packages/util/native-command/README.zh.md | 2 +- .../util/native-command/src/path-opener.ts | 13 ++++++- .../native-command/tests/path-opener.spec.ts | 39 ++++++++++++++++++- 23 files changed, 192 insertions(+), 35 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.i18n.yaml b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.i18n.yaml index 3c1e280e32..715341ab78 100644 --- a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.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-present-workspace-source-files.md -2026-09-08-present-workspace-source-files.md: 0dade8823d1385a28b7d9fe6085895d64dd9e898 -2026-09-08-present-workspace-source-files.zh.md: d849b62870a42ad3643e8909f70bf9c29f9115fa +2026-09-08-present-workspace-source-files.md: b8956b17a20090847da6de3c301254a73701e6f2 +2026-09-08-present-workspace-source-files.zh.md: 2e82f4e27a99f484005243a4769f3aadad290666 diff --git a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md index 0dade8823d..b8956b17a2 100644 --- a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md +++ b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md @@ -18,7 +18,7 @@ The tool remains an ordinary package with shared filesystem and tool error class An authenticated POST selects a declaration by viewed Session, event sequence, and original file index. The event carries no owning Session ID; relative paths in inherited history resolve against the viewed Session's workspace. The Host rechecks canonical workspace containment and regular-file existence before native opening. Route disposal cancels and awaits pending commands. The “Files changed” row lists successful file-tool mutations and retains its separate text-preview behavior. Its Chinese label is “本轮文件改动”; neither label implies final delivery. -File cards use the same split-control pattern as the Session header. The card and the left Open segment preview the source in the right Sidebar; the chevron opens the standard menu for default-app and file-manager actions. The Host selects the file in Finder or Explorer, or opens its containing folder through the default Linux file manager. Both native actions resolve the same saved declaration and recheck workspace containment; neither accepts a browser-supplied replacement path. Host-derived desktop metadata keeps remote-browser labels and availability honest, and the route enforces the configured availability on each native gesture. One delivery spans the row; multiple deliveries use at most two columns, retain every declaration, and collapse after the first four cards until the user expands the list. +File cards use the same split-control pattern as the Session header. The card and the left Open segment preview the source in the right Sidebar; the chevron opens the standard menu for default-app and file-manager actions. The Host selects the file in Finder or Explorer, or opens its containing folder through the default Linux file manager. Both native actions resolve the same saved declaration and recheck workspace containment; neither accepts a browser-supplied replacement path. Host-derived desktop metadata keeps remote-browser labels and availability honest, and the route enforces the configured availability on each native gesture. One delivery spans the row; multiple deliveries use at most two columns, retain every declaration, and collapse after the first four cards until the user expands the list. Desktop metadata is invalidated with the connection generation so an old Host cannot keep native actions disabled or supply the wrong file-manager labels. Old metadata requests are cancelled and cannot replace the new generation’s response. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md index d849b62870..2e82f4e27a 100644 --- a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md +++ b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md @@ -18,7 +18,7 @@ Status: implemented 经过认证的 POST 按当前查看的 Session、事件序号和原始文件索引选择声明。事件不携带所属 Session ID;继承历史中的相对路径按当前查看的 Session 工作区解析。Host 在原生打开前重新检查规范路径的工作区包含关系和普通文件是否存在。路由释放时取消并等待进行中的命令。“本轮文件改动”行列出成功的文件工具修改,并保留独立的文本预览行为。其英文标签为“Files changed”;两个标签均不表示最终交付。 -文件卡片采用与 Session 顶栏相同的分段控件。点击卡片或左侧“打开”区域会在右侧 Sidebar 预览源文件;右侧箭头打开包含默认应用与文件管理器操作的标准菜单。Host 在 Finder 或文件资源管理器中选中文件,或通过 Linux 默认文件管理器打开所在文件夹。两个原生操作都解析同一份已保存声明并重新检查工作区包含关系;均不接受浏览器提供的替代路径。来自 Host 的桌面信息使远程浏览器中的文案和可用性保持准确,路由在每次原生操作时执行配置的可用性检查。单个交付占满整行;多个交付每行最多两列,并保留所有声明,前四张卡片之后的内容在用户展开列表前保持收起。 +文件卡片采用与 Session 顶栏相同的分段控件。点击卡片或左侧“打开”区域会在右侧 Sidebar 预览源文件;右侧箭头打开包含默认应用与文件管理器操作的标准菜单。Host 在 Finder 或文件资源管理器中选中文件,或通过 Linux 默认文件管理器打开所在文件夹。两个原生操作都解析同一份已保存声明并重新检查工作区包含关系;均不接受浏览器提供的替代路径。来自 Host 的桌面信息使远程浏览器中的文案和可用性保持准确,路由在每次原生操作时执行配置的可用性检查。单个交付占满整行;多个交付每行最多两列,并保留所有声明,前四张卡片之后的内容在用户展开列表前保持收起。 桌面元数据随连接代次失效,避免旧主机信息让原生操作持续禁用或显示错误的文件管理器名称。旧元数据请求会被取消,不能覆盖新代次的响应。 ## 考虑过的替代方案 diff --git a/apps/web/tests/present.e2e.ts b/apps/web/tests/present.e2e.ts index 7b2f110f76..d1b4bc713c 100644 --- a/apps/web/tests/present.e2e.ts +++ b/apps/web/tests/present.e2e.ts @@ -128,6 +128,8 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con const revealResponse = page.waitForResponse(response => response.url().includes('action=reveal') && response.request().method() === 'POST') await page.getByRole('menuitem', { name: process.platform === 'darwin' ? /Show in Finder/ : /Open containing folder/ }).click() expect((await revealResponse).status()).toBe(204) + expect(await row.getByRole('button', { name: 'Open report.txt in sidebar', exact: true }) + .evaluate(button => button === document.activeElement)).toBe(true) await expect.poll(opened).toHaveLength(beforeReveal + 1) expect((await opened()).at(-1)).toEqual({ action: 'reveal', content: null, path: await realpath(process.platform === 'darwin' ? join(cwd, 'report.txt') : cwd) }) for (const [name, bytes] of [['report.txt', 'EDITED_REPORT\n'], ['说明.txt', 'EDITED_NOTE\n']] as const) { diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml index d5c876bb0c..ab6fce907a 100644 --- a/packages/client/ui-deliverables/README.i18n.yaml +++ b/packages/client/ui-deliverables/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-deliverables/README.md -README.md: 87d4b1acbafbab3d1c35a14688671b9ae974c32c -README.zh.md: 9ad0b8102735448836445fdc80b2371ef539d241 +README.md: 9faea88696f3d6a7d39429e82449978647330a5f +README.zh.md: 8b2c846dffe674d2112f9b7680040a762d8b0c60 diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md index 87d4b1acba..9faea88696 100644 --- a/packages/client/ui-deliverables/README.md +++ b/packages/client/ui-deliverables/README.md @@ -32,7 +32,7 @@ Mount this plugin alongside `ui-conversation`; a finished turn then ends with th The Web `standard`, `ptc`, and `cordis` presets expose `present` for final workspace files, including files created through Bash. Call it with `files: [{ path, description? }]` after creating the files. The [present tool](../../fs/tool-present/README.md) owns file-count limits and Session declarations. The closing turn shows one delivery as a full-width card and multiple deliveries in a grid of at most two cards per row. A list longer than four files starts collapsed and provides a control that reveals or hides the complete list. Each card shows the basename and description, or the file type when no description exists; a trailing parenthesized suffix in the description is omitted, and hovering the card replaces that line with the Sidebar-preview action. Clicking the card or the left side of its split Open control previews the file in the right Sidebar. The chevron opens the standard menu for the Host default application plus Show in Finder on macOS, Show in File Explorer on Windows and WSL, or Open containing folder through the default Linux file manager. Matching inline-code references open the same source files without starting a browser download. Repeated declaration of a path selects its latest description before the closing reply. -The `present` tool row shows running, delivered, failed, or interrupted status; expanding a settled row reveals its recorded result. The collapsible card grid retains every delivered file. Both menu actions share pending state and show progress, acknowledgement, or an action-specific retryable error. Desktop information is read when delivery cards appear. A missing desktop disables the Open menu; a failed desktop-information read offers Retry. It requires a desktop and a suitable default application on the serving Host; a remote browser does not open applications on its own device. +The `present` tool row shows running, delivered, failed, or interrupted status; expanding a settled row reveals its recorded result. The collapsible card grid retains every delivered file. Both menu actions share pending state and show progress, acknowledgement, or an action-specific retryable error. Desktop information is read when delivery cards appear and invalidated on connection replacement; responses from a replaced connection cannot publish metadata. Selecting a native menu action returns keyboard focus to the available Sidebar Open button. Pending actions close the menu until another explicit gesture. A missing desktop disables the Open menu; a failed desktop-information read offers Retry. It requires a desktop and a suitable default application on the serving Host; a remote browser does not open applications on its own device. ### The row diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index 9ad0b81027..8b2c846dff 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -32,7 +32,7 @@ kind: "package-reference" Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交付最终工作区文件,包括通过 Bash 创建的文件。创建文件后,以 `files: [{ path, description? }]` 调用。[present 工具](../../fs/tool-present/README.zh.md)拥有文件数量限制和 Session 声明。收尾 turn 把单个交付显示为横向占满内容区的卡片,把多个交付显示为每行最多两张卡片的网格。文件超过四个时,列表默认收起,并提供显示或隐藏完整列表的控件。每张卡片显示 basename 与说明;没有说明时显示文件类型,说明末尾的括号后缀会被省略,悬停卡片时该行切换为侧栏预览提示。点击卡片或分段“打开”控件的左侧会在右侧 Sidebar 中预览文件;右侧箭头打开标准菜单,其中提供 Host 默认应用,以及 macOS 上的“在 Finder 中显示”、Windows 和 WSL 上的“在文件资源管理器中显示”或 Linux 默认文件管理器的“打开所在文件夹”。匹配的行内代码引用打开相同源文件,不触发浏览器下载。同一路径重复声明时,选择收尾回复之前最近一次的说明。 -`present` 工具行显示正在交付、已交付、失败或中断状态;展开已结束的调用可查看其记录的结果。可折叠卡片网格保留全部交付文件。菜单中的两个操作共享等待状态,并显示进度、请求确认或各自可重试的错误。交付卡片出现时才读取桌面信息。Host 没有桌面时禁用“打开”菜单;桌面信息读取失败时提供“重试”。服务 Host 必须具备桌面和合适的默认应用;远程浏览器不会打开其所在设备上的应用。 +`present` 工具行显示正在交付、已交付、失败或中断状态;展开已结束的调用可查看其记录的结果。可折叠卡片网格保留全部交付文件。菜单中的两个操作共享等待状态,并显示进度、请求确认或各自可重试的错误。交付卡片出现时读取桌面信息,连接更换时清除缓存,旧连接的响应不能更新元数据。选择原生菜单操作后,键盘焦点回到仍可用的侧边栏“打开”按钮。等待操作完成时关闭菜单,用户再次点击才会打开。Host 没有桌面时禁用“打开”菜单;桌面信息读取失败时提供“重试”。服务 Host 必须具备桌面和合适的默认应用;远程浏览器不会打开其所在设备上的应用。 ### 该行 diff --git a/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx b/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx index 2ca532182f..73e29f5523 100644 --- a/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx +++ b/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx @@ -1,5 +1,5 @@ /** File identity and explicit default-app or file-manager actions for one delivery. */ -import { useState } from 'react' +import { useRef, useState } from 'react' import { resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path' import { Menu, LinkIcon, classifyLinkPath, IconRightUpOutline16, @@ -31,15 +31,23 @@ export function PresentedFileCard({ file, cwd, phase, host, onPreview, onAction, onAction: (action: PresentedAction) => void } & PropsLocale) { const [menuOpen, setMenuOpen] = useState(false) + const previewRef = useRef(null) const pending = phase === 'opening' || phase === 'revealing' const menuDisabled = pending || host === null || !host.available + if (menuDisabled && menuOpen) setMenuOpen(false) const reveal = host?.fileManager ?? 'directory' - const act = (action: PresentedAction) => { setMenuOpen(false); onAction(action) } + const act = (action: PresentedAction) => { + setMenuOpen(false) + previewRef.current?.focus() + onAction(action) + } const name = basename(file.path) const metadata = name.match(/\.([^.]+)$/)?.[1]?.toUpperCase() ?? t('presented.file') const status = phase === undefined ? cardDescription(file.description, metadata) - : t(phase === 'revealed' && reveal === 'directory' ? 'presented.directoryOpened' : `presented.${phase}`) + : t(reveal === 'directory' && phase === 'revealed' ? 'presented.directoryOpened' + : reveal === 'directory' && phase === 'revealing' ? 'presented.directoryOpening' + : reveal === 'directory' && phase === 'revealError' ? 'presented.directoryError' : `presented.${phase}`) return
- { setMenuOpen(false) }} diff --git a/packages/client/ui-deliverables/src/client/index.ts b/packages/client/ui-deliverables/src/client/index.ts index 2a324c0be4..7ce723aa09 100644 --- a/packages/client/ui-deliverables/src/client/index.ts +++ b/packages/client/ui-deliverables/src/client/index.ts @@ -9,6 +9,7 @@ */ import type { Context as ClientContext } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-api-remotes/client' +import type {} from '@deepseek-ai/dsh-client-connection/client' import type { ChatFileMentions } from '@deepseek-ai/dsh-client-ui-chat/client' import type {} from '@deepseek-ai/dsh-client-locale/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -41,6 +42,7 @@ export const inject = ['slots', 'locale', 'uiConversation', 'remote', 'remote.se export function apply(ctx: ClientContext): void { const opener = new PresentedOpenController() ctx.effect(() => () => opener.dispose()) + ctx.on('connection/reset', () => { opener.resetHost() }) ctx.uiConversation.events.register(deliverablesDefinition) ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-deliverables: dictionaries') ctx.slots.inject( diff --git a/packages/client/ui-deliverables/src/client/locales.ts b/packages/client/ui-deliverables/src/client/locales.ts index de5310bbf0..5b9fa7781d 100644 --- a/packages/client/ui-deliverables/src/client/locales.ts +++ b/packages/client/ui-deliverables/src/client/locales.ts @@ -5,10 +5,12 @@ export const NS = 'deliverables' /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { - 'presented.revealError': '无法打开所在文件夹,请重试', + 'presented.revealError': '无法在文件管理器中显示,请重试', + 'presented.directoryError': '无法打开所在文件夹,请重试', + 'presented.directoryOpening': '正在打开所在文件夹…', 'presented.directoryOpened': '已请求打开所在文件夹', 'presented.revealed': '已请求在文件管理器中显示', - 'presented.revealing': '正在打开所在文件夹…', + 'presented.revealing': '正在文件管理器中显示…', 'presented.unavailable': '此主机没有可用的桌面,无法打开文件或文件夹', 'presented.retry': '重试', 'presented.hostError': '无法读取主机桌面信息', @@ -44,10 +46,12 @@ export const zh = { /** English dictionary (same key set). */ export const en: Record = { - 'presented.revealError': 'Could not open containing folder. Try again.', + 'presented.revealError': 'Could not show in file manager. Try again.', + 'presented.directoryError': 'Could not open containing folder. Try again.', + 'presented.directoryOpening': 'Opening containing folder…', 'presented.directoryOpened': 'Requested opening containing folder', 'presented.revealed': 'Requested display in file manager', - 'presented.revealing': 'Opening containing folder…', + 'presented.revealing': 'Showing in file manager…', 'presented.unavailable': 'This Host has no desktop available to open files or folders', 'presented.retry': 'Retry', 'presented.hostError': 'Could not read the Host desktop information', diff --git a/packages/client/ui-deliverables/src/client/present-open.ts b/packages/client/ui-deliverables/src/client/present-open.ts index 8d74c1615f..bb47634011 100644 --- a/packages/client/ui-deliverables/src/client/present-open.ts +++ b/packages/client/ui-deliverables/src/client/present-open.ts @@ -13,6 +13,7 @@ export class PresentedOpenController { /** Native destination metadata, or a retryable read failure. */ readonly host = createSnapshotStore(null) private loading: Promise | undefined + private metadata = new AbortController() private readonly lifetime = new AbortController() private readonly pending = new Set>() @@ -47,25 +48,38 @@ export class PresentedOpenController { if (this.lifetime.signal.aborted) return if (this.loading !== undefined) return this.loading this.host.set(null) - const task = this.readHost() + const task = this.readHost(AbortSignal.any([this.lifetime.signal, this.metadata.signal])) this.loading = task this.pending.add(task) try { await task } - finally { this.loading = undefined; this.pending.delete(task) } + finally { + if (this.loading === task) this.loading = undefined + this.pending.delete(task) + } } - private async readHost(): Promise { + /** Invalidate desktop metadata on connection replacement; mounted cards request the new Host. */ + resetHost(): void { + const wasLoading = this.loading !== undefined + this.metadata.abort() + this.metadata = new AbortController() + this.loading = undefined + this.host.set(null) + if (wasLoading) void this.loadHost() + } + + private async readHost(signal: AbortSignal): Promise { let host: PresentedHost | 'error' = 'error' try { - const response = await fetch(PRESENT_HOST_PATH, { signal: this.lifetime.signal }) + const response = await fetch(PRESENT_HOST_PATH, { signal }) if (response.ok) { const value: unknown = await response.json() if (isPresentedHost(value)) host = value } } catch { - // HTTP, JSON, and transport failures leave a retryable metadata read. + host = 'error' } - if (!this.lifetime.signal.aborted) this.host.set(host) + if (!signal.aborted) this.host.set(host) } /** Cancel outstanding requests and wait until no request can publish state. */ diff --git a/packages/client/ui-deliverables/tests/present-open.client.spec.ts b/packages/client/ui-deliverables/tests/present-open.client.spec.ts index aa022adf4c..bdcaaab3b9 100644 --- a/packages/client/ui-deliverables/tests/present-open.client.spec.ts +++ b/packages/client/ui-deliverables/tests/present-open.client.spec.ts @@ -116,3 +116,39 @@ it('coalesces metadata reads and suppresses their publication after disposal', a await Promise.all([first, second, disposal]) expect(controller.host.getSnapshot()).toBeNull() }) + + +it('invalidates cached desktop metadata without eagerly fetching an unused Host', async () => { + const fetcher = vi.fn().mockResolvedValue(Response.json({ name: 'old', available: false, fileManager: null })) + vi.stubGlobal('fetch', fetcher) + const controller = new PresentedOpenController() + await controller.loadHost() + controller.resetHost() + expect(controller.host.getSnapshot()).toBeNull() + expect(fetcher).toHaveBeenCalledOnce() + fetcher.mockResolvedValue(Response.json({ name: 'new', available: true, fileManager: 'finder' })) + await controller.loadHost() + expect(controller.host.getSnapshot()).toMatchObject({ name: 'new', available: true }) + await controller.dispose() +}) + +it('discards a replaced Host response and keeps the new metadata request coalesced', async () => { + const oldReply = Promise.withResolvers() + const newReply = Promise.withResolvers() + const fetcher = vi.fn().mockReturnValueOnce(oldReply.promise).mockReturnValue(newReply.promise) + vi.stubGlobal('fetch', fetcher) + const controller = new PresentedOpenController() + const oldLoad = controller.loadHost() + controller.resetHost() + expect((fetcher.mock.calls[0]?.[1] as RequestInit).signal?.aborted).toBe(true) + const newLoad = controller.loadHost() + oldReply.resolve(Response.json({ name: 'old', available: false, fileManager: null })) + await oldLoad + expect(controller.host.getSnapshot()).toBeNull() + const coalesced = controller.loadHost() + expect(fetcher).toHaveBeenCalledTimes(2) + newReply.resolve(Response.json({ name: 'new', available: true, fileManager: 'finder' })) + await Promise.all([newLoad, coalesced]) + expect(controller.host.getSnapshot()).toMatchObject({ name: 'new' }) + await controller.dispose() +}) diff --git a/packages/client/ui-deliverables/tests/presented-file-card.client.spec.tsx b/packages/client/ui-deliverables/tests/presented-file-card.client.spec.tsx index 0f060643b4..28207b5a29 100644 --- a/packages/client/ui-deliverables/tests/presented-file-card.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/presented-file-card.client.spec.tsx @@ -128,3 +128,39 @@ it.each([ expect(view.getByText(expected)).toBeTruthy() expect(view.queryByText(description)).toBeNull() }) + + +it('does not reopen a menu after a shared native request settles', () => { + const p = props() + const view = render() + fireEvent.click(view.getByRole('button', { name: 'More file actions for out/report.pdf' })) + expect(view.getByRole('menu')).toBeTruthy() + view.rerender() + expect(view.queryByRole('menu')).toBeNull() + view.rerender() + expect(view.queryByRole('menu')).toBeNull() +}) + +it('keeps focus on the available preview button after selecting a native action', () => { + const p = props() + const view = render() + fireEvent.click(view.getByRole('button', { name: 'More file actions for out/report.pdf' })) + const item = view.getByRole('menuitem', { name: 'Show in Finder' }) + item.focus() + fireEvent.click(item) + view.rerender() + const preview = view.getByRole('button', { name: 'Open out/report.pdf in sidebar' }) + expect(document.activeElement).toBe(preview) + view.rerender() + expect(document.activeElement).toBe(preview) +}) + +it.each([en, zh])('distinguishes directory-only progress and errors in each locale', (dictionary) => { + const p = { ...props(), t: makeTranslate(dictionary) } + const view = render() + expect(view.getByText(dictionary['presented.revealing'])).toBeTruthy() + view.rerender() + expect(view.getByText(dictionary['presented.directoryOpening'])).toBeTruthy() + view.rerender() + expect(view.getByText(dictionary['presented.directoryError'])).toBeTruthy() +}) diff --git a/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx b/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx index 3c72544468..4b7ae935b0 100644 --- a/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx @@ -555,13 +555,24 @@ describe('plugin registration', () => { service?.forClosing(delivered, SessionId('child-session'))?.resolve('report.docx')?.open() expect(fetcher).toHaveBeenCalledWith('/api/present.open?sessionId=child-session&seq=2&index=0', { method: 'POST', signal: expect.any(AbortSignal) as AbortSignal }) const face = entry!.inject!(SessionId('child-session') as never) as unknown as DeliverablesInjected + fetcher.mockResolvedValueOnce(Response.json({ name: 'desktop', available: true, fileManager: 'finder' })) await face.reloadPresentedHost() + expect(face.hooks.presentedHost.getSnapshot()).toMatchObject({ name: 'desktop' }) + ctx.emit('connection/reset') + expect(face.hooks.presentedHost.getSnapshot()).toBeNull() await face.openPresented(SessionId('child-session'), 2, 0) expect(face.hooks.presentedOpen.getSnapshot()['/api/present.open?sessionId=child-session&seq=2&index=0']).toBe('opened') // A turn that produced nothing yields no vocabulary at all. expect(service?.forClosing(tailOwner(undefined, 2), SessionId('viewed-session'))).toBeUndefined() + fetcher.mockResolvedValueOnce(Response.json({ name: 'last-host', available: true, fileManager: 'finder' })) + await face.reloadPresentedHost() await fiber.dispose() + const reset = vi.fn() + const unsubscribe = face.hooks.presentedHost.subscribe(reset) + ctx.emit('connection/reset') + expect(reset).not.toHaveBeenCalled() + unsubscribe() expect(ctx.slots.entries('conversation.chat.turnTail')).toHaveLength(0) expect(ctx.slots.entries('tool.call.toolview')).toHaveLength(0) // Fiber teardown retracts the service: the consumer's ctx.get sees the off state. diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index fc91ac631b..361c454a4d 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md -README.md: 194fc2a3ac97a8b40bb6c385af3b4ee6b3930b52 -README.zh.md: e2dcea7a7c03556fef789db21cef8187c8b72d66 +README.md: 1c3bb7a7d36884234340b29da9f8763e46796e61 +README.zh.md: a8bc94f4afeb5c13d95ccafea4ef1b926524abd0 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 194fc2a3ac..1c3bb7a7d3 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -67,7 +67,7 @@ Writing your own component in your own package is fine when the need is genuinel ### Controls and icons -The catalog above lists what each export is for; this section covers the behavior that props alone do not show. The `ic_ds_*` icon set and `FishLogo`/`BrandWordmark` marks fill brand and inline-icon slots. `LinkIcon` draws the leading category glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, all riding `currentColor` — and `classifyLinkPath` derives a file path's category from its extension. `FileTypeIcon` is the one glyph that does not ride `currentColor`: its sheet colour is the type's identity, so a consumer that wants it muted (an empty state) applies `filter: grayscale(1)` itself. `classifyFileType` and `classifyLinkPath` read one extension vocabulary (`file-extensions.ts`), so a path classifies consistently on a link and on a sheet; the sheet is the finer of the two. `ConnectionIndicator` renders a warning-colored disconnected action, a connecting label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Hover or keyboard focus shows only the reconnect action label, including while the connecting dots animate. Every state reserves the widest supplied label and uses fixed icon and text columns, so copy changes do not move or resize the control. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. `Toast` holds for the window its owner names through `holdMs`, because how long a banner has to stay depends on how much there is to read; the same value drives its unmount timer and the stylesheet's fade delay, so the two cannot disagree. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order. `Button.segment` joins the start and end controls of a split action without changing standalone button geometry. `Menu.autoFocus` focuses its first enabled item, supports Arrow Up/Down and Home/End navigation, and restores the trigger on Escape; it is opt-in for action menus. +The catalog above lists what each export is for; this section covers the behavior that props alone do not show. The `ic_ds_*` icon set and `FishLogo`/`BrandWordmark` marks fill brand and inline-icon slots. `LinkIcon` draws the leading category glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, all riding `currentColor` — and `classifyLinkPath` derives a file path's category from its extension. `FileTypeIcon` is the one glyph that does not ride `currentColor`: its sheet colour is the type's identity, so a consumer that wants it muted (an empty state) applies `filter: grayscale(1)` itself. `classifyFileType` and `classifyLinkPath` read one extension vocabulary (`file-extensions.ts`), so a path classifies consistently on a link and on a sheet; the sheet is the finer of the two. `ConnectionIndicator` renders a warning-colored disconnected action, a connecting label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Hover or keyboard focus shows only the reconnect action label, including while the connecting dots animate. Every state reserves the widest supplied label and uses fixed icon and text columns, so copy changes do not move or resize the control. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. `Toast` holds for the window its owner names through `holdMs`, because how long a banner has to stay depends on how much there is to read; the same value drives its unmount timer and the stylesheet's fade delay, so the two cannot disagree. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order. `Button.segment` joins the start and end controls of a split action without changing standalone button geometry. `Menu.autoFocus` focuses its first enabled item, supports Arrow Up/Down and Home/End navigation, and focuses the first button in the anchor on Escape; it is opt-in for action menus. ### Rendering agent output diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index e2dcea7a7c..a8bc94f4af 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -67,7 +67,7 @@ kind: "package-library" ### 控件与图标 -上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。`ic_ds_*` 图标集与 `FishLogo`/`BrandWordmark` 标记填充品牌与行内图标 slot。`LinkIcon` 为可点击产物链接绘制前置分类图形——地球、文件夹、代码、图片、文档或纸张,全部随 `currentColor`——`classifyLinkPath` 按扩展名推导文件路径的类别。`FileTypeIcon` 是唯一不随 `currentColor` 的字形:纸片颜色就是类型的身份,需要压灰的使用方(空状态)自行加 `filter: grayscale(1)`。`classifyFileType` 与 `classifyLinkPath` 读同一份扩展名词表(`file-extensions.ts`),一条路径在链接上与纸片上的归类一致;纸片分得更细。`ConnectionIndicator` 可渲染警告色的断联操作、以独立于 retry 时序的 500ms 节奏推进一至三个点的连接中状态,或成功色的恢复状态。悬停或键盘聚焦时只显示重连操作文案,连接中的圆点动画也保持隐藏。所有状态都为最长的输入 label 预留空间,并使用固定的图标列和文字列,因此文案变化不会移动控件或改变其宽度。它的 owner 提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。 `Toast` 的停留时长由使用方通过 `holdMs` 指定,因为横幅该留多久取决于有多少内容要读;同一个值同时驱动它的卸载定时器与样式表的淡出延迟,两者不可能再错位。 `rankByName` 是 `/` 菜单命令源与 skill 源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 `Button.segment` 将分段操作的首尾按钮连接起来,不改变独立按钮的几何样式。`Menu.autoFocus` 聚焦首个启用项,支持上下方向键与 Home/End 导航,并在 Escape 时将焦点还给触发按钮;操作菜单可显式启用。 +上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。`ic_ds_*` 图标集与 `FishLogo`/`BrandWordmark` 标记填充品牌与行内图标 slot。`LinkIcon` 为可点击产物链接绘制前置分类图形——地球、文件夹、代码、图片、文档或纸张,全部随 `currentColor`——`classifyLinkPath` 按扩展名推导文件路径的类别。`FileTypeIcon` 是唯一不随 `currentColor` 的字形:纸片颜色就是类型的身份,需要压灰的使用方(空状态)自行加 `filter: grayscale(1)`。`classifyFileType` 与 `classifyLinkPath` 读同一份扩展名词表(`file-extensions.ts`),一条路径在链接上与纸片上的归类一致;纸片分得更细。`ConnectionIndicator` 可渲染警告色的断联操作、以独立于 retry 时序的 500ms 节奏推进一至三个点的连接中状态,或成功色的恢复状态。悬停或键盘聚焦时只显示重连操作文案,连接中的圆点动画也保持隐藏。所有状态都为最长的输入 label 预留空间,并使用固定的图标列和文字列,因此文案变化不会移动控件或改变其宽度。它的 owner 提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。 `Toast` 的停留时长由使用方通过 `holdMs` 指定,因为横幅该留多久取决于有多少内容要读;同一个值同时驱动它的卸载定时器与样式表的淡出延迟,两者不可能再错位。 `rankByName` 是 `/` 菜单命令源与 skill 源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 `Button.segment` 将分段操作的首尾按钮连接起来,不改变独立按钮的几何样式。`Menu.autoFocus` 聚焦首个启用项,支持上下方向键与 Home/End 导航,并在 Escape 时聚焦 anchor 内的第一个按钮;操作菜单可显式启用。 ### 渲染 agent 输出 diff --git a/packages/client/ui-primitives/src/Menu.tsx b/packages/client/ui-primitives/src/Menu.tsx index ed19fbb70c..edc4825bc1 100644 --- a/packages/client/ui-primitives/src/Menu.tsx +++ b/packages/client/ui-primitives/src/Menu.tsx @@ -48,7 +48,7 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 } /** * Render an anchored dropdown menu. - * @param props.autoFocus - focus the first item on open and enable arrow-key navigation; Escape restores the trigger. + * @param props.autoFocus - focus the first item on open and enable arrow-key navigation; Escape focuses the anchor's first button. * @param props.open - whether the list is showing (owner-controlled). * @param props.anchor - the trigger element (rendered in place). * @param props.items - selectable rows and optional separators. diff --git a/packages/util/native-command/README.i18n.yaml b/packages/util/native-command/README.i18n.yaml index 5dba1e1698..6e9e0b3aad 100644 --- a/packages/util/native-command/README.i18n.yaml +++ b/packages/util/native-command/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/native-command/README.md -README.md: b127c1f03fc91282cd2a847af84f8183f147f9ac -README.zh.md: 7ff612fe9bead184ace2b5ee2c54700c8d673df0 +README.md: b0b1963a97c6d528c123d4900db529bd3dc38332 +README.zh.md: 6a245e3473a4e4dd9944dc53898e04be2fcba2e4 diff --git a/packages/util/native-command/README.md b/packages/util/native-command/README.md index b127c1f03f..b0b1963a97 100644 --- a/packages/util/native-command/README.md +++ b/packages/util/native-command/README.md @@ -47,7 +47,7 @@ The `NativeCommandRunner` type is the injectable command boundary for host integ `openNativePath(path, signal)` hands a path to the default application and prefers the named default browser for HTML and SVG where the platform can identify one. `openNativeTextFile(path, signal)` selects text-editor intent; on macOS it uses `open -t`. WSL paths are translated with `wslpath -w` before the Windows desktop receives them. `canOpenNativePath()` reports whether the current Host plausibly has a desktop target. -`revealNativePath(path, signal)` selects the file in Finder or Explorer, including WSL path translation, and opens its parent directory through `xdg-open` on desktop Linux. `nativeFileManager()` identifies that action for Host-derived UI labels; desktop availability remains a separate `canOpenNativePath()` check. Callers must authorize the absolute file path before invoking either operation. Platform dispatch is covered by injected-runner tests; native desktop verification belongs to the corresponding platform. +`revealNativePath(path, signal)` selects the file in Finder or Explorer, including WSL path translation, and opens its parent directory through `xdg-open` on desktop Linux. `nativeFileManager()` identifies that action for Host-derived UI labels; desktop availability remains a separate `canOpenNativePath()` check. Callers must authorize the absolute file path before invoking either operation. Platform dispatch is covered by injected-runner tests; native desktop verification belongs to the corresponding platform. Explorer receives an encoded file URI as a separate argument. Its exit code 1 is accepted as a delegated handoff; cancellation, missing executables, and other exit codes still reject. This acknowledgement does not prove that a desktop window selected the file. ----- diff --git a/packages/util/native-command/README.zh.md b/packages/util/native-command/README.zh.md index 7ff612fe9b..6a245e3473 100644 --- a/packages/util/native-command/README.zh.md +++ b/packages/util/native-command/README.zh.md @@ -47,7 +47,7 @@ const { stdout, stderr } = await runNativeCommand('osascript', ['-e', script], s `openNativePath(path, signal)` 将路径交给默认应用;平台能够确定默认浏览器时,HTML 与 SVG 会优先交给该浏览器。`openNativeTextFile(path, signal)` 选择文本编辑器意图;macOS 使用 `open -t`。WSL 路径先通过 `wslpath -w` 转换,再交给 Windows 桌面。`canOpenNativePath()` 报告当前 Host 是否可能具备桌面目标。 -`revealNativePath(path, signal)` 在 Finder 或文件资源管理器中选中文件,包含 WSL 路径转换;在桌面 Linux 上通过 `xdg-open` 打开上层目录。`nativeFileManager()` 标识该操作,供 UI 根据 Host 选择文案;桌面是否可用仍由独立的 `canOpenNativePath()` 检查决定。调用方必须先授权绝对文件路径,再执行操作。平台分派由注入运行器的测试覆盖;原生桌面验证由对应平台负责。 +`revealNativePath(path, signal)` 在 Finder 或文件资源管理器中选中文件,包含 WSL 路径转换;在桌面 Linux 上通过 `xdg-open` 打开上层目录。`nativeFileManager()` 标识该操作,供 UI 根据 Host 选择文案;桌面是否可用仍由独立的 `canOpenNativePath()` 检查决定。调用方必须先授权绝对文件路径,再执行操作。平台分派由注入运行器的测试覆盖;原生桌面验证由对应平台负责。 Explorer 接收独立参数中的编码文件 URI。退出码 1 按已转交请求处理;取消、找不到可执行文件和其他退出码仍然报错。该确认不能证明桌面窗口已选中文件。 ----- diff --git a/packages/util/native-command/src/path-opener.ts b/packages/util/native-command/src/path-opener.ts index 0d8dcb32da..8f3443b3c1 100644 --- a/packages/util/native-command/src/path-opener.ts +++ b/packages/util/native-command/src/path-opener.ts @@ -11,6 +11,7 @@ import { release as osRelease } from 'node:os' import { dirname, extname } from 'node:path' +import { pathToFileURL } from 'node:url' import { runNativeCommand, type NativeCommandRunner } from './runner.ts' /** Testable command boundary; native implementations never invoke a shell. */ @@ -222,7 +223,7 @@ export function nativeFileManager(internals: PathOpenerInternals = {}): NativeFi * @param path - absolute file path already authorized by the caller. * @param signal - caller lifetime; abort terminates the native command. * @param internals - platform, environment, and command runner for adapter tests. - * @returns after the file-manager command accepts the request; launch failures reject. + * @returns after command completion; Explorer exit 1 is accepted as a delegated handoff, not proof of selection. */ export async function revealNativePath( path: string, signal: AbortSignal, internals: PathOpenerInternals = {}, @@ -243,7 +244,15 @@ export async function revealNativePath( windowsPath = translated.stdout.replace(/[\r\n]+$/, '') if (windowsPath === '') throw new Error('wslpath returned no Windows path') } - await run('explorer.exe', [`/select,${windowsPath}`], signal) + // Explorer parses commas itself; a file URI preserves commas and whitespace in the path. + const target = pathToFileURL(windowsPath, { windows: true }).href.replaceAll(',', '%2C') + try { + await run('explorer.exe', ['/select,', target], signal) + } catch (error) { + signal.throwIfAborted() + // Explorer can exit 1 after delegating to the existing desktop process. + if (!(error instanceof Error) || !('code' in error) || error.code !== 1) throw error + } return } if (manager === 'directory') { diff --git a/packages/util/native-command/tests/path-opener.spec.ts b/packages/util/native-command/tests/path-opener.spec.ts index 347588095d..5082d141ed 100644 --- a/packages/util/native-command/tests/path-opener.spec.ts +++ b/packages/util/native-command/tests/path-opener.spec.ts @@ -331,7 +331,7 @@ describe('canOpenNativePath', () => { describe('native file manager', () => { it.each([ ['darwin', 'finder', '/tmp/my report.txt', 'open', ['-R', '/tmp/my report.txt']], - ['win32', 'explorer', 'C:\\work\\my report.txt', 'explorer.exe', ['/select,C:\\work\\my report.txt']], + ['win32', 'explorer', 'C:\\work\\my report.txt', 'explorer.exe', ['/select,', 'file:///C:/work/my%20report.txt']], ['linux', 'directory', '/tmp/a $b; report.txt', 'xdg-open', ['/tmp']], ] as const)('reveals through %s without opening the file association', async (platform, manager, path, command, args) => { const run = vi.fn(async () => ({ stdout: '', stderr: '' })) @@ -347,7 +347,7 @@ describe('native file manager', () => { expect(nativeFileManager(internals)).toBe('explorer') await revealNativePath('/mnt/c/work/报告.txt', signal(), internals) expect(run.mock.calls.map(([cmd, args]) => [cmd, args])).toEqual([ - ['wslpath', ['-w', '/mnt/c/work/报告.txt']], ['explorer.exe', ['/select,C:\\work\\报告.txt']], + ['wslpath', ['-w', '/mnt/c/work/报告.txt']], ['explorer.exe', ['/select,', 'file:///C:/work/%E6%8A%A5%E5%91%8A.txt']], ]) }) @@ -378,3 +378,38 @@ it('uses the native runner for a file-manager handoff when none is injected', as await revealNativePath('/tmp/report.txt', signal()) expect(execFileMock).toHaveBeenCalled() }) + + +it.each(['win32', 'linux'] as const)('accepts Explorer delegate exit 1 through the native runner on %s', async (platform) => { + execFileMock.mockImplementation((command, _args, _options, callback) => { + if (command === 'wslpath') callback(null, 'C:\\work\\report.txt', '') + else callback(Object.assign(new Error('delegated'), { code: 1 }), '', '') + }) + await expect(revealNativePath(platform === 'win32' ? 'C:\\work\\report.txt' : '/mnt/c/work/report.txt', signal(), + { platform, env: { WSL_DISTRO_NAME: 'Ubuntu' } })).resolves.toBeUndefined() +}) + +it.each([2, 'ENOENT', undefined])('preserves Explorer failure %s', async (code) => { + const failure = Object.assign(new Error('launch failed'), code === undefined ? {} : { code }) + execFileMock.mockImplementation((_command, _args, _options, callback) => { callback(failure, '', '') }) + await expect(revealNativePath('C:\\file.txt', signal(), { platform: 'win32' })).rejects.toMatchObject({ code }) +}) + +it('preserves cancellation even when Explorer returns delegate exit 1', async () => { + const abort = new AbortController() + const reason = new Error('cancelled') + const run = vi.fn(async () => { + abort.abort(reason) + throw Object.assign(new Error('delegated'), { code: 1 }) + }) + await expect(revealNativePath('C:\\file.txt', abort.signal, { platform: 'win32', run })).rejects.toBe(reason) +}) + +it.each([ + ['C:\\my files\\报告,#%.txt', 'file:///C:/my%20files/%E6%8A%A5%E5%91%8A%2C%23%25.txt'], + ['\\\\server\\share\\a,b.txt', 'file://server/share/a%2Cb.txt'], +])('preserves special characters in the Explorer target %s', async (path, target) => { + const run = vi.fn().mockResolvedValue({ stdout: '', stderr: '' }) + await revealNativePath(path, signal(), { platform: 'win32', run }) + expect(run).toHaveBeenCalledWith('explorer.exe', ['/select,', target], expect.any(AbortSignal)) +}) From 4ee9e055d549cd45850d549e5c375dc15d5fba83 Mon Sep 17 00:00:00 2001 From: yixiangihsiang Date: Wed, 9 Sep 2026 16:37:44 +0800 Subject: [PATCH 021/111] feat(client): add shared file type icons --- ...-09-04-web-clickable-link-styles.i18n.yaml | 4 +- .../2026-09-04-web-clickable-link-styles.md | 6 +- ...2026-09-04-web-clickable-link-styles.zh.md | 6 +- ...026-09-08-shared-file-type-icons.i18n.yaml | 6 + .../2026-09-08-shared-file-type-icons.md | 43 ++ .../2026-09-08-shared-file-type-icons.zh.md | 43 ++ .../client/ui-attachment/src/FileCard.tsx | 14 +- .../composer-attachments.client.spec.tsx | 13 + .../src/client/chat/MessageItem.module.css | 2 +- .../ui-chat/src/client/chat/MessageItem.tsx | 12 +- .../tests/chat-branch-tails.client.spec.tsx | 4 + .../src/client/queue/QueueDock.tsx | 4 +- .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 8 +- packages/client/ui-primitives/README.zh.md | 8 +- .../client/ui-primitives/src/CodeFileIcon.tsx | 471 ++++++++++++++++++ .../ui-primitives/src/DocumentFileIcon.tsx | 2 +- .../ui-primitives/src/FileTypeIcon.module.css | 50 ++ .../client/ui-primitives/src/FileTypeIcon.tsx | 394 +++++++++------ .../client/ui-primitives/src/LinkIcon.tsx | 31 +- .../ui-primitives/src/code-file-types.ts | 339 +++++++++++++ .../ui-primitives/src/file-extensions.ts | 59 --- packages/client/ui-primitives/src/index.ts | 6 +- .../tests/code-file-icon.client.spec.tsx | 123 +++++ .../file-type-icon-styles.client.spec.ts | 38 ++ .../tests/file-type-icon.client.spec.tsx | 136 +++-- .../tests/link-icon.client.spec.tsx | 4 + .../tests/text-title.client.spec.tsx | 4 +- .../tests/files-body.client.spec.tsx | 3 + 29 files changed, 1539 insertions(+), 298 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.md create mode 100644 .agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.zh.md create mode 100644 packages/client/ui-primitives/src/CodeFileIcon.tsx create mode 100644 packages/client/ui-primitives/src/FileTypeIcon.module.css create mode 100644 packages/client/ui-primitives/src/code-file-types.ts delete mode 100644 packages/client/ui-primitives/src/file-extensions.ts create mode 100644 packages/client/ui-primitives/tests/code-file-icon.client.spec.tsx create mode 100644 packages/client/ui-primitives/tests/file-type-icon-styles.client.spec.ts diff --git a/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.i18n.yaml b/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.i18n.yaml index 4aec891a9f..ddf0632164 100644 --- a/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.md -2026-09-04-web-clickable-link-styles.md: 8781990a40de30e26d0c59c0ecc1a08ba41643ac -2026-09-04-web-clickable-link-styles.zh.md: 7b5fde4c87319f6ae34b76d1c8e712e6f9f18392 +2026-09-04-web-clickable-link-styles.md: 6fa61945143853a2812468638dafd23ebcd456dc +2026-09-04-web-clickable-link-styles.zh.md: 0b15a6c72df9151ffb79f7fb41d582e191e79be6 diff --git a/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.md b/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.md index 8781990a40..6fa6194514 100644 --- a/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.md +++ b/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.md @@ -13,7 +13,7 @@ Clickable artifact links in the chat transcript wore four different costumes: ma One link language across the transcript's clickable-link surfaces — markdown anchors (including reference links, mailto, and URL-promoted inline code), prose file mentions, web search source links and the fetch URL, produced-file chips, and workflow member links: - Color comes through a dedicated `--dsw-alias-link` alias in `design-platform.css` (light `deepseek-500`, dark `deepseek-400`), decoupled from `state-business-primary`; links render at `font-weight: 500` with no underline at rest and `underline dotted` at 3px offset on hover/focus. -- A leading category glyph — the new `LinkIcon` in ui-primitives with kinds `url` (globe), `folder`, `code`, `image`, `document`, and `other` (paper) — renders `currentColor` only; `classifyLinkPath` derives the file kinds from the extension, and code, web, and data extensions share the code glyph by design. Two anchor shapes carry no glyph: workflow member links (an in-app member view fits no file or URL category) and anchors wrapping only images (a badge or thumbnail — a dangling globe beside the picture leads no text). Inline glyphs sit at 1.1em with a −0.25em baseline offset; the flex-centered produced-file glyphs instead nudge 1.2px down because the 22px text box carries its glyphs below box center. +- A leading category glyph — the new `LinkIcon` in ui-primitives with kinds `url` (globe), `folder`, `code`, `image`, `document`, and `other` (paper) — renders `currentColor` only; `classifyLinkPath` folds the [shared detailed file-type classification](2026-09-08-shared-file-type-icons.md) into those six link categories, and code, web, and data extensions share the code glyph by design. Two anchor shapes carry no glyph: workflow member links (an in-app member view fits no file or URL category) and anchors wrapping only images (a badge or thumbnail — a dangling globe beside the picture leads no text). Inline glyphs sit at 1.1em with a −0.25em baseline offset; the flex-centered produced-file glyphs instead nudge 1.2px down because the 22px text box carries its glyphs below box center. - Produced-file chips drop the grey pill and the 96px cap: plain link-blue text at natural width that shrinks with ellipsis only when the row overflows; the container-query bands still budget 96px per chip when choosing how many chips to show. - Deliberately untouched: ToolRow's grey dotted file links, and the grey "Show in folder" action (it gains the folder glyph but keeps its grey style). - In the same pass, the inline-code chip tint moved from `neutral-bluish-100` to `neutral-50` (dark: `neutral-800`) and gained a 0.5px l1 border. @@ -22,8 +22,8 @@ Coverage: a LinkIcon unit spec (one distinct glyph per kind, classification tabl ## Alternatives considered -- **Colored Word/Excel/PPT/PDF brand glyphs.** Implemented, then removed: fixed brand fills break the icon set's currentColor-only rule, so those extensions fold into the single outline `document` glyph. -- **Per-extension icons.** Collapsed to six categories: more glyphs than the eye can parse at 14px adds noise, and per-site favicons remain possible later behind the same `url` category. +- **Colored Word/Excel/PPT/PDF link glyphs.** Rejected: fixed brand fills break the icon set's currentColor-only rule, so those extensions fold into the single outline `document` glyph. The larger file-card primitive uses distinct current-color silhouettes instead. +- **Per-extension link icons.** Collapsed to six categories: more glyphs than the eye can parse at 14px adds noise. The 28px `FileTypeIcon` owns the more detailed file identities, and per-site favicons remain possible later behind the same `url` category. - **Keeping links on `state-business-primary`.** Darker link blues (blue-600/650/700 were auditioned and reverted) would have dragged focus rings and state dots along; the dedicated alias localizes any future tuning to one line. - **Glyphs on ToolRow path links.** Rejected: tool rows keep their quieter grey dotted affordance, and leading glyphs there would stack icons in already dense rows. diff --git a/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.zh.md b/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.zh.md index 7b5fde4c87..0b15a6c72d 100644 --- a/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.zh.md +++ b/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.zh.md @@ -13,7 +13,7 @@ Status: implemented 会话记录的可点击链接表面——Markdown 锚点(含引用式链接、mailto、被提升为链接的 inline code)、正文文件引用、网页搜索来源链接与抓取 URL、产物 chips、workflow 成员链接——统一为一套链接语言: - 颜色经由 `design-platform.css` 中专用的 `--dsw-alias-link` 别名(亮色 `deepseek-500`,暗色 `deepseek-400`),与 `state-business-primary` 解耦;链接以 `font-weight: 500` 呈现,默认无下划线,hover/focus 时为 3px offset 的 `underline dotted`。 -- 前置分类图标——ui-primitives 新增的 `LinkIcon`,kind 为 `url`(地球)、`folder`、`code`、`image`、`document`、`other`(纸张)——只渲染 `currentColor`;`classifyLinkPath` 按扩展名推导文件类别,代码、网页、数据扩展名按设计共用 code 图形。两类锚点不带图标:workflow 成员链接(应用内成员视图不属于任何文件或 URL 类别)和只包图片的锚点(徽章或缩略图——图片旁悬着的地球没有可引导的文字)。行内图标为 1.1em、基线偏移 −0.25em;flex 居中的产物图标则下移 1.2px,因为 22px 文字盒的字形低于盒中心。 +- 前置分类图标——ui-primitives 新增的 `LinkIcon`,kind 为 `url`(地球)、`folder`、`code`、`image`、`document`、`other`(纸张)——只渲染 `currentColor`;`classifyLinkPath` 把[共享精细文件类型分类](2026-09-08-shared-file-type-icons.zh.md)折叠进这六种链接类别,代码、网页、数据扩展名按设计共用 code 图形。两类锚点不带图标:workflow 成员链接(应用内成员视图不属于任何文件或 URL 类别)和只包图片的锚点(徽章或缩略图——图片旁悬着的地球没有可引导的文字)。行内图标为 1.1em、基线偏移 −0.25em;flex 居中的产物图标则下移 1.2px,因为 22px 文字盒的字形低于盒中心。 - 产物 chips 去掉灰色药丸和 96px 上限:纯链接蓝文字按自然宽度展示,仅当整行溢出时才收缩出省略号;容器查询档位在决定展示几个 chip 时仍按每个 96px 预算。 - 刻意不动:ToolRow 的灰色点线文件链接,以及灰色的「在文件夹中显示」操作(它获得文件夹图标但保持灰色样式)。 - 同一批次中,inline code 底色从 `neutral-bluish-100` 换到 `neutral-50`(暗色:`neutral-800`),并新增 0.5px l1 描边。 @@ -22,8 +22,8 @@ Status: implemented ## 备选方案 -- **彩色 Word/Excel/PPT/PDF 品牌图形。** 实现后又移除:固定品牌填充违反图标集 currentColor-only 规则,这些扩展名并入单一的 outline `document` 图形。 -- **每个扩展名一个图标。** 收敛为六个类别:14px 下超出肉眼可分辨数量的图形只会增加噪音,按站点的 favicon 以后仍可在同一 `url` 类别之下引入。 +- **彩色 Word/Excel/PPT/PDF 链接图形。** 否决:固定品牌填充违反图标集 currentColor-only 规则,因此这些扩展名并入单一的 outline `document` 图形。较大的文件卡片 primitive 改用各自不同的 current-color 轮廓。 +- **每个扩展名一个链接图标。** 收敛为六个类别:14px 下超出肉眼可分辨数量的图形只会增加噪音。28px 的 `FileTypeIcon` 拥有更精细的文件身份,按站点的 favicon 以后仍可在同一 `url` 类别之下引入。 - **链接继续用 `state-business-primary`。** 更深的链接蓝(试过 blue-600/650/700 又回退)会连带焦点环和状态点;专用别名把未来的调色收敛到一行。 - **给 ToolRow 路径链接加图形。** 否决:工具行保持更安静的灰色点线示能,在已经很密的行里加前置图形会造成图标堆叠。 diff --git a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.i18n.yaml b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.i18n.yaml new file mode 100644 index 0000000000..e8a971188f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.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-shared-file-type-icons.md +2026-09-08-shared-file-type-icons.md: 36b4ff8f57dd810da3d2af94aac15ec5afab799e +2026-09-08-shared-file-type-icons.zh.md: 4b33a8ffe5934953b12e82750ce57febc6f054f4 diff --git a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.md b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.md new file mode 100644 index 0000000000..36b4ff8f57 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.md @@ -0,0 +1,43 @@ +# Agent Note: Shared file-type icons + +Status: implemented + +English | [中文](2026-09-08-shared-file-type-icons.zh.md) + +## Problem + +Client feature plugins can share React components only through `@deepseek-ai/dsh-client-ui-primitives`, but file cards had no shared file-type presentation. `LinkIcon` owned the only extension table and deliberately collapsed paths into six link categories, while attachment cards, sent-message attachments, queued files, and workspace file rows used one generic document glyph. Two of those consumers also carried separate extension-display helpers. Adding a precise file icon anywhere else would have required another extension table or a runtime import between feature plugins. + +## Decision + +`ui-primitives` owns one Cordis-free file classification and rendering API. `fileExtension(path)` applies the shared basename and final-dot semantics to either path separator. `classifyFileType(path)` matches without case sensitivity and returns the closed `FileType` union: the traditional `code`, `excel`, `folder`, `html`, `image`, `markdown`, `other`, `pdf`, `ppt`, `video`, and `word` categories plus the detailed `CodeFileType` set imported from the supplied `square-code-icons` map. Exact filenames, filename prefixes, filename suffixes, optional project context, and extensions run in that order. The path classifier returns every member except `folder`; callers use the explicit `FileTypeIcon` `type` override when they know an entry is a directory. Unknown extensions, missing extensions, and trailing dots resolve to `other`, except the shared table recognizes named files such as `Dockerfile`, `Makefile`, `package.json`, `.gitignore`, `README`, and `CHANGELOG`. + +`FileTypeIcon` accepts a path, shared `IconProps`, the optional explicit type, and an optional project-file snapshot. Traditional file types render the supplied 28px document and folder contours as inline SVG. Excel, Markdown, PDF, PPT, and Word foreground marks scale to 122% around their visual center; the remaining marked traditional glyphs use 112%, while the file body and folded corner retain their source geometry and the generic file has no invented center mark. The sheet is a solid category color, the foreground mark and ordinary folded corner are white, and the generic file has a darker grey corner. CSS assigns the supplied category palette through static design tokens: DeepSeek blue for code/HTML/Markdown, the lighter DeepSeek blue for Word, green for Excel, two amber steps for folder/PPT, red for PDF, and neutral grey for unknown files. Image and video share the supplied violet through a component-local variable because the design platform has no matching violet token. A caller may override a traditional sheet through `--dsh-file-type-icon-color`. + +Recognized code and configuration files render the corresponding 20px square artwork scaled to the requested icon size. These technology marks retain their embedded multicolor fills and are the explicit exception to the ordinary current-color icon rule. The map selects React before TypeScript/JavaScript, Angular filename suffixes before their base extension, Docker/Node/Git/Make/CMake by filename rules, and Flutter only when the optional project snapshot contains a `pubspec.yaml` whose text includes `flutter:`. Markdown and SVG remain owned by the traditional Markdown and image categories. Every traditional and technology SVG is `aria-hidden`, and the card, row, or button that owns the file identity supplies the accessible name. + +`LinkIcon` delegates extension classification to `classifyFileType` and folds the detailed result into its existing link vocabulary: code and HTML use `code`, images use `image`, PDF/Word/Excel/PPT use `document`, and Markdown/video/unknown files use `other`. Extensionless names remain `other` in link contexts, so the 14px clickable-link appearance defined by the [clickable-link decision](2026-09-04-web-clickable-link-styles.md) does not change. + +Attachment upload cards, sent-message file cards, queued-file rows, and workspace file rows render `FileTypeIcon`. The two metadata rows use `fileExtension` rather than local parsers; a leading-dot basename such as `.env` therefore displays `ENV`, while an absent or trailing suffix displays no extension label. Image content continues to render as a preview rather than a file-type glyph, and produced-file links and Markdown file mentions continue to use `LinkIcon` because they are link surfaces. + +## Alternatives considered + +**Use `LinkIcon` for every file surface.** Rejected. Its six categories and 14px outline drawings communicate link destinations at text size; a 28px file card has room for the supplied HTML, Markdown, PDF, Word, Excel, PPT, and video identities. + +**Keep a second extension table beside `LinkIcon`.** Rejected. The same path could drift to different categories as either list grows. One detailed table plus an explicit detailed-to-link adapter preserves both consumers' semantics. + +**Put the supplied traditional-file colors directly in each SVG path.** Rejected. Traditional SVG geometry stays reusable and follows the icon set's `currentColor` rule; the component stylesheet owns the default category palette, and render sites retain one CSS-variable override instead of rewriting path fills. The technology artwork is exempt because its embedded multicolor marks identify the language or tool rather than decorating a generic file silhouette. + +**Add archive, audio, and data categories for symmetry.** Rejected. The supplied artwork and current consumers require no such glyphs. New `FileType` members require a shipped render site and artwork that remains legible at the 28px seat. + +## Testing + +The `ui-primitives` specs cover case-insensitive suffixes, both path separators, leading-dot files, missing and trailing suffixes, common named files, unknown fallback, all detailed code mappings, rule priority, Flutter context, every supplied technology SVG, instance-safe gradient ids, `aria-hidden`, sizing/class forwarding, distinct artwork, the 112% and 122% foreground-mark transforms, and the traditional solid-sheet/contrast-mark layers without literal SVG colors. A stylesheet spec pins every traditional category-to-color mapping, the caller override, and the local violet value. The existing `LinkIcon` classification table pins its coarse output, including `Makefile` remaining `other`. Attachment, chat, queue, and sidebar component suites exercise the migrated render paths; their accessibility output does not change because the glyphs remain decorative. + +## Consequences + +- Client packages use one filename parser and one detailed file-type table instead of importing or recreating feature-local logic. +- A new suffix joins the detailed table only when an existing glyph truthfully represents it. If its link category differs from the current adapter, the change must also decide whether the 14px link appearance changes. +- Code and configuration artwork preserves its embedded palette and does not accept the traditional `--dsh-file-type-icon-color` override. +- The primitive owns no copy but does own the default file-type palette. Consumers continue to own accessible labels and surrounding text, and may replace the category color through `--dsh-file-type-icon-color`. +- The detailed category names describe presentation, not MIME validation. A suffix is a display hint and does not establish file contents or trust. diff --git a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.zh.md b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.zh.md new file mode 100644 index 0000000000..4b33a8ffe5 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.zh.md @@ -0,0 +1,43 @@ +# Agent Note: 共享文件类型图标 + +Status: implemented + +[English](2026-09-08-shared-file-type-icons.md) | 中文 + +## 问题 + +客户端功能插件只能通过 `@deepseek-ai/dsh-client-ui-primitives` 共享 React 组件,但文件卡片没有共享的文件类型呈现。`LinkIcon` 拥有唯一一份扩展名表,并有意把路径折叠为六种链接类别;附件卡片、已发送消息附件、排队文件和工作区文件行则统一使用一个通用文档图形。其中两个消费方还各自带着一份扩展名展示辅助函数。在其他位置增加精细文件图标,需要再复制一份扩展名表或让功能插件之间产生运行时 import。 + +## 决策 + +`ui-primitives` 统一拥有一套 Cordis-free 的文件分类与渲染 API。`fileExtension(path)` 对两种路径分隔符采用共享的 basename 与最终点号语义。`classifyFileType(path)` 不区分大小写,并返回闭合的 `FileType` 联合:传统的 `code`、`excel`、`folder`、`html`、`image`、`markdown`、`other`、`pdf`、`ppt`、`video`、`word` 类别,以及从所提供 `square-code-icons` 映射接入的细分 `CodeFileType` 集合。解析按完整文件名、文件名前缀、文件名后缀、可选项目上下文、扩展名的顺序执行。路径分类器返回 `folder` 之外的全部成员;调用方确认条目是目录时,通过 `FileTypeIcon` 的显式 `type` 覆盖指定目录。未知扩展名、无扩展名和末尾点号回退到 `other`,但共享表识别 `Dockerfile`、`Makefile`、`package.json`、`.gitignore`、`README`、`CHANGELOG` 等具名文件。 + +`FileTypeIcon` 接受路径、共享 `IconProps`、可选的显式类型和可选的项目文件快照。传统文件类型把所提供的 28px 文档与文件夹轮廓渲染为 inline SVG。Excel、Markdown、PDF、PPT、Word 的前景标记围绕自身视觉中心缩放至 122%,其余带标记的传统图形使用 112%;文件底板与折角保持源图几何,通用文件不凭空增加中心标记。底板使用实色分类颜色,前景标记与普通折角使用白色,通用文件使用较深的灰色折角。CSS 通过静态设计 token 分配所提供的分类调色板:code/HTML/Markdown 使用 DeepSeek 蓝,Word 使用较浅的 DeepSeek 蓝,Excel 使用绿色,folder/PPT 使用两档琥珀色,PDF 使用红色,未知文件使用中性灰。image 与 video 通过组件本地变量共用所提供的紫色,因为设计平台没有匹配的紫色 token。调用方可通过 `--dsh-file-type-icon-color` 覆盖传统底板颜色。 + +已识别的代码与配置文件把对应的 20px 方形图稿缩放到请求的图标尺寸。这些技术标记保留自身内嵌的多色填充,是普通 current-color 图标规则的明确例外。映射让 React 优先于 TypeScript/JavaScript、Angular 文件名后缀优先于基础扩展名,并按文件名识别 Docker/Node/Git/Make/CMake;只有可选项目快照包含内容带 `flutter:` 的 `pubspec.yaml` 时才选择 Flutter。Markdown 与 SVG 仍由传统 Markdown 和图片类别拥有。所有传统与技术 SVG 都是 `aria-hidden` 的,拥有文件身份的卡片、行或按钮提供无障碍名称。 + +`LinkIcon` 委托 `classifyFileType` 做扩展名分类,再把精细结果折叠进原有链接词汇:code 与 HTML 使用 `code`,图片使用 `image`,PDF/Word/Excel/PPT 使用 `document`,Markdown、video 与未知文件使用 `other`。无扩展名文件在链接语境中仍是 `other`,因此[可点击链接决策](2026-09-04-web-clickable-link-styles.zh.md)定义的 14px 外观不变。 + +附件上传卡片、已发送消息文件卡片、排队文件行和工作区文件行渲染 `FileTypeIcon`。两处元数据行使用 `fileExtension`,不再保留本地解析器;`.env` 这样的前导点 basename 会显示 `ENV`,无后缀或末尾点号则不显示扩展名 label。图片内容继续渲染为预览而不是文件类型图形,产物文件链接与 Markdown 文件提及继续使用 `LinkIcon`,因为它们属于链接表面。 + +## 备选方案 + +**所有文件表面都使用 `LinkIcon`。** 否决。它的六种类别和 14px outline 图形用于在文字尺寸下表达链接目标;28px 文件卡片有空间表达所提供的 HTML、Markdown、PDF、Word、Excel、PPT 与 video 身份。 + +**在 `LinkIcon` 旁保留第二份扩展名表。** 否决。任一列表增长后,同一路径可能漂移到不同类别。一份精细表加一个显式的精细到链接适配,能同时保住两类消费方的语义。 + +**把所提供的传统文件颜色直接写入每一条 SVG path。** 否决。传统 SVG 几何保持可复用,并遵守图标集的 `currentColor` 规则;组件样式表拥有默认分类调色板,渲染点只需一个 CSS 变量即可覆盖颜色,不需要重写 path 填充。技术图稿是例外,因为其内嵌的多色标记用于识别语言或工具,而不是装饰通用文件轮廓。 + +**为了对称增加 archive、audio 与 data 类别。** 否决。提供的图稿与当前消费方都不需要这些图形。新增 `FileType` 成员必须有已发布的渲染点,以及在 28px slot 中仍可辨认的图稿。 + +## 测试 + +`ui-primitives` 测试覆盖不区分大小写的后缀、两种路径分隔符、前导点文件、无后缀与末尾点号、常见具名文件、未知回退、全部细分代码映射、规则优先级、Flutter 上下文、每一份技术 SVG、实例安全的渐变 id、`aria-hidden`、尺寸/class 转发、不同图稿、112% 与 122% 前景标记变换,以及不含 SVG 字面颜色的传统实色底板/对比标记层。样式表测试钉住每一项传统类别到颜色的映射、调用方覆盖变量与本地紫色值。既有 `LinkIcon` 分类表钉住它的粗粒度输出,包括 `Makefile` 仍为 `other`。附件、聊天、队列和侧边栏组件测试覆盖迁移后的渲染路径;由于图形仍是装饰性的,其无障碍输出不变。 + +## 后果 + +- 客户端包使用一个文件名解析器与一份精细文件类型表,不再 import 或重新实现功能包本地逻辑。 +- 新后缀只在已有图形能准确表达它时加入精细表。若它的链接类别与当前适配不同,这次改动还必须决定是否改变 14px 链接外观。 +- 代码与配置图稿保留内嵌调色板,不接受传统图形的 `--dsh-file-type-icon-color` 覆盖。 +- primitive 不拥有文案,但拥有默认文件类型调色板。消费方继续拥有无障碍 label 与周围文字,并可通过 `--dsh-file-type-icon-color` 替换分类颜色。 +- 精细类别名称描述展示意图,不是 MIME 校验。后缀只是展示提示,不能证明文件内容或可信度。 diff --git a/packages/client/ui-attachment/src/FileCard.tsx b/packages/client/ui-attachment/src/FileCard.tsx index e10c91993c..3692302325 100644 --- a/packages/client/ui-attachment/src/FileCard.tsx +++ b/packages/client/ui-attachment/src/FileCard.tsx @@ -1,4 +1,4 @@ -import { DocumentFileIcon, fileSizeText, IconCloseFill14 } from '@deepseek-ai/dsh-client-ui-primitives' +import { fileExtension, FileTypeIcon, fileSizeText, IconCloseFill14 } from '@deepseek-ai/dsh-client-ui-primitives' import css from './FileCard.module.css' /** Localized strings consumed by one pending-file card. */ @@ -18,13 +18,7 @@ export interface FileCardLabels { /** Upload display state resolved by the owner. */ export type FileCardState = 'uploading' | 'ready' | 'error' -function extensionOf(name: string): string { - const dot = name.lastIndexOf('.') - if (dot <= 0 || dot === name.length - 1) return '' - return name.slice(dot + 1).toUpperCase().slice(0, 8) -} - -/** One pending generic-file card: name, size or upload status, remove, retry. */ +/** One pending file card: type glyph, name, size or upload status, remove, retry. */ export function FileCard({ name, bytes, state, progress, labels, onRemove, onRetry, }: { @@ -36,7 +30,7 @@ export function FileCard({ onRemove: () => void onRetry: () => void }) { - const extension = extensionOf(name) + const extension = fileExtension(name).toUpperCase().slice(0, 8) const meta = state === 'uploading' ? labels.uploading : state === 'error' @@ -51,7 +45,7 @@ export function FileCard({ {state === 'uploading' ? - : } + : } {retryable ? ( diff --git a/packages/client/ui-attachment/tests/composer-attachments.client.spec.tsx b/packages/client/ui-attachment/tests/composer-attachments.client.spec.tsx index 8e6f01474e..c0d0920c52 100644 --- a/packages/client/ui-attachment/tests/composer-attachments.client.spec.tsx +++ b/packages/client/ui-attachment/tests/composer-attachments.client.spec.tsx @@ -263,4 +263,17 @@ describe('ComposerAttachments file drafts', () => { expect(group.textContent).toContain('文件') expect(group.textContent).toContain('3B') }) + + it('uses the shared leading-dot suffix in ready-file metadata', () => { + const view = render() + expect(view.getByTitle('.env').textContent).toContain('ENV 3B') + }) }) diff --git a/packages/client/ui-chat/src/client/chat/MessageItem.module.css b/packages/client/ui-chat/src/client/chat/MessageItem.module.css index 2f44408b33..8507492a70 100644 --- a/packages/client/ui-chat/src/client/chat/MessageItem.module.css +++ b/packages/client/ui-chat/src/client/chat/MessageItem.module.css @@ -317,7 +317,7 @@ .fileIcon { flex: none; - width: 24px; + width: 28px; height: 28px; } diff --git a/packages/client/ui-chat/src/client/chat/MessageItem.tsx b/packages/client/ui-chat/src/client/chat/MessageItem.tsx index fc4f257048..f4e0fffa54 100644 --- a/packages/client/ui-chat/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-chat/src/client/chat/MessageItem.tsx @@ -2,7 +2,7 @@ import { Fragment, memo, useEffect, useMemo, useState } from 'react' import type { ReactNode } from 'react' import type { PendingSubmission } from '@deepseek-ai/dsh-api-session-controller/client' import type { MessageImageSource } from '@deepseek-ai/dsh-client-ui-conversation/client' -import { DocumentFileIcon, fileSizeText, JsonBlock, projectUserText, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' +import { fileExtension, FileTypeIcon, fileSizeText, JsonBlock, projectUserText, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatNodeOwnerProps, ChatNodeViewProps, ChatViewSlotProps } from '../contract/slots.ts' import type { ModelRetryNode, TurnErrorNode, UserMessageNode } from '../contract/snapshot.ts' import { CompactionItem } from './CompactionItem.tsx' @@ -16,12 +16,6 @@ type PresentedAttachment = | { readonly type: 'image'; readonly image: MessageImageSource } | { readonly type: 'file'; readonly file: UserFile['attachment'] } -function extensionOf(name: string): string { - const dot = name.lastIndexOf('.') - if (dot <= 0 || dot === name.length - 1) return '' - return name.slice(dot + 1).toUpperCase().slice(0, 8) -} - function contentParts(content: readonly unknown[]): { text: string attachments: PresentedAttachment[] @@ -206,11 +200,11 @@ function UserStyleBubble({ ) : ( - + {attachment.file.name} - {[extensionOf(attachment.file.name), fileSizeText(attachment.file.bytes)] + {[fileExtension(attachment.file.name).toUpperCase().slice(0, 8), fileSizeText(attachment.file.bytes)] .filter(Boolean).join(' ')} diff --git a/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx b/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx index 5991449f44..a349f34576 100644 --- a/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx @@ -1085,6 +1085,10 @@ describe('user file attachments', () => { expect(view.getByTitle('notes.pdf').textContent).toContain('3.2MB') expect(view.getByTitle('tiny.txt').textContent).toContain('12B') expect(view.getByTitle('mid.csv').textContent).toContain('500KB') + const icons = ['notes.pdf', 'tiny.txt', 'mid.csv'].map(name => + view.getByTitle(name).querySelector('svg')?.innerHTML, + ) + expect(new Set(icons).size).toBe(icons.length) expect(view.getByText('summarize these')).toBeTruthy() }) }) diff --git a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx index 0dcbdda890..3492d37686 100644 --- a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx @@ -5,7 +5,7 @@ import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots import type { SessionId } from '@deepseek-ai/dsh-session/types' import { IconCheckOutline16, IconChevronDownOutline14, IconChevronUpOutline14, IconCloseOutline16, - DocumentFileIcon, fileSizeText, IconEditOutline16, IconQueueOutline14, IconSendOutline14, + FileTypeIcon, fileSizeText, IconEditOutline16, IconQueueOutline14, IconSendOutline14, IconTrashOutline16, projectUserText, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' import type { QueueAction, QueueItemId, QueueRow } from '../contract/queue.ts' @@ -52,7 +52,7 @@ function queueAttachments(content: QueueRow['content']): Array< function QueueFile({ attachment, label }: { attachment: FileAttachmentRef; label: string }) { return ( - + {attachment.name} {fileSizeText(attachment.bytes)} diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 138d94440f..9e0a5fd104 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md -README.md: 8fd59c5ecc98a609f1383e428701cf590da95e73 -README.zh.md: 2478330825681f65aa83415c698cf0edc54fdad6 +README.md: 93691e0203b259b0004f8048b01c09947f033989 +README.zh.md: a446ab71fb7422760830539388e7cfe59264bf9b diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 8fd59c5ecc..93691e0203 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Use `dsh-client-ui-primitives` to build web-client controls and render agent output with shared React UI. It includes standard controls, icons, anchored overlays, and renderers for Markdown with TeX, terminal output, file reads, diffs, search, web retrieval, and JSON. The renderers handle untrusted model output by dropping raw HTML, restricting links, and parsing ANSI escape sequences. Callers must supply localized labels, and the components rely only on React and `--dsw-*` design tokens. +Use `dsh-client-ui-primitives` to build web-client controls and render agent output with shared React UI. It includes standard controls, icons, anchored overlays, and renderers for Markdown with TeX, terminal output, file reads, diffs, search, web retrieval, and JSON. The renderers handle untrusted model output by dropping raw HTML, restricting links, and parsing ANSI escape sequences. The components import no Cordis runtime; callers supply localized labels, and theme-facing colors use `--dsw-*` design tokens. ## Table of Contents @@ -54,8 +54,8 @@ Check this table before writing a control in a feature package. A plugin cannot | `JsonTree`, `JsonBlock` | Read-only JSON inspection. | | `MarkdownText`, `CodeBlock` | Untrusted GFM with TeX math, and highlighted code. `CodeBlock` accepts opt-in `lineNumbers`; copied source excludes the gutter. | | `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, `WebBlock` | The agent-output card matching each tool-result intent. | -| `icons/*`, `FishLogo`, `BrandWordmark`, `ReferenceIcon`, `LinkIcon`, `DocumentFileIcon` | Glyphs and brand marks, all riding `currentColor`. | -| `FileTypeIcon` | The coloured file-type sheet (code, html, image, markdown, pdf, sheet, slides, document, other); `classifyFileType` picks the kind from a path's extension. | +| `icons/*`, `FishLogo`, `BrandWordmark`, `ReferenceIcon`, `LinkIcon`, `DocumentFileIcon` | Glyphs and brand marks. Use `LinkIcon` only for 14px clickable-link categories and `DocumentFileIcon` only when no file path exists to classify. | +| `FileTypeIcon`, `classifyFileType`, `fileExtension` | A category-colored 28px file or folder glyph and the shared case-insensitive filename mapping behind it. Code and configuration files use detailed full-color technology glyphs; use `LinkIcon` for link-leading glyphs and image previews for image content. | Three pairs are easy to confuse: @@ -67,7 +67,7 @@ Writing your own component in your own package is fine when the need is genuinel ### Controls and icons -The catalog above lists what each export is for; this section covers the behavior that props alone do not show. The `ic_ds_*` icon set and `FishLogo`/`BrandWordmark` marks fill brand and inline-icon slots. `LinkIcon` draws the leading category glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, all riding `currentColor` — and `classifyLinkPath` derives a file path's category from its extension. `FileTypeIcon` is the one glyph that does not ride `currentColor`: its sheet colour is the type's identity, so a consumer that wants it muted (an empty state) applies `filter: grayscale(1)` itself. `classifyFileType` and `classifyLinkPath` read one extension vocabulary (`file-extensions.ts`), so a path classifies consistently on a link and on a sheet; the sheet is the finer of the two. `ConnectionIndicator` renders a warning-colored disconnected action, a connecting label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Hover or keyboard focus shows only the reconnect action label, including while the connecting dots animate. Every state reserves the widest supplied label and uses fixed icon and text columns, so copy changes do not move or resize the control. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. `Toast` holds for the window its owner names through `holdMs`, because how long a banner has to stay depends on how much there is to read; the same value drives its unmount timer and the stylesheet's fade delay, so the two cannot disagree. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order. +The catalog above lists what each export is for; this section covers the behavior that props alone do not show. The `ic_ds_*` icon set and `FishLogo`/`BrandWordmark` marks fill brand and inline-icon slots. `FileTypeIcon` renders the traditional 28px Excel, folder, HTML, image, Markdown, generic, PDF, PPT, video, and Word glyphs, and uses the full-color square technology artwork for recognized code and configuration files. `classifyFileType` applies exact filename, prefix, suffix, optional project-context, and extension rules in that order; React names win over TypeScript/JavaScript, Angular suffixes win over their base extension, and a Dart file becomes Flutter only when the supplied project files contain a `pubspec.yaml` with `flutter:`. Markdown and SVG remain traditional Markdown and image files. `fileExtension` exposes the same basename and final-dot parsing for adjacent metadata labels. Traditional glyphs use a solid category-colored sheet with a white mark and translucent white corner; the generic file uses a grey sheet and darker grey corner. Callers may override the sheet color through `--dsh-file-type-icon-color`. The full-color technology artwork is the deliberate exception and retains its embedded palette. All glyphs are decorative and carry no label. `LinkIcon` remains the smaller leading glyph for clickable artifact links — globe, folder, code, image, document, or plain paper — while `classifyLinkPath` folds the shared file types into that existing six-category vocabulary. `ConnectionIndicator` renders a warning-colored disconnected action, a connecting label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Hover or keyboard focus shows only the reconnect action label, including while the connecting dots animate. Every state reserves the widest supplied label and uses fixed icon and text columns, so copy changes do not move or resize the control. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. `Toast` holds for the window its owner names through `holdMs`, because how long a banner has to stay depends on how much there is to read; the same value drives its unmount timer and the stylesheet's fade delay, so the two cannot disagree. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order. ### Rendering agent output diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 2478330825..a446ab71fb 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -9,7 +9,7 @@ kind: "package-library" ## 概述 -使用 `dsh-client-ui-primitives`,通过共享 React UI 构建 Web 客户端控件并渲染 agent 输出。它提供标准控件、图标、锚定浮层,以及用于带 TeX 公式的 Markdown、终端输出、文件读取、差异、搜索、网页检索和 JSON 的渲染器。这些渲染器会丢弃原始 HTML、限制链接并解析 ANSI 转义序列,以处理不受信任的模型输出。调用方必须提供本地化 label;这些组件仅依赖 React 和 `--dsw-*` 设计 token。 +使用 `dsh-client-ui-primitives`,通过共享 React UI 构建 Web 客户端控件并渲染 agent 输出。它提供标准控件、图标、锚定浮层,以及用于带 TeX 公式的 Markdown、终端输出、文件读取、差异、搜索、网页检索和 JSON 的渲染器。这些渲染器会丢弃原始 HTML、限制链接并解析 ANSI 转义序列,以处理不受信任的模型输出。组件不 import Cordis 运行时;调用方提供本地化 label,主题相关颜色使用 `--dsw-*` 设计 token。 ## 目录 @@ -54,8 +54,8 @@ kind: "package-library" | `JsonTree`、`JsonBlock` | 只读 JSON 查看。 | | `MarkdownText`、`CodeBlock` | 不可信 GFM 与 TeX 数学,以及高亮代码。`CodeBlock` 可通过 `lineNumbers` 开启行号;复制的源码不含行号栏。 | | `TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock`、`WebBlock` | 与各类工具结果意图对应的 agent 输出卡片。 | -| `icons/*`、`FishLogo`、`BrandWordmark`、`ReferenceIcon`、`LinkIcon`、`DocumentFileIcon` | 字形与品牌标识,全部随 `currentColor`。 | -| `FileTypeIcon` | 彩色文件类型纸片(code、html、image、markdown、pdf、sheet、slides、document、other);`classifyFileType` 按路径扩展名选出类型。 | +| `icons/*`、`FishLogo`、`BrandWordmark`、`ReferenceIcon`、`LinkIcon`、`DocumentFileIcon` | 字形与品牌标识。`LinkIcon` 只用于 14px 的可点击链接分类,`DocumentFileIcon` 只用于没有文件路径可供分类的场景。 | +| `FileTypeIcon`、`classifyFileType`、`fileExtension` | 按类别着色的 28px 文件或文件夹图形,以及它背后共享的不区分大小写文件名映射。代码与配置文件使用细分的全彩技术图形;链接前置图形使用 `LinkIcon`,图片内容使用图片预览。 | 有三组容易混淆: @@ -67,7 +67,7 @@ kind: "package-library" ### 控件与图标 -上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。`ic_ds_*` 图标集与 `FishLogo`/`BrandWordmark` 标记填充品牌与行内图标 slot。`LinkIcon` 为可点击产物链接绘制前置分类图形——地球、文件夹、代码、图片、文档或纸张,全部随 `currentColor`——`classifyLinkPath` 按扩展名推导文件路径的类别。`FileTypeIcon` 是唯一不随 `currentColor` 的字形:纸片颜色就是类型的身份,需要压灰的使用方(空状态)自行加 `filter: grayscale(1)`。`classifyFileType` 与 `classifyLinkPath` 读同一份扩展名词表(`file-extensions.ts`),一条路径在链接上与纸片上的归类一致;纸片分得更细。`ConnectionIndicator` 可渲染警告色的断联操作、以独立于 retry 时序的 500ms 节奏推进一至三个点的连接中状态,或成功色的恢复状态。悬停或键盘聚焦时只显示重连操作文案,连接中的圆点动画也保持隐藏。所有状态都为最长的输入 label 预留空间,并使用固定的图标列和文字列,因此文案变化不会移动控件或改变其宽度。它的 owner 提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。 `Toast` 的停留时长由使用方通过 `holdMs` 指定,因为横幅该留多久取决于有多少内容要读;同一个值同时驱动它的卸载定时器与样式表的淡出延迟,两者不可能再错位。 `rankByName` 是 `/` 菜单命令源与 skill 源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 +上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。`ic_ds_*` 图标集与 `FishLogo`/`BrandWordmark` 标记填充品牌与行内图标 slot。`FileTypeIcon` 渲染传统的 28px Excel、folder、HTML、image、Markdown、generic、PDF、PPT、video 与 Word 图形,并为已识别的代码和配置文件使用全彩方形技术图形。`classifyFileType` 按完整文件名、前缀、后缀、可选项目上下文、扩展名的顺序匹配;React 文件名优先于 TypeScript/JavaScript,Angular 后缀优先于基础扩展名,只有传入的项目文件包含带 `flutter:` 的 `pubspec.yaml` 时 Dart 文件才使用 Flutter。Markdown 与 SVG 仍分别使用传统 Markdown 与图片图形。`fileExtension` 为相邻元数据 label 暴露同一套 basename 与最终点号解析。传统图形使用实色分类底板、白色标记和半透明白色折角;通用文件使用灰色底板与较深灰色折角。调用方可通过 `--dsh-file-type-icon-color` 覆盖底板颜色。全彩技术图形是明确例外,会保留其内嵌调色板。所有图形都是装饰性的,不自带 label。`LinkIcon` 仍是可点击产物链接较小的前置分类图形——地球、文件夹、代码、图片、文档或纸张——`classifyLinkPath` 把共享文件类型折叠进原有六类词汇。`ConnectionIndicator` 可渲染警告色的断联操作、以独立于 retry 时序的 500ms 节奏推进一至三个点的连接中状态,或成功色的恢复状态。悬停或键盘聚焦时只显示重连操作文案,连接中的圆点动画也保持隐藏。所有状态都为最长的输入 label 预留空间,并使用固定的图标列和文字列,因此文案变化不会移动控件或改变其宽度。它的 owner 提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。 `Toast` 的停留时长由使用方通过 `holdMs` 指定,因为横幅该留多久取决于有多少内容要读;同一个值同时驱动它的卸载定时器与样式表的淡出延迟,两者不可能再错位。 `rankByName` 是 `/` 菜单命令源与 skill 源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 ### 渲染 agent 输出 diff --git a/packages/client/ui-primitives/src/CodeFileIcon.tsx b/packages/client/ui-primitives/src/CodeFileIcon.tsx new file mode 100644 index 0000000000..2e32bee8b0 --- /dev/null +++ b/packages/client/ui-primitives/src/CodeFileIcon.tsx @@ -0,0 +1,471 @@ +import { useId } from 'react' +import type { ReactNode } from 'react' +import type { IconProps } from './icons/props.ts' +import type { CodeFileType } from './code-file-types.ts' + +/** + * Render one full-color square code-file glyph from the embedded icon set. + * @param props - Detailed code type, optional size, and optional CSS class. + * @returns The selected decorative SVG with its identifying palette intact. + */ +export function CodeFileIcon({ type, size = 20, className }: IconProps & { readonly type: CodeFileType }): ReactNode { + const gradientId = useId() + return ( + + {codeFileArtwork(type, gradientId)} + + ) +} + +function codeFileArtwork(type: CodeFileType, gradientId: string): ReactNode { + switch (type) { + case 'angular': + return ( + <> + + + + + ) + case 'c': + return ( + <> + + + + {'C'} + + + ) + case 'clojure': + return ( + <> + + + + + + + ) + case 'cmake': + return ( + <> + + + + + + + ) + case 'cpp': + return ( + <> + + + + {'C'} + + + + ) + case 'csharp': + return ( + <> + + + + {'C'} + + + + ) + case 'css': + return ( + <> + + + {'CSS'} + + + ) + case 'dart': + return ( + <> + + + + + ) + case 'docker': + return ( + <> + + + + + + + ) + case 'elixir': + return ( + <> + + + + + + + + + + + ) + case 'env': + return ( + <> + + + {'ENV'} + + + ) + case 'erlang': + return ( + <> + + + + {'E'} + + + ) + case 'flutter': + return ( + <> + + + + + ) + case 'git': + return ( + <> + + + + + + + + ) + case 'go': + return ( + <> + + + + ) + case 'graphql': + return ( + <> + + + + + + + + + + + + ) + case 'haskell': + return ( + <> + + + + ) + case 'ini': + return ( + <> + + + {'INI'} + + + ) + case 'java': + return ( + <> + + + + + ) + case 'javascript': + return ( + <> + + + {'JS'} + + + ) + case 'json': + return ( + <> + + + {'{ }'} + + + ) + case 'kotlin': + return ( + <> + + + + + + + + + + + ) + case 'lua': + return ( + <> + + + + + + + ) + case 'makefile': + return ( + <> + + + + ) + case 'node': + return ( + <> + + + + + ) + case 'objective-c': + return ( + <> + + + {'OC'} + + + ) + case 'perl': + return ( + <> + + + {'PL'} + + + ) + case 'php': + return ( + <> + + + + {'PHP'} + + + ) + case 'powershell': + return ( + <> + + + + + ) + case 'protobuf': + return ( + <> + + + + + ) + case 'python': + return ( + <> + + + + + + + ) + case 'r': + return ( + <> + + + + + + ) + case 'react': + return ( + <> + + + + + + + + + ) + case 'ruby': + return ( + <> + + + + + ) + case 'rust': + return ( + <> + + + + + {'R'} + + + ) + case 'scala': + return ( + <> + + + + ) + case 'shell': + return ( + <> + + + + ) + case 'solidity': + return ( + <> + + + + ) + case 'sql': + return ( + <> + + + + + + ) + case 'svelte': + return ( + <> + + + + + ) + case 'swift': + return ( + <> + + + + ) + case 'toml': + return ( + <> + + + {'TOML'} + + + ) + case 'typescript': + return ( + <> + + + {'TS'} + + + ) + case 'vue': + return ( + <> + + + + + ) + case 'wasm': + return ( + <> + + + + ) + case 'xml': + return ( + <> + + + + ) + case 'yaml': + return ( + <> + + + {'YAML'} + + + ) + case 'zig': + return ( + <> + + + + {'ZIG'} + + + ) + /* v8 ignore next -- closed-union backstop; only reached if a type is forged */ + default: return assertNever(type) + } +} + +/** Closed-union exhaustiveness guard for the embedded artwork table. */ +/* v8 ignore next 3 -- only reachable when an untyped caller forges a code file type */ +function assertNever(value: never): never { + throw new Error(`unreachable code file type: ${String(value)}`) +} diff --git a/packages/client/ui-primitives/src/DocumentFileIcon.tsx b/packages/client/ui-primitives/src/DocumentFileIcon.tsx index 743a01a3ff..21aac0409d 100644 --- a/packages/client/ui-primitives/src/DocumentFileIcon.tsx +++ b/packages/client/ui-primitives/src/DocumentFileIcon.tsx @@ -1,7 +1,7 @@ import { useId } from 'react' /** - * Render the DeepSeek Web document glyph used by generic-file cards. + * Render a generic document glyph when the caller intentionally has no file path to classify. * @param props - optional CSS class for sizing and placement. * @returns a decorative document SVG with an instance-safe gradient id. */ diff --git a/packages/client/ui-primitives/src/FileTypeIcon.module.css b/packages/client/ui-primitives/src/FileTypeIcon.module.css new file mode 100644 index 0000000000..4de4d946e0 --- /dev/null +++ b/packages/client/ui-primitives/src/FileTypeIcon.module.css @@ -0,0 +1,50 @@ +.icon { + /* The design platform has no violet token matching the supplied image/video artwork. */ + --dsh-file-type-violet: rgb(139, 118, 246); + + color: var(--dsh-file-type-icon-color, var(--dsh-file-type-default-color)); +} + +.code { + --dsh-file-type-default-color: var(--dsw-static-deepseek-500); +} + +.excel { + --dsh-file-type-default-color: var(--dsw-static-green-500); +} + +.folder { + --dsh-file-type-default-color: var(--dsw-static-amber-400); +} + +.html { + --dsh-file-type-default-color: var(--dsw-static-deepseek-500); +} + +.image { + --dsh-file-type-default-color: var(--dsh-file-type-violet); +} + +.markdown { + --dsh-file-type-default-color: var(--dsw-static-deepseek-500); +} + +.other { + --dsh-file-type-default-color: var(--dsw-static-neutral-bluish-300); +} + +.pdf { + --dsh-file-type-default-color: var(--dsw-static-red-600); +} + +.ppt { + --dsh-file-type-default-color: var(--dsw-static-amber-500); +} + +.video { + --dsh-file-type-default-color: var(--dsh-file-type-violet); +} + +.word { + --dsh-file-type-default-color: var(--dsw-static-deepseek-450); +} diff --git a/packages/client/ui-primitives/src/FileTypeIcon.tsx b/packages/client/ui-primitives/src/FileTypeIcon.tsx index f03c01d4a7..6df0cdbe7b 100644 --- a/packages/client/ui-primitives/src/FileTypeIcon.tsx +++ b/packages/client/ui-primitives/src/FileTypeIcon.tsx @@ -1,167 +1,263 @@ -/** - * The coloured file-type sheet: a 28×28 paper with a folded corner, filled in - * the type's colour, carrying a white mark for the type. Unlike the `ic_ds_*` - * glyphs it does not ride `currentColor` — the colour is the type's identity — - * so a consumer that wants it muted applies a CSS filter. Design sources: - * DSHFiles iconCode, iconHTML, iconImage, iconMD, iconPDF, iconExcel, - * iconPPT, iconWord, iconOthers. - * - * TODO: interim. The sheets and `classifyFileType` are drawn for the Sidebar - * only; the product-wide file-type standard that will replace them is described - * in `file-extensions.ts`. - */ import type { ReactNode } from 'react' +import clsx from 'clsx' +import { CodeFileIcon } from './CodeFileIcon.tsx' import { - CODE_EXTENSIONS, HTML_EXTENSIONS, IMAGE_EXTENSIONS, MARKDOWN_EXTENSIONS, PDF_EXTENSIONS, SHEET_EXTENSIONS, - SLIDES_EXTENSIONS, WORD_EXTENSIONS, fileExtension, -} from './file-extensions.ts' + classifyCodeFileType, isCodeFileType, type CodeFileType, type FileTypeProjectContext, +} from './code-file-types.ts' import type { IconProps } from './icons/props.ts' +import css from './FileTypeIcon.module.css' -/** File types with their own sheet colour and mark; `other` is the grey sheet with text lines. */ -export type FileTypeKind = 'code' | 'html' | 'image' | 'markdown' | 'pdf' | 'sheet' | 'slides' | 'document' | 'other' +/** File categories with distinct 28px glyphs. */ +export type FileType = + | CodeFileType + | 'code' + | 'excel' + | 'folder' + | 'html' + | 'image' + | 'markdown' + | 'other' + | 'pdf' + | 'ppt' + | 'video' + | 'word' -/** Props for {@link FileTypeIcon}: the type plus the shared icon sizing seat. */ -export interface FileTypeIconProps extends IconProps { - kind: FileTypeKind +/** Compatibility name for consumers that pass an already resolved category. */ +export type FileTypeKind = FileType + +type ClassifiedFileType = Exclude +type TraditionalFileType = Exclude + +export type { CodeFileType, FileTypeProjectContext } from './code-file-types.ts' + +/** Props for {@link FileTypeIcon}: either a path to classify or an already resolved kind. */ +export type FileTypeIconProps = IconProps & ( + | { + /** File path or name to classify. */ + readonly path: string + /** Optional project-file snapshot for context-sensitive code icons such as Flutter. */ + readonly context?: FileTypeProjectContext | undefined + } + | { + /** Explicit category for callers that already resolved the file type. */ + readonly kind: FileTypeKind + } +) + +const EXTENSION_TYPES: Readonly> = { + scss: 'code', + sass: 'code', + less: 'code', + vue: 'code', + svelte: 'code', + astro: 'code', + env: 'code', + bat: 'code', + cmd: 'code', + csv: 'code', + tsv: 'code', + html: 'html', + htm: 'html', + png: 'image', + jpg: 'image', + jpeg: 'image', + gif: 'image', + svg: 'image', + webp: 'image', + avif: 'image', + bmp: 'image', + ico: 'image', + tif: 'image', + tiff: 'image', + heic: 'image', + heif: 'image', + md: 'markdown', + mdx: 'markdown', + markdown: 'markdown', + pdf: 'pdf', + ppt: 'ppt', + pptx: 'ppt', + mp4: 'video', + mov: 'video', + m4v: 'video', + webm: 'video', + mkv: 'video', + avi: 'video', + mpg: 'video', + mpeg: 'video', + doc: 'word', + docx: 'word', + xls: 'excel', + xlsx: 'excel', } -/** Extension sets in precedence order: a specific type wins over the code set it may also sit in. */ -const KIND_SETS: readonly (readonly [FileTypeKind, ReadonlySet])[] = [ - ['markdown', MARKDOWN_EXTENSIONS], - ['html', HTML_EXTENSIONS], - ['pdf', PDF_EXTENSIONS], - ['sheet', SHEET_EXTENSIONS], - ['slides', SLIDES_EXTENSIONS], - ['document', WORD_EXTENSIONS], - ['image', IMAGE_EXTENSIONS], - ['code', CODE_EXTENSIONS], -] - -/** - * Derive a file path's type glyph from its extension. Unknown and missing - * extensions fall to `other`. - * @param path - File path or bare name, with either separator. - * @returns The file's type. - */ -export function classifyFileType(path: string): FileTypeKind { - const extension = fileExtension(path) - return KIND_SETS.find(([, set]) => set.has(extension))?.[0] ?? 'other' +const NAME_TYPES: Readonly> = { + changelog: 'markdown', + contributing: 'markdown', + readme: 'markdown', } -const SHEET_PATH = 'M8.48949 28H19.511C21.6482 28 22.7167 28 23.5596 27.6509C24.6835 27.1853 25.5764 26.2924 26.042 25.1685C26.3911 24.3256 26.3911 23.257 26.3911 21.1199V8.79443C26.3911 8.32877 26.3911 8.09593 26.3473 7.87507C26.2889 7.58058 26.1733 7.30042 26.007 7.05048C25.8822 6.86303 25.718 6.69799 25.3895 6.36792L20.0613 1.01354C19.7307 0.681235 19.5653 0.515081 19.3771 0.38885C19.1263 0.220541 18.8446 0.103463 18.5483 0.0443412C18.3261 0 18.0917 0 17.6229 0H8.48949C6.35233 0 5.28376 0 4.44085 0.349145C3.31697 0.814671 2.42405 1.70759 1.95852 2.83147C1.60938 3.67438 1.60938 4.74296 1.60938 6.88011V21.1199C1.60938 23.257 1.60938 24.3256 1.95852 25.1685C2.42405 26.2924 3.31697 27.1853 4.44085 27.6509C5.28376 28 6.35233 28 8.48949 28Z' - -const CORNER_PATH = 'M26.3911 7.37445L19.0527 0V3.77445C19.0527 4.89271 19.0527 5.45184 19.2354 5.89289C19.479 6.48096 19.9462 6.94818 20.5343 7.19176C20.9753 7.37445 21.5345 7.37445 22.6527 7.37445H26.3911Z' - -/** One type's sheet colour and its white mark. */ -interface Face { - readonly fill: string - readonly mark: ReactNode -} - -const FACES: Readonly> = { - code: { - fill: '#4176E6', - mark: ( - <> - - - - ), - }, - html: { - fill: '#4176E6', - mark: ( - - ), - }, - image: { - fill: '#8B76F6', - mark: ( - <> - - - - - ), - }, - markdown: { - fill: '#4176E6', - mark: ( - - ), - }, - pdf: { - fill: '#EC1313', - mark: ( - - ), - }, - sheet: { - fill: '#22C55E', - mark: ( - - ), - }, - slides: { - fill: '#F59E0B', - mark: ( - - ), - }, - document: { - fill: '#5686FE', - mark: ( - - ), - }, - other: { - fill: '#CFD3D6', - mark: ( - <> - - - - - ), - }, +function basename(path: string): string { + return path.slice(Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1) } /** - * Render one file type's coloured sheet. - * @param props - The type, optional size (default 28px, the drawn size), and - * optional CSS class. - * @returns The type's SVG, `aria-hidden`; the adjacent name carries the meaning. + * Extract the final suffix from a file path without changing its case. + * A leading dot starts a suffix, while a missing or trailing dot returns an empty string. + * @param path - File path or basename using either path separator. + * @returns The characters after the basename's final dot. */ -export function FileTypeIcon({ kind, size = 28, className }: FileTypeIconProps): ReactNode { - const face = FACES[kind] +export function fileExtension(path: string): string { + const name = basename(path) + const dot = name.lastIndexOf('.') + return dot < 0 ? '' : name.slice(dot + 1) +} + +/** + * Classify a file path or name for file-card presentation. + * Matching is case-insensitive and applies code filename rules before extension rules; + * unknown names fall back to `other`. + * @param path - File path or basename using either path separator. + * @param context - Optional project files used by context-sensitive code mappings. + * @returns The file's closed presentation category. + */ +export function classifyFileType( + path: string, + context?: FileTypeProjectContext, +): ClassifiedFileType { + const name = basename(path).toLowerCase() + const extension = fileExtension(name).toLowerCase() + return classifyCodeFileType(name, extension, context) + ?? NAME_TYPES[name] + ?? EXTENSION_TYPES[extension] + ?? 'other' +} + +const FILE_BODY = 'M8.48924 28H19.5108C21.6479 28 22.7165 28 23.5594 27.6509C24.6833 27.1853 25.5762 26.2924 26.0417 25.1685C26.3909 24.3256 26.3909 23.257 26.3909 21.1199V8.79443C26.3909 8.32877 26.3909 8.09593 26.3471 7.87507C26.2887 7.58058 26.173 7.30042 26.0067 7.05048C25.882 6.86303 25.7177 6.69799 25.3893 6.36792L20.0611 1.01354C19.7304 0.681235 19.5651 0.515081 19.3769 0.38885C19.126 0.220541 18.8443 0.103463 18.5481 0.0443412C18.3259 0 18.0915 0 17.6226 0H8.48924C6.35209 0 5.28351 0 4.4406 0.349145C3.31672 0.814671 2.4238 1.70759 1.95828 2.83147C1.60913 3.67438 1.60913 4.74296 1.60913 6.88011V21.1199C1.60913 23.257 1.60913 24.3256 1.95828 25.1685C2.4238 26.2924 3.31672 27.1853 4.4406 27.6509C5.28351 28 6.35209 28 8.48924 28Z' +const FILE_FOLD = 'M26.3909 7.37445L19.0525 0V3.77445C19.0525 4.89271 19.0525 5.45184 19.2352 5.89289C19.4788 6.48096 19.946 6.94818 20.5341 7.19176C20.9751 7.37445 21.5342 7.37445 22.6525 7.37445H26.3909Z' +const FILE_MARK_TRANSFORM = 'translate(14 16) scale(1.12) translate(-14 -16)' +const LARGE_FILE_MARK_TRANSFORM = 'translate(14 16) scale(1.22) translate(-14 -16)' +const FOLDER_MARK_TRANSFORM = 'translate(14 13.0693) scale(1.12) translate(-14 -13.0693)' + +function FileGlyph({ + size, className, children, markTransform = FILE_MARK_TRANSFORM, muted = false, +}: IconProps & { children?: ReactNode; markTransform?: string; muted?: boolean }): ReactNode { return ( - - {face.mark} - {/* The grey sheet folds a darker corner; every coloured sheet folds a translucent white one. */} - {kind === 'other' - ? - : } + + {muted + ? + : } + {children !== undefined && ( + {children} + )} ) } + +function FolderGlyph({ size, className }: IconProps): ReactNode { + return ( + + + + + + + ) +} + +function glyph(type: TraditionalFileType, size: number, className: string | undefined): ReactNode { + switch (type) { + case 'code': + return ( + + + + + ) + case 'excel': + return ( + + + + ) + case 'folder': return + case 'html': + return ( + + + + ) + case 'image': + return ( + + + + + + ) + case 'markdown': + return ( + + + + ) + case 'other': return + case 'pdf': + return ( + + + + ) + case 'ppt': + return ( + + + + ) + case 'video': + return ( + + + + ) + case 'word': + return ( + + + + ) + /* v8 ignore next -- closed-union backstop; only reached if a type is forged */ + default: return assertNever(type) + } +} + +/** Closed-union exhaustiveness guard for traditional file artwork. */ +/* v8 ignore next 3 -- only reachable when an untyped caller forges a traditional file type */ +function assertNever(value: never): never { + throw new Error(`unreachable traditional file type: ${String(value)}`) +} + +/** + * Render a decorative file-type glyph for a path or an explicitly resolved kind. + * @param props - Path or kind selection, optional project context, size, and CSS class. + * @returns The category-colored SVG; the caller owns the accessible name and may override + * the color through `--dsh-file-type-icon-color`. + */ +export function FileTypeIcon(props: FileTypeIconProps): ReactNode { + const { size = 28, className } = props + const resolvedType = 'path' in props + ? classifyFileType(props.path, props.context) + : props.kind + return isCodeFileType(resolvedType) + ? + : glyph(resolvedType, size, clsx(css.icon, css[resolvedType], className)) +} diff --git a/packages/client/ui-primitives/src/LinkIcon.tsx b/packages/client/ui-primitives/src/LinkIcon.tsx index 37dade0529..9e5612fd22 100644 --- a/packages/client/ui-primitives/src/LinkIcon.tsx +++ b/packages/client/ui-primitives/src/LinkIcon.tsx @@ -9,10 +9,8 @@ * ic_photo_outline_20, ic_paper_doc_outline_20, ic_paper_outline_20. */ import type { ReactNode } from 'react' -import { - CODE_EXTENSIONS, IMAGE_EXTENSIONS, PDF_EXTENSIONS, SHEET_EXTENSIONS, SLIDES_EXTENSIONS, WORD_EXTENSIONS, - fileExtension, -} from './file-extensions.ts' +import { classifyFileType, fileExtension } from './FileTypeIcon.tsx' +import { isCodeFileType, isLinkCodeExtension } from './code-file-types.ts' import type { IconProps } from './icons/props.ts' /** @@ -28,22 +26,29 @@ export interface LinkIconProps extends IconProps { kind: LinkIconKind } -/** Office-style documents share one paper-doc glyph on a link. */ -const DOCUMENT_SETS = [PDF_EXTENSIONS, SHEET_EXTENSIONS, SLIDES_EXTENSIONS, WORD_EXTENSIONS] - /** - * Derive a file path's link-icon category from its extension. Code, web, and - * data files share the code glyph; unknown and missing extensions fall to - * `other` (the plain-paper glyph). + * Derive a file path's link-icon category from its extension. Unknown and + * missing extensions fall to `other` (the plain-paper glyph). * @param path - File path as the producing tool spelled it (either separator). * @returns The file's glyph category; never `url` or `folder`. */ export function classifyLinkPath(path: string): LinkIconKind { + const type = classifyFileType(path) const extension = fileExtension(path) + if (isCodeFileType(type)) return isLinkCodeExtension(extension) ? 'code' : 'other' if (extension === '') return 'other' - if (CODE_EXTENSIONS.has(extension)) return 'code' - if (IMAGE_EXTENSIONS.has(extension)) return 'image' - return DOCUMENT_SETS.some(set => set.has(extension)) ? 'document' : 'other' + switch (type) { + case 'code': + case 'html': return 'code' + case 'image': return 'image' + case 'excel': + case 'pdf': + case 'ppt': + case 'word': return 'document' + case 'markdown': + case 'other': + case 'video': return 'other' + } } const GlobeGlyph = ({ size, className }: IconProps) => ( diff --git a/packages/client/ui-primitives/src/code-file-types.ts b/packages/client/ui-primitives/src/code-file-types.ts new file mode 100644 index 0000000000..f82aa24370 --- /dev/null +++ b/packages/client/ui-primitives/src/code-file-types.ts @@ -0,0 +1,339 @@ +/** Full-color code and configuration glyphs supplied by square-code-icons v1. */ +export const CODE_FILE_TYPES = [ + 'angular', + 'c', + 'clojure', + 'cmake', + 'cpp', + 'csharp', + 'css', + 'dart', + 'docker', + 'elixir', + 'env', + 'erlang', + 'flutter', + 'git', + 'go', + 'graphql', + 'haskell', + 'ini', + 'java', + 'javascript', + 'json', + 'kotlin', + 'lua', + 'makefile', + 'node', + 'objective-c', + 'perl', + 'php', + 'powershell', + 'protobuf', + 'python', + 'r', + 'react', + 'ruby', + 'rust', + 'scala', + 'shell', + 'solidity', + 'sql', + 'svelte', + 'swift', + 'toml', + 'typescript', + 'vue', + 'wasm', + 'xml', + 'yaml', + 'zig', +] as const + +/** A code or configuration file category with its own full-color square glyph. */ +export type CodeFileType = typeof CODE_FILE_TYPES[number] + +/** Optional project files used by context-sensitive code-icon rules. */ +export interface FileTypeProjectContext { + /** Text content keyed by project-relative or basename path. */ + readonly files: Readonly> +} + +const CODE_FILE_TYPE_SET = new Set(CODE_FILE_TYPES) + +const FILE_NAME_TYPES: Readonly> = { + '.bash_profile': 'shell', + '.bashrc': 'shell', + '.env': 'env', + '.gitattributes': 'git', + '.gitconfig': 'git', + '.gitignore': 'git', + '.gitmodules': 'git', + '.mailmap': 'git', + '.profile': 'shell', + '.zprofile': 'shell', + '.zshrc': 'shell', + 'bsdmakefile': 'makefile', + 'cmakelists.txt': 'cmake', + 'commit_editmsg': 'git', + 'compose.yaml': 'docker', + 'compose.yml': 'docker', + 'docker-compose.yaml': 'docker', + 'docker-compose.yml': 'docker', + 'dockerfile': 'docker', + 'gemfile': 'ruby', + 'gnumakefile': 'makefile', + 'guardfile': 'ruby', + 'makefile': 'makefile', + 'npm-shrinkwrap.json': 'node', + 'package-lock.json': 'node', + 'package.json': 'node', + 'podfile': 'ruby', + 'rakefile': 'ruby', +} + +const FILE_NAME_PREFIX_TYPES: readonly (readonly [string, CodeFileType])[] = [ + ['dockerfile.', 'docker'], + ['.env.', 'env'], +] + +const FILE_NAME_SUFFIX_TYPES: readonly (readonly [string, CodeFileType])[] = [ + ['.component.ts', 'angular'], + ['.component.html', 'angular'], + ['.directive.ts', 'angular'], + ['.service.ts', 'angular'], + ['.module.ts', 'angular'], + ['.pipe.ts', 'angular'], + ['.guard.ts', 'angular'], + ['.interceptor.ts', 'angular'], + ['.dockerfile', 'docker'], +] + +const EXTENSION_TYPES: Readonly> = { + 'bash': 'shell', + 'c': 'c', + 'c++': 'cpp', + 'cc': 'cpp', + 'cfg': 'ini', + 'cjs': 'javascript', + 'clj': 'clojure', + 'cljc': 'clojure', + 'cljs': 'clojure', + 'cmake': 'cmake', + 'cpp': 'cpp', + 'cs': 'csharp', + 'csh': 'shell', + 'css': 'css', + 'csx': 'csharp', + 'cts': 'typescript', + 'cxx': 'cpp', + 'dart': 'dart', + 'dtd': 'xml', + 'edn': 'clojure', + 'erl': 'erlang', + 'es6': 'javascript', + 'escript': 'erlang', + 'ex': 'elixir', + 'exs': 'elixir', + 'fish': 'shell', + 'gemspec': 'ruby', + 'go': 'go', + 'gql': 'graphql', + 'graphql': 'graphql', + 'h': 'c', + 'h++': 'cpp', + 'hh': 'cpp', + 'hpp': 'cpp', + 'hrl': 'erlang', + 'hs': 'haskell', + 'hxx': 'cpp', + 'ini': 'ini', + 'ipp': 'cpp', + 'java': 'java', + 'js': 'javascript', + 'json': 'json', + 'json5': 'json', + 'jsonc': 'json', + 'jsx': 'react', + 'ksh': 'shell', + 'kt': 'kotlin', + 'kts': 'kotlin', + 'lhs': 'haskell', + 'lua': 'lua', + 'm': 'objective-c', + 'mak': 'makefile', + 'mjs': 'javascript', + 'mk': 'makefile', + 'mm': 'objective-c', + 'mts': 'typescript', + 'node': 'node', + 'pch': 'objective-c', + 'php': 'php', + 'php3': 'php', + 'php4': 'php', + 'php5': 'php', + 'phps': 'php', + 'phtml': 'php', + 'pl': 'perl', + 'plist': 'xml', + 'pm': 'perl', + 'pod': 'perl', + 'proto': 'protobuf', + 'ps1': 'powershell', + 'psd1': 'powershell', + 'psm1': 'powershell', + 'py': 'python', + 'pyi': 'python', + 'pyw': 'python', + 'pyx': 'python', + 'r': 'r', + 'rake': 'ruby', + 'rb': 'ruby', + 'rmd': 'r', + 'rs': 'rust', + 'sc': 'scala', + 'scala': 'scala', + 'sh': 'shell', + 'sol': 'solidity', + 'sql': 'sql', + 'svelte': 'svelte', + 'swift': 'swift', + 't': 'perl', + 'tcsh': 'shell', + 'toml': 'toml', + 'tpp': 'cpp', + 'ts': 'typescript', + 'tsx': 'react', + 'vue': 'vue', + 'wasm': 'wasm', + 'wast': 'wasm', + 'wat': 'wasm', + 'xml': 'xml', + 'xsd': 'xml', + 'xsl': 'xml', + 'xslt': 'xml', + 'yaml': 'yaml', + 'yml': 'yaml', + 'zig': 'zig', + 'zsh': 'shell', +} + +const LINK_CODE_EXTENSIONS = new Set([ + 'ts', + 'tsx', + 'js', + 'jsx', + 'mjs', + 'cjs', + 'cts', + 'mts', + 'css', + 'scss', + 'sass', + 'less', + 'html', + 'htm', + 'vue', + 'svelte', + 'astro', + 'json', + 'jsonc', + 'json5', + 'yaml', + 'yml', + 'toml', + 'xml', + 'ini', + 'env', + 'sh', + 'bash', + 'zsh', + 'fish', + 'ps1', + 'bat', + 'cmd', + 'py', + 'pyi', + 'rb', + 'rs', + 'go', + 'java', + 'kt', + 'kts', + 'c', + 'cc', + 'cpp', + 'cxx', + 'h', + 'hh', + 'hpp', + 'cs', + 'php', + 'swift', + 'sql', + 'csv', + 'tsv', + 'proto', + 'graphql', + 'gql', + 'lua', + 'r', + 'pl', + 'scala', + 'clj', + 'cljs', + 'ex', + 'exs', + 'erl', + 'hs', + 'dart', +]) + +function basename(path: string): string { + return path.slice(Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1) +} + +/** + * Test whether a resolved file type uses the full-color code-icon set. + * @param type - Resolved file-type string. + * @returns Whether the value is a detailed code-file type. + */ +export function isCodeFileType(type: string): type is CodeFileType { + return CODE_FILE_TYPE_SET.has(type) +} + +/** + * Test whether an extension belonged to the established coarse LinkIcon code category. + * @param extension - Extension without a leading dot. + * @returns Whether clickable links keep the code glyph for this extension. + */ +export function isLinkCodeExtension(extension: string): boolean { + return LINK_CODE_EXTENSIONS.has(extension.toLowerCase()) +} + +/** + * Resolve the most specific code/configuration icon according to the supplied map priority. + * @param name - Lowercase basename. + * @param extension - Lowercase extension without the leading dot. + * @param context - Optional project-file snapshot for context-sensitive matches. + * @returns The detailed code type, or null when the traditional file classifier owns the path. + */ +export function classifyCodeFileType( + name: string, + extension: string, + context?: FileTypeProjectContext, +): CodeFileType | null { + const exact = FILE_NAME_TYPES[name] + if (exact !== undefined) return exact + for (const [prefix, type] of FILE_NAME_PREFIX_TYPES) { + if (name.startsWith(prefix)) return type + } + for (const [suffix, type] of FILE_NAME_SUFFIX_TYPES) { + if (name.endsWith(suffix)) return type + } + if (extension === 'dart' && context !== undefined) { + const pubspec = Object.entries(context.files).find(([path]) => basename(path).toLowerCase() === 'pubspec.yaml')?.[1] + if (pubspec?.includes('flutter:') === true) return 'flutter' + } + return EXTENSION_TYPES[extension] ?? null +} diff --git a/packages/client/ui-primitives/src/file-extensions.ts b/packages/client/ui-primitives/src/file-extensions.ts deleted file mode 100644 index 8adbf67af2..0000000000 --- a/packages/client/ui-primitives/src/file-extensions.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** - * File-extension vocabularies shared by the glyph pickers (`classifyLinkPath`, - * `classifyFileType`), so a path classifies the same way on a link and on a - * file-type icon. - * - * TODO: this is an interim rule. The product will settle one extension - * standard for every place a file is drawn — an upload in the input bar, a - * sent attachment, an artifact, the Sidebar's tree rows, the Sidebar's tab - * chips and pane headers — and these sets, the glyph pickers over them, and - * the sheets in `FileTypeIcon` are to be replaced by it. Until then the - * surfaces differ: the Sidebar classifies by these sets, the conversation's - * links fold the four office types into `document` (`LinkIcon`), and the - * attachment cards (`ui-attachment` `FileCard`) draw one generic glyph with no - * classification at all. - */ - -/** Code, web, and data extensions. */ -export const CODE_EXTENSIONS: ReadonlySet = new Set([ - 'ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs', 'cts', 'mts', 'css', 'scss', 'sass', 'less', - 'html', 'htm', 'vue', 'svelte', 'astro', 'json', 'jsonc', 'json5', 'yaml', 'yml', - 'toml', 'xml', 'ini', 'env', 'sh', 'bash', 'zsh', 'fish', 'ps1', 'bat', 'cmd', - 'py', 'pyi', 'rb', 'rs', 'go', 'java', 'kt', 'kts', 'c', 'cc', 'cpp', 'cxx', - 'h', 'hh', 'hpp', 'cs', 'php', 'swift', 'sql', 'csv', 'tsv', 'proto', 'graphql', - 'gql', 'lua', 'r', 'pl', 'scala', 'clj', 'cljs', 'ex', 'exs', 'erl', 'hs', 'dart', -]) - -/** Raster and vector image extensions. */ -export const IMAGE_EXTENSIONS: ReadonlySet = new Set([ - 'png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'avif', 'bmp', 'ico', 'tif', 'tiff', 'heic', 'heif', -]) - -/** Web page extensions; a subset of {@link CODE_EXTENSIONS} with its own file-type glyph. */ -export const HTML_EXTENSIONS: ReadonlySet = new Set(['html', 'htm']) - -/** Markdown extensions. */ -export const MARKDOWN_EXTENSIONS: ReadonlySet = new Set(['md', 'mdx', 'markdown']) - -/** PDF. */ -export const PDF_EXTENSIONS: ReadonlySet = new Set(['pdf']) - -/** Spreadsheet extensions; `csv`/`tsv` also sit in {@link CODE_EXTENSIONS}. */ -export const SHEET_EXTENSIONS: ReadonlySet = new Set(['xls', 'xlsx', 'xlsm', 'csv', 'tsv', 'numbers']) - -/** Slide-deck extensions. */ -export const SLIDES_EXTENSIONS: ReadonlySet = new Set(['ppt', 'pptx', 'key']) - -/** Word-processor document extensions. */ -export const WORD_EXTENSIONS: ReadonlySet = new Set(['doc', 'docx', 'rtf', 'odt', 'pages']) - -/** - * A path's lowercase extension without the dot, taken from its last segment. - * @param path - File path with either separator. - * @returns The extension, or `''` for a name without a dot. - */ -export function fileExtension(path: string): string { - const name = path.slice(Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1) - const dot = name.lastIndexOf('.') - return dot < 0 ? '' : name.slice(dot + 1).toLowerCase() -} diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index d9b12eda0e..a483fee247 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -33,8 +33,10 @@ export { ReferenceIcon } from './ReferenceIcon.tsx' export type { ReferenceIconKind, ReferenceIconProps } from './ReferenceIcon.tsx' export { LinkIcon, classifyLinkPath } from './LinkIcon.tsx' export type { LinkIconKind, LinkIconProps } from './LinkIcon.tsx' -export { FileTypeIcon, classifyFileType } from './FileTypeIcon.tsx' -export type { FileTypeKind, FileTypeIconProps } from './FileTypeIcon.tsx' +export { FileTypeIcon, classifyFileType, fileExtension } from './FileTypeIcon.tsx' +export type { + CodeFileType, FileType, FileTypeIconProps, FileTypeKind, FileTypeProjectContext, +} from './FileTypeIcon.tsx' export { projectUserText } from './user-text.tsx' export { Tooltip } from './Tooltip.tsx' export type { TooltipSide } from './Tooltip.tsx' diff --git a/packages/client/ui-primitives/tests/code-file-icon.client.spec.tsx b/packages/client/ui-primitives/tests/code-file-icon.client.spec.tsx new file mode 100644 index 0000000000..f8b155e871 --- /dev/null +++ b/packages/client/ui-primitives/tests/code-file-icon.client.spec.tsx @@ -0,0 +1,123 @@ +// @vitest-environment jsdom +import { cleanup, render } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import { + classifyFileType, FileTypeIcon, type CodeFileType, type FileTypeProjectContext, +} from '@deepseek-ai/dsh-client-ui-primitives' +import { CODE_FILE_TYPES } from '../src/code-file-types.ts' + +afterEach(cleanup) + +const flutter: FileTypeProjectContext = { + files: { 'config/pubspec.yaml': 'name: demo\ndependencies:\n flutter: sdk' }, +} + +describe('code-file classification', () => { + it.each([ + ['app.component.ts', 'angular'], + ['file.c', 'c'], + ['file.clj', 'clojure'], + ['CMakeLists.txt', 'cmake'], + ['file.cc', 'cpp'], + ['file.cs', 'csharp'], + ['file.css', 'css'], + ['file.dart', 'dart'], + ['Dockerfile', 'docker'], + ['file.ex', 'elixir'], + ['.env', 'env'], + ['file.erl', 'erlang'], + ['.gitignore', 'git'], + ['file.go', 'go'], + ['file.graphql', 'graphql'], + ['file.hs', 'haskell'], + ['file.ini', 'ini'], + ['file.java', 'java'], + ['file.js', 'javascript'], + ['file.json', 'json'], + ['file.kt', 'kotlin'], + ['file.lua', 'lua'], + ['Makefile', 'makefile'], + ['package.json', 'node'], + ['file.m', 'objective-c'], + ['file.pl', 'perl'], + ['file.php', 'php'], + ['file.ps1', 'powershell'], + ['file.proto', 'protobuf'], + ['file.py', 'python'], + ['file.r', 'r'], + ['file.jsx', 'react'], + ['Gemfile', 'ruby'], + ['file.rs', 'rust'], + ['file.scala', 'scala'], + ['.bashrc', 'shell'], + ['file.sol', 'solidity'], + ['file.sql', 'sql'], + ['file.svelte', 'svelte'], + ['file.swift', 'swift'], + ['file.toml', 'toml'], + ['file.ts', 'typescript'], + ['file.vue', 'vue'], + ['file.wasm', 'wasm'], + ['file.xml', 'xml'], + ['file.yaml', 'yaml'], + ['file.zig', 'zig'], + ] as [string, CodeFileType][])('%s → %s', (path, type) => { + expect(classifyFileType(path)).toBe(type) + }) + + it('applies filename priority before generic extensions', () => { + expect(classifyFileType('package.json')).toBe('node') + expect(classifyFileType('docker-compose.yaml')).toBe('docker') + expect(classifyFileType('feature.component.ts')).toBe('angular') + expect(classifyFileType('Dockerfile.local')).toBe('docker') + expect(classifyFileType('.env.production')).toBe('env') + }) + + it('selects Flutter only when the supplied project snapshot identifies it', () => { + expect(classifyFileType('lib/main.dart')).toBe('dart') + expect(classifyFileType('lib/main.dart', { files: { 'pubspec.yaml': 'name: demo' } })).toBe('dart') + expect(classifyFileType('lib/main.dart', flutter)).toBe('flutter') + + const view = render() + const explicit = render() + expect(view.container.innerHTML).toBe(explicit.container.innerHTML) + }) + + it('keeps Markdown and SVG on the traditional file artwork', () => { + expect(classifyFileType('README.md')).toBe('markdown') + expect(classifyFileType('logo.svg')).toBe('image') + }) +}) + +describe('full-color code-file artwork', () => { + it.each(CODE_FILE_TYPES)('%s renders its supplied square SVG', (type) => { + const { container } = render() + const svg = container.querySelector('svg')! + expect(svg.getAttribute('viewBox')).toBe('0 0 20 20') + expect(svg.getAttribute('width')).toBe('28') + expect(svg.getAttribute('aria-hidden')).toBe('true') + expect(container.innerHTML).toMatch(/#[0-9a-fA-F]{3,8}/) + expect(container.innerHTML).not.toContain('currentColor') + }) + + it('keeps every code category visually distinct', () => { + const artwork = CODE_FILE_TYPES.map((type) => { + const { container } = render() + return container.querySelector('svg')!.innerHTML + }) + expect(new Set(artwork).size).toBe(CODE_FILE_TYPES.length) + }) + + it('uses instance-safe gradient ids and forwards sizing classes', () => { + const { container } = render(<>) + const ids = [...container.querySelectorAll('linearGradient')].map(gradient => gradient.id) + expect(new Set(ids).size).toBe(2) + expect([...container.querySelectorAll('rect')].map(rect => rect.getAttribute('fill'))).toEqual(ids.map(id => `url(#${id})`)) + + const sized = render() + const svg = sized.container.querySelector('svg')! + expect(svg.getAttribute('width')).toBe('16') + expect(svg.getAttribute('height')).toBe('16') + expect(svg.classList.contains('x')).toBe(true) + }) +}) diff --git a/packages/client/ui-primitives/tests/file-type-icon-styles.client.spec.ts b/packages/client/ui-primitives/tests/file-type-icon-styles.client.spec.ts new file mode 100644 index 0000000000..347241675d --- /dev/null +++ b/packages/client/ui-primitives/tests/file-type-icon-styles.client.spec.ts @@ -0,0 +1,38 @@ +/** FileTypeIcon's default per-category palette as CSS text. */ +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import type { CodeFileType, FileType } from '@deepseek-ai/dsh-client-ui-primitives' + +const css = readFileSync(fileURLToPath(new URL('../src/FileTypeIcon.module.css', import.meta.url)), 'utf8') + +type TraditionalFileType = Exclude + +const TYPE_COLORS: Readonly> = { + code: 'var(--dsw-static-deepseek-500)', + excel: 'var(--dsw-static-green-500)', + folder: 'var(--dsw-static-amber-400)', + html: 'var(--dsw-static-deepseek-500)', + image: 'var(--dsh-file-type-violet)', + markdown: 'var(--dsw-static-deepseek-500)', + other: 'var(--dsw-static-neutral-bluish-300)', + pdf: 'var(--dsw-static-red-600)', + ppt: 'var(--dsw-static-amber-500)', + video: 'var(--dsh-file-type-violet)', + word: 'var(--dsw-static-deepseek-450)', +} + +describe('FileTypeIcon.module.css', () => { + it.each(Object.entries(TYPE_COLORS) as [TraditionalFileType, string][])( + '%s has its own default color', + (type, color) => { + const rule = css.match(new RegExp(`\\.${type}\\s*\\{([^}]*)\\}`))?.[1] + expect(rule).toContain(`--dsh-file-type-default-color: ${color}`) + }, + ) + + it('keeps one caller override and the supplied violet in named variables', () => { + expect(css).toContain('color: var(--dsh-file-type-icon-color, var(--dsh-file-type-default-color))') + expect(css).toContain('--dsh-file-type-violet: rgb(139, 118, 246)') + }) +}) diff --git a/packages/client/ui-primitives/tests/file-type-icon.client.spec.tsx b/packages/client/ui-primitives/tests/file-type-icon.client.spec.tsx index 306a9e7941..65f9dbaecb 100644 --- a/packages/client/ui-primitives/tests/file-type-icon.client.spec.tsx +++ b/packages/client/ui-primitives/tests/file-type-icon.client.spec.tsx @@ -1,61 +1,133 @@ // @vitest-environment jsdom import { cleanup, render } from '@testing-library/react' import { afterEach, describe, expect, it } from 'vitest' -import { FileTypeIcon, classifyFileType, type FileTypeKind } from '@deepseek-ai/dsh-client-ui-primitives' +import { + classifyFileType, fileExtension, FileTypeIcon, type FileTypeKind, +} from '@deepseek-ai/dsh-client-ui-primitives' +import css from '../src/FileTypeIcon.module.css' afterEach(cleanup) -const KINDS: FileTypeKind[] = ['code', 'html', 'image', 'markdown', 'pdf', 'sheet', 'slides', 'document', 'other'] +describe('fileExtension', () => { + it.each([ + ['src/README.md', 'md'], + ['C:\\work\\REPORT.PDF', 'PDF'], + ['.env', 'env'], + ['archive.tar.gz', 'gz'], + ['Makefile', ''], + ['trailing.', ''], + ])('%s → %s', (path, extension) => { + expect(fileExtension(path)).toBe(extension) + }) +}) describe('classifyFileType', () => { it.each([ - ['src/render.tsx', 'code'], - ['site/index.HTML', 'html'], - ['README.md', 'markdown'], - ['data/export.csv', 'sheet'], - ['book.xlsx', 'sheet'], + ['src/index.TSX', 'react'], + ['site/index.html', 'html'], + ['data/export.CSV', 'code'], ['shots/hero.png', 'image'], + ['notes/README.MDX', 'markdown'], ['report.pdf', 'pdf'], - ['deck.pptx', 'slides'], - ['C:\\work\\summary.docx', 'document'], + ['deck.pptx', 'ppt'], + ['clip.webm', 'video'], + ['letter.docx', 'word'], + ['budget.xlsx', 'excel'], + ['Makefile', 'makefile'], + ['README', 'markdown'], + ['.env', 'env'], + ['.hidden', 'other'], ['notes.unknownext', 'other'], - ['Makefile', 'other'], + ['extensionless', 'other'], ['archive.tar/.hidden', 'other'], - ] as [string, FileTypeKind][])('%s → %s', (path, kind) => { - expect(classifyFileType(path)).toBe(kind) + ] as [string, Exclude][])('%s → %s', (path, type) => { + expect(classifyFileType(path)).toBe(type) }) }) describe('FileTypeIcon', () => { - it.each(KINDS)('%s draws the shared sheet in its own colour, aria-hidden', (kind) => { - const { container } = render() + const types: FileTypeKind[] = [ + 'code', 'excel', 'folder', 'html', 'image', 'markdown', 'other', 'pdf', 'ppt', 'video', 'word', + ] + + it.each(types)('%s renders a distinct aria-hidden svg without literal colors', (type) => { + const { container } = render() const svg = container.querySelector('svg')! + expect(svg).not.toBeNull() expect(svg.getAttribute('aria-hidden')).toBe('true') - expect(svg.getAttribute('width')).toBe('28') - expect(svg.querySelector('path')!.getAttribute('fill')).toMatch(/^#[0-9A-F]{6}$/) + expect(svg.classList.contains(css.icon!)).toBe(true) + expect(svg.classList.contains(css[type]!)).toBe(true) + const markup = container.innerHTML + expect(markup).not.toMatch(/#[0-9a-fA-F]{3,8}/) + const paints = [...container.querySelectorAll('[fill], [stroke]')].flatMap(node => + ['fill', 'stroke'].map(attribute => node.getAttribute(attribute)).filter(value => value !== null), + ) + expect(paints).toContain('currentColor') + expect(paints.every(value => [ + 'currentColor', 'none', 'var(--dsw-static-neutral-00)', 'var(--dsw-static-neutral-400)', + ].includes(value))).toBe(true) }) - it('every kind carries a distinct mark', () => { - const marks = KINDS.map((kind) => { - const { container } = render() - const [, mark] = Array.from(container.querySelectorAll('path, rect')) - return `${mark!.getAttribute('d') ?? ''}${mark!.getAttribute('y') ?? ''}` + it.each(types)('%s uses a solid sheet fill', (type) => { + const { container } = render() + const sheet = container.querySelector('svg > path')! + expect(sheet.getAttribute('fill')).toBe('currentColor') + expect(sheet.getAttribute('fill-opacity')).toBeNull() + }) + + it('uses white marks and fold for coloured sheets, with a darker grey fold for other', () => { + const coloured = render().container + expect(coloured.querySelector('[data-file-type-mark]')?.getAttribute('color')) + .toBe('var(--dsw-static-neutral-00)') + expect(coloured.querySelector('svg > path:nth-of-type(2)')?.getAttribute('fill')) + .toBe('var(--dsw-static-neutral-00)') + + const other = render().container + expect(other.querySelector('svg > path:nth-of-type(2)')?.getAttribute('fill')) + .toBe('var(--dsw-static-neutral-400)') + }) + + it('draws one distinct glyph for every category', () => { + const paths = types.map((type) => { + const { container } = render() + return [...container.querySelectorAll('path')].map(path => path.getAttribute('d')).join('|') }) - expect(new Set(marks).size).toBe(KINDS.length) + expect(new Set(paths).size).toBe(types.length) }) - it('folds a darker corner on the grey sheet and a translucent one elsewhere', () => { - const grey = render().container.querySelectorAll('path') - expect(grey[grey.length - 1]!.getAttribute('fill')).toBe('#A2A4A6') - const blue = render().container.querySelectorAll('path') - expect(blue[blue.length - 1]!.getAttribute('fill-opacity')).toBe('0.7') + it.each(['code', 'folder', 'html', 'image', 'video'] as const)( + 'enlarges the %s center mark without scaling the file shell', + (type) => { + const { container } = render() + expect(container.querySelector('[data-file-type-mark]')?.getAttribute('transform')).toContain('scale(1.12)') + expect(container.querySelector('svg > path')?.getAttribute('transform')).toBeNull() + }, + ) + + it.each(['excel', 'markdown', 'pdf', 'ppt', 'word'] as const)( + 'gives the %s center mark the larger emphasis scale', + (type) => { + const { container } = render() + expect(container.querySelector('[data-file-type-mark]')?.getAttribute('transform')).toContain('scale(1.22)') + expect(container.querySelector('svg > path')?.getAttribute('transform')).toBeNull() + }, + ) + + it('keeps the generic file glyph without an invented center mark', () => { + const { container } = render() + expect(container.querySelector('[data-file-type-mark]')).toBeNull() }) - it('size and className land on the svg', () => { - const { container } = render() - const svg = container.querySelector('svg')! - expect(svg.getAttribute('width')).toBe('16') - expect(svg.getAttribute('height')).toBe('16') + it('classifies the path by default and forwards size and className', () => { + const inferred = render() + const explicit = render() + expect(inferred.container.innerHTML).toBe(explicit.container.innerHTML) + expect(inferred.container.querySelector('svg')!.getAttribute('width')).toBe('28') + + const sized = render() + const svg = sized.container.querySelector('svg')! + expect(svg.getAttribute('width')).toBe('20') + expect(svg.getAttribute('height')).toBe('20') expect(svg.classList.contains('x')).toBe(true) }) }) diff --git a/packages/client/ui-primitives/tests/link-icon.client.spec.tsx b/packages/client/ui-primitives/tests/link-icon.client.spec.tsx index 170ba6f726..8b1152a65e 100644 --- a/packages/client/ui-primitives/tests/link-icon.client.spec.tsx +++ b/packages/client/ui-primitives/tests/link-icon.client.spec.tsx @@ -12,9 +12,13 @@ describe('classifyLinkPath', () => { ['data/export.CSV', 'code'], ['shots/hero.png', 'image'], ['report.pdf', 'document'], + ['budget.xlsx', 'document'], ['deck.pptx', 'document'], + ['notes.md', 'other'], + ['clip.mp4', 'other'], ['notes.unknownext', 'other'], ['Makefile', 'other'], + ['README', 'other'], ['C:\\work\\summary.docx', 'document'], ['archive.tar/.hidden', 'other'], ] as [string, LinkIconKind][])('%s → %s', (path, kind) => { diff --git a/packages/client/ui-sidebar-documentpreview/tests/text-title.client.spec.tsx b/packages/client/ui-sidebar-documentpreview/tests/text-title.client.spec.tsx index 564a491c9e..8b3913ebeb 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/text-title.client.spec.tsx +++ b/packages/client/ui-sidebar-documentpreview/tests/text-title.client.spec.tsx @@ -21,8 +21,8 @@ describe('TextTitle', () => { }) it('picks the sheet from the title\'s extension', () => { - const markdown = render().container.querySelector('path')?.getAttribute('fill') - const pdf = render().container.querySelector('path')?.getAttribute('fill') + const markdown = render().container.querySelector('svg')?.innerHTML + const pdf = render().container.querySelector('svg')?.innerHTML expect(markdown).not.toBe(pdf) }) }) diff --git a/packages/client/ui-sidebar-files/tests/files-body.client.spec.tsx b/packages/client/ui-sidebar-files/tests/files-body.client.spec.tsx index d9c8309481..51bf217312 100644 --- a/packages/client/ui-sidebar-files/tests/files-body.client.spec.tsx +++ b/packages/client/ui-sidebar-files/tests/files-body.client.spec.tsx @@ -53,6 +53,9 @@ describe('FilesBody', () => { expect(path?.getAttribute('title')).toBe(ROOT) expect([...path?.querySelectorAll('span > span') ?? []].map(span => span.textContent)).toEqual(['/work/', 'app']) expect(names(view.container)).toEqual([`${ROOT}/src`, `${ROOT}/.env`, `${ROOT}/pipe`, `${ROOT}/README.md`]) + const envIcon = view.container.querySelector(`[data-files-path="${ROOT}/.env"] svg`)?.innerHTML + const readmeIcon = view.container.querySelector(`[data-files-path="${ROOT}/README.md"] svg`)?.innerHTML + expect(envIcon).not.toBe(readmeIcon) }) it('heads a separator-only root by the root itself, since it has no final segment', async () => { From 1eee75b424835dfb5d2766a953e88013a513ac14 Mon Sep 17 00:00:00 2001 From: yixiangihsiang Date: Wed, 9 Sep 2026 16:37:44 +0800 Subject: [PATCH 022/111] fix(client): satisfy icon validation gates --- .../client/ui-primitives/src/CodeFileIcon.tsx | 52 +++++++++++++------ .../ui-primitives/tests/icons.client.spec.tsx | 15 +++++- 2 files changed, 50 insertions(+), 17 deletions(-) diff --git a/packages/client/ui-primitives/src/CodeFileIcon.tsx b/packages/client/ui-primitives/src/CodeFileIcon.tsx index 2e32bee8b0..a6f5608153 100644 --- a/packages/client/ui-primitives/src/CodeFileIcon.tsx +++ b/packages/client/ui-primitives/src/CodeFileIcon.tsx @@ -3,6 +3,26 @@ import type { ReactNode } from 'react' import type { IconProps } from './icons/props.ts' import type { CodeFileType } from './code-file-types.ts' +/** Non-localizable letterforms that are part of the source-format artwork. */ +const CODE_MARKS = { + c: 'C', + cpp: 'C', + csharp: 'C', + css: 'CSS', + env: 'ENV', + erlang: 'E', + ini: 'INI', + javascript: 'JS', + objectiveC: 'OC', + perl: 'PL', + php: 'PHP', + r: 'R', + toml: 'TOML', + typescript: 'TS', + yaml: 'YAML', + zig: 'ZIG', +} as const + /** * Render one full-color square code-file glyph from the embedded icon set. * @param props - Detailed code type, optional size, and optional CSS class. @@ -33,7 +53,7 @@ function codeFileArtwork(type: CodeFileType, gradientId: string): ReactNode { - {'C'} + {CODE_MARKS.c} ) @@ -63,7 +83,7 @@ function codeFileArtwork(type: CodeFileType, gradientId: string): ReactNode { - {'C'} + {CODE_MARKS.cpp} @@ -74,7 +94,7 @@ function codeFileArtwork(type: CodeFileType, gradientId: string): ReactNode { - {'C'} + {CODE_MARKS.csharp} @@ -84,7 +104,7 @@ function codeFileArtwork(type: CodeFileType, gradientId: string): ReactNode { <> - {'CSS'} + {CODE_MARKS.css} ) @@ -125,7 +145,7 @@ function codeFileArtwork(type: CodeFileType, gradientId: string): ReactNode { <> - {'ENV'} + {CODE_MARKS.env} ) @@ -135,7 +155,7 @@ function codeFileArtwork(type: CodeFileType, gradientId: string): ReactNode { - {'E'} + {CODE_MARKS.erlang} ) @@ -192,7 +212,7 @@ function codeFileArtwork(type: CodeFileType, gradientId: string): ReactNode { <> - {'INI'} + {CODE_MARKS.ini} ) @@ -209,7 +229,7 @@ function codeFileArtwork(type: CodeFileType, gradientId: string): ReactNode { <> - {'JS'} + {CODE_MARKS.javascript} ) @@ -266,7 +286,7 @@ function codeFileArtwork(type: CodeFileType, gradientId: string): ReactNode { <> - {'OC'} + {CODE_MARKS.objectiveC} ) @@ -275,7 +295,7 @@ function codeFileArtwork(type: CodeFileType, gradientId: string): ReactNode { <> - {'PL'} + {CODE_MARKS.perl} ) @@ -285,7 +305,7 @@ function codeFileArtwork(type: CodeFileType, gradientId: string): ReactNode { - {'PHP'} + {CODE_MARKS.php} ) @@ -351,7 +371,7 @@ function codeFileArtwork(type: CodeFileType, gradientId: string): ReactNode { - {'R'} + {CODE_MARKS.r} ) @@ -405,7 +425,7 @@ function codeFileArtwork(type: CodeFileType, gradientId: string): ReactNode { <> - {'TOML'} + {CODE_MARKS.toml} ) @@ -414,7 +434,7 @@ function codeFileArtwork(type: CodeFileType, gradientId: string): ReactNode { <> - {'TS'} + {CODE_MARKS.typescript} ) @@ -445,7 +465,7 @@ function codeFileArtwork(type: CodeFileType, gradientId: string): ReactNode { <> - {'YAML'} + {CODE_MARKS.yaml} ) @@ -455,7 +475,7 @@ function codeFileArtwork(type: CodeFileType, gradientId: string): ReactNode { - {'ZIG'} + {CODE_MARKS.zig} ) diff --git a/packages/client/ui-primitives/tests/icons.client.spec.tsx b/packages/client/ui-primitives/tests/icons.client.spec.tsx index 1b9a9c7773..62cced1199 100644 --- a/packages/client/ui-primitives/tests/icons.client.spec.tsx +++ b/packages/client/ui-primitives/tests/icons.client.spec.tsx @@ -3,7 +3,7 @@ import { cleanup, render } from '@testing-library/react' import { afterEach, describe, expect, it } from 'vitest' import * as primitives from '@deepseek-ai/dsh-client-ui-primitives' import { - IconAlarmClockOutline16, IconApiOutline14, IconArchiveOutline20, IconFolderClose16, + DocumentFileIcon, IconAlarmClockOutline16, IconApiOutline14, IconArchiveOutline20, IconFolderClose16, IconGoalOutline16, IconSendOutline16, } from '@deepseek-ai/dsh-client-ui-primitives' @@ -70,6 +70,19 @@ describe('FishLogo', () => { }) }) +describe('DocumentFileIcon', () => { + it('renders the generic document artwork with instance-safe gradients and its class', () => { + const { container } = render(<>) + const svgs = [...container.querySelectorAll('svg')] + const ids = [...container.querySelectorAll('linearGradient')].map(gradient => gradient.id) + expect(svgs).toHaveLength(2) + expect(svgs[0]?.classList.contains('document')).toBe(true) + expect(new Set(ids).size).toBe(2) + expect([...container.querySelectorAll('svg > path:first-child')].map(path => path.getAttribute('fill'))) + .toEqual(ids.map(id => `url(#${id})`)) + }) +}) + describe('BrandWordmark', () => { it('can render the name artwork with or without its leading mark', () => { const view = render() From 00bfdfbce633076dd5510c91f3d328ccf6cfcdfc Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 9 Sep 2026 16:44:51 +0800 Subject: [PATCH 023/111] fix(present): follow session filesystem access for deliverables --- ...8-present-workspace-source-files.i18n.yaml | 4 +- ...26-09-08-present-workspace-source-files.md | 10 +-- ...09-08-present-workspace-source-files.zh.md | 8 +-- ...-09-09-present-filesystem-access.i18n.yaml | 6 ++ .../2026-09-09-present-filesystem-access.md | 27 ++++++++ ...2026-09-09-present-filesystem-access.zh.md | 27 ++++++++ docs/config-catalog.md | 2 +- docs/persistence-catalog.md | 2 +- docs/tool-catalog.md | 4 +- .../client/ui-deliverables/README.i18n.yaml | 4 +- packages/client/ui-deliverables/README.md | 8 +-- packages/client/ui-deliverables/README.zh.md | 8 +-- packages/client/ui-deliverables/package.json | 7 +- .../src/client/PresentedFileCard.tsx | 2 +- .../ui-deliverables/src/client/locales.ts | 2 + .../src/client/present-open.ts | 6 +- packages/client/ui-deliverables/src/index.ts | 6 +- .../ui-deliverables/src/present-open.ts | 31 +++++---- .../tests/present-open.client.spec.ts | 9 +++ .../tests/present-open.host.spec.ts | 68 +++++++++++++++---- .../ui-deliverables/tests/prompt.host.spec.ts | 2 + .../client/ui-deliverables/tsconfig.host.json | 9 +++ packages/fs/tool-present/README.i18n.yaml | 4 +- packages/fs/tool-present/README.md | 12 ++-- packages/fs/tool-present/README.zh.md | 12 ++-- packages/fs/tool-present/src/index.ts | 10 +-- packages/fs/tool-present/src/types.ts | 6 +- .../fs/tool-present/tests/present.spec.ts | 32 ++++++++- pnpm-lock.yaml | 15 ++++ 29 files changed, 258 insertions(+), 85 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-09-09-present-filesystem-access.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-09-09-present-filesystem-access.md create mode 100644 .agents/notes/implemented/feature/2026-09-09-present-filesystem-access.zh.md diff --git a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.i18n.yaml b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.i18n.yaml index 715341ab78..9d3acb0e07 100644 --- a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.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-present-workspace-source-files.md -2026-09-08-present-workspace-source-files.md: b8956b17a20090847da6de3c301254a73701e6f2 -2026-09-08-present-workspace-source-files.zh.md: 2e82f4e27a99f484005243a4769f3aadad290666 +2026-09-08-present-workspace-source-files.md: 650c16fe5a614ce1ccf118744a5f0e8cb5d19f4b +2026-09-08-present-workspace-source-files.zh.md: 61bca82c83ff42600a7017039a6be4c3a4605769 diff --git a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md index b8956b17a2..650c16fe5a 100644 --- a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md +++ b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md @@ -10,15 +10,15 @@ Users need to open and edit the files produced in their workspace, including she ## Decision -The [present tool](../../../../packages/fs/tool-present/README.md) declares existing regular files inside the calling Session's workspace. It records paths and optional descriptions without reading or copying contents. The [deliverables plugin](../../../../packages/client/ui-deliverables/README.md) opens current workspace sources in the Host's default application. Edits are visible on the next open; deletion or movement makes the declaration unavailable. File-content preservation and copy-on-write storage are deferred until a persistence design owns them. +The [present tool](../../../../packages/fs/tool-present/README.md) declares existing regular source files under the [Session filesystem access policy](2026-09-09-present-filesystem-access.md). It records paths and optional descriptions without reading or copying contents. The [deliverables plugin](../../../../packages/client/ui-deliverables/README.md) opens current workspace sources in the Host's default application. Edits are visible on the next open; deletion or movement makes the declaration unavailable. File-content preservation and copy-on-write storage are deferred until a persistence design owns them. The tool description requires `present` after writing a file the user asked to receive and before the final response, including files created through Bash or code execution. A prose path reference does not replace the call. The recorded [SVG delivery scenario](../../../../snapshots/web/present-svg/snapshot.yml) uses a user request that does not name `present`, and checks the resulting file, delivery event, and card. Its UI snapshot covers the expanded Chat transcript; navigation and composer controls belong to their own scenarios, so unrelated chrome changes cannot invalidate file-delivery expectations. The tool remains an ordinary package with shared filesystem and tool error classes. Its pure type entry owns the delivery event without importing Host code into the browser. The `standard`, `ptc`, and `cordis` presets mount it; `minimal` retains its two tools. Each plugin instance correlates its executions with successful final `tools/result` notifications before appending `deliverables/presented`. Native and nested calls share this rule. A later enclosing program failure does not revoke a completed nested declaration; blocked results publish none, and same-name scoped replacements cannot publish another instance's results. -An authenticated POST selects a declaration by viewed Session, event sequence, and original file index. The event carries no owning Session ID; relative paths in inherited history resolve against the viewed Session's workspace. The Host rechecks canonical workspace containment and regular-file existence before native opening. Route disposal cancels and awaits pending commands. The “Files changed” row lists successful file-tool mutations and retains its separate text-preview behavior. Its Chinese label is “本轮文件改动”; neither label implies final delivery. +An authenticated POST selects a declaration by viewed Session, event sequence, and original file index. The event carries no owning Session ID; relative paths in inherited history resolve against the viewed Session's workspace. The Host verifies regular-file existence and Host-path mapping before native opening. Route disposal cancels and awaits pending commands. The “Files changed” row lists successful file-tool mutations and retains its separate text-preview behavior. Its Chinese label is “本轮文件改动”; neither label implies final delivery. -File cards use the same split-control pattern as the Session header. The card and the left Open segment preview the source in the right Sidebar; the chevron opens the standard menu for default-app and file-manager actions. The Host selects the file in Finder or Explorer, or opens its containing folder through the default Linux file manager. Both native actions resolve the same saved declaration and recheck workspace containment; neither accepts a browser-supplied replacement path. Host-derived desktop metadata keeps remote-browser labels and availability honest, and the route enforces the configured availability on each native gesture. One delivery spans the row; multiple deliveries use at most two columns, retain every declaration, and collapse after the first four cards until the user expands the list. Desktop metadata is invalidated with the connection generation so an old Host cannot keep native actions disabled or supply the wrong file-manager labels. Old metadata requests are cancelled and cannot replace the new generation’s response. +File cards use the same split-control pattern as the Session header. The card and the left Open segment preview the source in the right Sidebar; the chevron opens the standard menu for default-app and file-manager actions. The Host selects the file in Finder or Explorer, or opens its containing folder through the default Linux file manager. Both native actions resolve the same saved declaration and verify the Session filesystem and Host path; neither accepts a browser-supplied replacement path. Host-derived desktop metadata keeps remote-browser labels and availability honest, and the route enforces the configured availability on each native gesture. One delivery spans the row; multiple deliveries use at most two columns, retain every declaration, and collapse after the first four cards until the user expands the list. Desktop metadata is invalidated with the connection generation so an old Host cannot keep native actions disabled or supply the wrong file-manager labels. Old metadata requests are cancelled and cannot replace the new generation’s response. ## Alternatives considered @@ -30,7 +30,7 @@ File cards use the same split-control pattern as the Session header. The card an **Tool text as the durable index** cannot survive post-processing or result spill reliably. Execution identity and final successful results retain declaration ownership independently of displayed tool text. -**Descriptor-bound filesystem extensions** would change every provider without making an external desktop application's later path lookup atomic. Current checks reject ordinary escapes; concurrent swap-and-restore remains outside the path API's guarantees. +**Descriptor-bound filesystem extensions** would change every provider without making an external desktop application's later path lookup atomic. Current checks verify file metadata and path mapping; concurrent swap-and-restore remains outside the path API's guarantees. ## Consequences @@ -38,4 +38,4 @@ The Session log persists declarations but no attachment references or file conte The removed file-size cap has no role in a metadata-only declaration; the configurable file-count limit still bounds result size. Cards show file names and descriptions, falling back to file types, without stale byte-size metadata. No artifact service or speculative storage fallback is introduced. -Focused tests cover content-free declarations, invalid inputs, blocked results, source-path identity, current bytes after edits, missing files, workspace escapes, fork-relative paths, retry, cancellation, and disposal. The recorded Web scenario covers nested completion followed by enclosing failure, source edits, reload, deletion errors, card and prose opens without browser downloads, and content-free Session export. +Focused tests cover content-free declarations, invalid inputs, blocked results, source-path identity, current bytes after edits, missing files, external paths and unavailable Host mappings, fork-relative paths, retry, cancellation, and disposal. The recorded Web scenario covers nested completion followed by enclosing failure, source edits, reload, deletion errors, card and prose opens without browser downloads, and content-free Session export. diff --git a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md index 2e82f4e27a..61bca82c83 100644 --- a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md +++ b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md @@ -10,15 +10,15 @@ Status: implemented ## 决策 -[present 工具](../../../../packages/fs/tool-present/README.zh.md)声明交付调用方 Session 工作区中已存在的普通文件。它记录路径和可选说明,不读取或复制内容。[交付插件](../../../../packages/client/ui-deliverables/README.zh.md)使用 Host 默认应用打开当前工作区源文件。下次打开会看到编辑后的内容;删除或移动文件会使声明不可用。文件内容保留与写时复制存储延期到有持久化设计负责时实现。 +[present 工具](../../../../packages/fs/tool-present/README.zh.md)声明交付[Session 文件系统访问策略](2026-09-09-present-filesystem-access.zh.md)允许的已有普通源文件。它记录路径和可选说明,不读取或复制内容。[交付插件](../../../../packages/client/ui-deliverables/README.zh.md)使用 Host 默认应用打开当前工作区源文件。下次打开会看到编辑后的内容;删除或移动文件会使声明不可用。文件内容保留与写时复制存储延期到有持久化设计负责时实现。 工具说明要求在写好用户要求接收的文件后、最终回复前调用 `present`,包括通过 Bash 或代码执行创建的文件。正文中的路径引用不能替代调用。录制的 [SVG 交付场景](../../../../snapshots/web/present-svg/snapshot.yml)使用未提及 `present` 的用户请求,检查生成文件、交付事件和卡片。其 UI 快照覆盖展开后的 Chat 对话内容;导航和输入框控件由各自场景负责,避免无关界面改动使文件交付预期失效。 工具保持为普通包,共享文件系统和工具错误类型。其纯类型入口拥有交付事件,不向浏览器导入 Host 代码。`standard`、`ptc` 与 `cordis` preset 挂载工具;`minimal` 保持两个工具。每个插件实例将其执行与成功的最终 `tools/result` 通知关联,再追加 `deliverables/presented`。原生与嵌套调用遵循同一规则。外层程序随后失败不会撤销已完成的嵌套声明;被阻止的结果不发布声明,同名作用域替换也不能发布其他实例的结果。 -经过认证的 POST 按当前查看的 Session、事件序号和原始文件索引选择声明。事件不携带所属 Session ID;继承历史中的相对路径按当前查看的 Session 工作区解析。Host 在原生打开前重新检查规范路径的工作区包含关系和普通文件是否存在。路由释放时取消并等待进行中的命令。“本轮文件改动”行列出成功的文件工具修改,并保留独立的文本预览行为。其英文标签为“Files changed”;两个标签均不表示最终交付。 +经过认证的 POST 按当前查看的 Session、事件序号和原始文件索引选择声明。事件不携带所属 Session ID;继承历史中的相对路径按当前查看的 Session 工作区解析。Host 在原生打开前检查普通文件是否存在,并验证 Host 路径映射。路由释放时取消并等待进行中的命令。“本轮文件改动”行列出成功的文件工具修改,并保留独立的文本预览行为。其英文标签为“Files changed”;两个标签均不表示最终交付。 -文件卡片采用与 Session 顶栏相同的分段控件。点击卡片或左侧“打开”区域会在右侧 Sidebar 预览源文件;右侧箭头打开包含默认应用与文件管理器操作的标准菜单。Host 在 Finder 或文件资源管理器中选中文件,或通过 Linux 默认文件管理器打开所在文件夹。两个原生操作都解析同一份已保存声明并重新检查工作区包含关系;均不接受浏览器提供的替代路径。来自 Host 的桌面信息使远程浏览器中的文案和可用性保持准确,路由在每次原生操作时执行配置的可用性检查。单个交付占满整行;多个交付每行最多两列,并保留所有声明,前四张卡片之后的内容在用户展开列表前保持收起。 桌面元数据随连接代次失效,避免旧主机信息让原生操作持续禁用或显示错误的文件管理器名称。旧元数据请求会被取消,不能覆盖新代次的响应。 +文件卡片采用与 Session 顶栏相同的分段控件。点击卡片或左侧“打开”区域会在右侧 Sidebar 预览源文件;右侧箭头打开包含默认应用与文件管理器操作的标准菜单。Host 在 Finder 或文件资源管理器中选中文件,或通过 Linux 默认文件管理器打开所在文件夹。两个原生操作都解析同一份已保存声明并验证 Session 文件系统与 Host 路径;均不接受浏览器提供的替代路径。来自 Host 的桌面信息使远程浏览器中的文案和可用性保持准确,路由在每次原生操作时执行配置的可用性检查。单个交付占满整行;多个交付每行最多两列,并保留所有声明,前四张卡片之后的内容在用户展开列表前保持收起。 桌面元数据随连接代次失效,避免旧主机信息让原生操作持续禁用或显示错误的文件管理器名称。旧元数据请求会被取消,不能覆盖新代次的响应。 ## 考虑过的替代方案 @@ -30,7 +30,7 @@ Status: implemented **以工具文本作为持久索引**无法可靠应对后处理或结果溢出。执行身份与最终成功结果使声明归属独立于展示的工具文本。 -**绑定文件描述符的文件系统扩展**会改动所有提供方,却无法使外部桌面应用随后按路径打开的动作原子化。当前检查拒绝普通越界;并发替换后复原仍不在路径 API 的保证范围内。 +**绑定文件描述符的文件系统扩展**会改动所有提供方,却无法使外部桌面应用随后按路径打开的动作原子化。当前检查验证文件元数据和路径映射;并发替换后复原仍不在路径 API 的保证范围内。 ## 影响 diff --git a/.agents/notes/implemented/feature/2026-09-09-present-filesystem-access.i18n.yaml b/.agents/notes/implemented/feature/2026-09-09-present-filesystem-access.i18n.yaml new file mode 100644 index 0000000000..4a87c67631 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-09-present-filesystem-access.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-09-present-filesystem-access.md +2026-09-09-present-filesystem-access.md: 24b0e767d96261a95e449278d0919c381141f50d +2026-09-09-present-filesystem-access.zh.md: 2f4dc550823bd6c8de6ad61c113d58e3f238bbb5 diff --git a/.agents/notes/implemented/feature/2026-09-09-present-filesystem-access.md b/.agents/notes/implemented/feature/2026-09-09-present-filesystem-access.md new file mode 100644 index 0000000000..24b0e767d9 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-09-present-filesystem-access.md @@ -0,0 +1,27 @@ +# Agent Note: Present follows Session filesystem access + +Status: implemented + +English | [中文](2026-09-09-present-filesystem-access.zh.md) + +## Problem + +Generated files commonly live outside the workspace, especially in `/tmp`. Workspace containment rejects files that the Session filesystem and Sidebar already allow. A provider process path may also name a remote file rather than a file on the serving Host. + +## Decision + +`present` accepts existing regular files accessible through its composed `ctx.fs`, with relative paths resolved against the Session working directory. There is no workspace containment check or special temporary-directory allowlist. Missing files, directories, final symbolic links, and provider failures reject the declaration. A sandbox's private temporary files remain unavailable when the filesystem provider cannot see them. + +Native actions resolve or resume the viewed Session's Agent and use its `workspaceFiles.stat`. The resulting canonical process path must map from a Host path back to the same process path through that Agent's filesystem. Absent or different mappings produce 422 and a localized Sidebar-preview suggestion. This conservatively supports Host paths that share their canonical process spelling; providers with only a nonidentity Host mapping can still serve previews. A same-named local file never substitutes for an unmapped provider file. + +This replaces the workspace-only access rule in the [source-file delivery decision](2026-09-08-present-workspace-source-files.md). That note continues to own content-free declarations, Session events, and editing current sources. The request still selects only saved Session/event/file coordinates, never an arbitrary browser-supplied path. + +## Alternatives considered + +A `/tmp` allowlist excludes other readable output locations and duplicates filesystem policy. Treating every provider process path as a Host path can open an unrelated local file. Adding a generic inverse path-mapping API or copying remote files expands provider and retention responsibilities beyond source-file delivery. + +## Consequences + +Workspace files, accessible temporary files, Downloads, and files in another project use the same declaration rules. Native opening requires both a serving desktop and a verified Host path. Metadata checks do not make a desktop application's later path lookup atomic. + +Focused tests cover external absolute and relative paths, final symbolic links, missing files, Session lookup failures, absent and mismatched Host mappings, and localized native-unavailable state. Existing recorded Web scenarios retain declaration, preview, native action, and content-free export coverage. diff --git a/.agents/notes/implemented/feature/2026-09-09-present-filesystem-access.zh.md b/.agents/notes/implemented/feature/2026-09-09-present-filesystem-access.zh.md new file mode 100644 index 0000000000..2f4dc55082 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-09-present-filesystem-access.zh.md @@ -0,0 +1,27 @@ +# Agent Note:Present 遵循 Session 文件系统访问规则 + +Status: implemented + +[English](2026-09-09-present-filesystem-access.md) | 中文 + +## 问题 + +生成文件经常位于工作区外,尤其是 `/tmp`。工作区包含检查会拒绝 Session 文件系统和侧栏已经允许访问的文件。提供方的进程路径也可能指向远端文件,而非服务 Host 上的文件。 + +## 决策 + +`present` 接受所组合的 `ctx.fs` 可访问的已有普通文件,相对路径按 Session 工作目录解析。不检查工作区包含关系,也不另设临时目录白名单。文件缺失、为目录、最终路径为符号链接或提供方出错时,声明失败。文件系统提供方不可见的沙箱私有临时文件仍不可用。 + +原生操作查找或恢复当前查看的 Session 的 Agent,并调用其 `workspaceFiles.stat`。得到的规范化进程路径必须能通过该 Agent 的文件系统从 Host 路径映射回同一进程路径。映射缺失或不同会返回 422,并显示使用侧栏预览的本地化提示。这只支持规范化后与进程路径写法相同的 Host 路径;仅支持不同写法的 Host 映射的提供方仍可提供预览。没有映射时,本机同名文件不能替代提供方文件。 + +本决策替代[源文件交付决策](2026-09-08-present-workspace-source-files.zh.md)中只允许工作区文件的访问规则。原说明继续负责不保存内容的声明、Session 事件和编辑当前源文件。请求仍只按已保存的 Session、事件和文件索引选择文件,不接受浏览器提供的任意路径。 + +## 考虑过的替代方案 + +`/tmp` 白名单会排除其他可读的输出位置,并重复文件系统策略。把所有提供方进程路径都视作 Host 路径可能打开不相关的本机文件。增加通用反向路径映射 API 或复制远端文件,会让提供方与文件保留承担源文件交付之外的职责。 + +## 影响 + +工作区文件、可访问的临时文件、Downloads 和其他项目中的文件使用相同的声明规则。原生打开同时要求服务主机有可用桌面和经过验证的 Host 路径。元数据检查不能使桌面应用随后按路径打开文件的操作具有原子性。 + +定向测试覆盖工作区外的绝对和相对路径、最终符号链接、文件缺失、Session 查询失败、Host 映射缺失或不一致,以及本地化的原生操作不可用状态。已有的 Web 录制场景继续覆盖声明、预览、原生操作和不含文件内容的导出。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 7b677ae08d..e187952809 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -3459,7 +3459,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-client-ui-commands` ([`packages/client/ui-commands/src/index.ts`](../packages/client/ui-commands/src/index.ts)) - `@deepseek-ai/dsh-client-ui-conversation` ([`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts)) - `@deepseek-ai/dsh-client-ui-cordis` ([`packages/extensions/ui-cordis/src/index.ts`](../packages/extensions/ui-cordis/src/index.ts)) -- `@deepseek-ai/dsh-client-ui-deliverables` — requires `systemPrompt` · `connection` · `sessionQuery` · `sessionController` ([`packages/client/ui-deliverables/src/index.ts`](../packages/client/ui-deliverables/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-deliverables` — requires `systemPrompt` · `connection` · `sessionQuery` · `sessionController` · `workspaceFiles` · `fs` ([`packages/client/ui-deliverables/src/index.ts`](../packages/client/ui-deliverables/src/index.ts)) - `@deepseek-ai/dsh-client-ui-directory-picker-browse` ([`packages/client/ui-directory-picker-browse/src/index.ts`](../packages/client/ui-directory-picker-browse/src/index.ts)) - `@deepseek-ai/dsh-client-ui-directory-picker-native` ([`packages/client/ui-directory-picker-native/src/index.ts`](../packages/client/ui-directory-picker-native/src/index.ts)) - `@deepseek-ai/dsh-client-ui-goal` ([`packages/client/ui-goal/src/index.ts`](../packages/client/ui-goal/src/index.ts)) diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index bf70879f80..46e91f23a7 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -403,7 +403,7 @@ Source: [`packages/compaction/compaction/src/types.ts:34`](../packages/compactio #### `deliverables/presented` — log-only ```ts persistence-catalog -/** Declared workspace files from a successful final present result, including nested calls. */ +/** Declared filesystem files from a successful final present result, including nested calls. */ 'deliverables/presented': { turn: number; callId: ToolCallId; files: PresentedFile[] } ``` diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 928f7537ab..c6c09aee8e 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -225,7 +225,7 @@ The bash tool is the model-facing consumer of the bash executor seam. A `run_in_ ### `present` -Declare existing workspace files as final deliverables. When a file you create or update is an output the user asked to receive, you must call present after writing it and before your final response, including files created through Bash or code execution. Mentioning its path in your reply does not replace this call. The files must already exist. The user opens the current source files; their contents are not copied or preserved. +Declare existing files accessible through the Session filesystem as final deliverables. When a file you create or update is an output the user asked to receive, you must call present after writing it and before your final response, including files created through Bash or code execution. Mentioning its path in your reply does not replace this call. The files must already exist. The user opens the current source files; their contents are not copied or preserved. ```json { @@ -239,7 +239,7 @@ Declare existing workspace files as final deliverables. When a file you create o "properties": { "path": { "type": "string", - "description": "Path of an existing file inside the workspace." + "description": "Path of an existing regular file. Relative paths use the Session working directory." }, "description": { "type": "string", diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml index ab6fce907a..12e097c4de 100644 --- a/packages/client/ui-deliverables/README.i18n.yaml +++ b/packages/client/ui-deliverables/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-deliverables/README.md -README.md: 9faea88696f3d6a7d39429e82449978647330a5f -README.zh.md: 8b2c846dffe674d2112f9b7680040a762d8b0c60 +README.md: 50677e0cfb260c7b9bd31ed9599a2d0214dda201 +README.zh.md: 2196be4fed14cb269056ce4fd83960ca4024f63a diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md index 9faea88696..50677e0cfb 100644 --- a/packages/client/ui-deliverables/README.md +++ b/packages/client/ui-deliverables/README.md @@ -30,7 +30,7 @@ Mount this plugin alongside `ui-conversation`; a finished turn then ends with th ### Explicit deliveries -The Web `standard`, `ptc`, and `cordis` presets expose `present` for final workspace files, including files created through Bash. Call it with `files: [{ path, description? }]` after creating the files. The [present tool](../../fs/tool-present/README.md) owns file-count limits and Session declarations. The closing turn shows one delivery as a full-width card and multiple deliveries in a grid of at most two cards per row. A list longer than four files starts collapsed and provides a control that reveals or hides the complete list. Each card shows the basename and description, or the file type when no description exists; a trailing parenthesized suffix in the description is omitted, and hovering the card replaces that line with the Sidebar-preview action. Clicking the card or the left side of its split Open control previews the file in the right Sidebar. The chevron opens the standard menu for the Host default application plus Show in Finder on macOS, Show in File Explorer on Windows and WSL, or Open containing folder through the default Linux file manager. Matching inline-code references open the same source files without starting a browser download. Repeated declaration of a path selects its latest description before the closing reply. +The Web `standard`, `ptc`, and `cordis` presets expose `present` for final files accessible through the Session filesystem, including files created through Bash. Call it with `files: [{ path, description? }]` after creating the files. The [present tool](../../fs/tool-present/README.md) owns file-count limits and Session declarations. The closing turn shows one delivery as a full-width card and multiple deliveries in a grid of at most two cards per row. A list longer than four files starts collapsed and provides a control that reveals or hides the complete list. Each card shows the basename and description, or the file type when no description exists; a trailing parenthesized suffix in the description is omitted, and hovering the card replaces that line with the Sidebar-preview action. Clicking the card or the left side of its split Open control previews the file in the right Sidebar. The chevron opens the standard menu for the Host default application plus Show in Finder on macOS, Show in File Explorer on Windows and WSL, or Open containing folder through the default Linux file manager. Matching inline-code references open the same source files without starting a browser download. Repeated declaration of a path selects its latest description before the closing reply. The `present` tool row shows running, delivered, failed, or interrupted status; expanding a settled row reveals its recorded result. The collapsible card grid retains every delivered file. Both menu actions share pending state and show progress, acknowledgement, or an action-specific retryable error. Desktop information is read when delivery cards appear and invalidated on connection replacement; responses from a replaced connection cannot publish metadata. Selecting a native menu action returns keyboard focus to the available Sidebar Open button. Pending actions close the menu until another explicit gesture. A missing desktop disables the Open menu; a failed desktop-information read offers Retry. It requires a desktop and a suitable default application on the serving Host; a remote browser does not open applications on its own device. @@ -52,7 +52,7 @@ The closing prose carries the same vocabulary: an inline-code token resolves by The Node half registers the static `ui:deliverable-file-references` system-prompt section asking the model to mention primary files from successful creation or modification calls and to write those and any other changed-file references as Markdown inline code. The browser half registers a wrapper around `ProducedFiles` and explicit deliveries into the chat view's `conversation.chat.turnTail` hole. `deliverablesDefinition` folds each Turn's successful first-party mutation calls into `DeliverablesTurnData` from the validated raw arguments of `write`, `edit`, and mutating `str_replace_editor` commands. Reads, deletes, unsupported tools, malformed calls, and failed results contribute nothing. A new mutation tool needs an explicit Client contribution before it joins the list. The package also provides the `chatFileMentions` service the chat view consults per closing message; composing the plugin out removes both surfaces and leaves the view's empty chain at zero cost. -Native opening uses an authenticated POST addressed by the viewed Session, event sequence, and original file index. The Host resolves the declaration against that Session’s workspace and checks the current file exists within it before launching either native action. The same configured desktop availability governs metadata and execution. Edits affect subsequent opens; deletion returns an error. No file-content copy or attachment is created. Plugin disposal cancels and awaits pending native-open requests. +Native opening uses an authenticated POST addressed by the viewed Session, event sequence, and original file index. The Host resolves or resumes that Session’s Agent and checks the declaration through its `workspaceFiles.stat`, using the same filesystem as Sidebar previews. Native actions require the canonical process path to map from a Host path back to that same process path. Providers without this mapping return 422 and the card directs the user to Sidebar preview; a same-named Host file is insufficient. The same configured desktop availability governs metadata and execution. Edits affect subsequent opens; deletion returns an error. No file-content copy or attachment is created. Plugin disposal cancels and awaits pending native-open requests. @@ -96,7 +96,7 @@ These limits define the current deliverables vocabulary. They are current packag - **Mention matching is exact path or unique basename only** — a suffix mention stays inert; widening the matcher is deferred until a real closing-message shape needs it. - **Terminal-created files require explicit delivery** — call `present` to declare them for native opening. -- **Declarations do not preserve file contents** — reopening or transferring a Session requires the source files in the viewed Session’s workspace. Missing files return 404; paths resolving outside the workspace return 403. +- **Declarations do not preserve file contents** — reopening or transferring a Session requires source files accessible through the viewed Session’s filesystem. Missing files, directories, and final symbolic links return 404. - **Directories have no destination** — chips open files in the right Sidebar's text preview, which shows files only; the former native folder handoff is gone rather than replaced. @@ -109,4 +109,4 @@ None. -**Runtime invariant:** No companion is published. Prompt, slot, dictionary, file-action route, and optional service registrations are effect-owned; the Session log owns declarations and the workspace owns file contents. +**Runtime invariant:** No companion is published. Prompt, slot, dictionary, file-action route, and optional service registrations are effect-owned; the Session log owns declarations and the filesystem owns file contents. diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index 8b2c846dff..2196be4fed 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -30,7 +30,7 @@ kind: "package-reference" ### 显式交付 -Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交付最终工作区文件,包括通过 Bash 创建的文件。创建文件后,以 `files: [{ path, description? }]` 调用。[present 工具](../../fs/tool-present/README.zh.md)拥有文件数量限制和 Session 声明。收尾 turn 把单个交付显示为横向占满内容区的卡片,把多个交付显示为每行最多两张卡片的网格。文件超过四个时,列表默认收起,并提供显示或隐藏完整列表的控件。每张卡片显示 basename 与说明;没有说明时显示文件类型,说明末尾的括号后缀会被省略,悬停卡片时该行切换为侧栏预览提示。点击卡片或分段“打开”控件的左侧会在右侧 Sidebar 中预览文件;右侧箭头打开标准菜单,其中提供 Host 默认应用,以及 macOS 上的“在 Finder 中显示”、Windows 和 WSL 上的“在文件资源管理器中显示”或 Linux 默认文件管理器的“打开所在文件夹”。匹配的行内代码引用打开相同源文件,不触发浏览器下载。同一路径重复声明时,选择收尾回复之前最近一次的说明。 +Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交付Session 文件系统可访问的最终文件,包括通过 Bash 创建的文件。创建文件后,以 `files: [{ path, description? }]` 调用。[present 工具](../../fs/tool-present/README.zh.md)拥有文件数量限制和 Session 声明。收尾 turn 把单个交付显示为横向占满内容区的卡片,把多个交付显示为每行最多两张卡片的网格。文件超过四个时,列表默认收起,并提供显示或隐藏完整列表的控件。每张卡片显示 basename 与说明;没有说明时显示文件类型,说明末尾的括号后缀会被省略,悬停卡片时该行切换为侧栏预览提示。点击卡片或分段“打开”控件的左侧会在右侧 Sidebar 中预览文件;右侧箭头打开标准菜单,其中提供 Host 默认应用,以及 macOS 上的“在 Finder 中显示”、Windows 和 WSL 上的“在文件资源管理器中显示”或 Linux 默认文件管理器的“打开所在文件夹”。匹配的行内代码引用打开相同源文件,不触发浏览器下载。同一路径重复声明时,选择收尾回复之前最近一次的说明。 `present` 工具行显示正在交付、已交付、失败或中断状态;展开已结束的调用可查看其记录的结果。可折叠卡片网格保留全部交付文件。菜单中的两个操作共享等待状态,并显示进度、请求确认或各自可重试的错误。交付卡片出现时读取桌面信息,连接更换时清除缓存,旧连接的响应不能更新元数据。选择原生菜单操作后,键盘焦点回到仍可用的侧边栏“打开”按钮。等待操作完成时关闭菜单,用户再次点击才会打开。Host 没有桌面时禁用“打开”菜单;桌面信息读取失败时提供“重试”。服务 Host 必须具备桌面和合适的默认应用;远程浏览器不会打开其所在设备上的应用。 @@ -52,7 +52,7 @@ Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交 Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段,要求模型点名成功创建或修改的主要文件,并把这些文件以及正文中提到的其他本轮变更文件写成 Markdown 行内代码。浏览器半部把组合 `ProducedFiles` 与显式交付的包装组件注册进 chat 视图的 `conversation.chat.turnTail` 洞。`deliverablesDefinition` 根据 `write`、`edit` 和有修改作用的 `str_replace_editor` 命令中经过校验的原始参数,把每个轮次成功的第一方修改调用折叠进 `DeliverablesTurnData`。读取、删除、不受支持的工具、格式错误的调用和失败结果不贡献任何条目。新的修改工具必须增加显式 Client contribution 才能加入列表。本包还提供 chat 视图按收尾消息查询的 `chatFileMentions` 服务;把插件组合出去会同时移除两个表面,视图的空链以零成本留下。 -原生打开使用经过认证的 POST,通过当前查看的 Session、事件序号和原始文件索引定位声明。Host 按该 Session 的工作区解析路径,检查当前文件存在且位于工作区内,再启动所选原生操作。同一份桌面可用性配置同时约束信息查询和实际执行。编辑会影响后续打开的内容;删除后返回错误。不创建文件内容副本或附件。插件释放时取消并等待进行中的原生打开请求。 +原生打开使用经过认证的 POST,通过当前查看的 Session、事件序号和原始文件索引定位声明。Host 查找或恢复该 Session 的 Agent,通过其 `workspaceFiles.stat` 检查声明,与侧栏预览使用同一文件系统。原生操作要求规范化的进程路径能从 Host 路径映射回同一进程路径。提供方没有这种映射时返回 422,卡片提示使用侧栏预览;Host 上存在同名文件并不足够。同一份桌面可用性配置同时约束信息查询和实际执行。编辑会影响后续打开的内容;删除后返回错误。不创建文件内容副本或附件。插件释放时取消并等待进行中的原生打开请求。 @@ -96,7 +96,7 @@ Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段, - **提及匹配只认精确路径或唯一 basename**——后缀式提及保持惰性;等真实的收尾消息形态产生需求后再放宽匹配规则。 - **终端创建的文件需要显式交付**——调用 `present` 声明文件,以便原生打开。 -- **声明不保存文件内容**——重新打开或转移 Session 后,需要当前查看的 Session 工作区中仍有源文件。文件缺失返回 404;解析到工作区外的路径返回 403。 +- **声明不保存文件内容**:重新打开或转移 Session 后,源文件仍需能被当前查看的 Session 文件系统访问。文件缺失、为目录或最终路径为符号链接时返回 404。 - **目录没有打开目标**——标签项在右侧 Sidebar 的文本预览中打开文件,该预览仅支持文件,不提供原生文件夹打开动作。 @@ -109,4 +109,4 @@ Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段, -**运行时不变式:** 不发布伴生入口。提示词、slot、dictionary、文件操作路由与可选 service 注册归 effect 所有;Session 日志拥有声明,工作区拥有文件内容。 +**运行时不变式:** 不发布伴生入口。提示词、slot、dictionary、文件操作路由与可选 service 注册归 effect 所有;Session 日志拥有声明,文件系统拥有文件内容。 diff --git a/packages/client/ui-deliverables/package.json b/packages/client/ui-deliverables/package.json index ca55e77f16..cd71933d2f 100644 --- a/packages/client/ui-deliverables/package.json +++ b/packages/client/ui-deliverables/package.json @@ -68,7 +68,12 @@ "@deepseek-ai/dsh-client-ui-tool": "workspace:^", "@deepseek-ai/dsh-tool-present": "workspace:^", "@deepseek-ai/dsh-api-session-controller": "workspace:^", - "@deepseek-ai/dsh-util-workspace-path": "workspace:^" + "@deepseek-ai/dsh-util-workspace-path": "workspace:^", + "@deepseek-ai/dsh-api-workspace-files": "workspace:^", + "@deepseek-ai/dsh-fs": "workspace:^", + "@deepseek-ai/dsh-fs-local": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx b/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx index 73e29f5523..51946bf65a 100644 --- a/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx +++ b/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx @@ -56,7 +56,7 @@ export function PresentedFileCard({ file, cwd, phase, host, onPreview, onAction,
{name} + data-error={phase === 'error' || phase === 'revealError' || phase === 'nativeUnavailable' ? true : undefined}> {status} {t('presented.preview')} diff --git a/packages/client/ui-deliverables/src/client/locales.ts b/packages/client/ui-deliverables/src/client/locales.ts index 5b9fa7781d..1e4d78f8c1 100644 --- a/packages/client/ui-deliverables/src/client/locales.ts +++ b/packages/client/ui-deliverables/src/client/locales.ts @@ -5,6 +5,7 @@ export const NS = 'deliverables' /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { + 'presented.nativeUnavailable': '此文件没有可用的主机路径,请在侧边栏预览', 'presented.revealError': '无法在文件管理器中显示,请重试', 'presented.directoryError': '无法打开所在文件夹,请重试', 'presented.directoryOpening': '正在打开所在文件夹…', @@ -46,6 +47,7 @@ export const zh = { /** English dictionary (same key set). */ export const en: Record = { + 'presented.nativeUnavailable': 'This file has no available Host path. Preview it in the sidebar.', 'presented.revealError': 'Could not show in file manager. Try again.', 'presented.directoryError': 'Could not open containing folder. Try again.', 'presented.directoryOpening': 'Opening containing folder…', diff --git a/packages/client/ui-deliverables/src/client/present-open.ts b/packages/client/ui-deliverables/src/client/present-open.ts index bb47634011..8e66b380c0 100644 --- a/packages/client/ui-deliverables/src/client/present-open.ts +++ b/packages/client/ui-deliverables/src/client/present-open.ts @@ -4,7 +4,7 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types' import { presentedFileUrl, PRESENT_HOST_PATH, isPresentedHost, type PresentedAction, type PresentedHost } from '../presented.ts' /** State of the latest explicit open gesture for one saved file. */ -export type PresentedOpenPhase = 'opening' | 'opened' | 'revealing' | 'revealed' | 'error' | 'revealError' +export type PresentedOpenPhase = 'opening' | 'opened' | 'revealing' | 'revealed' | 'error' | 'revealError' | 'nativeUnavailable' /** One browser plugin's file-open requests, cancelled when that plugin is disposed. */ export class PresentedOpenController { @@ -18,7 +18,7 @@ export class PresentedOpenController { private readonly pending = new Set>() /** - * Open a declared workspace file once while a request for the same coordinates is pending. + * Open a declared file once while a request for the same coordinates is pending. * Failures remain visible on the card and a later gesture retries them. * @param sessionId - viewed Session, including a fork's own identity. * @param seq - durable delivery event sequence. @@ -93,7 +93,7 @@ export class PresentedOpenController { let phase: PresentedOpenPhase = action === 'open' ? 'opened' : 'revealed' try { const response = await fetch(action === 'open' ? url : `${url}&action=reveal`, { method: 'POST', signal: this.lifetime.signal }) - if (!response.ok) phase = failure + if (!response.ok) phase = response.status === 422 ? 'nativeUnavailable' : failure } catch { // Transport failures share the retryable card state with Host open failures. phase = failure diff --git a/packages/client/ui-deliverables/src/index.ts b/packages/client/ui-deliverables/src/index.ts index 4239299e63..ee4ef63a40 100644 --- a/packages/client/ui-deliverables/src/index.ts +++ b/packages/client/ui-deliverables/src/index.ts @@ -1,7 +1,7 @@ /** * Deliverables plugin, node half. Registers the response-format guidance that * lets the browser half recognize final-response file references and serves - * authenticated native opens of workspace files. The browser + * authenticated native opens of declared files. The browser * half ships via exports["./client"], discovered through the package.json * dsh.client declaration. */ @@ -10,8 +10,8 @@ import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-system-prompt' import { registerPresentOpen } from './present-open.ts' -/** Services required for file-reference guidance and authenticated native opens of workspace files. */ -export const inject = ['systemPrompt', 'connection', 'sessionQuery', 'sessionController'] +/** Services required for file-reference guidance and authenticated native opens of declared files. */ +export const inject = ['systemPrompt', 'connection', 'sessionQuery', 'sessionController', 'workspaceFiles', 'fs'] /** Stable final-response guidance owned by the matching renderer. */ const FILE_REFERENCE_PROMPT = 'When you successfully create or modify files, mention the primary outputs in your final response. ' diff --git a/packages/client/ui-deliverables/src/present-open.ts b/packages/client/ui-deliverables/src/present-open.ts index ba5880a0e9..97aab3242a 100644 --- a/packages/client/ui-deliverables/src/present-open.ts +++ b/packages/client/ui-deliverables/src/present-open.ts @@ -1,8 +1,9 @@ -/** Open declared source files inside the viewed Session's workspace. */ -import { realpath, stat } from 'node:fs/promises' -import { isAbsolute, relative, resolve, sep } from 'node:path' +/** Open declared source files verified by the viewed Session's filesystem. */ import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-api-session-controller' +import type {} from '@deepseek-ai/dsh-api-workspace-files' +import type {} from '@deepseek-ai/dsh-fs' +import { remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol' import type {} from '@deepseek-ai/dsh-client-connection' import type {} from '@deepseek-ai/dsh-session-query' import type { SessionId, SessionSeq } from '@deepseek-ai/dsh-session' @@ -53,27 +54,31 @@ async function handlePresentOpen(ctx: Context, request: Request): Promise { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 422 }))) + const controller = new PresentedOpenController() + await controller.open(id, 2, 1, action) + expect(controller.state.getSnapshot()[url]).toBe('nativeUnavailable') + await controller.dispose() +}) diff --git a/packages/client/ui-deliverables/tests/present-open.host.spec.ts b/packages/client/ui-deliverables/tests/present-open.host.spec.ts index fc12bc71d3..346ce60279 100644 --- a/packages/client/ui-deliverables/tests/present-open.host.spec.ts +++ b/packages/client/ui-deliverables/tests/present-open.host.spec.ts @@ -2,6 +2,10 @@ import { mkdtemp, rm, readFile, writeFile, mkdir, realpath, symlink, unlink } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local' +import { WorkspaceFiles } from '@deepseek-ai/dsh-api-workspace-files' +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import { Context } from '@deepseek-ai/cordis' import { HostConnectionService } from '@deepseek-ai/dsh-client-connection' import type { BrowserAuth } from '@deepseek-ai/dsh-client-connection/src/browser-auth.ts' @@ -30,6 +34,11 @@ async function fixture() { const ctx = new Context() cleanups.push(() => ctx.fiber.dispose()) const session: { cwd?: string } = { cwd } + await ctx.plugin(LocalFileSystem, { cwd }) + ctx.provide('sandboxPolicy', { resolve: () => ({ workspaceRoot: session.cwd ?? cwd }) } as never) + new WorkspaceFiles(ctx, { maxBytes: 1024, maxFileBytes: 1024, maxLines: 100, maxEntries: 100 }) + const agent = { ctx, session: { header: session } } as unknown as Agent + const resolveAgent = vi.fn(async () => ({ agent })) const readEvent = vi.fn(async (request: SessionEventReadRequest) => { if (request.sessionId !== 'owner') throw new SessionQueryError('missing', 'SESSION_QUERY_SESSION_NOT_FOUND') if (request.seq !== 7) throw new SessionQueryError('missing', 'SESSION_QUERY_EVENT_NOT_FOUND') @@ -37,15 +46,15 @@ async function fixture() { }) ctx.provide('sessionQuery', { readEvent } as never) const opener = vi.fn(async (_request: { path: string; action?: 'reveal' }, _signal: AbortSignal) => ({ opened: true as const })) - ctx.provide('sessionController', { openWorkspacePath: opener, workspaceDesktop: () => ({ name: 'desktop', available: true, fileManager: 'finder' }) } as never) + ctx.provide('sessionController', { resolveAgent, openWorkspacePath: opener, workspaceDesktop: () => ({ name: 'desktop', available: true, fileManager: 'finder' }) } as never) const connection = new HostConnectionService(ctx, [], {} as BrowserAuth) - const fiber = ctx.plugin({ inject: ['connection', 'sessionQuery', 'sessionController'], apply: registerPresentOpen }) + const fiber = ctx.plugin({ inject: ['connection', 'sessionQuery', 'sessionController', 'workspaceFiles', 'fs'], apply: registerPresentOpen }) await fiber const handler = connection.createSharedFetchHandler('/api') const open = (query = '?sessionId=owner&seq=7&index=0', signal?: AbortSignal) => handler.fetch(new Request( `http://localhost${PRESENT_OPEN_PATH}${query}`, { method: 'POST', signal: signal ?? null }, )) - return { root, cwd, ctx, fiber, file, session, readEvent, open, opener, handler } + return { root, cwd, ctx, fiber, file, session, readEvent, open, opener, handler, resolveAgent } } describe('Presented workspace file native open route', () => { @@ -75,7 +84,8 @@ describe('Presented workspace file native open route', () => { const fork = join(root, 'fork') await mkdir(fork) await writeFile(join(fork, file.path), 'child source') - readEvent.mockResolvedValueOnce({ session: { ...session, cwd: fork }, target: { type: 'deliverables/presented', data: { turn: 1, callId: 'inherited', files: [file] } } as SessionEvent }) + session.cwd = fork + readEvent.mockResolvedValueOnce({ session, target: { type: 'deliverables/presented', data: { turn: 1, callId: 'inherited', files: [file] } } as SessionEvent }) expect((await open('?sessionId=fork&seq=7&index=0')).status).toBe(204) expect(opener.mock.lastCall?.[0].path).toBe(await realpath(join(fork, file.path))) }) @@ -109,30 +119,29 @@ describe('Presented workspace file native open route', () => { expect(opener).not.toHaveBeenCalled() }) - it('reports removed files, directories, and absent workspaces without launching', async () => { - const { cwd, file, session, open, opener } = await fixture() + it('reports removed files and directories without launching', async () => { + const { cwd, file, open, opener } = await fixture() await unlink(join(cwd, file.path)) expect((await open()).status).toBe(404) file.path = '.' expect((await open()).status).toBe(404) - delete session.cwd - expect((await open()).status).toBe(404) expect(opener).not.toHaveBeenCalled() }) - it('refuses traversal and a source replaced by a symlink outside the workspace', async () => { + it('opens external regular files through absolute and relative paths but refuses final symlinks', async () => { const { root, cwd, file, open, opener } = await fixture() const outside = join(root, 'outside.txt') await writeFile(outside, 'outside') const source = join(cwd, file.path) await unlink(source) await symlink(outside, source) - expect((await open()).status).toBe(403) - file.path = '../outside.txt' - expect((await open()).status).toBe(403) - file.path = outside - expect((await open()).status).toBe(403) + expect((await open()).status).toBe(404) expect(opener).not.toHaveBeenCalled() + for (const path of ['../outside.txt', outside]) { + file.path = path + expect((await open()).status).toBe(204) + expect(opener.mock.lastCall?.[0].path).toBe(await realpath(outside)) + } }) it('reports query and launcher failures without leaking Host paths and allows retry', async () => { @@ -186,7 +195,7 @@ it('reports the serving desktop and reveals only an authorized declared source', expect(opener).toHaveBeenCalledWith({ path: await realpath(join(cwd, file.path)), action: 'reveal' }, expect.any(AbortSignal)) expect((await open('?sessionId=owner&seq=7&index=0&action=delete')).status).toBe(400) file.path = '..' - expect((await open('?sessionId=owner&seq=7&index=0&action=reveal')).status).toBe(403) + expect((await open('?sessionId=owner&seq=7&index=0&action=reveal')).status).toBe(404) expect(opener).toHaveBeenCalledOnce() }) @@ -199,3 +208,32 @@ it('refuses native actions when the configured Host desktop is unavailable', asy } expect(opener).not.toHaveBeenCalled() }) + + +it('refuses native opening without a matching Host mapping even when a same-name Host file exists', async () => { + const { ctx, open, opener } = await fixture() + const mapping = vi.spyOn(ctx.fs, 'processPathFromHostPath').mockReturnValue(undefined) + expect((await open()).status).toBe(422) + mapping.mockReturnValue('/another-filesystem/file') + expect((await open()).status).toBe(422) + expect(opener).not.toHaveBeenCalled() +}) + +it('reports Session resolution failures before checking files', async () => { + const { resolveAgent, open, opener } = await fixture() + resolveAgent.mockResolvedValueOnce({ error: new RemoteError('session/not-found', 'missing', { sessionId: SessionId('owner') }) }) + expect((await open()).status).toBe(404) + expect(opener).not.toHaveBeenCalled() +}) + + +it('uses the resolved Agent filesystem instead of the Host filesystem', async () => { + const { ctx, cwd, open, resolveAgent, opener } = await fixture() + const scoped = ctx.isolate('fs') + await scoped.plugin(LocalFileSystem, { cwd }) + vi.spyOn(scoped.fs, 'processPathFromHostPath').mockReturnValue(undefined) + resolveAgent.mockResolvedValueOnce({ agent: { ctx: scoped, session: { header: { cwd } } } as unknown as Agent }) + expect((await open()).status).toBe(422) + expect(opener).not.toHaveBeenCalled() + expect((await open()).status).toBe(204) +}) diff --git a/packages/client/ui-deliverables/tests/prompt.host.spec.ts b/packages/client/ui-deliverables/tests/prompt.host.spec.ts index 8f342099fd..e168303128 100644 --- a/packages/client/ui-deliverables/tests/prompt.host.spec.ts +++ b/packages/client/ui-deliverables/tests/prompt.host.spec.ts @@ -19,6 +19,8 @@ describe('ui-deliverables node plugin', () => { ctx.provide('connection', { fetch: { register: () => () => {} } } as never) ctx.provide('sessionQuery', {} as never) ctx.provide('sessionController', {} as never) + ctx.provide('workspaceFiles', {} as never) + ctx.provide('fs', {} as never) const mounted = ctx.plugin({ apply, inject }) await mounted.await() diff --git a/packages/client/ui-deliverables/tsconfig.host.json b/packages/client/ui-deliverables/tsconfig.host.json index 0123290583..ee8f384dd8 100644 --- a/packages/client/ui-deliverables/tsconfig.host.json +++ b/packages/client/ui-deliverables/tsconfig.host.json @@ -34,6 +34,15 @@ }, { "path": "../../api/session-controller/tsconfig.host.json" + }, + { + "path": "../../api/workspace-files/tsconfig.host.json" + }, + { + "path": "../../fs/fs" + }, + { + "path": "../../typert/protocol" } ] } diff --git a/packages/fs/tool-present/README.i18n.yaml b/packages/fs/tool-present/README.i18n.yaml index d1453cce74..22c9c3dd2d 100644 --- a/packages/fs/tool-present/README.i18n.yaml +++ b/packages/fs/tool-present/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/fs/tool-present/README.md -README.md: 77c1a83a30bdbe58b3be35d8bc0d879f1669b5a5 -README.zh.md: cec2d64b4e6f95a4e8210e7e24a9c796ed95d749 +README.md: 93003711545f678336a02c9bf8589176f844af65 +README.zh.md: 152ba39be7184ca536a56ed9b4cfb5dfa4a5fe82 diff --git a/packages/fs/tool-present/README.md b/packages/fs/tool-present/README.md index 77c1a83a30..9300371154 100644 --- a/packages/fs/tool-present/README.md +++ b/packages/fs/tool-present/README.md @@ -1,5 +1,5 @@ --- -description: "Declare workspace files as deliverables with present; configuration, Session ownership, and source-file opening." +description: "Declare accessible files as deliverables with present; configuration, Session ownership, and source-file opening." kind: "package-reference" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Use `present` to declare final workspace files, including files created through shell commands. Users open the current source files in their default application. The tool records paths and optional descriptions without copying file contents. +Use `present` to declare final files accessible through the Session filesystem, including files created through shell commands. Users open the current source files in their default application. The tool records paths and optional descriptions without copying file contents. ## Table of Contents @@ -25,7 +25,7 @@ Use `present` to declare final workspace files, including files created through ## Use this package -The `standard`, `ptc`, and `cordis` agent presets mount this plugin. Call `present` with `files: [{ path, description? }]` after creating the files. Files must exist inside the Session workspace and be regular files. Missing files, directories, and paths outside the workspace fail the call. +The `standard`, `ptc`, and `cordis` agent presets mount this plugin. Call `present` with `files: [{ path, description? }]` after creating the files. Files must be regular files accessible through the Session filesystem. Relative paths resolve against the Session working directory; absolute paths may name files outside it, including `/tmp` or Downloads. Missing files, directories, final symbolic links, and provider-denied paths fail the call. Files in a shell sandbox’s private `/tmp` must first be written somewhere the Session filesystem can access. Mount it in an agent's Cordis composition with `tools`, `fs`, and the `turnBoundary` Session projection available: @@ -49,7 +49,7 @@ The file-count limit is validated at mount. The tool requires an agent Session w
Implementation internals — click to expand -The tool resolves paths through the configured filesystem provider and checks workspace containment and regular-file metadata without reading contents. Successful final `tools/result` notifications append `deliverables/presented`, including nested calls. A later enclosing program failure does not revoke an already completed declaration. Blocked results publish none. Each plugin instance records only calls it executed; scoped tools with the same name cannot publish through another instance. +The tool resolves paths through the configured filesystem provider and checks regular-file metadata without reading contents. Successful final `tools/result` notifications append `deliverables/presented`, including nested calls. A later enclosing program failure does not revoke an already completed declaration. Blocked results publish none. Each plugin instance records only calls it executed; scoped tools with the same name cannot publish through another instance. The pure `./types` entry declares `PresentedFile` and the Session event without importing Host runtime code. The Web consumer validates persisted declarations before displaying or opening them. The event stores no Session ID, so forked history resolves relative paths against the viewed Session's workspace. @@ -73,7 +73,7 @@ The pure `./types` entry declares `PresentedFile` and the Session event without #### What the model sees -The [present schema](../../../docs/tool-catalog.md#present) asks for existing workspace files: “Declare existing workspace files as final deliverables. When a file you create or update is an output the user asked to receive, you must call present after writing it and before your final response, including files created through Bash or code execution. Mentioning its path in your reply does not replace this call. The files must already exist. The user opens the current source files; their contents are not copied or preserved.” Results report `Presented ` for each file; the program result and durable event contain paths and optional descriptions. +The [present schema](../../../docs/tool-catalog.md#present) asks for existing accessible files: “Declare existing files accessible through the Session filesystem as final deliverables. When a file you create or update is an output the user asked to receive, you must call present after writing it and before your final response, including files created through Bash or code execution. Mentioning its path in your reply does not replace this call. The files must already exist. The user opens the current source files; their contents are not copied or preserved.” Results report `Presented ` for each file; the program result and durable event contain paths and optional descriptions. #### Token effect @@ -87,7 +87,7 @@ The tool schema is static for the mount lifetime. Delivery result text extends t -- Path containment checks are best effort; they cannot atomically defend against a concurrent symlink replacement before the desktop application opens the file. +- Metadata and Host-path checks cannot atomically prevent replacement before a desktop application opens a file. - Edits change what opens. Deleted or moved source files cannot be opened from their declarations. - Session ZIP exports contain declarations, not file contents. Persistent delivery versions and copy-on-write storage are deferred. diff --git a/packages/fs/tool-present/README.zh.md b/packages/fs/tool-present/README.zh.md index cec2d64b4e..152ba39be7 100644 --- a/packages/fs/tool-present/README.zh.md +++ b/packages/fs/tool-present/README.zh.md @@ -1,5 +1,5 @@ --- -description: "通过 present 声明交付工作区文件;配置、Session 归属与源文件打开。" +description: "通过 present 声明交付可访问的文件;配置、Session 归属与源文件打开。" kind: "package-reference" --- @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -使用 `present` 声明交付最终工作区文件,包括通过 shell 命令创建的文件。用户使用默认应用打开当前源文件。工具记录路径和可选说明,不复制文件内容。 +使用 `present` 声明交付Session 文件系统可访问的最终文件,包括通过 shell 命令创建的文件。用户使用默认应用打开当前源文件。工具记录路径和可选说明,不复制文件内容。 ## 目录 @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -`standard`、`ptc` 与 `cordis` Agent preset 挂载本插件。创建文件后,以 `files: [{ path, description? }]` 调用 `present`。文件必须存在于 Session 工作区内,且为普通文件。文件缺失、为目录或路径位于工作区外时,调用失败。 +`standard`、`ptc` 与 `cordis` Agent preset 挂载本插件。创建文件后,以 `files: [{ path, description? }]` 调用 `present`。文件必须是 Session 文件系统可访问的普通文件。相对路径按 Session 工作目录解析;绝对路径可以指向工作区外的文件,包括 `/tmp` 或 Downloads。文件缺失、为目录、最终路径为符号链接或提供方拒绝访问时,调用失败。Shell 沙箱私有 `/tmp` 中的文件需要先写入 Session 文件系统可访问的位置。 在 Agent 的 Cordis 组合中挂载,并提供 `tools`、`fs` 和 `turnBoundary` Session 投影: @@ -49,7 +49,7 @@ kind: "package-reference"
实现细节——点击展开 -工具通过配置的文件系统提供方解析路径,检查工作区包含关系和普通文件元数据,不读取内容。成功的最终 `tools/result` 通知追加 `deliverables/presented`,嵌套调用也适用。外层程序随后失败不会撤销已完成的声明。被阻止的结果不发布声明。每个插件实例只记录其实际执行的调用;同名作用域工具不能通过其他实例发布交付。 +工具通过配置的文件系统提供方解析路径,检查普通文件元数据,不读取内容。成功的最终 `tools/result` 通知追加 `deliverables/presented`,嵌套调用也适用。外层程序随后失败不会撤销已完成的声明。被阻止的结果不发布声明。每个插件实例只记录其实际执行的调用;同名作用域工具不能通过其他实例发布交付。 纯 `./types` 入口声明 `PresentedFile` 与 Session 事件,不导入 Host 运行时代码。Web 消费方在展示或打开文件前校验持久声明。事件不保存 Session ID,因此 fork 历史中的相对路径按当前查看的 Session 工作区解析。 @@ -73,7 +73,7 @@ kind: "package-reference" #### 模型看到的内容 -[present schema](../../../docs/tool-catalog.zh.md#present)要求已有的工作区文件:“Declare existing workspace files as final deliverables. When a file you create or update is an output the user asked to receive, you must call present after writing it and before your final response, including files created through Bash or code execution. Mentioning its path in your reply does not replace this call. The files must already exist. The user opens the current source files; their contents are not copied or preserved.” 每个文件的结果为 `Presented `;程序结果和持久事件包含路径及可选说明。 +[present schema](../../../docs/tool-catalog.zh.md#present)要求已有且可访问的文件:“Declare existing files accessible through the Session filesystem as final deliverables. When a file you create or update is an output the user asked to receive, you must call present after writing it and before your final response, including files created through Bash or code execution. Mentioning its path in your reply does not replace this call. The files must already exist. The user opens the current source files; their contents are not copied or preserved.” 每个文件的结果为 `Presented `;程序结果和持久事件包含路径及可选说明。 #### Token 影响 @@ -87,7 +87,7 @@ kind: "package-reference" -- 路径包含关系检查是尽力而为的;无法原子防御桌面应用打开文件前发生的并发符号链接替换。 +- 元数据和 Host 路径检查无法原子性地阻止桌面应用打开文件前发生的路径替换。 - 编辑会改变打开的内容。源文件删除或移动后,无法通过原声明打开。 - Session ZIP 导出包含声明,不包含文件内容。交付版本持久化和写时复制存储延期实现。 diff --git a/packages/fs/tool-present/src/index.ts b/packages/fs/tool-present/src/index.ts index b898c5671a..626006e17b 100644 --- a/packages/fs/tool-present/src/index.ts +++ b/packages/fs/tool-present/src/index.ts @@ -1,4 +1,4 @@ -/** Scoped tool that declares workspace file deliveries in their owning Session. */ +/** Scoped tool that declares filesystem deliveries in their owning Session. */ import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { FsError } from '@deepseek-ai/dsh-fs' @@ -37,7 +37,7 @@ export function apply(ctx: Context, config: Config): void { const pending = new WeakMap() ctx.tools.register(defineTool({ name: 'present', - description: 'Declare existing workspace files as final deliverables. ' + description: 'Declare existing files accessible through the Session filesystem as final deliverables. ' + 'When a file you create or update is an output the user asked to receive, you must call present after writing it and before your final response, including files created through Bash or code execution. ' + 'Mentioning its path in your reply does not replace this call. The files must already exist. ' + 'The user opens the current source files; their contents are not copied or preserved.', @@ -47,7 +47,7 @@ export function apply(ctx: Context, config: Config): void { items: { type: 'object', additionalProperties: false, properties: { - path: { type: 'string', required: true, description: 'Path of an existing file inside the workspace.' }, + path: { type: 'string', required: true, description: 'Path of an existing regular file. Relative paths use the Session working directory.' }, description: { type: 'string', description: 'Brief description for the user.' }, }, }, @@ -80,12 +80,12 @@ export function apply(ctx: Context, config: Config): void { const cwd = exec.agent.session.header.cwd if (cwd === undefined) throw new Error('present requires a workspace') const options = { cwd, signal: exec.signal } - const root = await ctx.fs.resolve('.', options) const files: PresentedFile[] = [] for (const file of args.files) { if (file.path.trim().length === 0) throw new Error('present requires a non-empty file path') + const entry = await ctx.fs.lstat(file.path, { cwd }, exec.signal) + if (entry !== undefined && entry.type !== 'file') throw new Error(`Cannot present ${file.path}: not a regular file`) const target = await ctx.fs.resolve(file.path, options) - if (!ctx.fs.contains(root, target)) throw new Error(`Cannot present ${file.path}: outside the workspace`) const info = await ctx.fs.stat(target, exec.signal) if (info === undefined) throw new FsError(`Cannot present ${file.path}: file not found. Check the path, create the file if needed, and retry.`, 'FS_NOT_FOUND') if (info.type !== 'file') throw new Error(`Cannot present ${file.path}: not a regular file`) diff --git a/packages/fs/tool-present/src/types.ts b/packages/fs/tool-present/src/types.ts index 796f0104ab..04d8aba451 100644 --- a/packages/fs/tool-present/src/types.ts +++ b/packages/fs/tool-present/src/types.ts @@ -1,9 +1,9 @@ /** Durable file deliveries produced by the present tool. */ import type { ToolCallId } from '@deepseek-ai/dsh-llm/brand' -/** A declared workspace file whose current contents remain at its source path. */ +/** A declared filesystem file whose current contents remain at its source path. */ export interface PresentedFile { - /** Original workspace path. */ + /** Original absolute path or path relative to the Session working directory. */ path: string /** Optional description supplied by the model. */ description?: string @@ -11,7 +11,7 @@ export interface PresentedFile { declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap { - /** Declared workspace files from a successful final present result, including nested calls. */ + /** Declared filesystem files from a successful final present result, including nested calls. */ 'deliverables/presented': { turn: number; callId: ToolCallId; files: PresentedFile[] } } } diff --git a/packages/fs/tool-present/tests/present.spec.ts b/packages/fs/tool-present/tests/present.spec.ts index 95606e09dc..0f219f7b14 100644 --- a/packages/fs/tool-present/tests/present.spec.ts +++ b/packages/fs/tool-present/tests/present.spec.ts @@ -1,7 +1,7 @@ /** Explicit deliveries commit only after a successful final tool result. */ import { mkdtemp, rm, writeFile, symlink } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { join, relative } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' @@ -133,7 +133,7 @@ describe('present', () => { expect(owner.session.snapshotEvents().some(event => event.type === 'deliverables/presented')).toBe(false) }) - it('rejects missing, non-file, outside-workspace, empty, and excessive inputs', async () => { + it('rejects missing, non-file, empty, and excessive inputs', async () => { const { root, owner, execute } = await setup() await writeFile(join(root, 'large'), 'four') await symlink(tmpdir(), join(root, 'outside')) @@ -165,3 +165,31 @@ it('requires an agent, an open turn, and a workspace', async () => { const absent = await ctx.tools.execute({ signal: new AbortController().signal, callId: ToolCallId('no-workspace'), name: 'present', arguments: { files: [{ path: 'a' }] }, agent: noWorkspace }) expect(absent.isError).toBe(true) }) + + +it('declares readable files outside the Session directory using absolute and relative paths', async () => { + const { root, execute, owner } = await setup() + const outside = await mkdtemp(join(tmpdir(), 'dsh-present-external-')) + cleanups.push(() => rm(outside, { recursive: true, force: true })) + const file = join(outside, 'report.txt') + await writeFile(file, 'external report') + const files = [{ path: file }, { path: relative(root, file) }] + expect((await execute(files)).isError).toBe(false) + expect(owner.session.snapshotEvents().find(event => event.type === 'deliverables/presented')?.data.files).toEqual(files) +}) + +it('refuses a final symlink to an ordinary file', async () => { + const { root, execute } = await setup() + await writeFile(join(root, 'source'), 'source') + await symlink(join(root, 'source'), join(root, 'link')) + expect((await execute([{ path: 'link' }])).isError).toBe(true) +}) + + +it('refuses a file replaced by a directory after inspecting its final component', async () => { + const { ctx, root, execute } = await setup() + await writeFile(join(root, 'source'), 'source') + const directory = await ctx.fs.stat(await ctx.fs.resolve(root)) + vi.spyOn(ctx.fs, 'stat').mockResolvedValueOnce(directory) + expect((await execute([{ path: 'source' }])).isError).toBe(true) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b94d4efb64..6cfebbe630 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2687,12 +2687,18 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ version: link:../../api/remotes '@deepseek-ai/dsh-api-session-controller': specifier: workspace:^ version: link:../../api/session-controller + '@deepseek-ai/dsh-api-workspace-files': + specifier: workspace:^ + version: link:../../api/workspace-files '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../connection @@ -2723,6 +2729,12 @@ importers: '@deepseek-ai/dsh-client-ui-tool': specifier: workspace:^ version: link:../ui-tool + '@deepseek-ai/dsh-fs': + specifier: workspace:^ + version: link:../../fs/fs + '@deepseek-ai/dsh-fs-local': + specifier: workspace:^ + version: link:../../fs/fs-local '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -2738,6 +2750,9 @@ importers: '@deepseek-ai/dsh-tool-present': specifier: workspace:^ version: link:../../fs/tool-present + '@deepseek-ai/dsh-typert-protocol': + specifier: workspace:^ + version: link:../../typert/protocol '@deepseek-ai/dsh-util-workspace-path': specifier: workspace:^ version: link:../../util/workspace-path From b443c4d2468f4240f21601930baa44078833a582 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 9 Sep 2026 16:48:39 +0800 Subject: [PATCH 024/111] fix(present): resolve file services in live agent scope and refresh snapshots --- docs/config-catalog.md | 2 +- docs/persistence-catalog.i18n.yaml | 4 ++-- docs/persistence-catalog.zh.md | 2 +- docs/tool-catalog.i18n.yaml | 4 ++-- docs/tool-catalog.zh.md | 4 ++-- packages/client/ui-deliverables/src/index.ts | 2 +- packages/client/ui-deliverables/src/present-open.ts | 6 ++++-- .../ui-deliverables/tests/present-open.host.spec.ts | 8 ++++++++ packages/client/ui-deliverables/tests/prompt.host.spec.ts | 2 -- .../web/cordis-tool-round/tool-schemas.expected.json | 4 ++-- snapshots/web/fresh-round-trip/tool-schemas.expected.json | 4 ++-- snapshots/web/ptc-round/system-prompt.expected.md | 4 ++-- 12 files changed, 27 insertions(+), 19 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index e187952809..7b677ae08d 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -3459,7 +3459,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-client-ui-commands` ([`packages/client/ui-commands/src/index.ts`](../packages/client/ui-commands/src/index.ts)) - `@deepseek-ai/dsh-client-ui-conversation` ([`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts)) - `@deepseek-ai/dsh-client-ui-cordis` ([`packages/extensions/ui-cordis/src/index.ts`](../packages/extensions/ui-cordis/src/index.ts)) -- `@deepseek-ai/dsh-client-ui-deliverables` — requires `systemPrompt` · `connection` · `sessionQuery` · `sessionController` · `workspaceFiles` · `fs` ([`packages/client/ui-deliverables/src/index.ts`](../packages/client/ui-deliverables/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-deliverables` — requires `systemPrompt` · `connection` · `sessionQuery` · `sessionController` ([`packages/client/ui-deliverables/src/index.ts`](../packages/client/ui-deliverables/src/index.ts)) - `@deepseek-ai/dsh-client-ui-directory-picker-browse` ([`packages/client/ui-directory-picker-browse/src/index.ts`](../packages/client/ui-directory-picker-browse/src/index.ts)) - `@deepseek-ai/dsh-client-ui-directory-picker-native` ([`packages/client/ui-directory-picker-native/src/index.ts`](../packages/client/ui-directory-picker-native/src/index.ts)) - `@deepseek-ai/dsh-client-ui-goal` ([`packages/client/ui-goal/src/index.ts`](../packages/client/ui-goal/src/index.ts)) diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index d3294070f9..389c95a40b 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/persistence-catalog.md -persistence-catalog.md: bf70879f80f959c865eca17efbdbce3ef78fda2e -persistence-catalog.zh.md: 1332dfdc8778dde651eeedefab2f87b09d67c06e +persistence-catalog.md: 46e91f23a7aad053791df190f769ad911f334b68 +persistence-catalog.zh.md: 087915b83d4dfd5c45a1583658d6ebe091904a3f diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 1332dfdc87..087915b83d 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -405,7 +405,7 @@ export type SessionEvent = { #### `deliverables/presented` — 仅日志 ```ts persistence-catalog -/** Declared workspace files from a successful final present result, including nested calls. */ +/** Declared filesystem files from a successful final present result, including nested calls. */ 'deliverables/presented': { turn: number; callId: ToolCallId; files: PresentedFile[] } ``` diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml index e5150a443c..a9fa6c7545 100644 --- a/docs/tool-catalog.i18n.yaml +++ b/docs/tool-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/tool-catalog.md -tool-catalog.md: 928f7537ab212edf6af1dec93077caed2bb003b7 -tool-catalog.zh.md: 5ac34f6188f2b0689e02b72595375f00b02d5f6f +tool-catalog.md: c6c09aee8ed1cac69558bb02ca3395eaebed86e5 +tool-catalog.zh.md: 2cd6715ff03752885da0bb91e7e9ed9796f9ede4 diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md index 5ac34f6188..2cd6715ff0 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -229,7 +229,7 @@ bash 工具是 bash 执行器 seam 面向模型的消费方。使用 `run_in_bac ### `present` -声明交付已有的工作区文件。如果你创建或更新的文件是用户要求接收的成果,则必须在写入完成后、最终回复前调用 present,包括通过 Bash 或代码执行创建的文件。在回复中提到文件路径不能替代这次调用。文件必须已存在。用户打开当前源文件;不复制或保存其内容。 +声明交付 Session 文件系统可访问的已有文件。如果你创建或更新的文件是用户要求接收的成果,则必须在写入完成后、最终回复前调用 present,包括通过 Bash 或代码执行创建的文件。在回复中提到文件路径不能替代这次调用。文件必须已存在。用户打开当前源文件;不复制或保存其内容。 ```json { @@ -243,7 +243,7 @@ bash 工具是 bash 执行器 seam 面向模型的消费方。使用 `run_in_bac "properties": { "path": { "type": "string", - "description": "Path of an existing file inside the workspace." + "description": "Path of an existing regular file. Relative paths use the Session working directory." }, "description": { "type": "string", diff --git a/packages/client/ui-deliverables/src/index.ts b/packages/client/ui-deliverables/src/index.ts index ee4ef63a40..a6799274a3 100644 --- a/packages/client/ui-deliverables/src/index.ts +++ b/packages/client/ui-deliverables/src/index.ts @@ -11,7 +11,7 @@ import type {} from '@deepseek-ai/dsh-system-prompt' import { registerPresentOpen } from './present-open.ts' /** Services required for file-reference guidance and authenticated native opens of declared files. */ -export const inject = ['systemPrompt', 'connection', 'sessionQuery', 'sessionController', 'workspaceFiles', 'fs'] +export const inject = ['systemPrompt', 'connection', 'sessionQuery', 'sessionController'] /** Stable final-response guidance owned by the matching renderer. */ const FILE_REFERENCE_PROMPT = 'When you successfully create or modify files, mention the primary outputs in your final response. ' diff --git a/packages/client/ui-deliverables/src/present-open.ts b/packages/client/ui-deliverables/src/present-open.ts index 97aab3242a..88655ca325 100644 --- a/packages/client/ui-deliverables/src/present-open.ts +++ b/packages/client/ui-deliverables/src/present-open.ts @@ -63,8 +63,10 @@ async function handlePresentOpen(ctx: Context, request: Request): Promise { + const { ctx, open, resolveAgent, opener } = await fixture() + resolveAgent.mockResolvedValueOnce({ agent: { ctx: ctx.isolate(service) } as unknown as Agent }) + expect((await open()).status).toBe(500) + expect(opener).not.toHaveBeenCalled() +}) diff --git a/packages/client/ui-deliverables/tests/prompt.host.spec.ts b/packages/client/ui-deliverables/tests/prompt.host.spec.ts index e168303128..8f342099fd 100644 --- a/packages/client/ui-deliverables/tests/prompt.host.spec.ts +++ b/packages/client/ui-deliverables/tests/prompt.host.spec.ts @@ -19,8 +19,6 @@ describe('ui-deliverables node plugin', () => { ctx.provide('connection', { fetch: { register: () => () => {} } } as never) ctx.provide('sessionQuery', {} as never) ctx.provide('sessionController', {} as never) - ctx.provide('workspaceFiles', {} as never) - ctx.provide('fs', {} as never) const mounted = ctx.plugin({ apply, inject }) await mounted.await() diff --git a/snapshots/web/cordis-tool-round/tool-schemas.expected.json b/snapshots/web/cordis-tool-round/tool-schemas.expected.json index 8cbf16c112..3477af247d 100644 --- a/snapshots/web/cordis-tool-round/tool-schemas.expected.json +++ b/snapshots/web/cordis-tool-round/tool-schemas.expected.json @@ -522,7 +522,7 @@ }, { "name": "present", - "description": "Declare existing workspace files as final deliverables. When a file you create or update is an output the user asked to receive, you must call present after writing it and before your final response, including files created through Bash or code execution. Mentioning its path in your reply does not replace this call. The files must already exist. The user opens the current source files; their contents are not copied or preserved.", + "description": "Declare existing files accessible through the Session filesystem as final deliverables. When a file you create or update is an output the user asked to receive, you must call present after writing it and before your final response, including files created through Bash or code execution. Mentioning its path in your reply does not replace this call. The files must already exist. The user opens the current source files; their contents are not copied or preserved.", "parameters": { "type": "object", "properties": { @@ -534,7 +534,7 @@ "properties": { "path": { "type": "string", - "description": "Path of an existing file inside the workspace." + "description": "Path of an existing regular file. Relative paths use the Session working directory." }, "description": { "type": "string", diff --git a/snapshots/web/fresh-round-trip/tool-schemas.expected.json b/snapshots/web/fresh-round-trip/tool-schemas.expected.json index cb28c49747..d39c60d137 100644 --- a/snapshots/web/fresh-round-trip/tool-schemas.expected.json +++ b/snapshots/web/fresh-round-trip/tool-schemas.expected.json @@ -325,7 +325,7 @@ }, { "name": "present", - "description": "Declare existing workspace files as final deliverables. When a file you create or update is an output the user asked to receive, you must call present after writing it and before your final response, including files created through Bash or code execution. Mentioning its path in your reply does not replace this call. The files must already exist. The user opens the current source files; their contents are not copied or preserved.", + "description": "Declare existing files accessible through the Session filesystem as final deliverables. When a file you create or update is an output the user asked to receive, you must call present after writing it and before your final response, including files created through Bash or code execution. Mentioning its path in your reply does not replace this call. The files must already exist. The user opens the current source files; their contents are not copied or preserved.", "parameters": { "type": "object", "properties": { @@ -337,7 +337,7 @@ "properties": { "path": { "type": "string", - "description": "Path of an existing file inside the workspace." + "description": "Path of an existing regular file. Relative paths use the Session working directory." }, "description": { "type": "string", diff --git a/snapshots/web/ptc-round/system-prompt.expected.md b/snapshots/web/ptc-round/system-prompt.expected.md index ebdf046722..3708515cdb 100644 --- a/snapshots/web/ptc-round/system-prompt.expected.md +++ b/snapshots/web/ptc-round/system-prompt.expected.md @@ -162,10 +162,10 @@ interface ToolArgsMap { /** children (default) lists direct children only; descendants walks the complete tree below you. */ scope?: "children" | "descendants"; } & Record; - /** Declare existing workspace files as final deliverables. When a file you create or update is an output the user asked to receive, you must call present after writing it and before your final response, including files created through Bash or code execution. Mentioning its path in your reply does not replace this call. The files must already exist. The user opens the current source files; their contents are not copied or preserved. */ + /** Declare existing files accessible through the Session filesystem as final deliverables. When a file you create or update is an output the user asked to receive, you must call present after writing it and before your final response, including files created through Bash or code execution. Mentioning its path in your reply does not replace this call. The files must already exist. The user opens the current source files; their contents are not copied or preserved. */ present: { files: { - /** Path of an existing file inside the workspace. */ + /** Path of an existing regular file. Relative paths use the Session working directory. */ path: string; /** Brief description for the user. */ description?: string; From 99bffa7f9a16fd3e87569d275c91d8f2c403e911 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 9 Sep 2026 16:52:04 +0800 Subject: [PATCH 025/111] fix(web): update delivery card snapshot and remove unused button segments --- apps/web/tests/present-svg.e2e.ts | 2 +- packages/client/ui-primitives/README.i18n.yaml | 4 ++-- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 2 +- packages/client/ui-primitives/src/Button.module.css | 3 --- packages/client/ui-primitives/src/Button.tsx | 6 ++---- snapshots/web/present-svg/ui.expected.md | 8 ++++---- 7 files changed, 11 insertions(+), 16 deletions(-) diff --git a/apps/web/tests/present-svg.e2e.ts b/apps/web/tests/present-svg.e2e.ts index 4ff6a5b819..bb6bd2e0b5 100644 --- a/apps/web/tests/present-svg.e2e.ts +++ b/apps/web/tests/present-svg.e2e.ts @@ -101,7 +101,7 @@ describe('web e2e: requested SVG is explicitly delivered', () => { expect(events.some(event => event.type === 'assistant/message' && event.seq > delivery.seq && event.data.message.content.some(block => block.type === 'text'))).toBe(true) - const card = page.locator('[data-presented-files-row]').getByRole('button').filter({ hasText: FILE }) + const card = page.locator('[data-presented-file]').filter({ hasText: FILE }) await card.waitFor({ state: 'visible' }) expect(await card.count()).toBe(1) expect(await page.getByText('产物', { exact: true }).count()).toBe(0) diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 361c454a4d..88739b753c 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md -README.md: 1c3bb7a7d36884234340b29da9f8763e46796e61 -README.zh.md: a8bc94f4afeb5c13d95ccafea4ef1b926524abd0 +README.md: 8e1ee78d02ce0802d1a8353050de69e6a7ab7c65 +README.zh.md: 4c50570d917a081a886525cf6f9be0de2ccc7065 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 1c3bb7a7d3..8e1ee78d02 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -67,7 +67,7 @@ Writing your own component in your own package is fine when the need is genuinel ### Controls and icons -The catalog above lists what each export is for; this section covers the behavior that props alone do not show. The `ic_ds_*` icon set and `FishLogo`/`BrandWordmark` marks fill brand and inline-icon slots. `LinkIcon` draws the leading category glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, all riding `currentColor` — and `classifyLinkPath` derives a file path's category from its extension. `FileTypeIcon` is the one glyph that does not ride `currentColor`: its sheet colour is the type's identity, so a consumer that wants it muted (an empty state) applies `filter: grayscale(1)` itself. `classifyFileType` and `classifyLinkPath` read one extension vocabulary (`file-extensions.ts`), so a path classifies consistently on a link and on a sheet; the sheet is the finer of the two. `ConnectionIndicator` renders a warning-colored disconnected action, a connecting label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Hover or keyboard focus shows only the reconnect action label, including while the connecting dots animate. Every state reserves the widest supplied label and uses fixed icon and text columns, so copy changes do not move or resize the control. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. `Toast` holds for the window its owner names through `holdMs`, because how long a banner has to stay depends on how much there is to read; the same value drives its unmount timer and the stylesheet's fade delay, so the two cannot disagree. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order. `Button.segment` joins the start and end controls of a split action without changing standalone button geometry. `Menu.autoFocus` focuses its first enabled item, supports Arrow Up/Down and Home/End navigation, and focuses the first button in the anchor on Escape; it is opt-in for action menus. +The catalog above lists what each export is for; this section covers the behavior that props alone do not show. The `ic_ds_*` icon set and `FishLogo`/`BrandWordmark` marks fill brand and inline-icon slots. `LinkIcon` draws the leading category glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, all riding `currentColor` — and `classifyLinkPath` derives a file path's category from its extension. `FileTypeIcon` is the one glyph that does not ride `currentColor`: its sheet colour is the type's identity, so a consumer that wants it muted (an empty state) applies `filter: grayscale(1)` itself. `classifyFileType` and `classifyLinkPath` read one extension vocabulary (`file-extensions.ts`), so a path classifies consistently on a link and on a sheet; the sheet is the finer of the two. `ConnectionIndicator` renders a warning-colored disconnected action, a connecting label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Hover or keyboard focus shows only the reconnect action label, including while the connecting dots animate. Every state reserves the widest supplied label and uses fixed icon and text columns, so copy changes do not move or resize the control. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. `Toast` holds for the window its owner names through `holdMs`, because how long a banner has to stay depends on how much there is to read; the same value drives its unmount timer and the stylesheet's fade delay, so the two cannot disagree. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order. `Menu.autoFocus` focuses its first enabled item, supports Arrow Up/Down and Home/End navigation, and focuses the first button in the anchor on Escape; it is opt-in for action menus. ### Rendering agent output diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index a8bc94f4af..4c50570d91 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -67,7 +67,7 @@ kind: "package-library" ### 控件与图标 -上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。`ic_ds_*` 图标集与 `FishLogo`/`BrandWordmark` 标记填充品牌与行内图标 slot。`LinkIcon` 为可点击产物链接绘制前置分类图形——地球、文件夹、代码、图片、文档或纸张,全部随 `currentColor`——`classifyLinkPath` 按扩展名推导文件路径的类别。`FileTypeIcon` 是唯一不随 `currentColor` 的字形:纸片颜色就是类型的身份,需要压灰的使用方(空状态)自行加 `filter: grayscale(1)`。`classifyFileType` 与 `classifyLinkPath` 读同一份扩展名词表(`file-extensions.ts`),一条路径在链接上与纸片上的归类一致;纸片分得更细。`ConnectionIndicator` 可渲染警告色的断联操作、以独立于 retry 时序的 500ms 节奏推进一至三个点的连接中状态,或成功色的恢复状态。悬停或键盘聚焦时只显示重连操作文案,连接中的圆点动画也保持隐藏。所有状态都为最长的输入 label 预留空间,并使用固定的图标列和文字列,因此文案变化不会移动控件或改变其宽度。它的 owner 提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。 `Toast` 的停留时长由使用方通过 `holdMs` 指定,因为横幅该留多久取决于有多少内容要读;同一个值同时驱动它的卸载定时器与样式表的淡出延迟,两者不可能再错位。 `rankByName` 是 `/` 菜单命令源与 skill 源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 `Button.segment` 将分段操作的首尾按钮连接起来,不改变独立按钮的几何样式。`Menu.autoFocus` 聚焦首个启用项,支持上下方向键与 Home/End 导航,并在 Escape 时聚焦 anchor 内的第一个按钮;操作菜单可显式启用。 +上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。`ic_ds_*` 图标集与 `FishLogo`/`BrandWordmark` 标记填充品牌与行内图标 slot。`LinkIcon` 为可点击产物链接绘制前置分类图形——地球、文件夹、代码、图片、文档或纸张,全部随 `currentColor`——`classifyLinkPath` 按扩展名推导文件路径的类别。`FileTypeIcon` 是唯一不随 `currentColor` 的字形:纸片颜色就是类型的身份,需要压灰的使用方(空状态)自行加 `filter: grayscale(1)`。`classifyFileType` 与 `classifyLinkPath` 读同一份扩展名词表(`file-extensions.ts`),一条路径在链接上与纸片上的归类一致;纸片分得更细。`ConnectionIndicator` 可渲染警告色的断联操作、以独立于 retry 时序的 500ms 节奏推进一至三个点的连接中状态,或成功色的恢复状态。悬停或键盘聚焦时只显示重连操作文案,连接中的圆点动画也保持隐藏。所有状态都为最长的输入 label 预留空间,并使用固定的图标列和文字列,因此文案变化不会移动控件或改变其宽度。它的 owner 提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。 `Toast` 的停留时长由使用方通过 `holdMs` 指定,因为横幅该留多久取决于有多少内容要读;同一个值同时驱动它的卸载定时器与样式表的淡出延迟,两者不可能再错位。 `rankByName` 是 `/` 菜单命令源与 skill 源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 `Menu.autoFocus` 聚焦首个启用项,支持上下方向键与 Home/End 导航,并在 Escape 时聚焦 anchor 内的第一个按钮;操作菜单可显式启用。 ### 渲染 agent 输出 diff --git a/packages/client/ui-primitives/src/Button.module.css b/packages/client/ui-primitives/src/Button.module.css index b8efa4391d..9fa1712a66 100644 --- a/packages/client/ui-primitives/src/Button.module.css +++ b/packages/client/ui-primitives/src/Button.module.css @@ -77,6 +77,3 @@ align-items: center; justify-content: center; } - -.start { border-radius: 8px 0 0 8px; } -.end { border-radius: 0 8px 8px 0; } diff --git a/packages/client/ui-primitives/src/Button.tsx b/packages/client/ui-primitives/src/Button.tsx index 9519388d4d..d2e39dbf23 100644 --- a/packages/client/ui-primitives/src/Button.tsx +++ b/packages/client/ui-primitives/src/Button.tsx @@ -12,20 +12,18 @@ export type ButtonVariant = 'primary' | 'ghost' | 'outline' | 'toolbar' * Render a button. * @param props.variant - visual family (default 'ghost'). * @param props.size - 'md' 36px capsule (figma Button) or 'sm' 28px compact. - * @param props.segment - joined button edge within an owner-rendered split control. * @param props.icon - optional leading 16px icon node. * @returns the button element; native button attributes pass through. */ -export function Button({ variant = 'ghost', size = 'md', icon, segment, className, children, ...rest }: { +export function Button({ variant = 'ghost', size = 'md', icon, className, children, ...rest }: { variant?: ButtonVariant size?: 'md' | 'sm' - segment?: 'start' | 'end' icon?: ReactNode className?: string | undefined children?: ReactNode } & ButtonHTMLAttributes) { return ( - diff --git a/snapshots/web/present-svg/ui.expected.md b/snapshots/web/present-svg/ui.expected.md index 13cc9d46c6..1fc1597b18 100644 --- a/snapshots/web/present-svg/ui.expected.md +++ b/snapshots/web/present-svg/ui.expected.md @@ -51,11 +51,11 @@ - paragraph: 下方附一行核心思想说明:指令与数据以二进制统一存储在存储器中。用浏览器打开该 SVG 文件即可查看。 - text: 本轮文件改动 - button "打开 {{cwd}}/workspace/von-neumann.svg": von-neumann.svg -- text: 交付文件 -- button "在默认程序中打开 von-neumann.svg": - - text: von-neumann.svg SVG 冯诺依曼架构示意图 SVG +- button "在侧边栏预览 von-neumann.svg" +- text: von-neumann.svg 冯诺依曼架构示意图 SVG +- button "在侧边栏打开 von-neumann.svg": 打开 +- button "von-neumann.svg 的更多文件操作": - img - - text: 打开 - button "复制": - img - button "好的回答": From dfda1d6aafa19a698f67b92c53b8994375682a5f Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 9 Sep 2026 17:02:24 +0800 Subject: [PATCH 026/111] fix(web): ship protocol runtime dependency for delivered file actions --- packages/client/ui-deliverables/package.json | 6 ++++-- pnpm-lock.yaml | 7 ++++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/client/ui-deliverables/package.json b/packages/client/ui-deliverables/package.json index cd71933d2f..2e56c1b3b2 100644 --- a/packages/client/ui-deliverables/package.json +++ b/packages/client/ui-deliverables/package.json @@ -72,12 +72,14 @@ "@deepseek-ai/dsh-api-workspace-files": "workspace:^", "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", - "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^" }, "files": [ "lib/index.js", "lib/client.js", "lib/types/**/*.d.ts" - ] + ], + "dependencies": { + "@deepseek-ai/dsh-typert-protocol": "workspace:^" + } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6cfebbe630..3785077c2f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2683,6 +2683,10 @@ importers: version: 4.4.3 packages/client/ui-deliverables: + dependencies: + '@deepseek-ai/dsh-typert-protocol': + specifier: workspace:^ + version: link:../../typert/protocol devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -2750,9 +2754,6 @@ importers: '@deepseek-ai/dsh-tool-present': specifier: workspace:^ version: link:../../fs/tool-present - '@deepseek-ai/dsh-typert-protocol': - specifier: workspace:^ - version: link:../../typert/protocol '@deepseek-ai/dsh-util-workspace-path': specifier: workspace:^ version: link:../../util/workspace-path From 56c4c3e47c195ff5edbfe3d307bdef81f3de348b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 9 Sep 2026 16:49:47 +0800 Subject: [PATCH 027/111] docs(session): centralize format status and timeless guidance --- .../2026-06-14-session-persistence.i18n.yaml | 4 +- .../2026-06-14-session-persistence.md | 4 +- .../2026-06-14-session-persistence.zh.md | 4 +- ...handle-based-session-persistence.i18n.yaml | 4 +- ...-08-27-handle-based-session-persistence.md | 2 +- ...-27-handle-based-session-persistence.zh.md | 2 +- ...historical-unknown-event-refusal.i18n.yaml | 4 +- ...-alpha-historical-unknown-event-refusal.md | 2 +- ...pha-historical-unknown-event-refusal.zh.md | 2 +- ...leased-session-format-migrations.i18n.yaml | 4 +- ...8-31-released-session-format-migrations.md | 2 +- ...1-released-session-format-migrations.zh.md | 2 +- ...02-system-prompt-as-surface-node.i18n.yaml | 4 +- ...026-09-02-system-prompt-as-surface-node.md | 2 +- ...-09-02-system-prompt-as-surface-node.zh.md | 2 +- ...2026-08-10-npm-release-sequences.i18n.yaml | 4 +- .../2026-08-10-npm-release-sequences.md | 2 +- .../2026-08-10-npm-release-sequences.zh.md | 2 +- ...8-24-session-log-snapshot-corpus.i18n.yaml | 4 +- .../2026-08-24-session-log-snapshot-corpus.md | 4 +- ...26-08-24-session-log-snapshot-corpus.zh.md | 4 +- AGENTS.md | 2 +- docs/AGENTS.md | 2 +- .../adding-a-session-format-version.i18n.yaml | 4 +- .../adding-a-session-format-version.md | 18 +-- .../adding-a-session-format-version.zh.md | 18 +-- ...deepseek-llm-api-wire-extensions.i18n.yaml | 4 +- docs/deepseek-llm-api-wire-extensions.md | 4 +- docs/deepseek-llm-api-wire-extensions.zh.md | 4 +- docs/session-format-status.i18n.yaml | 6 + docs/session-format-status.md | 47 ++++++ docs/session-format-status.zh.md | 47 ++++++ docs/subsystems/persistence.i18n.yaml | 4 +- docs/subsystems/persistence.md | 2 +- docs/subsystems/persistence.zh.md | 2 +- docs/subsystems/session.i18n.yaml | 4 +- docs/subsystems/session.md | 2 +- docs/subsystems/session.zh.md | 2 +- docs/testing.i18n.yaml | 4 +- docs/testing.md | 2 +- docs/testing.zh.md | 2 +- packages/core/session/README.i18n.yaml | 4 +- packages/core/session/README.md | 2 +- packages/core/session/README.zh.md | 2 +- packages/core/session/src/types.ts | 2 +- .../session-format-catalog/README.i18n.yaml | 4 +- .../session/session-format-catalog/README.md | 2 +- .../session-format-catalog/README.zh.md | 2 +- .../session-format-v2-to-v3/README.i18n.yaml | 4 +- .../session/session-format-v2-to-v3/README.md | 2 +- .../session-format-v2-to-v3/README.zh.md | 2 +- .../README.i18n.yaml | 4 +- .../session-persistence-jsonl/README.md | 2 +- .../session-persistence-jsonl/README.zh.md | 2 +- .../session-persistence-jsonl/src/format.ts | 4 +- .../session-snapshot/README.i18n.yaml | 4 +- .../test-support/session-snapshot/README.md | 6 +- .../session-snapshot/README.zh.md | 6 +- scripts/doc-standard.spec.ts | 147 +++++++++++++++++- 59 files changed, 345 insertions(+), 100 deletions(-) create mode 100644 docs/session-format-status.i18n.yaml create mode 100644 docs/session-format-status.md create mode 100644 docs/session-format-status.zh.md diff --git a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.i18n.yaml index 8771aecc10..f827897a1e 100644 --- a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.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-06-14-session-persistence.md -2026-06-14-session-persistence.md: afe372c51f5056d02bdf3a17157ef755dd02e3f8 -2026-06-14-session-persistence.zh.md: 1fe84d955fe8878b715b3d23f05ecfc1fe5bbc78 +2026-06-14-session-persistence.md: d79975e1fedb6efcd0e4ea83bc799bb158082e41 +2026-06-14-session-persistence.zh.md: f70b8005a254cc6a9ea8ada31c32e5552ee0c8b2 diff --git a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.md b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.md index afe372c51f..d79975e1fe 100644 --- a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.md +++ b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.md @@ -19,7 +19,7 @@ Persistence is a **capability seam** with an abstract Service Definition ([capab Key durable, contested choices: -- **The canonical durable log persists every current `SessionEvent` losslessly.** In v2, one `assistant/message` or `assistant/attempt` embeds the exact timed provider stream for an attempt; `deriveMessages()` projects only the surface message. Dropping embedded stream members is tempting, but it loses replay, timing, usage, partial-failure, and diagnostic facts. Removing a complete event likewise requires dense renumbering because `seq = log.length` and `events[i].seq === i`; the [v1-to-v2 migration](2026-09-01-v2-embedded-assistant-streams.md) performs that rewrite explicitly rather than filtering the canonical log. +- **The canonical durable log persists every current `SessionEvent` losslessly.** One `assistant/message` or `assistant/attempt` embeds the exact timed provider stream for an attempt; `deriveMessages()` projects only the surface message. Dropping embedded stream members is tempting, but it loses replay, timing, usage, partial-failure, and diagnostic facts. Removing a complete event likewise requires dense renumbering because `seq = log.length` and `events[i].seq === i`; the [v1-to-v2 migration](2026-09-01-v2-embedded-assistant-streams.md) performs that rewrite explicitly rather than filtering the canonical log. - **Append-only; a crashed turn is closed, never truncated.** Flushed events are never rewritten. The [semantic checkpoint policy](../../../../packages/session/session-checkpoint-policy/README.md) drains the request before model dispatch, a recorded top-level call before tool dispatch, and the complete response/result batch after a step; the loop drains the final turn boundary. Because one interrupted turn may contain substantial valid work, persistence returns its contiguous, parseable events unmodified; the reader owns balancing — resume computes risk-classified error results for unanswered assistant calls, a missing `step/end`, and `turn/end` with `{ kind: 'interrupted' }` (`interruptedTurnClosers`) and appends them through its write handle, while read-only observers add the same closers in memory. The synthetic results keep resumed provider transcripts valid. Only the incomplete fragment of a torn final append is discarded — complete records recovered from it are durably rewritten by the write path before its first new append; a parse error or sequence gap in the committed prefix is corruption and makes the session unloadable. - **The file backend is canonical while the service remains extensible.** `dsh-session-persistence-jsonl` is the sole first-party provider and passes `runPersistenceContract`; the abstract service remains available to out-of-tree providers. The [JSONL-only persistence decision](../simplification/2026-08-30-jsonl-only-session-persistence.md) owns removal of the first-party database provider and its deliberate compatibility cut. - **Metadata is out-of-log.** Format version, cwd, and lineage are storage concerns, not replayable conversation state, so they live in a `SessionHeader` owned by `dsh-session` and attached to a `Session` via a new readonly `session.header` — never in `SessionEventMap`, never reaching `deriveMessages()`. `createdAt` is non-negative safe-integer Unix epoch milliseconds: live creation and persistence registration reject fractional values, and JSONL validates the decoded header. The alternative (a merge-extensible `session/meta` event as log line 0) was rejected: an in-log event would ride along with a seeded/forked session for free, but metadata is not replayable state, so the explicit out-of-log header boundary is the cleaner cost. (The header was originally split into an immutable `SessionHeader` plus a mutable `SessionSummary` whose union was `SessionMeta`; the mutable summary was later removed as dead state — see [Drop the mutable session summary](../../archived/simplification/2026-06-19-drop-mutable-session-summary.md).) @@ -29,7 +29,7 @@ Key durable, contested choices: Each key choice above records its rejected alternative where the choice is stated: a **stream-filtered canonical log** — loses attempt evidence, while removing events without an explicit migration breaks contiguous sequence numbers; **truncating a crashed turn** — silently destroys a long autonomous run's real work; an **in-log `session/meta` event as log line 0** — metadata is not replayable state; **finite fractional `createdAt` values** — have no producer and diverge from integer Unix-millisecond storage; **hard-injecting `sessionPersistence` into the loop** — would pend non-persistent demos forever. -Format versioning: the header carries a `version`; handles expose only `SESSION_FORMAT_VERSION = 2`. JSONL event-body reads compose the static v0-to-v1 and v1-to-v2 adjacent migration chain before returning a handle; the first edge owns bounded legacy normalization, while the second owns Assistant stream embedding and dense reference remapping. V0 remains at suffixless `session.jsonl[.zstd]`, while positive versions use immutable lowercase `session.vN.jsonl[.zstd]` names ([released Session migration](2026-08-31-released-session-format-migrations.md)). Current-generation append and flush are robust to partial trailing writes; a future provider or write-ahead log needs its own power-loss and recovery contract. +Format versioning: the header carries a `version`; handles expose only the logical format selected by `SESSION_FORMAT_VERSION` ([version authority](../../../../docs/session-format-status.md)). JSONL event-body reads compose the complete static adjacent migration chain before returning a handle; each edge owns its historical transformations. V0 remains at suffixless `session.jsonl[.zstd]`, while positive versions use immutable lowercase `session.vN.jsonl[.zstd]` names ([released Session migration](2026-08-31-released-session-format-migrations.md)). Current-generation append and flush are robust to partial trailing writes; a future provider or write-ahead log needs its own power-loss and recovery contract. ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md index 1fe84d955f..f70b8005a2 100644 --- a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md @@ -19,7 +19,7 @@ Status: implemented 长期有效、存在争议的关键选择: -- **规范持久日志无损保留每个当前 `SessionEvent`。** 在 v2 中,一个 `assistant/message` 或 `assistant/attempt` 会嵌入该 attempt 的精确带时间 provider stream;`deriveMessages()` 只投影 surface message。删除嵌入 stream 成员看似诱人,但会丢失 replay、timing、usage、部分失败与诊断事实。移除完整事件同样需要密集重新编号,因为 `seq = log.length` 且 `events[i].seq === i`;[v1 到 v2 迁移](2026-09-01-v2-embedded-assistant-streams.zh.md)会显式执行该改写,而不是过滤规范日志。 +- **规范持久日志无损保留每个当前 `SessionEvent`。** 一个 `assistant/message` 或 `assistant/attempt` 会嵌入该 attempt 的精确带时间 provider stream;`deriveMessages()` 只投影 surface message。删除嵌入 stream 成员看似诱人,但会丢失 replay、timing、usage、部分失败与诊断事实。移除完整事件同样需要密集重新编号,因为 `seq = log.length` 且 `events[i].seq === i`;[v1 到 v2 迁移](2026-09-01-v2-embedded-assistant-streams.zh.md)会显式执行该改写,而不是过滤规范日志。 - **仅追加;崩溃的轮次被关闭,而非截断。** 已刷写的事件永不被重写。[语义检查点策略](../../../../packages/session/session-checkpoint-policy/README.zh.md)会在调用模型前排空请求、在调用工具前排空已记录的顶层调用,并在步骤结束后排空完整的响应/结果批次;循环则排空最终轮次边界。由于一个被中断的轮次可能包含大量有效工作,持久化会原样返回其连续、可解析的事件;配平是读方的职责——resume 会为未应答的 assistant 调用计算按风险分类的错误结果、补一个缺失的 `step/end`,以及带 `{ kind: 'interrupted' }` 的 `turn/end`(`interruptedTurnClosers`),并通过其写句柄追加它们,而只读观察方仅在内存中添加同样的收尾事件。合成结果保证恢复后的提供方 transcript(文本记录)仍然有效。只有撕裂的最终 append 中不完整的碎片会被丢弃——从中恢复的完整记录由写路径在第一次新 append 之前持久重写;已提交前缀中的解析错误或序号间隙,属于数据损坏,会使该会话不可加载。 - **文件后端为规范实现,服务保持可扩展。** `dsh-session-persistence-jsonl` 是唯一 first-party provider,并通过 `runPersistenceContract`;抽象服务继续供仓库外 provider 使用。[JSONL-only 持久化决策](../simplification/2026-08-30-jsonl-only-session-persistence.zh.md)负责 first-party 数据库 provider 的删除及其明确 compatibility cut。 - **元数据在日志之外。** 格式版本、cwd 和谱系是存储关注点,不是可回放的对话状态,因此它们存放在 `dsh-session` 拥有的 `SessionHeader` 中,并通过新的只读属性 `session.header` 附加到 `Session` 上——永远不进入 `SessionEventMap`,永远不到达 `deriveMessages()`。`createdAt` 是以 Unix epoch 毫秒表示的非负安全整数:运行时创建和持久化注册会拒绝小数值,JSONL 会验证解码后的 header。替代方案(一个可合并扩展的 `session/meta` 事件作为日志第 0 行)被否决:日志内事件会自然随 seed/fork 的会话携带,但元数据不是可回放状态,因此显式的日志外 header 边界是更清晰的取舍。(header 最初被拆分为不可变的 `SessionHeader` 加可变的 `SessionSummary`,二者的联合类型为 `SessionMeta`;可变 summary 后来因属于死状态而被移除——见 [移除可变会话摘要](../../archived/simplification/2026-06-19-drop-mutable-session-summary.md)。) @@ -29,7 +29,7 @@ Status: implemented 上述每个关键选择都在陈述处记录了被否决的替代方案:**过滤 stream 的规范日志**会丢失 attempt 证据,而未通过显式迁移移除事件会破坏连续序号;**截断崩溃的轮次**会静默销毁长时间自主运行中的真实工作;**日志内 `session/meta` 事件作为第 0 行**——元数据不是可回放状态;**有限的非整数 `createdAt` 值**没有生产方,且与整数 Unix 毫秒存储不一致;**将 `sessionPersistence` 硬注入循环**会让非持久化的演示永远挂起。 -格式版本控制:header 携带 `version`;句柄只暴露 `SESSION_FORMAT_VERSION = 2`。JSONL 的事件正文读取会在返回句柄前组合静态 v0-to-v1 与 v1-to-v2 相邻迁移链;第一条边负责有界 legacy normalization,第二条边负责 Assistant stream 嵌入与密集引用重映射。V0 保留无后缀的 `session.jsonl[.zstd]`,正版本则使用不可变的小写 `session.vN.jsonl[.zstd]` 名称([已发布 Session 迁移](2026-08-31-released-session-format-migrations.zh.md))。当前 generation 的 append 与 flush 能稳健处理不完整尾部写入;未来 provider 或 WAL 必须定义自己的断电与恢复约定。 +格式版本控制:header 携带 `version`;句柄只暴露由 `SESSION_FORMAT_VERSION` 选定的逻辑格式([版本真源](../../../../docs/session-format-status.zh.md))。JSONL 的事件正文读取会在返回句柄前组合完整的静态相邻迁移链;每条迁移边拥有自身的历史转换。V0 保留无后缀的 `session.jsonl[.zstd]`,正版本则使用不可变的小写 `session.vN.jsonl[.zstd]` 名称([已发布 Session 迁移](2026-08-31-released-session-format-migrations.zh.md))。当前 generation 的 append 与 flush 能稳健处理不完整尾部写入;未来 provider 或 WAL 必须定义自己的断电与恢复约定。 ## 后果 diff --git a/.agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.i18n.yaml index 6d86465557..0521855ea8 100644 --- a/.agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.md -2026-08-27-handle-based-session-persistence.md: 9b5ff5d46444ac924859c4121abc1cf5d1538485 -2026-08-27-handle-based-session-persistence.zh.md: c9230c899141f4ab9979f93256da42a3aaae0984 +2026-08-27-handle-based-session-persistence.md: e2f07856b7ef8ffd8180ed7ff515210c95dde29b +2026-08-27-handle-based-session-persistence.zh.md: 472a1024887ecca67245de27f52c68ec0b34caa1 diff --git a/.agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.md b/.agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.md index 9b5ff5d464..e2f07856b7 100644 --- a/.agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.md +++ b/.agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.md @@ -32,7 +32,7 @@ The previous persistence seam owned far more than storage. A shared coordinator ## Consequences -Resume, fork, subagent, ACP, webhook, and SDK sessions all persist through one explicit acquisition point, and dispose provably releases write ownership (reopening for write succeeds after teardown). The costs: a backend plugin reload under live sessions invalidates their handles — writes fail loudly until the sessions restart, where adoption previously re-attached silently; `ctx.sessions.create` + `flush` in a test persists nothing without a handle (tests seed through `create`/`append`/`close`); resume re-reads a cold log only when no immediately preceding observation parsed the same artifact — a bounded provider-local memo (session id + stat revision, invalidated by every local mutation) serves the observe-then-promote and authorize-then-resume handoffs without restoring the deleted borrow/reservation lifecycle, and the session-query reader's own prepared cache remains the pin-capable layer above it (a later consolidation may fold one into the other); and an empty created session is invisible to other processes until an explicit flush (ACP forces one for its resumable-empty-session promise). `SESSION_FORMAT_VERSION` stays 0. +Resume, fork, subagent, ACP, webhook, and SDK sessions all persist through one explicit acquisition point, and dispose provably releases write ownership (reopening for write succeeds after teardown). The costs: a backend plugin reload under live sessions invalidates their handles — writes fail loudly until the sessions restart, where adoption previously re-attached silently; `ctx.sessions.create` + `flush` in a test persists nothing without a handle (tests seed through `create`/`append`/`close`); resume re-reads a cold log only when no immediately preceding observation parsed the same artifact — a bounded provider-local memo (session id + stat revision, invalidated by every local mutation) serves the observe-then-promote and authorize-then-resume handoffs without restoring the deleted borrow/reservation lifecycle, and the session-query reader's own prepared cache remains the pin-capable layer above it (a later consolidation may fold one into the other); and an empty created session is invisible to other processes until an explicit flush (ACP forces one for its resumable-empty-session promise). Handle ownership does not change the serialized Session representation. ## Related diff --git a/.agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.zh.md b/.agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.zh.md index c9230c8991..472a102488 100644 --- a/.agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.zh.md @@ -32,7 +32,7 @@ Status: implemented ## 后果 -恢复、fork、subagent、ACP、webhook 与 SDK 会话全部经由一个显式获取点持久化,且 dispose 可证明地释放写所有权(teardown 之后重新以写模式打开可以成功)。代价:在有活跃会话时重载后端插件会使它们的句柄失效——写入会响亮地失败,直到会话重启,而以前接管会静默重连;测试中 `ctx.sessions.create` + `flush` 在没有句柄时什么也不持久化(测试通过 `create`/`append`/`close` 播种);只有当紧邻其前没有观察读解析过同一产物时,恢复才重新读取冷日志——一个有界的 provider 内部 memo(按会话 id + stat 修订号,任何本地修改都使其失效)服务观察后提升与授权后恢复这两类交接,而不恢复已删除的 borrow/reservation 生命周期;session-query reader 自己的已准备缓存仍是其上方具备 pin 能力的一层(后续可考虑二者收敛);空的已创建会话在显式 flush 之前对其他进程不可见(ACP 为其可恢复空会话承诺强制执行一次 flush)。`SESSION_FORMAT_VERSION` 保持为 0。 +恢复、fork、subagent、ACP、webhook 与 SDK 会话全部经由一个显式获取点持久化,且 dispose 可证明地释放写所有权(teardown 之后重新以写模式打开可以成功)。代价:在有活跃会话时重载后端插件会使它们的句柄失效——写入会响亮地失败,直到会话重启,而以前接管会静默重连;测试中 `ctx.sessions.create` + `flush` 在没有句柄时什么也不持久化(测试通过 `create`/`append`/`close` 播种);只有当紧邻其前没有观察读解析过同一产物时,恢复才重新读取冷日志——一个有界的 provider 内部 memo(按会话 id + stat 修订号,任何本地修改都使其失效)服务观察后提升与授权后恢复这两类交接,而不恢复已删除的 borrow/reservation 生命周期;session-query reader 自己的已准备缓存仍是其上方具备 pin 能力的一层(后续可考虑二者收敛);空的已创建会话在显式 flush 之前对其他进程不可见(ACP 为其可恢复空会话承诺强制执行一次 flush)。句柄所有权不改变序列化的 Session 表示。 ## 相关 diff --git a/.agents/notes/implemented/architecture/2026-08-31-alpha-historical-unknown-event-refusal.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-31-alpha-historical-unknown-event-refusal.i18n.yaml index d018fd81f7..c976a60147 100644 --- a/.agents/notes/implemented/architecture/2026-08-31-alpha-historical-unknown-event-refusal.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-31-alpha-historical-unknown-event-refusal.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-31-alpha-historical-unknown-event-refusal.md -2026-08-31-alpha-historical-unknown-event-refusal.md: 58690e30281c1f5e10f85726c1f1e50fd4664fe9 -2026-08-31-alpha-historical-unknown-event-refusal.zh.md: 73ab2ca47ab3f68b11e71ffec09287253215aa53 +2026-08-31-alpha-historical-unknown-event-refusal.md: c63b36f63206e0992416239483d908b0645a98c2 +2026-08-31-alpha-historical-unknown-event-refusal.zh.md: 231c46c11486b05a69a200b857a0ea6df35b6e79 diff --git a/.agents/notes/implemented/architecture/2026-08-31-alpha-historical-unknown-event-refusal.md b/.agents/notes/implemented/architecture/2026-08-31-alpha-historical-unknown-event-refusal.md index 58690e3028..c63b36f632 100644 --- a/.agents/notes/implemented/architecture/2026-08-31-alpha-historical-unknown-event-refusal.md +++ b/.agents/notes/implemented/architecture/2026-08-31-alpha-historical-unknown-event-refusal.md @@ -14,7 +14,7 @@ Silently copying such an event can leave stale numeric references after a later The alpha v0-to-v1 edge owns a frozen complete released-v0 event and payload inventory. It refuses every unknown historical event type before target staging, including an event marked `ignorable: true`, and refuses unexpected members of known payloads except fields explicitly classified as owner-opaque JSON. Merge-extensible nested discriminants remain part of that explicit policy: unknown content-block types, message-source kinds, assistant finish-reason kinds, and turn-ending reason kinds are preserved as owner-opaque JSON, while known arms receive structural validation. The diagnostic names the event type, its sequence number, and the unchanged source generation. -The rule applies only while crossing a historical format edge. Ordinary current-format reading retains the established envelope behavior: an unknown required event refuses, while an unknown event carrying `ignorable: true` remains readable. New v1 external events therefore keep the existing equal-version extension seam, but they do not become implicitly migratable by a future format edge. +The rule applies only while crossing a historical format edge. Ordinary current-format reading retains the established envelope behavior: an unknown required event refuses, while an unknown event carrying `ignorable: true` remains readable. Native current-format external events therefore keep the existing equal-version extension seam, but they do not become implicitly migratable by a future format edge. Every first-party source event type has an executable disposition and target validator in the edge package. The catalog is build-static and profile-independent, so mounting or omitting the producer plugin cannot change whether an old artifact migrates. diff --git a/.agents/notes/implemented/architecture/2026-08-31-alpha-historical-unknown-event-refusal.zh.md b/.agents/notes/implemented/architecture/2026-08-31-alpha-historical-unknown-event-refusal.zh.md index 73ab2ca47a..231c46c114 100644 --- a/.agents/notes/implemented/architecture/2026-08-31-alpha-historical-unknown-event-refusal.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-31-alpha-historical-unknown-event-refusal.zh.md @@ -14,7 +14,7 @@ Status: implemented Alpha v0-to-v1 迁移边拥有冻结且完整的已发布 v0 事件与 payload 清单。它在目标 staging 前拒绝每个未知历史事件类型,包括标记了 `ignorable: true` 的事件;除明确分类为 owner 不透明 JSON 的字段外,它也拒绝已知 payload 的意外成员。可合并扩展的嵌套判别字段同样属于这项显式策略:未知 content-block type、message-source kind、assistant finish-reason kind 与 turn-ending reason kind 会作为 owner 不透明 JSON 保留,已知分支则接受结构校验。诊断会点名事件类型、序号和保持不变的源 generation。 -该规则只适用于跨越历史格式迁移边。普通当前格式读取保留既有信封行为:未知必需事件被拒绝,带 `ignorable: true` 的未知事件仍可读取。因此新的 v1 外部事件继续使用既有同版本扩展 seam,但不会自动获得未来格式迁移能力。 +该规则只适用于跨越历史格式迁移边。普通当前格式读取保留既有信封行为:未知必需事件被拒绝,带 `ignorable: true` 的未知事件仍可读取。因此原生当前格式的外部事件继续使用既有同版本扩展 seam,但不会自动获得未来格式迁移能力。 每个第一方源事件类型都在迁移边包中拥有可执行 disposition 与目标 validator。catalog 在构建时静态确定且与 profile 无关,因此 producer 插件是否挂载不会改变旧产物能否迁移。 diff --git a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.i18n.yaml index 2d7c9e0a83..759741d3f1 100644 --- a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md -2026-08-31-released-session-format-migrations.md: b10de9b20570bf6a9f7407d11c16af9b61a4a62c -2026-08-31-released-session-format-migrations.zh.md: 7707cfffe02f42c685741870e97b6a2f75af540c +2026-08-31-released-session-format-migrations.md: d566459af64f4177ed0135813e75aaf77480823c +2026-08-31-released-session-format-migrations.zh.md: f82b406117691197d808111f1c8aa4c722d4bab2 diff --git a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md index b10de9b205..d566459af6 100644 --- a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md +++ b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md @@ -76,7 +76,7 @@ Preset renames cover the creation header and every selection event because the l A source inherited count can be unknown before EOF: V2 derives it from seed markers, and V1→V2 can change cardinality. The chain passes that absence to the next stage instead of fabricating a count. The [V2-to-V3 inheritance rules](../../../../packages/session/session-format-v2-to-v3/README.md#sequence-references) support this case; older stages that require a header-supplied count still refuse when it is absent. This permits seeded multi-hop restoration without retaining an intermediate artifact array. -V3 is a released Session format: [dsh-v0.1.5-alpha.1](https://github.com/deepseek-harness/deepseek-harness/releases/tag/dsh-v0.1.5-alpha.1) ships a [V3 writer](https://github.com/deepseek-harness/deepseek-harness/blob/dsh-v0.1.5-alpha.1/packages/core/session/src/types.ts#L88). An alpha product release still establishes a released persistence format. V0 through V3 retain their released semantics; committed generations remain byte-preserved during migration. A subsequent structural change requires the next adjacent edge under the [versioning rule](2026-08-10-session-log-version-mechanism.md), not an amendment to V2→V3. Ordinary event additions follow that rule’s required-event refusal mechanism rather than automatically allocating a version. An existing V3 file does not rerun its incoming migration; integration tests use isolated disposable homes and unchanged historical inputs. +The [version and release-status reference](../../../../docs/session-format-status.md) owns the published-format record and identifies the code’s writer authority. Released formats retain their semantics; committed generations remain byte-preserved during migration. A subsequent structural change requires the next adjacent edge under the [versioning rule](2026-08-10-session-log-version-mechanism.md), not an amendment to a released conversion. Ordinary event additions follow that rule’s required-event refusal mechanism rather than automatically allocating a version. A current-format file does not rerun its incoming migration; integration tests use isolated disposable homes and unchanged historical inputs. The [committed-corpus inventory](../../../../packages/test-support/llm-replay/tests/session-format-corpus-inventory.ts) identifies deliberately unsupported historical conversions by source path, generation, and exact refusal reason. Retaining those artifacts must not force chronology-changing migration or permit a blanket skip: every listed artifact must still raise the typed migration refusal, and unlisted artifacts must restore. Native current-generation fixtures cannot be classified as unsupported, because they do not traverse an incoming edge. Headerless test-harness protocol examples remain a separate explicit class. The corpus test checks source bytes after both successful and refused restoration; it does not rewrite historical evidence to satisfy the current reader. diff --git a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md index 7707cfffe0..f82b406117 100644 --- a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md @@ -76,7 +76,7 @@ Chain 中不存在 `flatMap`、spread expansion、中间 event array 或 schedul 源继承数量在 EOF 前可能未知:V2 从种子标记推导它,而 V1→V2 可以改变事件数量。迁移链将这种缺失传递给下一个 Stage,而不伪造数量。[V2 到 V3 继承规则](../../../../packages/session/session-format-v2-to-v3/README.zh.md#sequence-references)支持此情况;需要 header 提供数量的旧 Stage 仍在数量缺失时拒绝。这使有种子的多跳恢复无需保留中间产物数组。 -V3 是已发布的 Session 格式:[dsh-v0.1.5-alpha.1](https://github.com/deepseek-harness/deepseek-harness/releases/tag/dsh-v0.1.5-alpha.1) 已交付 [V3 写入器](https://github.com/deepseek-harness/deepseek-harness/blob/dsh-v0.1.5-alpha.1/packages/core/session/src/types.ts#L88)。产品的 alpha 发布同样确立已发布的持久化格式。V0 至 V3 保留各自已发布的语义;迁移期间已提交代际的字节保持不变。后续结构性变更必须按[版本规则](2026-08-10-session-log-version-mechanism.zh.md)添加下一条相邻迁移边,而非修改 V2→V3。普通事件新增遵循该规则的必需事件拒绝机制,而非自动分配版本。已有 V3 文件不会重新执行入边迁移;集成测试使用隔离、可丢弃的 home 和未变更的历史输入。 +[版本与发布状态参考](../../../../docs/session-format-status.zh.md)拥有已发布格式记录,并指明代码中的写入器真源。已发布格式保留其语义;迁移期间已提交代际的字节保持不变。后续结构性变更必须按[版本规则](2026-08-10-session-log-version-mechanism.zh.md)添加下一条相邻迁移边,而非修改已发布转换。普通事件新增遵循该规则的必需事件拒绝机制,而非自动分配版本。当前格式文件不会重新执行入边迁移;集成测试使用隔离、可丢弃的 home 和未变更的历史输入。 [已提交语料清单](../../../../packages/test-support/llm-replay/tests/session-format-corpus-inventory.ts) 按源路径、代际与精确拒绝原因标识有意不支持的历史转换。保留这些产物不能迫使迁移改变时序,也不能允许统一跳过:每个清单中的产物仍必须抛出类型化迁移拒绝,未列入的产物必须还原。原生当前代际 fixture 不经过入边,因此不能被归为不支持。没有版本 header 的测试框架协议示例保持为独立的显式类别。语料测试在还原成功和拒绝后都检查源字节;它不通过改写历史证据来满足当前 reader。 diff --git a/.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.i18n.yaml index 12fdfd3b82..174aa2592e 100644 --- a/.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.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-02-system-prompt-as-surface-node.md -2026-09-02-system-prompt-as-surface-node.md: 1e374e4d30fc6c623c9cf1ee573375ee0c1ea1ad -2026-09-02-system-prompt-as-surface-node.zh.md: 8d5173dd0bf2814da323fc9fd7ca60160a9ac1bf +2026-09-02-system-prompt-as-surface-node.md: 1500fd2350f02ab5d8f203c0d62b98832c6c5de5 +2026-09-02-system-prompt-as-surface-node.zh.md: 306f347092caa3e9daf2494fa26d290b1c1ab9a7 diff --git a/.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.md b/.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.md index 1e374e4d30..1500fd2350 100644 --- a/.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.md +++ b/.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.md @@ -60,7 +60,7 @@ In `packages/core/agent-loop/src/agent.ts`, `preStep` renders the prompt with `r The [V2-to-V3 specification](../../../../packages/session/session-format-v2-to-v3/README.md#system-head) owns system-head conversion and message identities; its [reference rules](../../../../packages/session/session-format-v2-to-v3/README.md#sequence-references) and [source refusal](../../../../packages/session/session-format-v2-to-v3/README.md#source-audit) define preservation and unsupported inputs. The migrated layout is semantically equivalent to native requests, not byte-identical to a native recording. A valid V2 source can lack an order-preserving conversion under the current step invariant; refusing it is preferable to moving history or relaxing ownership. Historical acceptance coordinates must not become acknowledgements of the transformed log. -The [released-format policy](2026-08-31-released-session-format-migrations.md) covers V3 as well as V0, V1, and V2. The V2-to-V3 conversion preserves its released semantics; an existing V3 generation does not rerun that edge. Projection-cache version 4 is independent of the Session format and does not imply Session V4. +The [released-format policy](2026-08-31-released-session-format-migrations.md) preserves each released conversion’s semantics; an existing target-format generation does not rerun its incoming edge. Projection-cache versions are independent of Session format versions. The [canonical-envelope specification](../../../../packages/session/session-format-v2-to-v3/README.md#canonical-envelopes) defines composition with the structural conversion; the [canonical-envelope decision](2026-09-06-v3-canonical-session-envelopes.md) owns the strict-acceptance rationale. diff --git a/.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.zh.md b/.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.zh.md index 8d5173dd0b..306f347092 100644 --- a/.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.zh.md @@ -60,7 +60,7 @@ Status: implemented [V2 到 V3 规范](../../../../packages/session/session-format-v2-to-v3/README.zh.md#system-head)负责系统头节点转换与消息身份;其[引用规则](../../../../packages/session/session-format-v2-to-v3/README.zh.md#sequence-references)和[源拒绝](../../../../packages/session/session-format-v2-to-v3/README.zh.md#source-audit)定义保留内容与不支持的输入。迁移布局与原生请求语义等价,而非与原生录制逐字节相同。有效 V2 源在当前步骤不变量下可能没有保持顺序的转换方式;拒绝它优于移动历史或放宽归属。历史接收坐标不得变为对转换后日志的确认。 -[已发布格式策略](2026-08-31-released-session-format-migrations.zh.md)既覆盖 V0、V1、V2,也覆盖 V3。V2-to-V3 转换保留其已发布的语义;已有 V3 代际不会重跑该迁移边。投影缓存版本 4 独立于 Session 格式,并不意味着 Session V4。 +[已发布格式策略](2026-08-31-released-session-format-migrations.zh.md)保留每条已发布转换的语义;已有目标格式代际不会重跑其入边。投影缓存版本独立于 Session 格式版本。 [规范信封规范](../../../../packages/session/session-format-v2-to-v3/README.zh.md#canonical-envelopes)定义与结构转换的组合;[规范信封决策](2026-09-06-v3-canonical-session-envelopes.zh.md)负责严格准入的依据。 diff --git a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.i18n.yaml b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.i18n.yaml index 692a6fe140..6c552822ed 100644 --- a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.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-10-npm-release-sequences.md -2026-08-10-npm-release-sequences.md: c039db2c7463f93f6f8847ae0ae6100df650f2a5 -2026-08-10-npm-release-sequences.zh.md: 1f14c03beb57d3a574305b348379b1542836083c +2026-08-10-npm-release-sequences.md: 19e8d8db8550816f542111a9b831a3694a080de5 +2026-08-10-npm-release-sequences.zh.md: b7a84f7af4203d775555770b8c61b87720bcddb1 diff --git a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md index c039db2c74..19e8d8db85 100644 --- a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md +++ b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md @@ -117,7 +117,7 @@ The dsh family applies the repository's publication payload policy, which reject The `pack` job walks the whole release set once, packing each member into one directory, writes the upload order, and uploads that directory as one artifact; it lives in `release.yml` / `release-vendor.yml`. The release set is one unit — half the packages can never reach the registry while the other half is still building. -`pack` carries no credentials and runs on every pull request and master push, so a pull request proves the release set still packs. Publication lives in a separate `release-publish.yml` / `release-vendor-publish.yml` workflow that is `workflow_dispatch`-only (so it never appears as a PR check): it repacks the current tree and then publishes each entry in order, behind the `npm-publish` environment for human approval. Pack runs are grouped per ref so concurrent pull requests do not displace each other; the `publish` job carries the global `Release-publish` group, because dist-tags are shared registry state. +`pack` carries no credentials and runs on every pull request and master push, so a pull request proves the release set still packs. Publication lives in a separate `release-publish.yml` / `release-vendor-publish.yml` workflow that is `workflow_dispatch`-only (so it never appears as a PR check): it repacks the current tree and then publishes each entry in order, behind the `npm-publish` environment for human approval. Pack runs are grouped per ref so concurrent pull requests do not displace each other; the `publish` job carries the global `Release-publish` group, because dist-tags are shared registry state. After a dsh publication succeeds, the release operator verifies its Session writer against the [release record](../../../../docs/session-format-status.md#updating-the-record) and updates that record when a higher Session format has shipped. A dsh verification installs the vendored family's pack output too. The harness packages declare the vendored framework as a peer, those packages live in another sequence, and the credential-free job cannot fetch them from a private registry — so the dsh `pack` job packs the vendored family for verification while publishing only the dsh set. The publish workflow (`release-publish.yml`) repacks the current tree and publishes only the dsh set. diff --git a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.zh.md b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.zh.md index 1f14c03beb..b7a84f7af4 100644 --- a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.zh.md +++ b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.zh.md @@ -117,7 +117,7 @@ dsh 族套用仓库的发布 payload 策略(拒绝源码与声明映射)。v `pack` job 一趟遍历整个发布集,把每个成员打进同一个目录,写出上传顺序,整个目录作为一份 artifact 上传;它位于 `release.yml` / `release-vendor.yml`。发布集是一个整体——绝不会出现一半的包已经上了 registry、另一半还在构建。 -`pack` 无凭据,在每个 pull request 和每次 master push 上跑,所以一个 pull request 就能证明发布集仍能完整打出来。发布则位于独立的 `release-publish.yml` / `release-vendor-publish.yml` 工作流,仅 `workflow_dispatch`(因此不会作为 PR check 出现):它重新打包当前树,再按顺序逐个发布,挂在 `npm-publish` environment 后面等人工审批。pack 的 run 按 ref 分组,并发的 pull request 不会互相顶掉;全局 `Release-publish` 分组落在 `publish` job 上,因为 dist-tag 是共享的 registry 状态。 +`pack` 无凭据,在每个 pull request 和每次 master push 上跑,所以一个 pull request 就能证明发布集仍能完整打出来。发布则位于独立的 `release-publish.yml` / `release-vendor-publish.yml` 工作流,仅 `workflow_dispatch`(因此不会作为 PR check 出现):它重新打包当前树,再按顺序逐个发布,挂在 `npm-publish` environment 后面等人工审批。pack 的 run 按 ref 分组,并发的 pull request 不会互相顶掉;全局 `Release-publish` 分组落在 `publish` job 上,因为 dist-tag 是共享的 registry 状态。 dsh 发布成功后,发布操作者按[发布记录](../../../../docs/session-format-status.zh.md#updating-the-record)核实其 Session 写入器;若交付了更高的 Session 格式,则更新该记录。 dsh 的验证会一并安装 vendored 族的 pack 产物。harness 的包把 vendored 框架声明成 peer,而那些包属于另一条序列,无凭据的 job 无法从私有 registry 取到——所以 dsh 的 `pack` job 为验证而打包 vendored 族,发布的仍只有 dsh 那一份。发布工作流(`release-publish.yml`)重新打包当前树,只发布 dsh 族。 diff --git a/.agents/notes/implemented/testing/2026-08-24-session-log-snapshot-corpus.i18n.yaml b/.agents/notes/implemented/testing/2026-08-24-session-log-snapshot-corpus.i18n.yaml index c2a7bcd232..84f01fcd06 100644 --- a/.agents/notes/implemented/testing/2026-08-24-session-log-snapshot-corpus.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-08-24-session-log-snapshot-corpus.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-08-24-session-log-snapshot-corpus.md -2026-08-24-session-log-snapshot-corpus.md: ebcd9709f9cd17ad288d787a13ca66efee5fcc42 -2026-08-24-session-log-snapshot-corpus.zh.md: 8d0614e150c927b491784b51a166e752a7718dae +2026-08-24-session-log-snapshot-corpus.md: 593e1c5569107d50cfc85d7df4fd9491107847b8 +2026-08-24-session-log-snapshot-corpus.zh.md: 2ddccef3cc2942449707edd166e75bffa3ab64d8 diff --git a/.agents/notes/implemented/testing/2026-08-24-session-log-snapshot-corpus.md b/.agents/notes/implemented/testing/2026-08-24-session-log-snapshot-corpus.md index ebcd9709f9..593e1c5569 100644 --- a/.agents/notes/implemented/testing/2026-08-24-session-log-snapshot-corpus.md +++ b/.agents/notes/implemented/testing/2026-08-24-session-log-snapshot-corpus.md @@ -22,7 +22,7 @@ Fixture decoding and comparison depend only on the selected JSONL content; filen Headless stderr reconstruction expands embedded reasoning from both `assistant/message` and log-only `assistant/attempt` settlements, so failed or retried reasoning remains part of the projected process output. -Each parent or child role uses `session[.][.vN].jsonl`, with v0 encoded by an omitted version and every filename matching its header. Replay, record, and refresh select the numerically highest generation per role. Most owners omit `sessionFormat` and track the current writer; a bounded historical owner declares its exact version and closed coverage names. The v2 corpus keeps selected v0 roles for multi-hop, packed-row, retry/failure, and shipped-profile coverage plus selected v1 roles for the adjacent structural edge. Record and refresh never rewrite an explicitly retained historical fixture, rename a committed generation, or delete one through automatic cleanup. A retained Session generation does not freeze its non-Session expected outputs: refresh still writes owned system-prompt and tool-schema sidecars from the current run. Reviewed source-tree curation removes a predecessor only after the same role has a verified current successor. The corpus policy requires current selected roles to remain the majority and caps historical selected roles at ten; lower predecessor generations may remain beside a selected current successor. +Each parent or child role uses `session[.][.vN].jsonl`, with v0 encoded by an omitted version and every filename matching its header. Replay, record, and refresh select the numerically highest generation per role. Most owners omit `sessionFormat` and track the current writer; a bounded historical owner declares its exact version and closed coverage names. The corpus keeps selected v0 roles for multi-hop, packed-row, retry/failure, and shipped-profile coverage plus selected v1 roles for the adjacent structural edge. Record and refresh never rewrite an explicitly retained historical fixture, rename a committed generation, or delete one through automatic cleanup. A retained Session generation does not freeze its non-Session expected outputs: refresh still writes owned system-prompt and tool-schema sidecars from the current run. Reviewed source-tree curation removes a predecessor only after the same role has a verified current successor. The corpus policy requires current selected roles to remain the majority and caps historical selected roles at ten; lower predecessor generations may remain beside a selected current successor. Scenario-owned HTTP fixtures separate the stable authority recorded in the session from their transport listener. Each fixture binds loopback port `0`, lets the operating system allocate and bind the port atomically, and maps the recorded URL or endpoint through the real provider to that listener. Any process-global transport interception matches only the recorded endpoint, is owned by the fixture fiber, and is restored before the listener closes. @@ -30,7 +30,7 @@ Every existing ACP scenario receives a behavior-preserving destination. Ordinary Workspace inputs remain scenario-local. A mutating scenario compares a complete expected final workspace that record and refresh never rewrite, so a model or tool self-report cannot satisfy the test. Existing intentional session reuse remains an explicit acyclic owner reference; the corpus adds no workspace inheritance or general fixture-merging mechanism. -Current-writer request-header pins are separate from retained migration inputs: `tool-call-turn` pins the default composition, and `empty-response-retry-current` pins the retry composition. Their readable sidecars remain owned by `text-turn`. The six retained historical inputs stay byte-frozen and selected for replay; their pinned directories contain no canonical V3 sibling that could displace them. Separate `writer.expected.jsonl` and `writer..expected.jsonl` files pin exact normalized native V3 parent and child output, while retained SDK scenarios pin current notifications in `notifications.current.expected.jsonl`. These output oracles are not replay generations. The [snapshot kit](../../../../packages/test-support/session-snapshot/README.md) owns selection and refresh behavior. Structural migration can preserve request meaning without reproducing native writer event layout, so the official migration has independent correctness tests. Reverse projection into historical headers, stripping structural differences, skipping output equality, or replacing frozen inputs would conceal regressions instead of verifying those separate obligations. +Current-writer request-header pins are separate from retained migration inputs: `tool-call-turn` pins the default composition, and `empty-response-retry-current` pins the retry composition. Their readable sidecars remain owned by `text-turn`. The six retained historical inputs stay byte-frozen and selected for replay; their pinned directories contain no newer canonical sibling that could displace them. Separate `writer.expected.jsonl` and `writer..expected.jsonl` files pin exact normalized native current-format parent and child output, while retained SDK scenarios pin current notifications in `notifications.current.expected.jsonl`. These output oracles are not replay generations. The [snapshot kit](../../../../packages/test-support/session-snapshot/README.md) owns selection and refresh behavior. Structural migration can preserve request meaning without reproducing native writer event layout, so the official migration has independent correctness tests. Reverse projection into historical headers, stripping structural differences, skipping output equality, or replacing frozen inputs would conceal regressions instead of verifying those separate obligations. ## Alternatives considered diff --git a/.agents/notes/implemented/testing/2026-08-24-session-log-snapshot-corpus.zh.md b/.agents/notes/implemented/testing/2026-08-24-session-log-snapshot-corpus.zh.md index 8d0614e150..2ddccef3cc 100644 --- a/.agents/notes/implemented/testing/2026-08-24-session-log-snapshot-corpus.zh.md +++ b/.agents/notes/implemented/testing/2026-08-24-session-log-snapshot-corpus.zh.md @@ -22,7 +22,7 @@ Fixture 解码与比较只取决于选定 JSONL 内容;文件名标识 invento Headless stderr 重建会同时展开 `assistant/message` 与仅写入日志的 `assistant/attempt` settlement 中嵌入的 reasoning,因此失败或重试尝试的 reasoning 仍属于进程输出投影。 -每个 parent 或 child 角色都使用 `session[.][.vN].jsonl`;v0 省略版本,且每个文件名都与其 header 一致。回放、录制与刷新按角色选择数值最高的 generation。大多数 owner 省略 `sessionFormat` 并跟随当前 writer;受限的历史 owner 会声明精确版本与封闭 coverage 名称。v2 语料保留选定 v0 角色,覆盖多跳、打包行、重试/失败与随附 profile,并保留选定 v1 角色覆盖相邻结构 edge。录制与刷新绝不改写显式保留的历史 fixture、重命名已提交 generation 或通过自动清理删除 generation。保留 Session generation 不会冻结非 Session 预期输出:refresh 仍会根据当前 run 写入 owner 持有的 system-prompt 与 tool-schema sidecar。受审阅的源树整理只有在同角色存在已验证的当前后继后才移除前代。语料策略要求选定当前角色始终占多数,并将选定历史角色上限设为十个;更低的前代 generation 可以保留在选定当前后继旁。 +每个 parent 或 child 角色都使用 `session[.][.vN].jsonl`;v0 省略版本,且每个文件名都与其 header 一致。回放、录制与刷新按角色选择数值最高的 generation。大多数 owner 省略 `sessionFormat` 并跟随当前 writer;受限的历史 owner 会声明精确版本与封闭 coverage 名称。语料保留选定 v0 角色,覆盖多跳、打包行、重试/失败与随附 profile,并保留选定 v1 角色覆盖相邻结构 edge。录制与刷新绝不改写显式保留的历史 fixture、重命名已提交 generation 或通过自动清理删除 generation。保留 Session generation 不会冻结非 Session 预期输出:refresh 仍会根据当前 run 写入 owner 持有的 system-prompt 与 tool-schema sidecar。受审阅的源树整理只有在同角色存在已验证的当前后继后才移除前代。语料策略要求选定当前角色始终占多数,并将选定历史角色上限设为十个;更低的前代 generation 可以保留在选定当前后继旁。 场景拥有的 HTTP fixture 将会话中录制的稳定 authority 与传输 listener 分离。每个 fixture 在回环地址上绑定端口 `0`,由操作系统以一次原子操作分配并绑定端口,再将录制的 URL 或 endpoint 通过真实 provider 映射到该 listener。任何进程全局传输拦截只匹配录制 endpoint,由 fixture fiber 拥有,并在关闭 listener 前恢复。 @@ -30,7 +30,7 @@ Headless stderr 重建会同时展开 `assistant/message` 与仅写入日志的 Workspace 输入继续归各场景本地所有。变更文件的场景比较完整的预期最终 workspace,record 与 refresh 绝不改写该预期,因此模型或工具的自报结果无法满足测试。现有的有意会话复用继续使用显式、无环的所有者引用;语料不增加 workspace 继承或通用 fixture 合并机制。 -当前 writer 的 request-header pin 与保留的迁移输入分离:`tool-call-turn` 固定 default 组合,`empty-response-retry-current` 固定 retry 组合。可读 sidecar 仍由 `text-turn` 持有。六份保留的历史输入保持字节冻结,并继续被选为回放输入;其固定历史版本的目录不含会取代它们的规范 V3 同角色文件。单独的 `writer.expected.jsonl` 与 `writer..expected.jsonl` 文件固定精确的规范化原生 V3 父子会话输出,保留历史输入的 SDK 场景则通过 `notifications.current.expected.jsonl` 固定当前通知。这些输出比较基准不是 replay 代际。[快照工具包](../../../../packages/test-support/session-snapshot/README.zh.md)负责选择与刷新行为。结构迁移可以保留请求含义而不复现原生 writer 的事件布局,因此正式迁移拥有独立的正确性测试。反向投影为历史 header、剥除结构差异、跳过输出相等断言或替换冻结输入都会掩盖回归,而不是验证这些相互独立的约定。 +当前 writer 的 request-header pin 与保留的迁移输入分离:`tool-call-turn` 固定 default 组合,`empty-response-retry-current` 固定 retry 组合。可读 sidecar 仍由 `text-turn` 持有。六份保留的历史输入保持字节冻结,并继续被选为回放输入;其固定历史版本的目录不含会取代它们的更新的规范同角色文件。单独的 `writer.expected.jsonl` 与 `writer..expected.jsonl` 文件固定精确的规范化原生当前格式的父子会话输出,保留历史输入的 SDK 场景则通过 `notifications.current.expected.jsonl` 固定当前通知。这些输出比较基准不是 replay 代际。[快照工具包](../../../../packages/test-support/session-snapshot/README.zh.md)负责选择与刷新行为。结构迁移可以保留请求含义而不复现原生 writer 的事件布局,因此正式迁移拥有独立的正确性测试。反向投影为历史 header、剥除结构差异、跳过输出相等断言或替换冻结输入都会掩盖回归,而不是验证这些相互独立的约定。 ## Alternatives considered diff --git a/AGENTS.md b/AGENTS.md index a479e76625..83ea425984 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ DeepSeek Harness is an all-plugin Cordis agent harness. Read [docs/architecture. ## Pre-stable APIs and released Session data -Public APIs are pre-stable; update every consumer. Released Session JSONL follows [adjacent migration](.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md): body reads may add a version-named successor but never move, overwrite, or delete committed generations; predecessors imply neither fallback nor downgrade support. SQLite domains use monotonic `SCHEMA_VERSION`. +Public APIs are pre-stable; update every consumer. [Session version/status](docs/session-format-status.md) defines the authorities. [Adjacent migration](.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md) may add a version-named successor but never move, overwrite, or delete committed generations; predecessors imply neither fallback nor downgrade support. SQLite uses monotonic `SCHEMA_VERSION`. **Application launch.** Only `dsh` profiles launch supported Node apps; package bins, demos, and public SDK argv escapes are forbidden ([rule](docs/architecture.md#application-launch)). diff --git a/docs/AGENTS.md b/docs/AGENTS.md index bb248a9042..7c345ecac8 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -35,7 +35,7 @@ Placement: bugs → postmortems; rationale → Agent Notes; procedures → cookb ## Writing rules -- **Document current state, not change history.** Avoid "previously/now/no longer", PRs, commits, and stack positions in durable prose; name the live mechanism. Put change stories in commits, PRs, Agent Notes, or postmortems; the latter two may cite merged PRs and issues as evidence. +- **Document current state, not change history.** Name live mechanisms, not PRs, commits, stack positions, or "previously/now/no longer". Keep history in commits, PRs, Agent Notes, or postmortems. General Session-format prose links [version/status authority](session-format-status.md); retain numbers for version-specific contracts, examples, or evidence. - **Every non-trivial change includes at least one Agent Note in the same PR.** Update the owning note or add one; only mechanical/local edits are exempt ([scope](../.agents/notes/README.md#when-to-write-one)). - **One physical line per paragraph** (`verify-md-wrap`): use editor soft-wrap. Code blocks, tables, and list structure keep their formatting; code comments stay under the linter's column limit. - **Fenced `ts` blocks must compile** (`doc-typecheck`); a pasted type declaration and its original JSDoc use ` ```ts type-equiv `, while a body-stripped public class declaration uses ` ```ts public-api `; register either in the manifest so neither can drift ([mechanics](development.md#documenting-types-verbatim-ts-type-equiv)). diff --git a/docs/cookbook/adding-a-session-format-version.i18n.yaml b/docs/cookbook/adding-a-session-format-version.i18n.yaml index d0020b2d3a..020554516a 100644 --- a/docs/cookbook/adding-a-session-format-version.i18n.yaml +++ b/docs/cookbook/adding-a-session-format-version.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cookbook/adding-a-session-format-version.md -adding-a-session-format-version.md: c445e53b0cb3e974581401d2bc99f2d180215933 -adding-a-session-format-version.zh.md: 3c3be794303dc7353d62c3e1eb51465942bc966d +adding-a-session-format-version.md: 54af2806bf7193ce9d1f5cd6c75f92b809c4ed95 +adding-a-session-format-version.zh.md: 4f7135b00aba37758343b8c17f2b4aff3146eeab diff --git a/docs/cookbook/adding-a-session-format-version.md b/docs/cookbook/adding-a-session-format-version.md index c445e53b0c..54af2806bf 100644 --- a/docs/cookbook/adding-a-session-format-version.md +++ b/docs/cookbook/adding-a-session-format-version.md @@ -4,7 +4,7 @@ English | [中文](adding-a-session-format-version.zh.md) ## Summary -Use this tutorial to introduce the next structural Session log version without rewriting released data. Let N be the latest released Session format, verified from release evidence and source, and N+1 the target. V3 is released and frozen; the concrete V3→V4 example assumes N=3. Start with a working contributor checkout and read the [package checklist](adding-a-package.md), [format library](../../packages/session/session-format/README.md), and [released-format decision](../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md). +Use this tutorial to introduce the next structural Session log version without rewriting released data. Read the [version and release-status authority](../session-format-status.md) to identify the checkout writer and the latest released format. Let N denote that verified released format and N+1 the target; substitute numeric values for these placeholders in names and metadata. Start with a working contributor checkout and read the [package checklist](adding-a-package.md), [format library](../../packages/session/session-format/README.md), and [released-format decision](../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md). ## Table of Contents @@ -21,20 +21,20 @@ Use this tutorial to introduce the next structural Session log version without r Bump the format for a structural change to headers, event envelopes, core event semantics, or surface reconstruction. Ordinary event additions do not require a bump; follow the [versioning rule](../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md). Distinguish the Session format integer from package release versions, SQLite schema versions, projection-unit versions, and protocol-wrapper versions. -For the V3→V4 example, use a shared `release/*` integration base, such as `release/session-log-v4`. The base change adds the V4 writer, codec, catalog wiring, identity migration, and verification. Create each independent child branch from that base and target its PR at the release branch, not another independent child's branch. Each child adds its structural transformation, validators, consumers, and tests to the example's `session-format-v3-to-v4` package. Do not introduce V5 or V6 just to represent review order. Merge reviewed children into the release branch through PRs, then validate the combined result before release. Honor release-branch force-push and deletion protections; do not force-sync it. +Use a shared `release/*` integration base for N+1. The base change adds the writer, codec, catalog wiring, identity migration, and verification. Create each independent child branch from that base and target its PR at the release branch, not another independent child’s branch. Each child adds its structural transformation, validators, consumers, and tests to the same adjacent migration package. Do not allocate extra versions just to represent review order. Merge reviewed children into the release branch through PRs, then validate the combined result before release. Honor release-branch force-push and deletion protections; do not force-sync it. -Released codecs and migration semantics, including V3 and V2→V3, remain frozen. Do not amend V0→V1, V1→V2, or V2→V3 to implement a new structural feature. Only the N→N+1 edge may incorporate coordinated changes before N+1 ships; after release, further structural changes need the next adjacent edge. +Released codecs and migration semantics remain frozen. Do not amend a released edge to implement a new structural feature. Only the N→N+1 edge may incorporate coordinated changes before N+1 ships; after release, further structural changes need the next adjacent edge. Use disposable, isolated Harness homes for unreleased N+1 integration testing. An interim N+1 file already has the target writer version, so a later edit to N→N+1 will not migrate that file again. Re-run from unchanged historical input in a fresh test home; never repair this by rewriting a committed generation or reusing a real user's home. ## 2. Add an identity edge -To implement the example V3→V4 edge, follow the package checklist to create a library, not a mounted plugin. An identity body conversion is only an initial wiring scaffold. The released [V2-to-V3 specification](../../packages/session/session-format-v2-to-v3/README.md#v2-to-v3-specification) is an example of explicit transformations and preservation rules, not an edge to extend or treat as an identity conversion. +Follow the package checklist to create a library for N→N+1, not a mounted plugin. An identity body conversion is only an initial wiring scaffold. The [V2-to-V3 specification](../../packages/session/session-format-v2-to-v3/README.md#v2-to-v3-specification) is a fixed example of explicit transformations and preservation rules, not an edge to extend or treat as an identity conversion. -In the example package manifest, declare `dsh.sessionFormatMigration` with `from: 3`, `to: 4`, an export path, and the exported migration, source codec, target codec, target-header validator, and target restorer. Reuse `releasedV3SessionFormatCodec` from the existing V2→V3 package and depend on that package; do not copy or redefine the released V3 codec. Export the example's V4 codec and validators from the new package. Add the new edge as a direct dependency of the catalog and add the workspace's TypeScript paths and project references. +Declare `dsh.sessionFormatMigration` with numeric `from: N` and `to: N+1`, an export path, and the exported migration, source codec, target codec, target-header validator, and target restorer. Reuse the source codec exported by the preceding edge package and depend on that package; do not copy or redefine a released codec. Export the target codec and validators from the new package. Add the edge as a direct dependency of the catalog and add the workspace’s TypeScript paths and project references. -Set `SESSION_FORMAT_VERSION` in [core Session types](../../packages/core/session/src/types.ts) to N+1 (4 in the example) alongside the new edge declarations, then generate the catalog. The command below generates only the declared chain; it does not implement a new version: +Set `SESSION_FORMAT_VERSION` in [core Session types](../../packages/core/session/src/types.ts) to N+1 alongside the new edge declarations, then generate the catalog. The command below generates only the declared chain; it does not implement a new version: ```sh pnpm run gen-session-format-catalog @@ -49,7 +49,7 @@ Use the [Stage interfaces](../../packages/session/session-format/src/types.ts), Implement `transformEvent(event, context)`, `transformRun(run, context)`, and `finish(context)`. Emit synchronously through `context.emitEvent` or `context.emitRun`; a call can produce zero, one, or many outputs. Let a stage consume codec-owned compact runs directly, or iterate `run.expand()` without materializing an intermediate array. The caller owns scheduling, and the chain finishes upstream stages before downstream stages. -Treat the inherited cut as a logical event count, not a physical row count. Expose `headerInheritedEventCount` only when it is known before EOF; `finish` returns the exact target cut. A preceding cardinality-changing edge can make that count unavailable at construction. Derive it from validated seed markers when required, and test the example's V0→V1→V2→V3→V4, V1→V2→V3→V4, and V2→V3→V4 chains with seeded Sessions, not just direct V3 input. Never substitute zero for an unknown cut. +Treat the inherited cut as a logical event count, not a physical row count. Expose `headerInheritedEventCount` only when it is known before EOF; `finish` returns the exact target cut. A preceding cardinality-changing edge can make that count unavailable at construction. Derive it from validated seed markers when required, and test seeded multi-hop restoration from each supported historical generation through N+1, not just direct N input. Never substitute zero for an unknown cut. Define the new edge's event admission and transformation rules explicitly. The [V2-to-V3 source audit](../../packages/session/session-format-v2-to-v3/README.md#source-audit) and [alpha V0→V1 rule](../../.agents/notes/implemented/architecture/2026-08-31-alpha-historical-unknown-event-refusal.md) own the policies of those released edges, not the new edge. Do not generalize either to every edge. A change to structure or event positions requires classifying source events, payload members, and references, and explicitly deciding whether opaque data can remain valid. [Equal-version retention](../../.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.md) alone does not prove a structural transformation safe. Validate target semantics and give each newly accepted case a rejecting counterexample; never widen older edges to hide an unsupported transformation. @@ -67,7 +67,7 @@ Verify both read and write paths. Header-only listing must not read bodies or pu ## 5. Create snapshot successors -Read [snapshot ownership](../../snapshots/AGENTS.md) and the [snapshot library](../../packages/test-support/session-snapshot/README.md). Select the owning scenario, not an adapter that only references it. After implementing N+1, keep each historical file and generate its successor. In the V3→V4 example, use `session.v4.jsonl` for the parent and `session.1.v4.jsonl`, `session.2.v4.jsonl`, and so on for children. Never rename `session.v3.jsonl` to V4 or change only its header. +Read [snapshot ownership](../../snapshots/AGENTS.md) and the [snapshot library](../../packages/test-support/session-snapshot/README.md). Select the owning scenario, not an adapter that only references it. After implementing N+1, keep each historical file and generate its successor using the target version’s canonical parent and child filenames. Never rename a predecessor to the target filename or change only its header. For unchanged replay input, use keyless refresh on the owner, then replay without write-back. These SDK commands use `text-turn` and the checkout's writer version. Implement and wire N+1 before using them to generate that version, and select the actual affected owner for a feature: @@ -93,7 +93,7 @@ pnpm run test:snapshot scripts/session-snapshot-corpus.corpus.ts After implementing the new edge, add its actual test path to the focused Vitest run. Add the changed JSONL, replay, projection, and SDK tests selected by the actual diff, plus the built publication-Worker smoke when that path changes. Require successful strict migration, identity preservation for the skeleton, malformed and unknown-required-event refusal, deterministic repeated restores, independent concurrent stage state, seeded multi-hop cuts, unchanged predecessors, and no fallback. Report exact commands and failures, not an inferred full-suite result. -Update the [owning Agent Note](../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md) rather than adding a redundant decision record. Audit related active notes for supersession; retain independent rationale and leave archived notes frozen. Update bilingual prose together, re-record each changed pair with the repository tool, then run documentation checks: +Update the [owning Agent Note](../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md) rather than adding a redundant decision record. Keep the [release record](../session-format-status.md#updating-the-record) unchanged until publication; after publication, update it with verified release evidence. Audit related active notes for supersession; retain independent rationale and leave archived notes frozen. Update bilingual prose together, re-record each changed pair with the repository tool, then run documentation checks: ```sh pnpm run verify-translation-pairing --write docs/cookbook/adding-a-session-format-version.md diff --git a/docs/cookbook/adding-a-session-format-version.zh.md b/docs/cookbook/adding-a-session-format-version.zh.md index 3c3be79430..4f7135b00a 100644 --- a/docs/cookbook/adding-a-session-format-version.zh.md +++ b/docs/cookbook/adding-a-session-format-version.zh.md @@ -4,7 +4,7 @@ ## 概述 -本教程介绍如何添加下一个结构性 Session 日志版本,同时不改写已发布数据。令 N 为经发布证据与源码确认的最新已发布 Session 格式,N+1 为目标版本。V3 已发布并冻结;具体的 V3→V4 示例假设 N=3。开始前,请准备可用的贡献者工作区,并阅读[包检查清单](adding-a-package.zh.md)、[格式库](../../packages/session/session-format/README.zh.md)和[已发布格式决策](../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md)。 +本教程介绍如何添加下一个结构性 Session 日志版本,同时不改写已发布数据。阅读[版本与发布状态真源](../session-format-status.zh.md),确定工作区写入器与最新已发布格式。令 N 表示经核实的已发布格式,N+1 表示目标版本;名称与元数据中的这些占位符须替换为数字。开始前,请准备可用的贡献者工作区,并阅读[包检查清单](adding-a-package.zh.md)、[格式库](../../packages/session/session-format/README.zh.md)和[已发布格式决策](../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md)。 ## 目录 @@ -21,20 +21,20 @@ 当 header、事件信封、核心事件语义或表面重建发生结构性变更时,提升格式版本。普通事件新增不需要提升版本;遵循[版本规则](../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md)。区分 Session 格式整数与包发布版本、SQLite schema 版本、投影单元版本及协议包装层版本。 -在 V3→V4 示例中,使用共享的 `release/*` 集成基线,例如 `release/session-log-v4`。基线变更添加 V4 写入器、codec、catalog 接线、恒等迁移与验证。从该基线创建各个独立子分支,并将其 PR(Pull Request)的目标设为发布分支,而非另一个独立子分支。每个子分支在示例的 `session-format-v3-to-v4` 包内添加自身的结构变换、校验器、消费方和测试。不要只为表示评审顺序而引入 V5 或 V6。通过 PR 将评审后的子分支合入发布分支,并在发布前验证组合结果。遵守发布分支的强制推送与删除保护;不要强制同步该分支。 +为 N+1 使用共享的 `release/*` 集成基线。基线变更添加写入器、codec、catalog 接线、恒等迁移与验证。从该基线创建各个独立子分支,并将其 PR(Pull Request)的目标设为发布分支,而非另一个独立子分支。每个子分支在同一个相邻迁移包内添加自身的结构变换、校验器、消费方和测试。不要只为表示评审顺序而分配额外版本。通过 PR 将评审后的子分支合入发布分支,并在发布前验证组合结果。遵守发布分支的强制推送与删除保护;不要强制同步该分支。 -已发布 codec 和迁移语义(包括 V3 与 V2→V3)保持冻结。不要通过修改 V0→V1、V1→V2 或 V2→V3 来实现新的结构性功能。只有 N→N+1 迁移边可在 N+1 发布前纳入协同变更;发布后,进一步的结构性变更需要下一条相邻迁移边。 +已发布 codec 和迁移语义保持冻结。不要通过修改已发布迁移边来实现新的结构性功能。只有 N→N+1 迁移边可在 N+1 发布前纳入协同变更;发布后,进一步的结构性变更需要下一条相邻迁移边。 未发布 N+1 的集成测试应使用可丢弃、相互隔离的 Harness home。中间版本产生的 N+1 文件已标为目标写入器版本,因此后续对 N→N+1 的修改不会再次迁移该文件。请在全新测试 home 中从未变更的历史输入重新运行;绝不通过改写已提交代际或复用真实用户 home 来修复这个问题。 ## 2. 添加恒等迁移边 -若要实现示例中的 V3→V4 迁移边,按照包检查清单创建库,而非挂载插件。恒等正文转换仅是最初的接线骨架。已发布的 [V2 到 V3 规范](../../packages/session/session-format-v2-to-v3/README.zh.md#v2-to-v3-specification)展示如何明确转换与保留规则,而不是可继续扩展或视为恒等转换的迁移边。 +按照包检查清单为 N→N+1 创建库,而非挂载插件。恒等正文转换仅是最初的接线骨架。[V2 到 V3 规范](../../packages/session/session-format-v2-to-v3/README.zh.md#v2-to-v3-specification)是明确转换与保留规则的固定示例,而不是可继续扩展或视为恒等转换的迁移边。 -在示例包的 manifest(元数据清单)中声明 `dsh.sessionFormatMigration`,包含 `from: 3`、`to: 4`、导出路径,以及导出的迁移、源 codec、目标 codec、目标 header 校验器和目标恢复器。复用现有 V2→V3 包的 `releasedV3SessionFormatCodec`,并依赖该包;不要复制或重新定义已发布 V3 codec。从新包导出示例的 V4 codec 和校验器。将新迁移边加入 catalog 的直接依赖,并添加工作区的 TypeScript 路径与项目引用。 +在 manifest(元数据清单)中声明 `dsh.sessionFormatMigration`,包含数值 `from: N` 和 `to: N+1`、导出路径,以及导出的迁移、源 codec、目标 codec、目标 header 校验器和目标恢复器。复用前一条迁移边所属包导出的源 codec,并依赖该包;不要复制或重新定义已发布 codec。从新包导出目标 codec 和校验器。将迁移边加入 catalog 的直接依赖,并添加工作区的 TypeScript 路径与项目引用。 -在添加新迁移边声明的同时,将[核心 Session 类型](../../packages/core/session/src/types.ts)中的 `SESSION_FORMAT_VERSION` 设为 N+1(示例中为 4),然后生成 catalog。下面的命令只生成已声明的迁移链;它不会实现新版本: +在添加新迁移边声明的同时,将[核心 Session 类型](../../packages/core/session/src/types.ts)中的 `SESSION_FORMAT_VERSION` 设为 N+1,然后生成 catalog。下面的命令只生成已声明的迁移链;它不会实现新版本: ```sh pnpm run gen-session-format-catalog @@ -49,7 +49,7 @@ pnpm run gen-session-format-catalog 实现 `transformEvent(event, context)`、`transformRun(run, context)` 和 `finish(context)`。通过 `context.emitEvent` 或 `context.emitRun` 同步输出;一次调用可以产生零个、一个或多个输出。让 Stage 直接消费 codec 所有的紧凑 run,或者迭代 `run.expand()`,而不物化中间数组。调用方负责调度,迁移链先结束上游 Stage,再结束下游 Stage。 -继承截点是逻辑事件数量,不是物理行数。只有在 EOF 前已知时才公开 `headerInheritedEventCount`;`finish` 返回精确的目标截点。前一条改变事件数量的迁移边可能使该数量在构造时不可知。必要时从已校验的种子标记推导它,并用有种子的 Session 测试示例中的 V0→V1→V2→V3→V4、V1→V2→V3→V4 和 V2→V3→V4 迁移链,而非仅测试直接 V3 输入。绝不以零替代未知截点。 +继承截点是逻辑事件数量,不是物理行数。只有在 EOF 前已知时才公开 `headerInheritedEventCount`;`finish` 返回精确的目标截点。前一条改变事件数量的迁移边可能使该数量在构造时不可知。必要时从已校验的种子标记推导它,并测试从每个受支持历史代际到 N+1 的有种子多跳恢复,而非仅测试直接 N 输入。绝不以零替代未知截点。 显式定义新迁移边的事件准入与变换规则。[V2 到 V3 源审计](../../packages/session/session-format-v2-to-v3/README.zh.md#source-audit)和 [Alpha V0→V1 规则](../../.agents/notes/implemented/architecture/2026-08-31-alpha-historical-unknown-event-refusal.zh.md)分别负责对应已发布迁移边的策略,而非新迁移边的策略。不要将任一策略推广到所有迁移边。结构或事件位置变化时,必须分类源事件、载荷成员与引用,并显式判断不透明数据能否保持有效。[同版本保留](../../.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.zh.md)本身不能证明结构变换安全。校验目标语义,并为每个新增可接受案例提供一个被拒绝的反例;绝不放宽旧迁移边来掩盖不受支持的转换。 @@ -67,7 +67,7 @@ pnpm run gen-session-format-catalog ## 5. 创建快照后继代际 -阅读[快照所有权](../../snapshots/AGENTS.md)和[快照库](../../packages/test-support/session-snapshot/README.zh.md)。选择拥有数据的场景,而非仅引用它的适配器。实现 N+1 后,保留每份历史文件,并生成其后继文件。在 V3→V4 示例中,父角色使用 `session.v4.jsonl`,子角色依次使用 `session.1.v4.jsonl`、`session.2.v4.jsonl` 等。绝不将 `session.v3.jsonl` 重命名为 V4,或仅修改其 header。 +阅读[快照所有权](../../snapshots/AGENTS.md)和[快照库](../../packages/test-support/session-snapshot/README.zh.md)。选择拥有数据的场景,而非仅引用它的适配器。实现 N+1 后,保留每份历史文件,并按目标版本的规范父子文件名生成后继文件。绝不将前代重命名为目标文件名,或仅修改其 header。 如果回放输入不变,在所有者上执行无密钥 refresh,再执行不写回的 replay。以下 SDK 命令使用 `text-turn` 和工作区的写入器版本。先实现并接入 N+1,才能用它们生成该版本;功能变更应选择实际受影响的所有者: @@ -93,7 +93,7 @@ pnpm run test:snapshot scripts/session-snapshot-corpus.corpus.ts 实现新迁移边后,将其实际测试路径加入聚焦的 Vitest 命令。根据实际 diff 添加受影响的 JSONL、回放、投影与 SDK 测试;发布 Worker 路径变化时还需构建产物冒烟测试。要求严格迁移成功、骨架保持恒等、拒绝格式错误与未知必需事件、重复恢复确定、并发 Stage 状态独立、有种子的多跳截点正确、前代不变且无回退。报告确切命令与失败,不要推断整个测试套件的结果。 -更新[所属 Agent Note](../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md),而非添加重复决策记录。审计相关活跃记录的取代关系;保留独立理由,并保持归档记录冻结。一起更新双语正文,通过仓库工具重新记录每个变更的配对,然后运行文档检查: +更新[所属 Agent Note](../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md),而非添加重复决策记录。发布前保持[发布记录](../session-format-status.zh.md#updating-the-record)不变;发布后,使用已核实的发布证据更新它。审计相关活跃记录的取代关系;保留独立理由,并保持归档记录冻结。一起更新双语正文,通过仓库工具重新记录每个变更的配对,然后运行文档检查: ```sh pnpm run verify-translation-pairing --write docs/cookbook/adding-a-session-format-version.md diff --git a/docs/deepseek-llm-api-wire-extensions.i18n.yaml b/docs/deepseek-llm-api-wire-extensions.i18n.yaml index 27bcf349da..e0b7be4a17 100644 --- a/docs/deepseek-llm-api-wire-extensions.i18n.yaml +++ b/docs/deepseek-llm-api-wire-extensions.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/deepseek-llm-api-wire-extensions.md -deepseek-llm-api-wire-extensions.md: 5b1c4de68949d56f99cbfa70ba4af7ca0b71715d -deepseek-llm-api-wire-extensions.zh.md: a78baace34dcb3e24667295cf06e111ce355aa72 +deepseek-llm-api-wire-extensions.md: a6689c97670260a7b673fc73d455726e357fd022 +deepseek-llm-api-wire-extensions.zh.md: 9f54d78609186055d3ac5e3b9b4f4272c7304e83 diff --git a/docs/deepseek-llm-api-wire-extensions.md b/docs/deepseek-llm-api-wire-extensions.md index 5b1c4de689..a6689c9767 100644 --- a/docs/deepseek-llm-api-wire-extensions.md +++ b/docs/deepseek-llm-api-wire-extensions.md @@ -73,7 +73,7 @@ An enabled inventory with no qualifying entries sends `packages: []`; disabling ## `dsh_session_log` -[`@deepseek-ai/dsh-session-log-deepseek`](../packages/session/session-log-deepseek/README.md) contributes one contiguous suffix of the canonical Session log. The field is disabled by default. When enabled, it applies to a request with a live Session and at least one event; a direct request, a stale Session id, or an empty log omits the field. +[`@deepseek-ai/dsh-session-log-deepseek`](../packages/session/session-log-deepseek/README.md) contributes one contiguous suffix of the canonical Session log. The field is disabled by default. When enabled, it applies to a request with a live Session and at least one event; a direct request, a stale Session id, or an empty log omits the field. The examples below use logical Session format 2 only to illustrate the wire fields; they do not identify the [current writer format](session-format-status.md). ```json { @@ -119,7 +119,7 @@ The `session` member projects `Session.header`, not a complete runtime Session o | Member | Presence | Meaning | |---|---|---| -| `version` | required | Logical Session format version; currently `2` | +| `version` | required | Logical Session format version from `Session.header`; see [format status](session-format-status.md) | | `id` | required | Exact Session id | | `createdAt` | required | Non-negative safe-integer Unix epoch milliseconds | | `cwd` | optional | Absolute working directory recorded at Session creation | diff --git a/docs/deepseek-llm-api-wire-extensions.zh.md b/docs/deepseek-llm-api-wire-extensions.zh.md index a78baace34..9f54d78609 100644 --- a/docs/deepseek-llm-api-wire-extensions.zh.md +++ b/docs/deepseek-llm-api-wire-extensions.zh.md @@ -73,7 +73,7 @@ ## `dsh_session_log` -[`@deepseek-ai/dsh-session-log-deepseek`](../packages/session/session-log-deepseek/README.zh.md) 贡献权威会话日志的一段连续后缀。该字段默认禁用。启用后,它适用于携带存活会话且至少存在一个事件的请求;直接请求、陈旧会话 id 或空日志会省略该字段。 +[`@deepseek-ai/dsh-session-log-deepseek`](../packages/session/session-log-deepseek/README.zh.md) 贡献权威会话日志的一段连续后缀。该字段默认禁用。启用后,它适用于携带存活会话且至少存在一个事件的请求;直接请求、陈旧会话 id 或空日志会省略该字段。下方示例使用逻辑 Session 格式 2 仅为说明协议字段,并不标识[当前写入格式](session-format-status.zh.md)。 ```json { @@ -119,7 +119,7 @@ | 成员 | 出现条件 | 含义 | |---|---|---| -| `version` | 必需 | 逻辑 Session 格式版本;当前为 `2` | +| `version` | 必需 | 来自 `Session.header` 的逻辑 Session 格式版本;见[格式状态](session-format-status.zh.md) | | `id` | 必需 | 确切的会话 id | | `createdAt` | 必需 | 非负安全整数 Unix epoch 毫秒数 | | `cwd` | 可选 | 创建会话时记录的绝对工作目录 | diff --git a/docs/session-format-status.i18n.yaml b/docs/session-format-status.i18n.yaml new file mode 100644 index 0000000000..01391d480e --- /dev/null +++ b/docs/session-format-status.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 docs/session-format-status.md +session-format-status.md: 222afcbd7e9d5baede55e748065ea49363da361b +session-format-status.zh.md: 6c7e97816cb97416b225304e99f23c68fd31a855 diff --git a/docs/session-format-status.md b/docs/session-format-status.md new file mode 100644 index 0000000000..222afcbd7e --- /dev/null +++ b/docs/session-format-status.md @@ -0,0 +1,47 @@ +# Session format version and release status + +English | [中文](session-format-status.zh.md) + +## Summary + +Use this reference to distinguish the checkout’s Session writer version from the latest published Session format. The code constant owns the writer version; the release record below owns the latest released format and its publication evidence. Other documentation links here instead of restating which version is current, next, or unreleased. + +## Table of Contents + +- [Sources of truth](#sources-of-truth) +- [Release record](#release-record) +- [Updating the record](#updating-the-record) +- [Dev Note](#dev-note) + + +## Sources of truth + +- **Checkout writer:** `SESSION_FORMAT_VERSION` in [core Session types](../packages/core/session/src/types.ts) is the only hand-maintained current-writer number in code. The [catalog generator](../scripts/gen-session-format-catalog.ts) derives codec ordering and checks that adjacent migrations reach it. A package version, codec export name, fixture filename, or projection-cache version is not the writer authority. +- **Latest released format:** `latestReleasedVersion` in the following record identifies the published Session format. `evidenceTag` names a published product release whose tagged writer has that value; it need not be the first release carrying the format. The bilingual copy is checked against the same record, not maintained as a separate decision. +- **Release status:** compare the writer constant with the verified release record. Equality means the writer format has shipped. A greater writer version is a development target beyond the recorded release. A lower writer version means this checkout predates that release. No separate released boolean is maintained. Before declaring a greater version unreleased, verify that no published release has advanced the record. + +An alpha, beta, or release-candidate product publication establishes released Session-format obligations. GitHub’s prerelease flag does not make persisted user data disposable. A missing release record is not evidence of non-publication. The [versioning rule](../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md) owns compatibility decisions; [released-format migration](../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md) owns immutable generations and adjacent conversion. + + +## Release record + +```yaml session-format-release +latestReleasedVersion: 3 +evidenceTag: dsh-v0.1.5-alpha.1 +``` + +Evidence: [published release](https://github.com/deepseek-harness/deepseek-harness/releases/tag/dsh-v0.1.5-alpha.1) and [its tagged writer source](https://github.com/deepseek-harness/deepseek-harness/blob/dsh-v0.1.5-alpha.1/packages/core/session/src/types.ts). + + +## Updating the record + +When a structural writer change is implemented, update the code constant and adjacent catalog together; do not advance this release record before publication. When a product release first publishes a higher Session format, confirm publication and its tagged writer, then advance this record and both evidence links in the same bilingual update. Later product releases carrying the same format do not require changing the record. Never lower it on the development trunk. + +The [documentation-standard test](../scripts/doc-standard.spec.ts) checks record structure, bilingual equality, evidence-link consistency, and that the documented release does not exceed the checkout writer. This keyless check does not query GitHub or prove that the record is up to date; publication verification remains part of the release update. + +Use “current format” and “next adjacent version” for general behavior. Keep explicit numbers for fixed migration inputs and outputs, wire schemas, historical evidence, and tests of those particular versions. The [format-version cookbook](cookbook/adding-a-session-format-version.md) uses N for the verified latest released format and N+1 for its successor. + + +## Dev Note + +None. diff --git a/docs/session-format-status.zh.md b/docs/session-format-status.zh.md new file mode 100644 index 0000000000..6c7e97816c --- /dev/null +++ b/docs/session-format-status.zh.md @@ -0,0 +1,47 @@ +# Session 格式版本与发布状态 + +[English](session-format-status.md) | 中文 + +## 概述 + +本参考区分工作区的 Session 写入器版本与最新已发布的 Session 格式。代码常量拥有写入器版本;下方发布记录拥有最新已发布格式及其发布证据。其他文档链接到这里,而不重复声明哪个版本是当前、下一个或尚未发布的版本。 + +## 目录 + +- [单一真源](#sources-of-truth) +- [发布记录](#release-record) +- [更新记录](#updating-the-record) +- [开发备注](#dev-note) + + +## 单一真源 + +- **工作区写入器:**[核心 Session 类型](../packages/core/session/src/types.ts)中的 `SESSION_FORMAT_VERSION` 是代码中唯一手工维护的当前写入器版本号。[目录生成器](../scripts/gen-session-format-catalog.ts)推导 codec 顺序,并检查相邻迁移是否到达该版本。包版本、codec 导出名称、fixture(测试前置数据)文件名或投影缓存版本都不是写入器版本的权威来源。 +- **最新已发布格式:**下方记录中的 `latestReleasedVersion` 标识已发布的 Session 格式。`evidenceTag` 指定一个已发布的产品版本,其标签对应的写入器具有该值;它不必是首次携带该格式的发布。双语副本按同一记录校验,不作为独立决策维护。 +- **发布状态:**比较写入器常量与已核实的发布记录。相等表示写入器格式已经发布。写入器版本更高表示它是超出记录中发布版本的开发目标。写入器版本更低表示该工作区早于该发布。不另行维护 released 布尔值。在声明更高版本尚未发布前,必须核实是否已有产品发布推进了记录。 + +产品的 alpha、beta 或 release-candidate 发布都会确立已发布 Session 格式的义务。GitHub 的 prerelease 标记不会让持久化用户数据成为可丢弃数据。缺少发布记录不代表尚未发布。[版本规则](../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md)拥有兼容性决策;[已发布格式迁移](../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md)拥有不可变代际与相邻转换规则。 + + +## 发布记录 + +```yaml session-format-release +latestReleasedVersion: 3 +evidenceTag: dsh-v0.1.5-alpha.1 +``` + +证据:[已发布产品版本](https://github.com/deepseek-harness/deepseek-harness/releases/tag/dsh-v0.1.5-alpha.1)及[对应标签的写入器源码](https://github.com/deepseek-harness/deepseek-harness/blob/dsh-v0.1.5-alpha.1/packages/core/session/src/types.ts)。 + + +## 更新记录 + +实现结构性写入器变更时,一起更新代码常量与相邻迁移目录;不要在产品发布前推进此发布记录。当产品首次发布更高的 Session 格式时,确认发布事实及对应标签的写入器,然后在同一次双语更新中推进本记录与两个证据链接。后续携带相同格式的产品发布无需改变此记录。开发主干上的记录绝不降低。 + +[文档标准测试](../scripts/doc-standard.spec.ts)检查记录结构、双语一致性、证据链接一致性,以及文档中的已发布版本不高于工作区写入器。这个无密钥检查不会查询 GitHub,也不能证明记录是最新的;核实发布事实仍属于发布更新的一部分。 + +一般行为使用“当前格式”和“下一条相邻版本”等表述。固定迁移的输入与输出、协议 schema、历史证据及针对特定版本的测试保留明确版本号。[格式版本实操手册](cookbook/adding-a-session-format-version.zh.md)用 N 表示已核实的最新发布格式,用 N+1 表示其后继版本。 + + +## 开发备注 + +无。 diff --git a/docs/subsystems/persistence.i18n.yaml b/docs/subsystems/persistence.i18n.yaml index e5a3fe2a7e..1abbf9957d 100644 --- a/docs/subsystems/persistence.i18n.yaml +++ b/docs/subsystems/persistence.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/persistence.md -persistence.md: b75ed288e1d034dc361457c8124a9974ecadad92 -persistence.zh.md: 1e5fa4269c520f2b196b245e4f2af4e8601da8eb +persistence.md: 7e18e9bbfec8c7aa81525da33ad3cffa4681ea73 +persistence.zh.md: 8250fb93b20dc33c2df705fd2c7092bc8dea155c diff --git a/docs/subsystems/persistence.md b/docs/subsystems/persistence.md index b75ed288e1..7e18e9bbfe 100644 --- a/docs/subsystems/persistence.md +++ b/docs/subsystems/persistence.md @@ -202,7 +202,7 @@ interface CreateSessionOptions { /** Initial replay or fork history supplied at construction. */ readonly seed?: readonly SessionEvent[] /** - * Exact fork-inherited prefix length when `meta.isSeeded` is true. In v2 the + * Exact fork-inherited prefix length when `meta.isSeeded` is true. The * constructor seed is exactly this inherited prefix; the constructor * appends the child-owned tagged marker at the cut. */ diff --git a/docs/subsystems/persistence.zh.md b/docs/subsystems/persistence.zh.md index 1e5fa4269c..8250fb93b2 100644 --- a/docs/subsystems/persistence.zh.md +++ b/docs/subsystems/persistence.zh.md @@ -202,7 +202,7 @@ interface CreateSessionOptions { /** Initial replay or fork history supplied at construction. */ readonly seed?: readonly SessionEvent[] /** - * Exact fork-inherited prefix length when `meta.isSeeded` is true. In v2 the + * Exact fork-inherited prefix length when `meta.isSeeded` is true. The * constructor seed is exactly this inherited prefix; the constructor * appends the child-owned tagged marker at the cut. */ diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index 883c778ccb..616359889e 100644 --- a/docs/subsystems/session.i18n.yaml +++ b/docs/subsystems/session.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/session.md -session.md: 50dbbf6474f1b1e756dedd508bb1d8b0ef349832 -session.zh.md: 9a1b1399e701a1fa2bda2ad59b59c9fbe484ef4a +session.md: 1443f6c3e5795d5a02662f25c3b39525084bb085 +session.zh.md: 8e4ca4b4b5c5b113ff6989a92030e318a888f449 diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index 50dbbf6474..1443f6c3e5 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -656,7 +656,7 @@ The optional `dsh-session/invariant` companion enforces the relations owned by c A fresh fork constructor requires its seed to equal the inherited prefix and appends `session/end-seed { inherited: true }` at the exact durable cut. A restore retains that tagged marker and appends an ordinary `session/end-seed {}` only when its complete stored seed does not already end in a marker. Both forms are log-only and produce no message; `Session`'s constructor is the only legitimate writer. -For fork lineage, locate the LAST marker whose payload carries `inherited: true`; v2 decoding requires it exactly when `SessionHeader.isSeeded` is true and derives `inheritedEventCount` from its seq. For lifecycle ownership, locate the last `session/end-seed` of either form. Reopening a seed that already ends in any marker does not append another ordinary marker. +For fork lineage, locate the LAST marker whose payload carries `inherited: true`; current-format decoding requires it exactly when `SessionHeader.isSeeded` is true and derives `inheritedEventCount` from its seq. For lifecycle ownership, locate the last `session/end-seed` of either form. Reopening a seed that already ends in any marker does not append another ordinary marker. It exists because seed history and live work are otherwise byte-identical, which defeats any plugin owning a standalone open/close bracket: an unmatched `compaction/start` reads the same whether the writer crashed mid-compaction or is compacting right now. An opening marker before `session/end-seed` came from the constructor seed and belongs to an ended lifecycle, whatever ended it (a crash, a succeeding process, or a fork out of a still-running parent), so its owner may treat it as dead. That covers only brackets *this* session inherited: a concurrently live session holding an open bracket over the same history has its own boundary elsewhere, so tolerating concurrent writers needs a liveness signal beyond the log. Core writes the boundary and reads nothing from it — a bracket's vocabulary stays with its owning plugin, which is why crash repair closes turn/step/tool boundaries and never `compaction/*`. diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index 9a1b1399e7..8e4ca4b4b5 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -660,7 +660,7 @@ interface TurnEndReasonMap { 新 fork constructor 要求 seed 等于 inherited prefix,并在精确持久 cut 追加 `session/end-seed { inherited: true }`。restore 会保留该 tagged marker,并且只在完整 stored seed 尚未以 marker 结尾时追加普通 `session/end-seed {}`。两种形式都只进入 log 且不产生 message;`Session` constructor 是唯一合法 writer。 -对于 fork lineage,定位 payload 携带 `inherited: true` 的最后一个 marker;v2 decoding 只在 `SessionHeader.isSeeded` 为 true 时要求该 marker,并从其 seq 推导 `inheritedEventCount`。对于 lifecycle ownership,定位任一形式的最后一个 `session/end-seed`。重新打开已经以任一 marker 结尾的 seed 时,不会再追加普通 marker。 +对于 fork lineage,定位 payload 携带 `inherited: true` 的最后一个 marker;当前格式 decoding 只在 `SessionHeader.isSeeded` 为 true 时要求该 marker,并从其 seq 推导 `inheritedEventCount`。对于 lifecycle ownership,定位任一形式的最后一个 `session/end-seed`。重新打开已经以任一 marker 结尾的 seed 时,不会再追加普通 marker。 它之所以必要,是因为种子历史与实时工作在字节层面完全相同,这会让任何拥有独立开/闭括号的插件失效:一个未配对的 `compaction/start`,无论写入方是在压缩中途崩溃、还是此刻正在压缩,读起来都一样。在 `session/end-seed` 之前的开启标记来自构造种子,并且属于一个已结束的生命周期,无论结束原因为何(崩溃、进程接替,或从仍在运行的父会话 fork 出来),因此其所有方可以视之为已死。这只覆盖*本*会话继承的括号:另一个并发存活的会话可能在同一段历史上持有开放括号,而它自己的边界在别处,因此容忍并发写入方还需要日志之外的存活信号。核心写入该边界但不从中读取任何内容——括号的词汇表仍归其所属插件,这也正是崩溃修复只关闭轮次/步骤/工具边界而从不处理 `compaction/*` 的原因。 diff --git a/docs/testing.i18n.yaml b/docs/testing.i18n.yaml index 899424ae54..82fc949fe6 100644 --- a/docs/testing.i18n.yaml +++ b/docs/testing.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/testing.md -testing.md: b9800bd7aa25e2556d2fa97e9397c140fffdb442 -testing.zh.md: 59f1a7ca05f0e50f6a3999498b41670228618514 +testing.md: bbf7db5d788667dc85c34dfdac4784e2b0dccca8 +testing.zh.md: 7d386494bb8fd712b93aeeeb4f8c7b196349a1db diff --git a/docs/testing.md b/docs/testing.md index b9800bd7aa..bbf7db5d78 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -14,7 +14,7 @@ How this repo tests, tier by tier, and the rules that keep a green suite meaning - **Snapshot** (`pnpm run test:snapshot`): a top-level scenario's highest recorded parent generation supplies user input and model replay, then serves as the expected persisted result. Parent filenames are `session[.vN].jsonl`; child roles are `session.[.vN].jsonl`; v0 omits `.v0`, positive versions require lowercase `.vN`, and each filename must agree with its header. Process scenarios start through `dsh`: headless owns one-shot behavior, the SDK owns persistent control, ACP owns automation-protocol behavior, and Web retains browser/ARIA evidence beside the same Session. `snapshot.yml` declares the profile, composition/header class, recording policy, exceptional replay or input metadata, and workspace facts. Typed tokens preserve parent/child identity relationships; only header pins own prompt/schema sidecars. A mutating scenario independently compares the complete `workspace.expected/` tree, which record and refresh never rewrite. Use `test:snapshot:record` when a model transcript changes and `test:snapshot:refresh` when replay input remains valid; review every resulting diff. - **Web browser snapshot** (`pnpm run test:web`; required Linux PR gate): Chromium compares session-driven output under `snapshots/web/` and UI-only output under `apps/web/tests/expected/`. CI forces read-only `DSH_SNAPSHOT=replay`, never writing expected outputs; record/refresh stay local and every diff is reviewed ([web e2e lane](../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md), [CI gate decision](../.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md)). `test:web` builds first for plugin CSS. -Session fixtures retain headers and payloads but omit body sequence/time envelopes; replay synthesizes them. Replay, record, and refresh select each parent/child role's highest generation. Current V3 uses `.v3`, one row per event, and embedded compact Assistant streams. Historical fixtures retain their released representation; explicit `sessionFormat` owners preserve migration coverage. Follow the [format-version cookbook](cookbook/adding-a-session-format-version.md#snapshot-successors) to add successors without changing predecessors. +Session fixtures retain headers and payloads but omit body sequence/time envelopes; replay synthesizes them. Replay, record, and refresh select each parent/child role's highest generation. Current fixtures use the [writer format](session-format-status.md) in their filenames and headers, one row per event, and embedded compact Assistant streams. Historical fixtures retain their released representation; explicit `sessionFormat` owners preserve migration coverage. Follow the [format-version cookbook](cookbook/adding-a-session-format-version.md#snapshot-successors) to add successors without changing predecessors. ## How specs execute diff --git a/docs/testing.zh.md b/docs/testing.zh.md index 59f1a7ca05..7d386494bb 100644 --- a/docs/testing.zh.md +++ b/docs/testing.zh.md @@ -14,7 +14,7 @@ - **快照**(`pnpm run test:snapshot`):顶层场景数值最高的已录制 parent generation 同时提供用户输入和模型回放,并作为持久化结果的预期值。parent 文件名是 `session[.vN].jsonl`;child 角色使用 `session.[.vN].jsonl`;v0 省略 `.v0`,正版本必须使用小写 `.vN`,且每个文件名必须与其 header 一致。进程级场景都通过 `dsh` 启动:headless 负责一次性行为,SDK 负责持久控制,ACP 负责自动化协议行为,Web 在同一 Session 旁保留浏览器与 ARIA 证据。`snapshot.yml` 声明 profile、组合与请求头类别、录制策略、例外回放或输入元数据以及 workspace 事实。带类型的 token 保留父子身份关系;只有请求头 pin 拥有 prompt/schema sidecar。变更 workspace 的场景会独立比较完整的 `workspace.expected/` 目录,record 与 refresh 绝不改写该目录。当模型 transcript(文本记录)变化时使用 `test:snapshot:record`,回放输入仍有效时使用 `test:snapshot:refresh`;请审查所有结果差异。 - **Web 浏览器快照**(`pnpm run test:web`;必需的 Linux PR(Pull Request)门禁):Chromium 比较 `snapshots/web/` 下由会话驱动的输出,以及 `apps/web/tests/expected/` 下仅含 UI 的输出。CI 强制只读的 `DSH_SNAPSHOT=replay`,绝不写入预期输出;record/refresh 留在本地,每处 diff 都须评审([web e2e 车道](../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md)、[CI 门禁决策](../.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.zh.md))。`test:web` 会先构建以交付插件 CSS。 -Session fixture 保留 header 与 payload,但省略正文 seq/time envelope;replay 会合成这些 envelope。Replay、record 与 refresh 会选择每个 parent/child 角色的最高 generation。当前 V3 使用 `.v3`、每个事件一行,并嵌入紧凑 Assistant stream。历史 fixture 保留其已发布表示;显式 `sessionFormat` 所有者保留迁移覆盖。按照[格式版本实操手册](cookbook/adding-a-session-format-version.zh.md#snapshot-successors)添加后继代际,不改动前代。 +Session fixture 保留 header 与 payload,但省略正文 seq/time envelope;replay 会合成这些 envelope。Replay、record 与 refresh 会选择每个 parent/child 角色的最高 generation。当前 fixture 在文件名与 header 中使用[写入格式](session-format-status.zh.md),每个事件一行,并嵌入紧凑 Assistant stream。历史 fixture 保留其已发布表示;显式 `sessionFormat` 所有者保留迁移覆盖。按照[格式版本实操手册](cookbook/adding-a-session-format-version.zh.md#snapshot-successors)添加后继代际,不改动前代。 ## spec 如何被执行 diff --git a/packages/core/session/README.i18n.yaml b/packages/core/session/README.i18n.yaml index fd052eb9f9..6b68ca2d74 100644 --- a/packages/core/session/README.i18n.yaml +++ b/packages/core/session/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/core/session/README.md -README.md: a06b22b5e8f48743047c68e883edd496fbc6eb77 -README.zh.md: 755894b4c30f0a3807f472cece32685d102efbeb +README.md: cdcab8ec4d2f6960bb651aff46745b20f91632e2 +README.zh.md: 745615f67084430475e0739b22afa35556b71ee6 diff --git a/packages/core/session/README.md b/packages/core/session/README.md index a06b22b5e8..cdcab8ec4d 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -181,7 +181,7 @@ Logging causes no invalidation, and exact reconstruction preserves request-prefi These limits define when the session store needs special care. They are current package constraints, not a task backlog. - **`fork()` cuts only at stable boundaries of live sessions** — the selected prefix must end outside an open turn and the source must be in the store; forking a persisted-but-unloaded session is excluded from the fork API. -- **`SESSION_FORMAT_VERSION` names the current V3 logical representation** — the V3 reader rejects retired `header.system` and validates `system/message` payloads and protected-head rewrites. Historical headers and events belong to adjacent format packages; the V2→V3 edge converts supported history before constructing `Session`, and write open publishes only the V3 successor. Equal-version unknown events require the envelope's explicit `ignorable` marker, which does not promise safe structural migration ([mechanism](../../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md)). +- **`SESSION_FORMAT_VERSION` names the [current logical representation](../../../docs/session-format-status.md)** — the current reader rejects retired `header.system` and validates `system/message` payloads and protected-head rewrites. Historical headers and events belong to adjacent format packages; the adjacent migration chain converts supported history before constructing `Session`, and write open publishes only the current-format successor. Equal-version unknown events require the envelope's explicit `ignorable` marker, which does not promise safe structural migration ([mechanism](../../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md)). - **`TurnEndReasonMap` omits the ACP-named `refusal` / `max_turn_requests` variants** — producer-gated: they land when an adapter or the loop first emits them. - **No session tree beyond fork** — a pi-style entry tree over branched sessions is deferred unless a consumer needs more than boundary-based forking. diff --git a/packages/core/session/README.zh.md b/packages/core/session/README.zh.md index 755894b4c3..745615f670 100644 --- a/packages/core/session/README.zh.md +++ b/packages/core/session/README.zh.md @@ -181,7 +181,7 @@ session.deriveMessages() // the derived model history 这些限制说明会话存储何时需要特别留意。它们是当前包约束,不是任务积压。 - **`fork()` 仅在实时会话的稳定边界处切分**:所选前缀结束时不得有开放轮次,且源会话必须位于存储中;fork API 不支持对已持久化但未加载的会话进行 fork。 -- **`SESSION_FORMAT_VERSION` 命名当前 V3 逻辑表示**——V3 读取器拒绝已退役的 `header.system`,并校验 `system/message` 载荷与受保护头节点的重写。历史 header 与事件归相邻格式包所有;V2→V3 迁移边在构造 `Session` 前转换受支持的历史,写打开只发布 V3 后继代际。同版本未知事件要求信封显式带有 `ignorable` 标记,但这不保证结构迁移的安全性([机制](../../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md))。 +- **`SESSION_FORMAT_VERSION` 命名[当前逻辑表示](../../../docs/session-format-status.zh.md)**——当前读取器拒绝已退役的 `header.system`,并校验 `system/message` 载荷与受保护头节点的重写。历史 header 与事件归相邻格式包所有;相邻迁移链在构造 `Session` 前转换受支持的历史,写打开只发布当前格式的后继代际。同版本未知事件要求信封显式带有 `ignorable` 标记,但这不保证结构迁移的安全性([机制](../../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md))。 - **`TurnEndReasonMap` 不含 ACP(Agent Client Protocol)命名的 `refusal`/`max_turn_requests` 变体**:受生产方约束;只有当适配器或循环首次产生这些变体时才加入。 - **fork 之外没有会话树**:基于分支会话的 pi 风格条目树被推迟,除非消费方需要超越基于边界的 forking 的能力。 diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index 192ef12502..139fccd5a5 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -138,7 +138,7 @@ export interface CreateSessionOptions { /** Initial replay or fork history supplied at construction. */ readonly seed?: readonly SessionEvent[] /** - * Exact fork-inherited prefix length when `meta.isSeeded` is true. In v2 the + * Exact fork-inherited prefix length when `meta.isSeeded` is true. The * constructor seed is exactly this inherited prefix; the constructor * appends the child-owned tagged marker at the cut. */ diff --git a/packages/session/session-format-catalog/README.i18n.yaml b/packages/session/session-format-catalog/README.i18n.yaml index 8823bffdc0..1a88983d94 100644 --- a/packages/session/session-format-catalog/README.i18n.yaml +++ b/packages/session/session-format-catalog/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/session/session-format-catalog/README.md -README.md: 5550dd90b4f57ac24f34bc05d04a9e075a8dce72 -README.zh.md: b80b3bb6876c00e4dc33ae5bb2aa7eb48fdcb7b5 +README.md: 8cebff66b4d788792075784dfc5c59a6da92a400 +README.zh.md: 949cb7c21f0550c702072217fd567058f440454d diff --git a/packages/session/session-format-catalog/README.md b/packages/session/session-format-catalog/README.md index 5550dd90b4..8cebff66b4 100644 --- a/packages/session/session-format-catalog/README.md +++ b/packages/session/session-format-catalog/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-session-format-catalog` gives persistence one deterministic Session format reader without consulting mounted plugins. It assembles codecs and adjacent edges from released v0 through current v3, checks the complete gap-free chain at module initialization, and exposes physical dispatch, header-only classification, single-pass row restoration, and current record encoding through `sessionFormatCatalog`. +`dsh-session-format-catalog` gives persistence one deterministic Session format reader without consulting mounted plugins. It assembles codecs and adjacent edges from the earliest supported format through the [current writer format](../../../docs/session-format-status.md), checks the complete gap-free chain at module initialization, and exposes physical dispatch, header-only classification, single-pass row restoration, and current record encoding through `sessionFormatCatalog`. ## Table of Contents diff --git a/packages/session/session-format-catalog/README.zh.md b/packages/session/session-format-catalog/README.zh.md index b80b3bb687..949cb7c21f 100644 --- a/packages/session/session-format-catalog/README.zh.md +++ b/packages/session/session-format-catalog/README.zh.md @@ -9,7 +9,7 @@ kind: "package-library" ## 概述 -`dsh-session-format-catalog` 为持久化提供一个确定性的 Session 格式读取器,且无需查询已挂载插件。它装配从已发布 v0 到当前 v3 的编解码器与相邻迁移边,在模块初始化时校验完整且无缺口的迁移链,并通过 `sessionFormatCatalog` 暴露物理分派、仅 header 分类、单遍行还原和当前格式逐记录编码。 +`dsh-session-format-catalog` 为持久化提供一个确定性的 Session 格式读取器,且无需查询已挂载插件。它装配从最早受支持格式到[当前写入格式](../../../docs/session-format-status.zh.md)的编解码器与相邻迁移边,在模块初始化时校验完整且无缺口的迁移链,并通过 `sessionFormatCatalog` 暴露物理分派、仅 header 分类、单遍行还原和当前格式逐记录编码。 ## 目录 diff --git a/packages/session/session-format-v2-to-v3/README.i18n.yaml b/packages/session/session-format-v2-to-v3/README.i18n.yaml index 1d01109622..b156fd7dae 100644 --- a/packages/session/session-format-v2-to-v3/README.i18n.yaml +++ b/packages/session/session-format-v2-to-v3/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/session/session-format-v2-to-v3/README.md -README.md: d13abe2b6324db5459babc173a6c6b4b7e0069b5 -README.zh.md: 5603d5a4049a0dc69c12fecf5347c9881da945be +README.md: 65e62f6d28f9f4cb0a75127429ae9a7ae610f5b2 +README.zh.md: c13a9fa57489759abf65e6ec1f7df178bdc2cdc8 diff --git a/packages/session/session-format-v2-to-v3/README.md b/packages/session/session-format-v2-to-v3/README.md index d13abe2b63..65e62f6d28 100644 --- a/packages/session/session-format-v2-to-v3/README.md +++ b/packages/session/session-format-v2-to-v3/README.md @@ -194,7 +194,7 @@ The edge preserves historical request meaning and model configuration; it does n - **Historical preset ambiguity** — released `code` references cannot distinguish a custom preset with the legacy built-in id; the [exact rename](#header-and-presets) is host-independent. -- **No file or settings migration** — this package never changes committed generations or `settings.yaml`. Persistence owns publishing the final successor; an existing V3 generation does not rerun its incoming edge. V3 is released; its compatibility obligations follow the [released-format policy](../../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md). +- **No file or settings migration** — this package never changes committed generations or `settings.yaml`. Persistence owns publishing the final successor; an existing V3 generation does not rerun its incoming edge. See [format release status](../../../docs/session-format-status.md) and the compatibility obligations in the [released-format policy](../../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md). ### Dev Note diff --git a/packages/session/session-format-v2-to-v3/README.zh.md b/packages/session/session-format-v2-to-v3/README.zh.md index 5603d5a404..c13a9fa574 100644 --- a/packages/session/session-format-v2-to-v3/README.zh.md +++ b/packages/session/session-format-v2-to-v3/README.zh.md @@ -194,7 +194,7 @@ V2 `session-log-deepseek/delivery-accepted` 若携带 `data.sessionFormatVersion - **历史预设歧义** — 已发布 `code` 引用无法区分与旧内置标识同名的自定义预设;[精确重命名](#header-and-presets)不依赖宿主。 -- **不迁移文件或设置** — 本包绝不修改已提交代或 `settings.yaml`。持久化负责发布最终后继代;已有 V3 代不重新运行其入边。V3 已发布;其兼容性义务遵循[已发布格式策略](../../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md)。 +- **不迁移文件或设置** — 本包绝不修改已提交代或 `settings.yaml`。持久化负责发布最终后继代;已有 V3 代不重新运行其入边。格式发布状态见[状态记录](../../../docs/session-format-status.zh.md),兼容性义务见[已发布格式策略](../../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md)。 ### 开发备注 diff --git a/packages/session/session-persistence-jsonl/README.i18n.yaml b/packages/session/session-persistence-jsonl/README.i18n.yaml index 856b68353f..7b0ceb074f 100644 --- a/packages/session/session-persistence-jsonl/README.i18n.yaml +++ b/packages/session/session-persistence-jsonl/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/session/session-persistence-jsonl/README.md -README.md: f60a1cb20ada758f0deaf59a16ce662ff66eaa71 -README.zh.md: d56513a05bb8fe04b1a518acf36d78a7b6c542c5 +README.md: 194ea835f87e4c34e0821083246bcf89c0e0a684 +README.zh.md: 4512d67e3439cbb4cf8642ca67be56bf0c7ac5b1 diff --git a/packages/session/session-persistence-jsonl/README.md b/packages/session/session-persistence-jsonl/README.md index f60a1cb20a..194ea835f8 100644 --- a/packages/session/session-persistence-jsonl/README.md +++ b/packages/session/session-persistence-jsonl/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-session-persistence-jsonl` stores each session in a current append-only JSONL log and retains immutable historical format generations — checksummed Zstandard frames by default, raw newline-delimited lines when compression is disabled. It serves the current logical `SessionEvent` stream through persistence handles, so format migration, compression, historical decoding, and crash recovery remain storage-internal details. Choose it when consumers need a per-session file on disk; the logs are readable as plain lines when `compression: 'none'` is selected. A root directory is the one required configuration; durability, lazy materialization, released-v0/v1 migration, and torn-tail crash recovery come with the backend. +`dsh-session-persistence-jsonl` stores each session in a current append-only JSONL log and retains immutable historical format generations — checksummed Zstandard frames by default, raw newline-delimited lines when compression is disabled. It serves the current logical `SessionEvent` stream through persistence handles, so format migration, compression, historical decoding, and crash recovery remain storage-internal details. Choose it when consumers need a per-session file on disk; the logs are readable as plain lines when `compression: 'none'` is selected. A root directory is the one required configuration; durability, lazy materialization, [supported historical-format migration](../session-format-catalog/README.md), and torn-tail crash recovery come with the backend. ## Table of Contents diff --git a/packages/session/session-persistence-jsonl/README.zh.md b/packages/session/session-persistence-jsonl/README.zh.md index d56513a05b..4512d67e34 100644 --- a/packages/session/session-persistence-jsonl/README.zh.md +++ b/packages/session/session-persistence-jsonl/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -`dsh-session-persistence-jsonl` 把每个会话存为当前的仅追加 JSONL 日志,并保留不可变的历史格式 generation——默认以带校验和的 Zstandard 帧存储,禁用压缩时以换行分隔的原始文本行存储。它通过持久化句柄提供当前逻辑 `SessionEvent` 流,因此格式迁移、压缩、历史解码与崩溃恢复仍是存储内部细节。当消费方需要按会话的磁盘文件时选择它;选择 `compression: 'none'` 后日志可作为纯文本按行读取。根目录是唯一必填配置;持久性、延迟实体化、已发布 v0/v1 迁移与撕裂尾部崩溃恢复都随后端提供。 +`dsh-session-persistence-jsonl` 把每个会话存为当前的仅追加 JSONL 日志,并保留不可变的历史格式 generation——默认以带校验和的 Zstandard 帧存储,禁用压缩时以换行分隔的原始文本行存储。它通过持久化句柄提供当前逻辑 `SessionEvent` 流,因此格式迁移、压缩、历史解码与崩溃恢复仍是存储内部细节。当消费方需要按会话的磁盘文件时选择它;选择 `compression: 'none'` 后日志可作为纯文本按行读取。根目录是唯一必填配置;持久性、延迟实体化、[受支持的历史格式迁移](../session-format-catalog/README.zh.md)与撕裂尾部崩溃恢复都随后端提供。 ## 目录 diff --git a/packages/session/session-persistence-jsonl/src/format.ts b/packages/session/session-persistence-jsonl/src/format.ts index f8fdb25874..4129207ece 100644 --- a/packages/session/session-persistence-jsonl/src/format.ts +++ b/packages/session/session-persistence-jsonl/src/format.ts @@ -304,7 +304,7 @@ export function logPath( } /** - * Serialize a v2 event batch as JSONL lines (no trailing newline). Compact + * Serialize a current event batch as JSONL lines (no trailing newline). Compact * Assistant streams are nested event data; every event occupies one row. * @param events - the batch to serialize, in log order. * @returns the batch's JSONL text; the writer adds the final newline. @@ -314,7 +314,7 @@ export function eventLines(events: readonly SessionEvent[]): string { } /** - * Serialize one v2 event as one JSONL record without its trailing newline. + * Serialize one current event as one JSONL record without its trailing newline. * @param event - current event to encode. * @returns one physical JSON record. */ diff --git a/packages/test-support/session-snapshot/README.i18n.yaml b/packages/test-support/session-snapshot/README.i18n.yaml index 8cc6afcbb6..648c027c08 100644 --- a/packages/test-support/session-snapshot/README.i18n.yaml +++ b/packages/test-support/session-snapshot/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/test-support/session-snapshot/README.md -README.md: c8d2ecc7c9d187006b711fdf4efd1ca205773cd9 -README.zh.md: 20fea4e4351a857908d5a9d9f78c1f80131c0ada +README.md: a73ea265516f965bec8d11c41d6eb5bb028c7ac6 +README.zh.md: 544694a9fe9678d89cffb1f3293ea4e648e89f5d diff --git a/packages/test-support/session-snapshot/README.md b/packages/test-support/session-snapshot/README.md index c8d2ecc7c9..a73ea26551 100644 --- a/packages/test-support/session-snapshot/README.md +++ b/packages/test-support/session-snapshot/README.md @@ -70,15 +70,15 @@ defineAcpSnapshotSuite({ Each recorded-session directory carries a closed `snapshot.yml` manifest plus canonical parent and contiguous child roles. Parent filenames are `session[.vN].jsonl`; children are `session.[.vN].jsonl`; v0 omits `.v0`, positive versions use lowercase `.vN`, and every filename agrees with its header. A role may retain older generations, but the harness selects the numerically highest one. An owning manifest may declare `sessionFormat.version` plus one or more closed `coverage` names to keep that historical generation as an explicit migration fixture; absence tracks the current writer. The manifest also names the scenario, shipped profile, composition/header class, recording source, and only the replay, platform, permission, environment, workspace, or input facts the completed Session cannot reconstruct. Storage guards check tool results and portable paths in every selected parent and child role. Prompt/schema scrubbing, message identity, and prompt-before-request ordering apply to current generations; retained predecessors keep their historical representation. The adapter registers expected-output, Session-log, and optional `workspace.expected/` comparisons; guards reject orphan directories, missing roles, noncanonical names, absolute paths, malformed manifests, and platform-specific separators. -`normalizeSessionSnapshot` retains the complete Session header and event payloads but omits top-level `seq`/`time` envelopes from committed fixtures after normalizing paths and scrubbing system-prompt text and tool schemas; it also normalizes embedded stream clocks and historical packed-row `seq0`/`time0` envelopes, and catalog child-creation clocks. Event order and source-event references are preserved. Replay synthesizes the top-level envelopes in memory, while runtime persistence continues to write complete logs. Multi-session comparison validates expected and harvested logs through the strict build-static Session format catalog before identity redaction and normalization; source filenames cannot alter format validation. A retained historical replay input is not the native V3 writer-output oracle: structural migration preserves request meaning but can produce a different event layout. Normalization preserves unexpected request-header fields, including `system`, so regressions remain visible. Versionless protocol-adapter unit fixtures remain outside the released Session format corpus. Current v3 fixtures use one row per event; retained v0/v1 fixtures may use canonical packed rows. The [temporary repository migrator](../../../scripts/migrate-packed-session-fixtures.ts) (`pnpm run migrate:packed-session-fixtures`) rewrites older historical layouts, and its [removal proposal](../../../.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.md) owns its deletion. +`normalizeSessionSnapshot` retains the complete Session header and event payloads but omits top-level `seq`/`time` envelopes from committed fixtures after normalizing paths and scrubbing system-prompt text and tool schemas; it also normalizes embedded stream clocks and historical packed-row `seq0`/`time0` envelopes, and catalog child-creation clocks. Event order and source-event references are preserved. Replay synthesizes the top-level envelopes in memory, while runtime persistence continues to write complete logs. Multi-session comparison validates expected and harvested logs through the strict build-static Session format catalog before identity redaction and normalization; source filenames cannot alter format validation. A retained historical replay input is not the native current-format writer-output oracle: structural migration preserves request meaning but can produce a different event layout. Normalization preserves unexpected request-header fields, including `system`, so regressions remain visible. Versionless protocol-adapter unit fixtures remain outside the released Session format corpus. Fixtures in the [current writer format](../../../docs/session-format-status.md) use one row per event; retained v0/v1 fixtures may use canonical packed rows. The [temporary repository migrator](../../../scripts/migrate-packed-session-fixtures.ts) (`pnpm run migrate:packed-session-fixtures`) rewrites older historical layouts, and its [removal proposal](../../../.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.md) owns its deletion. Spill scenarios save through the real local provider under a private temporary root. Their fixture adapter exposes fixed-length logical locators and maps only this run’s saved locators back to real files for retrieval, preserving preview budgets without writing into a shared logical path. Known snapshot spill paths normalize to stable locator tokens, including paths quoted inside JSON omission notices with JSON-escaped Windows separators. Refresh extraction preserves the matched serialized path spelling for literal replacement. Normalization changes only the locator: saved byte lengths and omission counts remain comparison evidence. -Retained historical scenarios keep their canonical Session files unchanged and selected for replay, with no canonical V3 sibling in the pinned directory. Their exact normalized native V3 output is recorded separately in `writer.expected.jsonl` for the parent and `writer..expected.jsonl` for children; these are output oracles, not replay generations. Retained SDK scenarios use `notifications.current.expected.jsonl` for current protocol output. Comparisons neither project current events back into a historical format nor strip structural differences. Independent migration tests verify the official transform rather than treating native writer layout as its expected event sequence. +Retained historical scenarios keep their canonical Session files unchanged and selected for replay, with no newer canonical sibling in the pinned directory. Their exact normalized native current-format output is recorded separately in `writer.expected.jsonl` for the parent and `writer..expected.jsonl` for children; these are output oracles, not replay generations. Retained SDK scenarios use `notifications.current.expected.jsonl` for current protocol output. Comparisons neither project current events back into a historical format nor strip structural differences. Independent migration tests verify the official transform rather than treating native writer layout as its expected event sequence. ### Record, replay, and refresh -`pnpm run test:snapshot:record` calls the live LLM and writes the harvested current generation under its canonical versioned filename. Record and refresh never rename or delete a completed generation, including generations of a child role absent from a later run; reviewed source-tree curation removes a predecessor only after the same role has a verified current replacement. Scenarios with an explicit `sessionFormat` remain read-only in record mode. `pnpm run test:snapshot:refresh` stays keyless, runs the selected highest replay input, and writes stdout, owned prompt and tool-schema sidecars, and a fresh current-generation comparable Session output; retained historical scenarios write the separate writer-output oracles instead of a canonical V3 replay generation. Each composition owner keeps its replay patch beside its live patch; top-level `snapshots/` owns Session-driven scenarios, while other expected outputs stay beside their owning package. [`dsh-llm-replay`](../llm-replay/README.md) serves the recorded streams selected through `DSH_SNAPSHOT_*` environment values. +`pnpm run test:snapshot:record` calls the live LLM and writes the harvested current generation under its canonical versioned filename. Record and refresh never rename or delete a completed generation, including generations of a child role absent from a later run; reviewed source-tree curation removes a predecessor only after the same role has a verified current replacement. Scenarios with an explicit `sessionFormat` remain read-only in record mode. `pnpm run test:snapshot:refresh` stays keyless, runs the selected highest replay input, and writes stdout, owned prompt and tool-schema sidecars, and a fresh current-generation comparable Session output; retained historical scenarios write the separate writer-output oracles instead of a canonical current-format replay generation. Each composition owner keeps its replay patch beside its live patch; top-level `snapshots/` owns Session-driven scenarios, while other expected outputs stay beside their owning package. [`dsh-llm-replay`](../llm-replay/README.md) serves the recorded streams selected through `DSH_SNAPSHOT_*` environment values. ### Pinning request headers and system prompts diff --git a/packages/test-support/session-snapshot/README.zh.md b/packages/test-support/session-snapshot/README.zh.md index 20fea4e435..544694a9fe 100644 --- a/packages/test-support/session-snapshot/README.zh.md +++ b/packages/test-support/session-snapshot/README.zh.md @@ -70,15 +70,15 @@ defineAcpSnapshotSuite({ 每个已记录 Session 目录携带封闭的 `snapshot.yml` manifest,以及规范 parent 与连续 child 角色。parent 文件名是 `session[.vN].jsonl`;child 是 `session.[.vN].jsonl`;v0 省略 `.v0`,正版本使用小写 `.vN`,且每个文件名与其 header 一致。一个角色可以保留旧 generation,但 harness 会选择数值最高的一项。拥有 fixture 的 manifest 可以声明 `sessionFormat.version` 与一个或多个封闭 `coverage` 名称,把该历史 generation 保留为显式迁移 fixture;省略此字段时跟随当前 writer。manifest 还会指名场景、随附 profile、组合/header 类别、录制来源,以及已完成 Session 无法重建的 replay、平台、权限、环境、workspace 或输入事实。存储保护检查每个选定 parent 与 child 角色的工具结果和可移植路径。提示词/schema 擦除、消息身份及提示词先于请求的顺序检查适用于当前 generation;保留的前代维持其历史表示。适配器注册预期输出、Session 日志与可选 `workspace.expected/` 比较;保护会拒绝遗留目录、缺失角色、非规范名称、绝对路径、malformed manifest 与平台专用分隔符。 -`normalizeSessionSnapshot` 在规范化路径并擦除系统提示文本与工具 schema 后,会保留完整 Session header 与事件 payload,但从已提交 fixture 中省略顶层 `seq`/`time` envelope;它还会规范化嵌入式 stream clock 与历史 packed-row 的 `seq0`/`time0` envelope 与 catalog child 创建时钟。事件顺序与来源事件引用保持不变。Replay 只在内存中合成顶层 envelope,而运行时持久化仍写入完整日志。多 Session 比较会先通过严格的构建期静态 Session 格式目录校验预期日志与收集日志,再进行身份脱敏与规范化;来源文件名不能改变格式校验。保留的历史 replay 输入不是原生 V3 writer 输出的比较基准:结构迁移保留请求含义,但可以产生不同的事件布局。归一化保留意外的 request-header 字段(包括 `system`),使回归保持可见。无版本的协议适配器单元测试 fixture 不属于已发布 Session 格式语料。当前 v3 fixture 每个事件占一行;保留的 v0/v1 fixture 可以使用规范 packed row。[临时仓库迁移器](../../../scripts/migrate-packed-session-fixtures.ts)(`pnpm run migrate:packed-session-fixtures`)会改写更旧的历史布局,由其[移除提案](../../../.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.zh.md)负责删除该迁移器。 +`normalizeSessionSnapshot` 在规范化路径并擦除系统提示文本与工具 schema 后,会保留完整 Session header 与事件 payload,但从已提交 fixture 中省略顶层 `seq`/`time` envelope;它还会规范化嵌入式 stream clock 与历史 packed-row 的 `seq0`/`time0` envelope 与 catalog child 创建时钟。事件顺序与来源事件引用保持不变。Replay 只在内存中合成顶层 envelope,而运行时持久化仍写入完整日志。多 Session 比较会先通过严格的构建期静态 Session 格式目录校验预期日志与收集日志,再进行身份脱敏与规范化;来源文件名不能改变格式校验。保留的历史 replay 输入不是原生当前格式 writer 输出的比较基准:结构迁移保留请求含义,但可以产生不同的事件布局。归一化保留意外的 request-header 字段(包括 `system`),使回归保持可见。无版本的协议适配器单元测试 fixture 不属于已发布 Session 格式语料。[当前写入器格式](../../../docs/session-format-status.zh.md)的 fixture 每个事件占一行;保留的 v0/v1 fixture 可以使用规范 packed row。[临时仓库迁移器](../../../scripts/migrate-packed-session-fixtures.ts)(`pnpm run migrate:packed-session-fixtures`)会改写更旧的历史布局,由其[移除提案](../../../.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.zh.md)负责删除该迁移器。 Spill 场景通过真实本地 provider 保存到私有临时根目录。夹具适配器提供固定长度的逻辑定位符,并仅将本次运行已保存的定位符映射回实际文件以供检索,在不写入共享逻辑路径的情况下保留预览预算。已知的快照 spill 路径会规范化为稳定的定位信息 token,包括 JSON 省略通知中带引号、使用 JSON 转义 Windows 分隔符的路径。刷新提取会保留匹配路径的序列化写法,以便进行字面替换。规范化只改变定位信息:保存字节数与省略计数仍作为比较证据。 -保留历史输入的场景保持规范 Session 文件不变,并继续选择它们进行回放;固定历史版本的目录中没有规范 V3 同角色文件。其精确的规范化原生 V3 输出单独记录在父会话的 `writer.expected.jsonl` 和子会话的 `writer..expected.jsonl` 中;这些是输出比较基准,而非 replay 代际。保留历史输入的 SDK 场景使用 `notifications.current.expected.jsonl` 记录当前协议输出。比较既不将当前事件反向投影为历史格式,也不剥除结构差异。独立迁移测试验证正式转换,而不把原生 writer 布局当作其预期事件序列。 +保留历史输入的场景保持规范 Session 文件不变,并继续选择它们进行回放;固定历史版本的目录中没有更新的规范同角色文件。其精确的规范化原生当前格式输出单独记录在父会话的 `writer.expected.jsonl` 和子会话的 `writer..expected.jsonl` 中;这些是输出比较基准,而非 replay 代际。保留历史输入的 SDK 场景使用 `notifications.current.expected.jsonl` 记录当前协议输出。比较既不将当前事件反向投影为历史格式,也不剥除结构差异。独立迁移测试验证正式转换,而不把原生 writer 布局当作其预期事件序列。 ### 录制、回放与刷新 -`pnpm run test:snapshot:record` 调用在线 LLM(大语言模型),并在规范具名版本文件下写入收集到的当前 generation。record 与 refresh 绝不重命名或删除已完成的 generation,即使后续运行不再产生某个 child 角色也一样;受审阅的源树整理只有在同角色存在已验证的当前替代文件后才移除前代。显式声明 `sessionFormat` 的场景在录制模式下保持只读。`pnpm run test:snapshot:refresh` 保持无密钥,运行选定的最高 replay 输入,并写入 stdout、各 pin 自有的 prompt 与工具 schema sidecar,以及新鲜当前 generation 的可比较 Session 输出;保留历史输入的场景写入单独的 writer 输出比较基准,而非规范 V3 replay 代际。每个组合 owner 把 replay patch 放在 live patch 旁;顶层 `snapshots/` 拥有 Session 驱动场景,其他预期输出留在其 package owner 旁。[`dsh-llm-replay`](../llm-replay/README.zh.md) 提供通过 `DSH_SNAPSHOT_*` 环境值选择的已记录流。 +`pnpm run test:snapshot:record` 调用在线 LLM(大语言模型),并在规范具名版本文件下写入收集到的当前 generation。record 与 refresh 绝不重命名或删除已完成的 generation,即使后续运行不再产生某个 child 角色也一样;受审阅的源树整理只有在同角色存在已验证的当前替代文件后才移除前代。显式声明 `sessionFormat` 的场景在录制模式下保持只读。`pnpm run test:snapshot:refresh` 保持无密钥,运行选定的最高 replay 输入,并写入 stdout、各 pin 自有的 prompt 与工具 schema sidecar,以及新鲜当前 generation 的可比较 Session 输出;保留历史输入的场景写入单独的 writer 输出比较基准,而非规范当前格式 replay 代际。每个组合 owner 把 replay patch 放在 live patch 旁;顶层 `snapshots/` 拥有 Session 驱动场景,其他预期输出留在其 package owner 旁。[`dsh-llm-replay`](../llm-replay/README.zh.md) 提供通过 `DSH_SNAPSHOT_*` 环境值选择的已记录流。 ### 固定请求 header 与系统提示 diff --git a/scripts/doc-standard.spec.ts b/scripts/doc-standard.spec.ts index bfb90703e0..9838d3e2c5 100644 --- a/scripts/doc-standard.spec.ts +++ b/scripts/doc-standard.spec.ts @@ -9,8 +9,9 @@ import { existsSync, globSync, readFileSync } from 'node:fs' import { resolve, sep } from 'node:path' -import { load } from 'js-yaml' +import { JSON_SCHEMA, load } from 'js-yaml' import { describe, expect, it } from 'vitest' +import { readCurrentSessionFormatVersion } from './gen-session-format-catalog.ts' const root = resolve(import.meta.dirname, '..') const PACKAGE_README_GLOBS = [ @@ -146,6 +147,150 @@ function packageReadmeStructureErrors(file: string, source: string): string[] { return required.flatMap(([pattern, label]) => pattern.test(source) ? [] : [`missing ${label}`]) } +interface SessionFormatRelease { + latestReleasedVersion: number + evidenceTag: string +} + +function validateSessionFormatRelease(source: string, currentWriterVersion: number): SessionFormatRelease { + const normalized = source.replaceAll('\r\n', '\n') + const openings = [...normalized.matchAll(/^```yaml session-format-release[ \t]*$/gmu)] + if (openings.length !== 1) throw new Error('Expected exactly one session-format-release record') + const block = /^```yaml session-format-release[ \t]*\n([\s\S]*?)^```[ \t]*$/mu.exec(normalized) + if (block === null) throw new Error('Expected a closed session-format-release record') + const metadata: unknown = load(block[1]!, { schema: JSON_SCHEMA }) + if (metadata === null || typeof metadata !== 'object' || Array.isArray(metadata)) { + throw new Error('Session format release record must be a mapping') + } + const fields = Object.keys(metadata).sort() + if (fields.join(',') !== 'evidenceTag,latestReleasedVersion') { + throw new Error('Session format release record requires exactly latestReleasedVersion and evidenceTag') + } + const { latestReleasedVersion, evidenceTag } = metadata as Record + if (typeof latestReleasedVersion !== 'number' || !Number.isSafeInteger(latestReleasedVersion) + || latestReleasedVersion < 0) { + throw new Error('latestReleasedVersion must be a non-negative safe integer') + } + if (latestReleasedVersion > currentWriterVersion) { + throw new Error('latestReleasedVersion must not exceed the current writer version') + } + if (typeof evidenceTag !== 'string' + || !/^dsh-v\d+\.\d+\.\d+(?:-[\dA-Za-z]+(?:[.-][\dA-Za-z]+)*)?(?:\+[\dA-Za-z]+(?:[.-][\dA-Za-z]+)*)?$/u.test(evidenceTag)) { + throw new Error('evidenceTag must be a non-empty dsh-v version tag without URL delimiters') + } + const repository = 'https://github.com/deepseek-harness/deepseek-harness' + for (const link of [ + `${repository}/releases/tag/${evidenceTag}`, + `${repository}/blob/${evidenceTag}/packages/core/session/src/types.ts`, + ]) { + if (!normalized.includes(`](${link})`)) throw new Error(`Missing matching evidence link: ${link}`) + } + return { latestReleasedVersion, evidenceTag } +} + +function sessionFormatReleaseFixture(): { record: SessionFormatRelease; body: string; links: string; source: string } { + const record = validateSessionFormatRelease( + readFileSync(resolve(root, 'docs/session-format-status.md'), 'utf8'), + readCurrentSessionFormatVersion(root), + ) + const body = `latestReleasedVersion: ${record.latestReleasedVersion}\nevidenceTag: ${record.evidenceTag}` + const repository = 'https://github.com/deepseek-harness/deepseek-harness' + const links = `[release](${repository}/releases/tag/${record.evidenceTag})\n` + + `[source](${repository}/blob/${record.evidenceTag}/packages/core/session/src/types.ts)` + return { record, body, links, source: releaseDocument(body, links) } +} + +function releaseDocument(body: string, links: string): string { + return `\`\`\`yaml session-format-release\n${body}\n\`\`\`\n\n${links}\n` +} + +describe('Session format release authority', () => { + it('keeps the bilingual release records equal and consistent with the writer and evidence links', () => { + const records = ['docs/session-format-status.md', 'docs/session-format-status.zh.md'].map(file => + validateSessionFormatRelease(readFileSync(resolve(root, file), 'utf8'), readCurrentSessionFormatVersion(root)), + ) + expect(records[0]).toEqual(records[1]) + }) + + it('accepts a released writer and a newer development writer, including format zero', () => { + const { record, body, links, source } = sessionFormatReleaseFixture() + expect(validateSessionFormatRelease(source, record.latestReleasedVersion)).toEqual(record) + expect(validateSessionFormatRelease(source, record.latestReleasedVersion + 1)).toEqual(record) + const zero = releaseDocument(body.replace(`latestReleasedVersion: ${record.latestReleasedVersion}`, 'latestReleasedVersion: 0'), links) + expect(validateSessionFormatRelease(zero, 0)).toEqual({ ...record, latestReleasedVersion: 0 }) + }) + + it('rejects missing, duplicated, unclosed, and malformed release records', () => { + const { record, body, links, source } = sessionFormatReleaseFixture() + for (const invalid of [ + links, + source + source, + source + '\n```yaml session-format-release\n', + `\`\`\`yaml session-format-release\n${body}`, + releaseDocument('[unterminated', links), + releaseDocument('', links), + releaseDocument('null', links), + releaseDocument('scalar', links), + releaseDocument(`- latestReleasedVersion: ${record.latestReleasedVersion}`, links), + releaseDocument(`${body}\n---\n${body}`, links), + ]) { + expect(() => validateSessionFormatRelease(invalid, record.latestReleasedVersion), invalid).toThrow() + } + }) + + it('rejects missing, duplicate, and extra record fields', () => { + const { record, body, links } = sessionFormatReleaseFixture() + for (const invalid of [ + '{}', + `evidenceTag: ${record.evidenceTag}`, + `latestReleasedVersion: ${record.latestReleasedVersion}`, + `${body}\nlatestReleasedVersion: ${record.latestReleasedVersion}`, + `${body}\nevidenceTag: ${record.evidenceTag}`, + `${body}\nreleased: true`, + ]) { + expect(() => validateSessionFormatRelease(releaseDocument(invalid, links), record.latestReleasedVersion), invalid).toThrow() + } + }) + + it('rejects invalid released versions and releases beyond the current writer', () => { + const { record, links } = sessionFormatReleaseFixture() + for (const value of ['-1', '1.5', String(Number.MAX_SAFE_INTEGER + 1), '.inf', '.nan', 'null', 'true', '"0"']) { + const source = releaseDocument(`latestReleasedVersion: ${value}\nevidenceTag: ${record.evidenceTag}`, links) + expect(() => validateSessionFormatRelease(source, Number.MAX_SAFE_INTEGER), value).toThrow('non-negative safe integer') + } + const writer = readCurrentSessionFormatVersion(root) + const future = releaseDocument(`latestReleasedVersion: ${writer + 1}\nevidenceTag: ${record.evidenceTag}`, links) + expect(() => validateSessionFormatRelease(future, writer)).toThrow('must not exceed the current writer') + }) + + it('rejects empty, malformed, and URL-injecting evidence tags', () => { + const { record, links } = sessionFormatReleaseFixture() + for (const tag of [ + null, true, 1, '', ' ', 'dsh-v', record.evidenceTag.replace('dsh-v', 'v'), + `${record.evidenceTag}/other`, `${record.evidenceTag}?query`, `${record.evidenceTag}#fragment`, + `${record.evidenceTag}%2Fother`, `${record.evidenceTag})`, `${record.evidenceTag}\n`, + ]) { + const source = releaseDocument(`latestReleasedVersion: ${record.latestReleasedVersion}\nevidenceTag: ${JSON.stringify(tag)}`, links) + expect(() => validateSessionFormatRelease(source, record.latestReleasedVersion), String(tag)).toThrow('dsh-v version tag') + } + }) + + it('rejects absent or mismatched release and tagged-source links', () => { + const { record, body, links } = sessionFormatReleaseFixture() + for (const invalid of [ + '', + links.replace(`/releases/tag/${record.evidenceTag}`, `/releases/tag/${record.evidenceTag}-other`), + links.replace(`/blob/${record.evidenceTag}/`, '/blob/main/'), + links.replace('/packages/core/session/src/types.ts', '/packages/core/session/src/other.ts'), + links.replaceAll('github.com', 'example.com'), + links.replace(`${record.evidenceTag})`, `${record.evidenceTag}?query)`), + links.replace('types.ts)', 'types.ts#fragment)'), + ]) { + expect(() => validateSessionFormatRelease(releaseDocument(body, invalid), record.latestReleasedVersion), invalid).toThrow('Missing matching evidence link') + } + }) +}) + describe('dsh-doc skill consolidation', () => { it('carries no prototype-era language', () => { const files = [ From 8a41f7a8e1a63fcd63641b82672b121a8a17d1ec Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 9 Sep 2026 17:12:06 +0800 Subject: [PATCH 028/111] docs(session): clarify release authority review findings --- .../2026-08-10-session-log-version-mechanism.i18n.yaml | 4 ++-- .../2026-08-10-session-log-version-mechanism.md | 8 ++++++++ .../2026-08-10-session-log-version-mechanism.zh.md | 8 ++++++++ .../process/2026-08-10-npm-release-sequences.i18n.yaml | 2 +- .../process/2026-08-10-npm-release-sequences.zh.md | 2 +- .../2026-08-24-session-log-snapshot-corpus.i18n.yaml | 4 ++-- .../testing/2026-08-24-session-log-snapshot-corpus.md | 2 +- .../testing/2026-08-24-session-log-snapshot-corpus.zh.md | 2 +- docs/session-format-status.i18n.yaml | 4 ++-- docs/session-format-status.md | 4 ++-- docs/session-format-status.zh.md | 4 ++-- scripts/doc-standard.spec.ts | 4 +++- 12 files changed, 33 insertions(+), 15 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.i18n.yaml index 811d459c8a..94db853913 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md -2026-08-10-session-log-version-mechanism.md: 69ad02bcbdaab360063fac3b6e7d98960937677a -2026-08-10-session-log-version-mechanism.zh.md: fca404f15fcc7916ad8f431b491a1fd2e1faad75 +2026-08-10-session-log-version-mechanism.md: 513b126d3b00841f71893715cc296cb67619543e +2026-08-10-session-log-version-mechanism.zh.md: 919bfb077c7037da2ebf7eb81b75745d6b4fe4d0 diff --git a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md index 69ad02bcbd..513b126d3b 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md +++ b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md @@ -18,6 +18,12 @@ Session logs must be upgradable after release, and the runtime that ships first **A per-event `ignorable` marker covers vocabulary growth, so ordinary event additions never bump the version.** The event vocabulary is decided by which plugins are mounted, which a single version integer cannot describe. A reader meeting an unrecognized event type refuses to interpret the log unless the event carries `ignorable: true` in its envelope. The default is *required*: forgetting the marker over-refuses a resumable session (an inconvenience), while a default of ignorable would make the same mistake silently resume a gutted one (a safety failure). The architecture makes this sound: model-visible content flows only through the four `surfaceOp`-marked surface event types plus the `request/header`/`request/context` folds, so the dangerous unknowns are exactly the non-surface events that change how the rest of the log is read (`session/end-seed` is the existing example). +### Writer and publication authority + +`SESSION_FORMAT_VERSION` owns the checkout writer number; the [release-status reference](../../../../docs/session-format-status.md) owns one bilingual `latestReleasedVersion` and `evidenceTag` record. Publication changes independently of source development, so status is derived by comparing those facts rather than maintaining a second `released` boolean. General documentation links to these authorities; fixed-version contracts and historical evidence keep their explicit numbers. + +The [documentation-standard check](../../../../scripts/doc-standard.spec.ts) validates record structure, bilingual equality, evidence-link consistency, and the local release/writer ordering without network access. It proves internal consistency, not publication or freshness. The release operator verifies publication and updates the record after a higher format ships, as required by the [release process](../process/2026-08-10-npm-release-sequences.md). This keeps compatibility review independent of credentials and GitHub availability while making the manual freshness obligation explicit. + ## Consequences What shipped in v0 (release 0812): direction-aware refusal with the raw-log path; the unknown-event guard against a generated known-vocabulary list (`KNOWN_SESSION_EVENT_TYPES`, emitted by `gen-persistence-catalog` from every `SessionEventMap` merge and kept fresh by `verify-persistence-catalog`); the `ignorable` envelope field accepted by seed validation, JSONL, and the BFF wire schema. V1 adds the static adjacent catalog, the identity v0-to-v1 edge, header-only descriptors, exact-generation JSONL publication, and current-only restoration described in [Released Session formats](2026-08-31-released-session-format-migrations.md). [Historical Session read preparation](2026-09-05-read-only-session-migration-preparation.md) owns the JSONL timing between in-memory restoration and write publication. V2 keeps the physical codec neutral to ordinary event vocabulary and payload additions: the adjacent edge freezes its released source and target inventories, while equal-version restoration applies the installed known-event set and current payload semantics. First-party writers do not set `ignorable` through `Session.append`, while a repository-external plugin is a current consumer; equal-version retention lives in the [external-plugin retention decision](2026-08-30-retain-ignorable-external-session-events.md), and the stricter historical rule lives in the [alpha migration refusal decision](2026-08-31-alpha-historical-unknown-event-refusal.md). The unknown-type guard remains read-side because append-time vocabulary refusal would stall a live session's durability. JSONL classifies foreign versions from the minimal raw header before current-header or event parsing, so a structurally different future format reports the upgrade direction instead of "corrupt". @@ -28,3 +34,5 @@ What shipped in v0 (release 0812): direction-aware refusal with the raw-log path - **Default-ignorable unknown events** — inverts the failure mode of a forgotten marker from visible over-refusal into silent corruption. - **Migrating during header-only listing** — makes cheap inventory mutate storage and requires event bodies to compute facts that a header cannot prove. Listing returns descriptors; event-body reads own publication. - **Per-plugin runtime registration of known event types** — rejected because it would make the known set composition-dependent and register event names without classifying whether omission is safe. The persisted `ignorable` marker keeps that classification with each record; the [external-plugin retention decision](2026-08-30-retain-ignorable-external-session-events.md) owns the current consumer constraint. +- **Duplicate release flags or runtime status services** — introduce another mutable authority for a maintainer fact that does not control Session execution. The writer constant and publication record suffice. +- **Network-dependent documentation gates or publication automation** — network queries would couple local documentation checks to credentials and GitHub availability; a runtime service or publication workflow change is unnecessary for record consistency. Publication verification remains an explicit release-operator obligation. diff --git a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md index fca404f15f..919bfb077c 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md @@ -18,6 +18,12 @@ Session log 在发布后必须能升级格式,而最先发布的运行时决 **逐事件的 `ignorable` 标记吸收词汇表增长,普通的新增事件永远不用升版本。**事件词汇表由挂载了哪些插件决定,单个版本整数描述不了它。读取器遇到不认识的事件类型时拒绝解读日志,除非该事件的信封带 `ignorable: true`。默认为必需:忘写标记的后果是把一个本可恢复的会话拒绝过头(体验问题),而默认可忽略会让同样的疏忽静默恢复出残缺会话(安全事故)。架构保证了这条规则成立:模型可见内容只经四种带 `surfaceOp` 标记的 surface 事件加 `request/header`、`request/context` 折叠进入重建,危险的未知事件恰好是那些不进 surface 但改变日志其余部分解读方式的事件(`session/end-seed` 是现存例子)。 +### 写入器与发布真源 + +`SESSION_FORMAT_VERSION` 拥有工作区写入器版本号;[发布状态参考](../../../../docs/session-format-status.zh.md)拥有唯一的双语 `latestReleasedVersion` 与 `evidenceTag` 记录。发布状态独立于源码开发而变化,因此通过比较这两个事实推导状态,而不另行维护 `released` 布尔值。一般文档链接到这些真源;固定版本约定与历史证据保留明确版本号。 + +[文档标准检查](../../../../scripts/doc-standard.spec.ts)在不访问网络的情况下,校验记录结构、双语一致性、证据链接一致性及本地发布版本与写入器版本的大小关系。它证明内部一致性,而非发布事实或记录新鲜度。[发布流程](../process/2026-08-10-npm-release-sequences.zh.md)要求发布操作者在更高格式交付后核实发布并更新记录。这让兼容性评审不依赖凭据与 GitHub 可用性,同时明确人工维护新鲜度的义务。 + ## 影响 v0(0812 发布)交付的内容:分方向的拒绝并带原始日志路径;基于生成的已知词汇清单(`KNOWN_SESSION_EVENT_TYPES`,由 `gen-persistence-catalog` 从所有 `SessionEventMap` 声明合并生成,`verify-persistence-catalog` 保证新鲜)的未知事件守卫;`ignorable` 信封字段被种子校验、JSONL 和 BFF 线上 schema 接受。V1 添加静态相邻 catalog、恒等 v0-to-v1 迁移边、仅 header descriptor、精确代际 JSONL 发布与[已发布 Session 格式](2026-08-31-released-session-format-migrations.zh.md)定义的当前专用恢复。[历史 Session 只读迁移准备](2026-09-05-read-only-session-migration-preparation.zh.md)负责内存恢复与写入发布之间的 JSONL 时序。V2 让物理 codec 对普通事件词汇与 payload 新增项保持中立:相邻迁移边冻结 released source 与 target 清单,同版本恢复则应用已安装的 known-event set 与当前 payload 语义。第一方 writer 不通过 `Session.append` 设置 `ignorable`,而一个仓库外插件仍依赖该字段;同版本保留由[外部插件保留决策](2026-08-30-retain-ignorable-external-session-events.zh.md)定义,更严格的历史规则由 [alpha 迁移拒绝决策](2026-08-31-alpha-historical-unknown-event-refusal.zh.md)定义。未知类型守卫仍只在读取侧生效,因为 append 时的词汇拒绝会中断活跃 Session 的持久化。JSONL 会在当前 header 或事件解析前从最小原始 header 分类外来版本,因此结构完全不同的未来格式会报告升级方向而不是"损坏"。 @@ -28,3 +34,5 @@ v0(0812 发布)交付的内容:分方向的拒绝并带原始日志路径 - **未知事件默认可忽略**:把忘写标记的后果从可见的过度拒绝反转成静默损坏。 - **在仅 header 列表期间迁移**:让便宜清单改变存储,而且需要读取事件正文才能计算 header 无法证明的事实。列表返回 descriptor,事件正文读取负责发布。 - **插件运行时注册已知事件类型**:不予采用,因为该方案会让已知集依赖插件组合,而且只注册事件名称,无法判定省略事件是否安全。持久化的 `ignorable` 标记把该分类保留在每条记录中;[外部插件保留决策](2026-08-30-retain-ignorable-external-session-events.zh.md)定义当前消费方约束。 +- **重复发布标记或运行时状态服务**:为不控制 Session 执行的维护信息增加另一个可变真源。写入器常量与发布记录已经足够。 +- **依赖网络的文档门禁或发布自动化**:网络查询会把本地文档检查耦合到凭据与 GitHub 可用性;记录一致性不需要运行时服务或发布工作流变更。核实发布仍是发布操作者的明确义务。 diff --git a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.i18n.yaml b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.i18n.yaml index 6c552822ed..aec7f94a6a 100644 --- a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-10-npm-release-sequences.md 2026-08-10-npm-release-sequences.md: 19e8d8db8550816f542111a9b831a3694a080de5 -2026-08-10-npm-release-sequences.zh.md: b7a84f7af4203d775555770b8c61b87720bcddb1 +2026-08-10-npm-release-sequences.zh.md: d057d77adbf35c20fc260203d3ef0db92e30f6b7 diff --git a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.zh.md b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.zh.md index b7a84f7af4..d057d77adb 100644 --- a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.zh.md +++ b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.zh.md @@ -117,7 +117,7 @@ dsh 族套用仓库的发布 payload 策略(拒绝源码与声明映射)。v `pack` job 一趟遍历整个发布集,把每个成员打进同一个目录,写出上传顺序,整个目录作为一份 artifact 上传;它位于 `release.yml` / `release-vendor.yml`。发布集是一个整体——绝不会出现一半的包已经上了 registry、另一半还在构建。 -`pack` 无凭据,在每个 pull request 和每次 master push 上跑,所以一个 pull request 就能证明发布集仍能完整打出来。发布则位于独立的 `release-publish.yml` / `release-vendor-publish.yml` 工作流,仅 `workflow_dispatch`(因此不会作为 PR check 出现):它重新打包当前树,再按顺序逐个发布,挂在 `npm-publish` environment 后面等人工审批。pack 的 run 按 ref 分组,并发的 pull request 不会互相顶掉;全局 `Release-publish` 分组落在 `publish` job 上,因为 dist-tag 是共享的 registry 状态。 dsh 发布成功后,发布操作者按[发布记录](../../../../docs/session-format-status.zh.md#updating-the-record)核实其 Session 写入器;若交付了更高的 Session 格式,则更新该记录。 +`pack` 无凭据,在每个 pull request 和每次 master push 上跑,所以一个 pull request 就能证明发布集仍能完整打出来。发布则位于独立的 `release-publish.yml` / `release-vendor-publish.yml` 工作流,仅 `workflow_dispatch`(因此不会作为 PR check 出现):它重新打包当前树,再按顺序逐个发布,挂在 `npm-publish` environment 后面等人工审批。pack 的 run 按 ref 分组,并发的 pull request 不会互相顶掉;全局 `Release-publish` 分组落在 `publish` job 上,因为 dist-tag 是共享的 registry 状态。dsh 发布成功后,发布操作者按[发布记录](../../../../docs/session-format-status.zh.md#updating-the-record)核实其 Session 写入器;若交付了更高的 Session 格式,则更新该记录。 dsh 的验证会一并安装 vendored 族的 pack 产物。harness 的包把 vendored 框架声明成 peer,而那些包属于另一条序列,无凭据的 job 无法从私有 registry 取到——所以 dsh 的 `pack` job 为验证而打包 vendored 族,发布的仍只有 dsh 那一份。发布工作流(`release-publish.yml`)重新打包当前树,只发布 dsh 族。 diff --git a/.agents/notes/implemented/testing/2026-08-24-session-log-snapshot-corpus.i18n.yaml b/.agents/notes/implemented/testing/2026-08-24-session-log-snapshot-corpus.i18n.yaml index 84f01fcd06..9cb458cb68 100644 --- a/.agents/notes/implemented/testing/2026-08-24-session-log-snapshot-corpus.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-08-24-session-log-snapshot-corpus.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-08-24-session-log-snapshot-corpus.md -2026-08-24-session-log-snapshot-corpus.md: 593e1c5569107d50cfc85d7df4fd9491107847b8 -2026-08-24-session-log-snapshot-corpus.zh.md: 2ddccef3cc2942449707edd166e75bffa3ab64d8 +2026-08-24-session-log-snapshot-corpus.md: a9001c2eabd610e08cfb1177f2b1339f306320af +2026-08-24-session-log-snapshot-corpus.zh.md: d02c798af23a205993e92ed72a0f3a162f8bdded diff --git a/.agents/notes/implemented/testing/2026-08-24-session-log-snapshot-corpus.md b/.agents/notes/implemented/testing/2026-08-24-session-log-snapshot-corpus.md index 593e1c5569..a9001c2eab 100644 --- a/.agents/notes/implemented/testing/2026-08-24-session-log-snapshot-corpus.md +++ b/.agents/notes/implemented/testing/2026-08-24-session-log-snapshot-corpus.md @@ -22,7 +22,7 @@ Fixture decoding and comparison depend only on the selected JSONL content; filen Headless stderr reconstruction expands embedded reasoning from both `assistant/message` and log-only `assistant/attempt` settlements, so failed or retried reasoning remains part of the projected process output. -Each parent or child role uses `session[.][.vN].jsonl`, with v0 encoded by an omitted version and every filename matching its header. Replay, record, and refresh select the numerically highest generation per role. Most owners omit `sessionFormat` and track the current writer; a bounded historical owner declares its exact version and closed coverage names. The corpus keeps selected v0 roles for multi-hop, packed-row, retry/failure, and shipped-profile coverage plus selected v1 roles for the adjacent structural edge. Record and refresh never rewrite an explicitly retained historical fixture, rename a committed generation, or delete one through automatic cleanup. A retained Session generation does not freeze its non-Session expected outputs: refresh still writes owned system-prompt and tool-schema sidecars from the current run. Reviewed source-tree curation removes a predecessor only after the same role has a verified current successor. The corpus policy requires current selected roles to remain the majority and caps historical selected roles at ten; lower predecessor generations may remain beside a selected current successor. +Each parent or child role uses `session[.][.vN].jsonl`, with v0 encoded by an omitted version and every filename matching its header. Replay, record, and refresh select the numerically highest generation per role. Most owners omit `sessionFormat` and track the current writer; a bounded historical owner declares its exact version and closed coverage names. The corpus keeps selected v0 roles for multi-hop, packed-row, retry/failure, and shipped-profile coverage plus selected v1 roles for the v1→v2 structural edge within the complete migration chain. Record and refresh never rewrite an explicitly retained historical fixture, rename a committed generation, or delete one through automatic cleanup. A retained Session generation does not freeze its non-Session expected outputs: refresh still writes owned system-prompt and tool-schema sidecars from the current run. Reviewed source-tree curation removes a predecessor only after the same role has a verified current successor. The corpus policy requires current selected roles to remain the majority and caps historical selected roles at ten; lower predecessor generations may remain beside a selected current successor. Scenario-owned HTTP fixtures separate the stable authority recorded in the session from their transport listener. Each fixture binds loopback port `0`, lets the operating system allocate and bind the port atomically, and maps the recorded URL or endpoint through the real provider to that listener. Any process-global transport interception matches only the recorded endpoint, is owned by the fixture fiber, and is restored before the listener closes. diff --git a/.agents/notes/implemented/testing/2026-08-24-session-log-snapshot-corpus.zh.md b/.agents/notes/implemented/testing/2026-08-24-session-log-snapshot-corpus.zh.md index 2ddccef3cc..d02c798af2 100644 --- a/.agents/notes/implemented/testing/2026-08-24-session-log-snapshot-corpus.zh.md +++ b/.agents/notes/implemented/testing/2026-08-24-session-log-snapshot-corpus.zh.md @@ -22,7 +22,7 @@ Fixture 解码与比较只取决于选定 JSONL 内容;文件名标识 invento Headless stderr 重建会同时展开 `assistant/message` 与仅写入日志的 `assistant/attempt` settlement 中嵌入的 reasoning,因此失败或重试尝试的 reasoning 仍属于进程输出投影。 -每个 parent 或 child 角色都使用 `session[.][.vN].jsonl`;v0 省略版本,且每个文件名都与其 header 一致。回放、录制与刷新按角色选择数值最高的 generation。大多数 owner 省略 `sessionFormat` 并跟随当前 writer;受限的历史 owner 会声明精确版本与封闭 coverage 名称。语料保留选定 v0 角色,覆盖多跳、打包行、重试/失败与随附 profile,并保留选定 v1 角色覆盖相邻结构 edge。录制与刷新绝不改写显式保留的历史 fixture、重命名已提交 generation 或通过自动清理删除 generation。保留 Session generation 不会冻结非 Session 预期输出:refresh 仍会根据当前 run 写入 owner 持有的 system-prompt 与 tool-schema sidecar。受审阅的源树整理只有在同角色存在已验证的当前后继后才移除前代。语料策略要求选定当前角色始终占多数,并将选定历史角色上限设为十个;更低的前代 generation 可以保留在选定当前后继旁。 +每个 parent 或 child 角色都使用 `session[.][.vN].jsonl`;v0 省略版本,且每个文件名都与其 header 一致。回放、录制与刷新按角色选择数值最高的 generation。大多数 owner 省略 `sessionFormat` 并跟随当前 writer;受限的历史 owner 会声明精确版本与封闭 coverage 名称。语料保留选定 v0 角色,覆盖多跳、打包行、重试/失败与随附 profile,并保留选定 v1 角色覆盖完整迁移链中的 v1→v2 结构 edge。录制与刷新绝不改写显式保留的历史 fixture、重命名已提交 generation 或通过自动清理删除 generation。保留 Session generation 不会冻结非 Session 预期输出:refresh 仍会根据当前 run 写入 owner 持有的 system-prompt 与 tool-schema sidecar。受审阅的源树整理只有在同角色存在已验证的当前后继后才移除前代。语料策略要求选定当前角色始终占多数,并将选定历史角色上限设为十个;更低的前代 generation 可以保留在选定当前后继旁。 场景拥有的 HTTP fixture 将会话中录制的稳定 authority 与传输 listener 分离。每个 fixture 在回环地址上绑定端口 `0`,由操作系统以一次原子操作分配并绑定端口,再将录制的 URL 或 endpoint 通过真实 provider 映射到该 listener。任何进程全局传输拦截只匹配录制 endpoint,由 fixture fiber 拥有,并在关闭 listener 前恢复。 diff --git a/docs/session-format-status.i18n.yaml b/docs/session-format-status.i18n.yaml index 01391d480e..c12df73162 100644 --- a/docs/session-format-status.i18n.yaml +++ b/docs/session-format-status.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/session-format-status.md -session-format-status.md: 222afcbd7e9d5baede55e748065ea49363da361b -session-format-status.zh.md: 6c7e97816cb97416b225304e99f23c68fd31a855 +session-format-status.md: 9076574b9b12b4f75615ff06939795a8f0051def +session-format-status.zh.md: 6c230479f8e41441f0c2e9848448a61d772a46b9 diff --git a/docs/session-format-status.md b/docs/session-format-status.md index 222afcbd7e..9076574b9b 100644 --- a/docs/session-format-status.md +++ b/docs/session-format-status.md @@ -18,9 +18,9 @@ Use this reference to distinguish the checkout’s Session writer version from t - **Checkout writer:** `SESSION_FORMAT_VERSION` in [core Session types](../packages/core/session/src/types.ts) is the only hand-maintained current-writer number in code. The [catalog generator](../scripts/gen-session-format-catalog.ts) derives codec ordering and checks that adjacent migrations reach it. A package version, codec export name, fixture filename, or projection-cache version is not the writer authority. - **Latest released format:** `latestReleasedVersion` in the following record identifies the published Session format. `evidenceTag` names a published product release whose tagged writer has that value; it need not be the first release carrying the format. The bilingual copy is checked against the same record, not maintained as a separate decision. -- **Release status:** compare the writer constant with the verified release record. Equality means the writer format has shipped. A greater writer version is a development target beyond the recorded release. A lower writer version means this checkout predates that release. No separate released boolean is maintained. Before declaring a greater version unreleased, verify that no published release has advanced the record. +- **Release status:** compare the writer constant with the verified release record. Equality means the writer format has shipped. A greater writer version is a development target beyond the recorded release. When comparing an older checkout against a newer branch’s verified record, a lower writer version identifies an older writer format; the local consistency gate rejects that ordering within one checkout. No separate released boolean is maintained. Before declaring a greater version unreleased, verify that no published release has advanced the record. -An alpha, beta, or release-candidate product publication establishes released Session-format obligations. GitHub’s prerelease flag does not make persisted user data disposable. A missing release record is not evidence of non-publication. The [versioning rule](../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md) owns compatibility decisions; [released-format migration](../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md) owns immutable generations and adjacent conversion. +An alpha, beta, or release-candidate product publication establishes released Session-format obligations. GitHub’s prerelease flag does not make persisted user data disposable. A missing release record is not evidence of non-publication. The [versioning and authority decision](../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md) owns compatibility decisions; [released-format migration](../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md) owns immutable generations and adjacent conversion. ## Release record diff --git a/docs/session-format-status.zh.md b/docs/session-format-status.zh.md index 6c7e97816c..6c230479f8 100644 --- a/docs/session-format-status.zh.md +++ b/docs/session-format-status.zh.md @@ -18,9 +18,9 @@ - **工作区写入器:**[核心 Session 类型](../packages/core/session/src/types.ts)中的 `SESSION_FORMAT_VERSION` 是代码中唯一手工维护的当前写入器版本号。[目录生成器](../scripts/gen-session-format-catalog.ts)推导 codec 顺序,并检查相邻迁移是否到达该版本。包版本、codec 导出名称、fixture(测试前置数据)文件名或投影缓存版本都不是写入器版本的权威来源。 - **最新已发布格式:**下方记录中的 `latestReleasedVersion` 标识已发布的 Session 格式。`evidenceTag` 指定一个已发布的产品版本,其标签对应的写入器具有该值;它不必是首次携带该格式的发布。双语副本按同一记录校验,不作为独立决策维护。 -- **发布状态:**比较写入器常量与已核实的发布记录。相等表示写入器格式已经发布。写入器版本更高表示它是超出记录中发布版本的开发目标。写入器版本更低表示该工作区早于该发布。不另行维护 released 布尔值。在声明更高版本尚未发布前,必须核实是否已有产品发布推进了记录。 +- **发布状态:**比较写入器常量与已核实的发布记录。相等表示写入器格式已经发布。写入器版本更高表示它是超出记录中发布版本的开发目标。用较新分支中已核实的记录对比旧工作区时,较低的写入器版本表示较旧的写入器格式;本地一致性门禁会拒绝同一工作区内的这种大小关系。不另行维护 released 布尔值。在声明更高版本尚未发布前,必须核实是否已有产品发布推进了记录。 -产品的 alpha、beta 或 release-candidate 发布都会确立已发布 Session 格式的义务。GitHub 的 prerelease 标记不会让持久化用户数据成为可丢弃数据。缺少发布记录不代表尚未发布。[版本规则](../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md)拥有兼容性决策;[已发布格式迁移](../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md)拥有不可变代际与相邻转换规则。 +产品的 alpha、beta 或 release-candidate 发布都会确立已发布 Session 格式的义务。GitHub 的 prerelease 标记不会让持久化用户数据成为可丢弃数据。缺少发布记录不代表尚未发布。[版本与真源决策](../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md)拥有兼容性决策;[已发布格式迁移](../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md)拥有不可变代际与相邻转换规则。 ## 发布记录 diff --git a/scripts/doc-standard.spec.ts b/scripts/doc-standard.spec.ts index 9838d3e2c5..aae2fcf2cf 100644 --- a/scripts/doc-standard.spec.ts +++ b/scripts/doc-standard.spec.ts @@ -2,7 +2,8 @@ * Quick comprehensive documentation-standard tests: the reference example * stays valid, the consolidated `dsh-doc` skill carries no stale copied * website values or prototype-era language, and the kind system maps each - * label to exactly one skill template. These run in `pnpm run test` and + * label to exactly one skill template. Session release records match the + * writer bound, bilingual counterpart, and evidence links. These run in `pnpm run test` and * `pnpm run test:docs` to guard the standard between heavier corpus gates. * @module scripts/doc-standard.spec */ @@ -152,6 +153,7 @@ interface SessionFormatRelease { evidenceTag: string } +/** Validate the release record and evidence links; throw on malformed or inconsistent input. */ function validateSessionFormatRelease(source: string, currentWriterVersion: number): SessionFormatRelease { const normalized = source.replaceAll('\r\n', '\n') const openings = [...normalized.matchAll(/^```yaml session-format-release[ \t]*$/gmu)] From 1d55cab9e66108ba536961402d3b8e6a0caa5ec0 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 9 Sep 2026 17:23:07 +0800 Subject: [PATCH 029/111] fix(web): use shared file type icons in delivery cards --- .../2026-09-08-shared-file-type-icons.i18n.yaml | 4 ++-- .../feature/2026-09-08-shared-file-type-icons.md | 2 +- .../2026-09-08-shared-file-type-icons.zh.md | 2 +- packages/client/ui-deliverables/README.i18n.yaml | 4 ++-- packages/client/ui-deliverables/README.md | 2 +- packages/client/ui-deliverables/README.zh.md | 2 +- .../src/client/Deliverables.module.css | 1 - .../src/client/PresentedFileCard.tsx | 6 +++--- .../tests/produced-files.client.spec.tsx | 14 ++++++++++++++ 9 files changed, 25 insertions(+), 12 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.i18n.yaml b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.i18n.yaml index e8a971188f..ce64968fbd 100644 --- a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.md -2026-09-08-shared-file-type-icons.md: 36b4ff8f57dd810da3d2af94aac15ec5afab799e -2026-09-08-shared-file-type-icons.zh.md: 4b33a8ffe5934953b12e82750ce57febc6f054f4 +2026-09-08-shared-file-type-icons.md: 0844cb8828edafe135d4f35b6b942691998b8b97 +2026-09-08-shared-file-type-icons.zh.md: 6d10a5669a3c68cfa327997f6822547689e3df1a diff --git a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.md b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.md index 36b4ff8f57..0844cb8828 100644 --- a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.md +++ b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.md @@ -18,7 +18,7 @@ Recognized code and configuration files render the corresponding 20px square art `LinkIcon` delegates extension classification to `classifyFileType` and folds the detailed result into its existing link vocabulary: code and HTML use `code`, images use `image`, PDF/Word/Excel/PPT use `document`, and Markdown/video/unknown files use `other`. Extensionless names remain `other` in link contexts, so the 14px clickable-link appearance defined by the [clickable-link decision](2026-09-04-web-clickable-link-styles.md) does not change. -Attachment upload cards, sent-message file cards, queued-file rows, and workspace file rows render `FileTypeIcon`. The two metadata rows use `fileExtension` rather than local parsers; a leading-dot basename such as `.env` therefore displays `ENV`, while an absent or trailing suffix displays no extension label. Image content continues to render as a preview rather than a file-type glyph, and produced-file links and Markdown file mentions continue to use `LinkIcon` because they are link surfaces. +Attachment upload cards, sent-message file cards, queued-file rows, and workspace file rows render `FileTypeIcon`. Explicit delivery cards also use `FileTypeIcon` at 28px and `fileExtension` for their fallback metadata. The two metadata rows use `fileExtension` rather than local parsers; a leading-dot basename such as `.env` therefore displays `ENV`, while an absent or trailing suffix displays no extension label. Image content continues to render as a preview rather than a file-type glyph, and produced-file links and Markdown file mentions continue to use `LinkIcon` because they are link surfaces. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.zh.md b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.zh.md index 4b33a8ffe5..6d10a5669a 100644 --- a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.zh.md +++ b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.zh.md @@ -18,7 +18,7 @@ Status: implemented `LinkIcon` 委托 `classifyFileType` 做扩展名分类,再把精细结果折叠进原有链接词汇:code 与 HTML 使用 `code`,图片使用 `image`,PDF/Word/Excel/PPT 使用 `document`,Markdown、video 与未知文件使用 `other`。无扩展名文件在链接语境中仍是 `other`,因此[可点击链接决策](2026-09-04-web-clickable-link-styles.zh.md)定义的 14px 外观不变。 -附件上传卡片、已发送消息文件卡片、排队文件行和工作区文件行渲染 `FileTypeIcon`。两处元数据行使用 `fileExtension`,不再保留本地解析器;`.env` 这样的前导点 basename 会显示 `ENV`,无后缀或末尾点号则不显示扩展名 label。图片内容继续渲染为预览而不是文件类型图形,产物文件链接与 Markdown 文件提及继续使用 `LinkIcon`,因为它们属于链接表面。 +附件上传卡片、已发送消息文件卡片、排队文件行和工作区文件行渲染 `FileTypeIcon`。显式交付卡片也使用 28px 的 `FileTypeIcon`,并通过 `fileExtension` 提供默认元数据。两处元数据行使用 `fileExtension`,不再保留本地解析器;`.env` 这样的前导点 basename 会显示 `ENV`,无后缀或末尾点号则不显示扩展名 label。图片内容继续渲染为预览而不是文件类型图形,产物文件链接与 Markdown 文件提及继续使用 `LinkIcon`,因为它们属于链接表面。 ## 备选方案 diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml index 12e097c4de..59ca1fefc1 100644 --- a/packages/client/ui-deliverables/README.i18n.yaml +++ b/packages/client/ui-deliverables/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-deliverables/README.md -README.md: 50677e0cfb260c7b9bd31ed9599a2d0214dda201 -README.zh.md: 2196be4fed14cb269056ce4fd83960ca4024f63a +README.md: 5ce7eb51df13e23a01a9ca05291c30a93e6d6d0c +README.zh.md: 9d25bbe4f76b3bf14807ea741f4176ececbe94b7 diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md index 50677e0cfb..5ce7eb51df 100644 --- a/packages/client/ui-deliverables/README.md +++ b/packages/client/ui-deliverables/README.md @@ -30,7 +30,7 @@ Mount this plugin alongside `ui-conversation`; a finished turn then ends with th ### Explicit deliveries -The Web `standard`, `ptc`, and `cordis` presets expose `present` for final files accessible through the Session filesystem, including files created through Bash. Call it with `files: [{ path, description? }]` after creating the files. The [present tool](../../fs/tool-present/README.md) owns file-count limits and Session declarations. The closing turn shows one delivery as a full-width card and multiple deliveries in a grid of at most two cards per row. A list longer than four files starts collapsed and provides a control that reveals or hides the complete list. Each card shows the basename and description, or the file type when no description exists; a trailing parenthesized suffix in the description is omitted, and hovering the card replaces that line with the Sidebar-preview action. Clicking the card or the left side of its split Open control previews the file in the right Sidebar. The chevron opens the standard menu for the Host default application plus Show in Finder on macOS, Show in File Explorer on Windows and WSL, or Open containing folder through the default Linux file manager. Matching inline-code references open the same source files without starting a browser download. Repeated declaration of a path selects its latest description before the closing reply. +The Web `standard`, `ptc`, and `cordis` presets expose `present` for final files accessible through the Session filesystem, including files created through Bash. Call it with `files: [{ path, description? }]` after creating the files. The [present tool](../../fs/tool-present/README.md) owns file-count limits and Session declarations. The closing turn shows one delivery as a full-width card and multiple deliveries in a grid of at most two cards per row. A list longer than four files starts collapsed and provides a control that reveals or hides the complete list. Each card uses the shared `FileTypeIcon` and shows the basename and description, or the file type when no description exists; a trailing parenthesized suffix in the description is omitted, and hovering the card replaces that line with the Sidebar-preview action. Clicking the card or the left side of its split Open control previews the file in the right Sidebar. The chevron opens the standard menu for the Host default application plus Show in Finder on macOS, Show in File Explorer on Windows and WSL, or Open containing folder through the default Linux file manager. Matching inline-code references open the same source files without starting a browser download. Repeated declaration of a path selects its latest description before the closing reply. The `present` tool row shows running, delivered, failed, or interrupted status; expanding a settled row reveals its recorded result. The collapsible card grid retains every delivered file. Both menu actions share pending state and show progress, acknowledgement, or an action-specific retryable error. Desktop information is read when delivery cards appear and invalidated on connection replacement; responses from a replaced connection cannot publish metadata. Selecting a native menu action returns keyboard focus to the available Sidebar Open button. Pending actions close the menu until another explicit gesture. A missing desktop disables the Open menu; a failed desktop-information read offers Retry. It requires a desktop and a suitable default application on the serving Host; a remote browser does not open applications on its own device. diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index 2196be4fed..9d25bbe4f7 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -30,7 +30,7 @@ kind: "package-reference" ### 显式交付 -Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交付Session 文件系统可访问的最终文件,包括通过 Bash 创建的文件。创建文件后,以 `files: [{ path, description? }]` 调用。[present 工具](../../fs/tool-present/README.zh.md)拥有文件数量限制和 Session 声明。收尾 turn 把单个交付显示为横向占满内容区的卡片,把多个交付显示为每行最多两张卡片的网格。文件超过四个时,列表默认收起,并提供显示或隐藏完整列表的控件。每张卡片显示 basename 与说明;没有说明时显示文件类型,说明末尾的括号后缀会被省略,悬停卡片时该行切换为侧栏预览提示。点击卡片或分段“打开”控件的左侧会在右侧 Sidebar 中预览文件;右侧箭头打开标准菜单,其中提供 Host 默认应用,以及 macOS 上的“在 Finder 中显示”、Windows 和 WSL 上的“在文件资源管理器中显示”或 Linux 默认文件管理器的“打开所在文件夹”。匹配的行内代码引用打开相同源文件,不触发浏览器下载。同一路径重复声明时,选择收尾回复之前最近一次的说明。 +Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交付Session 文件系统可访问的最终文件,包括通过 Bash 创建的文件。创建文件后,以 `files: [{ path, description? }]` 调用。[present 工具](../../fs/tool-present/README.zh.md)拥有文件数量限制和 Session 声明。收尾 turn 把单个交付显示为横向占满内容区的卡片,把多个交付显示为每行最多两张卡片的网格。文件超过四个时,列表默认收起,并提供显示或隐藏完整列表的控件。每张卡片使用共享的 `FileTypeIcon`,显示 basename 与说明;没有说明时显示文件类型,说明末尾的括号后缀会被省略,悬停卡片时该行切换为侧栏预览提示。点击卡片或分段“打开”控件的左侧会在右侧 Sidebar 中预览文件;右侧箭头打开标准菜单,其中提供 Host 默认应用,以及 macOS 上的“在 Finder 中显示”、Windows 和 WSL 上的“在文件资源管理器中显示”或 Linux 默认文件管理器的“打开所在文件夹”。匹配的行内代码引用打开相同源文件,不触发浏览器下载。同一路径重复声明时,选择收尾回复之前最近一次的说明。 `present` 工具行显示正在交付、已交付、失败或中断状态;展开已结束的调用可查看其记录的结果。可折叠卡片网格保留全部交付文件。菜单中的两个操作共享等待状态,并显示进度、请求确认或各自可重试的错误。交付卡片出现时读取桌面信息,连接更换时清除缓存,旧连接的响应不能更新元数据。选择原生菜单操作后,键盘焦点回到仍可用的侧边栏“打开”按钮。等待操作完成时关闭菜单,用户再次点击才会打开。Host 没有桌面时禁用“打开”菜单;桌面信息读取失败时提供“重试”。服务 Host 必须具备桌面和合适的默认应用;远程浏览器不会打开其所在设备上的应用。 diff --git a/packages/client/ui-deliverables/src/client/Deliverables.module.css b/packages/client/ui-deliverables/src/client/Deliverables.module.css index cd503f8f79..5125edd381 100644 --- a/packages/client/ui-deliverables/src/client/Deliverables.module.css +++ b/packages/client/ui-deliverables/src/client/Deliverables.module.css @@ -9,7 +9,6 @@ .cardPreview { position: absolute; z-index: 1; inset: 0; width: 100%; padding: 0; border: 0; border-radius: inherit; background: transparent; cursor: pointer; } .cardPreview:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--dsw-alias-brand-primary); } .fileIcon { position: relative; z-index: 2; flex: 0 0 auto; display: grid; place-items: center; width: 48px; height: 48px; overflow: hidden; pointer-events: none; border: 0.5px solid var(--dsw-alias-border-l2); border-radius: 12px; background: var(--deliverable-fill); color: var(--dsw-alias-link); } -.fileIcon svg { width: 20px; height: 20px; } .fileBody { position: relative; z-index: 2; display: flex; flex: 1; align-items: center; justify-content: space-between; gap: 12px; min-width: 0; pointer-events: none; } .details { display: flex; flex: 1; flex-direction: column; justify-content: center; gap: 2px; min-width: 0; } .fileName { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; font-weight: 500; line-height: 22px; } diff --git a/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx b/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx index 51946bf65a..ef848827e8 100644 --- a/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx +++ b/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx @@ -2,7 +2,7 @@ import { useRef, useState } from 'react' import { resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path' import { - Menu, LinkIcon, classifyLinkPath, IconRightUpOutline16, + Menu, FileTypeIcon, fileExtension, IconRightUpOutline16, IconChevronDownOutline14, IconFolderOpenOutline16, } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' @@ -42,7 +42,7 @@ export function PresentedFileCard({ file, cwd, phase, host, onPreview, onAction, onAction(action) } const name = basename(file.path) - const metadata = name.match(/\.([^.]+)$/)?.[1]?.toUpperCase() ?? t('presented.file') + const metadata = fileExtension(name).toUpperCase() || t('presented.file') const status = phase === undefined ? cardDescription(file.description, metadata) : t(reveal === 'directory' && phase === 'revealed' ? 'presented.directoryOpened' @@ -51,7 +51,7 @@ export function PresentedFileCard({ file, cwd, phase, host, onPreview, onAction, return
+ )}
- {body} + {showingPreview ? preview.content : body}
) } diff --git a/packages/client/ui-primitives/src/markdown/MermaidPreview.module.css b/packages/client/ui-primitives/src/markdown/MermaidPreview.module.css new file mode 100644 index 0000000000..bd24e36251 --- /dev/null +++ b/packages/client/ui-primitives/src/markdown/MermaidPreview.module.css @@ -0,0 +1,26 @@ +.canvas { + overflow: auto; + padding: 16px; + background: var(--dsw-static-neutral-00); + border-radius: 0 0 12px 12px; +} + +.diagram { + display: block; + max-width: 100%; + height: auto; + margin: 0 auto; +} + +.status { + padding: 16px; + color: var(--dsw-alias-label-secondary); +} + +.staging { + position: fixed; + top: 0; + left: -100000px; + visibility: hidden; + pointer-events: none; +} diff --git a/packages/client/ui-primitives/src/markdown/MermaidPreview.tsx b/packages/client/ui-primitives/src/markdown/MermaidPreview.tsx new file mode 100644 index 0000000000..b59a09a48c --- /dev/null +++ b/packages/client/ui-primitives/src/markdown/MermaidPreview.tsx @@ -0,0 +1,42 @@ +/** Read-only diagram preview with source fallback and per-source async ownership. */ + +import { useEffect, useState } from 'react' +import { renderMermaid } from './mermaid.ts' +import css from './MermaidPreview.module.css' + +/** Localized preview states; the diagram source remains verbatim. */ +export interface MermaidPreviewLabels { + diagram: string + loading: string + error: string +} + +type Result = { code: string; src: string } | { code: string; error: true } + +/** + * Display a complete Mermaid document on a light diagram canvas, or its source if rendering fails. + * @param props - Source and complete localized labels. Changing source discards the previous result. + * @returns A loading status, an inert SVG image, or an error with the original source. + */ +export function MermaidPreview({ code, labels }: { code: string; labels: MermaidPreviewLabels }) { + const [result, setResult] = useState(null) + useEffect(() => { + const controller = new AbortController() + void renderMermaid(code, controller.signal).then( + (src) => { if (!controller.signal.aborted) setResult({ code, src }) }, + () => { if (!controller.signal.aborted) setResult({ code, error: true }) }, + ) + return () => { controller.abort() } + }, [code]) + + if (result?.code !== code) return
{labels.loading}
+ if ('error' in result) { + return ( +
+
{labels.error}
+
{code}
+
+ ) + } + return
{labels.diagram}
+} diff --git a/packages/client/ui-primitives/src/markdown/mermaid.ts b/packages/client/ui-primitives/src/markdown/mermaid.ts new file mode 100644 index 0000000000..4db2a64b6c --- /dev/null +++ b/packages/client/ui-primitives/src/markdown/mermaid.ts @@ -0,0 +1,59 @@ +/** Lazy Mermaid runtime; each render owns and removes its temporary measurement DOM. */ + +import type { Mermaid } from 'mermaid' +import clsx from 'clsx' +import css from './MermaidPreview.module.css' + +let runtime: Promise | undefined +let nextDiagramId = 0 + +function loadMermaid(): Promise { + runtime ??= import('mermaid').then(({ default: mermaid }) => { + mermaid.initialize({ + startOnLoad: false, + securityLevel: 'strict', + suppressErrorRendering: true, + theme: 'neutral', + htmlLabels: false, + secure: [ + 'secure', 'securityLevel', 'startOnLoad', 'maxTextSize', 'maxEdges', + 'suppressErrorRendering', 'theme', 'themeVariables', 'themeCSS', 'htmlLabels', 'flowchart', + ], + }) + return mermaid + }).catch((error: unknown) => { + runtime = undefined + throw error + }) + return runtime +} + +/** + * Render untrusted diagram source as an SVG image URL, without installing SVG or link handlers in the UI. + * @param code - Complete Mermaid source. + * @param signal - Cancels work waiting for the runtime; an active Mermaid render finishes before cleanup. + * @returns An SVG data URL. Import, parse, rendering, and cancellation failures reject. + */ +export async function renderMermaid(code: string, signal: AbortSignal): Promise { + const mermaid = await loadMermaid() + signal.throwIfAborted() + const stage = document.createElement('div') + stage.className = clsx(css.staging) + stage.setAttribute('aria-hidden', 'true') + document.body.append(stage) + try { + // Mermaid's public render API serializes diagram work; the lower-level mermaidAPI does not. + const { svg } = await mermaid.render(`dsh-mermaid-${nextDiagramId++}`, code, stage) + const root = new DOMParser().parseFromString(svg, 'image/svg+xml').documentElement + const viewBox = root.getAttribute('viewBox') + if (viewBox !== null) { + // An SVG image needs intrinsic dimensions; Mermaid's percentage width is for inline SVG. + const [, , width, height] = viewBox.split(/\s+/) as [string, string, string, string] + root.setAttribute('width', width) + root.setAttribute('height', height) + } + return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(new XMLSerializer().serializeToString(root))}` + } finally { + stage.remove() + } +} diff --git a/packages/client/ui-primitives/src/markdown/render.tsx b/packages/client/ui-primitives/src/markdown/render.tsx index 4d7805553b..d946c06893 100644 --- a/packages/client/ui-primitives/src/markdown/render.tsx +++ b/packages/client/ui-primitives/src/markdown/render.tsx @@ -23,6 +23,8 @@ import type * as Md from 'mdast' import type {} from 'mdast-util-math' import { normalizeUri } from 'micromark-util-sanitize-uri' import { CodeBlock } from './CodeBlock.tsx' +import { MermaidPreview } from './MermaidPreview.tsx' +import type { MermaidPreviewLabels } from './MermaidPreview.tsx' import { renderTexToReact } from './katex.tsx' import { LinkIcon, classifyLinkPath } from '../LinkIcon.tsx' import type { PositionedBlock } from './incremental.ts' @@ -40,6 +42,8 @@ export interface MarkdownCodeLabels { export interface MarkdownLabels { code: MarkdownCodeLabels footnotes: string + /** Opt into settled Mermaid fence previews by supplying their complete localized chrome. */ + mermaid?: MermaidPreviewLabels & { preview: string; source: string } } function sanitizeUrl(url: string): string { @@ -394,6 +398,11 @@ function renderCode(node: Md.Code, key: Key, context: MarkdownRenderContext): Re streaming={context.streaming} copyLabel={context.labels.code.copyLabel} copiedLabel={context.labels.code.copiedLabel} + preview={lang === 'mermaid' && !context.streaming && context.labels.mermaid !== undefined ? { + content: , + previewLabel: context.labels.mermaid.preview, + sourceLabel: context.labels.mermaid.source, + } : undefined} /> ) } diff --git a/packages/client/ui-primitives/tests/code-block.client.spec.tsx b/packages/client/ui-primitives/tests/code-block.client.spec.tsx index 4413cf46d2..83c016c8f0 100644 --- a/packages/client/ui-primitives/tests/code-block.client.spec.tsx +++ b/packages/client/ui-primitives/tests/code-block.client.spec.tsx @@ -122,7 +122,7 @@ describe('CodeBlock', () => { expect(view.getByText('plain text')).toBeTruthy() }) - it('shows the language banner and copies the pre textContent', async () => { + it('shows the language banner and copies the displayed source text', async () => { vi.useFakeTimers() const writeText = vi.fn().mockResolvedValue(undefined) Object.defineProperty(navigator, 'clipboard', { diff --git a/packages/client/ui-primitives/tests/mermaid-preview.client.spec.tsx b/packages/client/ui-primitives/tests/mermaid-preview.client.spec.tsx new file mode 100644 index 0000000000..4a716c4886 --- /dev/null +++ b/packages/client/ui-primitives/tests/mermaid-preview.client.spec.tsx @@ -0,0 +1,107 @@ +// @vitest-environment jsdom + +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { MermaidPreview } from '../src/markdown/MermaidPreview.tsx' +import { renderMermaid } from '../src/markdown/mermaid.ts' +import { MarkdownText } from '../src/markdown/MarkdownText.tsx' +import { markdownLabels } from './labels.client.ts' + +vi.mock('../src/markdown/mermaid.ts', () => ({ renderMermaid: vi.fn() })) + +const labels = { + diagram: 'Mermaid diagram', loading: 'Rendering diagram…', error: 'Unable to render this diagram.', + preview: 'Preview', source: 'Source', +} +const source = 'flowchart LR\n A[Input] --> B[Preview]' +const imageUrl = 'data:image/svg+xml;charset=utf-8,%3Csvg%2F%3E' + +afterEach(() => { cleanup(); vi.unstubAllGlobals() }) +beforeEach(() => { vi.resetAllMocks() }) + +describe('MermaidPreview', () => { + it('shows loading until rendering completes, then displays an image without inserting SVG', async () => { + const pending = Promise.withResolvers() + vi.mocked(renderMermaid).mockReturnValue(pending.promise) + const view = render() + expect(screen.getByRole('status').textContent).toBe(labels.loading) + expect(screen.queryByRole('img')).toBeNull() + await act(async () => { pending.resolve(imageUrl) }) + expect(screen.getByRole('img', { name: labels.diagram }).getAttribute('src')).toBe(imageUrl) + expect(view.container.querySelector('svg')).toBeNull() + expect(screen.queryByRole('status')).toBeNull() + }) + + it('retains invalid source and recovers when the source changes', async () => { + vi.mocked(renderMermaid).mockRejectedValueOnce(new Error('Parse error')) + const view = render() + expect((await screen.findByText(labels.error)).getAttribute('role')).toBe('status') + expect(view.container.querySelector('pre code')?.textContent).toBe('invalid') + const next = Promise.withResolvers() + vi.mocked(renderMermaid).mockReturnValue(next.promise) + view.rerender() + expect(screen.getByRole('status').textContent).toBe(labels.loading) + expect(view.container.querySelector('pre')).toBeNull() + await act(async () => { next.resolve(imageUrl) }) + expect(screen.getByRole('img').getAttribute('src')).toBe(imageUrl) + }) + + it.each(['resolve', 'reject'] as const)('ignores a stale %s after a newer source finishes', async (outcome) => { + const old = Promise.withResolvers() + const next = Promise.withResolvers() + vi.mocked(renderMermaid).mockReturnValueOnce(old.promise).mockReturnValueOnce(next.promise) + const view = render() + const oldSignal = vi.mocked(renderMermaid).mock.calls[0]![1] + view.rerender() + expect(oldSignal.aborted).toBe(true) + await act(async () => { next.resolve(imageUrl) }) + await act(async () => { + if (outcome === 'resolve') old.resolve('obsolete-image') + else old.reject(new Error('obsolete-error')) + }) + expect(screen.getByRole('img').getAttribute('src')).toBe(imageUrl) + expect(screen.queryByRole('status')).toBeNull() + }) + + it('cancels the owner when unmounted while loading', async () => { + const pending = Promise.withResolvers() + vi.mocked(renderMermaid).mockReturnValue(pending.promise) + const view = render() + const signal = vi.mocked(renderMermaid).mock.calls[0]![1] + view.unmount() + expect(signal.aborted).toBe(true) + await act(async () => { pending.reject(new Error('cancelled')) }) + expect(screen.queryByRole('img')).toBeNull() + }) +}) + +describe('Markdown Mermaid fences', () => { + it('keeps streaming source literal, then offers preview, source and source copying', async () => { + const writeText = vi.fn().mockResolvedValue(undefined) + vi.stubGlobal('navigator', Object.create(navigator, { clipboard: { value: { writeText } } })) + vi.mocked(renderMermaid).mockResolvedValue(imageUrl) + const props = { text: `\`\`\`mermaid\n${source}\n\`\`\``, labels: { ...markdownLabels, mermaid: labels } } + const view = render() + expect(view.container.querySelector('pre code')?.textContent).toBe(source) + expect(renderMermaid).not.toHaveBeenCalled() + expect(screen.queryByRole('button', { name: labels.source })).toBeNull() + view.rerender() + await screen.findByRole('img', { name: labels.diagram }) + fireEvent.click(screen.getByRole('button', { name: markdownLabels.code.copyLabel })) + await screen.findByRole('button', { name: markdownLabels.code.copiedLabel }) + expect(writeText).toHaveBeenCalledWith(source) + fireEvent.click(screen.getByRole('button', { name: labels.source })) + expect(view.container.querySelector('pre code')?.textContent).toBe(source) + expect(screen.queryByRole('img')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: labels.preview })) + await screen.findByRole('img', { name: labels.diagram }) + }) + + it('leaves other languages and consumers without preview labels as code', () => { + const view = render() + expect(view.container.querySelector('pre code')?.textContent).toBe(source) + view.rerender() + expect(view.container.querySelector('pre code')?.textContent).toBe('flowchart LR') + expect(renderMermaid).not.toHaveBeenCalled() + }) +}) diff --git a/packages/client/ui-primitives/tests/mermaid-runtime.client.spec.ts b/packages/client/ui-primitives/tests/mermaid-runtime.client.spec.ts new file mode 100644 index 0000000000..8b79a42b9a --- /dev/null +++ b/packages/client/ui-primitives/tests/mermaid-runtime.client.spec.ts @@ -0,0 +1,83 @@ +// @vitest-environment jsdom + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const initialize = vi.fn() +const renderDiagram = vi.fn() + +beforeEach(() => { + vi.resetModules() + vi.resetAllMocks() + vi.doMock('mermaid', () => ({ default: { initialize, render: renderDiagram } })) +}) +afterEach(() => { vi.doUnmock('mermaid') }) + +describe('Mermaid runtime', () => { + it('shares lazy initialization and removes each measurement container after rendering', async () => { + const { renderMermaid } = await import('../src/markdown/mermaid.ts') + expect(initialize).not.toHaveBeenCalled() + const stages: HTMLElement[] = [] + const ids: string[] = [] + renderDiagram.mockImplementation(async (id: string, code: string, stage: HTMLElement) => { + expect(stage.isConnected).toBe(true) + expect(stage.getAttribute('aria-hidden')).toBe('true') + stages.push(stage) + ids.push(id) + return { svg: `${code}` } + }) + const results = await Promise.all([ + renderMermaid('中文', new AbortController().signal), + renderMermaid('second', new AbortController().signal), + ]) + expect(initialize).toHaveBeenCalledOnce() + expect(initialize).toHaveBeenCalledWith(expect.objectContaining({ + startOnLoad: false, securityLevel: 'strict', suppressErrorRendering: true, htmlLabels: false, + secure: [ + 'secure', 'securityLevel', 'startOnLoad', 'maxTextSize', 'maxEdges', + 'suppressErrorRendering', 'theme', 'themeVariables', 'themeCSS', 'htmlLabels', 'flowchart', + ], + })) + expect(results.map(url => decodeURIComponent(url.split(',')[1]!))).toEqual(['中文', 'second']) + expect(new Set(ids).size).toBe(2) + expect(stages.every(stage => !stage.isConnected)).toBe(true) + }) + + it('removes measurement DOM even when Mermaid rejects', async () => { + const { renderMermaid } = await import('../src/markdown/mermaid.ts') + let stage: HTMLElement | undefined + const error = new Error('bad diagram') + renderDiagram.mockImplementation(async (_id: string, _code: string, target: HTMLElement) => { + stage = target + target.innerHTML = 'partial render' + throw error + }) + await expect(renderMermaid('bad', new AbortController().signal)).rejects.toBe(error) + expect(stage?.isConnected).toBe(false) + }) + + it('preserves intrinsic diagram size instead of stretching percentage-width SVG images', async () => { + const { renderMermaid } = await import('../src/markdown/mermaid.ts') + renderDiagram.mockResolvedValue({ svg: '' }) + const url = await renderMermaid('flowchart LR', new AbortController().signal) + const svg = new DOMParser().parseFromString(decodeURIComponent(url.split(',')[1]!), 'image/svg+xml').documentElement + expect(svg.getAttribute('width')).toBe('420') + expect(svg.getAttribute('height')).toBe('180') + }) + + it('does not start cancelled work after loading the runtime', async () => { + const { renderMermaid } = await import('../src/markdown/mermaid.ts') + const controller = new AbortController() + controller.abort() + await expect(renderMermaid('unused', controller.signal)).rejects.toMatchObject({ name: 'AbortError' }) + expect(renderDiagram).not.toHaveBeenCalled() + }) + + it('allows another attempt after runtime initialization fails', async () => { + const { renderMermaid } = await import('../src/markdown/mermaid.ts') + initialize.mockImplementationOnce(() => { throw new Error('initialization failed') }) + await expect(renderMermaid('first', new AbortController().signal)).rejects.toThrow('initialization failed') + renderDiagram.mockResolvedValue({ svg: '' }) + await expect(renderMermaid('retry', new AbortController().signal)).resolves.toContain('data:image/svg+xml') + expect(initialize).toHaveBeenCalledTimes(2) + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bcea481379..d1c1098a48 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3323,6 +3323,9 @@ importers: mdast-util-math: specifier: ^3.0.0 version: 3.0.0 + mermaid: + specifier: 11.16.0 + version: 11.16.0 micromark-core-commonmark: specifier: ^2.0.3 version: 2.0.3 diff --git a/tsconfig.host.json b/tsconfig.host.json index c5a3f118b3..e7abea1600 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -67,6 +67,7 @@ "apps/web/tests/math-rendering.e2e.ts", "apps/web/tests/markdown-cjk-strong.e2e.ts", "apps/web/tests/markdown-inline-code-links.e2e.ts", + "apps/web/tests/markdown-mermaid.e2e.ts", "apps/web/tests/clickable-links-gallery.e2e.ts", "apps/web/tests/queue-actions.e2e.ts", "apps/web/tests/queue-image.e2e.ts", From fc0c885287f327d67ea2b6df49960d1287b054ed Mon Sep 17 00:00:00 2001 From: fz Date: Mon, 7 Sep 2026 23:37:48 +0800 Subject: [PATCH 036/111] style(ui): simplify Mermaid preview controls --- .../2026-09-07-web-mermaid-preview.i18n.yaml | 4 +- .../feature/2026-09-07-web-mermaid-preview.md | 4 +- .../2026-09-07-web-mermaid-preview.zh.md | 4 +- .../expected/markdown-mermaid/ui.expected.md | 13 ++- .../expected/markdown-mermaid/zh.expected.md | 6 +- apps/web/tests/markdown-mermaid.e2e.ts | 25 +++++- packages/client/ui-chat/README.i18n.yaml | 4 +- packages/client/ui-chat/README.md | 2 +- packages/client/ui-chat/README.zh.md | 2 +- .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 2 +- .../src/markdown/CodeBlock.module.css | 81 +++++++++++++++++++ .../ui-primitives/src/markdown/CodeBlock.tsx | 41 +++++++--- .../src/markdown/MermaidPreview.module.css | 2 +- .../tests/mermaid-preview.client.spec.tsx | 4 + 16 files changed, 165 insertions(+), 35 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-09-07-web-mermaid-preview.i18n.yaml b/.agents/notes/implemented/feature/2026-09-07-web-mermaid-preview.i18n.yaml index f40e8c5054..5bc79823f6 100644 --- a/.agents/notes/implemented/feature/2026-09-07-web-mermaid-preview.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-07-web-mermaid-preview.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-07-web-mermaid-preview.md -2026-09-07-web-mermaid-preview.md: c891b4133e48a555de945c250679555f91f44c2f -2026-09-07-web-mermaid-preview.zh.md: a4c79c430a07b2655c85354a4406da3914bbd4c4 +2026-09-07-web-mermaid-preview.md: df50e7c7f8b78a869d7d5b761864c06648f8d064 +2026-09-07-web-mermaid-preview.zh.md: 282e6fb4365a56425f2b341e2367e4a2cdae5c1c diff --git a/.agents/notes/implemented/feature/2026-09-07-web-mermaid-preview.md b/.agents/notes/implemented/feature/2026-09-07-web-mermaid-preview.md index c891b4133e..df50e7c7f8 100644 --- a/.agents/notes/implemented/feature/2026-09-07-web-mermaid-preview.md +++ b/.agents/notes/implemented/feature/2026-09-07-web-mermaid-preview.md @@ -10,7 +10,7 @@ Assistant replies can describe diagrams in Mermaid code fences, but readers must ## Decision -Chat enables settled fence previews through `MarkdownLabels.mermaid`. The shared Markdown renderer uses the parsed fence language; streaming fences and consumers without those labels retain code display. The static [UI primitives package](../../../../packages/client/ui-primitives/README.md) owns `MermaidPreview`, which accepts source and localized labels without Session, file, or Cordis dependencies. `CodeBlock.preview` reuses the banner and copy action; copying always reads the source prop. +Chat enables settled fence previews through `MarkdownLabels.mermaid`. The shared Markdown renderer uses the parsed fence language; streaming fences and consumers without those labels retain code display. The static [UI primitives package](../../../../packages/client/ui-primitives/README.md) owns `MermaidPreview`, which accepts source and localized labels without Session, file, or Cordis dependencies. `CodeBlock.preview` owns view switching and source copying. Previews omit the language banner and expose compact icon actions on hover or keyboard focus; touch devices keep those actions visible. Source and preview share the same focused toggle button, and copying always reads the source prop. Mermaid loads on demand. Its public render API serializes diagram work, and each call removes its temporary measurement DOM in `finally`. Strict security, disabled HTML labels, the neutral theme, and error-rendering policy cannot be overridden by diagram configuration. Generated SVG is displayed as an image without installing diagram links or scripts. Intrinsic dimensions come from the SVG viewBox; large diagrams shrink to fit, and the canvas stays light in both application themes. @@ -18,6 +18,8 @@ Source replacement and unmounting cancel result publication. Cancellation before ## Alternatives considered +**Keep the code banner above diagrams.** A language label and permanent text controls distract from the diagram. Overlay actions retain source access without reserving a title row. + **Render each streamed chunk.** Incomplete diagrams are frequently invalid, and repeated layout work competes with text streaming. The existing message-settlement boundary provides a complete source value. **Put rendering inside Chat or add a general preview registry.** A shared primitive with plain props satisfies reuse without another registry or feature-plugin dependency. This follows the [shared-control rule](../architecture/2026-09-05-shared-client-control-primitives.md). diff --git a/.agents/notes/implemented/feature/2026-09-07-web-mermaid-preview.zh.md b/.agents/notes/implemented/feature/2026-09-07-web-mermaid-preview.zh.md index a4c79c430a..282e6fb436 100644 --- a/.agents/notes/implemented/feature/2026-09-07-web-mermaid-preview.zh.md +++ b/.agents/notes/implemented/feature/2026-09-07-web-mermaid-preview.zh.md @@ -10,7 +10,7 @@ Assistant 回复可以在 Mermaid 代码 fence 中描述图表,但读者必须 ## 决策 -Chat 通过 `MarkdownLabels.mermaid` 启用已定稿 fence 预览。共享 Markdown 渲染器使用解析后的 fence 语言;流式 fence 和未传入这些 label 的调用方保留代码显示。静态 [UI primitives 包](../../../../packages/client/ui-primitives/README.zh.md)拥有 `MermaidPreview`,它接收源码与本地化 label,不依赖 Session、文件或 Cordis。`CodeBlock.preview` 复用标题栏和复制操作;复制始终读取源码 prop。 +Chat 通过 `MarkdownLabels.mermaid` 启用已定稿 fence 预览。共享 Markdown 渲染器使用解析后的 fence 语言;流式 fence 和未传入这些 label 的调用方保留代码显示。静态 [UI primitives 包](../../../../packages/client/ui-primitives/README.zh.md)拥有 `MermaidPreview`,它接收源码与本地化 label,不依赖 Session、文件或 Cordis。`CodeBlock.preview` 拥有视图切换与源码复制。预览省略语言标题栏,悬停或键盘聚焦时显示紧凑的图标操作;触屏上保持操作可见。源码与预览使用同一个已聚焦的切换按钮,复制始终读取源码 prop。 Mermaid 按需加载。它的公开 render API 串行执行图表工作,每次调用在 `finally` 中移除临时测量 DOM。图表配置无法覆盖严格安全模式、禁用 HTML label、中性主题和错误渲染策略。生成的 SVG 以图片显示,不安装图内链接或脚本。固有尺寸取自 SVG viewBox;大图缩小以适应宽度,画布在两种应用主题下均保持浅色。 @@ -18,6 +18,8 @@ Mermaid 按需加载。它的公开 render API 串行执行图表工作,每次 ## 考虑过的替代方案 +**在图表上方保留代码标题栏。** 语言标记和常驻的文字操作分散了对图表的注意力。浮层操作无需占用标题行即可保留源码入口。 + **渲染每个流式分片。** 不完整的图表经常无效,反复布局也会与文本流式输出竞争。已有的消息定稿边界提供完整源码值。 **把渲染放在 Chat 内或增加通用预览注册表。** 接收普通 prop 的共享原语无需另一套注册表或功能插件依赖即可满足复用。这遵循[共享控件规则](../architecture/2026-09-05-shared-client-control-primitives.zh.md)。 diff --git a/apps/web/tests/expected/markdown-mermaid/ui.expected.md b/apps/web/tests/expected/markdown-mermaid/ui.expected.md index 2b9d095829..58657a3bf6 100644 --- a/apps/web/tests/expected/markdown-mermaid/ui.expected.md +++ b/apps/web/tests/expected/markdown-mermaid/ui.expected.md @@ -4,6 +4,8 @@ - button "Session log": - text: Session log - img + - button "Open the sidebar": + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" @@ -11,20 +13,17 @@ - button "Copy": - img - heading "Mermaid previews" [level=1] -- text: mermaid +- button "Source" +- button "Copy" +- tooltip "Copy" +- img "Mermaid diagram" - button "Source" - button "Copy" - img "Mermaid diagram" -- text: mermaid -- button "Source" -- button "Copy" -- img "Mermaid diagram" -- text: mermaid - button "Source" - button "Copy" - status: Unable to render this diagram. The source is shown below. - code: flowchart LR A[unfinished -- text: mermaid - button "Source" - button "Copy" - img "Mermaid diagram" diff --git a/apps/web/tests/expected/markdown-mermaid/zh.expected.md b/apps/web/tests/expected/markdown-mermaid/zh.expected.md index 07cb8ac9d6..e3a3b84157 100644 --- a/apps/web/tests/expected/markdown-mermaid/zh.expected.md +++ b/apps/web/tests/expected/markdown-mermaid/zh.expected.md @@ -4,6 +4,8 @@ - button "Session 日志": - text: Session 日志 - img + - button "展开侧栏": + - img - tablist: - tab "对话" [selected] - tab "轨迹" @@ -11,20 +13,16 @@ - button "复制": - img - heading "Mermaid previews" [level=1] -- text: mermaid - button "源码" - button "复制" - img "Mermaid 图表" -- text: mermaid - button "源码" - button "复制" - img "Mermaid 图表" -- text: mermaid - button "源码" - button "复制" - status: 无法渲染此图表,源码如下。 - code: flowchart LR A[unfinished -- text: mermaid - button "源码" - button "复制" - img "Mermaid 图表" diff --git a/apps/web/tests/markdown-mermaid.e2e.ts b/apps/web/tests/markdown-mermaid.e2e.ts index d23ca2f8ad..3c6986801f 100644 --- a/apps/web/tests/markdown-mermaid.e2e.ts +++ b/apps/web/tests/markdown-mermaid.e2e.ts @@ -94,9 +94,20 @@ describe('web e2e: Mermaid chat previews', () => { expect(await page.locator('pre code').allTextContents()).toContain(INVALID) expect(await images.first().evaluate(node => decodeURIComponent((node as HTMLImageElement).src))).toContain('共享渲染器') const first = page.locator('.md-code-block').first() + const controls = first.locator('[class*="bannerWrap"]') + expect(await first.getByText('mermaid', { exact: true }).count()).toBe(0) + await page.mouse.move(0, 0) + expect(await controls.evaluate(node => getComputedStyle(node).opacity)).toBe('0') + await first.hover() + expect(await controls.evaluate(node => getComputedStyle(node).opacity)).toBe('1') await first.getByRole('button', { name: 'Copy', exact: true }).click() expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(FLOW) - await first.getByRole('button', { name: 'Source', exact: true }).click() + await page.mouse.move(0, 0) + await first.getByRole('button', { name: 'Source', exact: true }).focus() + expect(await controls.evaluate(node => getComputedStyle(node).opacity)).toBe('1') + await page.keyboard.press('Enter') + expect(await first.getByRole('button', { name: 'Preview', exact: true }) + .evaluate(node => node === document.activeElement)).toBe(true) expect(await first.locator('pre code').textContent()).toBe(FLOW) await first.getByRole('button', { name: 'Preview', exact: true }).click() await first.getByRole('img').waitFor() @@ -106,6 +117,8 @@ describe('web e2e: Mermaid chat previews', () => { expect(dialogs).toEqual([]) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) + await page.getByRole('heading', { name: 'Mermaid previews' }).click() + await page.mouse.move(0, 0) const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) .split(SEED_ID).join('{{seededId}}') await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'ui.expected.md'), snapshot, MODE) @@ -113,11 +126,19 @@ describe('web e2e: Mermaid chat previews', () => { }, 60_000) it.skipIf(MODE === 'record')('localizes the preview and failure states in Chinese', async () => { - const page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: 'zh-CN', timezoneId: 'Asia/Shanghai' }) + const page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: 'zh-CN', timezoneId: 'Asia/Shanghai', hasTouch: true }) await openConversation(page, scaffold) await expect.poll(() => page.getByRole('img', { name: 'Mermaid 图表' }).count(), { timeout: 20_000 }).toBe(3) expect(await page.getByRole('button', { name: '源码', exact: true }).count()).toBe(4) + const controls = page.locator('.md-code-block').first().locator('[class*="bannerWrap"]') + expect(await controls.evaluate(node => getComputedStyle(node).opacity)).toBe('1') + expect(await page.getByRole('button', { name: '源码', exact: true }).first() + .evaluate(node => node.getBoundingClientRect().width)).toBe(44) expect(await page.getByText('无法渲染此图表,源码如下。', { exact: true }).count()).toBe(1) + const firstImage = page.getByRole('img', { name: 'Mermaid 图表' }).first() + await expect.poll(() => firstImage.evaluate(node => (node as HTMLImageElement).naturalWidth)).toBeGreaterThan(0) + expect(await controls.evaluate(node => node.getBoundingClientRect().top)) + .toBeGreaterThanOrEqual(await firstImage.evaluate(node => node.getBoundingClientRect().bottom)) const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) .split(SEED_ID).join('{{seededId}}') await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'zh.expected.md'), snapshot, MODE) diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index 39b3cefe79..386c06ea91 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-chat/README.md -README.md: 6115e18f303cfa6ecc63379eabf8a7ad6f56b121 -README.zh.md: 3cb069cdf6cedb6d0c5c045f95e106c2c26959b6 +README.md: 951f88e5fa61a27802fb7df11a3347ba9676a66c +README.zh.md: 3a180afb43c4262d6054e1f19deefa490b9b36de diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index 6115e18f30..951f88e5fa 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -33,7 +33,7 @@ Each nonempty appended `system/message` owns a collapsed prompt row, including a ## Mermaid previews -Settled Assistant Markdown shows `mermaid` code fences as diagrams. Each fence offers Source/Preview switching and copies the original Mermaid source in either view. Streaming fences remain code until the message settles; invalid diagrams show an error and their source without disrupting other blocks. Chat enables the shared [Mermaid preview primitive](../ui-primitives/README.md) through localized labels. This is a presentation change and adds no model prompt, tool, or Session event. +Settled Assistant Markdown shows `mermaid` code fences as diagrams. Diagrams have no language banner. Source/Preview and Copy icon actions appear on hover or keyboard focus and remain visible on touch devices; both views copy the original Mermaid source. Streaming fences remain code until the message settles; invalid diagrams show an error and their source without disrupting other blocks. Chat enables the shared [Mermaid preview primitive](../ui-primitives/README.md) through localized labels. This is a presentation change and adds no model prompt, tool, or Session event. ----- diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 3cb069cdf6..3a180afb43 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -35,7 +35,7 @@ kind: "package-reference" ## Mermaid 预览 -已定稿的 Assistant Markdown 把 `mermaid` 代码 fence 显示为图表。每个 fence 支持源码/预览切换,在任一视图中复制的都是原始 Mermaid 源码。流式 fence 在消息定稿前保持代码显示;非法图表显示错误与源码,不影响其他块。Chat 通过本地化 label 启用共享的 [Mermaid 预览原语](../ui-primitives/README.zh.md)。这项展示变更不增加模型提示词、工具或 Session 事件。 +已定稿的 Assistant Markdown 把 `mermaid` 代码 fence 显示为图表。图表不显示语言标题栏。源码/预览切换与复制图标在悬停或键盘聚焦时出现,触屏上保持可见;两种视图复制的都是原始 Mermaid 源码。流式 fence 在消息定稿前保持代码显示;非法图表显示错误与源码,不影响其他块。Chat 通过本地化 label 启用共享的 [Mermaid 预览原语](../ui-primitives/README.zh.md)。这项展示变更不增加模型提示词、工具或 Session 事件。 ----- diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 7b19fb03f8..a8e5104e37 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md -README.md: 1e9ee01f6c2243c8b980e45cf512dda1af330af0 -README.zh.md: 02c0417c359070c2f4a4ca11a22709c401847cec +README.md: f2694588e2ce96f10055802adaf5ff23b9cae9f1 +README.zh.md: cbd7fec0c1708b9d902dff2302c9472ed2bbb56b diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 1e9ee01f6c..f2694588e2 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -76,7 +76,7 @@ The catalog above lists what each export is for; this section covers the behavio `MermaidPreview` renders complete Mermaid source on a light canvas. Rendering loads Mermaid on demand, uses strict security, and exposes the generated SVG as an image with no diagram link handlers. A render failure shows the original source with the supplied error label; replacing the source discards late results from the previous render. The diagram keeps its intrinsic size and shrinks to fit the available width. -Supply `MarkdownLabels.mermaid` to enable `mermaid` fence previews in a Markdown consumer; without it, fences remain code. Previews start after the message settles. `CodeBlock.preview` supplies an optional alternate body and localized Source/Preview switch labels while the existing Copy button always copies the source. [The Mermaid decision](../../../.agents/notes/implemented/feature/2026-09-07-web-mermaid-preview.md) records the rendering and reuse choices. +Supply `MarkdownLabels.mermaid` to enable `mermaid` fence previews in a Markdown consumer; without it, fences remain code. Previews start after the message settles. `CodeBlock.preview` supplies an optional headerless body with icon actions for source switching and copying. Actions appear on hover or keyboard focus, and stay visible on touch devices; copying always retains the source. [The Mermaid decision](../../../.agents/notes/implemented/feature/2026-09-07-web-mermaid-preview.md) records the rendering and reuse choices. ### Localizing copy diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 02c0417c35..cbd7fec0c1 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -76,7 +76,7 @@ kind: "package-library" `MermaidPreview` 在浅色画布上渲染完整的 Mermaid 源码。渲染按需加载 Mermaid、使用严格安全模式,并把生成的 SVG 显示为图片,不绑定图内链接处理器。渲染失败时显示原始源码与传入的错误文案;替换源码后会丢弃前一次渲染的延迟结果。图表保留固有尺寸,并在可用宽度不足时缩小。 -为 Markdown 调用方传入 `MarkdownLabels.mermaid` 即可启用 `mermaid` fence 预览;未传入时,fence 保持代码显示。预览在消息定稿后开始。`CodeBlock.preview` 提供可选的替代正文和本地化的「源码/预览」切换文案,现有「复制」按钮始终复制源码。[Mermaid 决策](../../../.agents/notes/implemented/feature/2026-09-07-web-mermaid-preview.zh.md)记录了渲染与复用选择。 +为 Markdown 调用方传入 `MarkdownLabels.mermaid` 即可启用 `mermaid` fence 预览;未传入时,fence 保持代码显示。预览在消息定稿后开始。`CodeBlock.preview` 提供无标题栏的替代正文,以及切换源码和复制的图标操作。操作在悬停或键盘聚焦时出现,触屏上保持可见;复制始终保留源码。[Mermaid 决策](../../../.agents/notes/implemented/feature/2026-09-07-web-mermaid-preview.zh.md)记录了渲染与复用选择。 ### 本地化文案 diff --git a/packages/client/ui-primitives/src/markdown/CodeBlock.module.css b/packages/client/ui-primitives/src/markdown/CodeBlock.module.css index 28271cbbdf..c25252aaf9 100644 --- a/packages/client/ui-primitives/src/markdown/CodeBlock.module.css +++ b/packages/client/ui-primitives/src/markdown/CodeBlock.module.css @@ -122,3 +122,84 @@ content: counter(source-line); user-select: none; } + +/* Alternate content is a figure; its actions overlay instead of taking a title row. */ +.preview.showingPreview { + background: transparent; +} + +.preview .bannerWrap { + position: absolute; + top: 8px; + right: 8px; + z-index: 1; + background: transparent; +} + +.preview .banner { + padding: 2px; + background: var(--dsw-alias-bg-base); + border: 0; + border-radius: 8px; + box-shadow: var(--dsw-elevation-panel); +} + +.preview .action { + gap: 2px; +} + +.iconButton { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 6px; + color: var(--dsw-alias-label-secondary); +} + +.iconButton span { + display: flex; +} + +.iconButton:hover { + color: var(--dsw-alias-label-primary); + background: var(--dsw-alias-interactive-bg-hover); +} + +.preview:not(.showingPreview) :where(pre) { + padding-top: 48px; +} + +@media (hover: hover) and (pointer: fine) { + .preview .bannerWrap { + opacity: 0; + pointer-events: none; + } + + .preview:hover .bannerWrap, + .preview:focus-within .bannerWrap { + opacity: 1; + pointer-events: auto; + } +} + +@media (pointer: coarse) { + .preview { + padding-bottom: 56px; + } + + .preview .bannerWrap { + top: auto; + bottom: 8px; + } + + .preview .iconButton { + width: 44px; + height: 44px; + } + + .preview:not(.showingPreview) :where(pre) { + padding-top: 16px; + } +} diff --git a/packages/client/ui-primitives/src/markdown/CodeBlock.tsx b/packages/client/ui-primitives/src/markdown/CodeBlock.tsx index 7f55844a91..3613ed4f16 100644 --- a/packages/client/ui-primitives/src/markdown/CodeBlock.tsx +++ b/packages/client/ui-primitives/src/markdown/CodeBlock.tsx @@ -2,6 +2,8 @@ import { Fragment, useCallback, useMemo, useRef, useState, useSyncExternalStore import type { CSSProperties, ReactNode } from 'react' import clsx from 'clsx' import { writeClipboard } from '../clipboard.ts' +import { Tooltip } from '../Tooltip.tsx' +import { IconCheckOutline16, IconCodeOutline16, IconCopyOutline16 } from '../icons/index.tsx' import { StreamingHighlightSession, grammarLoadCount, highlightToHtml, subscribeGrammarLoaded, } from './highlight.ts' @@ -31,7 +33,7 @@ export interface CodeBlockProps { copyLabel: string /** Copy-button label during the post-copy confirmation window. */ copiedLabel: string - /** Optional settled preview with localized switch labels; copying always retains the source text. */ + /** Headerless settled preview with hover/focus actions; copying always retains the source text. */ preview?: { content: ReactNode; previewLabel: string; sourceLabel: string } | undefined } @@ -173,24 +175,45 @@ export function CodeBlock({ code, lang, streaming, className, lineNumbers = fals
) + const copyActionLabel = copied ? copiedLabel : copyLabel + const copyAction = ( + + ) + return ( -
-
{lang ?? ''}
+ {!previewAvailable &&
{lang ?? ''}
}
{previewAvailable && ( - + + + )} - + {previewAvailable + ? {copyAction} + : copyAction}
diff --git a/packages/client/ui-primitives/src/markdown/MermaidPreview.module.css b/packages/client/ui-primitives/src/markdown/MermaidPreview.module.css index bd24e36251..f96c14418b 100644 --- a/packages/client/ui-primitives/src/markdown/MermaidPreview.module.css +++ b/packages/client/ui-primitives/src/markdown/MermaidPreview.module.css @@ -2,7 +2,7 @@ overflow: auto; padding: 16px; background: var(--dsw-static-neutral-00); - border-radius: 0 0 12px 12px; + border-radius: 12px; } .diagram { diff --git a/packages/client/ui-primitives/tests/mermaid-preview.client.spec.tsx b/packages/client/ui-primitives/tests/mermaid-preview.client.spec.tsx index 4a716c4886..4f477902f7 100644 --- a/packages/client/ui-primitives/tests/mermaid-preview.client.spec.tsx +++ b/packages/client/ui-primitives/tests/mermaid-preview.client.spec.tsx @@ -87,12 +87,16 @@ describe('Markdown Mermaid fences', () => { expect(screen.queryByRole('button', { name: labels.source })).toBeNull() view.rerender() await screen.findByRole('img', { name: labels.diagram }) + expect(screen.queryByText('mermaid')).toBeNull() + expect(screen.getByRole('button', { name: labels.source }).textContent).toBe('') + expect(screen.getByRole('button', { name: markdownLabels.code.copyLabel }).textContent).toBe('') fireEvent.click(screen.getByRole('button', { name: markdownLabels.code.copyLabel })) await screen.findByRole('button', { name: markdownLabels.code.copiedLabel }) expect(writeText).toHaveBeenCalledWith(source) fireEvent.click(screen.getByRole('button', { name: labels.source })) expect(view.container.querySelector('pre code')?.textContent).toBe(source) expect(screen.queryByRole('img')).toBeNull() + expect(screen.queryByText('mermaid')).toBeNull() fireEvent.click(screen.getByRole('button', { name: labels.preview })) await screen.findByRole('img', { name: labels.diagram }) }) From 220103670aef3ae830cf91f47abfccd31c1bdca2 Mon Sep 17 00:00:00 2001 From: fz Date: Tue, 8 Sep 2026 00:24:41 +0800 Subject: [PATCH 037/111] fix(ui): retain Mermaid previews and support hybrid touch controls --- .../2026-09-07-web-mermaid-preview.i18n.yaml | 4 +-- .../feature/2026-09-07-web-mermaid-preview.md | 2 +- .../2026-09-07-web-mermaid-preview.zh.md | 2 +- apps/web/tests/markdown-mermaid.e2e.ts | 32 ++++++++++++++++++- .../client/ui-primitives/README.i18n.yaml | 4 +-- packages/client/ui-primitives/README.md | 3 +- packages/client/ui-primitives/README.zh.md | 3 +- .../src/markdown/CodeBlock.module.css | 4 ++- .../ui-primitives/src/markdown/CodeBlock.tsx | 3 +- .../ui-primitives/src/markdown/render.tsx | 2 +- .../tests/mermaid-preview.client.spec.tsx | 7 ++-- 11 files changed, 52 insertions(+), 14 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-09-07-web-mermaid-preview.i18n.yaml b/.agents/notes/implemented/feature/2026-09-07-web-mermaid-preview.i18n.yaml index 5bc79823f6..f86ae95548 100644 --- a/.agents/notes/implemented/feature/2026-09-07-web-mermaid-preview.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-07-web-mermaid-preview.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-07-web-mermaid-preview.md -2026-09-07-web-mermaid-preview.md: df50e7c7f8b78a869d7d5b761864c06648f8d064 -2026-09-07-web-mermaid-preview.zh.md: 282e6fb4365a56425f2b341e2367e4a2cdae5c1c +2026-09-07-web-mermaid-preview.md: 811f688713491333587404d32946317d81ff2795 +2026-09-07-web-mermaid-preview.zh.md: 0fdb9cf318561f20fdb7042121bc3517c4d6c9a0 diff --git a/.agents/notes/implemented/feature/2026-09-07-web-mermaid-preview.md b/.agents/notes/implemented/feature/2026-09-07-web-mermaid-preview.md index df50e7c7f8..811f688713 100644 --- a/.agents/notes/implemented/feature/2026-09-07-web-mermaid-preview.md +++ b/.agents/notes/implemented/feature/2026-09-07-web-mermaid-preview.md @@ -10,7 +10,7 @@ Assistant replies can describe diagrams in Mermaid code fences, but readers must ## Decision -Chat enables settled fence previews through `MarkdownLabels.mermaid`. The shared Markdown renderer uses the parsed fence language; streaming fences and consumers without those labels retain code display. The static [UI primitives package](../../../../packages/client/ui-primitives/README.md) owns `MermaidPreview`, which accepts source and localized labels without Session, file, or Cordis dependencies. `CodeBlock.preview` owns view switching and source copying. Previews omit the language banner and expose compact icon actions on hover or keyboard focus; touch devices keep those actions visible. Source and preview share the same focused toggle button, and copying always reads the source prop. +Chat enables settled fence previews through `MarkdownLabels.mermaid`. The shared Markdown renderer uses the parsed fence language; streaming fences and consumers without those labels retain code display. The static [UI primitives package](../../../../packages/client/ui-primitives/README.md) owns `MermaidPreview`, which accepts source and localized labels without Session, file, or Cordis dependencies. `CodeBlock.preview` owns view switching and source copying. Previews omit the language banner and expose compact icon actions on hover or keyboard focus; devices with any touch input keep those actions visible below the diagram. Switching to source hides the mounted preview, so returning preserves completed rendering and pending work. Source and preview share the same focused toggle button, and copying always reads the source prop. Mermaid loads on demand. Its public render API serializes diagram work, and each call removes its temporary measurement DOM in `finally`. Strict security, disabled HTML labels, the neutral theme, and error-rendering policy cannot be overridden by diagram configuration. Generated SVG is displayed as an image without installing diagram links or scripts. Intrinsic dimensions come from the SVG viewBox; large diagrams shrink to fit, and the canvas stays light in both application themes. diff --git a/.agents/notes/implemented/feature/2026-09-07-web-mermaid-preview.zh.md b/.agents/notes/implemented/feature/2026-09-07-web-mermaid-preview.zh.md index 282e6fb436..0fdb9cf318 100644 --- a/.agents/notes/implemented/feature/2026-09-07-web-mermaid-preview.zh.md +++ b/.agents/notes/implemented/feature/2026-09-07-web-mermaid-preview.zh.md @@ -10,7 +10,7 @@ Assistant 回复可以在 Mermaid 代码 fence 中描述图表,但读者必须 ## 决策 -Chat 通过 `MarkdownLabels.mermaid` 启用已定稿 fence 预览。共享 Markdown 渲染器使用解析后的 fence 语言;流式 fence 和未传入这些 label 的调用方保留代码显示。静态 [UI primitives 包](../../../../packages/client/ui-primitives/README.zh.md)拥有 `MermaidPreview`,它接收源码与本地化 label,不依赖 Session、文件或 Cordis。`CodeBlock.preview` 拥有视图切换与源码复制。预览省略语言标题栏,悬停或键盘聚焦时显示紧凑的图标操作;触屏上保持操作可见。源码与预览使用同一个已聚焦的切换按钮,复制始终读取源码 prop。 +Chat 通过 `MarkdownLabels.mermaid` 启用已定稿 fence 预览。共享 Markdown 渲染器使用解析后的 fence 语言;流式 fence 和未传入这些 label 的调用方保留代码显示。静态 [UI primitives 包](../../../../packages/client/ui-primitives/README.zh.md)拥有 `MermaidPreview`,它接收源码与本地化 label,不依赖 Session、文件或 Cordis。`CodeBlock.preview` 拥有视图切换与源码复制。预览省略语言标题栏,悬停或键盘聚焦时显示紧凑的图标操作;具备任何触控输入的设备均在图表下方保持操作可见。切换到源码时隐藏已挂载的预览,因此返回时保留已完成的渲染与进行中的工作。源码与预览使用同一个已聚焦的切换按钮,复制始终读取源码 prop。 Mermaid 按需加载。它的公开 render API 串行执行图表工作,每次调用在 `finally` 中移除临时测量 DOM。图表配置无法覆盖严格安全模式、禁用 HTML label、中性主题和错误渲染策略。生成的 SVG 以图片显示,不安装图内链接或脚本。固有尺寸取自 SVG viewBox;大图缩小以适应宽度,画布在两种应用主题下均保持浅色。 diff --git a/apps/web/tests/markdown-mermaid.e2e.ts b/apps/web/tests/markdown-mermaid.e2e.ts index 3c6986801f..928e6cbf40 100644 --- a/apps/web/tests/markdown-mermaid.e2e.ts +++ b/apps/web/tests/markdown-mermaid.e2e.ts @@ -94,6 +94,7 @@ describe('web e2e: Mermaid chat previews', () => { expect(await page.locator('pre code').allTextContents()).toContain(INVALID) expect(await images.first().evaluate(node => decodeURIComponent((node as HTMLImageElement).src))).toContain('共享渲染器') const first = page.locator('.md-code-block').first() + const diagram = await first.getByRole('img').elementHandle() const controls = first.locator('[class*="bannerWrap"]') expect(await first.getByText('mermaid', { exact: true }).count()).toBe(0) await page.mouse.move(0, 0) @@ -109,8 +110,10 @@ describe('web e2e: Mermaid chat previews', () => { expect(await first.getByRole('button', { name: 'Preview', exact: true }) .evaluate(node => node === document.activeElement)).toBe(true) expect(await first.locator('pre code').textContent()).toBe(FLOW) + expect(await diagram!.evaluate(node => node.isConnected)).toBe(true) + expect(await diagram!.isVisible()).toBe(false) await first.getByRole('button', { name: 'Preview', exact: true }).click() - await first.getByRole('img').waitFor() + expect(await diagram!.isVisible()).toBe(true) await first.getByRole('button', { name: 'Copy', exact: true }).waitFor() expect(await page.locator('body').evaluate(node => getComputedStyle(node).display)).not.toBe('none') expect(await page.locator('[id^="dsh-mermaid-"]').count()).toBe(0) @@ -145,4 +148,31 @@ describe('web e2e: Mermaid chat previews', () => { await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md', 'zh.expected.md']) await page.close() }, 60_000) + + it.skipIf(MODE === 'record')('keeps actions visible when a mouse and touchscreen are both available', async () => { + const hybridBrowser = await chromium.launch({ + args: ['--blink-settings=availablePointerTypes=6,primaryPointerType=4,availableHoverTypes=2,primaryHoverType=2'], + }) + try { + const page = await newEnglishPage(hybridBrowser) + expect(await page.evaluate(() => + matchMedia('(hover: hover) and (pointer: fine) and (any-pointer: coarse)').matches)).toBe(true) + await openConversation(page, scaffold) + const first = page.locator('.md-code-block').first() + const diagram = first.getByRole('img', { name: 'Mermaid diagram' }) + await expect.poll(() => diagram.evaluate(node => (node as HTMLImageElement).naturalWidth)).toBeGreaterThan(0) + await page.mouse.move(0, 0) + const controls = first.locator('[class*="bannerWrap"]') + expect(await controls.evaluate(node => getComputedStyle(node).opacity)).toBe('1') + expect(await controls.evaluate(node => getComputedStyle(node).pointerEvents)).toBe('auto') + expect(await first.getByRole('button', { name: 'Source', exact: true }) + .evaluate(node => node.getBoundingClientRect().width)).toBe(44) + expect(await controls.evaluate(node => node.getBoundingClientRect().top)) + .toBeGreaterThanOrEqual(await diagram.evaluate(node => node.getBoundingClientRect().bottom)) + await first.getByRole('button', { name: 'Source', exact: true }).click() + expect(await first.locator('pre code').textContent()).toBe(FLOW) + } finally { + await hybridBrowser.close() + } + }, 60_000) }) diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index a8e5104e37..a0fd81c2bc 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md -README.md: f2694588e2ce96f10055802adaf5ff23b9cae9f1 -README.zh.md: cbd7fec0c1708b9d902dff2302c9472ed2bbb56b +README.md: 0ba4d6baaa54bbacc82202c490423b37613874df +README.zh.md: 06a266b61a79d4ff5e38ca0de9877a6d8ec66af0 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index f2694588e2..0ba4d6baaa 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -76,7 +76,7 @@ The catalog above lists what each export is for; this section covers the behavio `MermaidPreview` renders complete Mermaid source on a light canvas. Rendering loads Mermaid on demand, uses strict security, and exposes the generated SVG as an image with no diagram link handlers. A render failure shows the original source with the supplied error label; replacing the source discards late results from the previous render. The diagram keeps its intrinsic size and shrinks to fit the available width. -Supply `MarkdownLabels.mermaid` to enable `mermaid` fence previews in a Markdown consumer; without it, fences remain code. Previews start after the message settles. `CodeBlock.preview` supplies an optional headerless body with icon actions for source switching and copying. Actions appear on hover or keyboard focus, and stay visible on touch devices; copying always retains the source. [The Mermaid decision](../../../.agents/notes/implemented/feature/2026-09-07-web-mermaid-preview.md) records the rendering and reuse choices. +Supply `MarkdownLabels.mermaid` to enable `mermaid` fence previews in a Markdown consumer; without it, fences remain code. Previews start after the message settles. `CodeBlock.preview` supplies an optional headerless body with icon actions for source switching and copying. Actions appear on hover or keyboard focus, and stay visible below the diagram whenever a touchscreen is available, including with a mouse attached. Switching to source keeps the preview mounted, so returning reuses its result; copying always retains the source. [The Mermaid decision](../../../.agents/notes/implemented/feature/2026-09-07-web-mermaid-preview.md) records the rendering and reuse choices. ### Localizing copy @@ -145,6 +145,7 @@ None; this package neither assembles nor sends a provider request. These limits define how the atoms behave at the edges; they are current package constraints, not a component roadmap. +- **Mermaid rendering runs on the browser thread** — every mounted settled preview starts rendering, including off-screen diagrams. Mermaid serializes layouts; work already submitted to it cannot be interrupted. Preview virtualization and worker rendering are not provided. - **Streaming defers cross-boundary reference resolution** — a reference-style link or footnote whose definition sits on the other side of the incremental freeze boundary renders as literal text while the reply streams; the settled full parse at finalize resolves it. - **A long highlighted fence retains its complete token DOM** — streaming avoids re-parsing, re-tokenizing, and reconciling the completed prefix, but it does not discard old colors or virtualize token spans. Final DOM cardinality therefore still follows the fence's token count; nested/container fences and a pathological single long line remain on the general tail path. - **Glyph-level icons are redrawn approximations** — the fish logo and the sparkle mark come from font glyphs whose vector geometry is not exportable from the local design data; hand-authored recreations stand in until an exact export path exists. diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index cbd7fec0c1..06a266b61a 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -76,7 +76,7 @@ kind: "package-library" `MermaidPreview` 在浅色画布上渲染完整的 Mermaid 源码。渲染按需加载 Mermaid、使用严格安全模式,并把生成的 SVG 显示为图片,不绑定图内链接处理器。渲染失败时显示原始源码与传入的错误文案;替换源码后会丢弃前一次渲染的延迟结果。图表保留固有尺寸,并在可用宽度不足时缩小。 -为 Markdown 调用方传入 `MarkdownLabels.mermaid` 即可启用 `mermaid` fence 预览;未传入时,fence 保持代码显示。预览在消息定稿后开始。`CodeBlock.preview` 提供无标题栏的替代正文,以及切换源码和复制的图标操作。操作在悬停或键盘聚焦时出现,触屏上保持可见;复制始终保留源码。[Mermaid 决策](../../../.agents/notes/implemented/feature/2026-09-07-web-mermaid-preview.zh.md)记录了渲染与复用选择。 +为 Markdown 调用方传入 `MarkdownLabels.mermaid` 即可启用 `mermaid` fence 预览;未传入时,fence 保持代码显示。预览在消息定稿后开始。`CodeBlock.preview` 提供无标题栏的替代正文,以及切换源码和复制的图标操作。操作在悬停或键盘聚焦时出现;只要设备具备触屏,就在图表下方保持可见,包括同时连接鼠标的情况。切换到源码时保留已挂载的预览,返回时复用其结果;复制始终保留源码。[Mermaid 决策](../../../.agents/notes/implemented/feature/2026-09-07-web-mermaid-preview.zh.md)记录了渲染与复用选择。 ### 本地化文案 @@ -145,6 +145,7 @@ kind: "package-library" 这些限制说明原子组件在边缘情况下的行为;它们是当前包约束,不是组件路线图。 +- **Mermaid 渲染在浏览器线程上执行**:每个已挂载的定稿预览都会开始渲染,包括视口外的图表。Mermaid 串行执行布局,已提交给它的工作无法中断。当前不提供预览虚拟化或 worker 渲染。 - **流式期间跨边界引用解析被推迟**:定义落在增量冻结边界另一侧的引用式链接或脚注,在回复流式输出期间渲染为字面文本;定稿时的全量解析会将其解析。 - **长高亮 fence 会保留完整 token DOM**:流式路径避免重新解析、重新 tokenize 和 reconcile 已完成前缀,但不会丢弃旧颜色或虚拟化 token span。因此最终 DOM 数量仍随 fence 的 token 数增长;嵌套/容器内 fence 与病态的单个超长行仍走通用尾部路径。 - **字形级图标是重新绘制的近似版本**:鱼形标志与闪光标记来自字体字形,而本地设计数据无法导出其矢量几何;在获得精确导出路径前,使用手工重建版本代替。 diff --git a/packages/client/ui-primitives/src/markdown/CodeBlock.module.css b/packages/client/ui-primitives/src/markdown/CodeBlock.module.css index c25252aaf9..33a114d102 100644 --- a/packages/client/ui-primitives/src/markdown/CodeBlock.module.css +++ b/packages/client/ui-primitives/src/markdown/CodeBlock.module.css @@ -184,7 +184,7 @@ } } -@media (pointer: coarse) { +@media (any-pointer: coarse) { .preview { padding-bottom: 56px; } @@ -192,6 +192,8 @@ .preview .bannerWrap { top: auto; bottom: 8px; + opacity: 1; + pointer-events: auto; } .preview .iconButton { diff --git a/packages/client/ui-primitives/src/markdown/CodeBlock.tsx b/packages/client/ui-primitives/src/markdown/CodeBlock.tsx index 3613ed4f16..2504f7eddc 100644 --- a/packages/client/ui-primitives/src/markdown/CodeBlock.tsx +++ b/packages/client/ui-primitives/src/markdown/CodeBlock.tsx @@ -217,7 +217,8 @@ export function CodeBlock({ code, lang, streaming, className, lineNumbers = fals
- {showingPreview ? preview.content : body} + {previewAvailable && } + {!showingPreview && body} ) } diff --git a/packages/client/ui-primitives/src/markdown/render.tsx b/packages/client/ui-primitives/src/markdown/render.tsx index d946c06893..eb3d3c2172 100644 --- a/packages/client/ui-primitives/src/markdown/render.tsx +++ b/packages/client/ui-primitives/src/markdown/render.tsx @@ -42,7 +42,7 @@ export interface MarkdownCodeLabels { export interface MarkdownLabels { code: MarkdownCodeLabels footnotes: string - /** Opt into settled Mermaid fence previews by supplying their complete localized chrome. */ + /** Opt into settled Mermaid fence previews by supplying their complete localized labels. */ mermaid?: MermaidPreviewLabels & { preview: string; source: string } } diff --git a/packages/client/ui-primitives/tests/mermaid-preview.client.spec.tsx b/packages/client/ui-primitives/tests/mermaid-preview.client.spec.tsx index 4f477902f7..c7cf95a9d4 100644 --- a/packages/client/ui-primitives/tests/mermaid-preview.client.spec.tsx +++ b/packages/client/ui-primitives/tests/mermaid-preview.client.spec.tsx @@ -86,7 +86,7 @@ describe('Markdown Mermaid fences', () => { expect(renderMermaid).not.toHaveBeenCalled() expect(screen.queryByRole('button', { name: labels.source })).toBeNull() view.rerender() - await screen.findByRole('img', { name: labels.diagram }) + const diagram = await screen.findByRole('img', { name: labels.diagram }) expect(screen.queryByText('mermaid')).toBeNull() expect(screen.getByRole('button', { name: labels.source }).textContent).toBe('') expect(screen.getByRole('button', { name: markdownLabels.code.copyLabel }).textContent).toBe('') @@ -96,9 +96,12 @@ describe('Markdown Mermaid fences', () => { fireEvent.click(screen.getByRole('button', { name: labels.source })) expect(view.container.querySelector('pre code')?.textContent).toBe(source) expect(screen.queryByRole('img')).toBeNull() + expect(diagram.isConnected).toBe(true) expect(screen.queryByText('mermaid')).toBeNull() fireEvent.click(screen.getByRole('button', { name: labels.preview })) - await screen.findByRole('img', { name: labels.diagram }) + expect(screen.getByRole('img', { name: labels.diagram })).toBe(diagram) + expect(screen.queryByRole('status')).toBeNull() + expect(renderMermaid).toHaveBeenCalledOnce() }) it('leaves other languages and consumers without preview labels as code', () => { From 0bfd594875662bce0007371195e9354a5cf84ac4 Mon Sep 17 00:00:00 2001 From: fz Date: Tue, 8 Sep 2026 12:41:09 +0800 Subject: [PATCH 038/111] refactor(ui): use tagged Mermaid preview results --- .../src/markdown/MermaidPreview.tsx | 31 ++++++++++++------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/packages/client/ui-primitives/src/markdown/MermaidPreview.tsx b/packages/client/ui-primitives/src/markdown/MermaidPreview.tsx index b59a09a48c..5dadb6a6b3 100644 --- a/packages/client/ui-primitives/src/markdown/MermaidPreview.tsx +++ b/packages/client/ui-primitives/src/markdown/MermaidPreview.tsx @@ -11,7 +11,12 @@ export interface MermaidPreviewLabels { error: string } -type Result = { code: string; src: string } | { code: string; error: true } +type Result = { kind: 'ok'; code: string; src: string } | { kind: 'error'; code: string } + +/* v8 ignore next 3 -- closed-union backstop; only reached if a result is forged */ +function assertNever(value: never): never { + throw new Error(`unreachable Mermaid result: ${String(value)}`) +} /** * Display a complete Mermaid document on a light diagram canvas, or its source if rendering fails. @@ -23,20 +28,24 @@ export function MermaidPreview({ code, labels }: { code: string; labels: Mermaid useEffect(() => { const controller = new AbortController() void renderMermaid(code, controller.signal).then( - (src) => { if (!controller.signal.aborted) setResult({ code, src }) }, - () => { if (!controller.signal.aborted) setResult({ code, error: true }) }, + (src) => { if (!controller.signal.aborted) setResult({ kind: 'ok', code, src }) }, + () => { if (!controller.signal.aborted) setResult({ kind: 'error', code }) }, ) return () => { controller.abort() } }, [code]) if (result?.code !== code) return
{labels.loading}
- if ('error' in result) { - return ( -
-
{labels.error}
-
{code}
-
- ) + switch (result.kind) { + case 'ok': + return
{labels.diagram}
+ case 'error': + return ( +
+
{labels.error}
+
{code}
+
+ ) + /* v8 ignore next -- closed-union backstop; only reached if a result is forged */ + default: return assertNever(result) } - return
{labels.diagram}
} From 4fabbd39c588039e8967f95f3d427b1158b0cce7 Mon Sep 17 00:00:00 2001 From: fz Date: Tue, 8 Sep 2026 12:52:41 +0800 Subject: [PATCH 039/111] test(web): scope Mermaid snapshots to diagram content --- .../expected/markdown-mermaid/ui.expected.md | 37 ------------------- .../expected/markdown-mermaid/zh.expected.md | 37 ------------------- apps/web/tests/markdown-mermaid.e2e.ts | 7 ++-- 3 files changed, 3 insertions(+), 78 deletions(-) diff --git a/apps/web/tests/expected/markdown-mermaid/ui.expected.md b/apps/web/tests/expected/markdown-mermaid/ui.expected.md index 58657a3bf6..ac9ceb3164 100644 --- a/apps/web/tests/expected/markdown-mermaid/ui.expected.md +++ b/apps/web/tests/expected/markdown-mermaid/ui.expected.md @@ -1,17 +1,3 @@ -- banner: - - navigation "Session hierarchy": - - button "Mermaid previews" [disabled] - - button "Session log": - - text: Session log - - img - - button "Open the sidebar": - - img - - tablist: - - tab "Chat" [selected] - - tab "Trajectory" -- text: Preview these Mermaid diagrams. {{clock}} -- button "Copy": - - img - heading "Mermaid previews" [level=1] - button "Source" - button "Copy" @@ -27,26 +13,3 @@ - button "Source" - button "Copy" - img "Mermaid diagram" -- button "Copy": - - img -- button "Good response": - - img -- button "Bad response": - - img -- button "Branch into a new conversation": - - img -- button "Ran for {{duration}}": - - img - - text: Ran for {{duration}} -- text: {{clock}} -- textbox "Message or run a task... / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": - - img -- 'button "Access mode, current: Workspace Write"': Workspace Write -- button "Select model, current DeepSeek-V4-Flash": - - text: DeepSeek-V4-Flash - - img -- button "Send message" [disabled] -- text: 1 turns · 1 steps LLM {{duration}} diff --git a/apps/web/tests/expected/markdown-mermaid/zh.expected.md b/apps/web/tests/expected/markdown-mermaid/zh.expected.md index e3a3b84157..066e88f589 100644 --- a/apps/web/tests/expected/markdown-mermaid/zh.expected.md +++ b/apps/web/tests/expected/markdown-mermaid/zh.expected.md @@ -1,17 +1,3 @@ -- banner: - - navigation "会话层级": - - button "Mermaid previews" [disabled] - - button "Session 日志": - - text: Session 日志 - - img - - button "展开侧栏": - - img - - tablist: - - tab "对话" [selected] - - tab "轨迹" -- text: Preview these Mermaid diagrams. {{clock}} -- button "复制": - - img - heading "Mermaid previews" [level=1] - button "源码" - button "复制" @@ -26,26 +12,3 @@ - button "源码" - button "复制" - img "Mermaid 图表" -- button "复制": - - img -- button "好的回答": - - img -- button "有问题的回答": - - img -- button "在新对话中分支": - - img -- button "用时 {{duration}}": - - img - - text: 用时 {{duration}} -- text: {{clock}} -- textbox "发消息或做任务… / 调用指令 @ 文件或对话" -- button "指令": - - img -- button "添加附件": - - img -- button "访问模式,当前:工作区内修改": 工作区内修改 -- button "选择模型,当前 DeepSeek-V4-Flash": - - text: DeepSeek-V4-Flash - - img -- button "发送消息" [disabled] -- text: 1 轮 · 1 步 LLM {{duration}} diff --git a/apps/web/tests/markdown-mermaid.e2e.ts b/apps/web/tests/markdown-mermaid.e2e.ts index 928e6cbf40..82789caa63 100644 --- a/apps/web/tests/markdown-mermaid.e2e.ts +++ b/apps/web/tests/markdown-mermaid.e2e.ts @@ -15,6 +15,7 @@ import { newEnglishPage, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/markdown-mermaid', import.meta.url)) const MODE = webSnapshotMode() const SEED_ID = 'markdown-mermaid-web-e2e' +const DIAGRAM_MESSAGE = '[class*="markdown"]:has(.md-code-block)' const FLOW = 'flowchart LR\n A[输入] --> B[共享渲染器] --> C[图形预览]' const SEQUENCE = 'sequenceDiagram\n participant U as User\n participant R as Renderer\n U->>R: Mermaid source\n R-->>U: Diagram' const INVALID = 'flowchart LR\n A[unfinished' @@ -122,8 +123,7 @@ describe('web e2e: Mermaid chat previews', () => { expect(tripwire.warnings).toEqual([]) await page.getByRole('heading', { name: 'Mermaid previews' }).click() await page.mouse.move(0, 0) - const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) - .split(SEED_ID).join('{{seededId}}') + const snapshot = await captureStableAria(page, DIAGRAM_MESSAGE, scaffold.workspaceCwd) await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'ui.expected.md'), snapshot, MODE) await page.close() }, 60_000) @@ -142,8 +142,7 @@ describe('web e2e: Mermaid chat previews', () => { await expect.poll(() => firstImage.evaluate(node => (node as HTMLImageElement).naturalWidth)).toBeGreaterThan(0) expect(await controls.evaluate(node => node.getBoundingClientRect().top)) .toBeGreaterThanOrEqual(await firstImage.evaluate(node => node.getBoundingClientRect().bottom)) - const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) - .split(SEED_ID).join('{{seededId}}') + const snapshot = await captureStableAria(page, DIAGRAM_MESSAGE, scaffold.workspaceCwd) await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'zh.expected.md'), snapshot, MODE) await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md', 'zh.expected.md']) await page.close() From a8f9d81f3a68266f4d268b877fd2715697062c87 Mon Sep 17 00:00:00 2001 From: yudshj Date: Wed, 9 Sep 2026 12:18:09 +0800 Subject: [PATCH 040/111] feat(client): preview Graphviz, SVG, and HTML Markdown fences --- .../2026-09-07-web-mermaid-preview.i18n.yaml | 4 +- .../feature/2026-09-07-web-mermaid-preview.md | 2 + .../2026-09-07-web-mermaid-preview.zh.md | 2 + ...6-09-09-markdown-static-previews.i18n.yaml | 6 + .../2026-09-09-markdown-static-previews.md | 31 + .../2026-09-09-markdown-static-previews.zh.md | 31 + THIRD_PARTY_NOTICES.md | 4 + .../expected/markdown-mermaid/ui.expected.md | 9 + .../expected/markdown-mermaid/zh.expected.md | 9 + apps/web/tests/markdown-mermaid.e2e.ts | 55 +- apps/web/vite.config.ts | 16 +- packages/client/locale/src/locales/en.ts | 5 + packages/client/locale/src/locales/zh.ts | 5 + packages/client/ui-chat/README.i18n.yaml | 4 +- packages/client/ui-chat/README.md | 2 +- packages/client/ui-chat/README.zh.md | 2 +- .../ui-chat/src/client/markdown-labels.ts | 9 + .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 6 +- packages/client/ui-primitives/README.zh.md | 6 +- .../THIRD_PARTY_PREVIEW_NOTICES.txt | 688 ++++++++++++++++++ packages/client/ui-primitives/package.json | 5 +- .../src/markdown/MermaidPreview.module.css | 9 + .../src/markdown/MermaidPreview.tsx | 52 +- .../src/markdown/SourcePreview.tsx | 56 ++ .../ui-primitives/src/markdown/graphviz.ts | 22 + .../src/markdown/preview-document.ts | 44 ++ .../ui-primitives/src/markdown/render.tsx | 41 +- .../tests/fence-previews.client.spec.tsx | 103 +++ .../tests/graphviz-runtime.client.spec.ts | 35 + pnpm-lock.yaml | 11 + scripts/check-workspace-constraints.spec.ts | 14 + scripts/check-workspace-constraints.ts | 2 +- scripts/gen-third-party-notices.spec.ts | 24 + scripts/gen-third-party-notices.ts | 34 + 35 files changed, 1290 insertions(+), 62 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-09-09-markdown-static-previews.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-09-09-markdown-static-previews.md create mode 100644 .agents/notes/implemented/feature/2026-09-09-markdown-static-previews.zh.md create mode 100644 packages/client/ui-primitives/THIRD_PARTY_PREVIEW_NOTICES.txt create mode 100644 packages/client/ui-primitives/src/markdown/SourcePreview.tsx create mode 100644 packages/client/ui-primitives/src/markdown/graphviz.ts create mode 100644 packages/client/ui-primitives/src/markdown/preview-document.ts create mode 100644 packages/client/ui-primitives/tests/fence-previews.client.spec.tsx create mode 100644 packages/client/ui-primitives/tests/graphviz-runtime.client.spec.ts diff --git a/.agents/notes/implemented/feature/2026-09-07-web-mermaid-preview.i18n.yaml b/.agents/notes/implemented/feature/2026-09-07-web-mermaid-preview.i18n.yaml index f86ae95548..190b6ffb3e 100644 --- a/.agents/notes/implemented/feature/2026-09-07-web-mermaid-preview.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-07-web-mermaid-preview.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-07-web-mermaid-preview.md -2026-09-07-web-mermaid-preview.md: 811f688713491333587404d32946317d81ff2795 -2026-09-07-web-mermaid-preview.zh.md: 0fdb9cf318561f20fdb7042121bc3517c4d6c9a0 +2026-09-07-web-mermaid-preview.md: 246b38e764e1e5812427f935c4a41c28f0fa24a0 +2026-09-07-web-mermaid-preview.zh.md: c1dd25c1b6dc21c36d92e0d7c1477fcea9ae99c2 diff --git a/.agents/notes/implemented/feature/2026-09-07-web-mermaid-preview.md b/.agents/notes/implemented/feature/2026-09-07-web-mermaid-preview.md index 811f688713..246b38e764 100644 --- a/.agents/notes/implemented/feature/2026-09-07-web-mermaid-preview.md +++ b/.agents/notes/implemented/feature/2026-09-07-web-mermaid-preview.md @@ -26,6 +26,8 @@ Source replacement and unmounting cancel result publication. Cancellation before **Insert interactive SVG into the message.** The requested preview needs diagram display and source access. Displaying the result as an image keeps diagram links and event handlers inactive. +The [static fence preview decision](2026-09-09-markdown-static-previews.md) owns Graphviz, SVG, and HTML sandboxing and shared preview license distribution. + ## Consequences The feature changes presentation without changing persisted messages, provider requests, tools, or Host APIs. Mermaid adds lazily loaded browser assets. Rendering still runs on the browser thread, and work already handed to the library cannot be interrupted. The initial feature has no editing, export, zoom controls, or interactive diagram links. diff --git a/.agents/notes/implemented/feature/2026-09-07-web-mermaid-preview.zh.md b/.agents/notes/implemented/feature/2026-09-07-web-mermaid-preview.zh.md index 0fdb9cf318..c1dd25c1b6 100644 --- a/.agents/notes/implemented/feature/2026-09-07-web-mermaid-preview.zh.md +++ b/.agents/notes/implemented/feature/2026-09-07-web-mermaid-preview.zh.md @@ -26,6 +26,8 @@ Mermaid 按需加载。它的公开 render API 串行执行图表工作,每次 **把可交互 SVG 插入消息。** 此次预览需求只需要图表显示与源码访问。将结果显示为图片后,图内链接和事件处理器不会生效。 +[静态 fence 预览决策](2026-09-09-markdown-static-previews.zh.md)负责 Graphviz、SVG、HTML 的 sandbox 及共享预览许可证分发。 + ## 后果 功能只改变展示,不改变持久化消息、提供者请求、工具或 Host API。Mermaid 增加按需加载的浏览器资源。渲染仍在浏览器线程上执行,已交给库的工作无法中断。首版不包含编辑、导出、缩放控件或可交互图内链接。 diff --git a/.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.i18n.yaml b/.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.i18n.yaml new file mode 100644 index 0000000000..6cb3a179eb --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.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-09-markdown-static-previews.md +2026-09-09-markdown-static-previews.md: fd3772cabc2160f07e79b06dffa4374fe104cbd1 +2026-09-09-markdown-static-previews.zh.md: 92f4b030879acaeb484eed33e078574750a1801e diff --git a/.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.md b/.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.md new file mode 100644 index 0000000000..fd3772cabc --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.md @@ -0,0 +1,31 @@ +# Agent Note: Static Markdown fence previews + +Status: implemented + +English | [中文](2026-09-09-markdown-static-previews.zh.md) + +## Problem + +Readers need to see DOT diagrams, SVG artwork, and HTML examples directly in Assistant replies. These sources are untrusted, and a renderer's npm license field may omit compiled components with separate distribution obligations. + +## Decision + +The shared Markdown renderer enables settled `graphviz`/`dot`, `svg`, and `html` fences through localized `MarkdownLabels.preview`. `CodeBlock.preview` owns default visualization, source switching, and verbatim copying. `SourcePreview` owns loading, failure, and cancellation of stale result publication for both these documents and the [Mermaid preview](2026-09-07-web-mermaid-preview.md). Consumers without preview labels and streaming messages retain code. + +HTML uses DOMPurify with navigation attributes and document-loading elements forbidden, followed by an opaque `sandbox=""` iframe. A trusted CSP precedes source markup and permits only inline CSS, data images, and data fonts. Scripts, external resources, child frames, form submission, and same-origin access are unavailable. SVG is parsed as XML and placed in an image inside the frame, so SVG scripts and links never become active. Frames have a fixed scrollable viewport: measuring their content would require additional origin access or trusted frame scripts. Mermaid keeps its existing strict renderer and inert image presentation. + +Graphviz uses the pinned, unmodified `@viz-js/viz` 3.30.0 WebAssembly distribution with the `dot` layout engine. Its npm MIT declaration covers the wrapper; its build provenance identifies Graphviz 16.0.0 (EPL-2.0), Expat 2.8.4 (MIT), and Emscripten 5.0.7 (MIT/NCSA). Distribution retains these component terms and the exact Graphviz source download under EPL-2.0 section 3.1. [Full preview notices](../../../../packages/client/ui-primitives/THIRD_PARTY_PREVIEW_NOTICES.txt) also retain Mermaid's MIT text and select Apache-2.0 for DOMPurify. The UI primitives package ships the notices and the Web build emits the same bytes alongside its assets. License review covers embedded payloads separately from the general permissive npm-metadata policy. + +## Alternatives considered + +**Execute HTML scripts in a frame.** Static chat examples do not need script execution. An empty sandbox plus sanitization and CSP gives the preview fewer capabilities and avoids introducing a frame messaging protocol. + +**Insert rendered markup into Chat.** HTML styles and executable SVG would share the application document. Iframes isolate layout and origin; SVG image mode additionally disables SVG behavior. + +**Treat Viz.js as MIT-only or use a remote Graphviz service.** The compiled Graphviz license still applies locally; a remote renderer would send conversation content off-device. The bundled renderer retains source availability and legal notices without network rendering. + +## Consequences + +No Session event, prompt, or tool changes. Inline HTML styles work, while interactive scripts, external assets, and links do not. Graphviz adds a lazy WebAssembly asset and synchronous browser-thread layout; source cancellation prevents publication but cannot preempt active layout. Mermaid's existing layout limitations remain documented in its owning note. + +Component tests cover source copying, default preview, fallback, cancellation, and recovery. The keyless [browser scenario](../../../../apps/web/tests/markdown-mermaid.e2e.ts) verifies opaque frames, real image decoding, source toggling, blocked script/navigation/resource attempts, locale snapshots, and served license text. Notices checks pin the reviewed wrapper and native provenance so upgrades require renewed review. diff --git a/.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.zh.md b/.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.zh.md new file mode 100644 index 0000000000..92f4b03087 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.zh.md @@ -0,0 +1,31 @@ +# Agent Note: 静态 Markdown fence 预览 + +Status: implemented + +[English](2026-09-09-markdown-static-previews.md) | 中文 + +## Problem + +读者需要直接在 Assistant 回复中查看 DOT 图表、SVG 图像和 HTML 示例。这些源码不可信,而渲染器的 npm 许可证字段可能遗漏编译组件各自的分发义务。 + +## Decision + +共享 Markdown 渲染器通过本地化的 `MarkdownLabels.preview` 启用定稿后的 `graphviz`/`dot`、`svg` 和 `html` fence。`CodeBlock.preview` 负责默认可视化、源码切换与原样复制。`SourcePreview` 为这些文档及 [Mermaid 预览](2026-09-07-web-mermaid-preview.zh.md)负责加载、失败和取消过期结果发布。未提供预览文案的调用方与流式消息保留代码显示。 + +HTML 先由 DOMPurify 清理,禁止导航属性与文档加载元素,再进入不透明来源的 `sandbox=""` iframe。可信 CSP 位于源码标记之前,仅允许内联 CSS、data 图片与 data 字体。脚本、外部资源、子 iframe、表单提交与同源访问均不可用。SVG 按 XML 解析后作为图片放进 iframe,因此 SVG 脚本与链接不会激活。iframe 使用固定的可滚动视口:测量内容需要额外的来源访问权限或可信 iframe 脚本。Mermaid 保留既有的严格渲染器与不可执行图片展示。 + +Graphviz 使用固定且未修改的 `@viz-js/viz` 3.30.0 WebAssembly 发布包,布局引擎为 `dot`。其 npm MIT 声明覆盖包装层;构建来源记录标明 Graphviz 16.0.0(EPL-2.0)、Expat 2.8.4(MIT)与 Emscripten 5.0.7(MIT/NCSA)。分发保留各组件条款,并按 EPL-2.0 第 3.1 节提供准确的 Graphviz 源码下载地址。[完整预览声明](../../../../packages/client/ui-primitives/THIRD_PARTY_PREVIEW_NOTICES.txt)还保留 Mermaid 的 MIT 文本,并为 DOMPurify 选择 Apache-2.0。UI primitives 包携带该声明,Web 构建在资源旁输出相同字节。内嵌产物的许可证检查独立于通用的宽松 npm 元数据策略。 + +## Alternatives considered + +**在 iframe 内执行 HTML 脚本。** 静态聊天示例不需要脚本执行。空 sandbox、清理与 CSP 降低预览权限,也避免引入 iframe 消息协议。 + +**把渲染标记插入 Chat。** HTML 样式和可执行 SVG 会与应用共享文档。iframe 隔离布局与来源,SVG 图片模式额外禁用 SVG 行为。 + +**将 Viz.js 视为仅使用 MIT,或使用远程 Graphviz 服务。** 本地编译的 Graphviz 仍受其许可证约束;远程渲染会把对话内容发送到设备之外。内置渲染器保留源码可获取性与法律声明,无需网络渲染。 + +## Consequences + +不改变 Session 事件、提示词或工具。HTML 内联样式可用,交互脚本、外部资源和链接不可用。Graphviz 增加按需加载的 WebAssembly 资源与浏览器线程同步布局;源码取消可以阻止结果发布,但无法抢占进行中的布局。Mermaid 既有布局限制仍记录在其所属决策中。 + +组件测试覆盖源码复制、默认预览、回退、取消与恢复。无密钥[浏览器场景](../../../../apps/web/tests/markdown-mermaid.e2e.ts)验证不透明来源 iframe、真实图片解码、源码切换、脚本/导航/资源请求被阻止、本地化快照和已提供的许可证文本。声明检查固定已审查的包装层与原生构建来源,升级时须重新审查。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index c2705ac530..2452325d62 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -55,6 +55,7 @@ External packages installed for runtime use or distributed inside the prebuilt b | [`@shikijs/langs`](https://github.com/shikijs/shiki) | MIT | | [`@standard-schema/spec`](https://github.com/standard-schema/standard-schema) | MIT | | [`@tanstack/react-virtual`](https://github.com/TanStack/virtual) | MIT | +| [`@viz-js/viz`](https://github.com/mdaines/viz-js) | MIT | | [`@vscode/ripgrep`](https://github.com/microsoft/vscode-ripgrep) | MIT | | [`@xterm/headless`](https://github.com/xtermjs/xterm.js) | MIT | | [`@yarnpkg/parsers`](https://github.com/yarnpkg/berry) | BSD-2-Clause | @@ -66,6 +67,7 @@ External packages installed for runtime use or distributed inside the prebuilt b | [`commander`](https://github.com/tj/commander.js) | MIT | | [`compression`](https://github.com/expressjs/compression) | MIT | | [`diff`](https://github.com/kpdecker/jsdiff) | BSD-3-Clause | +| [`dompurify`](https://github.com/cure53/DOMPurify) | (MPL-2.0 OR Apache-2.0) | | [`e2b`](https://github.com/e2b-dev/e2b) | MIT | | [`electron-updater`](https://github.com/electron-userland/electron-builder) | MIT | | [`eventsource-parser`](https://github.com/rexxars/eventsource-parser) | MIT | @@ -113,6 +115,8 @@ External packages installed for runtime use or distributed inside the prebuilt b | [`zod`](https://github.com/colinhacks/zod) | MIT | | [`zustand`](https://github.com/pmndrs/zustand) | MIT | +The Markdown preview distribution also contains Graphviz 16.0.0 (EPL-2.0), Expat 2.8.4 (MIT), and Emscripten 5.0.7 runtime code (MIT/NCSA) inside `@viz-js/viz` 3.30.0. The wrapper's MIT metadata does not relicense these components. [Preview notices](packages/client/ui-primitives/THIRD_PARTY_PREVIEW_NOTICES.txt) preserve their full license texts and Graphviz source availability, together with Mermaid's MIT and DOMPurify's selected Apache-2.0 terms. The UI primitives npm package includes this file; the Web build emits it as `preview-third-party-notices.txt`. + pnpm applies local patches to the following packages at install time, so shipped artifacts carry modified copies; each patch file is the complete record of the modification: - `@yao-pkg/pkg@6.21.0` — [`patches/@yao-pkg__pkg@6.21.0.patch`](patches/@yao-pkg__pkg@6.21.0.patch) diff --git a/apps/web/tests/expected/markdown-mermaid/ui.expected.md b/apps/web/tests/expected/markdown-mermaid/ui.expected.md index ac9ceb3164..369f0ef21d 100644 --- a/apps/web/tests/expected/markdown-mermaid/ui.expected.md +++ b/apps/web/tests/expected/markdown-mermaid/ui.expected.md @@ -13,3 +13,12 @@ - button "Source" - button "Copy" - img "Mermaid diagram" +- button "Source" +- button "Copy" +- iframe +- button "Source" +- button "Copy" +- iframe +- button "Source" +- button "Copy" +- iframe diff --git a/apps/web/tests/expected/markdown-mermaid/zh.expected.md b/apps/web/tests/expected/markdown-mermaid/zh.expected.md index 066e88f589..74a348e8be 100644 --- a/apps/web/tests/expected/markdown-mermaid/zh.expected.md +++ b/apps/web/tests/expected/markdown-mermaid/zh.expected.md @@ -12,3 +12,12 @@ - button "源码" - button "复制" - img "Mermaid 图表" +- button "源码" +- button "复制" +- iframe +- button "源码" +- button "复制" +- iframe +- button "源码" +- button "复制" +- iframe diff --git a/apps/web/tests/markdown-mermaid.e2e.ts b/apps/web/tests/markdown-mermaid.e2e.ts index 82789caa63..c009e1d836 100644 --- a/apps/web/tests/markdown-mermaid.e2e.ts +++ b/apps/web/tests/markdown-mermaid.e2e.ts @@ -26,6 +26,15 @@ const UNTRUSTED = [ ' click B "javascript:alert(1)"', ].join('\n') +const DOT = 'digraph { rankdir=LR; Input -> Preview }' +const SVG = 'SVG preview' +const HTML = `

Static HTML

+ + +Disabled navigation + +` + function fixture(): string { const session = Session.create(SessionId('markdown-mermaid-source')) session.append('turn/start', { turn: 1 }) @@ -41,6 +50,7 @@ function fixture(): string { content: [{ type: 'text', text: [ '# Mermaid previews', ...[FLOW, SEQUENCE, INVALID, UNTRUSTED].map(code => `\`\`\`mermaid\n${code}\n\`\`\``), + ...[['dot', DOT], ['svg', SVG], ['html', HTML]].map(([lang, code]) => `\`\`\`${lang}\n${code}\n\`\`\``), ].join('\n\n') }], source: { kind: 'model', provider: 'fixture', model: 'fixture' }, }), @@ -132,7 +142,7 @@ describe('web e2e: Mermaid chat previews', () => { const page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: 'zh-CN', timezoneId: 'Asia/Shanghai', hasTouch: true }) await openConversation(page, scaffold) await expect.poll(() => page.getByRole('img', { name: 'Mermaid 图表' }).count(), { timeout: 20_000 }).toBe(3) - expect(await page.getByRole('button', { name: '源码', exact: true }).count()).toBe(4) + expect(await page.getByRole('button', { name: '源码', exact: true }).count()).toBe(7) const controls = page.locator('.md-code-block').first().locator('[class*="bannerWrap"]') expect(await controls.evaluate(node => getComputedStyle(node).opacity)).toBe('1') expect(await page.getByRole('button', { name: '源码', exact: true }).first() @@ -148,6 +158,49 @@ describe('web e2e: Mermaid chat previews', () => { await page.close() }, 60_000) + it.skipIf(MODE === 'record')('previews DOT, SVG, and static HTML inside isolated frames', async () => { + const page = await newEnglishPage(browser) + const requests: string[] = [] + const dialogs: string[] = [] + // Routing observes requests eligible for transport; Chromium also emits 'request' for CSP-blocked URLs. + await page.route('https://preview.invalid/**', async (route) => { + requests.push(route.request().url()) + await route.abort() + }) + page.on('dialog', (dialog) => { dialogs.push(dialog.message()); void dialog.dismiss() }) + await page.context().grantPermissions(['clipboard-read', 'clipboard-write']) + await openConversation(page, scaffold) + for (const [title, code] of [['Graphviz diagram', DOT], ['SVG preview', SVG], ['HTML preview', HTML]]) { + const frame = page.locator(`iframe[title="${title}"]`) + await frame.waitFor() + expect(await frame.getAttribute('sandbox')).toBe('') + const body = frame.contentFrame() + if (title === 'HTML preview') { + await body.getByRole('heading', { name: 'Static HTML' }).waitFor() + expect(await body.getByRole('heading').evaluate(node => getComputedStyle(node).color)).toBe('rgb(0, 128, 0)') + await body.getByText('Disabled navigation').click() + expect(await frame.evaluate(node => (node as HTMLIFrameElement).contentDocument)).toBeNull() + } else { + await expect.poll(() => body.locator('img').evaluate(node => (node as HTMLImageElement).naturalWidth)).toBeGreaterThan(0) + } + const block = page.locator('.md-code-block').filter({ has: frame }) + await block.hover() + await block.getByRole('button', { name: 'Copy', exact: true }).click() + expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(code) + await block.getByRole('button', { name: 'Source', exact: true }).click() + expect(await block.locator('pre code').textContent()).toBe(code) + await block.getByRole('button', { name: 'Preview', exact: true }).click() + expect(await frame.isVisible()).toBe(true) + } + expect(await page.locator('body').getAttribute('data-preview-escaped')).toBeNull() + expect(dialogs).toEqual([]) + expect(requests).toEqual([]) + const notices = await page.request.get(new URL('/preview-third-party-notices.txt', scaffold.authenticatedUrl).href) + expect(notices.ok()).toBe(true) + expect(await notices.text()).toContain('Eclipse Public License - v 2.0') + await page.close() + }) + it.skipIf(MODE === 'record')('keeps actions visible when a mouse and touchscreen are both available', async () => { const hybridBrowser = await chromium.launch({ args: ['--blink-settings=availablePointerTypes=6,primaryPointerType=4,availableHoverTypes=2,primaryHoverType=2'], diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 0e15b4f088..f6c90861f1 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -27,6 +27,20 @@ function clientDocumentTitle(): Plugin { } } +/** Preserve preview licenses and Graphviz source availability in every browser distribution. */ +function previewNotices(): Plugin { + return { + name: 'dsh-preview-notices', + async generateBundle() { + this.emitFile({ + type: 'asset', + fileName: 'preview-third-party-notices.txt', + source: await readFile(src('../../packages/client/ui-primitives/THIRD_PARTY_PREVIEW_NOTICES.txt'), 'utf8'), + }) + }, + } +} + /** Fail before a Vite dev or preview server can expose the boot-manifest-free shell. */ function rejectStandaloneServe(): Plugin { return { @@ -147,7 +161,7 @@ export default defineConfig({ // Relative asset URLs: preview.html mounts the same output under any base // directory, and the served index resolves identically from the site root. base: './', - plugins: [rejectStandaloneServe(), clientDocumentTitle(), react(), emitPreviewPage()], + plugins: [previewNotices(), rejectStandaloneServe(), clientDocumentTitle(), react(), emitPreviewPage()], build: { // The worker bootstrap holds its page at top-level await; Vite's default // `modules` target (es2020-era) rejects that syntax. diff --git a/packages/client/locale/src/locales/en.ts b/packages/client/locale/src/locales/en.ts index 6261002e3a..1d97566db1 100644 --- a/packages/client/locale/src/locales/en.ts +++ b/packages/client/locale/src/locales/en.ts @@ -38,6 +38,11 @@ export const en = { 'json.expandNode': 'Expand JSON node', 'json.label': 'JSON', 'markdown.footnotes': 'Footnotes', + 'markdown.preview.graphviz': 'Graphviz diagram', + 'markdown.preview.svg': 'SVG preview', + 'markdown.preview.html': 'HTML preview', + 'markdown.preview.loading': 'Rendering preview…', + 'markdown.preview.error': 'Unable to render this preview. The source is shown below.', 'markdown.mermaid.diagram': 'Mermaid diagram', 'markdown.mermaid.preview': 'Preview', 'markdown.mermaid.source': 'Source', diff --git a/packages/client/locale/src/locales/zh.ts b/packages/client/locale/src/locales/zh.ts index 9a03ac9b7f..a799548c28 100644 --- a/packages/client/locale/src/locales/zh.ts +++ b/packages/client/locale/src/locales/zh.ts @@ -36,6 +36,11 @@ export const zh = { 'json.expandNode': '展开 JSON 节点', 'json.label': 'JSON', 'markdown.footnotes': '脚注', + 'markdown.preview.graphviz': 'Graphviz 图表', + 'markdown.preview.svg': 'SVG 预览', + 'markdown.preview.html': 'HTML 预览', + 'markdown.preview.loading': '正在渲染预览…', + 'markdown.preview.error': '无法渲染此预览,源码如下。', 'markdown.mermaid.diagram': 'Mermaid 图表', 'markdown.mermaid.preview': '预览', 'markdown.mermaid.source': '源码', diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index 386c06ea91..554709bb19 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-chat/README.md -README.md: 951f88e5fa61a27802fb7df11a3347ba9676a66c -README.zh.md: 3a180afb43c4262d6054e1f19deefa490b9b36de +README.md: 1bd7f008e46b695899c703c928ebe8bb76fbe4c8 +README.zh.md: 5919622396dde5b0c8cbf51f71999672a11f13ec diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index 951f88e5fa..1bd7f008e4 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -33,7 +33,7 @@ Each nonempty appended `system/message` owns a collapsed prompt row, including a ## Mermaid previews -Settled Assistant Markdown shows `mermaid` code fences as diagrams. Diagrams have no language banner. Source/Preview and Copy icon actions appear on hover or keyboard focus and remain visible on touch devices; both views copy the original Mermaid source. Streaming fences remain code until the message settles; invalid diagrams show an error and their source without disrupting other blocks. Chat enables the shared [Mermaid preview primitive](../ui-primitives/README.md) through localized labels. This is a presentation change and adds no model prompt, tool, or Session event. +Settled Assistant Markdown shows `mermaid` code fences as diagrams. Diagrams have no language banner. Source/Preview and Copy icon actions appear on hover or keyboard focus and remain visible on touch devices; both views copy the original Mermaid source. Streaming fences remain code until the message settles; invalid diagrams show an error and their source without disrupting other blocks. Chat enables the shared [Mermaid preview primitive](../ui-primitives/README.md) through localized labels. Chat also previews `graphviz`/`dot`, `svg`, and static `html` fences in isolated iframes, with the same default visualization, source switching, and copying. HTML scripts, navigation, and remote resources are disabled. This is a presentation change and adds no model prompt, tool, or Session event. ----- diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 3a180afb43..5919622396 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -35,7 +35,7 @@ kind: "package-reference" ## Mermaid 预览 -已定稿的 Assistant Markdown 把 `mermaid` 代码 fence 显示为图表。图表不显示语言标题栏。源码/预览切换与复制图标在悬停或键盘聚焦时出现,触屏上保持可见;两种视图复制的都是原始 Mermaid 源码。流式 fence 在消息定稿前保持代码显示;非法图表显示错误与源码,不影响其他块。Chat 通过本地化 label 启用共享的 [Mermaid 预览原语](../ui-primitives/README.zh.md)。这项展示变更不增加模型提示词、工具或 Session 事件。 +已定稿的 Assistant Markdown 把 `mermaid` 代码 fence 显示为图表。图表不显示语言标题栏。源码/预览切换与复制图标在悬停或键盘聚焦时出现,触屏上保持可见;两种视图复制的都是原始 Mermaid 源码。流式 fence 在消息定稿前保持代码显示;非法图表显示错误与源码,不影响其他块。Chat 通过本地化 label 启用共享的 [Mermaid 预览原语](../ui-primitives/README.zh.md)。Chat 还在隔离 iframe 中预览 `graphviz`/`dot`、`svg` 和静态 `html` fence,共用默认可视化、源码切换与复制操作。HTML 脚本、导航与远程资源均被禁用。这项展示变更不增加模型提示词、工具或 Session 事件。 ----- diff --git a/packages/client/ui-chat/src/client/markdown-labels.ts b/packages/client/ui-chat/src/client/markdown-labels.ts index d42e6e5f83..ea70175c57 100644 --- a/packages/client/ui-chat/src/client/markdown-labels.ts +++ b/packages/client/ui-chat/src/client/markdown-labels.ts @@ -12,6 +12,15 @@ export function markdownLabels(t: ChatViewSlotProps['t']): MarkdownLabels { return { code: { copyLabel: t('copy'), copiedLabel: t('copied') }, footnotes: t('markdown.footnotes'), + preview: { + graphviz: t('markdown.preview.graphviz'), + svg: t('markdown.preview.svg'), + html: t('markdown.preview.html'), + preview: t('markdown.mermaid.preview'), + source: t('markdown.mermaid.source'), + loading: t('markdown.preview.loading'), + error: t('markdown.preview.error'), + }, mermaid: { diagram: t('markdown.mermaid.diagram'), preview: t('markdown.mermaid.preview'), diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index a0fd81c2bc..bdd6417054 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md -README.md: 0ba4d6baaa54bbacc82202c490423b37613874df -README.zh.md: 06a266b61a79d4ff5e38ca0de9877a6d8ec66af0 +README.md: a1e877c5249817c7ab2e9fa1f6a8ad9582c44a3a +README.zh.md: 81923da65cc21f794d2e7f5d822feb69c06f7499 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 0ba4d6baaa..a1e877c524 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -78,6 +78,10 @@ The catalog above lists what each export is for; this section covers the behavio Supply `MarkdownLabels.mermaid` to enable `mermaid` fence previews in a Markdown consumer; without it, fences remain code. Previews start after the message settles. `CodeBlock.preview` supplies an optional headerless body with icon actions for source switching and copying. Actions appear on hover or keyboard focus, and stay visible below the diagram whenever a touchscreen is available, including with a mouse attached. Switching to source keeps the preview mounted, so returning reuses its result; copying always retains the source. [The Mermaid decision](../../../.agents/notes/implemented/feature/2026-09-07-web-mermaid-preview.md) records the rendering and reuse choices. +Supply `MarkdownLabels.preview` to enable `graphviz`/`dot`, `svg`, and `html` fences. Each settled fence defaults to visualization and shares the source/copy controls. Graphviz loads `@viz-js/viz` on demand and renders DOT with the `dot` engine. SVG and Graphviz output are inert images inside opaque sandbox iframes; malformed SVG and DOT retain their source with an error. HTML accepts complete documents or fragments and preserves inline styles, but strips scripts and navigation. Frames have no sandbox permissions, and their Content Security Policy blocks external resources, embedded frames, forms, and scripts. Frames use a 400px scrollable viewport. Raw HTML outside a code fence remains ordinary Markdown input. + +[Preview license notices](THIRD_PARTY_PREVIEW_NOTICES.txt) ship with this package and the Web frontend. Mermaid is MIT; Viz.js is MIT but embeds Graphviz under EPL-2.0. The notices retain license texts and the exact Graphviz source download. Dependency upgrades require reviewing the embedded payload as well as npm metadata; the [preview decision](../../../.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.md) records the distribution and sandbox choices. + ### Localizing copy The atoms cannot read the application locale, so every piece of user-facing copy arrives through required label props. `HoverCard`, `TerminalBlock`, `JsonTree`, `CodeBlock`, `MarkdownText`, `JsonBlock`, `ConnectionIndicator`, `Modal`, `DiffBlock`, `ReadBlock`, `SearchBlock`, and `WebBlock` accept complete localized labels. The package owns no language fallback; omission fails typechecking, and each feature maps its typed `t` seat into the primitive's label interface. @@ -145,7 +149,7 @@ None; this package neither assembles nor sends a provider request. These limits define how the atoms behave at the edges; they are current package constraints, not a component roadmap. -- **Mermaid rendering runs on the browser thread** — every mounted settled preview starts rendering, including off-screen diagrams. Mermaid serializes layouts; work already submitted to it cannot be interrupted. Preview virtualization and worker rendering are not provided. +- **Diagram rendering runs on the browser thread** — every mounted settled preview starts rendering, including off-screen diagrams. Mermaid serializes layouts; Graphviz performs synchronous WebAssembly layout. Submitted layout work cannot be interrupted. Preview virtualization and worker rendering are not provided. - **Streaming defers cross-boundary reference resolution** — a reference-style link or footnote whose definition sits on the other side of the incremental freeze boundary renders as literal text while the reply streams; the settled full parse at finalize resolves it. - **A long highlighted fence retains its complete token DOM** — streaming avoids re-parsing, re-tokenizing, and reconciling the completed prefix, but it does not discard old colors or virtualize token spans. Final DOM cardinality therefore still follows the fence's token count; nested/container fences and a pathological single long line remain on the general tail path. - **Glyph-level icons are redrawn approximations** — the fish logo and the sparkle mark come from font glyphs whose vector geometry is not exportable from the local design data; hand-authored recreations stand in until an exact export path exists. diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 06a266b61a..81923da65c 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -78,6 +78,10 @@ kind: "package-library" 为 Markdown 调用方传入 `MarkdownLabels.mermaid` 即可启用 `mermaid` fence 预览;未传入时,fence 保持代码显示。预览在消息定稿后开始。`CodeBlock.preview` 提供无标题栏的替代正文,以及切换源码和复制的图标操作。操作在悬停或键盘聚焦时出现;只要设备具备触屏,就在图表下方保持可见,包括同时连接鼠标的情况。切换到源码时保留已挂载的预览,返回时复用其结果;复制始终保留源码。[Mermaid 决策](../../../.agents/notes/implemented/feature/2026-09-07-web-mermaid-preview.zh.md)记录了渲染与复用选择。 +传入 `MarkdownLabels.preview` 可启用 `graphviz`/`dot`、`svg` 和 `html` fence。每个定稿 fence 默认显示可视化,并共享源码切换与复制操作。Graphviz 按需加载 `@viz-js/viz`,用 `dot` 引擎渲染 DOT。SVG 与 Graphviz 输出作为不可执行的图片放入不透明来源的 sandbox iframe;非法 SVG 与 DOT 显示错误并保留源码。HTML 接受完整文档或片段、保留内联样式,同时移除脚本与导航。iframe 不授予任何 sandbox 权限,Content Security Policy 阻止外部资源、嵌套 iframe、表单与脚本。iframe 使用可滚动的 400px 视口。代码 fence 外的原始 HTML 仍按普通 Markdown 输入处理。 + +[预览许可证声明](THIRD_PARTY_PREVIEW_NOTICES.txt)随本包与 Web 前端分发。Mermaid 使用 MIT;Viz.js 使用 MIT,但内嵌 Graphviz 使用 EPL-2.0。声明保留完整许可证文本及准确的 Graphviz 源码下载地址。升级依赖时必须同时检查内嵌产物和 npm 元数据;[预览决策](../../../.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.zh.md)记录分发与 sandbox 选择。 + ### 本地化文案 这些原子组件无法读取应用 locale,因此每段面向用户的文案都必须通过 label prop 提供。`HoverCard`、`TerminalBlock`、`JsonTree`、`CodeBlock`、`MarkdownText`、`JsonBlock`、`ConnectionIndicator`、`Modal`、`DiffBlock`、`ReadBlock`、`SearchBlock` 与 `WebBlock` 接收完整的本地化 label。本包不拥有语言回退;遗漏会导致类型检查失败,各功能会把带类型的 `t` 席位映射到 primitive 的 label 接口。 @@ -145,7 +149,7 @@ kind: "package-library" 这些限制说明原子组件在边缘情况下的行为;它们是当前包约束,不是组件路线图。 -- **Mermaid 渲染在浏览器线程上执行**:每个已挂载的定稿预览都会开始渲染,包括视口外的图表。Mermaid 串行执行布局,已提交给它的工作无法中断。当前不提供预览虚拟化或 worker 渲染。 +- **图表渲染在浏览器线程上执行**:每个已挂载的定稿预览都会开始渲染,包括视口外的图表。Mermaid 串行执行布局;Graphviz 同步执行 WebAssembly 布局。已提交的布局工作无法中断。当前不提供预览虚拟化或 worker 渲染。 - **流式期间跨边界引用解析被推迟**:定义落在增量冻结边界另一侧的引用式链接或脚注,在回复流式输出期间渲染为字面文本;定稿时的全量解析会将其解析。 - **长高亮 fence 会保留完整 token DOM**:流式路径避免重新解析、重新 tokenize 和 reconcile 已完成前缀,但不会丢弃旧颜色或虚拟化 token span。因此最终 DOM 数量仍随 fence 的 token 数增长;嵌套/容器内 fence 与病态的单个超长行仍走通用尾部路径。 - **字形级图标是重新绘制的近似版本**:鱼形标志与闪光标记来自字体字形,而本地设计数据无法导出其矢量几何;在获得精确导出路径前,使用手工重建版本代替。 diff --git a/packages/client/ui-primitives/THIRD_PARTY_PREVIEW_NOTICES.txt b/packages/client/ui-primitives/THIRD_PARTY_PREVIEW_NOTICES.txt new file mode 100644 index 0000000000..3e1156a536 --- /dev/null +++ b/packages/client/ui-primitives/THIRD_PARTY_PREVIEW_NOTICES.txt @@ -0,0 +1,688 @@ +Markdown preview third-party notices + +Mermaid 11.16.0: MIT; source https://github.com/mermaid-js/mermaid/tree/mermaid%4011.16.0 +@viz-js/viz 3.30.0: MIT JavaScript wrapper; source and build instructions: +https://github.com/mdaines/viz-js/tree/99da545270e6e7b127a5c7ba65974b8a604a6358 + +The Viz.js WebAssembly payload contains Graphviz 16.0.0 under EPL-2.0, +Expat 2.8.4 under MIT, and Emscripten 5.0.7 runtime code under MIT/NCSA. +Graphviz remains under EPL-2.0; it is not relicensed as MIT by DeepSeek Harness. +Graphviz source code is available under EPL-2.0 at: +https://gitlab.com/api/v4/projects/4207231/packages/generic/graphviz-releases/16.0.0/graphviz-16.0.0.tar.gz +SHA-256: 36a1de1aaf5a2023b14f95170a5f8f0b12522d1c305b517fc261966597050749 +Viz.js's backend build instructions and additions are available at the commit above. +DeepSeek Harness uses the unmodified upstream Viz.js package. + +Expat source: https://github.com/libexpat/libexpat/tree/R_2_8_4 +Emscripten source: https://github.com/emscripten-core/emscripten/tree/5.0.7 +DOMPurify 3.4.11: Apache-2.0 OR MPL-2.0; this distribution selects Apache-2.0. +Source: https://github.com/cure53/DOMPurify/tree/3.4.11 + +The following license texts and copyright notices apply to their respective components. + + +===== Mermaid ===== + +The MIT License (MIT) + +Copyright (c) 2014 - 2022 Knut Sveidqvist + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +===== Viz.js ===== + +MIT License + +Copyright (c) Michael Daines + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +===== Graphviz ===== + +Eclipse Public License - v 2.0 + + THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE + PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION + OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. + +1. DEFINITIONS + +"Contribution" means: + + a) in the case of the initial Contributor, the initial content + Distributed under this Agreement, and + + b) in the case of each subsequent Contributor: + i) changes to the Program, and + ii) additions to the Program; + where such changes and/or additions to the Program originate from + and are Distributed by that particular Contributor. A Contribution + "originates" from a Contributor if it was added to the Program by + such Contributor itself or anyone acting on such Contributor's behalf. + Contributions do not include changes or additions to the Program that + are not Modified Works. + +"Contributor" means any person or entity that Distributes the Program. + +"Licensed Patents" mean patent claims licensable by a Contributor which +are necessarily infringed by the use or sale of its Contribution alone +or when combined with the Program. + +"Program" means the Contributions Distributed in accordance with this +Agreement. + +"Recipient" means anyone who receives the Program under this Agreement +or any Secondary License (as applicable), including Contributors. + +"Derivative Works" shall mean any work, whether in Source Code or other +form, that is based on (or derived from) the Program and for which the +editorial revisions, annotations, elaborations, or other modifications +represent, as a whole, an original work of authorship. + +"Modified Works" shall mean any work in Source Code or other form that +results from an addition to, deletion from, or modification of the +contents of the Program, including, for purposes of clarity any new file +in Source Code form that contains any contents of the Program. Modified +Works shall not include works that contain only declarations, +interfaces, types, classes, structures, or files of the Program solely +in each case in order to link to, bind by name, or subclass the Program +or Modified Works thereof. + +"Distribute" means the acts of a) distributing or b) making available +in any manner that enables the transfer of a copy. + +"Source Code" means the form of a Program preferred for making +modifications, including but not limited to software source code, +documentation source, and configuration files. + +"Secondary License" means either the GNU General Public License, +Version 2.0, or any later versions of that license, including any +exceptions or additional permissions as identified by the initial +Contributor. + +2. GRANT OF RIGHTS + + a) Subject to the terms of this Agreement, each Contributor hereby + grants Recipient a non-exclusive, worldwide, royalty-free copyright + license to reproduce, prepare Derivative Works of, publicly display, + publicly perform, Distribute and sublicense the Contribution of such + Contributor, if any, and such Derivative Works. + + b) Subject to the terms of this Agreement, each Contributor hereby + grants Recipient a non-exclusive, worldwide, royalty-free patent + license under Licensed Patents to make, use, sell, offer to sell, + import and otherwise transfer the Contribution of such Contributor, + if any, in Source Code or other form. This patent license shall + apply to the combination of the Contribution and the Program if, at + the time the Contribution is added by the Contributor, such addition + of the Contribution causes such combination to be covered by the + Licensed Patents. The patent license shall not apply to any other + combinations which include the Contribution. No hardware per se is + licensed hereunder. + + c) Recipient understands that although each Contributor grants the + licenses to its Contributions set forth herein, no assurances are + provided by any Contributor that the Program does not infringe the + patent or other intellectual property rights of any other entity. + Each Contributor disclaims any liability to Recipient for claims + brought by any other entity based on infringement of intellectual + property rights or otherwise. As a condition to exercising the + rights and licenses granted hereunder, each Recipient hereby + assumes sole responsibility to secure any other intellectual + property rights needed, if any. For example, if a third party + patent license is required to allow Recipient to Distribute the + Program, it is Recipient's responsibility to acquire that license + before distributing the Program. + + d) Each Contributor represents that to its knowledge it has + sufficient copyright rights in its Contribution, if any, to grant + the copyright license set forth in this Agreement. + + e) Notwithstanding the terms of any Secondary License, no + Contributor makes additional grants to any Recipient (other than + those set forth in this Agreement) as a result of such Recipient's + receipt of the Program under the terms of a Secondary License + (if permitted under the terms of Section 3). + +3. REQUIREMENTS + +3.1 If a Contributor Distributes the Program in any form, then: + + a) the Program must also be made available as Source Code, in + accordance with section 3.2, and the Contributor must accompany + the Program with a statement that the Source Code for the Program + is available under this Agreement, and informs Recipients how to + obtain it in a reasonable manner on or through a medium customarily + used for software exchange; and + + b) the Contributor may Distribute the Program under a license + different than this Agreement, provided that such license: + i) effectively disclaims on behalf of all other Contributors all + warranties and conditions, express and implied, including + warranties or conditions of title and non-infringement, and + implied warranties or conditions of merchantability and fitness + for a particular purpose; + + ii) effectively excludes on behalf of all other Contributors all + liability for damages, including direct, indirect, special, + incidental and consequential damages, such as lost profits; + + iii) does not attempt to limit or alter the recipients' rights + in the Source Code under section 3.2; and + + iv) requires any subsequent distribution of the Program by any + party to be under a license that satisfies the requirements + of this section 3. + +3.2 When the Program is Distributed as Source Code: + + a) it must be made available under this Agreement, or if the + Program (i) is combined with other material in a separate file or + files made available under a Secondary License, and (ii) the initial + Contributor attached to the Source Code the notice described in + Exhibit A of this Agreement, then the Program may be made available + under the terms of such Secondary Licenses, and + + b) a copy of this Agreement must be included with each copy of + the Program. + +3.3 Contributors may not remove or alter any copyright, patent, +trademark, attribution notices, disclaimers of warranty, or limitations +of liability ("notices") contained within the Program from any copy of +the Program which they Distribute, provided that Contributors may add +their own appropriate notices. + +4. COMMERCIAL DISTRIBUTION + +Commercial distributors of software may accept certain responsibilities +with respect to end users, business partners and the like. While this +license is intended to facilitate the commercial use of the Program, +the Contributor who includes the Program in a commercial product +offering should do so in a manner which does not create potential +liability for other Contributors. Therefore, if a Contributor includes +the Program in a commercial product offering, such Contributor +("Commercial Contributor") hereby agrees to defend and indemnify every +other Contributor ("Indemnified Contributor") against any losses, +damages and costs (collectively "Losses") arising from claims, lawsuits +and other legal actions brought by a third party against the Indemnified +Contributor to the extent caused by the acts or omissions of such +Commercial Contributor in connection with its distribution of the Program +in a commercial product offering. The obligations in this section do not +apply to any claims or Losses relating to any actual or alleged +intellectual property infringement. In order to qualify, an Indemnified +Contributor must: a) promptly notify the Commercial Contributor in +writing of such claim, and b) allow the Commercial Contributor to control, +and cooperate with the Commercial Contributor in, the defense and any +related settlement negotiations. The Indemnified Contributor may +participate in any such claim at its own expense. + +For example, a Contributor might include the Program in a commercial +product offering, Product X. That Contributor is then a Commercial +Contributor. If that Commercial Contributor then makes performance +claims, or offers warranties related to Product X, those performance +claims and warranties are such Commercial Contributor's responsibility +alone. Under this section, the Commercial Contributor would have to +defend claims against the other Contributors related to those performance +claims and warranties, and if a court requires any other Contributor to +pay any damages as a result, the Commercial Contributor must pay +those damages. + +5. NO WARRANTY + +EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, AND TO THE EXTENT +PERMITTED BY APPLICABLE LAW, THE PROGRAM IS PROVIDED ON AN "AS IS" +BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, EITHER EXPRESS OR +IMPLIED INCLUDING, WITHOUT LIMITATION, ANY WARRANTIES OR CONDITIONS OF +TITLE, NON-INFRINGEMENT, MERCHANTABILITY OR FITNESS FOR A PARTICULAR +PURPOSE. Each Recipient is solely responsible for determining the +appropriateness of using and distributing the Program and assumes all +risks associated with its exercise of rights under this Agreement, +including but not limited to the risks and costs of program errors, +compliance with applicable laws, damage to or loss of data, programs +or equipment, and unavailability or interruption of operations. + +6. DISCLAIMER OF LIABILITY + +EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, AND TO THE EXTENT +PERMITTED BY APPLICABLE LAW, NEITHER RECIPIENT NOR ANY CONTRIBUTORS +SHALL HAVE ANY LIABILITY FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING WITHOUT LIMITATION LOST +PROFITS), HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OR DISTRIBUTION OF THE PROGRAM OR THE +EXERCISE OF ANY RIGHTS GRANTED HEREUNDER, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + +7. GENERAL + +If any provision of this Agreement is invalid or unenforceable under +applicable law, it shall not affect the validity or enforceability of +the remainder of the terms of this Agreement, and without further +action by the parties hereto, such provision shall be reformed to the +minimum extent necessary to make such provision valid and enforceable. + +If Recipient institutes patent litigation against any entity +(including a cross-claim or counterclaim in a lawsuit) alleging that the +Program itself (excluding combinations of the Program with other software +or hardware) infringes such Recipient's patent(s), then such Recipient's +rights granted under Section 2(b) shall terminate as of the date such +litigation is filed. + +All Recipient's rights under this Agreement shall terminate if it +fails to comply with any of the material terms or conditions of this +Agreement and does not cure such failure in a reasonable period of +time after becoming aware of such noncompliance. If all Recipient's +rights under this Agreement terminate, Recipient agrees to cease use +and distribution of the Program as soon as reasonably practicable. +However, Recipient's obligations under this Agreement and any licenses +granted by Recipient relating to the Program shall continue and survive. + +Everyone is permitted to copy and distribute copies of this Agreement, +but in order to avoid inconsistency the Agreement is copyrighted and +may only be modified in the following manner. The Agreement Steward +reserves the right to publish new versions (including revisions) of +this Agreement from time to time. No one other than the Agreement +Steward has the right to modify this Agreement. The Eclipse Foundation +is the initial Agreement Steward. The Eclipse Foundation may assign the +responsibility to serve as the Agreement Steward to a suitable separate +entity. Each new version of the Agreement will be given a distinguishing +version number. The Program (including Contributions) may always be +Distributed subject to the version of the Agreement under which it was +received. In addition, after a new version of the Agreement is published, +Contributor may elect to Distribute the Program (including its +Contributions) under the new version. + +Except as expressly stated in Sections 2(a) and 2(b) above, Recipient +receives no rights or licenses to the intellectual property of any +Contributor under this Agreement, whether expressly, by implication, +estoppel or otherwise. All rights in the Program not expressly granted +under this Agreement are reserved. Nothing in this Agreement is intended +to be enforceable by any entity that is not a Contributor or Recipient. +No third-party beneficiary rights are created under this Agreement. + +Exhibit A - Form of Secondary Licenses Notice + +"This Source Code may also be made available under the following +Secondary Licenses when the conditions for such availability set forth +in the Eclipse Public License, v. 2.0 are satisfied: {name license(s), +version(s), and exceptions or additional permissions here}." + + Simply including a copy of this Agreement, including this Exhibit A + is not sufficient to license the Source Code under Secondary Licenses. + + If it is not possible or desirable to put the notice in a particular + file, then You may include the notice in a location (such as a LICENSE + file in a relevant directory) where a recipient would be likely to + look for such a notice. + + You may add additional accurate notices of copyright ownership. + + +===== Expat ===== + +Copyright (c) 1998-2000 Thai Open Source Software Center Ltd and Clark Cooper +Copyright (c) 2001-2025 Expat maintainers + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +===== Emscripten ===== + +Emscripten is available under 2 licenses, the MIT license and the +University of Illinois/NCSA Open Source License. + +Both are permissive open source licenses, with little if any +practical difference between them. + +The reason for offering both is that (1) the MIT license is +well-known, while (2) the University of Illinois/NCSA Open Source +License allows Emscripten's code to be integrated upstream into +LLVM, which uses that license, should the opportunity arise. + +The full text of both licenses follows. + +============================================================================== + +Copyright (c) 2010-2014 Emscripten authors, see AUTHORS file. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +============================================================================== + +Copyright (c) 2010-2014 Emscripten authors, see AUTHORS file. +All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the +"Software"), to deal with the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + + Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimers. + + Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following disclaimers + in the documentation and/or other materials provided with the + distribution. + + Neither the names of Mozilla, + nor the names of its contributors may be used to endorse + or promote products derived from this Software without specific prior + written permission. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE CONTRIBUTORS OR COPYRIGHT HOLDERS BE LIABLE FOR +ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS WITH THE SOFTWARE. + +============================================================================== + +This program uses portions of Node.js source code located in src/library_path.js, +in accordance with the terms of the MIT license. Node's license follows: + + """ + Copyright Joyent, Inc. and other Node contributors. All rights reserved. + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to + deal in the Software without restriction, including without limitation the + rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + sell copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + IN THE SOFTWARE. + """ + +The musl libc project is bundled in this repo, and it has the MIT license, see +system/lib/libc/musl/COPYRIGHT + +The third_party/ subdirectory contains code with other licenses. None of it is +used by default, but certain options use it (e.g., the optional closure compiler +flag will run closure compiler from third_party/). + + +===== DOMPurify ===== + + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/client/ui-primitives/package.json b/packages/client/ui-primitives/package.json index 6502b6564d..587cd95ee6 100644 --- a/packages/client/ui-primitives/package.json +++ b/packages/client/ui-primitives/package.json @@ -46,11 +46,14 @@ "mdast-util-math": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "micromark-util-sanitize-uri": "^2.0.1", - "mermaid": "11.16.0" + "mermaid": "11.16.0", + "@viz-js/viz": "3.30.0", + "dompurify": "3.4.11" }, "files": [ "lib/index.js", "lib/**/*.css", + "THIRD_PARTY_PREVIEW_NOTICES.txt", "lib/types/**/*.d.ts" ], "peerDependencies": { diff --git a/packages/client/ui-primitives/src/markdown/MermaidPreview.module.css b/packages/client/ui-primitives/src/markdown/MermaidPreview.module.css index f96c14418b..e21d84e284 100644 --- a/packages/client/ui-primitives/src/markdown/MermaidPreview.module.css +++ b/packages/client/ui-primitives/src/markdown/MermaidPreview.module.css @@ -24,3 +24,12 @@ visibility: hidden; pointer-events: none; } + +.frame { + display: block; + width: 100%; + height: 400px; + border: 0; + border-radius: 12px; + background: var(--dsw-static-neutral-00); +} diff --git a/packages/client/ui-primitives/src/markdown/MermaidPreview.tsx b/packages/client/ui-primitives/src/markdown/MermaidPreview.tsx index 5dadb6a6b3..dc330e9da8 100644 --- a/packages/client/ui-primitives/src/markdown/MermaidPreview.tsx +++ b/packages/client/ui-primitives/src/markdown/MermaidPreview.tsx @@ -1,51 +1,17 @@ -/** Read-only diagram preview with source fallback and per-source async ownership. */ +/** Mermaid image preview using the shared asynchronous source renderer. */ -import { useEffect, useState } from 'react' import { renderMermaid } from './mermaid.ts' -import css from './MermaidPreview.module.css' +import { SourcePreview } from './SourcePreview.tsx' +import type { PreviewLabels } from './SourcePreview.tsx' -/** Localized preview states; the diagram source remains verbatim. */ -export interface MermaidPreviewLabels { - diagram: string - loading: string - error: string -} - -type Result = { kind: 'ok'; code: string; src: string } | { kind: 'error'; code: string } - -/* v8 ignore next 3 -- closed-union backstop; only reached if a result is forged */ -function assertNever(value: never): never { - throw new Error(`unreachable Mermaid result: ${String(value)}`) -} +/** Localized Mermaid preview states; source remains verbatim. */ +export type MermaidPreviewLabels = PreviewLabels /** - * Display a complete Mermaid document on a light diagram canvas, or its source if rendering fails. - * @param props - Source and complete localized labels. Changing source discards the previous result. - * @returns A loading status, an inert SVG image, or an error with the original source. + * Display a complete Mermaid document as an inert SVG image. + * @param props - Source and complete localized labels. + * @returns A loading status, diagram image, or error with the original source. */ export function MermaidPreview({ code, labels }: { code: string; labels: MermaidPreviewLabels }) { - const [result, setResult] = useState(null) - useEffect(() => { - const controller = new AbortController() - void renderMermaid(code, controller.signal).then( - (src) => { if (!controller.signal.aborted) setResult({ kind: 'ok', code, src }) }, - () => { if (!controller.signal.aborted) setResult({ kind: 'error', code }) }, - ) - return () => { controller.abort() } - }, [code]) - - if (result?.code !== code) return
{labels.loading}
- switch (result.kind) { - case 'ok': - return
{labels.diagram}
- case 'error': - return ( -
-
{labels.error}
-
{code}
-
- ) - /* v8 ignore next -- closed-union backstop; only reached if a result is forged */ - default: return assertNever(result) - } + return } diff --git a/packages/client/ui-primitives/src/markdown/SourcePreview.tsx b/packages/client/ui-primitives/src/markdown/SourcePreview.tsx new file mode 100644 index 0000000000..376b69710a --- /dev/null +++ b/packages/client/ui-primitives/src/markdown/SourcePreview.tsx @@ -0,0 +1,56 @@ +/** Read-only diagram preview with source fallback and per-source async ownership. */ + +import { useEffect, useState } from 'react' +import css from './MermaidPreview.module.css' + +/** Localized preview states; the diagram source remains verbatim. */ +export interface PreviewLabels { + diagram: string + loading: string + error: string +} + +type Result = { kind: 'ok'; code: string; src: string } | { kind: 'error'; code: string } + +/* v8 ignore next 3 -- closed-union backstop; only reached if a result is forged */ +function assertNever(value: never): never { + throw new Error(`unreachable preview result: ${String(value)}`) +} + +/** + * Display a complete document or diagram, retaining the source when rendering fails. + * @param props - Source and complete localized labels. Changing source discards the previous result. + * @returns A loading status, an inert image or sandboxed document, or an error with the original source. + */ +export function SourcePreview({ code, labels, render, document: isDocument = false }: { + code: string + labels: PreviewLabels + render: (code: string, signal: AbortSignal) => string | Promise + document?: boolean +}) { + const [result, setResult] = useState(null) + useEffect(() => { + const controller = new AbortController() + void (async () => await render(code, controller.signal))().then( + (src) => { if (!controller.signal.aborted) setResult({ kind: 'ok', code, src }) }, + () => { if (!controller.signal.aborted) setResult({ kind: 'error', code }) }, + ) + return () => { controller.abort() } + }, [code, render]) + + if (result?.code !== code) return
{labels.loading}
+ switch (result.kind) { + case 'ok': + if (isDocument) return + `, new AbortController().signal) + const parsed = new DOMParser().parseFromString(doc, 'text/html') + expect(parsed.querySelector('h1')?.textContent).toBe('Example') + expect(parsed.querySelector('style')?.textContent).toBe('h1 { color: green }') + expect(parsed.querySelector('script, iframe, base, [href], [action], [formaction], [onload]')).toBeNull() + expect(parsed.querySelectorAll('meta[http-equiv]').length).toBe(1) + expect(parsed.querySelector('meta[http-equiv]')?.getAttribute('content')).toContain("default-src 'none'") + }) + + it('encodes SVG as an image rather than executable document markup', () => { + const code = '' + const parsed = new DOMParser().parseFromString(renderSvg(code, new AbortController().signal), 'text/html') + expect(parsed.querySelector('svg, script')).toBeNull() + expect(decodeURIComponent(parsed.querySelector('img')!.getAttribute('src')!)).toContain(code) + }) + + it.each(['', '', ''])('rejects non-SVG or malformed XML: %s', (code) => { + expect(() => renderSvg(code, new AbortController().signal)).toThrow('Invalid SVG document') + }) + + it.each([renderHtml, renderSvg])('does not prepare a cancelled document', (renderDocument) => { + const controller = new AbortController() + controller.abort() + expect(() => renderDocument(svg, controller.signal)).toThrow(controller.signal.reason) + }) +}) diff --git a/packages/client/ui-primitives/tests/graphviz-runtime.client.spec.ts b/packages/client/ui-primitives/tests/graphviz-runtime.client.spec.ts new file mode 100644 index 0000000000..354c0f9eaa --- /dev/null +++ b/packages/client/ui-primitives/tests/graphviz-runtime.client.spec.ts @@ -0,0 +1,35 @@ +// @vitest-environment jsdom + +import { afterEach, beforeEach, expect, it, vi } from 'vitest' + +const instance = vi.fn() +beforeEach(() => { + vi.resetModules() + instance.mockReset() + vi.doMock('@viz-js/viz', () => ({ instance })) +}) +afterEach(() => { vi.doUnmock('@viz-js/viz') }) + +it('retries failed initialization and reuses the loaded runtime', async () => { + const renderString = vi.fn().mockReturnValue('') + instance.mockRejectedValueOnce(new Error('WASM unavailable')).mockResolvedValue({ renderString }) + const { renderGraphviz } = await import('../src/markdown/graphviz.ts') + await expect(renderGraphviz('digraph {}', new AbortController().signal)).rejects.toThrow('WASM unavailable') + await expect(renderGraphviz('digraph {}', new AbortController().signal)).resolves.toContain('data:image/svg+xml') + await renderGraphviz('digraph {}', new AbortController().signal) + expect(instance).toHaveBeenCalledTimes(2) +}) + +it('skips layout after cancellation during runtime loading', async () => { + const pending = Promise.withResolvers<{ renderString: ReturnType }>() + instance.mockReturnValue(pending.promise) + const { renderGraphviz } = await import('../src/markdown/graphviz.ts') + const controller = new AbortController() + const result = renderGraphviz('digraph {}', controller.signal) + const rejection = expect(result).rejects.toMatchObject({ name: 'AbortError' }) + controller.abort() + const renderString = vi.fn() + pending.resolve({ renderString }) + await rejection + expect(renderString).not.toHaveBeenCalled() +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d1c1098a48..6f08b3708d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3305,12 +3305,18 @@ importers: '@types/react-dom': specifier: ~18.3.0 version: 18.3.7(@types/react@18.3.31) + '@viz-js/viz': + specifier: 3.30.0 + version: 3.30.0 anser: specifier: ^2.3.5 version: 2.3.5 clsx: specifier: ^2.0.0 version: 2.1.1 + dompurify: + specifier: 3.4.11 + version: 3.4.11 katex: specifier: ^0.16.47 version: 0.16.47 @@ -14255,6 +14261,9 @@ packages: '@vitest/utils@4.1.8': resolution: {integrity: sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==} + '@viz-js/viz@3.30.0': + resolution: {integrity: sha512-2zmcP55QY44uQfZ+GvgNHmGd8S6KUD+2eMur9AbYybSzrOd/m8nMUg0ZircJNiJRKGgVBKAE8kh0hAwpUFUFQQ==} + '@vscode/ripgrep-darwin-arm64@1.18.0': resolution: {integrity: sha512-r3ktHSvbFycQNF6sl7sNDPocpsI7J+mEzh1IaZFkY0spm3k2Z9t8hPAeOK7+p0l6p6/swkQC14XWX01low+94Q==} cpu: [arm64] @@ -20864,6 +20873,8 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.0 + '@viz-js/viz@3.30.0': {} + '@vscode/ripgrep-darwin-arm64@1.18.0': optional: true diff --git a/scripts/check-workspace-constraints.spec.ts b/scripts/check-workspace-constraints.spec.ts index 5ac40d3c99..d3dcddb787 100644 --- a/scripts/check-workspace-constraints.spec.ts +++ b/scripts/check-workspace-constraints.spec.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest' import { checkDshFamilyVersion, + checkWorkspaceManifest, checkExperimentalDependencyIsolation, checkExperimentalManifest, expectedDshPackageFiles, @@ -121,3 +122,16 @@ describe('package payload constraints', () => { ]) }) }) + +it('requires preview license notices in the published UI primitives payload', () => { + const name = '@deepseek-ai/dsh-client-ui-primitives' + const files = expectedDshPackageFiles({ name }) + expect(files).toContain('THIRD_PARTY_PREVIEW_NOTICES.txt') + const check = (entries: readonly string[]) => checkWorkspaceManifest({ + dir: 'packages/client/ui-primitives', manifest: { name, files: [...entries] }, + }).filter(message => message.includes('package.json files')) + expect(check(files)).toEqual([]) + expect(check(files.filter(file => file !== 'THIRD_PARTY_PREVIEW_NOTICES.txt'))).toEqual([ + expect.stringContaining('package.json files must be'), + ]) +}) diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index e7b0e948e8..73f32d72a6 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -154,7 +154,7 @@ const packageFileExtras: Readonly> = { // them through its own CSS pipeline, so the sheets are published artifacts. // The glob covers whichever sheets a package emits; sourcemaps stay // unpublished, as everywhere else in the repository. - '@deepseek-ai/dsh-client-ui-primitives': ['lib/**/*.css'], + '@deepseek-ai/dsh-client-ui-primitives': ['lib/**/*.css', 'THIRD_PARTY_PREVIEW_NOTICES.txt'], '@deepseek-ai/dsh-client-ui-dockkit': ['lib/**/*.css'], '@deepseek-ai/dsh-client-web': ['lib/**/*.css'], '@deepseek-ai/dsh-client-ui-theme': ['lib/styles'], diff --git a/scripts/gen-third-party-notices.spec.ts b/scripts/gen-third-party-notices.spec.ts index fabce6e8c9..46b1b35d11 100644 --- a/scripts/gen-third-party-notices.spec.ts +++ b/scripts/gen-third-party-notices.spec.ts @@ -4,6 +4,7 @@ import { tmpdir } from 'node:os' import { describe, expect, it } from 'vitest' import { CLAUDE_AGENT_SDK_PACKAGE, + assertPreviewDistribution, assertRuntimeLicenses, claudeDistributionFromManifest, collectPythonDependencies, @@ -395,3 +396,26 @@ describe('manifestPatterns', () => { ]) }) }) + +describe('preview distribution licenses', () => { + const dependencies = { mermaid: '11.16.0', '@viz-js/viz': '3.30.0', dompurify: '3.4.11' } + const sources = [ + 'pkg:docker/emscripten/emsdk@5.0.7?platform=linux%2Famd64', + 'https://github.com/libexpat/libexpat/releases/download/R_2_8_4/expat-2.8.4.tar.gz', + 'https://gitlab.com/api/v4/projects/4207231/packages/generic/graphviz-releases/16.0.0/graphviz-16.0.0.tar.gz', + ] + it('accepts the reviewed wrapper and native sources', () => { + expect(() => { assertPreviewDistribution(dependencies, sources) }).not.toThrow() + const notices = readFileSync(resolve(root, 'packages/client/ui-primitives/THIRD_PARTY_PREVIEW_NOTICES.txt'), 'utf8') + expect(notices).toContain('Eclipse Public License - v 2.0') + expect(notices).toContain(sources[2]) + expect(notices).toContain(readFileSync(resolve(root, 'packages/client/ui-primitives/node_modules/mermaid/LICENSE'), 'utf8').trim()) + expect(notices).toContain(readFileSync(resolve(root, 'packages/client/ui-primitives/node_modules/dompurify/LICENSE'), 'utf8').trim()) + }) + it.each(Object.keys(dependencies))('rejects an unreviewed %s upgrade', (name) => { + expect(() => { assertPreviewDistribution({ ...dependencies, [name]: '99.0.0' }, sources) }).toThrow('preview notices: review') + }) + it.each([sources.slice(1), [...sources, 'https://example.com/new-library'], [...sources.slice(0, 2), 'graphviz-next']])('rejects changed embedded sources', (...uris) => { + expect(() => { assertPreviewDistribution(dependencies, uris) }).toThrow('native sources') + }) +}) diff --git a/scripts/gen-third-party-notices.ts b/scripts/gen-third-party-notices.ts index 9e83956e5e..2130723202 100644 --- a/scripts/gen-third-party-notices.ts +++ b/scripts/gen-third-party-notices.ts @@ -685,6 +685,28 @@ ${rows.join('\n')} ` } +/** + * Require renewed license review when a preview dependency or embedded source changes. + * @param dependencies - UI primitives' browser build dependencies. + * @param sources - Native source URIs from the installed Viz.js build provenance. + * @returns Nothing; throws when a version or native source differs from the reviewed distribution. + */ +export function assertPreviewDistribution(dependencies: Record, sources: readonly string[]): void { + for (const [name, version] of Object.entries({ mermaid: '11.16.0', '@viz-js/viz': '3.30.0', dompurify: '3.4.11' })) { + if (dependencies[name] !== version) { + throw new Error(`preview notices: review ${name} ${String(dependencies[name])} licenses and update THIRD_PARTY_PREVIEW_NOTICES.txt`) + } + } + const expected = [ + 'pkg:docker/emscripten/emsdk@5.0.7?platform=linux%2Famd64', + 'https://github.com/libexpat/libexpat/releases/download/R_2_8_4/expat-2.8.4.tar.gz', + 'https://gitlab.com/api/v4/projects/4207231/packages/generic/graphviz-releases/16.0.0/graphviz-16.0.0.tar.gz', + ] + if (sources.length !== expected.length || expected.some(uri => !sources.includes(uri))) { + throw new Error('preview notices: review the changed Viz.js native sources and their distribution licenses') + } +} + /** * Render the complete notices document. * @returns The exact bytes THIRD_PARTY_NOTICES.md must hold after resolving browser inputs. @@ -695,6 +717,16 @@ export async function render(): Promise { // the manifests map it was resolved from; render() owns that single load. workspaceLinkedManifestCache.clear() const { manifests, names } = loadWorkspaceManifests() + const previewPackage = 'packages/client/ui-primitives' + const provenance = JSON.parse(readFileSync(resolve(root, previewPackage, 'node_modules/@viz-js/viz/lib/provenance.json'), 'utf8')) as { + predicate: { buildDefinition: { resolvedDependencies: { uri: string }[] } } + } + const previewDependencies = manifests.get(`${previewPackage}/package.json`)?.devDependencies + if (previewDependencies === undefined) throw new Error('preview notices: missing UI primitives browser dependencies') + assertPreviewDistribution( + previewDependencies, + provenance.predicate.buildDefinition.resolvedDependencies.map(source => source.uri), + ) const npm = collectNpmDeps(manifests, names, browser) const runtimeDeps = npm.filter(dep => dep.runtime) const devDeps = npm.filter(dep => !dep.runtime) @@ -735,6 +767,8 @@ External packages installed for runtime use or distributed inside the prebuilt b ${renderNpmTable(runtimeDeps)} +The Markdown preview distribution also contains Graphviz 16.0.0 (EPL-2.0), Expat 2.8.4 (MIT), and Emscripten 5.0.7 runtime code (MIT/NCSA) inside \`@viz-js/viz\` 3.30.0. The wrapper's MIT metadata does not relicense these components. [Preview notices](packages/client/ui-primitives/THIRD_PARTY_PREVIEW_NOTICES.txt) preserve their full license texts and Graphviz source availability, together with Mermaid's MIT and DOMPurify's selected Apache-2.0 terms. The UI primitives npm package includes this file; the Web build emits it as \`preview-third-party-notices.txt\`. + pnpm applies local patches to the following packages at install time, so shipped artifacts carry modified copies; each patch file is the complete record of the modification: ${patchedLines.join('\n')} From fb84576ea7a4cae41b52997901d722ab02cbc2f5 Mon Sep 17 00:00:00 2001 From: yudshj Date: Wed, 9 Sep 2026 13:11:55 +0800 Subject: [PATCH 041/111] refactor(web): unify markdown preview components and notes --- .../2026-09-07-web-mermaid-preview.i18n.yaml | 6 --- .../feature/2026-09-07-web-mermaid-preview.md | 35 ------------- .../2026-09-07-web-mermaid-preview.zh.md | 35 ------------- ...6-09-09-markdown-static-previews.i18n.yaml | 4 +- .../2026-09-09-markdown-static-previews.md | 24 ++++++--- .../2026-09-09-markdown-static-previews.zh.md | 24 ++++++--- packages/client/locale/src/locales/en.ts | 4 +- packages/client/locale/src/locales/zh.ts | 4 +- .../ui-chat/src/client/markdown-labels.ts | 25 ++++------ .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 7 ++- packages/client/ui-primitives/README.zh.md | 7 ++- packages/client/ui-primitives/src/index.ts | 2 - .../src/markdown/CodeBlock.module.css | 50 +++++++++---------- .../src/markdown/MermaidPreview.tsx | 17 ------- ...ew.module.css => SourcePreview.module.css} | 0 .../src/markdown/SourcePreview.tsx | 2 +- .../ui-primitives/src/markdown/mermaid.ts | 2 +- .../ui-primitives/src/markdown/render.tsx | 30 ++++------- .../tests/fence-previews.client.spec.tsx | 22 ++++---- ...pec.tsx => source-preview.client.spec.tsx} | 43 ++++++++++++---- 21 files changed, 144 insertions(+), 203 deletions(-) delete mode 100644 .agents/notes/implemented/feature/2026-09-07-web-mermaid-preview.i18n.yaml delete mode 100644 .agents/notes/implemented/feature/2026-09-07-web-mermaid-preview.md delete mode 100644 .agents/notes/implemented/feature/2026-09-07-web-mermaid-preview.zh.md delete mode 100644 packages/client/ui-primitives/src/markdown/MermaidPreview.tsx rename packages/client/ui-primitives/src/markdown/{MermaidPreview.module.css => SourcePreview.module.css} (100%) rename packages/client/ui-primitives/tests/{mermaid-preview.client.spec.tsx => source-preview.client.spec.tsx} (73%) diff --git a/.agents/notes/implemented/feature/2026-09-07-web-mermaid-preview.i18n.yaml b/.agents/notes/implemented/feature/2026-09-07-web-mermaid-preview.i18n.yaml deleted file mode 100644 index 190b6ffb3e..0000000000 --- a/.agents/notes/implemented/feature/2026-09-07-web-mermaid-preview.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-07-web-mermaid-preview.md -2026-09-07-web-mermaid-preview.md: 246b38e764e1e5812427f935c4a41c28f0fa24a0 -2026-09-07-web-mermaid-preview.zh.md: c1dd25c1b6dc21c36d92e0d7c1477fcea9ae99c2 diff --git a/.agents/notes/implemented/feature/2026-09-07-web-mermaid-preview.md b/.agents/notes/implemented/feature/2026-09-07-web-mermaid-preview.md deleted file mode 100644 index 246b38e764..0000000000 --- a/.agents/notes/implemented/feature/2026-09-07-web-mermaid-preview.md +++ /dev/null @@ -1,35 +0,0 @@ -# Agent Note: Mermaid previews in Chat code fences - -Status: implemented - -English | [中文](2026-09-07-web-mermaid-preview.zh.md) - -## Problem - -Assistant replies can describe diagrams in Mermaid code fences, but readers must interpret the source or copy it to another renderer. Rendering must handle incomplete streaming input and untrusted diagram content while remaining reusable outside Chat. - -## Decision - -Chat enables settled fence previews through `MarkdownLabels.mermaid`. The shared Markdown renderer uses the parsed fence language; streaming fences and consumers without those labels retain code display. The static [UI primitives package](../../../../packages/client/ui-primitives/README.md) owns `MermaidPreview`, which accepts source and localized labels without Session, file, or Cordis dependencies. `CodeBlock.preview` owns view switching and source copying. Previews omit the language banner and expose compact icon actions on hover or keyboard focus; devices with any touch input keep those actions visible below the diagram. Switching to source hides the mounted preview, so returning preserves completed rendering and pending work. Source and preview share the same focused toggle button, and copying always reads the source prop. - -Mermaid loads on demand. Its public render API serializes diagram work, and each call removes its temporary measurement DOM in `finally`. Strict security, disabled HTML labels, the neutral theme, and error-rendering policy cannot be overridden by diagram configuration. Generated SVG is displayed as an image without installing diagram links or scripts. Intrinsic dimensions come from the SVG viewBox; large diagrams shrink to fit, and the canvas stays light in both application themes. - -Source replacement and unmounting cancel result publication. Cancellation before runtime loading completes prevents rendering; an active Mermaid render finishes and releases its DOM but cannot publish to a cancelled component. Failures show a localized error and the original source. Replacing invalid source with a valid diagram recovers the preview. - -## Alternatives considered - -**Keep the code banner above diagrams.** A language label and permanent text controls distract from the diagram. Overlay actions retain source access without reserving a title row. - -**Render each streamed chunk.** Incomplete diagrams are frequently invalid, and repeated layout work competes with text streaming. The existing message-settlement boundary provides a complete source value. - -**Put rendering inside Chat or add a general preview registry.** A shared primitive with plain props satisfies reuse without another registry or feature-plugin dependency. This follows the [shared-control rule](../architecture/2026-09-05-shared-client-control-primitives.md). - -**Insert interactive SVG into the message.** The requested preview needs diagram display and source access. Displaying the result as an image keeps diagram links and event handlers inactive. - -The [static fence preview decision](2026-09-09-markdown-static-previews.md) owns Graphviz, SVG, and HTML sandboxing and shared preview license distribution. - -## Consequences - -The feature changes presentation without changing persisted messages, provider requests, tools, or Host APIs. Mermaid adds lazily loaded browser assets. Rendering still runs on the browser thread, and work already handed to the library cannot be interrupted. The initial feature has no editing, export, zoom controls, or interactive diagram links. - -Component tests cover delayed completion, stale success and failure, unmounting, source fallback, and copying. The keyless [browser scenario](../../../../apps/web/tests/markdown-mermaid.e2e.ts) exercises the assembled Chat with Chinese flowcharts, sequence diagrams, malformed source, configuration overrides, and English/Chinese UI snapshots. diff --git a/.agents/notes/implemented/feature/2026-09-07-web-mermaid-preview.zh.md b/.agents/notes/implemented/feature/2026-09-07-web-mermaid-preview.zh.md deleted file mode 100644 index c1dd25c1b6..0000000000 --- a/.agents/notes/implemented/feature/2026-09-07-web-mermaid-preview.zh.md +++ /dev/null @@ -1,35 +0,0 @@ -# Agent Note: Chat 代码 fence 中的 Mermaid 预览 - -Status: implemented - -[English](2026-09-07-web-mermaid-preview.md) | 中文 - -## 问题 - -Assistant 回复可以在 Mermaid 代码 fence 中描述图表,但读者必须理解源码或把它复制到其他渲染器。渲染需要处理不完整的流式输入与不可信图表内容,同时保持在 Chat 之外的可复用性。 - -## 决策 - -Chat 通过 `MarkdownLabels.mermaid` 启用已定稿 fence 预览。共享 Markdown 渲染器使用解析后的 fence 语言;流式 fence 和未传入这些 label 的调用方保留代码显示。静态 [UI primitives 包](../../../../packages/client/ui-primitives/README.zh.md)拥有 `MermaidPreview`,它接收源码与本地化 label,不依赖 Session、文件或 Cordis。`CodeBlock.preview` 拥有视图切换与源码复制。预览省略语言标题栏,悬停或键盘聚焦时显示紧凑的图标操作;具备任何触控输入的设备均在图表下方保持操作可见。切换到源码时隐藏已挂载的预览,因此返回时保留已完成的渲染与进行中的工作。源码与预览使用同一个已聚焦的切换按钮,复制始终读取源码 prop。 - -Mermaid 按需加载。它的公开 render API 串行执行图表工作,每次调用在 `finally` 中移除临时测量 DOM。图表配置无法覆盖严格安全模式、禁用 HTML label、中性主题和错误渲染策略。生成的 SVG 以图片显示,不安装图内链接或脚本。固有尺寸取自 SVG viewBox;大图缩小以适应宽度,画布在两种应用主题下均保持浅色。 - -替换源码与卸载组件会取消结果发布。在运行时加载完成前取消可阻止渲染;已经开始的 Mermaid 渲染会完成并释放 DOM,但无法向已取消的组件发布结果。失败时显示本地化错误和原始源码。用有效图表替换非法源码可恢复预览。 - -## 考虑过的替代方案 - -**在图表上方保留代码标题栏。** 语言标记和常驻的文字操作分散了对图表的注意力。浮层操作无需占用标题行即可保留源码入口。 - -**渲染每个流式分片。** 不完整的图表经常无效,反复布局也会与文本流式输出竞争。已有的消息定稿边界提供完整源码值。 - -**把渲染放在 Chat 内或增加通用预览注册表。** 接收普通 prop 的共享原语无需另一套注册表或功能插件依赖即可满足复用。这遵循[共享控件规则](../architecture/2026-09-05-shared-client-control-primitives.zh.md)。 - -**把可交互 SVG 插入消息。** 此次预览需求只需要图表显示与源码访问。将结果显示为图片后,图内链接和事件处理器不会生效。 - -[静态 fence 预览决策](2026-09-09-markdown-static-previews.zh.md)负责 Graphviz、SVG、HTML 的 sandbox 及共享预览许可证分发。 - -## 后果 - -功能只改变展示,不改变持久化消息、提供者请求、工具或 Host API。Mermaid 增加按需加载的浏览器资源。渲染仍在浏览器线程上执行,已交给库的工作无法中断。首版不包含编辑、导出、缩放控件或可交互图内链接。 - -组件测试覆盖延迟完成、过期成功与失败、卸载、源码回退和复制。无密钥[浏览器场景](../../../../apps/web/tests/markdown-mermaid.e2e.ts)通过完整 Chat 组合验证中文流程图、时序图、非法源码、配置覆盖以及英文/中文 UI 快照。 diff --git a/.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.i18n.yaml b/.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.i18n.yaml index 6cb3a179eb..c70073de17 100644 --- a/.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.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-09-markdown-static-previews.md -2026-09-09-markdown-static-previews.md: fd3772cabc2160f07e79b06dffa4374fe104cbd1 -2026-09-09-markdown-static-previews.zh.md: 92f4b030879acaeb484eed33e078574750a1801e +2026-09-09-markdown-static-previews.md: a6d1802546e9e976da7ae4d1ecc024924414e9a4 +2026-09-09-markdown-static-previews.zh.md: 42488547efafdbf5b1944b1300af13f359365d74 diff --git a/.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.md b/.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.md index fd3772cabc..a6d1802546 100644 --- a/.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.md +++ b/.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.md @@ -6,26 +6,38 @@ English | [中文](2026-09-09-markdown-static-previews.zh.md) ## Problem -Readers need to see DOT diagrams, SVG artwork, and HTML examples directly in Assistant replies. These sources are untrusted, and a renderer's npm license field may omit compiled components with separate distribution obligations. +Readers need to see Mermaid and DOT diagrams, SVG artwork, and HTML examples directly in Assistant replies without interpreting source or copying it to another renderer. These sources are untrusted, and a renderer's npm license field may omit compiled components with separate distribution obligations. ## Decision -The shared Markdown renderer enables settled `graphviz`/`dot`, `svg`, and `html` fences through localized `MarkdownLabels.preview`. `CodeBlock.preview` owns default visualization, source switching, and verbatim copying. `SourcePreview` owns loading, failure, and cancellation of stale result publication for both these documents and the [Mermaid preview](2026-09-07-web-mermaid-preview.md). Consumers without preview labels and streaming messages retain code. +The shared Markdown renderer enables settled `mermaid`, `graphviz`/`dot`, `svg`, and `html` fences through localized `MarkdownLabels.preview` and the parsed fence language. The [UI primitives package](../../../../packages/client/ui-primitives/README.md) owns `SourcePreview`, which accepts a renderer, source, and labels without Session, file, or Cordis dependencies. Mermaid and Graphviz each supply a `.ts` renderer to that same component. Consumers without preview labels and streaming messages retain code. -HTML uses DOMPurify with navigation attributes and document-loading elements forbidden, followed by an opaque `sandbox=""` iframe. A trusted CSP precedes source markup and permits only inline CSS, data images, and data fonts. Scripts, external resources, child frames, form submission, and same-origin access are unavailable. SVG is parsed as XML and placed in an image inside the frame, so SVG scripts and links never become active. Frames have a fixed scrollable viewport: measuring their content would require additional origin access or trusted frame scripts. Mermaid keeps its existing strict renderer and inert image presentation. +`CodeBlock.preview` owns default visualization, source switching, and verbatim copying. Previews omit the language banner and expose compact icon actions on hover or keyboard focus; devices with any touch input keep actions visible below the diagram. Switching to source hides the mounted preview, preserving completed rendering and pending work. Both views use the same focused toggle button, and copying always reads the source prop. + +`SourcePreview` owns loading, failure, and cancellation of stale result publication. Source replacement and unmounting cancel publication; cancellation before runtime loading completes skips layout. Failures show a localized error and the original source, and replacing invalid source with valid input recovers the preview. + +Mermaid loads on demand. Its public render API serializes diagram work, and each call removes its temporary measurement DOM in `finally`. Strict security, disabled HTML labels, the neutral theme, and error-rendering policy cannot be overridden by diagram configuration. Generated SVG is displayed as an image without installing links or scripts. Intrinsic dimensions come from the SVG viewBox; large diagrams shrink to fit, and the canvas stays light in both application themes. + +HTML uses DOMPurify with navigation attributes and document-loading elements forbidden, followed by an opaque `sandbox=""` iframe. A trusted CSP precedes source markup and permits only inline CSS, data images, and data fonts. Scripts, external resources, child frames, form submission, and same-origin access are unavailable. SVG is parsed as XML and placed in an image inside the frame, so SVG scripts and links never become active. Frames have a fixed scrollable viewport: measuring their content would require additional origin access or trusted frame scripts. Graphviz uses the pinned, unmodified `@viz-js/viz` 3.30.0 WebAssembly distribution with the `dot` layout engine. Its npm MIT declaration covers the wrapper; its build provenance identifies Graphviz 16.0.0 (EPL-2.0), Expat 2.8.4 (MIT), and Emscripten 5.0.7 (MIT/NCSA). Distribution retains these component terms and the exact Graphviz source download under EPL-2.0 section 3.1. [Full preview notices](../../../../packages/client/ui-primitives/THIRD_PARTY_PREVIEW_NOTICES.txt) also retain Mermaid's MIT text and select Apache-2.0 for DOMPurify. The UI primitives package ships the notices and the Web build emits the same bytes alongside its assets. License review covers embedded payloads separately from the general permissive npm-metadata policy. ## Alternatives considered +**Keep the code banner above diagrams.** A language label and permanent text controls distract from the diagram. Overlay actions retain source access without reserving a title row. + +**Render each streamed chunk.** Incomplete diagrams are frequently invalid, and repeated layout work competes with text streaming. Message settlement provides complete source. + +**Put rendering inside Chat or add a general preview registry.** A shared primitive with plain props satisfies reuse without another registry or feature-plugin dependency. This follows the [shared-control rule](../architecture/2026-09-05-shared-client-control-primitives.md). + **Execute HTML scripts in a frame.** Static chat examples do not need script execution. An empty sandbox plus sanitization and CSP gives the preview fewer capabilities and avoids introducing a frame messaging protocol. -**Insert rendered markup into Chat.** HTML styles and executable SVG would share the application document. Iframes isolate layout and origin; SVG image mode additionally disables SVG behavior. +**Insert rendered markup into Chat.** The requested preview needs diagram display and source access; HTML styles and executable SVG would share the application document. Iframes isolate layout and origin; SVG image mode additionally disables SVG behavior. **Treat Viz.js as MIT-only or use a remote Graphviz service.** The compiled Graphviz license still applies locally; a remote renderer would send conversation content off-device. The bundled renderer retains source availability and legal notices without network rendering. ## Consequences -No Session event, prompt, or tool changes. Inline HTML styles work, while interactive scripts, external assets, and links do not. Graphviz adds a lazy WebAssembly asset and synchronous browser-thread layout; source cancellation prevents publication but cannot preempt active layout. Mermaid's existing layout limitations remain documented in its owning note. +The feature changes presentation without changing persisted messages, provider requests, tools, or Host APIs. Inline HTML styles work, while interactive scripts, external assets, and links do not. Mermaid and Graphviz add lazy browser assets and run layout on the browser thread. Cancellation cannot preempt active layout; Mermaid finishes and releases its measurement DOM even when its result cannot be published. The previews have no editing, export, zoom controls, or interactive diagram links. -Component tests cover source copying, default preview, fallback, cancellation, and recovery. The keyless [browser scenario](../../../../apps/web/tests/markdown-mermaid.e2e.ts) verifies opaque frames, real image decoding, source toggling, blocked script/navigation/resource attempts, locale snapshots, and served license text. Notices checks pin the reviewed wrapper and native provenance so upgrades require renewed review. +Component tests cover source copying, default preview, delayed completion, stale success and failure, unmounting, fallback, cancellation, runtime loading failure, and recovery. The keyless [browser scenario](../../../../apps/web/tests/markdown-mermaid.e2e.ts) verifies Chinese Mermaid flowcharts, sequence diagrams, malformed source, configuration overrides, opaque frames, real image decoding, source toggling, blocked script/navigation/resource attempts, English/Chinese UI snapshots, and served license text. Notices checks pin the reviewed wrapper and native provenance so upgrades require renewed review. diff --git a/.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.zh.md b/.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.zh.md index 92f4b03087..42488547ef 100644 --- a/.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.zh.md +++ b/.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.zh.md @@ -6,26 +6,38 @@ Status: implemented ## Problem -读者需要直接在 Assistant 回复中查看 DOT 图表、SVG 图像和 HTML 示例。这些源码不可信,而渲染器的 npm 许可证字段可能遗漏编译组件各自的分发义务。 +读者需要直接在 Assistant 回复中查看 Mermaid 和 DOT 图表、SVG 图像和 HTML 示例,而不必解读源码或复制到其他渲染器。这些源码不可信,而渲染器的 npm 许可证字段可能遗漏编译组件各自的分发义务。 ## Decision -共享 Markdown 渲染器通过本地化的 `MarkdownLabels.preview` 启用定稿后的 `graphviz`/`dot`、`svg` 和 `html` fence。`CodeBlock.preview` 负责默认可视化、源码切换与原样复制。`SourcePreview` 为这些文档及 [Mermaid 预览](2026-09-07-web-mermaid-preview.zh.md)负责加载、失败和取消过期结果发布。未提供预览文案的调用方与流式消息保留代码显示。 +共享 Markdown 渲染器通过本地化的 `MarkdownLabels.preview` 和解析后的 fence 语言启用定稿后的 `mermaid`、`graphviz`/`dot`、`svg` 和 `html` fence。[UI primitives 包](../../../../packages/client/ui-primitives/README.zh.md)拥有 `SourcePreview`,它接收渲染器、源码和文案,不依赖 Session、文件或 Cordis。Mermaid 与 Graphviz 各自向同一组件提供 `.ts` 渲染器。未提供预览文案的调用方与流式消息保留代码显示。 -HTML 先由 DOMPurify 清理,禁止导航属性与文档加载元素,再进入不透明来源的 `sandbox=""` iframe。可信 CSP 位于源码标记之前,仅允许内联 CSS、data 图片与 data 字体。脚本、外部资源、子 iframe、表单提交与同源访问均不可用。SVG 按 XML 解析后作为图片放进 iframe,因此 SVG 脚本与链接不会激活。iframe 使用固定的可滚动视口:测量内容需要额外的来源访问权限或可信 iframe 脚本。Mermaid 保留既有的严格渲染器与不可执行图片展示。 +`CodeBlock.preview` 负责默认可视化、源码切换与原样复制。预览省略语言横幅,在悬停或键盘聚焦时显示紧凑的图标操作;具有任何触控输入的设备始终在图表下方显示操作。切换到源码会隐藏已挂载的预览,保留完成的渲染和待完成的工作。两种视图使用同一个保持焦点的切换按钮,复制始终读取源码属性。 + +`SourcePreview` 负责加载、失败和取消过期结果发布。替换源码和卸载组件会取消结果发布;在运行时加载完成前取消会跳过布局。失败时显示本地化错误与原始源码,用有效输入替换无效源码后可以恢复预览。 + +Mermaid 按需加载。其公开渲染 API 串行执行图表工作,每次调用都在 `finally` 中删除临时测量 DOM。图表配置无法覆盖严格安全模式、禁用 HTML 标签、中性色主题和错误渲染策略。生成的 SVG 作为图片显示,不安装链接或脚本。固有尺寸来自 SVG viewBox;大图缩小以适应宽度,两种应用主题下的画布均保持浅色。 + +HTML 先由 DOMPurify 清理,禁止导航属性与文档加载元素,再进入不透明来源的 `sandbox=""` iframe。可信 CSP 位于源码标记之前,仅允许内联 CSS、data 图片与 data 字体。脚本、外部资源、子 iframe、表单提交与同源访问均不可用。SVG 按 XML 解析后作为图片放进 iframe,因此 SVG 脚本与链接不会激活。iframe 使用固定的可滚动视口:测量内容需要额外的来源访问权限或可信 iframe 脚本。 Graphviz 使用固定且未修改的 `@viz-js/viz` 3.30.0 WebAssembly 发布包,布局引擎为 `dot`。其 npm MIT 声明覆盖包装层;构建来源记录标明 Graphviz 16.0.0(EPL-2.0)、Expat 2.8.4(MIT)与 Emscripten 5.0.7(MIT/NCSA)。分发保留各组件条款,并按 EPL-2.0 第 3.1 节提供准确的 Graphviz 源码下载地址。[完整预览声明](../../../../packages/client/ui-primitives/THIRD_PARTY_PREVIEW_NOTICES.txt)还保留 Mermaid 的 MIT 文本,并为 DOMPurify 选择 Apache-2.0。UI primitives 包携带该声明,Web 构建在资源旁输出相同字节。内嵌产物的许可证检查独立于通用的宽松 npm 元数据策略。 ## Alternatives considered +**在图表上方保留代码横幅。** 语言标签和常驻文字控件会分散对图表的注意力。浮层操作保留源码访问,不占用标题行。 + +**渲染每个流式片段。** 未完成的图表通常无效,反复布局会与文本流式输出争用资源。消息定稿可提供完整源码。 + +**在 Chat 内部渲染,或添加通用预览注册表。** 使用普通属性的共享基础组件即可满足复用,无需另加注册表或功能插件依赖。这遵循[共享控件规则](../architecture/2026-09-05-shared-client-control-primitives.zh.md)。 + **在 iframe 内执行 HTML 脚本。** 静态聊天示例不需要脚本执行。空 sandbox、清理与 CSP 降低预览权限,也避免引入 iframe 消息协议。 -**把渲染标记插入 Chat。** HTML 样式和可执行 SVG 会与应用共享文档。iframe 隔离布局与来源,SVG 图片模式额外禁用 SVG 行为。 +**把渲染标记插入 Chat。** 所需预览只需显示图表和访问源码;HTML 样式和可执行 SVG 会与应用共享文档。iframe 隔离布局与来源,SVG 图片模式额外禁用 SVG 行为。 **将 Viz.js 视为仅使用 MIT,或使用远程 Graphviz 服务。** 本地编译的 Graphviz 仍受其许可证约束;远程渲染会把对话内容发送到设备之外。内置渲染器保留源码可获取性与法律声明,无需网络渲染。 ## Consequences -不改变 Session 事件、提示词或工具。HTML 内联样式可用,交互脚本、外部资源和链接不可用。Graphviz 增加按需加载的 WebAssembly 资源与浏览器线程同步布局;源码取消可以阻止结果发布,但无法抢占进行中的布局。Mermaid 既有布局限制仍记录在其所属决策中。 +该功能改变呈现,不改变持久化消息、provider 请求、工具或 Host API。HTML 内联样式可用,交互脚本、外部资源和链接不可用。Mermaid 和 Graphviz 增加按需加载的浏览器资源,并在浏览器线程上执行布局。取消无法抢占进行中的布局;即使结果无法发布,Mermaid 仍会完成并释放测量 DOM。预览不提供编辑、导出、缩放控件或交互式图表链接。 -组件测试覆盖源码复制、默认预览、回退、取消与恢复。无密钥[浏览器场景](../../../../apps/web/tests/markdown-mermaid.e2e.ts)验证不透明来源 iframe、真实图片解码、源码切换、脚本/导航/资源请求被阻止、本地化快照和已提供的许可证文本。声明检查固定已审查的包装层与原生构建来源,升级时须重新审查。 +组件测试覆盖源码复制、默认预览、延迟完成、过期成功与失败、卸载、回退、取消、运行时加载失败与恢复。无密钥[浏览器场景](../../../../apps/web/tests/markdown-mermaid.e2e.ts)验证中文 Mermaid 流程图、时序图、无效源码、配置覆盖、不透明来源 iframe、真实图片解码、源码切换、脚本/导航/资源请求被阻止、中英文 UI 快照和已提供的许可证文本。声明检查固定已审查的包装层与原生构建来源,升级时须重新审查。 diff --git a/packages/client/locale/src/locales/en.ts b/packages/client/locale/src/locales/en.ts index 1d97566db1..374de7c7b0 100644 --- a/packages/client/locale/src/locales/en.ts +++ b/packages/client/locale/src/locales/en.ts @@ -44,8 +44,8 @@ export const en = { 'markdown.preview.loading': 'Rendering preview…', 'markdown.preview.error': 'Unable to render this preview. The source is shown below.', 'markdown.mermaid.diagram': 'Mermaid diagram', - 'markdown.mermaid.preview': 'Preview', - 'markdown.mermaid.source': 'Source', + 'markdown.preview.preview': 'Preview', + 'markdown.preview.source': 'Source', 'markdown.mermaid.loading': 'Rendering diagram…', 'markdown.mermaid.error': 'Unable to render this diagram. The source is shown below.', 'markdown.truncatedCharacters': '… truncated at {total} characters', diff --git a/packages/client/locale/src/locales/zh.ts b/packages/client/locale/src/locales/zh.ts index a799548c28..df1aa1d19e 100644 --- a/packages/client/locale/src/locales/zh.ts +++ b/packages/client/locale/src/locales/zh.ts @@ -42,8 +42,8 @@ export const zh = { 'markdown.preview.loading': '正在渲染预览…', 'markdown.preview.error': '无法渲染此预览,源码如下。', 'markdown.mermaid.diagram': 'Mermaid 图表', - 'markdown.mermaid.preview': '预览', - 'markdown.mermaid.source': '源码', + 'markdown.preview.preview': '预览', + 'markdown.preview.source': '源码', 'markdown.mermaid.loading': '正在渲染图表…', 'markdown.mermaid.error': '无法渲染此图表,源码如下。', 'markdown.truncatedCharacters': '… 已截断,共 {total} 字符', diff --git a/packages/client/ui-chat/src/client/markdown-labels.ts b/packages/client/ui-chat/src/client/markdown-labels.ts index ea70175c57..c77cb8e9e4 100644 --- a/packages/client/ui-chat/src/client/markdown-labels.ts +++ b/packages/client/ui-chat/src/client/markdown-labels.ts @@ -9,24 +9,21 @@ import type { ChatViewSlotProps } from './contract/slots.ts' * @returns Labels for code fences and footnotes. */ export function markdownLabels(t: ChatViewSlotProps['t']): MarkdownLabels { + const status = { loading: t('markdown.preview.loading'), error: t('markdown.preview.error') } return { code: { copyLabel: t('copy'), copiedLabel: t('copied') }, footnotes: t('markdown.footnotes'), preview: { - graphviz: t('markdown.preview.graphviz'), - svg: t('markdown.preview.svg'), - html: t('markdown.preview.html'), - preview: t('markdown.mermaid.preview'), - source: t('markdown.mermaid.source'), - loading: t('markdown.preview.loading'), - error: t('markdown.preview.error'), - }, - mermaid: { - diagram: t('markdown.mermaid.diagram'), - preview: t('markdown.mermaid.preview'), - source: t('markdown.mermaid.source'), - loading: t('markdown.mermaid.loading'), - error: t('markdown.mermaid.error'), + preview: t('markdown.preview.preview'), + source: t('markdown.preview.source'), + mermaid: { + diagram: t('markdown.mermaid.diagram'), + loading: t('markdown.mermaid.loading'), + error: t('markdown.mermaid.error'), + }, + graphviz: { ...status, diagram: t('markdown.preview.graphviz') }, + svg: { ...status, diagram: t('markdown.preview.svg') }, + html: { ...status, diagram: t('markdown.preview.html') }, }, } } diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index bdd6417054..a3d90c6a34 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md -README.md: a1e877c5249817c7ab2e9fa1f6a8ad9582c44a3a -README.zh.md: 81923da65cc21f794d2e7f5d822feb69c06f7499 +README.md: a4fe046ba31212b2029e7689a542b55dd9eceb8b +README.zh.md: 59a3f0cae2b9253c303e0fb244e13c61e17d0417 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index a1e877c524..a4fe046ba3 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -53,7 +53,6 @@ Check this table before writing a control in a feature package. A plugin cannot | `Toast` | Transient top-center banner held for the owner's `holdMs`. | | `JsonTree`, `JsonBlock` | Read-only JSON inspection. | | `MarkdownText`, `CodeBlock` | Untrusted GFM with TeX math, and highlighted code. `CodeBlock` accepts opt-in `lineNumbers`; copied source excludes the gutter. | -| `MermaidPreview` | Read-only Mermaid diagram image with loading and source fallback states. | | `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, `WebBlock` | The agent-output card matching each tool-result intent. | | `icons/*`, `FishLogo`, `BrandWordmark`, `ReferenceIcon`, `LinkIcon`, `DocumentFileIcon` | Glyphs and brand marks, all riding `currentColor`. | | `FileTypeIcon` | The coloured file-type sheet (code, html, image, markdown, pdf, sheet, slides, document, other); `classifyFileType` picks the kind from a path's extension. | @@ -74,11 +73,11 @@ The catalog above lists what each export is for; this section covers the behavio `MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. When the owner passes a `pathImages` vocabulary, image destinations that are local media paths rewrite to displayable URLs on settled renders only (the same streaming gate as file mentions); without a vocabulary, local destinations remain inert alt text. A load or decode failure replaces the image with its authored alt text, or the original destination when alt is empty. Changing the image source permits a fresh load. While a reply streams, it freezes completed blocks, advances a top-level open fence by completed lines, and highlights that fence from saved Shiki grammar state. Completed token lines enter fixed-size React groups, so later chunks reconcile only the growing group; an unchanged fence retains that DOM when the final full parse resolves cross-document syntax. `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, and `WebBlock` render the matching tool-result intent with copy controls, overflow handling, and ANSI processing where applicable. `JsonTree` and `JsonBlock` inspect JSON values read-only, while `projectUserText` projects sent user text into inline plain runs and reference chips for the message bubble and queue rows. -`MermaidPreview` renders complete Mermaid source on a light canvas. Rendering loads Mermaid on demand, uses strict security, and exposes the generated SVG as an image with no diagram link handlers. A render failure shows the original source with the supplied error label; replacing the source discards late results from the previous render. The diagram keeps its intrinsic size and shrinks to fit the available width. +Supply `MarkdownLabels.preview` to enable settled `mermaid`, `graphviz`/`dot`, `svg`, and `html` fences. Each language supplies localized preview states, and consumers without these labels retain code. Mermaid renders complete source on a light canvas. Rendering loads Mermaid on demand, uses strict security, and exposes the generated SVG as an image with no diagram link handlers. A render failure shows the original source with the supplied error label; replacing the source discards late results from the previous render. The diagram keeps its intrinsic size and shrinks to fit the available width. -Supply `MarkdownLabels.mermaid` to enable `mermaid` fence previews in a Markdown consumer; without it, fences remain code. Previews start after the message settles. `CodeBlock.preview` supplies an optional headerless body with icon actions for source switching and copying. Actions appear on hover or keyboard focus, and stay visible below the diagram whenever a touchscreen is available, including with a mouse attached. Switching to source keeps the preview mounted, so returning reuses its result; copying always retains the source. [The Mermaid decision](../../../.agents/notes/implemented/feature/2026-09-07-web-mermaid-preview.md) records the rendering and reuse choices. +`CodeBlock.preview` supplies an optional headerless body with icon actions for source switching and copying. Actions appear on hover or keyboard focus, and stay visible below the diagram whenever a touchscreen is available, including with a mouse attached. Switching to source keeps the preview mounted, so returning reuses its result; copying always retains the source. -Supply `MarkdownLabels.preview` to enable `graphviz`/`dot`, `svg`, and `html` fences. Each settled fence defaults to visualization and shares the source/copy controls. Graphviz loads `@viz-js/viz` on demand and renders DOT with the `dot` engine. SVG and Graphviz output are inert images inside opaque sandbox iframes; malformed SVG and DOT retain their source with an error. HTML accepts complete documents or fragments and preserves inline styles, but strips scripts and navigation. Frames have no sandbox permissions, and their Content Security Policy blocks external resources, embedded frames, forms, and scripts. Frames use a 400px scrollable viewport. Raw HTML outside a code fence remains ordinary Markdown input. +Each settled fence defaults to visualization and shares the source/copy controls. Graphviz loads `@viz-js/viz` on demand and renders DOT with the `dot` engine. SVG and Graphviz output are inert images inside opaque sandbox iframes; malformed SVG and DOT retain their source with an error. HTML accepts complete documents or fragments and preserves inline styles, but strips scripts and navigation. Frames have no sandbox permissions, and their Content Security Policy blocks external resources, embedded frames, forms, and scripts. Frames use a 400px scrollable viewport. Raw HTML outside a code fence remains ordinary Markdown input. [Preview license notices](THIRD_PARTY_PREVIEW_NOTICES.txt) ship with this package and the Web frontend. Mermaid is MIT; Viz.js is MIT but embeds Graphviz under EPL-2.0. The notices retain license texts and the exact Graphviz source download. Dependency upgrades require reviewing the embedded payload as well as npm metadata; the [preview decision](../../../.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.md) records the distribution and sandbox choices. diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 81923da65c..59a3f0cae2 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -53,7 +53,6 @@ kind: "package-library" | `Toast` | 顶部居中的瞬时横幅,保持时长由所有者的 `holdMs` 决定。 | | `JsonTree`、`JsonBlock` | 只读 JSON 查看。 | | `MarkdownText`、`CodeBlock` | 不可信 GFM 与 TeX 数学,以及高亮代码。`CodeBlock` 可通过 `lineNumbers` 开启行号;复制的源码不含行号栏。 | -| `MermaidPreview` | 只读 Mermaid 图表图片,包含加载状态与源码回退状态。 | | `TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock`、`WebBlock` | 与各类工具结果意图对应的 agent 输出卡片。 | | `icons/*`、`FishLogo`、`BrandWordmark`、`ReferenceIcon`、`LinkIcon`、`DocumentFileIcon` | 字形与品牌标识,全部随 `currentColor`。 | | `FileTypeIcon` | 彩色文件类型纸片(code、html、image、markdown、pdf、sheet、slides、document、other);`classifyFileType` 按路径扩展名选出类型。 | @@ -74,11 +73,11 @@ kind: "package-library" `MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。当 owner 传入 `pathImages` 词表时,本地媒体路径的图片目标只在落定渲染阶段重写为可展示 URL(与 file mentions 相同的流式门);不传词表时本地目标保持惰性 alt 文本。加载或解码失败后,图片替换为作者的 alt 文本;alt 为空时显示原始目标路径。图片源变化后可重新加载。回复流式输出时,它冻结已完成的块、按已完成行推进顶层未闭合 fence,并从保存的 Shiki grammar state 为该 fence 增量高亮。已完成的 token 行进入固定大小的 React 分组,后续分片只 reconcile 正在增长的分组;最终全量解析解决跨文档语法时,未变化的 fence 会保留该 DOM。`TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock` 与 `WebBlock` 把对应的工具结果意图渲染为带复制控件、溢出处理及适用时 ANSI 处理的卡片。`JsonTree` 与 `JsonBlock` 以只读方式检查 JSON 值;`projectUserText` 把已发送的用户文本投影为行内普通文本段与引用 chip,供消息气泡和排队行使用。 -`MermaidPreview` 在浅色画布上渲染完整的 Mermaid 源码。渲染按需加载 Mermaid、使用严格安全模式,并把生成的 SVG 显示为图片,不绑定图内链接处理器。渲染失败时显示原始源码与传入的错误文案;替换源码后会丢弃前一次渲染的延迟结果。图表保留固有尺寸,并在可用宽度不足时缩小。 +提供 `MarkdownLabels.preview` 可启用定稿后的 `mermaid`、`graphviz`/`dot`、`svg` 和 `html` fence。每种语言提供本地化预览状态,未提供这些文案的调用方保留代码显示。Mermaid 在浅色画布上渲染完整源码。渲染按需加载 Mermaid、使用严格安全模式,并把生成的 SVG 显示为图片,不绑定图内链接处理器。渲染失败时显示原始源码与传入的错误文案;替换源码后会丢弃前一次渲染的延迟结果。图表保留固有尺寸,并在可用宽度不足时缩小。 -为 Markdown 调用方传入 `MarkdownLabels.mermaid` 即可启用 `mermaid` fence 预览;未传入时,fence 保持代码显示。预览在消息定稿后开始。`CodeBlock.preview` 提供无标题栏的替代正文,以及切换源码和复制的图标操作。操作在悬停或键盘聚焦时出现;只要设备具备触屏,就在图表下方保持可见,包括同时连接鼠标的情况。切换到源码时保留已挂载的预览,返回时复用其结果;复制始终保留源码。[Mermaid 决策](../../../.agents/notes/implemented/feature/2026-09-07-web-mermaid-preview.zh.md)记录了渲染与复用选择。 +`CodeBlock.preview` 提供无标题栏的替代正文,以及切换源码和复制的图标操作。操作在悬停或键盘聚焦时出现;只要设备具备触屏,就在图表下方保持可见,包括同时连接鼠标的情况。切换到源码时保留已挂载的预览,返回时复用其结果;复制始终保留源码。 -传入 `MarkdownLabels.preview` 可启用 `graphviz`/`dot`、`svg` 和 `html` fence。每个定稿 fence 默认显示可视化,并共享源码切换与复制操作。Graphviz 按需加载 `@viz-js/viz`,用 `dot` 引擎渲染 DOT。SVG 与 Graphviz 输出作为不可执行的图片放入不透明来源的 sandbox iframe;非法 SVG 与 DOT 显示错误并保留源码。HTML 接受完整文档或片段、保留内联样式,同时移除脚本与导航。iframe 不授予任何 sandbox 权限,Content Security Policy 阻止外部资源、嵌套 iframe、表单与脚本。iframe 使用可滚动的 400px 视口。代码 fence 外的原始 HTML 仍按普通 Markdown 输入处理。 +每个定稿 fence 默认显示可视化,并共享源码切换与复制操作。Graphviz 按需加载 `@viz-js/viz`,用 `dot` 引擎渲染 DOT。SVG 与 Graphviz 输出作为不可执行的图片放入不透明来源的 sandbox iframe;非法 SVG 与 DOT 显示错误并保留源码。HTML 接受完整文档或片段、保留内联样式,同时移除脚本与导航。iframe 不授予任何 sandbox 权限,Content Security Policy 阻止外部资源、嵌套 iframe、表单与脚本。iframe 使用可滚动的 400px 视口。代码 fence 外的原始 HTML 仍按普通 Markdown 输入处理。 [预览许可证声明](THIRD_PARTY_PREVIEW_NOTICES.txt)随本包与 Web 前端分发。Mermaid 使用 MIT;Viz.js 使用 MIT,但内嵌 Graphviz 使用 EPL-2.0。声明保留完整许可证文本及准确的 Graphviz 源码下载地址。升级依赖时必须同时检查内嵌产物和 npm 元数据;[预览决策](../../../.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.zh.md)记录分发与 sandbox 选择。 diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index 7600a0f9ca..d9b12eda0e 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -64,8 +64,6 @@ export type { } from './WebBlock.tsx' export { CodeBlock } from './markdown/CodeBlock.tsx' export type { CodeBlockProps } from './markdown/CodeBlock.tsx' -export { MermaidPreview } from './markdown/MermaidPreview.tsx' -export type { MermaidPreviewLabels } from './markdown/MermaidPreview.tsx' export { JsonBlock } from './markdown/JsonBlock.tsx' export { MarkdownText } from './markdown/MarkdownText.tsx' export type { MarkdownCodeLabels, MarkdownFileMentions, MarkdownLabels, MarkdownPathImages } from './markdown/MarkdownText.tsx' diff --git a/packages/client/ui-primitives/src/markdown/CodeBlock.module.css b/packages/client/ui-primitives/src/markdown/CodeBlock.module.css index 33a114d102..91c122c61c 100644 --- a/packages/client/ui-primitives/src/markdown/CodeBlock.module.css +++ b/packages/client/ui-primitives/src/markdown/CodeBlock.module.css @@ -98,31 +98,6 @@ color: var(--dsw-alias-label-primary); } -.numbered :where(pre) code { - display: block; - counter-reset: source-line; - white-space: normal; -} - -.numbered :where(pre) code > :global(.line) { - position: relative; - display: block; - min-height: 1lh; - padding-inline-start: calc(var(--dsl-code-block-line-number-width) + 12px); - counter-increment: source-line; - white-space: var(--dsl-code-block-line-white-space, pre-wrap); -} - -.numbered :where(pre) code > :global(.line)::before { - position: absolute; - inset-inline-start: 0; - width: var(--dsl-code-block-line-number-width); - color: var(--dsw-alias-label-tertiary); - text-align: end; - content: counter(source-line); - user-select: none; -} - /* Alternate content is a figure; its actions overlay instead of taking a title row. */ .preview.showingPreview { background: transparent; @@ -205,3 +180,28 @@ padding-top: 16px; } } + +.numbered :where(pre) code { + display: block; + counter-reset: source-line; + white-space: normal; +} + +.numbered :where(pre) code > :global(.line) { + position: relative; + display: block; + min-height: 1lh; + padding-inline-start: calc(var(--dsl-code-block-line-number-width) + 12px); + counter-increment: source-line; + white-space: var(--dsl-code-block-line-white-space, pre-wrap); +} + +.numbered :where(pre) code > :global(.line)::before { + position: absolute; + inset-inline-start: 0; + width: var(--dsl-code-block-line-number-width); + color: var(--dsw-alias-label-tertiary); + text-align: end; + content: counter(source-line); + user-select: none; +} diff --git a/packages/client/ui-primitives/src/markdown/MermaidPreview.tsx b/packages/client/ui-primitives/src/markdown/MermaidPreview.tsx deleted file mode 100644 index dc330e9da8..0000000000 --- a/packages/client/ui-primitives/src/markdown/MermaidPreview.tsx +++ /dev/null @@ -1,17 +0,0 @@ -/** Mermaid image preview using the shared asynchronous source renderer. */ - -import { renderMermaid } from './mermaid.ts' -import { SourcePreview } from './SourcePreview.tsx' -import type { PreviewLabels } from './SourcePreview.tsx' - -/** Localized Mermaid preview states; source remains verbatim. */ -export type MermaidPreviewLabels = PreviewLabels - -/** - * Display a complete Mermaid document as an inert SVG image. - * @param props - Source and complete localized labels. - * @returns A loading status, diagram image, or error with the original source. - */ -export function MermaidPreview({ code, labels }: { code: string; labels: MermaidPreviewLabels }) { - return -} diff --git a/packages/client/ui-primitives/src/markdown/MermaidPreview.module.css b/packages/client/ui-primitives/src/markdown/SourcePreview.module.css similarity index 100% rename from packages/client/ui-primitives/src/markdown/MermaidPreview.module.css rename to packages/client/ui-primitives/src/markdown/SourcePreview.module.css diff --git a/packages/client/ui-primitives/src/markdown/SourcePreview.tsx b/packages/client/ui-primitives/src/markdown/SourcePreview.tsx index 376b69710a..be0871673c 100644 --- a/packages/client/ui-primitives/src/markdown/SourcePreview.tsx +++ b/packages/client/ui-primitives/src/markdown/SourcePreview.tsx @@ -1,7 +1,7 @@ /** Read-only diagram preview with source fallback and per-source async ownership. */ import { useEffect, useState } from 'react' -import css from './MermaidPreview.module.css' +import css from './SourcePreview.module.css' /** Localized preview states; the diagram source remains verbatim. */ export interface PreviewLabels { diff --git a/packages/client/ui-primitives/src/markdown/mermaid.ts b/packages/client/ui-primitives/src/markdown/mermaid.ts index 4db2a64b6c..888a01cdca 100644 --- a/packages/client/ui-primitives/src/markdown/mermaid.ts +++ b/packages/client/ui-primitives/src/markdown/mermaid.ts @@ -2,7 +2,7 @@ import type { Mermaid } from 'mermaid' import clsx from 'clsx' -import css from './MermaidPreview.module.css' +import css from './SourcePreview.module.css' let runtime: Promise | undefined let nextDiagramId = 0 diff --git a/packages/client/ui-primitives/src/markdown/render.tsx b/packages/client/ui-primitives/src/markdown/render.tsx index cb1bd91eb1..f96884df64 100644 --- a/packages/client/ui-primitives/src/markdown/render.tsx +++ b/packages/client/ui-primitives/src/markdown/render.tsx @@ -26,8 +26,8 @@ import { CodeBlock } from './CodeBlock.tsx' import { SourcePreview } from './SourcePreview.tsx' import { renderGraphviz } from './graphviz.ts' import { renderHtml, renderSvg } from './preview-document.ts' -import { MermaidPreview } from './MermaidPreview.tsx' -import type { MermaidPreviewLabels } from './MermaidPreview.tsx' +import { renderMermaid } from './mermaid.ts' +import type { PreviewLabels } from './SourcePreview.tsx' import { renderTexToReact } from './katex.tsx' import { LinkIcon, classifyLinkPath } from '../LinkIcon.tsx' import type { PositionedBlock } from './incremental.ts' @@ -45,17 +45,14 @@ export interface MarkdownCodeLabels { export interface MarkdownLabels { code: MarkdownCodeLabels footnotes: string - /** Opt into settled Mermaid fence previews by supplying their complete localized labels. */ - mermaid?: MermaidPreviewLabels & { preview: string; source: string } - /** Opt into settled Graphviz, SVG, and static HTML previews with complete localized labels. */ + /** Opt into settled Mermaid, Graphviz, SVG, and static HTML previews with complete localized labels. */ preview?: { - graphviz: string - svg: string - html: string + mermaid: PreviewLabels + graphviz: PreviewLabels + svg: PreviewLabels + html: PreviewLabels preview: string source: string - loading: string - error: string } } @@ -418,20 +415,13 @@ function renderCode(node: Md.Code, key: Key, context: MarkdownRenderContext): Re /** Resolve the supported fence language without treating arbitrary HTML in Markdown as a preview. */ function fencePreview(lang: string | undefined, code: string, labels: MarkdownLabels) { - if (lang === 'mermaid' && labels.mermaid !== undefined) { - return { - content: , - previewLabel: labels.mermaid.preview, - sourceLabel: labels.mermaid.source, - } - } if (labels.preview === undefined) return undefined const kind = lang === 'dot' ? 'graphviz' : lang - if (kind !== 'graphviz' && kind !== 'svg' && kind !== 'html') return undefined - const render = { graphviz: renderGraphviz, svg: renderSvg, html: renderHtml }[kind] + if (kind !== 'mermaid' && kind !== 'graphviz' && kind !== 'svg' && kind !== 'html') return undefined + const render = { mermaid: renderMermaid, graphviz: renderGraphviz, svg: renderSvg, html: renderHtml }[kind] return { content: ( - + ), previewLabel: labels.preview.preview, sourceLabel: labels.preview.source, diff --git a/packages/client/ui-primitives/tests/fence-previews.client.spec.tsx b/packages/client/ui-primitives/tests/fence-previews.client.spec.tsx index 50e4b3d2d9..8e8b4abf58 100644 --- a/packages/client/ui-primitives/tests/fence-previews.client.spec.tsx +++ b/packages/client/ui-primitives/tests/fence-previews.client.spec.tsx @@ -7,16 +7,20 @@ import { renderGraphviz } from '../src/markdown/graphviz.ts' import { renderHtml, renderSvg } from '../src/markdown/preview-document.ts' import { markdownLabels } from './labels.client.ts' +const status = { loading: 'Loading', error: 'Cannot preview' } const preview = { - graphviz: 'Graphviz diagram', svg: 'SVG preview', html: 'HTML preview', - preview: 'Preview', source: 'Source', loading: 'Loading', error: 'Cannot preview', + mermaid: { ...status, diagram: 'Mermaid diagram' }, + graphviz: { ...status, diagram: 'Graphviz diagram' }, + svg: { ...status, diagram: 'SVG preview' }, + html: { ...status, diagram: 'HTML preview' }, + preview: 'Preview', source: 'Source', } const svg = '示例' const cases = [ - ['graphviz', 'digraph { Input -> Preview }', preview.graphviz], - ['dot', 'digraph { Input -> Preview }', preview.graphviz], - ['svg', svg, preview.svg], - ['html', '

Example

', preview.html], + ['graphviz', 'digraph { Input -> Preview }', preview.graphviz.diagram], + ['dot', 'digraph { Input -> Preview }', preview.graphviz.diagram], + ['svg', svg, preview.svg.diagram], + ['html', '

Example

', preview.html.diagram], ] as const afterEach(() => { cleanup(); vi.unstubAllGlobals() }) @@ -53,11 +57,11 @@ describe('Markdown fence previews', () => { it.each(['svg', 'graphviz'])('retains invalid %s source and recovers after replacement', async (lang) => { const view = render() - await screen.findByText(preview.error) + await screen.findByText(status.error) expect(view.container.querySelector('pre code')?.textContent).toBe('broken') view.rerender() - await screen.findByTitle(preview.svg) - expect(screen.queryByText(preview.error)).toBeNull() + await screen.findByTitle(preview.svg.diagram) + expect(screen.queryByText(status.error)).toBeNull() }) it('requires an opted-in code fence and leaves raw HTML and other languages unrendered', () => { diff --git a/packages/client/ui-primitives/tests/mermaid-preview.client.spec.tsx b/packages/client/ui-primitives/tests/source-preview.client.spec.tsx similarity index 73% rename from packages/client/ui-primitives/tests/mermaid-preview.client.spec.tsx rename to packages/client/ui-primitives/tests/source-preview.client.spec.tsx index c7cf95a9d4..fe356f64bb 100644 --- a/packages/client/ui-primitives/tests/mermaid-preview.client.spec.tsx +++ b/packages/client/ui-primitives/tests/source-preview.client.spec.tsx @@ -2,7 +2,8 @@ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { MermaidPreview } from '../src/markdown/MermaidPreview.tsx' +import { SourcePreview } from '../src/markdown/SourcePreview.tsx' +import { CodeBlock } from '../src/markdown/CodeBlock.tsx' import { renderMermaid } from '../src/markdown/mermaid.ts' import { MarkdownText } from '../src/markdown/MarkdownText.tsx' import { markdownLabels } from './labels.client.ts' @@ -13,17 +14,21 @@ const labels = { diagram: 'Mermaid diagram', loading: 'Rendering diagram…', error: 'Unable to render this diagram.', preview: 'Preview', source: 'Source', } +const preview = { + preview: labels.preview, source: labels.source, + mermaid: labels, graphviz: labels, svg: labels, html: labels, +} const source = 'flowchart LR\n A[Input] --> B[Preview]' const imageUrl = 'data:image/svg+xml;charset=utf-8,%3Csvg%2F%3E' afterEach(() => { cleanup(); vi.unstubAllGlobals() }) beforeEach(() => { vi.resetAllMocks() }) -describe('MermaidPreview', () => { +describe('SourcePreview', () => { it('shows loading until rendering completes, then displays an image without inserting SVG', async () => { const pending = Promise.withResolvers() vi.mocked(renderMermaid).mockReturnValue(pending.promise) - const view = render() + const view = render() expect(screen.getByRole('status').textContent).toBe(labels.loading) expect(screen.queryByRole('img')).toBeNull() await act(async () => { pending.resolve(imageUrl) }) @@ -34,12 +39,12 @@ describe('MermaidPreview', () => { it('retains invalid source and recovers when the source changes', async () => { vi.mocked(renderMermaid).mockRejectedValueOnce(new Error('Parse error')) - const view = render() + const view = render() expect((await screen.findByText(labels.error)).getAttribute('role')).toBe('status') expect(view.container.querySelector('pre code')?.textContent).toBe('invalid') const next = Promise.withResolvers() vi.mocked(renderMermaid).mockReturnValue(next.promise) - view.rerender() + view.rerender() expect(screen.getByRole('status').textContent).toBe(labels.loading) expect(view.container.querySelector('pre')).toBeNull() await act(async () => { next.resolve(imageUrl) }) @@ -50,9 +55,9 @@ describe('MermaidPreview', () => { const old = Promise.withResolvers() const next = Promise.withResolvers() vi.mocked(renderMermaid).mockReturnValueOnce(old.promise).mockReturnValueOnce(next.promise) - const view = render() + const view = render() const oldSignal = vi.mocked(renderMermaid).mock.calls[0]![1] - view.rerender() + view.rerender() expect(oldSignal.aborted).toBe(true) await act(async () => { next.resolve(imageUrl) }) await act(async () => { @@ -66,7 +71,7 @@ describe('MermaidPreview', () => { it('cancels the owner when unmounted while loading', async () => { const pending = Promise.withResolvers() vi.mocked(renderMermaid).mockReturnValue(pending.promise) - const view = render() + const view = render() const signal = vi.mocked(renderMermaid).mock.calls[0]![1] view.unmount() expect(signal.aborted).toBe(true) @@ -76,11 +81,29 @@ describe('MermaidPreview', () => { }) describe('Markdown Mermaid fences', () => { + it('retains a preview when switching to numbered source', async () => { + vi.mocked(renderMermaid).mockResolvedValue(imageUrl) + const view = render(, + previewLabel: labels.preview, sourceLabel: labels.source, + }} />) + const diagram = await screen.findByRole('img', { name: labels.diagram }) + expect(view.container.querySelector('[data-line-numbers]')).not.toBeNull() + fireEvent.click(screen.getByRole('button', { name: labels.source })) + expect([...view.container.querySelectorAll('code > .line')].map(line => line.textContent)).toEqual(source.split('\n')) + expect(diagram.isConnected).toBe(true) + expect(screen.queryByRole('img')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: labels.preview })) + expect(screen.getByRole('img', { name: labels.diagram })).toBe(diagram) + expect(renderMermaid).toHaveBeenCalledOnce() + }) + it('keeps streaming source literal, then offers preview, source and source copying', async () => { const writeText = vi.fn().mockResolvedValue(undefined) vi.stubGlobal('navigator', Object.create(navigator, { clipboard: { value: { writeText } } })) vi.mocked(renderMermaid).mockResolvedValue(imageUrl) - const props = { text: `\`\`\`mermaid\n${source}\n\`\`\``, labels: { ...markdownLabels, mermaid: labels } } + const props = { text: `\`\`\`mermaid\n${source}\n\`\`\``, labels: { ...markdownLabels, preview } } const view = render() expect(view.container.querySelector('pre code')?.textContent).toBe(source) expect(renderMermaid).not.toHaveBeenCalled() @@ -107,7 +130,7 @@ describe('Markdown Mermaid fences', () => { it('leaves other languages and consumers without preview labels as code', () => { const view = render() expect(view.container.querySelector('pre code')?.textContent).toBe(source) - view.rerender() + view.rerender() expect(view.container.querySelector('pre code')?.textContent).toBe('flowchart LR') expect(renderMermaid).not.toHaveBeenCalled() }) From 9e050260e4559707a65c904bf0a9427c98096d05 Mon Sep 17 00:00:00 2001 From: "HYD@OVERTON" Date: Wed, 9 Sep 2026 17:04:20 +0800 Subject: [PATCH 042/111] feat(web): adapt diagram previews to application theme --- ...6-09-09-markdown-static-previews.i18n.yaml | 4 +- .../2026-09-09-markdown-static-previews.md | 2 +- .../2026-09-09-markdown-static-previews.zh.md | 2 +- apps/web/tests/markdown-mermaid.e2e.ts | 28 +++++++ .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 4 +- packages/client/ui-primitives/README.zh.md | 4 +- .../src/markdown/SourcePreview.module.css | 4 +- .../src/markdown/SourcePreview.tsx | 29 ++++++-- .../ui-primitives/src/markdown/graphviz.ts | 23 +++++- .../ui-primitives/src/markdown/mermaid.ts | 74 ++++++++++++++----- .../src/markdown/preview-document.ts | 4 +- .../src/markdown/preview-theme.ts | 27 +++++++ .../tests/graphviz-runtime.client.spec.ts | 32 ++++++++ .../tests/mermaid-runtime.client.spec.ts | 43 ++++++++++- .../tests/source-preview.client.spec.tsx | 25 +++++++ 16 files changed, 267 insertions(+), 42 deletions(-) create mode 100644 packages/client/ui-primitives/src/markdown/preview-theme.ts diff --git a/.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.i18n.yaml b/.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.i18n.yaml index c70073de17..8712ee2070 100644 --- a/.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.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-09-markdown-static-previews.md -2026-09-09-markdown-static-previews.md: a6d1802546e9e976da7ae4d1ecc024924414e9a4 -2026-09-09-markdown-static-previews.zh.md: 42488547efafdbf5b1944b1300af13f359365d74 +2026-09-09-markdown-static-previews.md: a5fa79fcc8c192dbcd3949debbabbccf85cb3b62 +2026-09-09-markdown-static-previews.zh.md: 30efe897702377c180c9e1650fda93ae42f6e580 diff --git a/.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.md b/.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.md index a6d1802546..a5fa79fcc8 100644 --- a/.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.md +++ b/.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.md @@ -16,7 +16,7 @@ The shared Markdown renderer enables settled `mermaid`, `graphviz`/`dot`, `svg`, `SourcePreview` owns loading, failure, and cancellation of stale result publication. Source replacement and unmounting cancel publication; cancellation before runtime loading completes skips layout. Failures show a localized error and the original source, and replacing invalid source with valid input recovers the preview. -Mermaid loads on demand. Its public render API serializes diagram work, and each call removes its temporary measurement DOM in `finally`. Strict security, disabled HTML labels, the neutral theme, and error-rendering policy cannot be overridden by diagram configuration. Generated SVG is displayed as an image without installing links or scripts. Intrinsic dimensions come from the SVG viewBox; large diagrams shrink to fit, and the canvas stays light in both application themes. +Mermaid loads on demand. A shared queue serializes theme initialization with diagram work, and each call removes its temporary measurement DOM in `finally`. Strict security, disabled HTML labels, the application palette, and error-rendering policy cannot be overridden by diagram configuration. Generated SVG is displayed as an image without installing links or scripts. Intrinsic dimensions come from the SVG viewBox; large diagrams shrink to fit, and the canvas follows the code-block background. Mounted previews observe document theme attributes and regenerate only when resolved colors change; obsolete renders cannot publish. Graphviz default colors follow the same palette, while authored DOT, SVG, and HTML colors remain intact. HTML uses DOMPurify with navigation attributes and document-loading elements forbidden, followed by an opaque `sandbox=""` iframe. A trusted CSP precedes source markup and permits only inline CSS, data images, and data fonts. Scripts, external resources, child frames, form submission, and same-origin access are unavailable. SVG is parsed as XML and placed in an image inside the frame, so SVG scripts and links never become active. Frames have a fixed scrollable viewport: measuring their content would require additional origin access or trusted frame scripts. diff --git a/.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.zh.md b/.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.zh.md index 42488547ef..30efe89770 100644 --- a/.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.zh.md +++ b/.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.zh.md @@ -16,7 +16,7 @@ Status: implemented `SourcePreview` 负责加载、失败和取消过期结果发布。替换源码和卸载组件会取消结果发布;在运行时加载完成前取消会跳过布局。失败时显示本地化错误与原始源码,用有效输入替换无效源码后可以恢复预览。 -Mermaid 按需加载。其公开渲染 API 串行执行图表工作,每次调用都在 `finally` 中删除临时测量 DOM。图表配置无法覆盖严格安全模式、禁用 HTML 标签、中性色主题和错误渲染策略。生成的 SVG 作为图片显示,不安装链接或脚本。固有尺寸来自 SVG viewBox;大图缩小以适应宽度,两种应用主题下的画布均保持浅色。 +Mermaid 按需加载。共享队列将主题初始化与图表渲染一起串行执行,每次调用都在 `finally` 中删除临时测量 DOM。图表配置无法覆盖严格安全模式、禁用 HTML 标签、应用配色和错误渲染策略。生成的 SVG 作为图片显示,不安装链接或脚本。固有尺寸来自 SVG viewBox;大图缩小以适应宽度,画布使用代码块背景。已挂载的预览观察文档主题属性,仅在解析后的配色变化时重新生成;过期渲染不能发布结果。Graphviz 的默认颜色采用同一配色,DOT、SVG 和 HTML 中明确指定的颜色保持原样。 HTML 先由 DOMPurify 清理,禁止导航属性与文档加载元素,再进入不透明来源的 `sandbox=""` iframe。可信 CSP 位于源码标记之前,仅允许内联 CSS、data 图片与 data 字体。脚本、外部资源、子 iframe、表单提交与同源访问均不可用。SVG 按 XML 解析后作为图片放进 iframe,因此 SVG 脚本与链接不会激活。iframe 使用固定的可滚动视口:测量内容需要额外的来源访问权限或可信 iframe 脚本。 diff --git a/apps/web/tests/markdown-mermaid.e2e.ts b/apps/web/tests/markdown-mermaid.e2e.ts index c009e1d836..d970cc9e11 100644 --- a/apps/web/tests/markdown-mermaid.e2e.ts +++ b/apps/web/tests/markdown-mermaid.e2e.ts @@ -88,6 +88,34 @@ describe('web e2e: Mermaid chat previews', () => { await scaffold?.close() }) + it.skipIf(MODE === 'record')('updates existing diagrams when the document switches between light and dark', async () => { + const page = await newEnglishPage(browser) + try { + await openConversation(page, scaffold) + const image = page.getByRole('img', { name: 'Mermaid diagram' }).first() + await image.waitFor() + let previous = await image.getAttribute('src') + for (const scheme of ['dark', 'light'] as const) { + await page.evaluate((value) => { + document.documentElement.style.colorScheme = value + document.body.toggleAttribute('data-ds-dark-theme', value === 'dark') + }, scheme) + await expect.poll(() => image.getAttribute('src')).not.toBe(previous) + await expect.poll(() => image.evaluate(node => (node as HTMLImageElement).naturalWidth)).toBeGreaterThan(0) + const colors = await image.evaluate(node => ({ + canvas: getComputedStyle(node.parentElement!).backgroundColor, + expected: getComputedStyle(document.body).getPropertyValue('--dsw-alias-markdown-code-block').trim(), + })) + expect(colors.canvas).toBe(colors.expected) + await expect.poll(() => page.getByTitle('HTML preview', { exact: true }).getAttribute('srcdoc')) + .toContain(`color-scheme:${scheme}`) + previous = await image.getAttribute('src') + } + } finally { + await page.close() + } + }) + it.skipIf(MODE === 'record')('renders diagrams, switches to source, copies source, and contains malformed content', async () => { const page = await newEnglishPage(browser) onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-mermaid')) diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index a3d90c6a34..b3a61421d4 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md -README.md: a4fe046ba31212b2029e7689a542b55dd9eceb8b -README.zh.md: 59a3f0cae2b9253c303e0fb244e13c61e17d0417 +README.md: 8bdd2b7f90ebe4a998be8158351e49f815343c78 +README.zh.md: b861f9e17ff73f20717aed63b5b087358daeae10 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index a4fe046ba3..8bdd2b7f90 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -73,12 +73,14 @@ The catalog above lists what each export is for; this section covers the behavio `MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. When the owner passes a `pathImages` vocabulary, image destinations that are local media paths rewrite to displayable URLs on settled renders only (the same streaming gate as file mentions); without a vocabulary, local destinations remain inert alt text. A load or decode failure replaces the image with its authored alt text, or the original destination when alt is empty. Changing the image source permits a fresh load. While a reply streams, it freezes completed blocks, advances a top-level open fence by completed lines, and highlights that fence from saved Shiki grammar state. Completed token lines enter fixed-size React groups, so later chunks reconcile only the growing group; an unchanged fence retains that DOM when the final full parse resolves cross-document syntax. `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, and `WebBlock` render the matching tool-result intent with copy controls, overflow handling, and ANSI processing where applicable. `JsonTree` and `JsonBlock` inspect JSON values read-only, while `projectUserText` projects sent user text into inline plain runs and reference chips for the message bubble and queue rows. -Supply `MarkdownLabels.preview` to enable settled `mermaid`, `graphviz`/`dot`, `svg`, and `html` fences. Each language supplies localized preview states, and consumers without these labels retain code. Mermaid renders complete source on a light canvas. Rendering loads Mermaid on demand, uses strict security, and exposes the generated SVG as an image with no diagram link handlers. A render failure shows the original source with the supplied error label; replacing the source discards late results from the previous render. The diagram keeps its intrinsic size and shrinks to fit the available width. +Supply `MarkdownLabels.preview` to enable settled `mermaid`, `graphviz`/`dot`, `svg`, and `html` fences. Each language supplies localized preview states, and consumers without these labels retain code. Mermaid and Graphviz use the active document palette on the code-block background and update mounted previews when the theme changes. Explicit colors in DOT, SVG, and HTML remain authored content. Rendering loads Mermaid on demand, uses strict security, and exposes the generated SVG as an image with no diagram link handlers. A render failure shows the original source with the supplied error label; replacing the source discards late results from the previous render. The diagram keeps its intrinsic size and shrinks to fit the available width. `CodeBlock.preview` supplies an optional headerless body with icon actions for source switching and copying. Actions appear on hover or keyboard focus, and stay visible below the diagram whenever a touchscreen is available, including with a mouse attached. Switching to source keeps the preview mounted, so returning reuses its result; copying always retains the source. Each settled fence defaults to visualization and shares the source/copy controls. Graphviz loads `@viz-js/viz` on demand and renders DOT with the `dot` engine. SVG and Graphviz output are inert images inside opaque sandbox iframes; malformed SVG and DOT retain their source with an error. HTML accepts complete documents or fragments and preserves inline styles, but strips scripts and navigation. Frames have no sandbox permissions, and their Content Security Policy blocks external resources, embedded frames, forms, and scripts. Frames use a 400px scrollable viewport. Raw HTML outside a code fence remains ordinary Markdown input. +Internal render functions accept optional native `MermaidConfig` and Viz.js `RenderOptions` for developer customization. Attribute and theme-variable overrides merge with document defaults; Mermaid preview restrictions and Graphviz SVG output remain fixed. These parameters have no settings UI. + [Preview license notices](THIRD_PARTY_PREVIEW_NOTICES.txt) ship with this package and the Web frontend. Mermaid is MIT; Viz.js is MIT but embeds Graphviz under EPL-2.0. The notices retain license texts and the exact Graphviz source download. Dependency upgrades require reviewing the embedded payload as well as npm metadata; the [preview decision](../../../.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.md) records the distribution and sandbox choices. ### Localizing copy diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 59a3f0cae2..b861f9e17f 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -73,12 +73,14 @@ kind: "package-library" `MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。当 owner 传入 `pathImages` 词表时,本地媒体路径的图片目标只在落定渲染阶段重写为可展示 URL(与 file mentions 相同的流式门);不传词表时本地目标保持惰性 alt 文本。加载或解码失败后,图片替换为作者的 alt 文本;alt 为空时显示原始目标路径。图片源变化后可重新加载。回复流式输出时,它冻结已完成的块、按已完成行推进顶层未闭合 fence,并从保存的 Shiki grammar state 为该 fence 增量高亮。已完成的 token 行进入固定大小的 React 分组,后续分片只 reconcile 正在增长的分组;最终全量解析解决跨文档语法时,未变化的 fence 会保留该 DOM。`TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock` 与 `WebBlock` 把对应的工具结果意图渲染为带复制控件、溢出处理及适用时 ANSI 处理的卡片。`JsonTree` 与 `JsonBlock` 以只读方式检查 JSON 值;`projectUserText` 把已发送的用户文本投影为行内普通文本段与引用 chip,供消息气泡和排队行使用。 -提供 `MarkdownLabels.preview` 可启用定稿后的 `mermaid`、`graphviz`/`dot`、`svg` 和 `html` fence。每种语言提供本地化预览状态,未提供这些文案的调用方保留代码显示。Mermaid 在浅色画布上渲染完整源码。渲染按需加载 Mermaid、使用严格安全模式,并把生成的 SVG 显示为图片,不绑定图内链接处理器。渲染失败时显示原始源码与传入的错误文案;替换源码后会丢弃前一次渲染的延迟结果。图表保留固有尺寸,并在可用宽度不足时缩小。 +提供 `MarkdownLabels.preview` 可启用定稿后的 `mermaid`、`graphviz`/`dot`、`svg` 和 `html` fence。每种语言提供本地化预览状态,未提供这些文案的调用方保留代码显示。Mermaid 与 Graphviz 在代码块背景上使用当前文档配色,主题切换时同步更新已挂载的预览。DOT、SVG 与 HTML 中明确指定的颜色仍保留原样。渲染按需加载 Mermaid、使用严格安全模式,并把生成的 SVG 显示为图片,不绑定图内链接处理器。渲染失败时显示原始源码与传入的错误文案;替换源码后会丢弃前一次渲染的延迟结果。图表保留固有尺寸,并在可用宽度不足时缩小。 `CodeBlock.preview` 提供无标题栏的替代正文,以及切换源码和复制的图标操作。操作在悬停或键盘聚焦时出现;只要设备具备触屏,就在图表下方保持可见,包括同时连接鼠标的情况。切换到源码时保留已挂载的预览,返回时复用其结果;复制始终保留源码。 每个定稿 fence 默认显示可视化,并共享源码切换与复制操作。Graphviz 按需加载 `@viz-js/viz`,用 `dot` 引擎渲染 DOT。SVG 与 Graphviz 输出作为不可执行的图片放入不透明来源的 sandbox iframe;非法 SVG 与 DOT 显示错误并保留源码。HTML 接受完整文档或片段、保留内联样式,同时移除脚本与导航。iframe 不授予任何 sandbox 权限,Content Security Policy 阻止外部资源、嵌套 iframe、表单与脚本。iframe 使用可滚动的 400px 视口。代码 fence 外的原始 HTML 仍按普通 Markdown 输入处理。 +内部渲染函数接受可选的原生 `MermaidConfig` 与 Viz.js `RenderOptions`,供开发者定制。属性与主题变量覆盖项和文档默认值合并;Mermaid 预览限制与 Graphviz SVG 输出保持固定。这些参数没有设置界面。 + [预览许可证声明](THIRD_PARTY_PREVIEW_NOTICES.txt)随本包与 Web 前端分发。Mermaid 使用 MIT;Viz.js 使用 MIT,但内嵌 Graphviz 使用 EPL-2.0。声明保留完整许可证文本及准确的 Graphviz 源码下载地址。升级依赖时必须同时检查内嵌产物和 npm 元数据;[预览决策](../../../.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.zh.md)记录分发与 sandbox 选择。 ### 本地化文案 diff --git a/packages/client/ui-primitives/src/markdown/SourcePreview.module.css b/packages/client/ui-primitives/src/markdown/SourcePreview.module.css index e21d84e284..46a674ed0a 100644 --- a/packages/client/ui-primitives/src/markdown/SourcePreview.module.css +++ b/packages/client/ui-primitives/src/markdown/SourcePreview.module.css @@ -1,7 +1,7 @@ .canvas { overflow: auto; padding: 16px; - background: var(--dsw-static-neutral-00); + background: var(--dsw-alias-markdown-code-block); border-radius: 12px; } @@ -31,5 +31,5 @@ height: 400px; border: 0; border-radius: 12px; - background: var(--dsw-static-neutral-00); + background: var(--dsw-alias-markdown-code-block); } diff --git a/packages/client/ui-primitives/src/markdown/SourcePreview.tsx b/packages/client/ui-primitives/src/markdown/SourcePreview.tsx index be0871673c..3ce07fb34d 100644 --- a/packages/client/ui-primitives/src/markdown/SourcePreview.tsx +++ b/packages/client/ui-primitives/src/markdown/SourcePreview.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react' import css from './SourcePreview.module.css' +import { readPreviewTheme } from './preview-theme.ts' /** Localized preview states; the diagram source remains verbatim. */ export interface PreviewLabels { @@ -19,7 +20,7 @@ function assertNever(value: never): never { /** * Display a complete document or diagram, retaining the source when rendering fails. - * @param props - Source and complete localized labels. Changing source discards the previous result. + * @param props - Source and complete localized labels. Source and document theme changes cancel obsolete renders. * @returns A loading status, an inert image or sandboxed document, or an error with the original source. */ export function SourcePreview({ code, labels, render, document: isDocument = false }: { @@ -30,12 +31,26 @@ export function SourcePreview({ code, labels, render, document: isDocument = fal }) { const [result, setResult] = useState(null) useEffect(() => { - const controller = new AbortController() - void (async () => await render(code, controller.signal))().then( - (src) => { if (!controller.signal.aborted) setResult({ kind: 'ok', code, src }) }, - () => { if (!controller.signal.aborted) setResult({ kind: 'error', code }) }, - ) - return () => { controller.abort() } + let controller: AbortController | undefined + let palette: string | undefined + const update = () => { + const next = JSON.stringify(readPreviewTheme()) + if (next === palette) return + palette = next + controller?.abort() + const current = new AbortController() + controller = current + void (async () => await render(code, current.signal))().then( + (src) => { if (!current.signal.aborted) setResult({ kind: 'ok', code, src }) }, + () => { if (!current.signal.aborted) setResult({ kind: 'error', code }) }, + ) + } + // Isolated image/frame documents cannot inherit the host's CSS variables. + const observer = new MutationObserver(update) + observer.observe(document.documentElement, { attributes: true, attributeFilter: ['style'] }) + observer.observe(document.body, { attributes: true, attributeFilter: ['style', 'data-ds-dark-theme'] }) + update() + return () => { observer.disconnect(); controller?.abort() } }, [code, render]) if (result?.code !== code) return
{labels.loading}
diff --git a/packages/client/ui-primitives/src/markdown/graphviz.ts b/packages/client/ui-primitives/src/markdown/graphviz.ts index 8c00f84766..fc8f90315b 100644 --- a/packages/client/ui-primitives/src/markdown/graphviz.ts +++ b/packages/client/ui-primitives/src/markdown/graphviz.ts @@ -1,22 +1,37 @@ /** Lazy Graphviz WebAssembly layout; generated SVG is displayed only as an inert image. */ -import type { instance } from '@viz-js/viz' +import type { instance, RenderOptions } from '@viz-js/viz' import { renderSvg } from './preview-document.ts' +import { readPreviewTheme } from './preview-theme.ts' let runtime: ReturnType | undefined /** - * Render Graphviz DOT as a sandboxed preview document. + * Render Graphviz DOT with document-theme defaults; authored attributes override those defaults. * @param code - Complete Graphviz DOT source. * @param signal - Cancels work waiting for the runtime; synchronous layout cannot be interrupted. + * @param options - Native overrides; attribute maps merge over theme defaults and output remains SVG regardless of format. * @returns A srcdoc document. Import, layout, invalid source, and cancellation failures reject. */ -export async function renderGraphviz(code: string, signal: AbortSignal): Promise { +export async function renderGraphviz(code: string, signal: AbortSignal, options?: RenderOptions): Promise { runtime ??= import('@viz-js/viz').then(module => module.instance()).catch((error: unknown) => { runtime = undefined throw error }) const viz = await runtime signal.throwIfAborted() - return renderSvg(viz.renderString(code, { format: 'svg', engine: 'dot' }), signal) + const palette = readPreviewTheme() + const color = (value: string): string => value.replace( + /^rgb\(\s*(\d+)[, ]+\s*(\d+)[, ]+\s*(\d+)\s*\)$/, + (_match, r: string, g: string, b: string) => `#${[r, g, b].map(channel => Number(channel).toString(16).padStart(2, '0')).join('')}`, + ) + return renderSvg(viz.renderString(code, { + engine: 'dot', ...options, format: 'svg', + graphAttributes: { bgcolor: 'transparent', fontcolor: color(palette.text), ...options?.graphAttributes }, + nodeAttributes: { + color: color(palette.border), fontcolor: color(palette.text), fillcolor: color(palette.surface), style: 'filled', + ...options?.nodeAttributes, + }, + edgeAttributes: { color: color(palette.line), fontcolor: color(palette.text), ...options?.edgeAttributes }, + }), signal) } diff --git a/packages/client/ui-primitives/src/markdown/mermaid.ts b/packages/client/ui-primitives/src/markdown/mermaid.ts index 888a01cdca..9d7b8c721e 100644 --- a/packages/client/ui-primitives/src/markdown/mermaid.ts +++ b/packages/client/ui-primitives/src/markdown/mermaid.ts @@ -1,48 +1,86 @@ /** Lazy Mermaid runtime; each render owns and removes its temporary measurement DOM. */ -import type { Mermaid } from 'mermaid' +import type { Mermaid, MermaidConfig } from 'mermaid' import clsx from 'clsx' import css from './SourcePreview.module.css' +import { readPreviewTheme } from './preview-theme.ts' let runtime: Promise | undefined let nextDiagramId = 0 +let pending: Promise = Promise.resolve() function loadMermaid(): Promise { - runtime ??= import('mermaid').then(({ default: mermaid }) => { - mermaid.initialize({ - startOnLoad: false, - securityLevel: 'strict', - suppressErrorRendering: true, - theme: 'neutral', - htmlLabels: false, - secure: [ - 'secure', 'securityLevel', 'startOnLoad', 'maxTextSize', 'maxEdges', - 'suppressErrorRendering', 'theme', 'themeVariables', 'themeCSS', 'htmlLabels', 'flowchart', - ], - }) - return mermaid - }).catch((error: unknown) => { + runtime ??= import('mermaid').then(({ default: mermaid }) => mermaid).catch((error: unknown) => { runtime = undefined throw error }) return runtime } +function initialize(mermaid: Mermaid, config: MermaidConfig | undefined): void { + const palette = readPreviewTheme() + mermaid.initialize({ + theme: 'base', + ...config, + startOnLoad: false, + securityLevel: 'strict', + suppressErrorRendering: true, + themeVariables: { + darkMode: palette.dark, + background: palette.background, + primaryColor: palette.surface, + primaryTextColor: palette.text, + primaryBorderColor: palette.border, + secondaryColor: palette.surface, + tertiaryColor: palette.surface, + lineColor: palette.line, + textColor: palette.text, + actorBkg: palette.surface, + actorBorder: palette.border, + actorTextColor: palette.text, + actorLineColor: palette.border, + signalColor: palette.line, + signalTextColor: palette.text, + labelBoxBkgColor: palette.surface, + labelTextColor: palette.text, + edgeLabelBackground: palette.background, + ...config?.themeVariables as Record | undefined, + }, + htmlLabels: false, + // Mermaid still accepts this legacy override alongside the global HTML-label setting. + // oxlint-disable-next-line typescript/no-deprecated + flowchart: { ...config?.flowchart, htmlLabels: false }, + secure: [ + 'secure', 'securityLevel', 'startOnLoad', 'maxTextSize', 'maxEdges', + 'suppressErrorRendering', 'theme', 'themeVariables', 'themeCSS', 'htmlLabels', 'flowchart', + ...(config?.secure ?? []), + ], + }) +} + /** - * Render untrusted diagram source as an SVG image URL, without installing SVG or link handlers in the UI. + * Render untrusted diagram source using the document palette, without installing SVG or link handlers in the UI. * @param code - Complete Mermaid source. * @param signal - Cancels work waiting for the runtime; an active Mermaid render finishes before cleanup. + * @param config - Native overrides of theme defaults; strict security, manual rendering, and disabled HTML labels remain fixed. * @returns An SVG data URL. Import, parse, rendering, and cancellation failures reject. */ -export async function renderMermaid(code: string, signal: AbortSignal): Promise { +export async function renderMermaid(code: string, signal: AbortSignal, config?: MermaidConfig): Promise { + const task = pending.then(() => renderOne(code, signal, config)) + pending = task.catch(() => { /* The caller owns rejection; subsequent diagrams still render. */ }) + return task +} + +async function renderOne(code: string, signal: AbortSignal, config: MermaidConfig | undefined): Promise { const mermaid = await loadMermaid() signal.throwIfAborted() + initialize(mermaid, config) const stage = document.createElement('div') stage.className = clsx(css.staging) stage.setAttribute('aria-hidden', 'true') document.body.append(stage) try { - // Mermaid's public render API serializes diagram work; the lower-level mermaidAPI does not. + // Initialization and rendering share the queue because Mermaid owns global configuration. const { svg } = await mermaid.render(`dsh-mermaid-${nextDiagramId++}`, code, stage) const root = new DOMParser().parseFromString(svg, 'image/svg+xml').documentElement const viewBox = root.getAttribute('viewBox') diff --git a/packages/client/ui-primitives/src/markdown/preview-document.ts b/packages/client/ui-primitives/src/markdown/preview-document.ts index c0de8b4497..15aa3bf2f3 100644 --- a/packages/client/ui-primitives/src/markdown/preview-document.ts +++ b/packages/client/ui-primitives/src/markdown/preview-document.ts @@ -1,12 +1,14 @@ /** Static preview documents: opaque sandbox frames with no scripts, navigation, or remote resources. */ import DOMPurify from 'dompurify' +import { readPreviewTheme } from './preview-theme.ts' // This policy belongs to the preview document, before any source-controlled markup. const CSP = "default-src 'none'; script-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src data:; base-uri 'none'; form-action 'none'" function documentOf(body: string): string { - return `${body}` + const scheme = readPreviewTheme().dark ? 'dark' : 'light' + return `${body}` } /** diff --git a/packages/client/ui-primitives/src/markdown/preview-theme.ts b/packages/client/ui-primitives/src/markdown/preview-theme.ts new file mode 100644 index 0000000000..e497e10a80 --- /dev/null +++ b/packages/client/ui-primitives/src/markdown/preview-theme.ts @@ -0,0 +1,27 @@ +/** Resolved document colors used by isolated diagram renderers. */ + +interface PreviewTheme { + dark: boolean + background: string + surface: string + text: string + line: string + border: string +} + +/** + * Read the palette after the application's theme presenter updates the DOM. + * @returns Semantic preview colors and the resolved browser color scheme. + */ +export function readPreviewTheme(): PreviewTheme { + const style = getComputedStyle(document.body) + const color = (name: string): string => style.getPropertyValue(`--dsw-alias-${name}`).trim() + return { + dark: getComputedStyle(document.documentElement).colorScheme === 'dark', + background: color('markdown-code-block'), + surface: color('bg-layer-2'), + text: color('label-primary'), + line: color('label-secondary'), + border: color('label-tertiary'), + } +} diff --git a/packages/client/ui-primitives/tests/graphviz-runtime.client.spec.ts b/packages/client/ui-primitives/tests/graphviz-runtime.client.spec.ts index 354c0f9eaa..1a7b170516 100644 --- a/packages/client/ui-primitives/tests/graphviz-runtime.client.spec.ts +++ b/packages/client/ui-primitives/tests/graphviz-runtime.client.spec.ts @@ -33,3 +33,35 @@ it('skips layout after cancellation during runtime loading', async () => { await rejection expect(renderString).not.toHaveBeenCalled() }) + +it('uses theme defaults without rewriting authored DOT attributes', async () => { + const previous = document.body.getAttribute('style') + try { + document.body.style.setProperty('--dsw-alias-label-primary', 'rgb(230, 231, 232)') + document.body.style.setProperty('--dsw-alias-label-secondary', '#aabbcc') + document.body.style.setProperty('--dsw-alias-label-tertiary', '#8899aa') + document.body.style.setProperty('--dsw-alias-bg-layer-2', '#222222') + const renderString = vi.fn().mockReturnValue('') + instance.mockResolvedValue({ renderString }) + const { renderGraphviz } = await import('../src/markdown/graphviz.ts') + const code = 'digraph { a [color="red"]; a -> b }' + await renderGraphviz(code, new AbortController().signal) + expect(renderString).toHaveBeenCalledWith(code, expect.objectContaining({ + nodeAttributes: { color: '#8899aa', fontcolor: '#e6e7e8', fillcolor: '#222222', style: 'filled' }, + edgeAttributes: { color: '#aabbcc', fontcolor: '#e6e7e8' }, + })) + await renderGraphviz(code, new AbortController().signal, { + engine: 'neato', format: 'plain', yInvert: true, + graphAttributes: { ranksep: 1 }, nodeAttributes: { shape: 'box' }, edgeAttributes: { color: 'red' }, + }) + expect(renderString).toHaveBeenLastCalledWith(code, { + engine: 'neato', format: 'svg', yInvert: true, + graphAttributes: { bgcolor: 'transparent', fontcolor: '#e6e7e8', ranksep: 1 }, + nodeAttributes: { color: '#8899aa', fontcolor: '#e6e7e8', fillcolor: '#222222', style: 'filled', shape: 'box' }, + edgeAttributes: { color: 'red', fontcolor: '#e6e7e8' }, + }) + } finally { + if (previous === null) document.body.removeAttribute('style') + else document.body.setAttribute('style', previous) + } +}) diff --git a/packages/client/ui-primitives/tests/mermaid-runtime.client.spec.ts b/packages/client/ui-primitives/tests/mermaid-runtime.client.spec.ts index 8b79a42b9a..863abde53d 100644 --- a/packages/client/ui-primitives/tests/mermaid-runtime.client.spec.ts +++ b/packages/client/ui-primitives/tests/mermaid-runtime.client.spec.ts @@ -13,7 +13,24 @@ beforeEach(() => { afterEach(() => { vi.doUnmock('mermaid') }) describe('Mermaid runtime', () => { - it('shares lazy initialization and removes each measurement container after rendering', async () => { + it('accepts native configuration without weakening preview restrictions', async () => { + const { renderMermaid } = await import('../src/markdown/mermaid.ts') + renderDiagram.mockResolvedValue({ svg: '' }) + await renderMermaid('flowchart LR', new AbortController().signal, { + // oxlint-disable-next-line typescript/no-deprecated + layout: 'dagre', flowchart: { nodeSpacing: 40, htmlLabels: true }, + themeVariables: { primaryColor: '#123456' }, + securityLevel: 'loose', startOnLoad: true, suppressErrorRendering: false, htmlLabels: true, secure: [], + }) + expect(initialize.mock.calls[0]![0]).toMatchObject({ + layout: 'dagre', flowchart: { nodeSpacing: 40, htmlLabels: false }, + themeVariables: { primaryColor: '#123456', darkMode: false }, + securityLevel: 'strict', startOnLoad: false, suppressErrorRendering: true, htmlLabels: false, + }) + expect(initialize.mock.calls[0]![0]).toHaveProperty('secure', expect.arrayContaining(['securityLevel'])) + }) + + it('reuses the runtime and removes each measurement container after rendering', async () => { const { renderMermaid } = await import('../src/markdown/mermaid.ts') expect(initialize).not.toHaveBeenCalled() const stages: HTMLElement[] = [] @@ -29,7 +46,7 @@ describe('Mermaid runtime', () => { renderMermaid('中文', new AbortController().signal), renderMermaid('second', new AbortController().signal), ]) - expect(initialize).toHaveBeenCalledOnce() + expect(initialize).toHaveBeenCalledTimes(2) expect(initialize).toHaveBeenCalledWith(expect.objectContaining({ startOnLoad: false, securityLevel: 'strict', suppressErrorRendering: true, htmlLabels: false, secure: [ @@ -42,6 +59,28 @@ describe('Mermaid runtime', () => { expect(stages.every(stage => !stage.isConnected)).toBe(true) }) + it('keeps theme initialization with its render and recovers the queue after failure', async () => { + const { renderMermaid } = await import('../src/markdown/mermaid.ts') + const first = Promise.withResolvers<{ svg: string }>() + renderDiagram.mockReturnValueOnce(first.promise).mockResolvedValue({ svg: '' }) + const light = renderMermaid('first', new AbortController().signal) + const failure = expect(light).rejects.toThrow('invalid') + await vi.waitFor(() => { expect(renderDiagram).toHaveBeenCalledOnce() }) + const previous = document.documentElement.style.colorScheme + try { + document.documentElement.style.colorScheme = 'dark' + const dark = renderMermaid('second', new AbortController().signal) + await Promise.resolve() + expect(initialize).toHaveBeenCalledOnce() + first.reject(new Error('invalid')) + await failure + await dark + expect(initialize.mock.calls[1]![0]).toMatchObject({ themeVariables: { darkMode: true } }) + } finally { + document.documentElement.style.colorScheme = previous + } + }) + it('removes measurement DOM even when Mermaid rejects', async () => { const { renderMermaid } = await import('../src/markdown/mermaid.ts') let stage: HTMLElement | undefined diff --git a/packages/client/ui-primitives/tests/source-preview.client.spec.tsx b/packages/client/ui-primitives/tests/source-preview.client.spec.tsx index fe356f64bb..7e63f06929 100644 --- a/packages/client/ui-primitives/tests/source-preview.client.spec.tsx +++ b/packages/client/ui-primitives/tests/source-preview.client.spec.tsx @@ -25,6 +25,31 @@ afterEach(() => { cleanup(); vi.unstubAllGlobals() }) beforeEach(() => { vi.resetAllMocks() }) describe('SourcePreview', () => { + it('updates mounted previews on theme changes and ignores obsolete completions', async () => { + const previous = document.documentElement.style.colorScheme + const old = Promise.withResolvers() + vi.mocked(renderMermaid).mockReturnValueOnce(old.promise).mockResolvedValue(imageUrl) + const view = render() + try { + const oldSignal = vi.mocked(renderMermaid).mock.calls[0]![1] + await act(async () => { document.documentElement.style.colorScheme = 'dark' }) + expect(oldSignal.aborted).toBe(true) + const image = await screen.findByRole('img') + await act(async () => { old.resolve('obsolete') }) + expect(image.getAttribute('src')).toBe(imageUrl) + await act(async () => { document.body.style.setProperty('--unrelated', '1') }) + expect(renderMermaid).toHaveBeenCalledTimes(2) + await act(async () => { document.documentElement.style.colorScheme = 'light' }) + expect(renderMermaid).toHaveBeenCalledTimes(3) + view.unmount() + await act(async () => { document.documentElement.style.colorScheme = 'dark' }) + expect(renderMermaid).toHaveBeenCalledTimes(3) + } finally { + view.unmount() + document.documentElement.style.colorScheme = previous + document.body.style.removeProperty('--unrelated') + } + }) it('shows loading until rendering completes, then displays an image without inserting SVG', async () => { const pending = Promise.withResolvers() vi.mocked(renderMermaid).mockReturnValue(pending.promise) From 9961eedec860552181501a3376db9d13d0b5fbbb Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 9 Sep 2026 17:48:47 +0800 Subject: [PATCH 043/111] fix(web): preserve office file icons and lazy sidebar replay --- .../feature/2026-09-08-shared-file-type-icons.i18n.yaml | 4 ++-- .../feature/2026-09-08-shared-file-type-icons.md | 4 ++-- .../feature/2026-09-08-shared-file-type-icons.zh.md | 2 +- apps/web/tests/seeded-history.e2e.ts | 2 +- packages/client/ui-primitives/README.i18n.yaml | 4 ++-- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 2 +- packages/client/ui-primitives/src/FileTypeIcon.tsx | 6 ++++++ packages/client/ui-primitives/src/code-file-types.ts | 2 +- .../ui-primitives/tests/file-type-icon.client.spec.tsx | 6 ++++++ .../client/ui-primitives/tests/link-icon.client.spec.tsx | 6 ++++++ 11 files changed, 29 insertions(+), 11 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.i18n.yaml b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.i18n.yaml index ce64968fbd..d9c504d845 100644 --- a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.md -2026-09-08-shared-file-type-icons.md: 0844cb8828edafe135d4f35b6b942691998b8b97 -2026-09-08-shared-file-type-icons.zh.md: 6d10a5669a3c68cfa327997f6822547689e3df1a +2026-09-08-shared-file-type-icons.md: 95380fd642e574389cbdc0d10038e2a0a8d85283 +2026-09-08-shared-file-type-icons.zh.md: 7ba0254206dc026a8967f86036e1bf329ac9ff21 diff --git a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.md b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.md index 0844cb8828..95380fd642 100644 --- a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.md +++ b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.md @@ -10,9 +10,9 @@ Client feature plugins can share React components only through `@deepseek-ai/dsh ## Decision -`ui-primitives` owns one Cordis-free file classification and rendering API. `fileExtension(path)` applies the shared basename and final-dot semantics to either path separator. `classifyFileType(path)` matches without case sensitivity and returns the closed `FileType` union: the traditional `code`, `excel`, `folder`, `html`, `image`, `markdown`, `other`, `pdf`, `ppt`, `video`, and `word` categories plus the detailed `CodeFileType` set imported from the supplied `square-code-icons` map. Exact filenames, filename prefixes, filename suffixes, optional project context, and extensions run in that order. The path classifier returns every member except `folder`; callers use the explicit `FileTypeIcon` `type` override when they know an entry is a directory. Unknown extensions, missing extensions, and trailing dots resolve to `other`, except the shared table recognizes named files such as `Dockerfile`, `Makefile`, `package.json`, `.gitignore`, `README`, and `CHANGELOG`. +`ui-primitives` owns one Cordis-free file classification and rendering API. `fileExtension(path)` applies the shared basename and final-dot semantics to either path separator. `classifyFileType(path)` matches without case sensitivity and returns the closed `FileType` union: the traditional `code`, `excel`, `folder`, `html`, `image`, `markdown`, `other`, `pdf`, `ppt`, `video`, and `word` categories plus the detailed `CodeFileType` categories rendered by `CodeFileIcon`. Exact filenames, filename prefixes, filename suffixes, optional project context, and extensions run in that order. The path classifier returns every member except `folder`; callers use the explicit `FileTypeIcon` `kind` override when they know an entry is a directory. Unknown extensions, missing extensions, and trailing dots resolve to `other`, except the shared table recognizes named files such as `Dockerfile`, `Makefile`, `package.json`, `.gitignore`, `README`, and `CHANGELOG`. -`FileTypeIcon` accepts a path, shared `IconProps`, the optional explicit type, and an optional project-file snapshot. Traditional file types render the supplied 28px document and folder contours as inline SVG. Excel, Markdown, PDF, PPT, and Word foreground marks scale to 122% around their visual center; the remaining marked traditional glyphs use 112%, while the file body and folded corner retain their source geometry and the generic file has no invented center mark. The sheet is a solid category color, the foreground mark and ordinary folded corner are white, and the generic file has a darker grey corner. CSS assigns the supplied category palette through static design tokens: DeepSeek blue for code/HTML/Markdown, the lighter DeepSeek blue for Word, green for Excel, two amber steps for folder/PPT, red for PDF, and neutral grey for unknown files. Image and video share the supplied violet through a component-local variable because the design platform has no matching violet token. A caller may override a traditional sheet through `--dsh-file-type-icon-color`. +`FileTypeIcon` accepts a path, shared `IconProps`, the explicit `kind`, and an optional project-file snapshot. Traditional file types render the supplied 28px document and folder contours as inline SVG. Excel, Markdown, PDF, PPT, and Word foreground marks scale to 122% around their visual center; the remaining marked traditional glyphs use 112%, while the file body and folded corner retain their source geometry and the generic file has no invented center mark. The sheet is a solid category color, the foreground mark and ordinary folded corner are white, and the generic file has a darker grey corner. CSS assigns the supplied category palette through static design tokens: DeepSeek blue for code/HTML/Markdown, the lighter DeepSeek blue for Word, green for Excel, two amber steps for folder/PPT, red for PDF, and neutral grey for unknown files. Image and video share the supplied violet through a component-local variable because the design platform has no matching violet token. A caller may override a traditional sheet through `--dsh-file-type-icon-color`. Recognized code and configuration files render the corresponding 20px square artwork scaled to the requested icon size. These technology marks retain their embedded multicolor fills and are the explicit exception to the ordinary current-color icon rule. The map selects React before TypeScript/JavaScript, Angular filename suffixes before their base extension, Docker/Node/Git/Make/CMake by filename rules, and Flutter only when the optional project snapshot contains a `pubspec.yaml` whose text includes `flutter:`. Markdown and SVG remain owned by the traditional Markdown and image categories. Every traditional and technology SVG is `aria-hidden`, and the card, row, or button that owns the file identity supplies the accessible name. diff --git a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.zh.md b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.zh.md index 6d10a5669a..7ba0254206 100644 --- a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.zh.md +++ b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -`ui-primitives` 统一拥有一套 Cordis-free 的文件分类与渲染 API。`fileExtension(path)` 对两种路径分隔符采用共享的 basename 与最终点号语义。`classifyFileType(path)` 不区分大小写,并返回闭合的 `FileType` 联合:传统的 `code`、`excel`、`folder`、`html`、`image`、`markdown`、`other`、`pdf`、`ppt`、`video`、`word` 类别,以及从所提供 `square-code-icons` 映射接入的细分 `CodeFileType` 集合。解析按完整文件名、文件名前缀、文件名后缀、可选项目上下文、扩展名的顺序执行。路径分类器返回 `folder` 之外的全部成员;调用方确认条目是目录时,通过 `FileTypeIcon` 的显式 `type` 覆盖指定目录。未知扩展名、无扩展名和末尾点号回退到 `other`,但共享表识别 `Dockerfile`、`Makefile`、`package.json`、`.gitignore`、`README`、`CHANGELOG` 等具名文件。 +`ui-primitives` 统一拥有一套 Cordis-free 的文件分类与渲染 API。`fileExtension(path)` 对两种路径分隔符采用共享的 basename 与最终点号语义。`classifyFileType(path)` 不区分大小写,并返回闭合的 `FileType` 联合:传统的 `code`、`excel`、`folder`、`html`、`image`、`markdown`、`other`、`pdf`、`ppt`、`video`、`word` 类别,以及由 `CodeFileIcon` 渲染的细分 `CodeFileType` 集合。解析按完整文件名、文件名前缀、文件名后缀、可选项目上下文、扩展名的顺序执行。路径分类器返回 `folder` 之外的全部成员;调用方确认条目是目录时,通过 `FileTypeIcon` 的显式 `kind` 覆盖指定目录。未知扩展名、无扩展名和末尾点号回退到 `other`,但共享表识别 `Dockerfile`、`Makefile`、`package.json`、`.gitignore`、`README`、`CHANGELOG` 等具名文件。 `FileTypeIcon` 接受路径、共享 `IconProps`、可选的显式类型和可选的项目文件快照。传统文件类型把所提供的 28px 文档与文件夹轮廓渲染为 inline SVG。Excel、Markdown、PDF、PPT、Word 的前景标记围绕自身视觉中心缩放至 122%,其余带标记的传统图形使用 112%;文件底板与折角保持源图几何,通用文件不凭空增加中心标记。底板使用实色分类颜色,前景标记与普通折角使用白色,通用文件使用较深的灰色折角。CSS 通过静态设计 token 分配所提供的分类调色板:code/HTML/Markdown 使用 DeepSeek 蓝,Word 使用较浅的 DeepSeek 蓝,Excel 使用绿色,folder/PPT 使用两档琥珀色,PDF 使用红色,未知文件使用中性灰。image 与 video 通过组件本地变量共用所提供的紫色,因为设计平台没有匹配的紫色 token。调用方可通过 `--dsh-file-type-icon-color` 覆盖传统底板颜色。 diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index 84096583f9..a9d713b93b 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -420,7 +420,7 @@ describe('web e2e: seeded history renders through cold resume', () => { await fileLink.click() await expect.poll(() => frame.getAttribute('data-rightbar-collapsed'), { timeout: 5_000 }).toBe(null) const column = page.locator('[data-rightbar-col]') - await expect.poll(() => column.locator('[data-dockkit-tab-title]').count(), { timeout: 5_000 }).toBe(2) + await expect.poll(() => column.locator('[data-dockkit-tab-title]').allTextContents(), { timeout: 5_000 }).toEqual(['a.txt']) // Path label survives from the recorded args (a.txt). await expect.poll(() => page.getByText('a.txt', { exact: false }).count(), { timeout: 5_000 }).toBeGreaterThan(0) const path = column.locator('[data-textpreview-path]') diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 983dce11c7..d247f941fb 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md -README.md: d6e6a101c9c4397532ac84bc27c519f9d02dcdaf -README.zh.md: 1c41e5185d8f0701bf4d5bd377f1a77b83401bfa +README.md: c511f1de3bd1b16f52ec73297122cd3b575f7c56 +README.zh.md: e8c92fdd6bf91e349fe5ec16271588bf4a9eed02 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index d6e6a101c9..c511f1de3b 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -67,7 +67,7 @@ Writing your own component in your own package is fine when the need is genuinel ### Controls and icons -The catalog above lists what each export is for; this section covers the behavior that props alone do not show. The `ic_ds_*` icon set and `FishLogo`/`BrandWordmark` marks fill brand and inline-icon slots. `FileTypeIcon` renders the traditional 28px Excel, folder, HTML, image, Markdown, generic, PDF, PPT, video, and Word glyphs, and uses the full-color square technology artwork for recognized code and configuration files. `classifyFileType` applies exact filename, prefix, suffix, optional project-context, and extension rules in that order; React names win over TypeScript/JavaScript, Angular suffixes win over their base extension, and a Dart file becomes Flutter only when the supplied project files contain a `pubspec.yaml` with `flutter:`. Markdown and SVG remain traditional Markdown and image files. `fileExtension` exposes the same basename and final-dot parsing for adjacent metadata labels. Traditional glyphs use a solid category-colored sheet with a white mark and translucent white corner; the generic file uses a grey sheet and darker grey corner. Callers may override the sheet color through `--dsh-file-type-icon-color`. The full-color technology artwork is the deliberate exception and retains its embedded palette. All glyphs are decorative and carry no label. `LinkIcon` remains the smaller leading glyph for clickable artifact links — globe, folder, code, image, document, or plain paper — while `classifyLinkPath` folds the shared file types into that existing six-category vocabulary. `ConnectionIndicator` renders a warning-colored disconnected action, a connecting label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Hover or keyboard focus shows only the reconnect action label, including while the connecting dots animate. Every state reserves the widest supplied label and uses fixed icon and text columns, so copy changes do not move or resize the control. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. `Toast` holds for the window its owner names through `holdMs`, because how long a banner has to stay depends on how much there is to read; the same value drives its unmount timer and the stylesheet's fade delay, so the two cannot disagree. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order. `Menu.autoFocus` focuses its first enabled item, supports Arrow Up/Down and Home/End navigation, and focuses the first button in the anchor on Escape; it is opt-in for action menus. +The catalog above lists what each export is for; this section covers the behavior that props alone do not show. The `ic_ds_*` icon set and `FishLogo`/`BrandWordmark` marks fill brand and inline-icon slots. `FileTypeIcon` renders the traditional 28px Excel, folder, HTML, image, Markdown, generic, PDF, PPT, video, and Word glyphs, and uses the full-color square technology artwork for recognized code and configuration files. `classifyFileType` applies exact filename, prefix, suffix, optional project-context, and extension rules in that order; React names win over TypeScript/JavaScript, Angular suffixes win over their base extension, and a Dart file becomes Flutter only when the supplied project files contain a `pubspec.yaml` with `flutter:`. Markdown and SVG remain traditional Markdown and image files. Office mappings include XLSM/Numbers as Excel, KEY as slides, and RTF/ODT/Pages as documents. `fileExtension` exposes the same basename and final-dot parsing for adjacent metadata labels. Traditional glyphs use a solid category-colored sheet with a white mark and translucent white corner; the generic file uses a grey sheet and darker grey corner. Callers may override the sheet color through `--dsh-file-type-icon-color`. The full-color technology artwork is the deliberate exception and retains its embedded palette. All glyphs are decorative and carry no label. `LinkIcon` remains the smaller leading glyph for clickable artifact links — globe, folder, code, image, document, or plain paper — while `classifyLinkPath` folds the shared file types into that existing six-category vocabulary. `ConnectionIndicator` renders a warning-colored disconnected action, a connecting label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Hover or keyboard focus shows only the reconnect action label, including while the connecting dots animate. Every state reserves the widest supplied label and uses fixed icon and text columns, so copy changes do not move or resize the control. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. `Toast` holds for the window its owner names through `holdMs`, because how long a banner has to stay depends on how much there is to read; the same value drives its unmount timer and the stylesheet's fade delay, so the two cannot disagree. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order. `Menu.autoFocus` focuses its first enabled item, supports Arrow Up/Down and Home/End navigation, and focuses the first button in the anchor on Escape; it is opt-in for action menus. ### Rendering agent output diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 1c41e5185d..e8c92fdd6b 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -67,7 +67,7 @@ kind: "package-library" ### 控件与图标 -上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。`ic_ds_*` 图标集与 `FishLogo`/`BrandWordmark` 标记填充品牌与行内图标 slot。`FileTypeIcon` 渲染传统的 28px Excel、folder、HTML、image、Markdown、generic、PDF、PPT、video 与 Word 图形,并为已识别的代码和配置文件使用全彩方形技术图形。`classifyFileType` 按完整文件名、前缀、后缀、可选项目上下文、扩展名的顺序匹配;React 文件名优先于 TypeScript/JavaScript,Angular 后缀优先于基础扩展名,只有传入的项目文件包含带 `flutter:` 的 `pubspec.yaml` 时 Dart 文件才使用 Flutter。Markdown 与 SVG 仍分别使用传统 Markdown 与图片图形。`fileExtension` 为相邻元数据 label 暴露同一套 basename 与最终点号解析。传统图形使用实色分类底板、白色标记和半透明白色折角;通用文件使用灰色底板与较深灰色折角。调用方可通过 `--dsh-file-type-icon-color` 覆盖底板颜色。全彩技术图形是明确例外,会保留其内嵌调色板。所有图形都是装饰性的,不自带 label。`LinkIcon` 仍是可点击产物链接较小的前置分类图形——地球、文件夹、代码、图片、文档或纸张——`classifyLinkPath` 把共享文件类型折叠进原有六类词汇。`ConnectionIndicator` 可渲染警告色的断联操作、以独立于 retry 时序的 500ms 节奏推进一至三个点的连接中状态,或成功色的恢复状态。悬停或键盘聚焦时只显示重连操作文案,连接中的圆点动画也保持隐藏。所有状态都为最长的输入 label 预留空间,并使用固定的图标列和文字列,因此文案变化不会移动控件或改变其宽度。它的 owner 提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。 `Toast` 的停留时长由使用方通过 `holdMs` 指定,因为横幅该留多久取决于有多少内容要读;同一个值同时驱动它的卸载定时器与样式表的淡出延迟,两者不可能再错位。 `rankByName` 是 `/` 菜单命令源与 skill 源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 `Menu.autoFocus` 聚焦首个启用项,支持上下方向键与 Home/End 导航,并在 Escape 时聚焦 anchor 内的第一个按钮;操作菜单可显式启用。 +上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。`ic_ds_*` 图标集与 `FishLogo`/`BrandWordmark` 标记填充品牌与行内图标 slot。`FileTypeIcon` 渲染传统的 28px Excel、folder、HTML、image、Markdown、generic、PDF、PPT、video 与 Word 图形,并为已识别的代码和配置文件使用全彩方形技术图形。`classifyFileType` 按完整文件名、前缀、后缀、可选项目上下文、扩展名的顺序匹配;React 文件名优先于 TypeScript/JavaScript,Angular 后缀优先于基础扩展名,只有传入的项目文件包含带 `flutter:` 的 `pubspec.yaml` 时 Dart 文件才使用 Flutter。Markdown 与 SVG 仍分别使用传统 Markdown 与图片图形。办公文件映射包含 XLSM/Numbers 的表格图标、KEY 的幻灯片图标,以及 RTF/ODT/Pages 的文档图标。`fileExtension` 为相邻元数据 label 暴露同一套 basename 与最终点号解析。传统图形使用实色分类底板、白色标记和半透明白色折角;通用文件使用灰色底板与较深灰色折角。调用方可通过 `--dsh-file-type-icon-color` 覆盖底板颜色。全彩技术图形是明确例外,会保留其内嵌调色板。所有图形都是装饰性的,不自带 label。`LinkIcon` 仍是可点击产物链接较小的前置分类图形——地球、文件夹、代码、图片、文档或纸张——`classifyLinkPath` 把共享文件类型折叠进原有六类词汇。`ConnectionIndicator` 可渲染警告色的断联操作、以独立于 retry 时序的 500ms 节奏推进一至三个点的连接中状态,或成功色的恢复状态。悬停或键盘聚焦时只显示重连操作文案,连接中的圆点动画也保持隐藏。所有状态都为最长的输入 label 预留空间,并使用固定的图标列和文字列,因此文案变化不会移动控件或改变其宽度。它的 owner 提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。 `Toast` 的停留时长由使用方通过 `holdMs` 指定,因为横幅该留多久取决于有多少内容要读;同一个值同时驱动它的卸载定时器与样式表的淡出延迟,两者不可能再错位。 `rankByName` 是 `/` 菜单命令源与 skill 源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 `Menu.autoFocus` 聚焦首个启用项,支持上下方向键与 Home/End 导航,并在 Escape 时聚焦 anchor 内的第一个按钮;操作菜单可显式启用。 ### 渲染 agent 输出 diff --git a/packages/client/ui-primitives/src/FileTypeIcon.tsx b/packages/client/ui-primitives/src/FileTypeIcon.tsx index 6df0cdbe7b..8d12b22726 100644 --- a/packages/client/ui-primitives/src/FileTypeIcon.tsx +++ b/packages/client/ui-primitives/src/FileTypeIcon.tsx @@ -77,6 +77,7 @@ const EXTENSION_TYPES: Readonly> = { pdf: 'pdf', ppt: 'ppt', pptx: 'ppt', + key: 'ppt', mp4: 'video', mov: 'video', m4v: 'video', @@ -87,8 +88,13 @@ const EXTENSION_TYPES: Readonly> = { mpeg: 'video', doc: 'word', docx: 'word', + rtf: 'word', + odt: 'word', + pages: 'word', xls: 'excel', xlsx: 'excel', + xlsm: 'excel', + numbers: 'excel', } const NAME_TYPES: Readonly> = { diff --git a/packages/client/ui-primitives/src/code-file-types.ts b/packages/client/ui-primitives/src/code-file-types.ts index f82aa24370..b30a6c6882 100644 --- a/packages/client/ui-primitives/src/code-file-types.ts +++ b/packages/client/ui-primitives/src/code-file-types.ts @@ -1,4 +1,4 @@ -/** Full-color code and configuration glyphs supplied by square-code-icons v1. */ +/** Code and configuration categories with embedded full-color glyphs. */ export const CODE_FILE_TYPES = [ 'angular', 'c', diff --git a/packages/client/ui-primitives/tests/file-type-icon.client.spec.tsx b/packages/client/ui-primitives/tests/file-type-icon.client.spec.tsx index 65f9dbaecb..12d0867088 100644 --- a/packages/client/ui-primitives/tests/file-type-icon.client.spec.tsx +++ b/packages/client/ui-primitives/tests/file-type-icon.client.spec.tsx @@ -28,6 +28,12 @@ describe('classifyFileType', () => { ['data/export.CSV', 'code'], ['shots/hero.png', 'image'], ['notes/README.MDX', 'markdown'], + ['report.xlsm', 'excel'], + ['budget.numbers', 'excel'], + ['deck.key', 'ppt'], + ['letter.rtf', 'word'], + ['letter.odt', 'word'], + ['letter.pages', 'word'], ['report.pdf', 'pdf'], ['deck.pptx', 'ppt'], ['clip.webm', 'video'], diff --git a/packages/client/ui-primitives/tests/link-icon.client.spec.tsx b/packages/client/ui-primitives/tests/link-icon.client.spec.tsx index 8b1152a65e..e644f451e8 100644 --- a/packages/client/ui-primitives/tests/link-icon.client.spec.tsx +++ b/packages/client/ui-primitives/tests/link-icon.client.spec.tsx @@ -11,6 +11,12 @@ describe('classifyLinkPath', () => { ['site/index.html', 'code'], ['data/export.CSV', 'code'], ['shots/hero.png', 'image'], + ['report.xlsm', 'document'], + ['budget.numbers', 'document'], + ['deck.key', 'document'], + ['letter.rtf', 'document'], + ['letter.odt', 'document'], + ['letter.pages', 'document'], ['report.pdf', 'document'], ['budget.xlsx', 'document'], ['deck.pptx', 'document'], From 454caf0280f2fded5696afa38a7dcf038225f5e6 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 9 Sep 2026 17:56:11 +0800 Subject: [PATCH 044/111] fix(web): reject empty sidebar splits and clarify page targeting --- ...t-sidebar-docking-infrastructure.i18n.yaml | 4 +- ...04-right-sidebar-docking-infrastructure.md | 6 +-- ...right-sidebar-docking-infrastructure.zh.md | 6 +-- ...debar-text-preview-and-file-tree.i18n.yaml | 4 +- ...9-05-sidebar-text-preview-and-file-tree.md | 2 +- ...5-sidebar-text-preview-and-file-tree.zh.md | 2 +- ...-08-sidebar-last-tab-close-rules.i18n.yaml | 4 +- ...2026-09-08-sidebar-last-tab-close-rules.md | 2 +- ...6-09-08-sidebar-last-tab-close-rules.zh.md | 2 +- ...r-and-preview-interaction-polish.i18n.yaml | 4 +- ...-sidebar-and-preview-interaction-polish.md | 2 +- ...debar-and-preview-interaction-polish.zh.md | 2 +- .../ui-primitives/tests/atoms.client.spec.tsx | 13 +++--- .../client/ui-sidebar-right/README.i18n.yaml | 4 +- packages/client/ui-sidebar-right/README.md | 6 +-- packages/client/ui-sidebar-right/README.zh.md | 6 +-- .../src/client/contract/slots.ts | 2 +- .../ui-sidebar-right/src/client/service.ts | 6 +-- .../ui-sidebar-right/src/client/stores.ts | 3 +- .../tests/service.client.spec.ts | 2 +- .../tests/stores.client.spec.ts | 42 +++++++++++++++++++ 21 files changed, 85 insertions(+), 39 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.i18n.yaml b/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.i18n.yaml index 6c2a754dc0..2a65513f7d 100644 --- a/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md -2026-09-04-right-sidebar-docking-infrastructure.md: 3f6bcff565598c4d8a24e374d308c388c1f6bb0a -2026-09-04-right-sidebar-docking-infrastructure.zh.md: aea7aacc4cb50b8974ada4a7e7eec27ed41623b9 +2026-09-04-right-sidebar-docking-infrastructure.md: 936f73c9210baf4297eace2deb270bb9594d7e8d +2026-09-04-right-sidebar-docking-infrastructure.zh.md: d234dbcbb2f60ba1dd3b3d9fca2936a16521751d diff --git a/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md b/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md index 3f6bcff565..936f73c921 100644 --- a/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md +++ b/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md @@ -39,7 +39,7 @@ The right Sidebar uses one mounted content tree in normal and fullscreen modes; [Default pages](2026-09-08-sidebar-default-pages.md) supersede default-guide reseeding here; [last-tab close rules](2026-09-08-sidebar-last-tab-close-rules.md) own explicit closing, while moving tabs still settles emptied panes. -`ui-sidebar-right` keeps one `SurfaceState` per session id — the layout, its history, and the mint counter — in a store declared at the seat registration. Every action mints the ids its intent needs, asks a kit planner for the operations, runs the settle planner over the result, and records the whole intent as one history entry before assigning the session's surface back; no action edits a layout in place. The settle step is the product's rule: a docked pane whose last tab is closed, moved out, or floated is merged away, and when only the root pane remains and it is empty, the current default page is reseeded — there is always at least one tab and never an empty pane, so no pane-closing gesture exists. State is memory-only: a reload returns every session to the collapsed default, and switching sessions keeps each surface where it was. Layout is presentation state and never enters the session log. +`ui-sidebar-right` keeps one `SurfaceState` per session id — the layout, its history, and the mint counter — in a store declared at the seat registration. Every action mints the ids its intent needs, asks a kit planner for the operations, runs the settle planner over the result, and records the whole intent as one history entry before assigning the session's surface back; no action edits a layout in place. The settle step is the product's rule: a docked pane whose last tab is closed, moved out, or floated is merged away, and an expanded empty root pane receives the current default page. A collapsed surface may remain empty until its next expansion; no separate pane-closing gesture exists. State is memory-only: a reload returns every session to the collapsed default, and switching sessions keeps each surface where it was. Layout is presentation state and never enters the session log. ### Beyond the surface @@ -69,7 +69,7 @@ The surface renders tabs whose bodies it does not know: each tab carries a `kind **Undo and redo buttons on the panel header.** Shipped first, then removed: the sequence is an architectural fact, and stepping it is not a product action yet. The API stays reachable as `@internal` methods for tests and the future navigation controller. -**Empty panes as a persistent state.** The first design allowed a pane to stay after its last tab left, with a placeholder. Rejected because nothing offered a way to close such a pane; every intent settles the surface so an emptied pane is merged away and an emptied root pane reseeds the current default page. +**Empty panes as a persistent state.** The first design allowed a pane to stay after its last tab left, with a placeholder. Rejected because nothing offered a way to close such a pane; every intent settles the surface so an emptied side pane is merged away. An empty root receives the current default page only while the column is expanded. **Inline the kit through `packages/util` and the `INLINE_SAFE` list.** A build probe showed it works, but the util build chain has no CSS pipeline and the kit ships a stylesheet; the static-linked client package (the `ui-primitives` precedent) was chosen knowing that changing the kit means rebuilding the shell and reloading. @@ -77,7 +77,7 @@ The surface renders tabs whose bodies it does not know: each tab carries a `kind - The docking surface itself no longer overflows its panel: `.surface` and `.pane` clamp to the column (`min-width: 0`, `overflow: hidden`), so a long unwrapped line scrolls inside the body and the strip's controls stay in view in every split. - Layout is undoable and per session, and it is memory-only; a reload starts every session collapsed. Undo is reachable only through `@internal` service methods; the product shows no history controls. -- A pane cannot be left empty and the surface cannot be left tabless: closing, moving out, or floating a pane's last tab drops the pane, and emptying the last pane restores the current default page. +- An expanded surface has no empty panes. Empty side panes merge away; an empty root receives the current default page only while expanded. New sessions and a collapsed surface whose last tab closed remain empty until expansion. - A pane holds at most one guide tab: a second one cannot be added, opened, duplicated, or moved in; the guide's uniqueness is per pane, so a split still seeds its new pane with a guide. - A pane may split only when each equal half can still hold what cannot shrink: the strip's fixed controls (its width minus the chip box and the fill, so the top-right pane's chrome counts on the half that hosts it) plus one chip at its minimum, measured in the component layer after every commit and on resize. Otherwise the split control stays, disabled with its own copy, the matching edge drop zones are withheld, and panes the user narrows keep their size; the product permits at most two horizontal panes, regardless of widening or divider movement. - The Sidebar panel never moves when the presentation switches, and its slide is the same in both presentations; the conversation is the only thing that animates on a switch. A hidden panel keeps its tabs mounted, so a preview survives a collapse. diff --git a/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md b/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md index aea7aacc4c..d234dbcbb2 100644 --- a/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md +++ b/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md @@ -39,7 +39,7 @@ Agent 产出的文件是最尖锐的案例。产出文件 chip 或 `read` 行的 [默认页](2026-09-08-sidebar-default-pages.zh.md)取代此处的默认补入引导页;[最后一个 tab 的关闭规则](2026-09-08-sidebar-last-tab-close-rules.zh.md)负责显式关闭,移动 tab 仍会处理被清空的格。 -`ui-sidebar-right` 为每个会话 id 保存一份 `SurfaceState`——布局、历史与铸造计数——住在坑位注册时声明的 store 里。每个 action 先铸造意图所需的 id,向库的 planner 索取操作,对结果跑一遍 settle planner,把整个意图记为一条历史账,再把该会话的 surface 整体赋回;没有 action 就地改布局。settle 是产品规则:最后一个 tab 被关闭、拖走或悬浮出去的停靠 pane 会被合并掉;只剩根 pane 且为空时重新种上当前默认页——永远至少有一个 tab、永远没有空 pane,所以不存在"关闭 pane"手势。状态仅在内存:刷新使所有会话回到折叠默认态,切换会话时各 surface 保持原样。布局是呈现状态,永不进入会话日志。 +`ui-sidebar-right` 为每个会话 id 保存一份 `SurfaceState`——布局、历史与铸造计数——住在坑位注册时声明的 store 里。每个 action 先铸造意图所需的 id,向库的 planner 索取操作,对结果跑一遍 settle planner,把整个意图记为一条历史账,再把该会话的 surface 整体赋回;没有 action 就地改布局。settle 是产品规则:最后一个 tab 被关闭、拖走或悬浮出去的停靠 pane 会被合并掉;展开且为空的根 pane 会填入当前默认页。折叠的布局可以保持为空,直到下次展开;没有单独的关闭 pane 手势。状态仅在内存:刷新使所有会话回到折叠默认态,切换会话时各 surface 保持原样。布局是呈现状态,永不进入会话日志。 ### 面之外 @@ -69,7 +69,7 @@ Agent 产出的文件是最尖锐的案例。产出文件 chip 或 `read` 行的 **面板头部的 undo 与 redo 按钮。** 先上后撤:序列是架构事实,步进它现在还不是产品动作。API 以 `@internal` 方法保留给测试与将来的导航控制器。 -**空 pane 作为一种持久状态。** 第一版允许 pane 在最后一个 tab 离开后带占位留下。否决,因为没有任何方式关掉这样的 pane;每个意图都会整理 surface,被清空的 pane 合并掉,被清空的根 pane 重新种上当前默认页。 +**空 pane 作为一种持久状态。** 第一版允许 pane 在最后一个 tab 离开后带占位留下。否决,因为没有任何方式关掉这样的 pane;每个意图都会整理 surface,被清空的侧 pane 合并掉。空根 pane 只在该列展开时填入当前默认页。 **经 `packages/util` 与 `INLINE_SAFE` 清单内联库。** 构建探针证明可行,但 util 构建链没有 CSS 管线而库带样式表;在知晓改库须重建壳并刷新页面的前提下,选择静态链接的 client 包(`ui-primitives` 先例)。 @@ -77,7 +77,7 @@ Agent 产出的文件是最尖锐的案例。产出文件 chip 或 `read` 行的 - 停靠面自身不再溢出面板:`.surface` 与 `.pane` 收在列内(`min-width: 0`、`overflow: hidden`),长的不换行行在正文内滚动,tab 条控件在任何分栏下都可见。 - 布局可撤销且按会话隔离,同时仅在内存;刷新使所有会话回到折叠态。undo 只能经 `@internal` 服务方法触达;产品不显示历史控件。 -- pane 不能留空、surface 不能没有 tab:关闭、拖走或悬浮出 pane 的最后一个 tab 会删掉该 pane,清空最后一个 pane 会恢复当前默认页。 +- 展开的布局不保留空 pane。空侧 pane 被合并,空根 pane 只在展开时填入当前默认页。新会话以及关闭最后一个 tab 后收起的布局保持为空,直到下次展开。 - 一个 pane 最多持有一个引导 tab:第二个不能被添加、打开、复制或搬入;唯一性按 pane 算,所以分栏仍给新 pane 种引导。 - pane 只有在等分后的两半都仍能容下不可收缩部分时才可分栏:tab 条的固定控件(条宽减去 chip 盒与填充,因此右上 pane 的面板控件只计在承载它的那一半)加一个最小宽度的 chip,由组件层在每次提交与尺寸变化后测量。否则分栏控件保留但禁用并带自己的文案,对应的边缘落区不再提供,用户拖窄的 pane 保持原尺寸;产品最多两个水平窗格,不因拉宽或拖分隔条而提高上限。 - 切换呈现模式时 Sidebar 面板一动不动,两种模式的平移一模一样;切换时只有会话区在动。隐藏的面板保持 tab 挂载,预览在折叠后仍在。 diff --git a/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.i18n.yaml b/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.i18n.yaml index 831ed01531..4e4b039406 100644 --- a/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.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-05-sidebar-text-preview-and-file-tree.md -2026-09-05-sidebar-text-preview-and-file-tree.md: 1a743c35634c5772e8173b770228a7f736a9d710 -2026-09-05-sidebar-text-preview-and-file-tree.zh.md: 72253286862f1e8729683fb719e83cfcf544d8e8 +2026-09-05-sidebar-text-preview-and-file-tree.md: 9c0b6db832f19ba758ba2d3851db72f9a1fdb785 +2026-09-05-sidebar-text-preview-and-file-tree.zh.md: a5173c20b5203942df3c6f86b2bef5961946fa08 diff --git a/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.md b/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.md index 1a743c3563..9c0b6db832 100644 --- a/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.md +++ b/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.md @@ -24,7 +24,7 @@ The body is a centred column — a lead line (`侧栏用来放你想一直看着 The body is also the replacement seam. It renders the `sidebar.right.tab.guide` chain with the shipped guide as the chain's fallback, so a product that registers its own entry takes the whole body, and with no entry, or every entry declining, the shipped guide draws. Because the shipped guide is the fallback and not a chain entry, there is always exactly one body and it cannot be outvoted by accident. -A pane holds at most one guide, and the docking layer enforces it as product behaviour: the strip's add control hides while a guide is present, opening the guide into such a pane focuses it, a guide is never duplicated, and a guide dragged, dropped, or docked into a pane that already has one merges into it (the arriving tab closes). Settling a surface reseeds the guide when the root pane empties, so there is always at least one tab and never an empty pane. +A pane holds at most one guide, and the docking layer enforces it as product behaviour: the strip's add control hides while a guide is present, opening the guide into such a pane focuses it, a guide is never duplicated, and a guide dragged, dropped, or docked into a pane that already has one merges into it (the arriving tab closes). An expanded empty root receives the current default page. Collapsed layouts may remain empty until expansion, when the default-page selection runs. ### The text preview diff --git a/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md b/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md index 7225328686..a5173c20b5 100644 --- a/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md +++ b/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md @@ -24,7 +24,7 @@ Sidebar 随包交付三个 tab 类型:**引导页**(`ui-sidebar-right`)、 体同时也是替换接缝。它渲染 `sidebar.right.tab.guide` 链,并以随包交付的引导页作为链的 fallback,于是注册了自己入口的产品接管整个体,而没有入口、或每个入口都拒绝时,随包交付的引导页照常绘制。因为随包交付的引导页是 fallback 而不是链上的一个入口,所以永远恰有一个体,也不可能被意外投掉。 -一个 pane 最多持有一个引导页,停靠层把这条作为产品行为强制执行:有引导页时 tab 条的添加控件隐藏,往这样的 pane 打开引导页只是聚焦它,引导页永不复制,被拖拽、落下或回坞进已有引导页的 pane 的引导页并入它(来者关闭)。settle 一个 surface 时,根 pane 空了就重新播下引导页,于是永远至少有一个 tab、永远没有空 pane。 +一个 pane 最多持有一个引导页,停靠层把这条作为产品行为强制执行:有引导页时 tab 条的添加控件隐藏,往这样的 pane 打开引导页只是聚焦它,引导页永不复制,被拖拽、落下或回坞进已有引导页的 pane 的引导页并入它(来者关闭)。展开且为空的根 pane 会填入当前默认页。折叠的布局可以保持为空,展开时才选择并创建默认页。 ### 文本预览 diff --git a/.agents/notes/implemented/feature/2026-09-08-sidebar-last-tab-close-rules.i18n.yaml b/.agents/notes/implemented/feature/2026-09-08-sidebar-last-tab-close-rules.i18n.yaml index 0f2da39358..2846db77ba 100644 --- a/.agents/notes/implemented/feature/2026-09-08-sidebar-last-tab-close-rules.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-08-sidebar-last-tab-close-rules.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-sidebar-last-tab-close-rules.md -2026-09-08-sidebar-last-tab-close-rules.md: 4fb0e54af1075dd595d18db4d74779a7cfd2656b -2026-09-08-sidebar-last-tab-close-rules.zh.md: 87cadf0f15eecb0cadc2fb2dacc5695771fd66f7 +2026-09-08-sidebar-last-tab-close-rules.md: ed76b0ad0b8bbadcf5eace12a7da3bd38c733d0e +2026-09-08-sidebar-last-tab-close-rules.zh.md: bcc54c0c39cc65434f21ef8f6f81a382b6eecdb7 diff --git a/.agents/notes/implemented/feature/2026-09-08-sidebar-last-tab-close-rules.md b/.agents/notes/implemented/feature/2026-09-08-sidebar-last-tab-close-rules.md index 4fb0e54af1..ed76b0ad0b 100644 --- a/.agents/notes/implemented/feature/2026-09-08-sidebar-last-tab-close-rules.md +++ b/.agents/notes/implemented/feature/2026-09-08-sidebar-last-tab-close-rules.md @@ -10,7 +10,7 @@ The settle planner guarantees the docked surface is never empty: closing the las ## Decision -The docked surface's last tab carries one rule, decided in the Sidebar store's `closeTab` and mirrored to the kit through a new `canCloseTab(tabId)` control-policy prop (joining `canSplit` and `canAddTab`): the guide standing as the only docked tab is unclosable — no chip close control, no menu close item, and a programmatic close records nothing; any other lone tab closes together with the column in one history entry, resets fullscreen to push mode, and lets the settle planner prepare the current default page for the next expansion. `soleDockedTab(state, tabId)` in [stores.ts](../../../../packages/client/ui-sidebar-right/src/client/stores.ts) names the condition; floating panels take no part in it. Per the packages rule "enforce a decision in the operation that makes it", the store's `closeTab` is the enforcement and `canCloseTab` only mirrors it into the chrome. This rule supersedes the close-protection part of [the default-page decision](2026-09-08-sidebar-default-pages.md); its selection rule remains active. +The docked surface's last tab carries one rule, decided in the Sidebar store's `closeTab` and mirrored to the kit through a new `canCloseTab(tabId)` control-policy prop (joining `canSplit` and `canAddTab`): the guide standing as the only docked tab is unclosable — no chip close control, no menu close item, and a programmatic close records nothing; any other lone tab closes together with the column in one history entry, resets fullscreen to push mode, and leaves the layout empty until the next expansion seeds the then-current default page. `soleDockedTab(state, tabId)` in [stores.ts](../../../../packages/client/ui-sidebar-right/src/client/stores.ts) names the condition; floating panels take no part in it. Per the packages rule "enforce a decision in the operation that makes it", the store's `closeTab` is the enforcement and `canCloseTab` only mirrors it into the chrome. This rule supersedes the close-protection part of [the default-page decision](2026-09-08-sidebar-default-pages.md); its selection rule remains active. Two kit-side presentation rules complete it in [TabPanel.tsx](../../../../packages/client/ui-dockkit/src/components/TabPanel.tsx) and [TabMenu.tsx](../../../../packages/client/ui-dockkit/src/components/TabMenu.tsx): a pane's lone chip whose close is withheld draws quiet — no capsule, no hover fill — since there is nothing to select against and nothing to do to it; and a menu that would hold no item at all produces no visible popup, so a secondary press on such a chip shows nothing rather than an empty box. diff --git a/.agents/notes/implemented/feature/2026-09-08-sidebar-last-tab-close-rules.zh.md b/.agents/notes/implemented/feature/2026-09-08-sidebar-last-tab-close-rules.zh.md index 87cadf0f15..bcc54c0c39 100644 --- a/.agents/notes/implemented/feature/2026-09-08-sidebar-last-tab-close-rules.zh.md +++ b/.agents/notes/implemented/feature/2026-09-08-sidebar-last-tab-close-rules.zh.md @@ -10,7 +10,7 @@ settle planner 保证停靠面永不为空:关掉最后一个 tab 会重新播 ## 决定 -停靠面的最后一个 tab 带一条规则,由 Sidebar store 的 `closeTab` 决定,并经新的控制策略 prop `canCloseTab(tabId)`(与 `canSplit`、`canAddTab` 并列)镜像给套件:作为唯一停靠 tab 的引导页不可关闭——chip 上没有关闭控件,菜单里没有关闭项,编程式关闭什么都不记录;任何其它 tab 独自留下时,关闭会连同整列一起收起、把全屏重置为挤压模式并记为一条历史,于是 settle planner 为下次展开准备当前默认页。[stores.ts](../../../../packages/client/ui-sidebar-right/src/client/stores.ts) 里的 `soleDockedTab(state, tabId)` 命名这个条件;浮动面板不参与。按照 packages 规则「在做出决定的操作里执行它」,store 的 `closeTab` 是执行点,`canCloseTab` 只是把它镜像到界面。本规则取代[默认页决策](2026-09-08-sidebar-default-pages.zh.md)中的关闭保护部分;其默认页选择规则仍然有效。 +停靠面的最后一个 tab 带一条规则,由 Sidebar store 的 `closeTab` 决定,并经新的控制策略 prop `canCloseTab(tabId)`(与 `canSplit`、`canAddTab` 并列)镜像给套件:作为唯一停靠 tab 的引导页不可关闭——chip 上没有关闭控件,菜单里没有关闭项,编程式关闭什么都不记录;任何其它 tab 独自留下时,关闭会连同整列一起收起、把全屏重置为挤压模式并记为一条历史,布局保持为空,直到下次展开时创建当时的默认页。[stores.ts](../../../../packages/client/ui-sidebar-right/src/client/stores.ts) 里的 `soleDockedTab(state, tabId)` 命名这个条件;浮动面板不参与。按照 packages 规则「在做出决定的操作里执行它」,store 的 `closeTab` 是执行点,`canCloseTab` 只是把它镜像到界面。本规则取代[默认页决策](2026-09-08-sidebar-default-pages.zh.md)中的关闭保护部分;其默认页选择规则仍然有效。 两条套件侧的呈现规则在 [TabPanel.tsx](../../../../packages/client/ui-dockkit/src/components/TabPanel.tsx) 与 [TabMenu.tsx](../../../../packages/client/ui-dockkit/src/components/TabMenu.tsx) 里补全它:某格仅剩的一个 chip 在关闭被收起时画成安静样式——没有胶囊底色,没有悬停填充——因为既没有别的 tab 可供选择,也没有任何可对它做的事;一个连一项都没有的菜单不会产生可见弹层,于是对这样的 chip 次键按下什么都不显示,而不是画一个空框。 diff --git a/.agents/notes/implemented/feature/2026-09-09-sidebar-and-preview-interaction-polish.i18n.yaml b/.agents/notes/implemented/feature/2026-09-09-sidebar-and-preview-interaction-polish.i18n.yaml index a6a4fb8c4a..530553e7cc 100644 --- a/.agents/notes/implemented/feature/2026-09-09-sidebar-and-preview-interaction-polish.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-09-sidebar-and-preview-interaction-polish.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-09-sidebar-and-preview-interaction-polish.md -2026-09-09-sidebar-and-preview-interaction-polish.md: 63205125fc5833f643217d38fabc9cb897ab1f9c -2026-09-09-sidebar-and-preview-interaction-polish.zh.md: bcfcbeb61cae71b45539393df20a9a6bfc00e3f0 +2026-09-09-sidebar-and-preview-interaction-polish.md: df0e768aa1aea913344c356d84c2a7d3386ccc6b +2026-09-09-sidebar-and-preview-interaction-polish.zh.md: d3f8b00fd86ec12d867d48a917cd4a65cab18ba9 diff --git a/.agents/notes/implemented/feature/2026-09-09-sidebar-and-preview-interaction-polish.md b/.agents/notes/implemented/feature/2026-09-09-sidebar-and-preview-interaction-polish.md index 63205125fc..df0e768aa1 100644 --- a/.agents/notes/implemented/feature/2026-09-09-sidebar-and-preview-interaction-polish.md +++ b/.agents/notes/implemented/feature/2026-09-09-sidebar-and-preview-interaction-polish.md @@ -10,7 +10,7 @@ Five small interaction defects around the right Sidebar and the document preview ## Decision -**Default pages seed lazily.** `createSurface` in [stores.ts](../../../../packages/client/ui-sidebar-right/src/client/stores.ts) mints a collapsed, empty surface; the store's `advance` passes the seed factory to `planSettle` only when the intent leaves the column expanded. The expansion that would first show an empty layout is what seeds the then-current default page, and closing the last closable tab collapses the column and leaves the layout empty until the next expansion. The [last-tab close rule](2026-09-08-sidebar-last-tab-close-rules.md) and [default-page selection](2026-09-08-sidebar-default-pages.md) stay as decided; only the seeding moment moved. +**Default pages seed lazily.** `createSurface` in [stores.ts](../../../../packages/client/ui-sidebar-right/src/client/stores.ts) mints a collapsed, empty surface; the store's `advance` passes the seed factory to `planSettle` only when the intent leaves the column expanded. The expansion that would first show an empty layout is what seeds the then-current default page, and closing the last closable tab collapses the column and leaves the layout empty until the next expansion. The [last-tab close rule](2026-09-08-sidebar-last-tab-close-rules.md) and [default-page selection](2026-09-08-sidebar-default-pages.md) stay as decided; default pages are created on expansion. Splitting an empty pane is a no-op: it mints no tabs, records no history, and reports no new pane. **Page uniqueness is pane-scoped.** The guide-only merge generalized to every page kind (`pageKind`/`panePage`): opening a page focuses an existing tab only inside the pane the open targets, and a page dragged, dropped, or docked into a pane already showing that kind's page merges into the pane's own. Resource tabs keep the kit's surface-wide reveal. diff --git a/.agents/notes/implemented/feature/2026-09-09-sidebar-and-preview-interaction-polish.zh.md b/.agents/notes/implemented/feature/2026-09-09-sidebar-and-preview-interaction-polish.zh.md index bcfcbeb61c..d3f8b00fd8 100644 --- a/.agents/notes/implemented/feature/2026-09-09-sidebar-and-preview-interaction-polish.zh.md +++ b/.agents/notes/implemented/feature/2026-09-09-sidebar-and-preview-interaction-polish.zh.md @@ -10,7 +10,7 @@ Status: implemented ## Decision -**默认页惰性播种。**[stores.ts](../../../../packages/client/ui-sidebar-right/src/client/stores.ts) 的 `createSurface` 铸造一个折叠且为空的停靠面;store 的 `advance` 只在意图让列保持展开时才把种子工厂传给 `planSettle`。首次会展示空布局的那次展开播种彼时的默认页;关闭最后一个可关 tab 时收起整列,布局保持为空直到下次展开。[最后一个 tab 的关闭规则](2026-09-08-sidebar-last-tab-close-rules.zh.md)与[默认页选择](2026-09-08-sidebar-default-pages.zh.md)维持原决定;改变的只是播种时机。 +**默认页惰性播种。**[stores.ts](../../../../packages/client/ui-sidebar-right/src/client/stores.ts) 的 `createSurface` 铸造一个折叠且为空的停靠面;store 的 `advance` 只在意图让列保持展开时才把种子工厂传给 `planSettle`。首次会展示空布局的那次展开播种彼时的默认页;关闭最后一个可关 tab 时收起整列,布局保持为空直到下次展开。[最后一个 tab 的关闭规则](2026-09-08-sidebar-last-tab-close-rules.zh.md)与[默认页选择](2026-09-08-sidebar-default-pages.zh.md)维持原决定;默认页在展开时创建。对空分栏执行 split 不产生变化,不创建 tab,不记录历史,也不返回新分栏。 **页唯一性以格为界。**只对引导页的合并规则推广到每种页 kind(`pageKind`/`panePage`):打开一个页只在这次打开的目标格内聚焦既有 tab;把页拖入、放入或收回到已展示该 kind 页的格会并入该格自己的 tab。资源 tab 保留套件的全停靠面聚焦。 diff --git a/packages/client/ui-primitives/tests/atoms.client.spec.tsx b/packages/client/ui-primitives/tests/atoms.client.spec.tsx index 5f8867876b..70649d0874 100644 --- a/packages/client/ui-primitives/tests/atoms.client.spec.tsx +++ b/packages/client/ui-primitives/tests/atoms.client.spec.tsx @@ -108,11 +108,14 @@ describe('Menu', () => { // the focus move it causes is the one signal left, and it closes. const iframe = document.createElement('iframe') document.body.appendChild(iframe) - iframe.focus() - expect(document.activeElement).toBe(iframe) - fireEvent.blur(window) - expect(onClose).toHaveBeenCalledTimes(1) - iframe.remove() + try { + iframe.focus() + expect(document.activeElement).toBe(iframe) + fireEvent.blur(window) + expect(onClose).toHaveBeenCalledTimes(1) + } finally { + iframe.remove() + } }) it('selected item shows the trailing check; align=end, side=top, and className apply', () => { diff --git a/packages/client/ui-sidebar-right/README.i18n.yaml b/packages/client/ui-sidebar-right/README.i18n.yaml index b58a139249..9824bd3a81 100644 --- a/packages/client/ui-sidebar-right/README.i18n.yaml +++ b/packages/client/ui-sidebar-right/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-sidebar-right/README.md -README.md: 484667e6286e672b0541b0345e2ba02c21215aed -README.zh.md: 521406868e3fe62240a838cfc2ea41b2c78e3134 +README.md: d0bf1b9a52aa93760d388f1677181d111faebb72 +README.zh.md: 087c55d1ae12d22173e2d2d2c1b5e164df58c774 diff --git a/packages/client/ui-sidebar-right/README.md b/packages/client/ui-sidebar-right/README.md index 484667e628..d0bf1b9a52 100644 --- a/packages/client/ui-sidebar-right/README.md +++ b/packages/client/ui-sidebar-right/README.md @@ -65,7 +65,7 @@ Carrying the mint counter in the surface is what makes a recorded sequence repla After every action the kit's settle planner keeps the expanded surface populated: a docked pane whose last tab was moved out or floated is merged away, and an emptied root pane receives the default page. A collapsed surface holds no such backfill — a session starts collapsed and empty, and a close that collapses the column leaves it empty — the expansion that would first show an empty layout is what seeds the default page. Explicit closing follows [the default-page and close rules](#the-guide). While expanded there is always at least one tab, and never an empty pane — so there is no separate "close pane" gesture. -The docked surface's last tab carries one more rule, decided in the store's `closeTab` and mirrored to the kit through `canCloseTab`: the guide standing as the only docked tab draws no close control and no menu close item — its chip sits quiet, and with no extension item contributed a secondary press opens no menu — and a programmatic close of it records nothing; any other tab standing alone closes together with the column in one entry — the settle planner reseeds the current default page for the next expansion. Floating panels take no part in the rule: they render whether or not the column is expanded, and their tabs close freely. +The docked surface's last tab carries one more rule, decided in the store's `closeTab` and mirrored to the kit through `canCloseTab`: the guide standing as the only docked tab draws no close control and no menu close item — its chip sits quiet, and with no extension item contributed a secondary press opens no menu — and a programmatic close of it records nothing; any other tab standing alone closes together with the column in one entry — the layout stays empty until the next expansion seeds its current default page. Floating panels take no part in the rule: they render whether or not the column is expanded, and their tabs close freely. State is memory-only. A reload returns every session to the collapsed default; switching sessions keeps each surface where it was. @@ -84,7 +84,7 @@ Two more seats extend what is already there: `sidebar.right.tab.guide` (chain) r ## `ctx.sidebarRight` -`openResource(address, options?)` and `openTab(kind, options?)` are the navigation controller, and every way into the column calls one of them: the conversation's file links and a tool row's line reference (`openResource(fileAddress, { params: { line } })`), the strip's add control and a guide entry box (`openTab`), a file tree's rows (`tab.actions.openResource`). A resource address is a `dsh-resource:///…` URI; without `options.kind` the registry claims it (globs and `canOpen`, best band wins), with it that kind's type in force opens it. A page is named by kind; the tab is recorded under an address this package composes and nobody else spells (`contract/seed.ts`). Both run the same steps as one history entry: a tab already showing the same (kind, contentId) is focused unless `revealIfOpened: false` — a resource wherever it sits, a page only within the pane the open targets (the same page in another pane never draws the open away); otherwise a new tab lands in `options.replaceTab`'s pane and slot (closing that tab), else `options.paneId`, else the active docked pane; the panel expands, because content the user cannot see is not opened. Then the Tab domain records the navigation — `params` reach the body as `navigation.params`, with `revision` stepped — outside the layout history. `params` is typed by what is opened: a viewer for a resource type merges its entry into `SidebarRightResourceParamsMap` (the text preview declares `{ line?: number }`); a page type that takes parameters merges into `SidebarRightTabParamsMap` under its kind; values are JSON-shaped by convention, unchecked at run time. An address outside `dsh-resource://`, one no type claims, or a kind nothing registered throws: that is a wiring mistake, not a user error. +`openResource(address, options?)` and `openTab(kind, options?)` are the navigation controller, and every way into the column calls one of them: the conversation's file links and a tool row's line reference (`openResource(fileAddress, { params: { line } })`), the strip's add control and a guide entry box (`openTab`), a file tree's rows (`tab.actions.openResource`). A resource address is a `dsh-resource:///…` URI; without `options.kind` the registry claims it (globs and `canOpen`, best band wins), with it that kind's type in force opens it. A page is named by kind; the tab is recorded under an address this package composes and nobody else spells (`contract/seed.ts`). Both run the same steps as one history entry: a resource tab already showing the same (kind, contentId) is focused wherever it sits unless `revealIfOpened: false`; page tabs always deduplicate within the target pane, regardless of that option; otherwise a new tab lands in `options.replaceTab`'s pane and slot (closing that tab), else `options.paneId`, else the active docked pane; the panel expands, because content the user cannot see is not opened. Then the Tab domain records the navigation — `params` reach the body as `navigation.params`, with `revision` stepped — outside the layout history. `params` is typed by what is opened: a viewer for a resource type merges its entry into `SidebarRightResourceParamsMap` (the text preview declares `{ line?: number }`); a page type that takes parameters merges into `SidebarRightTabParamsMap` under its kind; values are JSON-shaped by convention, unchecked at run time. An address outside `dsh-resource://`, one no type claims, or a kind nothing registered throws: that is a wiring mistake, not a user error. `close(tabId)` closes a tab; `active()` reads the active tab. `isExpanded()` and `toggleExpanded()` read and drive the column's expansion; the presentation switch is the panel's own control and not part of this face. Layout operations, for callers that arrange the column programmatically, each recorded like the gesture it stands in for: `focus(tabId)` focuses a tab and its pane; `split(paneId?)` splits a docked pane (the active one by default) under the same pane budget and room rule as the strip's control and returns the new pane's id, or `undefined` — recording nothing — when it cannot; `float(tabId, rect?)` takes a docked tab out into a panel; `dock(paneId)` returns a floating panel to the active docked pane. A tab or pane that does not exist, or already is where the call would put it, is left alone. The face exposes operations only: no layout snapshot, no operation log, no lookup by address. `_undo()` / `_redo()` step the mounted surface's history; they are `@internal` — the sequence has no user-facing control, and these exist for tests. Commands need a mounted session surface; with none, they throw rather than write into a surface nobody draws. @@ -98,7 +98,7 @@ The Tab domain retains navigation, an abort signal, and bound actions per (Sessi Default pages depend on the number of registered guide entries, not the number of tab types or open tabs. Exactly one entry opens its page directly (Files in the shipped composition); zero or multiple entries open the guide. Explicitly adding a guide still opens the guide, even with one entry. The sole docked guide is the only tab that cannot close; closing any other sole tab also collapses the column. The chip, context menu, and `close` API apply the same rule. -The guide tab is one entry capsule (a glyph and a title, nothing else) per `guide` entry the registered types contributed, in `order`, centred in the body; the guide has no words of its own. Picking a capsule calls `tab.actions.openTab(entry.kind, { replaceTab: true })`, so the guide gives way to the page it opened. A pane holds at most one guide tab. The strip's add control is drawn only while its pane holds none and opens one there with `openTab('guide', { paneId, revealIfOpened: false })`, so a guide in another pane does not capture the click; opening the guide into a pane that already has one focuses it instead; a guide dragged, dropped, or docked into such a pane merges into it — the arriving guide closes and the pane's own is focused; `duplicateTab` on the guide records nothing. A split, an emptied root pane, and the pane a sole tab vacates by dropping on its own edge use the same default-page rule, one tab per new pane; the self-edge drop leaves the dragged tab focused. A plain `openTab('guide')` keeps the tree-wide reveal every open has. The product allows two horizontal panes, initially equal, with divider ratios limited to 20%–80%. Insufficient width blocks a new split; with two panes already present, a body drop moves the tab between panes instead of creating a third. At the two-pane limit, split controls are hidden; closing back to one pane restores them. +The guide tab is one entry capsule (a glyph and a title, nothing else) per `guide` entry the registered types contributed, in `order`, centred in the body; the guide has no words of its own. Picking a capsule calls `tab.actions.openTab(entry.kind, { replaceTab: true })`, so the guide gives way to the page it opened. A pane holds at most one guide tab. The strip's add control is drawn only while its pane holds none and opens one there with `openTab('guide', { paneId, revealIfOpened: false })`, so a guide in another pane does not capture the click; opening the guide into a pane that already has one focuses it instead; a guide dragged, dropped, or docked into such a pane merges into it — the arriving guide closes and the pane's own is focused; `duplicateTab` on the guide records nothing. A split, an expanded empty root pane, and the pane a sole tab vacates by dropping on its own edge use the same default-page rule, one tab per new pane; the self-edge drop leaves the dragged tab focused. A plain `openTab('guide')` opens or focuses the guide only within the active or named pane. Splitting an empty pane does nothing and returns no new pane. The product allows two horizontal panes, initially equal, with divider ratios limited to 20%–80%. Insufficient width blocks a new split; with two panes already present, a body drop moves the tab between panes instead of creating a third. At the two-pane limit, split controls are hidden; closing back to one pane restores them. ## Copy diff --git a/packages/client/ui-sidebar-right/README.zh.md b/packages/client/ui-sidebar-right/README.zh.md index 521406868e..087c55d1ae 100644 --- a/packages/client/ui-sidebar-right/README.zh.md +++ b/packages/client/ui-sidebar-right/README.zh.md @@ -65,7 +65,7 @@ kind: "package-reference" 每个动作之后,套件的 settle planner 保证展开的停靠面有内容:最后一个 tab 被搬走或浮出的停靠格会被并掉;根格被清空时填入默认页。折叠的停靠面没有这种回填——会话以折叠且为空的停靠面开始,收起整列的关闭也让它保持为空——首次会展示空布局的那次展开才播种默认页。显式关闭遵循[默认页与关闭规则](#the-guide)。展开期间永远至少有一个 tab,永远没有空格——因此没有单独的「关闭格」手势。 -停靠面的最后一个 tab 还多带一条规则,由 store 的 `closeTab` 决定并经 `canCloseTab` 镜像给套件:作为唯一停靠 tab 的引导页不画关闭控件也不画菜单里的关闭项——它的 chip 呈安静样式,在没有扩展条目时次键按下也不弹出菜单——对它的编程式关闭什么都不记录;任何其它 tab 独自留下时,点击关闭会连同整列一起收起,记为一条历史——settle planner 为下次展开重新播种当前默认页。浮动面板不参与这条规则:它们无论列是否展开都会渲染,其 tab 照常关闭。 +停靠面的最后一个 tab 还多带一条规则,由 store 的 `closeTab` 决定并经 `canCloseTab` 镜像给套件:作为唯一停靠 tab 的引导页不画关闭控件也不画菜单里的关闭项——它的 chip 呈安静样式,在没有扩展条目时次键按下也不弹出菜单——对它的编程式关闭什么都不记录;任何其它 tab 独自留下时,点击关闭会连同整列一起收起,记为一条历史,布局保持为空,直到下次展开时创建当时的默认页。浮动面板不参与这条规则:它们无论列是否展开都会渲染,其 tab 照常关闭。 状态只在内存中。刷新会让每个会话回到折叠的默认态;切换会话则让每个停靠面留在原处。 @@ -84,7 +84,7 @@ tab 类型分两阶段注册,随包发布的引导类型走的正是别的包 ## `ctx.sidebarRight` -`openResource(address, options?)` 与 `openTab(kind, options?)` 是导航控制器,进入该列的每条路都调用其中之一:会话区的文件链接与工具行的行号引用(`openResource(fileAddress, { params: { line } })`),tab 条的添加控件与引导入口框(`openTab`),文件树的行(`tab.actions.openResource`)。资源地址是 `dsh-resource:///…` URI;不带 `options.kind` 时由注册表认领(glob 与 `canOpen`,最高档胜出),带它时由该 kind 生效的类型打开。页按 kind 命名;tab 记录在本包拼出、别处无人书写的地址下(`contract/seed.ts`)。两者以同一组步骤作为一条历史运行:已展示同一 (kind, contentId) 的 tab 被聚焦,除非 `revealIfOpened: false`——资源不论在哪个格都算,页只在这次打开的目标格内算(另一格里的同一页不会把这次打开吸走);否则新 tab 落到 `options.replaceTab` 所在的格与位置(并关掉那个 tab),再退而落到 `options.paneId`,再退而落到活跃停靠格;面板展开,因为用户看不到的内容不算打开。随后 Tab 域记录这次导航——`params` 以 `navigation.params` 抵达正文,`revision` 递增——不进布局历史。`params` 按所开之物定型:某资源类型的查看器把自己那项并入 `SidebarRightResourceParamsMap`(文本预览声明 `{ line?: number }`);接受参数的页类型按其 kind 并入 `SidebarRightTabParamsMap`;值约定为 JSON 形状,运行时不校验。`dsh-resource://` 之外的地址、无人认领的地址、或未注册的 kind 都会 throw:那是接线错误,不是用户错误。 +`openResource(address, options?)` 与 `openTab(kind, options?)` 是导航控制器,进入该列的每条路都调用其中之一:会话区的文件链接与工具行的行号引用(`openResource(fileAddress, { params: { line } })`),tab 条的添加控件与引导入口框(`openTab`),文件树的行(`tab.actions.openResource`)。资源地址是 `dsh-resource:///…` URI;不带 `options.kind` 时由注册表认领(glob 与 `canOpen`,最高档胜出),带它时由该 kind 生效的类型打开。页按 kind 命名;tab 记录在本包拼出、别处无人书写的地址下(`contract/seed.ts`)。两者以同一组步骤作为一条历史运行:已展示同一 (kind, contentId) 的资源 tab 被聚焦,不限所在分栏,除非 `revealIfOpened: false`;页 tab 始终只在目标分栏内去重,不受该选项影响;否则新 tab 落到 `options.replaceTab` 所在的格与位置(并关掉那个 tab),再退而落到 `options.paneId`,再退而落到活跃停靠格;面板展开,因为用户看不到的内容不算打开。随后 Tab 域记录这次导航——`params` 以 `navigation.params` 抵达正文,`revision` 递增——不进布局历史。`params` 按所开之物定型:某资源类型的查看器把自己那项并入 `SidebarRightResourceParamsMap`(文本预览声明 `{ line?: number }`);接受参数的页类型按其 kind 并入 `SidebarRightTabParamsMap`;值约定为 JSON 形状,运行时不校验。`dsh-resource://` 之外的地址、无人认领的地址、或未注册的 kind 都会 throw:那是接线错误,不是用户错误。 `close(tabId)` 关闭一个 tab;`active()` 读取活动 tab。`isExpanded()` 与 `toggleExpanded()` 读取并驱动该列的展开;形态切换是面板自己的控件,不属于这个接口。布局操作供以编程方式安排该列的调用方使用,每个都像它替代的手势一样被记录:`focus(tabId)` 聚焦一个 tab 及其格;`split(paneId?)` 在与 tab 条控件相同的格预算与空间规则下分栏一个停靠格(默认活跃格),返回新格的 id,做不到时返回 `undefined`——且不记录任何东西;`float(tabId, rect?)` 把停靠 tab 浮出为浮窗;`dock(paneId)` 把浮窗放回活跃停靠格。不存在的 tab 或格、或已处于调用目标状态的,都原样不动。该接口只暴露操作:没有布局快照、没有操作日志、没有按地址查找。`_undo()` / `_redo()` 步进已挂载停靠面的历史;它们是 `@internal`——序列没有面向用户的控件,这两个只为测试存在。命令需要一个已挂载的会话停靠面;没有时它们 throw,而不是写进一个没人绘制的面里。 @@ -98,7 +98,7 @@ Tab域按(Session,Tab id)保留导航、中止信号与绑定动作;私 默认页取决于已注册的引导入口数,不取决于 tab 类型数或已打开的 tab 数。恰好一个入口时直接打开对应页面(随包组合中为 Files);没有入口或有多个入口时打开引导页。即使只有一个入口,显式添加引导页仍会打开引导页。只有作为唯一停靠 tab 的引导页不可关闭;关闭其它任何唯一 tab 时会同时收起整列。chip、上下文菜单与 `close` API 使用同一规则。 -引导 tab 是各已注册类型贡献的每个 `guide` 条目一个入口胶囊(一个图标加一个标题,别无其他),按 `order` 排列并在正文中居中;引导页自己没有文字。选中一个胶囊会调用 `tab.actions.openTab(entry.kind, { replaceTab: true })`,于是引导页让位给它打开的页。一个格最多持有一个引导 tab。tab 条的添加控件只在该格没有引导 tab 时绘制,并以 `openTab('guide', { paneId, revealIfOpened: false })` 在该格打开一个,这样别的格里的引导页不会截走这次点击;把引导页开进已有引导页的格则改为聚焦它;把引导页拖入、放入或收回到这样的格会合并进去——来者关闭,该格自己的被聚焦;对引导页 `duplicateTab` 不记录任何东西。分栏、被清空的根格、以及唯一 tab 拖到本格边缘分屏后腾出的格使用相同的默认页规则,每个新格一个 tab;这种本格边缘拖放让被拖的 tab 保持聚焦。普通的 `openTab('guide')` 保留每次打开都有的整树聚焦。产品最多保留左右两格,默认均分,分隔条限定20%~80%。宽度不足以容纳两格时不允许新分栏;已有两格时,正文拖放用于跨格移动,不再创建第三格。达到两格上限时隐藏分栏控件;关闭回单格后恢复。 +引导 tab 是各已注册类型贡献的每个 `guide` 条目一个入口胶囊(一个图标加一个标题,别无其他),按 `order` 排列并在正文中居中;引导页自己没有文字。选中一个胶囊会调用 `tab.actions.openTab(entry.kind, { replaceTab: true })`,于是引导页让位给它打开的页。一个格最多持有一个引导 tab。tab 条的添加控件只在该格没有引导 tab 时绘制,并以 `openTab('guide', { paneId, revealIfOpened: false })` 在该格打开一个,这样别的格里的引导页不会截走这次点击;把引导页开进已有引导页的格则改为聚焦它;把引导页拖入、放入或收回到这样的格会合并进去——来者关闭,该格自己的被聚焦;对引导页 `duplicateTab` 不记录任何东西。分栏、展开且为空的根格、以及唯一 tab 拖到本格边缘分屏后腾出的格使用相同的默认页规则,每个新格一个 tab;这种本格边缘拖放让被拖的 tab 保持聚焦。普通的 `openTab('guide')` 只在活跃或指定分栏内打开或聚焦引导页。对空分栏执行 split 不产生变化,也不返回新分栏。产品最多保留左右两格,默认均分,分隔条限定20%~80%。宽度不足以容纳两格时不允许新分栏;已有两格时,正文拖放用于跨格移动,不再创建第三格。达到两格上限时隐藏分栏控件;关闭回单格后恢复。 ## 文案 diff --git a/packages/client/ui-sidebar-right/src/client/contract/slots.ts b/packages/client/ui-sidebar-right/src/client/contract/slots.ts index f964ee97c6..f5ee3de8b2 100644 --- a/packages/client/ui-sidebar-right/src/client/contract/slots.ts +++ b/packages/client/ui-sidebar-right/src/client/contract/slots.ts @@ -105,7 +105,7 @@ export interface SidebarRightTabNavigation { export interface SidebarRightTabPlacement { /** Land a new tab in this pane instead. */ readonly paneId?: PaneId - /** Defaults to `true`: a tab already showing the same content is focused instead of a second one opening. */ + /** Resource tabs reveal existing content by default; `false` permits duplicates. Pages always deduplicate within the target pane. */ readonly revealIfOpened?: boolean /** `true` opens in this tab's place — its pane and strip slot — and closes this tab in the same step. */ readonly replaceTab?: boolean diff --git a/packages/client/ui-sidebar-right/src/client/service.ts b/packages/client/ui-sidebar-right/src/client/service.ts index 81e003b06a..e80a291a4f 100644 --- a/packages/client/ui-sidebar-right/src/client/service.ts +++ b/packages/client/ui-sidebar-right/src/client/service.ts @@ -111,8 +111,8 @@ export interface SidebarRightPlacement { /** Take this tab's place — its pane and its strip slot — and close it in the same step. */ readonly replaceTab?: TabId /** - * Defaults to `true`: a tab already showing the same (kind, contentId) is - * focused and handed `params`. `false` opens another tab regardless. + * Resource tabs reveal an existing (kind, contentId) by default; `false` + * permits duplicates. Pages always deduplicate within the target pane. */ readonly revealIfOpened?: boolean } @@ -184,7 +184,7 @@ export interface ISidebarRight { * splits. * @param paneId - the pane to split; defaults to the active docked pane. * @returns the new pane's id, or `undefined` when nothing was split: the pane - * is missing or floating, the budget is spent, or two halves would not fit. + * is missing, floating, or empty, the budget is spent, or two halves would not fit. */ split(paneId?: PaneId): PaneId | undefined /** diff --git a/packages/client/ui-sidebar-right/src/client/stores.ts b/packages/client/ui-sidebar-right/src/client/stores.ts index 4318f9c670..88cb40a1b3 100644 --- a/packages/client/ui-sidebar-right/src/client/stores.ts +++ b/packages/client/ui-sidebar-right/src/client/stores.ts @@ -88,7 +88,7 @@ export interface OpenContentIntent { readonly paneId?: PaneId /** Take this tab's pane and slot, and close it in the same entry. */ readonly replaceTab?: TabId - /** `false` opens another tab even when the identity is already shown; defaults to `true`. */ + /** Resource tabs reveal an existing identity by default; `false` permits duplicates. Pages always deduplicate within the target pane. */ readonly revealIfOpened?: boolean } @@ -286,6 +286,7 @@ export function createSidebarRightStore( splitPane: (d, sessionId: string, paneId?: PaneId, settled?: (paneId: PaneId) => void) => { d.bySession = seat(d, sessionId, (s) => { const next = advance(s, (state, mint, makeTab) => dockPaneIds(state).length >= 2 + || getPane(state, paneId ?? activeDockPaneId(state)).tabs.length === 0 ? [] : planSplitPane(state, mint, paneId, makeTab), seed) if (settled !== undefined && next !== s) { diff --git a/packages/client/ui-sidebar-right/tests/service.client.spec.ts b/packages/client/ui-sidebar-right/tests/service.client.spec.ts index f064879859..dc4b16d5c5 100644 --- a/packages/client/ui-sidebar-right/tests/service.client.spec.ts +++ b/packages/client/ui-sidebar-right/tests/service.client.spec.ts @@ -201,7 +201,7 @@ describe('SidebarRightController — opening', () => { expect(Object.values(layout().tabs).filter(tab => tab.title === 'a.txt')).toHaveLength(2) }) - it('reveals an open guide for a plain open, as for any address', () => { + it('focuses the existing guide in the target pane', () => { const { controller, instance, publish, layout, tabOf, expand } = harness() instance.actions.open(SESSION) expand() diff --git a/packages/client/ui-sidebar-right/tests/stores.client.spec.ts b/packages/client/ui-sidebar-right/tests/stores.client.spec.ts index be26f864f2..961e657046 100644 --- a/packages/client/ui-sidebar-right/tests/stores.client.spec.ts +++ b/packages/client/ui-sidebar-right/tests/stores.client.spec.ts @@ -12,6 +12,7 @@ import { describe, expect, it, vi } from 'vitest' import type { LayoutState, PaneId, TabId } from '@deepseek-ai/dsh-client-ui-dockkit' import { dockPaneIds, findTabPane, getPane, getSplit } from '@deepseek-ai/dsh-client-ui-dockkit' import { createSidebarRightStore } from '../src/client/stores.ts' +import { pageAddress } from '../src/client/contract/seed.ts' const SESSION = 's-test' @@ -99,6 +100,47 @@ describe('createSidebarRightStore — the sequence', () => { expect(Object.values(layout().tabs)).toHaveLength(1) }) + it.each([false, true])('does not split an empty collapsed pane after close=%s', (afterClose) => { + const { actions, layout, surface } = harness() + if (afterClose) { + actions.openContent(SESSION, { kind: 'text', contentId: 'file:a', title: 'a' }, () => {}) + actions.closeTab(SESSION, Object.values(layout().tabs)[0]!.id) + } + const before = surface() + const settled = vi.fn() + actions.splitPane(SESSION, undefined, settled) + expect(surface()).toBe(before) + expect(settled).not.toHaveBeenCalled() + expect(layout().expanded).toBe(false) + expect(Object.keys(layout().tabs)).toHaveLength(0) + }) + + it.each(['guide', 'files'])('opens %s in the requested pane and merges a moved duplicate there', (kind) => { + const { actions, layout } = harness() + const address = pageAddress(kind) + actions.openContent(SESSION, { kind, contentId: address, title: kind }, () => {}) + const first = Object.values(layout().tabs)[0]! + const firstPane = layout().activePaneId + actions.splitPane(SESSION) + const secondPane = layout().activePaneId + const seed = getPane(layout(), secondPane).tabs[0]! + actions.openContent(SESSION, { kind: 'text', contentId: 'file:b', title: 'b', replaceTab: seed }, () => {}) + const opened = vi.fn<(tabId: TabId) => void>() + actions.openContent(SESSION, { kind, contentId: address, title: kind, paneId: secondPane }, opened) + const second = opened.mock.calls[0]![0] + expect(second).not.toBe(first.id) + expect(getPane(layout(), firstPane).tabs).toEqual([first.id]) + expect(getPane(layout(), secondPane).activeTabId).toBe(second) + expect(layout().activePaneId).toBe(secondPane) + const held = vi.fn() + actions.openContent(SESSION, { kind, contentId: address, title: kind, paneId: secondPane, revealIfOpened: false }, held) + expect(held).toHaveBeenCalledWith(second) + actions.dropTab(SESSION, first.id, secondPane, 'center') + expect(layout().tabs[first.id]).toBeUndefined() + expect(getPane(layout(), secondPane).activeTabId).toBe(second) + expect(dockPaneIds(layout())).toEqual([secondPane]) + }) + it('allows two docked panes and rejects further splits without recording', () => { const { actions, layout, entries, expand } = harness() expand() From 7f69a3ec37e022812544b334362f8d1a998bb1c1 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 9 Sep 2026 18:01:04 +0800 Subject: [PATCH 045/111] fix(web): complete shared file icon consumers and browser checks --- ...026-09-08-shared-file-type-icons.i18n.yaml | 4 +- .../2026-09-08-shared-file-type-icons.md | 4 +- .../2026-09-08-shared-file-type-icons.zh.md | 6 +-- .../file-upload-round/draft.expected.md | 3 ++ .../file-upload-round/history.expected.md | 3 ++ apps/web/tests/file-upload-round.e2e.ts | 24 ++++++++++- .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 2 +- .../ui-primitives/src/DocumentFileIcon.tsx | 41 ------------------- .../client/ui-primitives/src/FileTypeIcon.tsx | 1 - .../client/ui-primitives/src/LinkIcon.tsx | 1 + .../ui-primitives/src/code-file-types.ts | 1 + packages/client/ui-primitives/src/index.ts | 1 - .../tests/file-type-icon.client.spec.tsx | 2 + .../ui-primitives/tests/icons.client.spec.tsx | 15 +------ .../client/ui-sidebar-files/README.i18n.yaml | 4 +- packages/client/ui-sidebar-files/README.md | 2 +- packages/client/ui-sidebar-files/README.zh.md | 2 +- .../src/client/FilesTitle.tsx | 16 +------- 20 files changed, 51 insertions(+), 87 deletions(-) delete mode 100644 packages/client/ui-primitives/src/DocumentFileIcon.tsx diff --git a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.i18n.yaml b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.i18n.yaml index d9c504d845..bf23295de5 100644 --- a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.md -2026-09-08-shared-file-type-icons.md: 95380fd642e574389cbdc0d10038e2a0a8d85283 -2026-09-08-shared-file-type-icons.zh.md: 7ba0254206dc026a8967f86036e1bf329ac9ff21 +2026-09-08-shared-file-type-icons.md: f8c87cb3259f757483a6658d79306d3f02d90488 +2026-09-08-shared-file-type-icons.zh.md: ea115cd63b3144ded2c99f818bb9364eea8adfef diff --git a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.md b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.md index 95380fd642..f8c87cb325 100644 --- a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.md +++ b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.md @@ -14,11 +14,11 @@ Client feature plugins can share React components only through `@deepseek-ai/dsh `FileTypeIcon` accepts a path, shared `IconProps`, the explicit `kind`, and an optional project-file snapshot. Traditional file types render the supplied 28px document and folder contours as inline SVG. Excel, Markdown, PDF, PPT, and Word foreground marks scale to 122% around their visual center; the remaining marked traditional glyphs use 112%, while the file body and folded corner retain their source geometry and the generic file has no invented center mark. The sheet is a solid category color, the foreground mark and ordinary folded corner are white, and the generic file has a darker grey corner. CSS assigns the supplied category palette through static design tokens: DeepSeek blue for code/HTML/Markdown, the lighter DeepSeek blue for Word, green for Excel, two amber steps for folder/PPT, red for PDF, and neutral grey for unknown files. Image and video share the supplied violet through a component-local variable because the design platform has no matching violet token. A caller may override a traditional sheet through `--dsh-file-type-icon-color`. -Recognized code and configuration files render the corresponding 20px square artwork scaled to the requested icon size. These technology marks retain their embedded multicolor fills and are the explicit exception to the ordinary current-color icon rule. The map selects React before TypeScript/JavaScript, Angular filename suffixes before their base extension, Docker/Node/Git/Make/CMake by filename rules, and Flutter only when the optional project snapshot contains a `pubspec.yaml` whose text includes `flutter:`. Markdown and SVG remain owned by the traditional Markdown and image categories. Every traditional and technology SVG is `aria-hidden`, and the card, row, or button that owns the file identity supplies the accessible name. +Recognized code and configuration files render the corresponding 20px square artwork scaled to the requested icon size. These technology marks retain their embedded multicolor fills and are the explicit exception to the ordinary current-color icon rule. The map selects React before TypeScript/JavaScript, Angular filename suffixes before their base extension, Docker/Node/Git/Make/CMake by filename rules, and Flutter only when the optional project snapshot contains a `pubspec.yaml` whose text includes `flutter:`. Markdown and SVG remain owned by the traditional Markdown and image categories. CSV and TSV use the code glyph in file cards, rows, and preview titles; their clickable links also use code. Both `.env` and names ending in `.env` use the environment glyph. Every traditional and technology SVG is `aria-hidden`, and the card, row, or button that owns the file identity supplies the accessible name. `LinkIcon` delegates extension classification to `classifyFileType` and folds the detailed result into its existing link vocabulary: code and HTML use `code`, images use `image`, PDF/Word/Excel/PPT use `document`, and Markdown/video/unknown files use `other`. Extensionless names remain `other` in link contexts, so the 14px clickable-link appearance defined by the [clickable-link decision](2026-09-04-web-clickable-link-styles.md) does not change. -Attachment upload cards, sent-message file cards, queued-file rows, and workspace file rows render `FileTypeIcon`. Explicit delivery cards also use `FileTypeIcon` at 28px and `fileExtension` for their fallback metadata. The two metadata rows use `fileExtension` rather than local parsers; a leading-dot basename such as `.env` therefore displays `ENV`, while an absent or trailing suffix displays no extension label. Image content continues to render as a preview rather than a file-type glyph, and produced-file links and Markdown file mentions continue to use `LinkIcon` because they are link surfaces. +Attachment upload cards, sent-message file cards, queued-file rows, and workspace file rows render `FileTypeIcon`. The Files tab title renders its explicit `folder` kind at 16px. Explicit delivery cards also use `FileTypeIcon` at 28px and `fileExtension` for their fallback metadata. The two metadata rows use `fileExtension` rather than local parsers; a leading-dot basename such as `.env` therefore displays `ENV`, while an absent or trailing suffix displays no extension label. Image content continues to render as a preview rather than a file-type glyph, and produced-file links and Markdown file mentions continue to use `LinkIcon` because they are link surfaces. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.zh.md b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.zh.md index 7ba0254206..ea115cd63b 100644 --- a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.zh.md +++ b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.zh.md @@ -12,13 +12,13 @@ Status: implemented `ui-primitives` 统一拥有一套 Cordis-free 的文件分类与渲染 API。`fileExtension(path)` 对两种路径分隔符采用共享的 basename 与最终点号语义。`classifyFileType(path)` 不区分大小写,并返回闭合的 `FileType` 联合:传统的 `code`、`excel`、`folder`、`html`、`image`、`markdown`、`other`、`pdf`、`ppt`、`video`、`word` 类别,以及由 `CodeFileIcon` 渲染的细分 `CodeFileType` 集合。解析按完整文件名、文件名前缀、文件名后缀、可选项目上下文、扩展名的顺序执行。路径分类器返回 `folder` 之外的全部成员;调用方确认条目是目录时,通过 `FileTypeIcon` 的显式 `kind` 覆盖指定目录。未知扩展名、无扩展名和末尾点号回退到 `other`,但共享表识别 `Dockerfile`、`Makefile`、`package.json`、`.gitignore`、`README`、`CHANGELOG` 等具名文件。 -`FileTypeIcon` 接受路径、共享 `IconProps`、可选的显式类型和可选的项目文件快照。传统文件类型把所提供的 28px 文档与文件夹轮廓渲染为 inline SVG。Excel、Markdown、PDF、PPT、Word 的前景标记围绕自身视觉中心缩放至 122%,其余带标记的传统图形使用 112%;文件底板与折角保持源图几何,通用文件不凭空增加中心标记。底板使用实色分类颜色,前景标记与普通折角使用白色,通用文件使用较深的灰色折角。CSS 通过静态设计 token 分配所提供的分类调色板:code/HTML/Markdown 使用 DeepSeek 蓝,Word 使用较浅的 DeepSeek 蓝,Excel 使用绿色,folder/PPT 使用两档琥珀色,PDF 使用红色,未知文件使用中性灰。image 与 video 通过组件本地变量共用所提供的紫色,因为设计平台没有匹配的紫色 token。调用方可通过 `--dsh-file-type-icon-color` 覆盖传统底板颜色。 +`FileTypeIcon` 接受路径、共享 `IconProps`、显式 `kind`和可选的项目文件快照。传统文件类型把所提供的 28px 文档与文件夹轮廓渲染为 inline SVG。Excel、Markdown、PDF、PPT、Word 的前景标记围绕自身视觉中心缩放至 122%,其余带标记的传统图形使用 112%;文件底板与折角保持源图几何,通用文件不凭空增加中心标记。底板使用实色分类颜色,前景标记与普通折角使用白色,通用文件使用较深的灰色折角。CSS 通过静态设计 token 分配所提供的分类调色板:code/HTML/Markdown 使用 DeepSeek 蓝,Word 使用较浅的 DeepSeek 蓝,Excel 使用绿色,folder/PPT 使用两档琥珀色,PDF 使用红色,未知文件使用中性灰。image 与 video 通过组件本地变量共用所提供的紫色,因为设计平台没有匹配的紫色 token。调用方可通过 `--dsh-file-type-icon-color` 覆盖传统底板颜色。 -已识别的代码与配置文件把对应的 20px 方形图稿缩放到请求的图标尺寸。这些技术标记保留自身内嵌的多色填充,是普通 current-color 图标规则的明确例外。映射让 React 优先于 TypeScript/JavaScript、Angular 文件名后缀优先于基础扩展名,并按文件名识别 Docker/Node/Git/Make/CMake;只有可选项目快照包含内容带 `flutter:` 的 `pubspec.yaml` 时才选择 Flutter。Markdown 与 SVG 仍由传统 Markdown 和图片类别拥有。所有传统与技术 SVG 都是 `aria-hidden` 的,拥有文件身份的卡片、行或按钮提供无障碍名称。 +已识别的代码与配置文件把对应的 20px 方形图稿缩放到请求的图标尺寸。这些技术标记保留自身内嵌的多色填充,是普通 current-color 图标规则的明确例外。映射让 React 优先于 TypeScript/JavaScript、Angular 文件名后缀优先于基础扩展名,并按文件名识别 Docker/Node/Git/Make/CMake;只有可选项目快照包含内容带 `flutter:` 的 `pubspec.yaml` 时才选择 Flutter。Markdown 与 SVG 仍由传统 Markdown 和图片类别拥有。CSV 和 TSV 在文件卡片、文件行及预览标题中使用 code 图标,其可点击链接也使用 code。`.env` 和以 `.env` 结尾的文件名均使用环境配置图标。所有传统与技术 SVG 都是 `aria-hidden` 的,拥有文件身份的卡片、行或按钮提供无障碍名称。 `LinkIcon` 委托 `classifyFileType` 做扩展名分类,再把精细结果折叠进原有链接词汇:code 与 HTML 使用 `code`,图片使用 `image`,PDF/Word/Excel/PPT 使用 `document`,Markdown、video 与未知文件使用 `other`。无扩展名文件在链接语境中仍是 `other`,因此[可点击链接决策](2026-09-04-web-clickable-link-styles.zh.md)定义的 14px 外观不变。 -附件上传卡片、已发送消息文件卡片、排队文件行和工作区文件行渲染 `FileTypeIcon`。显式交付卡片也使用 28px 的 `FileTypeIcon`,并通过 `fileExtension` 提供默认元数据。两处元数据行使用 `fileExtension`,不再保留本地解析器;`.env` 这样的前导点 basename 会显示 `ENV`,无后缀或末尾点号则不显示扩展名 label。图片内容继续渲染为预览而不是文件类型图形,产物文件链接与 Markdown 文件提及继续使用 `LinkIcon`,因为它们属于链接表面。 +附件上传卡片、已发送消息文件卡片、排队文件行和工作区文件行渲染 `FileTypeIcon`。Files 标签页标题使用显式的 `folder` 类别,尺寸为 16px。显式交付卡片也使用 28px 的 `FileTypeIcon`,并通过 `fileExtension` 提供默认元数据。两处元数据行使用 `fileExtension`,不再保留本地解析器;`.env` 这样的前导点 basename 会显示 `ENV`,无后缀或末尾点号则不显示扩展名 label。图片内容继续渲染为预览而不是文件类型图形,产物文件链接与 Markdown 文件提及继续使用 `LinkIcon`,因为它们属于链接表面。 ## 备选方案 diff --git a/apps/web/tests/expected/file-upload-round/draft.expected.md b/apps/web/tests/expected/file-upload-round/draft.expected.md index aa5ef30e82..59e438946d 100644 --- a/apps/web/tests/expected/file-upload-round/draft.expected.md +++ b/apps/web/tests/expected/file-upload-round/draft.expected.md @@ -2,6 +2,9 @@ - selection order: poem.txt > reference-1.png > reference-2.png > reference-3.png > reference-4.png > reference-5.png > reference-6.png > reference-7.png > reference-8.png > reference-9.png > reference-10.png - one attachment group: true +- file icon dimensions: 28 × 28 +- file icon color: rgb(207, 211, 214) +- file icon uses a solid fill: true - all cards share one row: true - every card is 64px high: true - the file card is wider than an image: true diff --git a/apps/web/tests/expected/file-upload-round/history.expected.md b/apps/web/tests/expected/file-upload-round/history.expected.md index 1427ef8004..4db5550793 100644 --- a/apps/web/tests/expected/file-upload-round/history.expected.md +++ b/apps/web/tests/expected/file-upload-round/history.expected.md @@ -2,6 +2,9 @@ - source order: poem.txt > reference-1.png - one attachment group: true +- file icon dimensions: 28 × 28 +- file icon color: rgb(207, 211, 214) +- file icon uses a solid fill: true - file and image share one row: true - both cards are 64px high: true - the image is a 64px tile: true diff --git a/apps/web/tests/file-upload-round.e2e.ts b/apps/web/tests/file-upload-round.e2e.ts index d39babbf16..5df27af9d9 100644 --- a/apps/web/tests/file-upload-round.e2e.ts +++ b/apps/web/tests/file-upload-round.e2e.ts @@ -38,6 +38,9 @@ const IMAGE_NAMES = Array.from({ length: 10 }, (_unused, index) => `reference-${ /** Browser-measured relations for the mixed composer attachment rail. */ interface DraftRailGeometry { + readonly fileIconBox: string + readonly fileIconColor: string + readonly fileIconUsesSolidFill: boolean readonly order: readonly string[] readonly oneGroup: boolean readonly oneRow: boolean @@ -54,6 +57,9 @@ function renderDraftRailGeometry(geometry: DraftRailGeometry): string { '', `- selection order: ${geometry.order.join(' > ')}`, `- one attachment group: ${String(geometry.oneGroup)}`, + `- file icon dimensions: ${geometry.fileIconBox}`, + `- file icon color: ${geometry.fileIconColor}`, + `- file icon uses a solid fill: ${String(geometry.fileIconUsesSolidFill)}`, `- all cards share one row: ${String(geometry.oneRow)}`, `- every card is 64px high: ${String(geometry.equalHeight)}`, `- the file card is wider than an image: ${String(geometry.fileWider)}`, @@ -64,6 +70,9 @@ function renderDraftRailGeometry(geometry: DraftRailGeometry): string { /** Browser-measured relations for one durable mixed-attachment message. */ interface HistoryAttachmentGeometry { + readonly fileIconBox: string + readonly fileIconColor: string + readonly fileIconUsesSolidFill: boolean readonly order: readonly string[] readonly oneGroup: boolean readonly oneRow: boolean @@ -81,6 +90,9 @@ function renderHistoryAttachmentGeometry(geometry: HistoryAttachmentGeometry): s '', `- source order: ${geometry.order.join(' > ')}`, `- one attachment group: ${String(geometry.oneGroup)}`, + `- file icon dimensions: ${geometry.fileIconBox}`, + `- file icon color: ${geometry.fileIconColor}`, + `- file icon uses a solid fill: ${String(geometry.fileIconUsesSolidFill)}`, `- file and image share one row: ${String(geometry.oneRow)}`, `- both cards are 64px high: ${String(geometry.equalHeight)}`, `- the image is a 64px tile: ${String(geometry.imageIsTile)}`, @@ -145,12 +157,18 @@ describe('web e2e: generic file upload through the real assembly', () => { const rail = page.getByRole('group', { name: 'Pending attachments' }) await expect.poll(() => rail.locator(':scope > *').count(), { timeout: 10_000 }) .toBe(IMAGE_NAMES.length + 1) + await rail.locator('svg[viewBox="0 0 28 28"]').waitFor({ timeout: 15_000 }) const geometry = await rail.evaluate((element): DraftRailGeometry => { const cards = [...element.children] as HTMLElement[] const boxes = cards.map(card => card.getBoundingClientRect()) const imageWidth = boxes[cards.findIndex(card => card.querySelector('img') !== null)]?.width ?? 0 - const fileWidth = boxes[cards.findIndex(card => card.querySelector('[title="poem.txt"]') !== null)]?.width ?? 0 + const fileCard = cards.find(card => card.querySelector('[title="poem.txt"]') !== null)! + const fileWidth = fileCard.getBoundingClientRect().width + const fileIcon = fileCard.querySelector('svg[viewBox="0 0 28 28"]')! return { + fileIconBox: `${fileIcon.getBoundingClientRect().width} × ${fileIcon.getBoundingClientRect().height}`, + fileIconColor: getComputedStyle(fileIcon).color, + fileIconUsesSolidFill: fileIcon.querySelector('path')?.getAttribute('fill') === 'currentColor', order: cards.map(card => card.querySelector('img')?.getAttribute('alt') ?? card.querySelector('[title]')?.title ?? ''), oneGroup: document.querySelectorAll('[role="group"][aria-label="Pending attachments"]').length === 1, @@ -247,7 +265,11 @@ describe('web e2e: generic file upload through the real assembly', () => { const fileIndex = cards.findIndex(card => card.getAttribute('title') === 'poem.txt') const imageBox = boxes[imageIndex] const fileBox = boxes[fileIndex] + const fileIcon = cards[fileIndex]!.querySelector('svg')! return { + fileIconBox: `${fileIcon.getBoundingClientRect().width} × ${fileIcon.getBoundingClientRect().height}`, + fileIconColor: getComputedStyle(fileIcon).color, + fileIconUsesSolidFill: fileIcon.querySelector('path')?.getAttribute('fill') === 'currentColor', order: cards.map(card => card.getAttribute('title') ?? card.querySelector('img')?.getAttribute('alt') ?? ''), oneGroup: document.querySelectorAll('[data-message-attachments]').length === 1, oneRow: boxes.every(box => Math.abs(box.top - (boxes[0]?.top ?? box.top)) < 0.5), diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index d247f941fb..5672f45bb3 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md -README.md: c511f1de3bd1b16f52ec73297122cd3b575f7c56 -README.zh.md: e8c92fdd6bf91e349fe5ec16271588bf4a9eed02 +README.md: aebb0be860ebaba4543a8fd262599507ead64556 +README.zh.md: a545aff9837036890b6250ab1aa32fb651af0a18 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index c511f1de3b..aebb0be860 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -54,7 +54,7 @@ Check this table before writing a control in a feature package. A plugin cannot | `JsonTree`, `JsonBlock` | Read-only JSON inspection. | | `MarkdownText`, `CodeBlock` | Untrusted GFM with TeX math, and highlighted code. `CodeBlock` accepts opt-in `lineNumbers`; copied source excludes the gutter. | | `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, `WebBlock` | The agent-output card matching each tool-result intent. | -| `icons/*`, `FishLogo`, `BrandWordmark`, `ReferenceIcon`, `LinkIcon`, `DocumentFileIcon` | Glyphs and brand marks. Use `LinkIcon` only for 14px clickable-link categories and `DocumentFileIcon` only when no file path exists to classify. | +| `icons/*`, `FishLogo`, `BrandWordmark`, `ReferenceIcon`, `LinkIcon` | Glyphs and brand marks. Use `LinkIcon` for 14px clickable-link categories. | | `FileTypeIcon`, `classifyFileType`, `fileExtension` | A category-colored 28px file or folder glyph and the shared case-insensitive filename mapping behind it. Code and configuration files use detailed full-color technology glyphs; use `LinkIcon` for link-leading glyphs and image previews for image content. | Three pairs are easy to confuse: diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index e8c92fdd6b..a545aff983 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -54,7 +54,7 @@ kind: "package-library" | `JsonTree`、`JsonBlock` | 只读 JSON 查看。 | | `MarkdownText`、`CodeBlock` | 不可信 GFM 与 TeX 数学,以及高亮代码。`CodeBlock` 可通过 `lineNumbers` 开启行号;复制的源码不含行号栏。 | | `TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock`、`WebBlock` | 与各类工具结果意图对应的 agent 输出卡片。 | -| `icons/*`、`FishLogo`、`BrandWordmark`、`ReferenceIcon`、`LinkIcon`、`DocumentFileIcon` | 字形与品牌标识。`LinkIcon` 只用于 14px 的可点击链接分类,`DocumentFileIcon` 只用于没有文件路径可供分类的场景。 | +| `icons/*`、`FishLogo`、`BrandWordmark`、`ReferenceIcon`、`LinkIcon` | 字形与品牌标识。`LinkIcon` 用于 14px 的可点击链接分类。 | | `FileTypeIcon`、`classifyFileType`、`fileExtension` | 按类别着色的 28px 文件或文件夹图形,以及它背后共享的不区分大小写文件名映射。代码与配置文件使用细分的全彩技术图形;链接前置图形使用 `LinkIcon`,图片内容使用图片预览。 | 有三组容易混淆: diff --git a/packages/client/ui-primitives/src/DocumentFileIcon.tsx b/packages/client/ui-primitives/src/DocumentFileIcon.tsx deleted file mode 100644 index 21aac0409d..0000000000 --- a/packages/client/ui-primitives/src/DocumentFileIcon.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { useId } from 'react' - -/** - * Render a generic document glyph when the caller intentionally has no file path to classify. - * @param props - optional CSS class for sizing and placement. - * @returns a decorative document SVG with an instance-safe gradient id. - */ -export function DocumentFileIcon({ className }: { readonly className?: string | undefined }) { - const gradientId = useId() - return ( - - - - - - - - - - - - ) -} diff --git a/packages/client/ui-primitives/src/FileTypeIcon.tsx b/packages/client/ui-primitives/src/FileTypeIcon.tsx index 8d12b22726..0a88317401 100644 --- a/packages/client/ui-primitives/src/FileTypeIcon.tsx +++ b/packages/client/ui-primitives/src/FileTypeIcon.tsx @@ -51,7 +51,6 @@ const EXTENSION_TYPES: Readonly> = { vue: 'code', svelte: 'code', astro: 'code', - env: 'code', bat: 'code', cmd: 'code', csv: 'code', diff --git a/packages/client/ui-primitives/src/LinkIcon.tsx b/packages/client/ui-primitives/src/LinkIcon.tsx index 9e5612fd22..4f645f6514 100644 --- a/packages/client/ui-primitives/src/LinkIcon.tsx +++ b/packages/client/ui-primitives/src/LinkIcon.tsx @@ -48,6 +48,7 @@ export function classifyLinkPath(path: string): LinkIconKind { case 'markdown': case 'other': case 'video': return 'other' + default: return assertNever(type) } } diff --git a/packages/client/ui-primitives/src/code-file-types.ts b/packages/client/ui-primitives/src/code-file-types.ts index b30a6c6882..92cba6cd4a 100644 --- a/packages/client/ui-primitives/src/code-file-types.ts +++ b/packages/client/ui-primitives/src/code-file-types.ts @@ -130,6 +130,7 @@ const EXTENSION_TYPES: Readonly> = { 'dart': 'dart', 'dtd': 'xml', 'edn': 'clojure', + 'env': 'env', 'erl': 'erlang', 'es6': 'javascript', 'escript': 'erlang', diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index a483fee247..3e9ae259e4 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -42,7 +42,6 @@ export { Tooltip } from './Tooltip.tsx' export type { TooltipSide } from './Tooltip.tsx' export { Toast } from './Toast.tsx' export { fileSizeText } from './file-size.ts' -export { DocumentFileIcon } from './DocumentFileIcon.tsx' export { writeClipboard } from './clipboard.ts' export { relativeTime } from './relative-time.ts' export { rankByName } from './rank-by-name.ts' diff --git a/packages/client/ui-primitives/tests/file-type-icon.client.spec.tsx b/packages/client/ui-primitives/tests/file-type-icon.client.spec.tsx index 12d0867088..ff9126bf2c 100644 --- a/packages/client/ui-primitives/tests/file-type-icon.client.spec.tsx +++ b/packages/client/ui-primitives/tests/file-type-icon.client.spec.tsx @@ -13,6 +13,7 @@ describe('fileExtension', () => { ['src/README.md', 'md'], ['C:\\work\\REPORT.PDF', 'PDF'], ['.env', 'env'], + ['settings.env', 'env'], ['archive.tar.gz', 'gz'], ['Makefile', ''], ['trailing.', ''], @@ -42,6 +43,7 @@ describe('classifyFileType', () => { ['Makefile', 'makefile'], ['README', 'markdown'], ['.env', 'env'], + ['settings.env', 'env'], ['.hidden', 'other'], ['notes.unknownext', 'other'], ['extensionless', 'other'], diff --git a/packages/client/ui-primitives/tests/icons.client.spec.tsx b/packages/client/ui-primitives/tests/icons.client.spec.tsx index 62cced1199..1b9a9c7773 100644 --- a/packages/client/ui-primitives/tests/icons.client.spec.tsx +++ b/packages/client/ui-primitives/tests/icons.client.spec.tsx @@ -3,7 +3,7 @@ import { cleanup, render } from '@testing-library/react' import { afterEach, describe, expect, it } from 'vitest' import * as primitives from '@deepseek-ai/dsh-client-ui-primitives' import { - DocumentFileIcon, IconAlarmClockOutline16, IconApiOutline14, IconArchiveOutline20, IconFolderClose16, + IconAlarmClockOutline16, IconApiOutline14, IconArchiveOutline20, IconFolderClose16, IconGoalOutline16, IconSendOutline16, } from '@deepseek-ai/dsh-client-ui-primitives' @@ -70,19 +70,6 @@ describe('FishLogo', () => { }) }) -describe('DocumentFileIcon', () => { - it('renders the generic document artwork with instance-safe gradients and its class', () => { - const { container } = render(<>) - const svgs = [...container.querySelectorAll('svg')] - const ids = [...container.querySelectorAll('linearGradient')].map(gradient => gradient.id) - expect(svgs).toHaveLength(2) - expect(svgs[0]?.classList.contains('document')).toBe(true) - expect(new Set(ids).size).toBe(2) - expect([...container.querySelectorAll('svg > path:first-child')].map(path => path.getAttribute('fill'))) - .toEqual(ids.map(id => `url(#${id})`)) - }) -}) - describe('BrandWordmark', () => { it('can render the name artwork with or without its leading mark', () => { const view = render() diff --git a/packages/client/ui-sidebar-files/README.i18n.yaml b/packages/client/ui-sidebar-files/README.i18n.yaml index f98ead19a3..d6c937ecf4 100644 --- a/packages/client/ui-sidebar-files/README.i18n.yaml +++ b/packages/client/ui-sidebar-files/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-sidebar-files/README.md -README.md: 89dc8ca0d3b34c7f2e0a720e959d9a163beb533f -README.zh.md: 166e04bde8a61392248f14b43f71f2b88a4332bd +README.md: cadadc03f96f39f9b48813f25179f4506b6de791 +README.zh.md: e25d86b27556bac746c53abf016b37ce1ded81df diff --git a/packages/client/ui-sidebar-files/README.md b/packages/client/ui-sidebar-files/README.md index 89dc8ca0d3..cadadc03f9 100644 --- a/packages/client/ui-sidebar-files/README.md +++ b/packages/client/ui-sidebar-files/README.md @@ -26,7 +26,7 @@ The right Sidebar's navigator tab type: the session's workspace root as a tree, - **The type** — `ctx.sidebarRightTabs.register(...)` with kind `files`, id `@deepseek-ai/dsh-client-ui-sidebar-files`, band `builtin`, no patterns, and one guide entry (order 10, titled from the `sidebarFiles` namespace) that opens the type. - **The body** — the keyed `sidebar.right.pane.tab` seat under that id: a header row under the strip, then the tree. The header row is the document preview's (`ui-sidebar-documentpreview`): the root path, its directories greyed and its last segment in full ink, never ellipsized (a path wider than the row keeps its end and fades its start), with the one control, reload, at its right. The row is copied rather than shared because a plugin bundle shares runtime code only through the platform modules; once the artifact and slot surfaces settle, one copy in `ui-primitives` could serve every pane header. -- **The chip title** — the keyed `sidebar.right.pane.tab.title` seat under that id: a 16px folder sheet before the type's label. The tree's own rows never draw this sheet. +- **The chip title** — the keyed `sidebar.right.pane.tab.title` seat under that id: a shared `FileTypeIcon` folder glyph at 16px before the type's label. The tree's own rows never draw this sheet. Seven source files under `src/client/`: `definition.ts` (the type), `store.ts` (what it keeps), `face.ts` (how it lists, Remote binding included), `FilesBody.tsx` (what it draws, with its ordering and failure-line helpers), `FilesTitle.tsx` (the chip title), `locales.ts` (what it says), and `index.ts` (the wiring). diff --git a/packages/client/ui-sidebar-files/README.zh.md b/packages/client/ui-sidebar-files/README.zh.md index 166e04bde8..e25d86b275 100644 --- a/packages/client/ui-sidebar-files/README.zh.md +++ b/packages/client/ui-sidebar-files/README.zh.md @@ -26,7 +26,7 @@ kind: "package-reference" - **类型**:`ctx.sidebarRightTabs.register(...)`,kind 为 `files`,id 为 `@deepseek-ai/dsh-client-ui-sidebar-files`,档位 `builtin`,没有 patterns,另有一个打开该类型的引导页入口(order 10,标题取自 `sidebarFiles` 命名空间)。 - **正文**:以该 id 为键的 `sidebar.right.pane.tab` 坑位:strip 下的一行标题行,然后是树。标题行与文档预览(`ui-sidebar-documentpreview`)的相同:根路径,目录部分灰色、最后一段正色,从不省略号截断(比行宽的路径保留末尾、淡出开头),右端是它唯一的控件、重新读取。这一行是复制而非共享,因为插件 bundle 只经平台模块共享运行时代码;待 artifact 与各 slot 的形态定下来后,可以在 `ui-primitives` 放一份供每个 pane 标题行使用。 -- **标签页标题**:以该 id 为键的 `sidebar.right.pane.tab.title` 坑位:类型标签前的一枚 16px 文件夹图标。树本身的行不画这枚图标。 +- **标签页标题**:以该 id 为键的 `sidebar.right.pane.tab.title` 坑位:类型标签前的一枚 16px 共享 `FileTypeIcon` 文件夹图标。树本身的行不画这枚图标。 `src/client/` 下七个源文件:`definition.ts`(类型是什么)、`store.ts`(它保存什么)、`face.ts`(它如何列目录,含 Remote 绑定)、`FilesBody.tsx`(它画什么,含排序与失败行两个辅助函数)、`FilesTitle.tsx`(标签页标题)、`locales.ts`(它说什么)、`index.ts`(接线)。 diff --git a/packages/client/ui-sidebar-files/src/client/FilesTitle.tsx b/packages/client/ui-sidebar-files/src/client/FilesTitle.tsx index 2d03a997df..f02456b8ce 100644 --- a/packages/client/ui-sidebar-files/src/client/FilesTitle.tsx +++ b/packages/client/ui-sidebar-files/src/client/FilesTitle.tsx @@ -5,22 +5,10 @@ * this one. */ import type { ReactNode } from 'react' +import { FileTypeIcon } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import css from './FilesBody.module.css' -/** The folder: a 28-unit sheet in the folder colour, a white tab line across it. */ -function FolderGlyph(): ReactNode { - return ( - - ) -} - /** * The title as the chip and a floating panel's header show it. * @param props - the tab information hook. @@ -30,7 +18,7 @@ export function FilesTitle({ useTabInfo }: PropsRuntime<'sidebar.right.pane.tab. const { tab } = useTabInfo() return ( <> - + {tab.title} ) From 4e0a405a9b0723b5b0d817077265590ef8ea3258 Mon Sep 17 00:00:00 2001 From: yudshj Date: Wed, 9 Sep 2026 18:01:36 +0800 Subject: [PATCH 046/111] fix(web): size diagram previews to image content --- ...9-content-sized-diagram-previews.i18n.yaml | 6 ++ ...26-09-09-content-sized-diagram-previews.md | 25 +++++ ...09-09-content-sized-diagram-previews.zh.md | 25 +++++ ...6-09-09-markdown-static-previews.i18n.yaml | 4 +- .../2026-09-09-markdown-static-previews.md | 2 +- .../2026-09-09-markdown-static-previews.zh.md | 2 +- .../expected/markdown-mermaid/ui.expected.md | 4 +- .../expected/markdown-mermaid/zh.expected.md | 4 +- apps/web/tests/markdown-mermaid.e2e.ts | 92 ++++++++++++++++--- .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 2 +- .../ui-primitives/src/markdown/graphviz.ts | 2 +- .../src/markdown/preview-document.ts | 10 +- .../ui-primitives/src/markdown/render.tsx | 2 +- .../tests/fence-previews.client.spec.tsx | 31 ++++--- 16 files changed, 170 insertions(+), 47 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-09-09-content-sized-diagram-previews.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-09-09-content-sized-diagram-previews.md create mode 100644 .agents/notes/implemented/bug-fix/2026-09-09-content-sized-diagram-previews.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-09-09-content-sized-diagram-previews.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-09-content-sized-diagram-previews.i18n.yaml new file mode 100644 index 0000000000..be4173fd02 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-09-content-sized-diagram-previews.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/bug-fix/2026-09-09-content-sized-diagram-previews.md +2026-09-09-content-sized-diagram-previews.md: ceb29238eec09f2dde312835dbf376855998a507 +2026-09-09-content-sized-diagram-previews.zh.md: 2689602bfe3469f53e532367877f7e49d52d3d5d diff --git a/.agents/notes/implemented/bug-fix/2026-09-09-content-sized-diagram-previews.md b/.agents/notes/implemented/bug-fix/2026-09-09-content-sized-diagram-previews.md new file mode 100644 index 0000000000..ceb29238ee --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-09-content-sized-diagram-previews.md @@ -0,0 +1,25 @@ +# Agent Note: Content-sized diagram previews + +Status: implemented + +English | [中文](2026-09-09-content-sized-diagram-previews.zh.md) + +## Problem + +Short Graphviz and SVG images leave large empty regions when displayed inside 400px frames. A 59px Graphviz diagram occupies a 400px preview even though its rendered dimensions are already available to the browser. + +## Decision + +Mermaid, Graphviz, and SVG share the inert image canvas in `SourcePreview`. The browser derives its height from the image, preserves aspect ratio when shrinking to the available width, and adds 16px padding. Source switching retains the image and copying reads the original code. Theme changes regenerate Graphviz colors without introducing a separate sizing observer. + +This replaces the diagram-frame choice in [Static Markdown fence previews](../feature/2026-09-09-markdown-static-previews.md); that note continues to own HTML isolation, rendering lifecycle, and license obligations. HTML retains its opaque, script-free, 400px frame. SVG image mode disables scripts, link interaction, and external resource loading without inserting source-controlled markup into the application document. + +## Alternatives considered + +**Set iframe height to auto.** An iframe does not derive its outer height from its inner document. This still reserves an unrelated viewport. + +**Add a frame measurement script or same-origin access.** A diagram already has image dimensions. Extra execution or origin permissions and resize messaging are unnecessary for that content. + +## Consequences + +Short diagrams occupy only their rendered height and canvas padding. The browser geometry regression compares image and container height in light and dark modes, then verifies proportional scaling in a narrow viewport. Browser security checks include SVG scripts, event handlers, nested HTML, and external images; copy and source toggling remain covered. HTML automatic height remains outside this change because arbitrary document layout has different measurement and isolation requirements. diff --git a/.agents/notes/implemented/bug-fix/2026-09-09-content-sized-diagram-previews.zh.md b/.agents/notes/implemented/bug-fix/2026-09-09-content-sized-diagram-previews.zh.md new file mode 100644 index 0000000000..2689602bfe --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-09-content-sized-diagram-previews.zh.md @@ -0,0 +1,25 @@ +# Agent Note: 按内容定高的图表预览 + +Status: implemented + +[English](2026-09-09-content-sized-diagram-previews.md) | 中文 + +## Problem + +较矮的 Graphviz 与 SVG 图片显示在 400px iframe 中时,会留下大块空白。浏览器已经知道图片的渲染尺寸,但一张 59px 高的 Graphviz 图表仍占据 400px 的预览区域。 + +## Decision + +Mermaid、Graphviz 与 SVG 共享 `SourcePreview` 的不可执行图片画布。浏览器根据图片决定高度,在可用宽度不足时按比例缩小,并添加 16px 内边距。源码切换保留图片,复制读取原始代码。主题变化重新生成 Graphviz 配色,无需单独的尺寸观察器。 + +这替代了[静态 Markdown fence 预览](../feature/2026-09-09-markdown-static-previews.zh.md)中的图表 iframe 选择;该记录继续维护 HTML 隔离、渲染生命周期与许可证义务。HTML 保留不透明来源、禁用脚本的 400px iframe。SVG 图片模式禁用脚本、链接交互与外部资源加载,无需把源码控制的标记插入应用文档。 + +## Alternatives considered + +**把 iframe 高度设为 auto。** iframe 不会根据内部文档决定外部高度,仍然会保留与内容无关的视口。 + +**添加 iframe 测量脚本或同源访问。** 图表已经具备图片尺寸,不需要额外执行权限、来源权限或尺寸消息协议。 + +## Consequences + +较矮图表只占据渲染高度与画布内边距。浏览器几何回归用例在浅色和深色下比较图片与容器高度,再验证窄视口中的等比例缩放。浏览器安全检查包含 SVG 脚本、事件处理器、嵌套 HTML 与外部图片,并保留复制和源码切换覆盖。HTML 自动高度不属于本次改动,因为任意文档布局具有不同的测量与隔离要求。 diff --git a/.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.i18n.yaml b/.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.i18n.yaml index 8712ee2070..8a24afcd59 100644 --- a/.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.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-09-markdown-static-previews.md -2026-09-09-markdown-static-previews.md: a5fa79fcc8c192dbcd3949debbabbccf85cb3b62 -2026-09-09-markdown-static-previews.zh.md: 30efe897702377c180c9e1650fda93ae42f6e580 +2026-09-09-markdown-static-previews.md: f3a0363e3d7c4cb19ca67dfc5011e936f7c024cf +2026-09-09-markdown-static-previews.zh.md: 24f87cd73504c1fbd274e1420a3692b3ca55ed5e diff --git a/.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.md b/.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.md index a5fa79fcc8..f3a0363e3d 100644 --- a/.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.md +++ b/.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.md @@ -18,7 +18,7 @@ The shared Markdown renderer enables settled `mermaid`, `graphviz`/`dot`, `svg`, Mermaid loads on demand. A shared queue serializes theme initialization with diagram work, and each call removes its temporary measurement DOM in `finally`. Strict security, disabled HTML labels, the application palette, and error-rendering policy cannot be overridden by diagram configuration. Generated SVG is displayed as an image without installing links or scripts. Intrinsic dimensions come from the SVG viewBox; large diagrams shrink to fit, and the canvas follows the code-block background. Mounted previews observe document theme attributes and regenerate only when resolved colors change; obsolete renders cannot publish. Graphviz default colors follow the same palette, while authored DOT, SVG, and HTML colors remain intact. -HTML uses DOMPurify with navigation attributes and document-loading elements forbidden, followed by an opaque `sandbox=""` iframe. A trusted CSP precedes source markup and permits only inline CSS, data images, and data fonts. Scripts, external resources, child frames, form submission, and same-origin access are unavailable. SVG is parsed as XML and placed in an image inside the frame, so SVG scripts and links never become active. Frames have a fixed scrollable viewport: measuring their content would require additional origin access or trusted frame scripts. +HTML uses DOMPurify with navigation attributes and document-loading elements forbidden, followed by an opaque `sandbox=""` iframe. A trusted CSP precedes source markup and permits only inline CSS, data images, and data fonts. Scripts, external resources, child frames, form submission, and same-origin access are unavailable. SVG is parsed as XML and displayed with Graphviz output as inert images in a [content-sized canvas](../bug-fix/2026-09-09-content-sized-diagram-previews.md), so SVG scripts and links never become active. HTML frames have a fixed scrollable viewport: measuring their content would require additional origin access or trusted frame scripts. Graphviz uses the pinned, unmodified `@viz-js/viz` 3.30.0 WebAssembly distribution with the `dot` layout engine. Its npm MIT declaration covers the wrapper; its build provenance identifies Graphviz 16.0.0 (EPL-2.0), Expat 2.8.4 (MIT), and Emscripten 5.0.7 (MIT/NCSA). Distribution retains these component terms and the exact Graphviz source download under EPL-2.0 section 3.1. [Full preview notices](../../../../packages/client/ui-primitives/THIRD_PARTY_PREVIEW_NOTICES.txt) also retain Mermaid's MIT text and select Apache-2.0 for DOMPurify. The UI primitives package ships the notices and the Web build emits the same bytes alongside its assets. License review covers embedded payloads separately from the general permissive npm-metadata policy. diff --git a/.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.zh.md b/.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.zh.md index 30efe89770..24f87cd735 100644 --- a/.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.zh.md +++ b/.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.zh.md @@ -18,7 +18,7 @@ Status: implemented Mermaid 按需加载。共享队列将主题初始化与图表渲染一起串行执行,每次调用都在 `finally` 中删除临时测量 DOM。图表配置无法覆盖严格安全模式、禁用 HTML 标签、应用配色和错误渲染策略。生成的 SVG 作为图片显示,不安装链接或脚本。固有尺寸来自 SVG viewBox;大图缩小以适应宽度,画布使用代码块背景。已挂载的预览观察文档主题属性,仅在解析后的配色变化时重新生成;过期渲染不能发布结果。Graphviz 的默认颜色采用同一配色,DOT、SVG 和 HTML 中明确指定的颜色保持原样。 -HTML 先由 DOMPurify 清理,禁止导航属性与文档加载元素,再进入不透明来源的 `sandbox=""` iframe。可信 CSP 位于源码标记之前,仅允许内联 CSS、data 图片与 data 字体。脚本、外部资源、子 iframe、表单提交与同源访问均不可用。SVG 按 XML 解析后作为图片放进 iframe,因此 SVG 脚本与链接不会激活。iframe 使用固定的可滚动视口:测量内容需要额外的来源访问权限或可信 iframe 脚本。 +HTML 先由 DOMPurify 清理,禁止导航属性与文档加载元素,再进入不透明来源的 `sandbox=""` iframe。可信 CSP 位于源码标记之前,仅允许内联 CSS、data 图片与 data 字体。脚本、外部资源、子 iframe、表单提交与同源访问均不可用。SVG 按 XML 解析后,与 Graphviz 输出一同作为不可执行图片显示在[按内容定高的画布](../bug-fix/2026-09-09-content-sized-diagram-previews.zh.md)中,因此 SVG 脚本与链接不会激活。HTML iframe 使用固定的可滚动视口:测量内容需要额外的来源访问权限或可信 iframe 脚本。 Graphviz 使用固定且未修改的 `@viz-js/viz` 3.30.0 WebAssembly 发布包,布局引擎为 `dot`。其 npm MIT 声明覆盖包装层;构建来源记录标明 Graphviz 16.0.0(EPL-2.0)、Expat 2.8.4(MIT)与 Emscripten 5.0.7(MIT/NCSA)。分发保留各组件条款,并按 EPL-2.0 第 3.1 节提供准确的 Graphviz 源码下载地址。[完整预览声明](../../../../packages/client/ui-primitives/THIRD_PARTY_PREVIEW_NOTICES.txt)还保留 Mermaid 的 MIT 文本,并为 DOMPurify 选择 Apache-2.0。UI primitives 包携带该声明,Web 构建在资源旁输出相同字节。内嵌产物的许可证检查独立于通用的宽松 npm 元数据策略。 diff --git a/apps/web/tests/expected/markdown-mermaid/ui.expected.md b/apps/web/tests/expected/markdown-mermaid/ui.expected.md index 369f0ef21d..985f68fee0 100644 --- a/apps/web/tests/expected/markdown-mermaid/ui.expected.md +++ b/apps/web/tests/expected/markdown-mermaid/ui.expected.md @@ -15,10 +15,10 @@ - img "Mermaid diagram" - button "Source" - button "Copy" -- iframe +- img "Graphviz diagram" - button "Source" - button "Copy" -- iframe +- img "SVG preview" - button "Source" - button "Copy" - iframe diff --git a/apps/web/tests/expected/markdown-mermaid/zh.expected.md b/apps/web/tests/expected/markdown-mermaid/zh.expected.md index 74a348e8be..52d7053ded 100644 --- a/apps/web/tests/expected/markdown-mermaid/zh.expected.md +++ b/apps/web/tests/expected/markdown-mermaid/zh.expected.md @@ -14,10 +14,10 @@ - img "Mermaid 图表" - button "源码" - button "复制" -- iframe +- img "Graphviz 图表" - button "源码" - button "复制" -- iframe +- img "SVG 预览" - button "源码" - button "复制" - iframe diff --git a/apps/web/tests/markdown-mermaid.e2e.ts b/apps/web/tests/markdown-mermaid.e2e.ts index d970cc9e11..95249740bf 100644 --- a/apps/web/tests/markdown-mermaid.e2e.ts +++ b/apps/web/tests/markdown-mermaid.e2e.ts @@ -27,7 +27,12 @@ const UNTRUSTED = [ ].join('\n') const DOT = 'digraph { rankdir=LR; Input -> Preview }' -const SVG = 'SVG preview' +const SVG = ` +SVG preview + + +
+
` const HTML = `

Static HTML

@@ -88,6 +93,52 @@ describe('web e2e: Mermaid chat previews', () => { await scaffold?.close() }) + it.skipIf(MODE === 'record').each(['light', 'dark'] as const)( + 'tightly wraps short diagram previews in %s mode', async (scheme) => { + const page = await newEnglishPage(browser) + try { + await page.emulateMedia({ colorScheme: scheme }) + await openConversation(page, scaffold) + await expect.poll(() => page.evaluate(() => getComputedStyle(document.documentElement).colorScheme)).toBe(scheme) + + const mermaid = page.getByRole('img', { name: 'Mermaid diagram' }).first() + await mermaid.evaluate(async (node: HTMLImageElement) => { await node.decode() }) + const mermaidHeight = await mermaid.evaluate(node => node.getBoundingClientRect().height) + const canvasHeight = await mermaid.evaluate(node => node.parentElement!.getBoundingClientRect().height) + // Compact diagrams need at most the Mermaid canvas's 16px padding on each side. + expect(canvasHeight - mermaidHeight).toBeLessThanOrEqual(32) + + for (const title of ['Graphviz diagram', 'SVG preview']) { + const image = page.getByRole('img', { name: title, exact: true }) + await image.evaluate(async (node: HTMLImageElement) => { await node.decode() }) + const imageHeight = await image.evaluate(node => node.getBoundingClientRect().height) + const containerHeight = await image.evaluate(node => node.parentElement!.getBoundingClientRect().height) + expect(imageHeight).toBeGreaterThan(0) + expect(containerHeight).toBeGreaterThanOrEqual(imageHeight) + expect.soft(containerHeight - imageHeight, + `${scheme} ${title}: ${containerHeight}px container around ${imageHeight}px content`, + ).toBeLessThanOrEqual(32) + } + await page.setViewportSize({ width: 360, height: 800 }) + const svg = page.getByRole('img', { name: 'SVG preview', exact: true }) + await expect.poll(() => svg.evaluate((node) => { + const { width, height } = node.getBoundingClientRect() + return width > 0 && height > 0 && width < 460 + })).toBe(true) + const compact = await svg.evaluate((node: HTMLImageElement) => ({ + width: node.getBoundingClientRect().width, + height: node.getBoundingClientRect().height, + canvasHeight: node.parentElement!.getBoundingClientRect().height, + ratio: node.naturalWidth / node.naturalHeight, + })) + expect(compact.width / compact.height).toBeCloseTo(compact.ratio, 1) + expect(compact.canvasHeight - compact.height).toBeLessThanOrEqual(32) + } finally { + await page.close() + } + }, + ) + it.skipIf(MODE === 'record')('updates existing diagrams when the document switches between light and dark', async () => { const page = await newEnglishPage(browser) try { @@ -102,10 +153,19 @@ describe('web e2e: Mermaid chat previews', () => { }, scheme) await expect.poll(() => image.getAttribute('src')).not.toBe(previous) await expect.poll(() => image.evaluate(node => (node as HTMLImageElement).naturalWidth)).toBeGreaterThan(0) - const colors = await image.evaluate(node => ({ - canvas: getComputedStyle(node.parentElement!).backgroundColor, - expected: getComputedStyle(document.body).getPropertyValue('--dsw-alias-markdown-code-block').trim(), - })) + const colors = await image.evaluate((node) => { + const reference = document.createElement('span') + reference.style.backgroundColor = 'var(--dsw-alias-markdown-code-block)' + document.body.append(reference) + try { + return { + canvas: getComputedStyle(node.parentElement!).backgroundColor, + expected: getComputedStyle(reference).backgroundColor, + } + } finally { + reference.remove() + } + }) expect(colors.canvas).toBe(colors.expected) await expect.poll(() => page.getByTitle('HTML preview', { exact: true }).getAttribute('srcdoc')) .toContain(`color-scheme:${scheme}`) @@ -186,7 +246,7 @@ describe('web e2e: Mermaid chat previews', () => { await page.close() }, 60_000) - it.skipIf(MODE === 'record')('previews DOT, SVG, and static HTML inside isolated frames', async () => { + it.skipIf(MODE === 'record')('previews inert DOT and SVG images and isolates static HTML', async () => { const page = await newEnglishPage(browser) const requests: string[] = [] const dialogs: string[] = [] @@ -198,27 +258,29 @@ describe('web e2e: Mermaid chat previews', () => { page.on('dialog', (dialog) => { dialogs.push(dialog.message()); void dialog.dismiss() }) await page.context().grantPermissions(['clipboard-read', 'clipboard-write']) await openConversation(page, scaffold) - for (const [title, code] of [['Graphviz diagram', DOT], ['SVG preview', SVG], ['HTML preview', HTML]]) { - const frame = page.locator(`iframe[title="${title}"]`) - await frame.waitFor() - expect(await frame.getAttribute('sandbox')).toBe('') - const body = frame.contentFrame() + for (const [title, code] of [['Graphviz diagram', DOT], ['SVG preview', SVG], ['HTML preview', HTML]] as const) { + const preview = title === 'HTML preview' + ? page.getByTitle(title, { exact: true }) + : page.getByRole('img', { name: title, exact: true, includeHidden: true }) + await preview.waitFor() if (title === 'HTML preview') { + expect(await preview.getAttribute('sandbox')).toBe('') + const body = preview.contentFrame() await body.getByRole('heading', { name: 'Static HTML' }).waitFor() expect(await body.getByRole('heading').evaluate(node => getComputedStyle(node).color)).toBe('rgb(0, 128, 0)') await body.getByText('Disabled navigation').click() - expect(await frame.evaluate(node => (node as HTMLIFrameElement).contentDocument)).toBeNull() + expect(await preview.evaluate(node => (node as HTMLIFrameElement).contentDocument)).toBeNull() } else { - await expect.poll(() => body.locator('img').evaluate(node => (node as HTMLImageElement).naturalWidth)).toBeGreaterThan(0) + await expect.poll(() => preview.evaluate(node => (node as HTMLImageElement).naturalWidth)).toBeGreaterThan(0) } - const block = page.locator('.md-code-block').filter({ has: frame }) + const block = page.locator('.md-code-block').filter({ has: preview }) await block.hover() await block.getByRole('button', { name: 'Copy', exact: true }).click() expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(code) await block.getByRole('button', { name: 'Source', exact: true }).click() expect(await block.locator('pre code').textContent()).toBe(code) await block.getByRole('button', { name: 'Preview', exact: true }).click() - expect(await frame.isVisible()).toBe(true) + expect(await preview.isVisible()).toBe(true) } expect(await page.locator('body').getAttribute('data-preview-escaped')).toBeNull() expect(dialogs).toEqual([]) diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index b3a61421d4..bca6072d44 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md -README.md: 8bdd2b7f90ebe4a998be8158351e49f815343c78 -README.zh.md: b861f9e17ff73f20717aed63b5b087358daeae10 +README.md: e6f63196375a4f96416ea05efc03cb72e8182823 +README.zh.md: 323a09e396c9b40b1401909a81998bf35af0ae6f diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 8bdd2b7f90..e6f6319637 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -77,7 +77,7 @@ Supply `MarkdownLabels.preview` to enable settled `mermaid`, `graphviz`/`dot`, ` `CodeBlock.preview` supplies an optional headerless body with icon actions for source switching and copying. Actions appear on hover or keyboard focus, and stay visible below the diagram whenever a touchscreen is available, including with a mouse attached. Switching to source keeps the preview mounted, so returning reuses its result; copying always retains the source. -Each settled fence defaults to visualization and shares the source/copy controls. Graphviz loads `@viz-js/viz` on demand and renders DOT with the `dot` engine. SVG and Graphviz output are inert images inside opaque sandbox iframes; malformed SVG and DOT retain their source with an error. HTML accepts complete documents or fragments and preserves inline styles, but strips scripts and navigation. Frames have no sandbox permissions, and their Content Security Policy blocks external resources, embedded frames, forms, and scripts. Frames use a 400px scrollable viewport. Raw HTML outside a code fence remains ordinary Markdown input. +Each settled fence defaults to visualization and shares the source/copy controls. Graphviz loads `@viz-js/viz` on demand and renders DOT with the `dot` engine. SVG and Graphviz output are inert images in the same content-sized canvas as Mermaid; malformed SVG and DOT retain their source with an error. HTML accepts complete documents or fragments and preserves inline styles, but strips scripts and navigation. HTML frames have no sandbox permissions, and their Content Security Policy blocks external resources, embedded frames, forms, and scripts. Diagram canvases follow the rendered image height with 16px padding and shrink images proportionally to fit the available width. HTML frames use a 400px scrollable viewport. Raw HTML outside a code fence remains ordinary Markdown input. Internal render functions accept optional native `MermaidConfig` and Viz.js `RenderOptions` for developer customization. Attribute and theme-variable overrides merge with document defaults; Mermaid preview restrictions and Graphviz SVG output remain fixed. These parameters have no settings UI. diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index b861f9e17f..323a09e396 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -77,7 +77,7 @@ kind: "package-library" `CodeBlock.preview` 提供无标题栏的替代正文,以及切换源码和复制的图标操作。操作在悬停或键盘聚焦时出现;只要设备具备触屏,就在图表下方保持可见,包括同时连接鼠标的情况。切换到源码时保留已挂载的预览,返回时复用其结果;复制始终保留源码。 -每个定稿 fence 默认显示可视化,并共享源码切换与复制操作。Graphviz 按需加载 `@viz-js/viz`,用 `dot` 引擎渲染 DOT。SVG 与 Graphviz 输出作为不可执行的图片放入不透明来源的 sandbox iframe;非法 SVG 与 DOT 显示错误并保留源码。HTML 接受完整文档或片段、保留内联样式,同时移除脚本与导航。iframe 不授予任何 sandbox 权限,Content Security Policy 阻止外部资源、嵌套 iframe、表单与脚本。iframe 使用可滚动的 400px 视口。代码 fence 外的原始 HTML 仍按普通 Markdown 输入处理。 +每个定稿 fence 默认显示可视化,并共享源码切换与复制操作。Graphviz 按需加载 `@viz-js/viz`,用 `dot` 引擎渲染 DOT。SVG 与 Graphviz 输出作为不可执行的图片,使用与 Mermaid 相同的按内容定高画布;非法 SVG 与 DOT 显示错误并保留源码。HTML 接受完整文档或片段、保留内联样式,同时移除脚本与导航。HTML iframe 不授予任何 sandbox 权限,Content Security Policy 阻止外部资源、嵌套 iframe、表单与脚本。图表画布随渲染后的图片高度收缩,保留 16px 内边距,并在可用宽度不足时等比例缩小图片。HTML iframe 使用可滚动的 400px 视口。代码 fence 外的原始 HTML 仍按普通 Markdown 输入处理。 内部渲染函数接受可选的原生 `MermaidConfig` 与 Viz.js `RenderOptions`,供开发者定制。属性与主题变量覆盖项和文档默认值合并;Mermaid 预览限制与 Graphviz SVG 输出保持固定。这些参数没有设置界面。 diff --git a/packages/client/ui-primitives/src/markdown/graphviz.ts b/packages/client/ui-primitives/src/markdown/graphviz.ts index fc8f90315b..766fe0633c 100644 --- a/packages/client/ui-primitives/src/markdown/graphviz.ts +++ b/packages/client/ui-primitives/src/markdown/graphviz.ts @@ -11,7 +11,7 @@ let runtime: ReturnType | undefined * @param code - Complete Graphviz DOT source. * @param signal - Cancels work waiting for the runtime; synchronous layout cannot be interrupted. * @param options - Native overrides; attribute maps merge over theme defaults and output remains SVG regardless of format. - * @returns A srcdoc document. Import, layout, invalid source, and cancellation failures reject. + * @returns An SVG image data URL. Import, layout, invalid source, and cancellation failures reject. */ export async function renderGraphviz(code: string, signal: AbortSignal, options?: RenderOptions): Promise { runtime ??= import('@viz-js/viz').then(module => module.instance()).catch((error: unknown) => { diff --git a/packages/client/ui-primitives/src/markdown/preview-document.ts b/packages/client/ui-primitives/src/markdown/preview-document.ts index 15aa3bf2f3..f62e227f44 100644 --- a/packages/client/ui-primitives/src/markdown/preview-document.ts +++ b/packages/client/ui-primitives/src/markdown/preview-document.ts @@ -1,4 +1,4 @@ -/** Static preview documents: opaque sandbox frames with no scripts, navigation, or remote resources. */ +/** Static HTML documents and SVG image URLs without executable source markup. */ import DOMPurify from 'dompurify' import { readPreviewTheme } from './preview-theme.ts' @@ -28,10 +28,10 @@ export function renderHtml(code: string, signal: AbortSignal): string { } /** - * Prepare an SVG image inside an isolated document, without activating SVG scripts or links. + * Prepare an SVG image URL without activating SVG scripts, links, or external resources. * @param code - Complete SVG XML; malformed XML or a non-SVG root throws. * @param signal - Prevents preparation after the preview owner is cancelled. - * @returns A srcdoc document containing a responsive inert SVG image. + * @returns A data URL for an image whose intrinsic dimensions determine the preview height. */ export function renderSvg(code: string, signal: AbortSignal): string { signal.throwIfAborted() @@ -40,7 +40,5 @@ export function renderSvg(code: string, signal: AbortSignal): string { || parsed.documentElement.namespaceURI !== 'http://www.w3.org/2000/svg') { throw new Error('Invalid SVG document') } - // Double-quoted attributes keep encodeURIComponent's literal apostrophes inert. - const url = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(code)}` - return documentOf(``) + return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(code)}` } diff --git a/packages/client/ui-primitives/src/markdown/render.tsx b/packages/client/ui-primitives/src/markdown/render.tsx index f96884df64..0f67ce6a7d 100644 --- a/packages/client/ui-primitives/src/markdown/render.tsx +++ b/packages/client/ui-primitives/src/markdown/render.tsx @@ -421,7 +421,7 @@ function fencePreview(lang: string | undefined, code: string, labels: MarkdownLa const render = { mermaid: renderMermaid, graphviz: renderGraphviz, svg: renderSvg, html: renderHtml }[kind] return { content: ( - + ), previewLabel: labels.preview.preview, sourceLabel: labels.preview.source, diff --git a/packages/client/ui-primitives/tests/fence-previews.client.spec.tsx b/packages/client/ui-primitives/tests/fence-previews.client.spec.tsx index 8e8b4abf58..f7fe436821 100644 --- a/packages/client/ui-primitives/tests/fence-previews.client.spec.tsx +++ b/packages/client/ui-primitives/tests/fence-previews.client.spec.tsx @@ -35,22 +35,29 @@ describe('Markdown fence previews', () => { expect(view.container.querySelector('iframe')).toBeNull() expect(view.container.querySelector('pre code')?.textContent).toBe(code) view.rerender() - const frame = await screen.findByTitle(title) - expect(frame.getAttribute('sandbox')).toBe('') - expect(frame.getAttribute('referrerpolicy')).toBe('no-referrer') - expect(frame.getAttribute('srcdoc')).toContain('Content-Security-Policy') + const findPreview = async () => lang === 'html' ? await screen.findByTitle(title) : await screen.findByRole('img', { name: title }) + const element = await findPreview() + if (lang === 'html') { + expect(element.getAttribute('sandbox')).toBe('') + expect(element.getAttribute('referrerpolicy')).toBe('no-referrer') + expect(element.getAttribute('srcdoc')).toContain('Content-Security-Policy') + } else { + expect(element.getAttribute('src')).toMatch(/^data:image\/svg\+xml/) + expect(view.container.querySelector('iframe')).toBeNull() + expect(element.parentElement!.querySelector('svg')).toBeNull() + } expect(view.container.querySelector('pre code')).toBeNull() fireEvent.click(screen.getByRole('button', { name: 'Source' })) expect(view.container.querySelector('pre code')?.textContent).toBe(code) - expect(frame.closest('[hidden]')).not.toBeNull() + expect(element.closest('[hidden]')).not.toBeNull() fireEvent.click(screen.getByRole('button', { name: markdownLabels.code.copyLabel })) await waitFor(() => { expect(writeText).toHaveBeenCalledWith(code) }) fireEvent.click(screen.getByRole('button', { name: 'Preview' })) - expect(screen.getByTitle(title)).toBe(frame) - expect(frame.closest('[hidden]')).toBeNull() + expect(await findPreview()).toBe(element) + expect(element.closest('[hidden]')).toBeNull() view.unmount() render() - await screen.findByTitle(title) + await findPreview() fireEvent.click(screen.getByRole('button', { name: markdownLabels.code.copyLabel })) await waitFor(() => { expect(writeText).toHaveBeenCalledTimes(2) }) }) @@ -60,7 +67,7 @@ describe('Markdown fence previews', () => { await screen.findByText(status.error) expect(view.container.querySelector('pre code')?.textContent).toBe('broken') view.rerender() - await screen.findByTitle(preview.svg.diagram) + await screen.findByRole('img', { name: preview.svg.diagram }) expect(screen.queryByText(status.error)).toBeNull() }) @@ -90,9 +97,9 @@ describe('static preview documents', () => { it('encodes SVG as an image rather than executable document markup', () => { const code = '' - const parsed = new DOMParser().parseFromString(renderSvg(code, new AbortController().signal), 'text/html') - expect(parsed.querySelector('svg, script')).toBeNull() - expect(decodeURIComponent(parsed.querySelector('img')!.getAttribute('src')!)).toContain(code) + const url = renderSvg(code, new AbortController().signal) + expect(url).toMatch(/^data:image\/svg\+xml;charset=utf-8,/) + expect(decodeURIComponent(url.slice(url.indexOf(',') + 1))).toBe(code) }) it.each(['', '', ''])('rejects non-SVG or malformed XML: %s', (code) => { From b9587ada121a4fc322dc4f94328682fb64fba845 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 9 Sep 2026 18:07:16 +0800 Subject: [PATCH 047/111] fix(web): open delivered files using the viewed session scope --- ...-09-09-present-filesystem-access.i18n.yaml | 4 +- .../2026-09-09-present-filesystem-access.md | 2 +- ...2026-09-09-present-filesystem-access.zh.md | 2 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- .../client/ui-deliverables/README.i18n.yaml | 4 +- packages/client/ui-deliverables/README.md | 2 +- packages/client/ui-deliverables/README.zh.md | 2 +- packages/client/ui-deliverables/package.json | 3 +- packages/client/ui-deliverables/src/index.ts | 2 +- .../ui-deliverables/src/present-open.ts | 15 +++--- .../tests/present-open.host.spec.ts | 46 +++++++------------ .../ui-deliverables/tests/prompt.host.spec.ts | 3 ++ .../client/ui-deliverables/tsconfig.host.json | 3 ++ pnpm-lock.yaml | 3 ++ 16 files changed, 48 insertions(+), 51 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-09-09-present-filesystem-access.i18n.yaml b/.agents/notes/implemented/feature/2026-09-09-present-filesystem-access.i18n.yaml index 4a87c67631..5bb1142826 100644 --- a/.agents/notes/implemented/feature/2026-09-09-present-filesystem-access.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-09-present-filesystem-access.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-09-present-filesystem-access.md -2026-09-09-present-filesystem-access.md: 24b0e767d96261a95e449278d0919c381141f50d -2026-09-09-present-filesystem-access.zh.md: 2f4dc550823bd6c8de6ad61c113d58e3f238bbb5 +2026-09-09-present-filesystem-access.md: 5c9d5bec47c73ac1c5952b887d56e582148a4cea +2026-09-09-present-filesystem-access.zh.md: 6387f07b17276b3455d19d650b4ae031a7efda4c diff --git a/.agents/notes/implemented/feature/2026-09-09-present-filesystem-access.md b/.agents/notes/implemented/feature/2026-09-09-present-filesystem-access.md index 24b0e767d9..5c9d5bec47 100644 --- a/.agents/notes/implemented/feature/2026-09-09-present-filesystem-access.md +++ b/.agents/notes/implemented/feature/2026-09-09-present-filesystem-access.md @@ -12,7 +12,7 @@ Generated files commonly live outside the workspace, especially in `/tmp`. Works `present` accepts existing regular files accessible through its composed `ctx.fs`, with relative paths resolved against the Session working directory. There is no workspace containment check or special temporary-directory allowlist. Missing files, directories, final symbolic links, and provider failures reject the declaration. A sandbox's private temporary files remain unavailable when the filesystem provider cannot see them. -Native actions resolve or resume the viewed Session's Agent and use its `workspaceFiles.stat`. The resulting canonical process path must map from a Host path back to the same process path through that Agent's filesystem. Absent or different mappings produce 422 and a localized Sidebar-preview suggestion. This conservatively supports Host paths that share their canonical process spelling; providers with only a nonidentity Host mapping can still serve previews. A same-named local file never substitutes for an unmapped provider file. +Native actions use the viewed Session header returned with the declaration to form the `workspaceFiles.stat` scope: its cwd, or the deployment workspace root when absent. The same composed filesystem serves Sidebar previews and native validation without activating an Agent, including for child Sessions. The resulting canonical process path must map from a Host path back to the same process path through that filesystem. Absent or different mappings produce 422 and a localized Sidebar-preview suggestion. This conservatively supports Host paths that share their canonical process spelling; providers with only a nonidentity Host mapping can still serve previews. A same-named local file never substitutes for an unmapped provider file. This replaces the workspace-only access rule in the [source-file delivery decision](2026-09-08-present-workspace-source-files.md). That note continues to own content-free declarations, Session events, and editing current sources. The request still selects only saved Session/event/file coordinates, never an arbitrary browser-supplied path. diff --git a/.agents/notes/implemented/feature/2026-09-09-present-filesystem-access.zh.md b/.agents/notes/implemented/feature/2026-09-09-present-filesystem-access.zh.md index 2f4dc55082..6387f07b17 100644 --- a/.agents/notes/implemented/feature/2026-09-09-present-filesystem-access.zh.md +++ b/.agents/notes/implemented/feature/2026-09-09-present-filesystem-access.zh.md @@ -12,7 +12,7 @@ Status: implemented `present` 接受所组合的 `ctx.fs` 可访问的已有普通文件,相对路径按 Session 工作目录解析。不检查工作区包含关系,也不另设临时目录白名单。文件缺失、为目录、最终路径为符号链接或提供方出错时,声明失败。文件系统提供方不可见的沙箱私有临时文件仍不可用。 -原生操作查找或恢复当前查看的 Session 的 Agent,并调用其 `workspaceFiles.stat`。得到的规范化进程路径必须能通过该 Agent 的文件系统从 Host 路径映射回同一进程路径。映射缺失或不同会返回 422,并显示使用侧栏预览的本地化提示。这只支持规范化后与进程路径写法相同的 Host 路径;仅支持不同写法的 Host 映射的提供方仍可提供预览。没有映射时,本机同名文件不能替代提供方文件。 +原生操作使用与声明一起返回的当前 Session header,为 `workspaceFiles.stat` 提供工作目录:优先使用其中的 cwd,未记录时使用部署的工作目录。侧栏预览和原生校验使用同一组合文件系统,无需启动 Agent,子会话也适用。得到的规范化进程路径必须能通过该文件系统从 Host 路径映射回同一进程路径。映射缺失或不同会返回 422,并显示使用侧栏预览的本地化提示。这只支持规范化后与进程路径写法相同的 Host 路径;仅支持不同写法的 Host 映射的提供方仍可提供预览。没有映射时,本机同名文件不能替代提供方文件。 本决策替代[源文件交付决策](2026-09-08-present-workspace-source-files.zh.md)中只允许工作区文件的访问规则。原说明继续负责不保存内容的声明、Session 事件和编辑当前源文件。请求仍只按已保存的 Session、事件和文件索引选择文件,不接受浏览器提供的任意路径。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 5e94a4dce0..901cce8be7 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 1b7a52346a95a4933362f646f5873c49fc1bd71b -config-catalog.zh.md: 8100201248be35987ac536a5dcf9d2b9c90f4a17 +config-catalog.md: 9adf44b995601196b602e9219e53c52208039051 +config-catalog.zh.md: e2f6c6babc18bfa689f6830c81231012b4d9de18 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 1b7a52346a..9adf44b995 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -3459,7 +3459,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-client-ui-commands` ([`packages/client/ui-commands/src/index.ts`](../packages/client/ui-commands/src/index.ts)) - `@deepseek-ai/dsh-client-ui-conversation` ([`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts)) - `@deepseek-ai/dsh-client-ui-cordis` ([`packages/extensions/ui-cordis/src/index.ts`](../packages/extensions/ui-cordis/src/index.ts)) -- `@deepseek-ai/dsh-client-ui-deliverables` — requires `systemPrompt` · `connection` · `sessionQuery` · `sessionController` ([`packages/client/ui-deliverables/src/index.ts`](../packages/client/ui-deliverables/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-deliverables` — requires `systemPrompt` · `connection` · `sessionQuery` · `sessionController` · `workspaceFiles` · `fs` · `sandboxPolicy` ([`packages/client/ui-deliverables/src/index.ts`](../packages/client/ui-deliverables/src/index.ts)) - `@deepseek-ai/dsh-client-ui-directory-picker-browse` ([`packages/client/ui-directory-picker-browse/src/index.ts`](../packages/client/ui-directory-picker-browse/src/index.ts)) - `@deepseek-ai/dsh-client-ui-directory-picker-native` ([`packages/client/ui-directory-picker-native/src/index.ts`](../packages/client/ui-directory-picker-native/src/index.ts)) - `@deepseek-ai/dsh-client-ui-goal` ([`packages/client/ui-goal/src/index.ts`](../packages/client/ui-goal/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 8100201248..e2f6c6babc 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -3461,7 +3461,7 @@ export interface Config { - `@deepseek-ai/dsh-client-ui-commands`([`packages/client/ui-commands/src/index.ts`](../packages/client/ui-commands/src/index.ts)) - `@deepseek-ai/dsh-client-ui-conversation`([`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts)) - `@deepseek-ai/dsh-client-ui-cordis`([`packages/extensions/ui-cordis/src/index.ts`](../packages/extensions/ui-cordis/src/index.ts)) -- `@deepseek-ai/dsh-client-ui-deliverables` — 需要 `systemPrompt` · `connection` · `sessionQuery` · `sessionController`([`packages/client/ui-deliverables/src/index.ts`](../packages/client/ui-deliverables/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-deliverables` — 需要 `systemPrompt` · `connection` · `sessionQuery` · `sessionController` · `workspaceFiles` · `fs` · `sandboxPolicy`([`packages/client/ui-deliverables/src/index.ts`](../packages/client/ui-deliverables/src/index.ts)) - `@deepseek-ai/dsh-client-ui-directory-picker-browse`([`packages/client/ui-directory-picker-browse/src/index.ts`](../packages/client/ui-directory-picker-browse/src/index.ts)) - `@deepseek-ai/dsh-client-ui-directory-picker-native`([`packages/client/ui-directory-picker-native/src/index.ts`](../packages/client/ui-directory-picker-native/src/index.ts)) - `@deepseek-ai/dsh-client-ui-goal`([`packages/client/ui-goal/src/index.ts`](../packages/client/ui-goal/src/index.ts)) diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml index 59ca1fefc1..74d4c89ee7 100644 --- a/packages/client/ui-deliverables/README.i18n.yaml +++ b/packages/client/ui-deliverables/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-deliverables/README.md -README.md: 5ce7eb51df13e23a01a9ca05291c30a93e6d6d0c -README.zh.md: 9d25bbe4f76b3bf14807ea741f4176ececbe94b7 +README.md: 857b71c5e0f3e4ba0dac0e8453492684c76ca0e4 +README.zh.md: 8e801a164c8a60de4aea8ff6a224e4823e2d3ccc diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md index 5ce7eb51df..857b71c5e0 100644 --- a/packages/client/ui-deliverables/README.md +++ b/packages/client/ui-deliverables/README.md @@ -52,7 +52,7 @@ The closing prose carries the same vocabulary: an inline-code token resolves by The Node half registers the static `ui:deliverable-file-references` system-prompt section asking the model to mention primary files from successful creation or modification calls and to write those and any other changed-file references as Markdown inline code. The browser half registers a wrapper around `ProducedFiles` and explicit deliveries into the chat view's `conversation.chat.turnTail` hole. `deliverablesDefinition` folds each Turn's successful first-party mutation calls into `DeliverablesTurnData` from the validated raw arguments of `write`, `edit`, and mutating `str_replace_editor` commands. Reads, deletes, unsupported tools, malformed calls, and failed results contribute nothing. A new mutation tool needs an explicit Client contribution before it joins the list. The package also provides the `chatFileMentions` service the chat view consults per closing message; composing the plugin out removes both surfaces and leaves the view's empty chain at zero cost. -Native opening uses an authenticated POST addressed by the viewed Session, event sequence, and original file index. The Host resolves or resumes that Session’s Agent and checks the declaration through its `workspaceFiles.stat`, using the same filesystem as Sidebar previews. Native actions require the canonical process path to map from a Host path back to that same process path. Providers without this mapping return 422 and the card directs the user to Sidebar preview; a same-named Host file is insufficient. The same configured desktop availability governs metadata and execution. Edits affect subsequent opens; deletion returns an error. No file-content copy or attachment is created. Plugin disposal cancels and awaits pending native-open requests. +Native opening uses an authenticated POST addressed by the viewed Session, event sequence, and original file index. The Host reads the viewed Session header with the declaration and passes its cwd, or the deployment workspace root when absent, to `workspaceFiles.stat`. This uses the same composed filesystem as Sidebar previews and does not activate an Agent, including for child Sessions. Native actions require the canonical process path to map from a Host path back to that same process path. Providers without this mapping return 422 and the card directs the user to Sidebar preview; a same-named Host file is insufficient. The same configured desktop availability governs metadata and execution. Edits affect subsequent opens; deletion returns an error. No file-content copy or attachment is created. Plugin disposal cancels and awaits pending native-open requests. diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index 9d25bbe4f7..8e801a164c 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -52,7 +52,7 @@ Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交 Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段,要求模型点名成功创建或修改的主要文件,并把这些文件以及正文中提到的其他本轮变更文件写成 Markdown 行内代码。浏览器半部把组合 `ProducedFiles` 与显式交付的包装组件注册进 chat 视图的 `conversation.chat.turnTail` 洞。`deliverablesDefinition` 根据 `write`、`edit` 和有修改作用的 `str_replace_editor` 命令中经过校验的原始参数,把每个轮次成功的第一方修改调用折叠进 `DeliverablesTurnData`。读取、删除、不受支持的工具、格式错误的调用和失败结果不贡献任何条目。新的修改工具必须增加显式 Client contribution 才能加入列表。本包还提供 chat 视图按收尾消息查询的 `chatFileMentions` 服务;把插件组合出去会同时移除两个表面,视图的空链以零成本留下。 -原生打开使用经过认证的 POST,通过当前查看的 Session、事件序号和原始文件索引定位声明。Host 查找或恢复该 Session 的 Agent,通过其 `workspaceFiles.stat` 检查声明,与侧栏预览使用同一文件系统。原生操作要求规范化的进程路径能从 Host 路径映射回同一进程路径。提供方没有这种映射时返回 422,卡片提示使用侧栏预览;Host 上存在同名文件并不足够。同一份桌面可用性配置同时约束信息查询和实际执行。编辑会影响后续打开的内容;删除后返回错误。不创建文件内容副本或附件。插件释放时取消并等待进行中的原生打开请求。 +原生打开使用经过认证的 POST,通过当前查看的 Session、事件序号和原始文件索引定位声明。Host 读取声明及当前查看的 Session header,将其中的 cwd 传给 `workspaceFiles.stat`;未记录 cwd 时使用部署的工作目录。它与侧栏预览使用同一组合文件系统,无需启动 Agent,子会话也适用。原生操作要求规范化的进程路径能从 Host 路径映射回同一进程路径。提供方没有这种映射时返回 422,卡片提示使用侧栏预览;Host 上存在同名文件并不足够。同一份桌面可用性配置同时约束信息查询和实际执行。编辑会影响后续打开的内容;删除后返回错误。不创建文件内容副本或附件。插件释放时取消并等待进行中的原生打开请求。 diff --git a/packages/client/ui-deliverables/package.json b/packages/client/ui-deliverables/package.json index 2e56c1b3b2..16534d6f8d 100644 --- a/packages/client/ui-deliverables/package.json +++ b/packages/client/ui-deliverables/package.json @@ -72,7 +72,8 @@ "@deepseek-ai/dsh-api-workspace-files": "workspace:^", "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", - "@deepseek-ai/dsh-agent": "workspace:^" + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-sandbox-policy": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-deliverables/src/index.ts b/packages/client/ui-deliverables/src/index.ts index a6799274a3..04e5e7a8dd 100644 --- a/packages/client/ui-deliverables/src/index.ts +++ b/packages/client/ui-deliverables/src/index.ts @@ -11,7 +11,7 @@ import type {} from '@deepseek-ai/dsh-system-prompt' import { registerPresentOpen } from './present-open.ts' /** Services required for file-reference guidance and authenticated native opens of declared files. */ -export const inject = ['systemPrompt', 'connection', 'sessionQuery', 'sessionController'] +export const inject = ['systemPrompt', 'connection', 'sessionQuery', 'sessionController', 'workspaceFiles', 'fs', 'sandboxPolicy'] /** Stable final-response guidance owned by the matching renderer. */ const FILE_REFERENCE_PROMPT = 'When you successfully create or modify files, mention the primary outputs in your final response. ' diff --git a/packages/client/ui-deliverables/src/present-open.ts b/packages/client/ui-deliverables/src/present-open.ts index 88655ca325..9589ef0006 100644 --- a/packages/client/ui-deliverables/src/present-open.ts +++ b/packages/client/ui-deliverables/src/present-open.ts @@ -3,6 +3,7 @@ import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-api-session-controller' import type {} from '@deepseek-ai/dsh-api-workspace-files' import type {} from '@deepseek-ai/dsh-fs' +import type {} from '@deepseek-ai/dsh-sandbox-policy' import { remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol' import type {} from '@deepseek-ai/dsh-client-connection' import type {} from '@deepseek-ai/dsh-session-query' @@ -54,19 +55,17 @@ async function handlePresentOpen(ctx: Context, request: Request): Promise ctx.fiber.dispose()) const session: { cwd?: string } = { cwd } await ctx.plugin(LocalFileSystem, { cwd }) - ctx.provide('sandboxPolicy', { resolve: () => ({ workspaceRoot: session.cwd ?? cwd }) } as never) - new WorkspaceFiles(ctx, { maxBytes: 1024, maxFileBytes: 1024, maxLines: 100, maxEntries: 100 }) - const agent = { ctx, session: { header: session } } as unknown as Agent - const resolveAgent = vi.fn(async () => ({ agent })) + ctx.provide('sandboxPolicy', { workspaceRoot: cwd } as never) + await ctx.plugin({ + inject: ['fs', 'sandboxPolicy'], + apply: (scope) => { new WorkspaceFiles(scope, { maxBytes: 1024, maxFileBytes: 1024, maxLines: 100, maxEntries: 100 }) }, + }) + const resolveAgent = vi.fn(() => { throw new Error('Agent activation is unavailable') }) const readEvent = vi.fn(async (request: SessionEventReadRequest) => { if (request.sessionId !== 'owner') throw new SessionQueryError('missing', 'SESSION_QUERY_SESSION_NOT_FOUND') if (request.seq !== 7) throw new SessionQueryError('missing', 'SESSION_QUERY_EVENT_NOT_FOUND') @@ -48,7 +48,7 @@ async function fixture() { const opener = vi.fn(async (_request: { path: string; action?: 'reveal' }, _signal: AbortSignal) => ({ opened: true as const })) ctx.provide('sessionController', { resolveAgent, openWorkspacePath: opener, workspaceDesktop: () => ({ name: 'desktop', available: true, fileManager: 'finder' }) } as never) const connection = new HostConnectionService(ctx, [], {} as BrowserAuth) - const fiber = ctx.plugin({ inject: ['connection', 'sessionQuery', 'sessionController', 'workspaceFiles', 'fs'], apply: registerPresentOpen }) + const fiber = ctx.plugin({ inject: ['connection', 'sessionQuery', 'sessionController', 'workspaceFiles', 'fs', 'sandboxPolicy'], apply: registerPresentOpen }) await fiber const handler = connection.createSharedFetchHandler('/api') const open = (query = '?sessionId=owner&seq=7&index=0', signal?: AbortSignal) => handler.fetch(new Request( @@ -219,29 +219,17 @@ it('refuses native opening without a matching Host mapping even when a same-name expect(opener).not.toHaveBeenCalled() }) -it('reports Session resolution failures before checking files', async () => { - const { resolveAgent, open, opener } = await fixture() - resolveAgent.mockResolvedValueOnce({ error: new RemoteError('session/not-found', 'missing', { sessionId: SessionId('owner') }) }) - expect((await open()).status).toBe(404) - expect(opener).not.toHaveBeenCalled() +it('opens a viewed child Session without activating an Agent', async () => { + const { session, readEvent, open, opener, resolveAgent } = await fixture() + readEvent.mockResolvedValueOnce({ session, target: { type: 'deliverables/presented', data: { turn: 1, callId: 'child', files: [{ path: '日记模板.docx' }] } } as SessionEvent }) + expect((await open('?sessionId=child&seq=7&index=0')).status).toBe(204) + expect(opener).toHaveBeenCalledOnce() + expect(resolveAgent).not.toHaveBeenCalled() }) - -it('uses the resolved Agent filesystem instead of the Host filesystem', async () => { - const { ctx, cwd, open, resolveAgent, opener } = await fixture() - const scoped = ctx.isolate('fs') - await scoped.plugin(LocalFileSystem, { cwd }) - vi.spyOn(scoped.fs, 'processPathFromHostPath').mockReturnValue(undefined) - resolveAgent.mockResolvedValueOnce({ agent: { ctx: scoped, session: { header: { cwd } } } as unknown as Agent }) - expect((await open()).status).toBe(422) - expect(opener).not.toHaveBeenCalled() +it('uses the deployment workspace root when the viewed Session has no cwd', async () => { + const { session, open, cwd, file, opener } = await fixture() + delete session.cwd expect((await open()).status).toBe(204) -}) - - -it.each(['workspaceFiles', 'fs'])('refuses an Agent missing its %s service', async (service) => { - const { ctx, open, resolveAgent, opener } = await fixture() - resolveAgent.mockResolvedValueOnce({ agent: { ctx: ctx.isolate(service) } as unknown as Agent }) - expect((await open()).status).toBe(500) - expect(opener).not.toHaveBeenCalled() + expect(opener.mock.lastCall?.[0].path).toBe(await realpath(join(cwd, file.path))) }) diff --git a/packages/client/ui-deliverables/tests/prompt.host.spec.ts b/packages/client/ui-deliverables/tests/prompt.host.spec.ts index 8f342099fd..b56bfa3b8e 100644 --- a/packages/client/ui-deliverables/tests/prompt.host.spec.ts +++ b/packages/client/ui-deliverables/tests/prompt.host.spec.ts @@ -19,6 +19,9 @@ describe('ui-deliverables node plugin', () => { ctx.provide('connection', { fetch: { register: () => () => {} } } as never) ctx.provide('sessionQuery', {} as never) ctx.provide('sessionController', {} as never) + ctx.provide('workspaceFiles', {} as never) + ctx.provide('fs', {} as never) + ctx.provide('sandboxPolicy', {} as never) const mounted = ctx.plugin({ apply, inject }) await mounted.await() diff --git a/packages/client/ui-deliverables/tsconfig.host.json b/packages/client/ui-deliverables/tsconfig.host.json index ee8f384dd8..289e7df535 100644 --- a/packages/client/ui-deliverables/tsconfig.host.json +++ b/packages/client/ui-deliverables/tsconfig.host.json @@ -43,6 +43,9 @@ }, { "path": "../../typert/protocol" + }, + { + "path": "../../sandbox/sandbox-policy" } ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5764c8c667..2d0f407fb4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2742,6 +2742,9 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-sandbox-policy': + specifier: workspace:^ + version: link:../../sandbox/sandbox-policy '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session From 3c4c6c3d7b7079d1ea3f721ddf3d519ba8ba7cc9 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 9 Sep 2026 18:20:01 +0800 Subject: [PATCH 048/111] fix(ci): cancel superseded PR and master validation --- .../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 +-- ...26-09-06-master-only-platform-ci.i18n.yaml | 4 +- .../2026-09-06-master-only-platform-ci.md | 2 +- .../2026-09-06-master-only-platform-ci.zh.md | 2 +- .../2026-09-09-cancel-superseded-ci.i18n.yaml | 6 ++ .../2026-09-09-cancel-superseded-ci.md | 39 +++++++++++ .../2026-09-09-cancel-superseded-ci.zh.md | 39 +++++++++++ .../2026-06-19-real-api-e2e-ci.i18n.yaml | 4 +- .../testing/2026-06-19-real-api-e2e-ci.md | 2 +- .../testing/2026-06-19-real-api-e2e-ci.zh.md | 2 +- .../workflows/build-exe-for-python-sdk.yml | 3 +- .github/workflows/ci-master.yml | 9 +-- .github/workflows/ci.yml | 10 ++- .github/workflows/e2e.yml | 6 +- .github/workflows/release-vendor.yml | 2 +- .github/workflows/release.yml | 2 +- scripts/ci-workflow.spec.ts | 65 +++++++++++++------ scripts/tests/ci-master-platforms.spec.ts | 41 +++++++++++- scripts/tests/ci-release-selfhosted.spec.ts | 2 +- 21 files changed, 198 insertions(+), 66 deletions(-) create mode 100644 .agents/notes/implemented/process/2026-09-09-cancel-superseded-ci.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-09-09-cancel-superseded-ci.md create mode 100644 .agents/notes/implemented/process/2026-09-09-cancel-superseded-ci.zh.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..62e46dfa22 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: 559fa61bcf416bed3bd58b3ffcbc145038cdce22 +2026-07-26-ci-failover-runbook.zh.md: e2098b928b1a1f158bcbfabd940ca23a5cd0e28e 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..559fa61bcf 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 @@ -12,11 +12,7 @@ The three required Linux worker jobs in [CI](../../../../.github/workflows/ci.ym 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. -`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. - -The exemption is narrower than "a drill always finishes", in two ways. GitHub keeps a single pending entry per group, so a newer pending run displaces an older one and intermediate push runs still end as `cancelled` during busy periods. And the expression is evaluated against the *newly triggered* run, so a run whose own event is not `push` — a benchmark dispatched on master within `ci-master.yml`, sharing its group `CI master-` — evaluates to `true` and does cancel a drill that is mid-flight. That is a rare manual action and the next master push restores the evidence, so it does not warrant further mechanism. What the carve-out buys is that the lane periodically reaches a verdict at all, which is what makes it usable as evidence. - -The decision belongs at workflow level because cancellation applies to the whole superseded run: a job-level `concurrency` group does not exempt its job. The negated form is load-bearing rather than cosmetic: naming `pull_request` alone would also stop cancelling `workflow_dispatch`, and each runner benchmark fans out to twelve larger runners for up to fifteen minutes inside this same group on master, so a re-dispatch would queue ahead of a drill instead of replacing a stale measurement. What bounds the cost is that a master push in `ci-master.yml` carries the [post-merge runtime and Wine checks](2026-09-06-master-only-platform-ci.md) and these two drills; the pull-request jobs live in the separate `ci.yml` (which does not see `push`), and the benchmarks are `workflow_dispatch`-gated within `ci-master.yml`. `scripts/ci-workflow.spec.ts` pins that push-reachable set — classifying by exact condition, since a negated event test mentions the event it excludes — so a new push-reachable job cannot quietly start accumulating uncancelled runs. +The [superseded-CI cancellation policy](2026-09-09-cancel-superseded-ci.md) governs master pushes and manual runs in the same workflow/ref group, including standby drills. Rapid master updates can starve a drill before it reaches a verdict. Use the latest completed standby verdict and check its age and commit before treating it as readiness evidence; a cancelled or merely scheduled run is not proof of readiness. ### Release rehearsals share the Linux switch @@ -44,7 +40,7 @@ The two switches are independent: flip only the one whose platform is degraded. ## Capacity during failover -Capacity includes the master standby, main-CI jobs, and three release-rehearsal jobs for each eligible PR or master push while the Linux switch is set. Each trusted PR also adds three Node compatibility jobs at gate concurrency one, including the build-backed Node 22 leg and cold temporary runtime downloads. The release workflows do not cancel running rehearsals when another run arrives, so overlapping refs can add sustained build, pack, and install load. Check current CPU, memory, disk, and queue pressure before extending self-hosted operation; extra registrations on this VM add scheduling slots, not machine resources. Do not infer spare capacity from the standby alone. When host resources permit extra registrations, use an org registration token (org Settings → Actions → Runners → New runner). Clone an existing runner directory **excluding its identity files** — `rsync -a --exclude '.runner*' --exclude '.credentials*' --exclude '_diag' --exclude '_work' / /` (the globs also catch `.runner_migrated`/`.credentials_migrated`, which GitHub writes on migrated runners and which equally trigger the already-configured refusal) — then run `config.sh` (copying `.runner`/`.credentials` verbatim makes it refuse with "already configured"), and **start the listener**: `sudo ./svc.sh install ubuntu && sudo ./svc.sh start`. Registration alone leaves the runner offline; a started service adds a scheduling slot, not CPU or memory. +Capacity includes the master standby, main-CI jobs, and three release-rehearsal jobs for each eligible PR or master push while the Linux switch is set. Each trusted PR also adds three Node compatibility jobs at gate concurrency one, including the build-backed Node 22 leg and cold temporary runtime downloads. The release rehearsal workflows cancel superseded runs within each workflow/ref group under the [cancellation policy](2026-09-09-cancel-superseded-ci.md); different refs can still add concurrent build, pack, and install load. Check current CPU, memory, disk, and queue pressure before extending self-hosted operation; extra registrations on this VM add scheduling slots, not machine resources. Do not infer spare capacity from the standby alone. When host resources permit extra registrations, use an org registration token (org Settings → Actions → Runners → New runner). Clone an existing runner directory **excluding its identity files** — `rsync -a --exclude '.runner*' --exclude '.credentials*' --exclude '_diag' --exclude '_work' / /` (the globs also catch `.runner_migrated`/`.credentials_migrated`, which GitHub writes on migrated runners and which equally trigger the already-configured refusal) — then run `config.sh` (copying `.runner`/`.credentials` verbatim makes it refuse with "already configured"), and **start the listener**: `sudo ./svc.sh install ubuntu && sudo ./svc.sh start`. Registration alone leaves the runner offline; a started service adds a scheduling slot, not CPU or memory. ### Switch back @@ -63,4 +59,4 @@ The variables are writer-manageable repository state; a pull request event itsel ## Consequences -Recovering from a hosted-pool outage is flipping the affected platform's variable (any writer) plus a re-run, with no merge on the critical path. The cost is a second runner topology per platform to keep working: the standby lanes exercise them on every master push so the failover targets never go stale, and the snapshot-concurrency and cache-restore branches in `ci.yml` carry a `selfhosted` leg (Linux only) that must stay in step with the hosted leg. Splitting the switch by platform adds one more variable to manage but bounds the blast radius of each switch to the jobs of a single platform. +Recovering from a hosted-pool outage is flipping the affected platform's variable (any writer) plus a re-run, with no merge on the critical path. The cost is a second runner topology per platform to keep working: master pushes schedule the standby lanes, but only completed verdicts establish readiness under the [cancellation policy](2026-09-09-cancel-superseded-ci.md), and the snapshot-concurrency and cache-restore branches in `ci.yml` carry a `selfhosted` leg (Linux only) that must stay in step with the hosted leg. Splitting the switch by platform adds one more variable to manage but bounds the blast radius of each switch to the jobs of a single platform. 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..e2098b928b 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 @@ -12,11 +12,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 推送上重新验证完整的未分片聚合流程。 -`ci-master.yml` 只豁免一个事件不做取消(`${{ github.event_name != 'push' }}`),因此一次 master 推送不会取消上一次推送留下的、仍在运行的演练。每次演练以单门禁工作进程执行完整的未分片聚合流程,耗时长于 master 合并的间隔;在无条件取消下,演练会在得出结论前被后续运行取代,该通道无法产出供响应者查看的就绪证据。 - -这项豁免比「演练总能跑完」要窄,有两点限制。其一,GitHub 每个组只保留一个待运行条目,更新的待运行条目会顶掉更早的,繁忙时段中间的推送运行仍会以 `cancelled` 结束。其二,该表达式是针对**新触发的运行**求值的,因此自身事件不是 `push` 的运行——例如在 `ci-master.yml` 内的 master 上派发的基准测试,与其演练共用 `CI master-` 组——求值为 `true`,会取消正在运行中的演练。这属于罕见的手动操作,且下一次 master 推送即可恢复证据,因此不值得为它再加机制。这项豁免换来的是该通道**周期性**地得出结论,而这正是它能作为证据的前提。 - -这个决定必须放在工作流级:取消作用于被取代的整个运行,作业级 `concurrency` 组并不能豁免其所属作业。采用否定式写法而非仅指名 `pull_request`,是有实质作用的:后者会连 `workflow_dispatch` 一起停止取消,而每次运行器基准测试会在 master 上的同一并发组内同时占用 12 台大规格运行器、最长 15 分钟,届时重复派发会排在演练之前,而不是替换掉已过时的测量。成本之所以可控,是因为 `ci-master.yml` 中一次 master 推送承载[合并后的运行时与 Wine 检查](2026-09-06-master-only-platform-ci.zh.md)和这两条演练;拉取请求作业位于独立的 `ci.yml`(不监听 `push`),而基准测试在 `ci-master.yml` 内受 `workflow_dispatch` 门控。`scripts/ci-workflow.spec.ts` 会锁定这个推送可达集合——按条件精确匹配,因为否定式事件判断会包含它所排除的事件名——使新的推送可达作业无法悄悄开始累积未取消的运行。 +[被取代 CI 的取消策略](2026-09-09-cancel-superseded-ci.zh.md) 管理同一工作流/引用组内的 master 推送和手动运行,包括热备演练。master 快速更新可能让演练因反复被取消而始终无法得出结论。判断就绪状态时,使用最近一次已完成的热备结论,并核对其时间和提交;已取消或仅被调度的运行不构成就绪证据。 ### 发布演练共用 Linux 开关 @@ -44,7 +40,7 @@ Status: implemented ## 切换期间的容量 -Linux 开关启用期间,容量需覆盖 master 热备、主 CI 作业,以及每个符合条件的 PR 或 master 推送的三个发布演练作业。每个可信 PR 还会增加三个门禁并发度为一的 Node 兼容性作业,包括需要构建的 Node 22 条目和冷临时运行时下载。发布工作流不会因为新运行到来而取消正在执行的演练,因此不同引用的重叠运行会增加持续的构建、打包和安装负载。延长自托管运行前,检查当前 CPU、内存、磁盘和队列压力;同一虚拟机上新增注册只增加调度槽位,不增加机器资源。不能只依据热备负载推断空闲容量。主机资源允许增加注册实例时,使用组织级注册 token(组织 Settings → Actions → Runners → New runner)。复制现有 runner 目录时**必须排除身份文件**——`rsync -a --exclude '.runner*' --exclude '.credentials*' --exclude '_diag' --exclude '_work' / /`(通配同时排除 `.runner_migrated`/`.credentials_migrated`——GitHub 会在迁移过的运行器上写入这些文件,它们同样会触发 already-configured 拒绝)——再跑 `config.sh`(原样拷贝 `.runner`/`.credentials` 会使其以 "already configured" 拒绝),然后**启动监听器**:`sudo ./svc.sh install ubuntu && sudo ./svc.sh start`。仅注册不会上线;启动服务增加的是调度槽位,而非 CPU 或内存。 +Linux 开关启用期间,容量需覆盖 master 热备、主 CI 作业,以及每个符合条件的 PR 或 master 推送的三个发布演练作业。每个可信 PR 还会增加三个门禁并发度为一的 Node 兼容性作业,包括需要构建的 Node 22 条目和冷临时运行时下载。发布演练工作流依据[取消策略](2026-09-09-cancel-superseded-ci.zh.md)取消各工作流/引用组内被取代的运行;不同引用仍可能增加并发构建、打包和安装负载。延长自托管运行前,检查当前 CPU、内存、磁盘和队列压力;同一虚拟机上新增注册只增加调度槽位,不增加机器资源。不能只依据热备负载推断空闲容量。主机资源允许增加注册实例时,使用组织级注册 token(组织 Settings → Actions → Runners → New runner)。复制现有 runner 目录时**必须排除身份文件**——`rsync -a --exclude '.runner*' --exclude '.credentials*' --exclude '_diag' --exclude '_work' / /`(通配同时排除 `.runner_migrated`/`.credentials_migrated`——GitHub 会在迁移过的运行器上写入这些文件,它们同样会触发 already-configured 拒绝)——再跑 `config.sh`(原样拷贝 `.runner`/`.credentials` 会使其以 "already configured" 拒绝),然后**启动监听器**:`sudo ./svc.sh install ubuntu && sudo ./svc.sh start`。仅注册不会上线;启动服务增加的是调度槽位,而非 CPU 或内存。 ### 切回 @@ -63,4 +59,4 @@ Linux 开关启用期间,容量需覆盖 master 热备、主 CI 作业,以 ## 后果 -从托管池故障中恢复只需切换受影响平台的变量(任何写者可设)加一次重跑,关键路径上没有合并。代价是每个平台都要维护第二套运行器拓扑:热备通道在每次 master 推送时都运行它们,避免故障切换目标变得陈旧;而 `ci.yml` 中的快照并发与缓存恢复分支带有一条 `selfhosted` 支路(仅 Linux),必须与托管支路保持同步。按平台拆分开关多了一个需要管理的变量,但把每个开关的影响范围限定在单个平台的作业上。 +从托管池故障中恢复只需切换受影响平台的变量(任何写者可设)加一次重跑,关键路径上没有合并。代价是每个平台都要维护第二套运行器拓扑:master 推送会调度热备通道,但依据[取消策略](2026-09-09-cancel-superseded-ci.zh.md),只有已完成的结论才能证明就绪状态;而 `ci.yml` 中的快照并发与缓存恢复分支带有一条 `selfhosted` 支路(仅 Linux),必须与托管支路保持同步。按平台拆分开关多了一个需要管理的变量,但把每个开关的影响范围限定在单个平台的作业上。 diff --git a/.agents/notes/implemented/process/2026-09-06-master-only-platform-ci.i18n.yaml b/.agents/notes/implemented/process/2026-09-06-master-only-platform-ci.i18n.yaml index 1ce78b6e0f..6006f0447a 100644 --- a/.agents/notes/implemented/process/2026-09-06-master-only-platform-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-09-06-master-only-platform-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-09-06-master-only-platform-ci.md -2026-09-06-master-only-platform-ci.md: 28284206c8c6d3fbb5de8ecadbcdf2035a5bb8c0 -2026-09-06-master-only-platform-ci.zh.md: eed843b0d235c80256343890e91b1de84f482174 +2026-09-06-master-only-platform-ci.md: d32864a4eafa81433c6d4fd0e47892b4d17091b1 +2026-09-06-master-only-platform-ci.zh.md: 4fb55d5e95f8fe75e6d1efb8b57295b8e8469e8d diff --git a/.agents/notes/implemented/process/2026-09-06-master-only-platform-ci.md b/.agents/notes/implemented/process/2026-09-06-master-only-platform-ci.md index 28284206c8..d32864a4ea 100644 --- a/.agents/notes/implemented/process/2026-09-06-master-only-platform-ci.md +++ b/.agents/notes/implemented/process/2026-09-06-master-only-platform-ci.md @@ -14,7 +14,7 @@ Python runtime builds on macOS Intel and ARM and Linux ARM64, plus Windows build Wine runs once as an independent hosted Ubuntu master job. Its existing image-keyed apt cache restore/save also supplies default-branch cache production, so it needs no separate cache-seeding job. The native Linux and Windows serial aggregates do not invoke Wine. Keeping Wine hosted avoids shared-host apt transactions and shared Wine-prefix cleanup on the persistent Linux VM. The script owns a scratch snapshot, a checkout-local Wine prefix, and a checksum-verified Windows Node cache; provisioning, failure propagation, and always-run cleanup remain intact. -The parent and reusable runtime workflows preserve running master-push checks against subsequent master pushes. GitHub concurrency still permits replacement of pending runs; manual benchmarks can cancel the parent run. A master push schedules all three selected carriers but does not guarantee every intermediate commit reaches a result. PR, manual, and release cancellation retain their existing behavior. +The [superseded-CI cancellation policy](2026-09-09-cancel-superseded-ci.md) applies to the parent and reusable runtime workflows: newer master pushes or manual runs cancel older validation in the same workflow/ref group, while release-owned builds remain protected. A master push schedules all three selected carriers but does not guarantee every intermediate commit reaches a result. This decision partially supersedes scheduling in the [installed-wheel validation](../testing/2026-08-23-installed-python-wheel-black-box-ci.md), [native Windows CI](2026-08-08-native-windows-pull-request-ci.md), [serial references](2026-07-21-serial-cross-platform-ci-reference.md), and [failover runbook](2026-07-26-ci-failover-runbook.md). Those notes remain active for artifact provenance, platform fidelity, serial completeness, and trust rules. diff --git a/.agents/notes/implemented/process/2026-09-06-master-only-platform-ci.zh.md b/.agents/notes/implemented/process/2026-09-06-master-only-platform-ci.zh.md index eed843b0d2..4fb55d5e95 100644 --- a/.agents/notes/implemented/process/2026-09-06-master-only-platform-ci.zh.md +++ b/.agents/notes/implemented/process/2026-09-06-master-only-platform-ci.zh.md @@ -14,7 +14,7 @@ macOS Intel、ARM 与 Linux ARM64 上的 Python 运行时构建,以及通过 W Wine 作为独立的托管 Ubuntu master 作业运行一次。其现有的按镜像标识的 apt 缓存恢复和保存也负责生成默认分支缓存,因此不需要单独的缓存预热作业。原生 Linux 与 Windows 串行聚合不调用 Wine。Wine 保持托管运行,避免在持久 Linux VM 上执行共享宿主机 apt 事务和共享 Wine prefix 清理。脚本负责临时快照、checkout 内的 Wine prefix 和经过校验和验证的 Windows Node 缓存;环境准备、失败传播及始终执行的清理保持不变。 -父工作流与可复用运行时工作流均保留正在执行的 master 推送检查,不被后续 master 推送取消。GitHub 并发机制仍允许替换待执行的运行;手动基准测试可以取消父工作流。master 推送会调度全部三个选定载体,但不保证每个中间提交都得到结果。PR(Pull Request)、手动和发布运行的取消行为保持不变。 +[被取代 CI 的取消策略](2026-09-09-cancel-superseded-ci.zh.md) 适用于父工作流与可复用运行时工作流:更新的 master 推送或手动运行会取消同一工作流/引用组内的旧验证,而发布所属的构建仍受保护。master 推送会调度全部三个选定载体,但不保证每个中间提交都得到结果。 本决策部分取代[安装后 wheel 包验证](../testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md)、[原生 Windows CI](2026-08-08-native-windows-pull-request-ci.zh.md)、[串行参考](2026-07-21-serial-cross-platform-ci-reference.zh.md)和[故障切换手册](2026-07-26-ci-failover-runbook.zh.md)中的调度策略。这些记录仍保留产物来源、平台保真度、串行完整性与信任规则的决策价值。 diff --git a/.agents/notes/implemented/process/2026-09-09-cancel-superseded-ci.i18n.yaml b/.agents/notes/implemented/process/2026-09-09-cancel-superseded-ci.i18n.yaml new file mode 100644 index 0000000000..5ef13299ed --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-09-cancel-superseded-ci.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-cancel-superseded-ci.md +2026-09-09-cancel-superseded-ci.md: 8de1f757870a2b18852a224ec2f1cc33d9f6a95e +2026-09-09-cancel-superseded-ci.zh.md: 3ae6ec6ad58b0dfc94e803a33ba92ded4e239485 diff --git a/.agents/notes/implemented/process/2026-09-09-cancel-superseded-ci.md b/.agents/notes/implemented/process/2026-09-09-cancel-superseded-ci.md new file mode 100644 index 0000000000..8de1f75787 --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-09-cancel-superseded-ci.md @@ -0,0 +1,39 @@ +# Agent Note: Cancel superseded CI validation + +Status: implemented + +English | [中文](2026-09-09-cancel-superseded-ci.zh.md) + +## Problem + +Validation of an obsolete PR revision or master commit consumes runner capacity without establishing the newest revision’s status. Unconditional aggregate verdicts and coverage-history uploads can also keep cancelled runs doing bookkeeping. Preserving older post-merge runs favors historical completion over current validation, especially on the shared self-hosted pools. + +## Decision + +Validation favors the newest run within each workflow/ref group. [CI](../../../../.github/workflows/ci.yml), [CI master](../../../../.github/workflows/ci-master.yml), [real-API e2e](../../../../.github/workflows/e2e.yml), and the credential-free [dsh](../../../../.github/workflows/release.yml) and [vendor](../../../../.github/workflows/release-vendor.yml) pack validations use `cancel-in-progress: true` with `${{ github.workflow }}-${{ github.ref }}`. Different PR refs and different workflows do not cancel each other. Event type is not part of the group: master pushes and manual benchmarks can supersede each other in CI master, and e2e pushes, scheduled runs, and manual runs can supersede each other on the same ref. + +The [reusable Python runtime builder](../../../../.github/workflows/build-exe-for-python-sdk.yml) uses `${{ !inputs.release }}`. Its `build-single-exe-${{ github.workflow }}-${{ github.ref }}` group remains distinct from its caller’s group, and the caller workflow name isolates ordinary CI from release-owned builds. Release-owned builds are exempt because they belong to an intentional publication transaction. Publication, deployment, and metadata workflows retain their own policies; this decision does not apply cancellation indiscriminately across workflows. + +The PR aggregate uses `${{ !cancelled() && github.event_name == 'pull_request' }}`. The explicit status function preserves evaluation after failed or skipped dependencies rather than accepting GitHub’s default success-only condition. The aggregate still fails on any failure, cancellation, or skip among its dependencies when the workflow itself is not cancelled; cancellation of the whole workflow suppresses its obsolete verdict. Coverage duration history uses `!cancelled()` too: failed coverage can still save useful measurements, but cancelled coverage does not upload them. Wine’s `always()` cleanup remains necessary resource cleanup rather than optional bookkeeping. + +This reverses the cancellation exemption in the [failover runbook](2026-07-26-ci-failover-runbook.md), [master-only platform CI](2026-09-06-master-only-platform-ci.md), and [real-API e2e decision](../testing/2026-06-19-real-api-e2e-ci.md). Those notes retain independent value for pool trust and switching, platform coverage, and secret exposure. The [release rehearsal decision](2026-09-06-release-rehearsal-selfhosted.md) retains runner selection and isolation ownership. None is fully superseded or archived. + +## Alternatives considered + +**Preserve running master-push drills.** The former `${{ github.event_name != 'push' }}` exemption favored periodic readiness evidence: each standby executes its complete unsharded aggregate with one gate worker and can outlast the interval between master merges. Even that policy did not guarantee every drill completed. GitHub retains one pending run per group, replacing intermediate pending pushes; cancellation is evaluated on the newly triggered run, so a manual benchmark sharing the master group could still cancel a drill. That rare manual interruption was accepted on the expectation of evidence from a subsequent push. The exemption’s cost was bounded by the master-only runtime checks, Wine, and two drills; PR jobs remained in a separate workflow, and exact-condition regression checks pinned the push-reachable job set. This policy is rejected in favor of freeing capacity for current validation, explicitly accepting standby starvation. + +**Protect a drill with job-level concurrency, or cancel only PR events.** A job-level group cannot exempt a job from cancellation of its entire workflow. A PR-only cancellation condition also exempts manual dispatch: a repeated runner benchmark can occupy twelve larger runners for up to fifteen minutes rather than replacing an obsolete measurement. Workflow-level cancellation covers both pushes and manual runs. + +**Keep every post-merge, nightly, and pack run.** Historical completion provides more per-commit and per-trigger evidence, but obsolete validation competes with the newest run. These validations do not publish packages, so preserving every run is not the same requirement as protecting an intentional publication transaction. + +**Replace every `always()` condition.** Failure aggregation and resource cleanup have different obligations. A success-only aggregate can hide failed dependencies behind a skipped required check; removing unconditional Wine cleanup can leave resources running. Only cancelled-run bookkeeping is suppressed. + +## Consequences + +Rapid master updates can repeatedly cancel the longer standby drills before they produce a verdict. Operators use the latest completed standby verdict, checking its age and commit before relying on it for failover readiness; a scheduled, running, or cancelled drill is not readiness evidence. The policy does not guarantee that every intermediate commit, nightly trigger, or benchmark completes. Different refs can still compete for shared host capacity. + +Cancellation is a request handled by GitHub Actions and its runners, not a guarantee of immediate termination or bounded queue delay. Cleanup can still take time. The policy makes obsolete validation cancellable; it does not promise a fixed runtime or cancellation latency. + +## Verification + +[Workflow regressions](../../../../scripts/ci-workflow.spec.ts) pin workflow/ref isolation, release-owned exemptions, aggregate status conditions, coverage-history cancellation, and retained Wine cleanup. [Platform routing regressions](../../../../scripts/tests/ci-master-platforms.spec.ts) preserve the master/PR target split and release matrix; [release rehearsal regressions](../../../../scripts/tests/ci-release-selfhosted.spec.ts) preserve cancellation alongside runner eligibility and publication isolation. These configuration checks do not reproduce GitHub scheduling or runner shutdown. Live supersession and completed standby evidence remain CI verification responsibilities. diff --git a/.agents/notes/implemented/process/2026-09-09-cancel-superseded-ci.zh.md b/.agents/notes/implemented/process/2026-09-09-cancel-superseded-ci.zh.md new file mode 100644 index 0000000000..3ae6ec6ad5 --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-09-cancel-superseded-ci.zh.md @@ -0,0 +1,39 @@ +# Agent Note: 取消被取代的 CI 验证 + +Status: implemented + +[English](2026-09-09-cancel-superseded-ci.md) | 中文 + +## 问题 + +验证已被取代的 PR(Pull Request)修订或 master 提交会消耗运行器容量,却不能确定最新修订的状态。无条件执行的聚合判定和覆盖率耗时历史上传,还可能让已取消的运行继续处理记账任务。保留旧的合并后运行,意味着优先完成历史验证而非当前验证,在共享自托管池上尤其如此。 + +## 决策 + +验证优先保留各工作流/引用组内最新的运行。[CI](../../../../.github/workflows/ci.yml)、[CI master](../../../../.github/workflows/ci-master.yml)、[真实 API e2e](../../../../.github/workflows/e2e.yml),以及无凭据的 [dsh](../../../../.github/workflows/release.yml) 和 [vendor](../../../../.github/workflows/release-vendor.yml) 打包验证,均在 `${{ github.workflow }}-${{ github.ref }}` 组中使用 `cancel-in-progress: true`。不同 PR 引用和不同工作流不会相互取消。事件类型不参与分组:CI master 中的 master 推送与手动基准测试可以相互取代,e2e 的推送、定时运行和手动运行也可以在同一引用上相互取代。 + +[可复用 Python 运行时构建器](../../../../.github/workflows/build-exe-for-python-sdk.yml)使用 `${{ !inputs.release }}`。其 `build-single-exe-${{ github.workflow }}-${{ github.ref }}` 组与调用方的组保持区分,调用方工作流名称将普通 CI 与发布所属的构建隔离。发布所属的构建获得豁免,因为它们属于一次有意发起的发布事务。发布、部署和元数据工作流保留各自的策略;本决策不会不加区分地对所有工作流应用取消。 + +PR 聚合使用 `${{ !cancelled() && github.event_name == 'pull_request' }}`。显式状态函数使其在依赖失败或跳过后仍然求值,而非采用 GitHub 默认的仅成功条件。当工作流本身未被取消时,聚合仍会因任意依赖失败、取消或跳过而失败;整个工作流被取消时则抑制其已失去用途的判定。覆盖率耗时历史也使用 `!cancelled()`:覆盖率失败时仍可保存有用的测量数据,但被取消的覆盖率运行不上传。Wine 的 `always()` 清理仍是必要的资源清理,而非可选记账任务。 + +本决策推翻[故障切换手册](2026-07-26-ci-failover-runbook.zh.md)、[仅 master 执行的平台 CI](2026-09-06-master-only-platform-ci.zh.md) 和[真实 API e2e 决策](../testing/2026-06-19-real-api-e2e-ci.zh.md)中的取消豁免。这些记录对运行器池信任与切换、平台覆盖和密钥暴露仍有独立价值。[发布演练决策](2026-09-06-release-rehearsal-selfhosted.zh.md)仍负责运行器选择与隔离。没有记录被完全取代或归档。 + +## 曾考虑的替代方案 + +**保留正在执行的 master 推送演练。** 原有 `${{ github.event_name != 'push' }}` 豁免优先保证周期性就绪证据:每条热备以单门禁工作进程执行完整的未分片聚合流程,耗时可能长于 master 合并间隔。即便该策略也不保证每次演练完成。GitHub 每个组仅保留一个待运行条目,会替换中间的待执行推送;取消条件针对新触发的运行求值,因此共享 master 组的手动基准测试仍可取消演练。当时接受了这种罕见的手动中断,期望后续推送提供证据。豁免成本被限定为仅 master 执行的运行时检查、Wine 和两条演练;PR 作业仍在独立工作流中,按精确条件匹配的回归检查固定推送可达作业集合。为释放容量给当前验证而否决该策略,明确接受热备因反复被取消而无法完成。 + +**用作业级并发保护演练,或仅取消 PR 事件。** 作业级分组不能让作业免于整个工作流的取消。仅针对 PR 的取消条件还会豁免手动触发:重复派发的运行器基准测试可能占用十二台大型运行器长达十五分钟,而非替换陈旧的测量。工作流级取消同时覆盖推送和手动运行。 + +**保留每次合并后、每夜和打包运行。** 完成历史运行能提供更多按提交和触发划分的证据,但已被取代的验证会与最新运行竞争。这些验证不发布包,因此保留每次运行与保护有意发起的发布事务并不是同一项要求。 + +**替换每个 `always()` 条件。** 失败聚合与资源清理承担不同义务。仅成功时执行的聚合可能把依赖失败隐藏为跳过的必需检查;移除无条件 Wine 清理则可能留下仍在运行的资源。只有已取消运行的记账任务被抑制。 + +## 后果 + +master 快速更新可能反复取消耗时更长的热备演练,使其无法产出结论。运维人员使用最近一次已完成的热备结论,并在依赖它判断故障切换就绪状态前核对其时间和提交;已调度、正在执行或已取消的演练都不构成就绪证据。该策略不保证每个中间提交、每夜触发或基准测试都能完成。不同引用仍会竞争共享主机容量。 + +取消是由 GitHub Actions 及其运行器处理的请求,不保证立即终止或限定排队时长。清理仍可能耗时。该策略让已被取代的验证可以被取消,但不承诺固定运行时长或取消延迟。 + +## 验证 + +[工作流回归测试](../../../../scripts/ci-workflow.spec.ts)固定工作流/引用隔离、发布所属构建豁免、聚合状态条件、覆盖率历史取消及保留的 Wine 清理。[平台路由回归测试](../../../../scripts/tests/ci-master-platforms.spec.ts)保留 master/PR 目标划分与发布矩阵;[发布演练回归测试](../../../../scripts/tests/ci-release-selfhosted.spec.ts)在验证取消策略的同时保留运行器准入与发布隔离。这些配置检查不重现 GitHub 调度或运行器停止过程。真实运行取代行为与已完成热备证据仍由 CI 负责验证。 diff --git a/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.i18n.yaml b/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.i18n.yaml index 676b7c29a9..e50f4af442 100644 --- a/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-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/testing/2026-06-19-real-api-e2e-ci.md -2026-06-19-real-api-e2e-ci.md: 4f51032d90b6773d01db7bbfdced6328bd882ad0 -2026-06-19-real-api-e2e-ci.zh.md: 390df23aebec3a6a54bc48eb52022fe02e872164 +2026-06-19-real-api-e2e-ci.md: b218d77f0817416b01459fbb70f5cacffe048ee8 +2026-06-19-real-api-e2e-ci.zh.md: 52c988dad00ddb3ee7f0cd2d23da9771f0900808 diff --git a/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md b/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md index 4f51032d90..b218d77f08 100644 --- a/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md +++ b/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md @@ -54,7 +54,7 @@ The repo secret is named `DEEPSEEK_API_KEY_EXTERNAL`; it is mapped to the `DEEPS ### Scope, runtime shape -The job runs only `test:e2e` on Node 24; keyless gates and version compatibility belong to the main CI workflow. Tests run unbuilt through the workspace paths map with a bounded configurable worker pool, per-test retries, and a job timeout. Superseded PR runs are cancelled, while push and scheduled runs complete for post-merge signal. +The job runs only `test:e2e` on Node 24; keyless gates and version compatibility belong to the main CI workflow. Tests run unbuilt through the workspace paths map with a bounded configurable worker pool, per-test retries, and a job timeout. The [superseded-CI cancellation policy](../process/2026-09-09-cancel-superseded-ci.md) cancels older runs in the same workflow/ref group across PR, push, schedule, and manual triggers; a post-merge or nightly trigger does not guarantee completion. The DeepSeek native `web_search` probe is registered but skipped. The live Anthropic-compatible endpoint can return a successful response without structured source blocks, so its positive-source assertion is not a reliable merge signal; unit coverage still pins response parsing, but CI does not prove the live source-block wire shape. diff --git a/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.zh.md b/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.zh.md index 390df23aeb..52c988dad0 100644 --- a/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.zh.md +++ b/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.zh.md @@ -54,7 +54,7 @@ repo secret 命名为 `DEEPSEEK_API_KEY_EXTERNAL`;映射到适配器和测试 ### 范围与运行时形态 -job 仅在 Node 24 上运行 `test:e2e`;无密钥门禁和版本兼容性属于主 CI 工作流。测试通过 workspace paths 映射以未构建形式运行,使用有界的可配置 worker 池、逐测试重试和 job 超时。被取代的 PR 运行会被取消,而 push 和 schedule 运行完整执行以提供合并后信号。 +job 仅在 Node 24 上运行 `test:e2e`;无密钥门禁和版本兼容性属于主 CI 工作流。测试通过 workspace paths 映射以未构建形式运行,使用有界的可配置 worker 池、逐测试重试和 job 超时。[被取代 CI 的取消策略](../process/2026-09-09-cancel-superseded-ci.zh.md)会在 PR、push、schedule 和手动触发之间取消同一工作流/引用组内的旧运行;合并后或每夜触发并不保证完成。 DeepSeek 原生 `web_search` 探测已注册但会跳过。线上 Anthropic 兼容端点可能返回成功响应却没有结构化来源块,因此对来源存在性的正向断言不是可靠的合并信号;单元测试仍会锁定响应解析行为,但 CI 不会验证线上端点返回的来源块协议格式(wire format)。 diff --git a/.github/workflows/build-exe-for-python-sdk.yml b/.github/workflows/build-exe-for-python-sdk.yml index ae7dd61554..49866ba5ea 100644 --- a/.github/workflows/build-exe-for-python-sdk.yml +++ b/.github/workflows/build-exe-for-python-sdk.yml @@ -46,7 +46,8 @@ concurrency: # github.workflow identifies the caller inside a reusable workflow and keeps # an ordinary CI run from cancelling a full release validation on the same ref. group: build-single-exe-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: ${{ github.event_name != 'push' || github.ref != 'refs/heads/master' }} + # Release-owned builds are part of an intentional publication transaction. + cancel-in-progress: ${{ !inputs.release }} permissions: contents: read diff --git a/.github/workflows/ci-master.yml b/.github/workflows/ci-master.yml index df9f7f2bcc..dd861115d4 100644 --- a/.github/workflows/ci-master.yml +++ b/.github/workflows/ci-master.yml @@ -14,14 +14,11 @@ on: - larger-runner-benchmark - consolidated-runner-benchmark -# Master runs platform runtime checks, Wine, and two self-hosted standby drills. -# The drills outlast the interval between master merges, so -# push is exempt from cancellation (see ci-failover-runbook). workflow_dispatch -# keeps cancelling: a re-dispatched runner benchmark holds up to 12 larger -# runners for 15 minutes in this same group. +# New master pushes and manual runs replace obsolete checks in this workflow/ref. +# Standby drills share cancellation; use completed runs as readiness evidence. concurrency: group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: ${{ github.event_name != 'push' }} + cancel-in-progress: true permissions: contents: read diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4e05b444a4..e7cff1454e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -562,7 +562,7 @@ jobs: # Coverage flakes must not prevent the cache from building; the # measured durations remain useful even when a partition failed. The # per-run key keeps every save a fresh immutable cache entry. - if: always() + if: ${{ !cancelled() }} uses: actions/cache/save@v4 with: path: .coverage-times.json @@ -666,10 +666,8 @@ jobs: # `needs`. Native Windows build and process checks are required; Wine and # the deferred Python runtime targets live in ci-master.yml and do not # participate in this PR verdict. `needs` cannot cross workflow files. - # `if: always()` is load-bearing: without it a failed dependency - # would SKIP this job, and GitHub counts a skipped required check as passing - # — so this job always runs and fails on any non-success result, including - # 'cancelled' and 'skipped'. + # An explicit status function runs the verdict after failed/skipped needs + # without keeping a cancelled workflow alive for an obsolete verdict. all-checks-passed: name: all checks passed # This bookkeeping-only verdict must not depend on custom-pool @@ -684,7 +682,7 @@ jobs: && fromJSON('["self-hosted", "linux", "x64", "vm-backup"]') || 'ubuntu-latest' }} needs: [node-24, node-24-coverage, node-24-bench, node-24-consumers, node-compat, python-sdk, python-runtime, windows-build, windows-native-tests] - if: always() && github.event_name == 'pull_request' + if: ${{ !cancelled() && github.event_name == 'pull_request' }} steps: - name: Fail if any needed job did not succeed if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') || contains(needs.*.result, 'skipped') diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 099908616c..3b7ff878f8 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -36,11 +36,11 @@ on: # 00:17 UTC nightly = 08:17 Asia/Shanghai — off the top-of-hour cron stampede. - cron: '17 0 * * *' -# Cancel a superseded PR run (it is on a stale commit); never cancel a -# push/schedule run — it is already producing the post-merge/nightly signal. +# Keep only the newest validation in each workflow/ref, including master +# pushes, nightly runs, and manual dispatches. concurrency: group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} + cancel-in-progress: true # Least privilege: this job only reads the repo to run tests. permissions: diff --git a/.github/workflows/release-vendor.yml b/.github/workflows/release-vendor.yml index ddf3c3f96c..75fb865767 100644 --- a/.github/workflows/release-vendor.yml +++ b/.github/workflows/release-vendor.yml @@ -20,7 +20,7 @@ permissions: concurrency: # Pack runs per ref so concurrent pull requests never displace each other. group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: false + cancel-in-progress: true env: PRIMARY_NODE_VERSION: '24' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1415d1f9e9..633186c8f1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,7 +19,7 @@ permissions: concurrency: # Pack runs per ref so concurrent pull requests never displace each other. group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: false + cancel-in-progress: true env: PRIMARY_NODE_VERSION: '24' diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index 46177da403..c0aba73898 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -8,6 +8,43 @@ const runnerPrivatePnpmDestination = /^\$\{\{ runner\.temp \}\}\/setup-pnpm-\$\{ const nativeWindowsPnpmDestination = '${{ runner.temp }}/setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}' describe('CI workflow', () => { + it.each(['ci.yml', 'ci-master.yml', 'e2e.yml', 'release.yml', 'release-vendor.yml'])( + '%s cancels superseded validation runs without crossing workflow or ref boundaries', (name) => { + const workflow = loadWorkflow('.github/workflows/' + name) + expect(workflow.concurrency).toEqual({ + group: '${{ github.workflow }}-${{ github.ref }}', + 'cancel-in-progress': true, + }) + }, + ) + + it('cancels reusable CI builds without cancelling release-owned builds', () => { + const workflow = loadWorkflow('.github/workflows/build-exe-for-python-sdk.yml') + expect(workflow.concurrency).toEqual({ + group: 'build-single-exe-${{ github.workflow }}-${{ github.ref }}', + 'cancel-in-progress': '${{ !inputs.release }}', + }) + }) + + it('does not cancel protected publication or deployment transactions', () => { + for (const name of ['release-publish.yml', 'release-vendor-publish.yml']) { + const publish = workflowJob(loadWorkflow('.github/workflows/' + name), 'publish') + expect(publish.concurrency).toMatchObject({ 'cancel-in-progress': false }) + } + for (const name of ['python-release.yml', 'node-addon-system-release.yml', 'docs-pages.yml']) { + expect(loadWorkflow('.github/workflows/' + name).concurrency).toMatchObject({ 'cancel-in-progress': false }) + } + }) + + it('skips coverage-history uploads on cancellation but retains Wine cleanup', () => { + const coverage = workflowJob(loadWorkflow('.github/workflows/ci.yml'), 'windows-coverage') + const wine = workflowJob(loadWorkflow('.github/workflows/ci-master.yml'), 'windows') + expect(coverage.steps).toContainEqual(expect.objectContaining({ + name: 'Save coverage duration history', if: '${{ !cancelled() }}', + })) + expect(wine.steps).toContainEqual(expect.objectContaining({ name: 'Shut down wineserver', if: 'always()' })) + }) + it('isolates every pnpm action setup destination per runner', () => { const files = ['.github/workflows/ci.yml', '.github/workflows/ci-master.yml'] const setups: Array<{ jobName: string; step: unknown }> = [] @@ -346,7 +383,7 @@ describe('CI workflow', () => { ) }) - it('exempts push from cancellation in ci-master, so one master merge does not cancel the running drill', () => { + it('cancels superseded master runs without changing the post-merge job inventory', () => { const workflow = loadWorkflow('.github/workflows/ci-master.yml') const prWorkflow = loadWorkflow('.github/workflows/ci.yml') if (!isRecord(workflow.jobs) || !isRecord(workflow.concurrency)) { @@ -356,21 +393,11 @@ describe('CI workflow', () => { throw new TypeError('ci workflow must define jobs') } - // Cancellation applies to the whole superseded RUN, so this has to be - // decided at workflow level and gated on the event: a job-level group - // cannot exempt its job from its run being cancelled. Only push is exempt — - // a drill takes longer than the interval between master merges. The negated - // form is load-bearing: `== 'pull_request'` would also stop cancelling - // workflow_dispatch, and a re-dispatched runner benchmark holds up to 12 - // larger runners for 15 minutes in this same group on master. - expect(workflow.concurrency['cancel-in-progress']).toBe("${{ github.event_name != 'push' }}") - - // The PR-only ci.yml still cancels a superseded run on a new push, so a - // fresh head does not stack a second full 9-job run behind a stale one. - // Unlike ci-master it has no push carve-out: every PR event supersedes. - expect(prWorkflow.concurrency).toMatchObject({ + expect(workflow.concurrency).toEqual({ + group: '${{ github.workflow }}-${{ github.ref }}', 'cancel-in-progress': true, }) + expect(prWorkflow.concurrency).toEqual(workflow.concurrency) // The exact event sets are what keep master-only jobs out of the PR check // panel: ci-master triggers only on push(master) + workflow_dispatch and @@ -382,13 +409,12 @@ describe('CI workflow', () => { expect(Object.keys(workflow.on).sort()).toEqual(['push', 'workflow_dispatch']) expect(Object.keys(prWorkflow.on)).toEqual(['pull_request']) - // Neither drill may carry a job-level group: it would not exempt the job - // from run-scoped cancellation. + // Drills share the parent run’s supersession policy. for (const name of ['serial-linux-selfhosted', 'serial-windows']) { const job = workflow.jobs[name] if (!isRecord(job)) throw new TypeError(`${name} must be defined`) expect(job.concurrency).toBeUndefined() - // Both stay master-push-only; that is what makes the push carve-out safe. + // Standby drills remain post-merge work, but share run cancellation. expect(job.if).toBe("github.event_name == 'push' && github.ref == 'refs/heads/master'") } @@ -409,10 +435,7 @@ describe('CI workflow', () => { .sort() expect(pushReachable).toEqual(['python-runtime', 'serial-linux-selfhosted', 'serial-windows', 'windows']) - // Why workflow_dispatch must keep cancelling: each benchmark fans out to a - // dozen larger runners at once, in this same group on master. If it stopped - // cancelling, a re-dispatch would queue ahead of a drill instead of - // replacing the stale measurement. + // Manual benchmarks retain their bounded fan-out. for (const name of ['larger-runner-benchmark', 'consolidated-runner-benchmark']) { const job = workflow.jobs[name] if (!isRecord(job) || !isRecord(job.strategy)) { diff --git a/scripts/tests/ci-master-platforms.spec.ts b/scripts/tests/ci-master-platforms.spec.ts index b07f71cd62..3cdbb00f9b 100644 --- a/scripts/tests/ci-master-platforms.spec.ts +++ b/scripts/tests/ci-master-platforms.spec.ts @@ -1,6 +1,7 @@ /** Scheduling policy for post-merge native runtime carriers and Wine. */ import { readFileSync } from 'node:fs' import { resolve } from 'node:path' +import { runInNewContext } from 'node:vm' import { load } from 'js-yaml' import { describe, expect, it } from 'vitest' import { gatesForMode } from '../run-gates.ts' @@ -34,7 +35,43 @@ function commands(job: Job): string[] { return (job.steps ?? []).flatMap(step => step.run ? [step.run] : []) } +// These boolean/string cases share Actions and JavaScript semantics. GitHub +// supplies status functions; this probe is not a general Actions interpreter. +function evaluateCondition(expression: string, cancelled: boolean, results: string[], event = 'pull_request'): boolean { + const source = expression.trim().replace(/^[$][{][{]|[}][}]$/g, '') + .replaceAll('needs.*.result', 'results') + return runInNewContext(source, { + cancelled: () => cancelled, + always: () => true, + contains: (values: string[], value: string) => values.includes(value), + results, + github: { event_name: event }, + }, { timeout: 1000 }) as boolean +} + describe('master-only platform scheduling', () => { + it.each(['success', 'failure', 'skipped', 'cancelled'])( + 'reports %s dependencies in active runs but never starts a cancelled-run verdict', (result) => { + const aggregate = workflow('ci.yml').jobs['all-checks-passed']! + const results = aggregate.needs!.map(() => 'success') + results[0] = result + const condition = aggregate.if as string + // A status function prevents Actions from implicitly gating on success(). + expect(condition).toContain('!cancelled()') + expect(evaluateCondition(condition, false, results)).toBe(true) + expect(evaluateCondition(condition, true, results)).toBe(false) + expect(evaluateCondition(condition, false, results, 'push')).toBe(false) + const failureStep = aggregate.steps!.find(step => step.name === 'Fail if any needed job did not succeed')! + expect(evaluateCondition(failureStep.if!, false, results)).toBe(result !== 'success') + expect(failureStep.run).toContain('exit 1') + }, + ) + + it('distinguishes the obsolete always verdict from the cancellable status guard', () => { + expect(evaluateCondition("always() && github.event_name == 'pull_request'", true, ['success'])).toBe(true) + expect(evaluateCondition(workflow('ci.yml').jobs['all-checks-passed']!.if as string, true, ['success'])).toBe(false) + }) + it('keeps only Linux and Windows x64 runtimes in required PR CI', () => { const pr = workflow('ci.yml') expect(Object.keys(pr.on)).toEqual(['pull_request']) @@ -49,7 +86,7 @@ describe('master-only platform scheduling', () => { expect(aggregate.needs).toContain('python-runtime') expect(aggregate.needs).not.toContain('windows') expect(aggregate.needs!.every(id => id in pr.jobs)).toBe(true) - expect(aggregate.if).toBe("always() && github.event_name == 'pull_request'") + expect(aggregate.if).toBe("${{ !cancelled() && github.event_name == 'pull_request' }}") expect(aggregate.steps).toContainEqual(expect.objectContaining({ if: "contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') || contains(needs.*.result, 'skipped')", })) @@ -70,7 +107,7 @@ describe('master-only platform scheduling', () => { expect(runtime['continue-on-error']).toBeUndefined() const builder = workflow('build-exe-for-python-sdk.yml') expect(builder.concurrency?.['cancel-in-progress']).toBe( - "${{ github.event_name != 'push' || github.ref != 'refs/heads/master' }}", + '${{ !inputs.release }}', ) const build = builder.jobs.build! const preflight = build.steps!.find(step => step.name === 'Preflight installed-wheel real API test (POSIX)')! diff --git a/scripts/tests/ci-release-selfhosted.spec.ts b/scripts/tests/ci-release-selfhosted.spec.ts index 4a357114ba..ade0291f5f 100644 --- a/scripts/tests/ci-release-selfhosted.spec.ts +++ b/scripts/tests/ci-release-selfhosted.spec.ts @@ -88,7 +88,7 @@ for (const [file, jobIds] of [['release.yml', ['dependencies', 'pack']], ['relea expect(Object.keys(release.jobs)).toEqual(jobIds) expect(release.on).toEqual({ pull_request: null, push: { branches: ['master'] }, workflow_dispatch: null }) expect(release.permissions).toEqual({ contents: 'read' }) - expect(release.concurrency).toEqual({ group: '${{ github.workflow }}-${{ github.ref }}', 'cancel-in-progress': false }) + expect(release.concurrency).toEqual({ group: '${{ github.workflow }}-${{ github.ref }}', 'cancel-in-progress': true }) }) for (const jobId of jobIds) { describe(jobId, () => { From 0a9c77461777598f8adf67d242044b91aaeb73ca Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 9 Sep 2026 17:25:43 +0800 Subject: [PATCH 049/111] docs(webworker): document bigint handle identity --- packages/experimental/webworker-runtime/README.i18n.yaml | 4 ++-- packages/experimental/webworker-runtime/README.md | 2 +- packages/experimental/webworker-runtime/README.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/experimental/webworker-runtime/README.i18n.yaml b/packages/experimental/webworker-runtime/README.i18n.yaml index 1a3a5a0997..c06d04c923 100644 --- a/packages/experimental/webworker-runtime/README.i18n.yaml +++ b/packages/experimental/webworker-runtime/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/experimental/webworker-runtime/README.md -README.md: 2327851be62bc4e09e8678cd8d0c7b07663d1bdd -README.zh.md: afd1bef17c8e921b4a6e97bd224deaa0f94e9486 +README.md: d50a7c43db99ff7417cdb39aeb6caae2070125a1 +README.zh.md: 29b92ee280a3d7780218741620655b98d651498c diff --git a/packages/experimental/webworker-runtime/README.md b/packages/experimental/webworker-runtime/README.md index 2327851be6..d50a7c43db 100644 --- a/packages/experimental/webworker-runtime/README.md +++ b/packages/experimental/webworker-runtime/README.md @@ -26,7 +26,7 @@ The browser worker host: the whole harness plugin tree runs inside one dedicated Three artifacts from one tsdown pipeline: - **`lib/index.js` (assembly library)** — `createWorkerHost`/`startWorkerHost` mount the base image and any ordered data overlays (`storage/`), install the module loader (`module-system/`) and the `process` shim, boot the tree through the image's own `dsh-app-boot`, and hand the tunnel its serving seams. Overlays may replace files only under `home/` and `workspace/`; they cannot replace the base manifest, configuration, or modules. The image layout contract (`image-layout.ts`: virtual root, config/manifest paths, empty directories, the `lowered` wrapper-contract gate) is shared with the packer. Boot patches force the deployment-shaped rows: frontend serving off, JSONL session logs on the plaintext path, preset roots onto the image's `config/agent-presets`. -- **`lib/worker.js` (worker bundle)** — the assembly plus this package's Node-compatibility layer as one self-contained ES module. The module proxy table (`module-proxies.ts`) is the only platform fork: `node:*` builtins over VFS/tunnel/browser primitives, structural stubs that fail loud on the console for what a browser cannot do, and native/binary package replacements. `node:module` supplies `createRequire().resolve` and `.resolve.paths()` over the image package root, so unchanged packages can discover manifests without evaluating their modules. The global `process` shim carries Node detection fields including `title`, preventing Worker execution from entering DOM-only branches. The pack-time parser reports statically named module requests, including module-scope direct calls of the form `createRequire(import.meta.url)('pkg')` through a named `node:module` or `module` import, to the packer's reachability walk. Stored, CommonJS-obtained, and rebased `createRequire` calls require image entry seeds. VFS mutations drive `node:fs` callback, polling, and promise watchers; open descriptors retain file identity and access mode across rename, replacement, and unlink; `readable-stream` supplies the stream state machine used by file streams and unchanged image packages such as Chokidar and readdirp. AsyncLocalStorage carries sync-stack causality across `await` through the snapshot/restore faces the pack-time lowering injects. The worker holds no compiler: an image the packer did not lower is refused at mount. +- **`lib/worker.js` (worker bundle)** — the assembly plus this package's Node-compatibility layer as one self-contained ES module. The module proxy table (`module-proxies.ts`) is the only platform fork: `node:*` builtins over VFS/tunnel/browser primitives, structural stubs that fail loud on the console for what a browser cannot do, and native/binary package replacements. `node:module` supplies `createRequire().resolve` and `.resolve.paths()` over the image package root, so unchanged packages can discover manifests without evaluating their modules. The global `process` shim carries Node detection fields including `title`, preventing Worker execution from entering DOM-only branches. The pack-time parser reports statically named module requests, including module-scope direct calls of the form `createRequire(import.meta.url)('pkg')` through a named `node:module` or `module` import, to the packer's reachability walk. Stored, CommonJS-obtained, and rebased `createRequire` calls require image entry seeds. VFS mutations drive `node:fs` callback, polling, and promise watchers; open descriptors retain file identity and access mode across rename, replacement, and unlink, and `FileHandle.stat({ bigint: true })` reports the same device and inode identity as a path stat while the name still refers to that file; `readable-stream` supplies the stream state machine used by file streams and unchanged image packages such as Chokidar and readdirp. AsyncLocalStorage carries sync-stack causality across `await` through the snapshot/restore faces the pack-time lowering injects. The worker holds no compiler: an image the packer did not lower is refused at mount. - **`src/shell/` (the worker's own process layer)** — a browser worker cannot fork, so `node:child_process` is not a stub but an implementation: `spawn` starts the command in its own Web Worker — this same bundle, told by its first frame to be a shell process — and reports it through the `ChildProcess` surface the subprocess service consumes. The command runs off the host's thread, `SIGKILL` terminates it whatever it is doing, and it reaches the VFS only by message (the host serves those frames). Worker platform executables preserve native-package protocols such as Landlock without replacing their JavaScript packages or coupling their implementations to `node:child_process`; ordinary commands use the package's evaluator and coreutils command table. The grammar is `@yarnpkg/parsers`' `parseShell`, while `execSync`/`fork` still refuse because they need a real process. - **`lib/client.js` (page half)** — startup has two independent stages. `chooseWorkerHostSource({ image?, fixtureManifest? })` optionally owns the boot barrier and fixture manifest: without `preview-fixture` it waits at the source chooser, while a valid query selects directly; either path returns ordered overlays. `connectWorkerHost(worker, { image?, overlays? })` remains the public base-runtime connector; callers that skip the chooser get an empty overlay list. `apps/web` invokes both and supplies its statically bundled Worker. The opening `init` frame carries the base and ordered overlay URLs, the boot payload delivers the structured index-injection table, and `applyIndexInjections` executes it before the shell entry runs. Script preload rows are advisory and skipped because `/plugins` resources resolve only through the tunnel; `loadBundle` fetches each combo on first demand, embeds its tunnel-only source map as a Base64 data URL, and executes the script as a Blob. The tunnel also exposes fetch-shaped transport, the independent file-upload carrier, and the API client. Request frames preserve Blob bodies through structured clone and transfer `ReadableStream` ownership. The Host Worker streams both forms into the route, so neither browser thread creates a complete byte array for a generic-file upload. diff --git a/packages/experimental/webworker-runtime/README.zh.md b/packages/experimental/webworker-runtime/README.zh.md index afd1bef17c..29b92ee280 100644 --- a/packages/experimental/webworker-runtime/README.zh.md +++ b/packages/experimental/webworker-runtime/README.zh.md @@ -26,7 +26,7 @@ kind: "package-library" 一条 tsdown 管线出三个产物: - **`lib/index.js`(装配库)**——`createWorkerHost`/`startWorkerHost` 挂载基础镜像和按序排列的数据 overlays(`storage/`)、安装模块加载器(`module-system/`)与 `process` shim、经镜像自带的 `dsh-app-boot` 启动插件树,并把服务缝隙交给隧道。Overlay 只能替换 `home/` 与 `workspace/` 下的文件,不能替换基础 manifest、配置或模块。镜像布局契约(`image-layout.ts`:虚拟根、config/manifest 路径、空目录、`lowered` 包装契约门)与 packer 共享。boot patch 强制部署形态行:关前端静态服务、JSONL 会话日志走明文、preset 根指向镜像内 `config/agent-presets`。 -- **`lib/worker.js`(worker 束)**——装配库加本包的 Node 兼容层,合成一个自含 ES module。模块代理表(`module-proxies.ts`)是唯一平台叉口:`node:*` 内建走 VFS、隧道和浏览器原语,浏览器做不到的走结构化 stub(调用即在 console 报错并抛出),native/binary 包则替换执行后端。`node:module` 在镜像 package 根之上提供 `createRequire().resolve` 与 `.resolve.paths()`,使未修改的包无需执行目标模块即可发现 manifest。全局 `process` shim 带有包括 `title` 在内的 Node 环境识别字段,避免 Worker 执行误入仅适用于 DOM 的分支。pack 期解析器会把名称静态可知的模块请求报告给 packer 的可达性遍历,其中包括通过 `node:module` 或 `module` 具名导入在模块作用域直接发起的 `createRequire(import.meta.url)('pkg')` 调用。保存、经 CommonJS 获取或另设基准的 `createRequire` 调用需要镜像入口种子。VFS mutation 驱动 `node:fs` 的 callback、polling 和 promise watcher;打开的 descriptor 在 rename、replacement 和 unlink 后仍保留文件身份与访问模式;`readable-stream` 提供文件流以及 Chokidar、readdirp 等未修改镜像包所用的流状态机。AsyncLocalStorage 经 pack 时降低注入的 snapshot/restore 面在 `await` 间携带同步栈因果。worker 不带编译器:packer 未降低的镜像在挂载时被拒。 +- **`lib/worker.js`(worker 束)**——装配库加本包的 Node 兼容层,合成一个自含 ES module。模块代理表(`module-proxies.ts`)是唯一平台叉口:`node:*` 内建走 VFS、隧道和浏览器原语,浏览器做不到的走结构化 stub(调用即在 console 报错并抛出),native/binary 包则替换执行后端。`node:module` 在镜像 package 根之上提供 `createRequire().resolve` 与 `.resolve.paths()`,使未修改的包无需执行目标模块即可发现 manifest。全局 `process` shim 带有包括 `title` 在内的 Node 环境识别字段,避免 Worker 执行误入仅适用于 DOM 的分支。pack 期解析器会把名称静态可知的模块请求报告给 packer 的可达性遍历,其中包括通过 `node:module` 或 `module` 具名导入在模块作用域直接发起的 `createRequire(import.meta.url)('pkg')` 调用。保存、经 CommonJS 获取或另设基准的 `createRequire` 调用需要镜像入口种子。VFS mutation 驱动 `node:fs` 的 callback、polling 和 promise watcher;打开的 descriptor 在 rename、replacement 和 unlink 后仍保留文件身份与访问模式,只要文件名仍指向该文件,`FileHandle.stat({ bigint: true })` 报告的 device 与 inode 身份就与路径 stat 相同;`readable-stream` 提供文件流以及 Chokidar、readdirp 等未修改镜像包所用的流状态机。AsyncLocalStorage 经 pack 时降低注入的 snapshot/restore 面在 `await` 间携带同步栈因果。worker 不带编译器:packer 未降低的镜像在挂载时被拒。 - **`src/shell/`(worker 自己的进程层)**——浏览器 worker 无法 fork,所以 `node:child_process` 不是 stub 而是实现:`spawn` 把命令放进它自己的 Web Worker——就是这同一个束,由首帧告诉它「你是 shell 进程」——并以 subprocess 服务消费的 `ChildProcess` 面报告结果。命令不占宿主线程,`SIGKILL` 不管它在干什么都能终止它,而它只能靠消息触达 VFS(由宿主应答这些帧)。Worker 平台 executable 在不替换 JavaScript 包、也不把具体实现耦合进 `node:child_process` 的情况下保持 Landlock 等 native 包协议;普通命令使用本包的求值器与 coreutils 命令表。语法来自 `@yarnpkg/parsers` 的 `parseShell`,而 `execSync`/`fork` 依然拒绝,因为它们需要真进程。 - **`lib/client.js`(页面半)**——启动分为相互独立的两段。`chooseWorkerHostSource({ image?, fixtureManifest? })` 可选地拥有 boot barrier 与 fixture manifest:没有 `preview-fixture` 时停在来源选择面板,合法 query 则直接选择;两条路径都返回按序排列的 overlays。`connectWorkerHost(worker, { image?, overlays? })` 仍是公开的基础运行态连接器;调用方跳过选择器时 overlay 列表为空。`apps/web` 调用这两段并提供静态打包的 Worker。开局 `init` 帧携带基础镜像与按序排列的 overlay URL,boot 载荷送达结构化 index 注入表,`applyIndexInjections` 在壳入口运行前逐行执行。脚本 preload 行只是提示,因此会被跳过:`/plugins` 资源只能经 tunnel 解析,`loadBundle` 会在首次需要时获取 combo、把仅 tunnel 可达的 sourcemap 内嵌为 Base64 data URL,再以 Blob 执行脚本。Tunnel 还暴露 fetch 形式的传输、独立文件上传载体和 API 客户端。请求帧通过结构化克隆保留 Blob 请求体,并转移 `ReadableStream` 的所有权。Host Worker 将两种请求体都逐块送入路由,因此通用文件上传不会在任何浏览器线程创建完整字节数组。 From a7bd71e0245d64a4603e6d71fd08a73e0db23ab2 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 9 Sep 2026 17:26:00 +0800 Subject: [PATCH 050/111] fix(webworker): preserve bigint file handle identity --- apps/web/tests/preview-boot.e2e.ts | 15 +++++++++++++-- .../node/builtin_modules/implemented/fs.ts | 8 ++++++-- .../webworker-runtime/src/storage/memory.ts | 19 +++++++++++++++++-- .../webworker-runtime/src/storage/types.ts | 7 ++++++- .../webworker-runtime/tests/node/fs.spec.ts | 7 +++++++ .../tests/storage/memory-vfs.spec.ts | 11 +++++++++++ 6 files changed, 60 insertions(+), 7 deletions(-) diff --git a/apps/web/tests/preview-boot.e2e.ts b/apps/web/tests/preview-boot.e2e.ts index 7422f902e1..ca04c720c2 100644 --- a/apps/web/tests/preview-boot.e2e.ts +++ b/apps/web/tests/preview-boot.e2e.ts @@ -34,7 +34,10 @@ import { IMAGE_FILE_NAME, PREVIEW_FIXTURE_MANIFEST_FILE, PREVIEW_FIXTURE_MANIFEST_VERSION, type PreviewFixtureManifest, } from '@deepseek-ai/dsh-experimental-webworker-runtime' -import { buildVfsExampleFiles } from '../../../packages/experimental/webworker-runtime/tests/vfs-example-fixture.ts' +import { + VFS_EXAMPLE_SESSION_IDS, + buildVfsExampleFiles, +} from '../../../packages/experimental/webworker-runtime/tests/vfs-example-fixture.ts' import { captureStableAria, compareOrRefreshGolden, webSnapshotMode } from './scaffold.ts' import { newEnglishPage, REPO_ROOT, saveFailureShot } from './support.ts' @@ -317,7 +320,7 @@ async function bootPreview(origin: string, browser: Browser): Promise { await page.locator('[data-composer-input][data-placeholder="Describe what you want to build, / commands, @ files or sessions"]') .waitFor({ timeout: 30_000 }) - const exercised = await page.evaluate(async () => { + const exercised = await page.evaluate(async ({ seededSessionId, seededSessionTitle }) => { type Result = { result: { ok: true; value: T } | { ok: false; error: { code: string; message: string } } } interface PreviewTransport { fetch(input: string, init: RequestInit): Promise @@ -352,6 +355,9 @@ async function bootPreview(origin: string, browser: Browser): Promise { if (!body.result.ok) throw new Error(`${endpoint} failed: ${body.result.error.message}`) return body.result.value } + const renamed = await remote<{ title: string }>('session/rename', { + request: { sessionId: seededSessionId, title: seededSessionTitle }, + }) const skills = await remote<{ skills: Array<{ name: string }> }>( 'skills/list', { request: { sessionId } }, ) @@ -384,10 +390,15 @@ async function bootPreview(origin: string, browser: Browser): Promise { await remote('credentials/unset', { ref: 'PREVIEW_TEST_SECRET' }) await new Promise((resolve) => { setTimeout(resolve, 250) }) return { + renamedTitle: renamed.title, skillCount: skills.skills.length, credentialConfigured: credentials.PREVIEW_TEST_SECRET?.configured, } + }, { + seededSessionId: VFS_EXAMPLE_SESSION_IDS.main, + seededSessionTitle: SHOWCASE_TITLE, }) + expect(exercised.renamedTitle).toBe(SHOWCASE_TITLE) expect(exercised.skillCount).toBeGreaterThan(0) expect(exercised.credentialConfigured).toBe(true) diff --git a/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs.ts b/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs.ts index b2f4462a50..66676be09e 100644 --- a/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs.ts +++ b/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs.ts @@ -399,7 +399,7 @@ export interface FileHandle { writeFile(data: string | Uint8Array, encoding?: BufferEncoding): Promise write(data: string | Uint8Array): Promise<{ bytesWritten: number }> read(buffer: Uint8Array, offset?: number, length?: number, position?: number | null): Promise<{ bytesRead: number; buffer: Uint8Array }> - stat(): Promise + stat(options?: VfsStatOptions): Promise truncate(length?: number): Promise sync(): Promise datasync(): Promise @@ -441,7 +441,11 @@ export function openHandleSync(path: PathArg, flags = 'r', mode?: number): FileH bytesRead: readSync(fd, buffer, offset, length, position), buffer, }), - stat: async () => directory ? statSync(target) as VfsStats : descriptor('fstat').file.stat(), + stat: async (options?: VfsStatOptions) => directory + ? statSync(target, options) + : options?.bigint === true + ? descriptor('fstat').file.statBigInt() + : descriptor('fstat').file.stat(), truncate: async (length = 0) => { if (directory) writeFileSync(target, new Uint8Array(length)) else descriptor('ftruncate').file.truncate(length) diff --git a/packages/experimental/webworker-runtime/src/storage/memory.ts b/packages/experimental/webworker-runtime/src/storage/memory.ts index 24d12a688e..3a14c62b49 100644 --- a/packages/experimental/webworker-runtime/src/storage/memory.ts +++ b/packages/experimental/webworker-runtime/src/storage/memory.ts @@ -431,6 +431,18 @@ export class MemoryVfs implements Vfs { return statsOf(node.bytes.length, node.mtimeMs, false, this.identityOfFile(node), node.mode) } + /** @returns BigInt stats for an open file, including its device and inode identity. */ + private fileBigIntStats(node: FileNode): VfsBigIntStats { + return bigIntStatsOf( + node.bytes.length, + node.mtimeMs, + false, + this.identityOfFile(node), + node.mode, + this.fileLinkCount(node), + ) + } + /** Forget removed directory identities, so recreated paths report new ones. */ private forgetIdentity(target: string): void { this.identities.delete(target) @@ -647,7 +659,9 @@ export class MemoryVfs implements Vfs { truncate: async (length = 0): Promise => { current('ftruncate').truncate(length) }, - stat: async (): Promise => current('fstat').stat(), + stat: async (options?: VfsStatOptions): Promise => options?.bigint === true + ? current('fstat').statBigInt() + : current('fstat').stat(), sync: async (): Promise => { current('fsync'); await this.flush() }, datasync: async (): Promise => { current('fdatasync'); await this.flush() }, close: async (): Promise => { closed = true }, @@ -692,6 +706,7 @@ export class MemoryVfs implements Vfs { this.truncateFile(node, length) }, stat: () => this.fileStats(node), + statBigInt: () => this.fileBigIntStats(node), } } @@ -704,7 +719,7 @@ export class MemoryVfs implements Vfs { */ private handleTail(target: string): Pick { return { - stat: async (): Promise => this.plainStats(target), + stat: async (options?: VfsStatOptions): Promise => this.statSync(target, options), sync: async (): Promise => { await this.flush() }, datasync: async (): Promise => { await this.flush() }, close: async (): Promise => {}, diff --git a/packages/experimental/webworker-runtime/src/storage/types.ts b/packages/experimental/webworker-runtime/src/storage/types.ts index f1482e07d6..616d24cf27 100644 --- a/packages/experimental/webworker-runtime/src/storage/types.ts +++ b/packages/experimental/webworker-runtime/src/storage/types.ts @@ -119,7 +119,7 @@ export interface VfsFileHandle { writeFile(data: string | Uint8Array): Promise readFile(options?: VfsReadOptions): Promise truncate(length?: number): Promise - stat(): Promise + stat(options?: VfsStatOptions): Promise sync(): Promise datasync(): Promise close(): Promise @@ -157,6 +157,11 @@ export interface VfsOpenFile { * @returns Current file metadata, including after rename or unlink. */ stat(): VfsStats + /** + * Read BigInt metadata from the opened file identity. + * @returns Current file metadata, including device and inode identity. + */ + statBigInt(): VfsBigIntStats } /** diff --git a/packages/experimental/webworker-runtime/tests/node/fs.spec.ts b/packages/experimental/webworker-runtime/tests/node/fs.spec.ts index ce9e6fd916..cb30f33dc2 100644 --- a/packages/experimental/webworker-runtime/tests/node/fs.spec.ts +++ b/packages/experimental/webworker-runtime/tests/node/fs.spec.ts @@ -115,6 +115,13 @@ check('rmSync removes', fs.existsSync('/dsh/renamed.txt'), false) fs.writeFileSync('/dsh/log-handle.jsonl', 'header\n') const appendHandle = await fsp.open('/dsh/log-handle.jsonl', 'a') check('append handle sees the existing size', (await appendHandle.stat()).size, 7) +const appendHandleStats = await appendHandle.stat({ bigint: true }) as VfsBigIntStats +const appendPathStats = await fsp.stat('/dsh/log-handle.jsonl', { bigint: true }) as VfsBigIntStats +check('bigint handle stat matches the path identity', [ + typeof appendHandleStats.ino, + appendHandleStats.ino === appendPathStats.ino, + appendHandleStats.dev === appendPathStats.dev, +], ['bigint', true, true]) await appendHandle.writeFile('batch-1\n') await appendHandle.sync() check('handle.sync flushes the active VFS', flushes, 1) diff --git a/packages/experimental/webworker-runtime/tests/storage/memory-vfs.spec.ts b/packages/experimental/webworker-runtime/tests/storage/memory-vfs.spec.ts index b80987cd4c..521342f19d 100644 --- a/packages/experimental/webworker-runtime/tests/storage/memory-vfs.spec.ts +++ b/packages/experimental/webworker-runtime/tests/storage/memory-vfs.spec.ts @@ -203,6 +203,17 @@ describe('mutation publication', () => { expect(new TextDecoder().decode(descriptor.read(0, descriptor.stat().size))).toBe('detached') }) + it('reports the path identity through a BigInt file handle stat', async () => { + const vfs = new MemoryVfs() + vfs.seed('/dsh/session.lock', '') + const handle = vfs.open('/dsh/session.lock', 'r') + const held = await handle.stat({ bigint: true }) as VfsBigIntStats + const current = vfs.statSync('/dsh/session.lock', { bigint: true }) as VfsBigIntStats + + expect([held.dev, held.ino]).toEqual([current.dev, current.ino]) + await handle.close() + }) + it('decomposes a directory rename into replayable destination state', () => { const recorded: VfsMutation[] = [] const vfs = new MemoryVfs({ From 950474605f9995e675a71ba64618021d2d0108cb Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 9 Sep 2026 17:52:14 +0800 Subject: [PATCH 051/111] docs(webworker): record file handle compatibility --- .../2026-08-31-cross-process-session-write-lease.i18n.yaml | 4 ++-- .../feature/2026-08-31-cross-process-session-write-lease.md | 2 +- .../2026-08-31-cross-process-session-write-lease.zh.md | 2 +- packages/experimental/webworker-runtime/README.i18n.yaml | 4 ++-- packages/experimental/webworker-runtime/README.md | 2 +- packages/experimental/webworker-runtime/README.zh.md | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-31-cross-process-session-write-lease.i18n.yaml b/.agents/notes/implemented/feature/2026-08-31-cross-process-session-write-lease.i18n.yaml index 71a7ed9371..0451d6edcb 100644 --- a/.agents/notes/implemented/feature/2026-08-31-cross-process-session-write-lease.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-31-cross-process-session-write-lease.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-08-31-cross-process-session-write-lease.md -2026-08-31-cross-process-session-write-lease.md: ef8ebe2de6b231075dee31a9184bcc0a5b323011 -2026-08-31-cross-process-session-write-lease.zh.md: 8a79b6d5327c7bd5d0ca12ac435140bc0951e993 +2026-08-31-cross-process-session-write-lease.md: bf02ce3eddfb5bcfe2a7d6bd6a6701c47d5f28a4 +2026-08-31-cross-process-session-write-lease.zh.md: b287ab7d6cb4202745fd1112bcb313cb261d674e diff --git a/.agents/notes/implemented/feature/2026-08-31-cross-process-session-write-lease.md b/.agents/notes/implemented/feature/2026-08-31-cross-process-session-write-lease.md index ef8ebe2de6..bf02ce3edd 100644 --- a/.agents/notes/implemented/feature/2026-08-31-cross-process-session-write-lease.md +++ b/.agents/notes/implemented/feature/2026-08-31-cross-process-session-write-lease.md @@ -10,7 +10,7 @@ The JSONL backend's write-handle claim excluded a second writer only inside one ## Decision -`SessionWriteLease` (packages/session/session-persistence-jsonl/src/lease.ts) holds a kernel lock on `session.lock` beside the log for the whole life of a write handle: POSIX takes a non-blocking `flock(2)` through the prebuilt `@deepseek-ai/node-addon-system/flock` binding, and Windows holds a named kernel semaphore (count 1) derived from the canonical lock path (`CreateSemaphoreW` in src/win32.ts beside the existing koffi bindings) — a kernel object with no filesystem footprint, destroyed with its last handle. Contention maps to `SessionAlreadyOwnedError`; the kernel releases the lock when the holder's descriptor or handle closes, including on any process death, so a crashed holder never blocks a successor and no expiry bookkeeping exists. A live but wedged holder keeps the lock until its process exits: expropriating a stalled writer was rejected because its resumed appends would tear the log, and on POSIX removing the lock file remains the explicit forfeit for that case. Because a POSIX lock names an inode rather than a path, acquisition verifies the locked inode is still the file at the lock path and retries otherwise. The lock is taken at write-open of an existing artifact and, for a created session, only right before its first materializing write — an unmaterialized session leaves no filesystem footprint, and a handle that acquired the lock keeps it through close even when materialization fails; release never removes the lock file, preserving the stable inode later lockers verify against. The browser worker deployment stubs the flock entry to immediate success: it is single-process, so the in-process write claim already excludes every writer. +`SessionWriteLease` (packages/session/session-persistence-jsonl/src/lease.ts) holds a kernel lock on `session.lock` beside the log for the whole life of a write handle: POSIX takes a non-blocking `flock(2)` through the prebuilt `@deepseek-ai/node-addon-system/flock` binding, and Windows holds a named kernel semaphore (count 1) derived from the canonical lock path (`CreateSemaphoreW` in src/win32.ts beside the existing koffi bindings) — a kernel object with no filesystem footprint, destroyed with its last handle. Contention maps to `SessionAlreadyOwnedError`; the kernel releases the lock when the holder's descriptor or handle closes, including on any process death, so a crashed holder never blocks a successor and no expiry bookkeeping exists. A live but wedged holder keeps the lock until its process exits: expropriating a stalled writer was rejected because its resumed appends would tear the log, and on POSIX removing the lock file remains the explicit forfeit for that case. Because a POSIX lock names an inode rather than a path, acquisition verifies the locked inode is still the file at the lock path and retries otherwise. The lock is taken at write-open of an existing artifact and, for a created session, only right before its first materializing write — an unmaterialized session leaves no filesystem footprint, and a handle that acquired the lock keeps it through close even when materialization fails; release never removes the lock file, preserving the stable inode later lockers verify against. The browser worker deployment stubs the flock entry to immediate success because its in-process write claim excludes every writer. Its `node:fs` replacement still reports BigInt device and inode identity from `FileHandle.stat({ bigint: true })`, matching path `stat` while the path names that file, because the lease retains the inode-replacement check after the stubbed acquisition. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-08-31-cross-process-session-write-lease.zh.md b/.agents/notes/implemented/feature/2026-08-31-cross-process-session-write-lease.zh.md index 8a79b6d532..b287ab7d6c 100644 --- a/.agents/notes/implemented/feature/2026-08-31-cross-process-session-write-lease.zh.md +++ b/.agents/notes/implemented/feature/2026-08-31-cross-process-session-write-lease.zh.md @@ -10,7 +10,7 @@ JSONL 后端的写句柄认领只在单个后端实例内部排除第二个写 ## Decision -`SessionWriteLease`(packages/session/session-persistence-jsonl/src/lease.ts)在日志旁的 `session.lock` 上持有内核锁,贯穿写句柄的整个生命期:POSIX 经由预编译 `@deepseek-ai/node-addon-system/flock` 绑定 以非阻塞 `flock(2)` 加锁,Windows 持有由规范锁路径派生的命名内核信号量(计数 1,`CreateSemaphoreW`,实现在 src/win32.ts 既有 koffi 绑定旁)——零文件系统足迹的内核对象,随最后一个句柄关闭而销毁。竞争映射为 `SessionAlreadyOwnedError`;持有者的描述符或句柄关闭时内核释放锁,包括任何形式的进程死亡,因此崩溃的持有者从不阻塞后继者,也不存在任何过期簿记。活着但卡死的持有者保有锁直到其进程退出:剥夺停顿写入方的所有权被否决,因为其复活后的追加会撕坏日志;POSIX 上删除锁文件仍是该场景的显式放弃手段。由于 POSIX 锁指向 inode 而非路径,获取后会校验所锁 inode 仍是锁路径上的文件,否则重试。锁在写打开既有工件时立即获取,新建会话则仅在首次物化写入之前获取——未物化的会话不留任何文件系统足迹,已取得锁的句柄即使物化失败也保有锁直到关闭;释放从不删除锁文件,保住后续加锁者用于校验的稳定 inode。浏览器 worker 部署将 flock 入口存根为立即成功:它是单进程部署,进程内写认领已排除所有写入方。 +`SessionWriteLease`(packages/session/session-persistence-jsonl/src/lease.ts)在日志旁的 `session.lock` 上持有内核锁,贯穿写句柄的整个生命期:POSIX 经由预编译 `@deepseek-ai/node-addon-system/flock` 绑定 以非阻塞 `flock(2)` 加锁,Windows 持有由规范锁路径派生的命名内核信号量(计数 1,`CreateSemaphoreW`,实现在 src/win32.ts 既有 koffi 绑定旁)——零文件系统足迹的内核对象,随最后一个句柄关闭而销毁。竞争映射为 `SessionAlreadyOwnedError`;持有者的描述符或句柄关闭时内核释放锁,包括任何形式的进程死亡,因此崩溃的持有者从不阻塞后继者,也不存在任何过期簿记。活着但卡死的持有者保有锁直到其进程退出:剥夺停顿写入方的所有权被否决,因为其复活后的追加会撕坏日志;POSIX 上删除锁文件仍是该场景的显式放弃手段。由于 POSIX 锁指向 inode 而非路径,获取后会校验所锁 inode 仍是锁路径上的文件,否则重试。锁在写打开既有工件时立即获取,新建会话则仅在首次物化写入之前获取——未物化的会话不留任何文件系统足迹,已取得锁的句柄即使物化失败也保有锁直到关闭;释放从不删除锁文件,保住后续加锁者用于校验的稳定 inode。浏览器 worker 部署将 flock 入口存根为立即成功,因为进程内写认领已排除所有写入方。它的 `node:fs` 替代实现仍从 `FileHandle.stat({ bigint: true })` 报告 BigInt device 与 inode 身份,并在该路径仍指向所打开文件时与路径 `stat` 一致,因为租约在存根式加锁后仍保留 inode 替换检查。 ## Alternatives considered diff --git a/packages/experimental/webworker-runtime/README.i18n.yaml b/packages/experimental/webworker-runtime/README.i18n.yaml index c06d04c923..2a908c9a11 100644 --- a/packages/experimental/webworker-runtime/README.i18n.yaml +++ b/packages/experimental/webworker-runtime/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/experimental/webworker-runtime/README.md -README.md: d50a7c43db99ff7417cdb39aeb6caae2070125a1 -README.zh.md: 29b92ee280a3d7780218741620655b98d651498c +README.md: 09c79e969165d341371bd85708a01f2655f3d24d +README.zh.md: 16267df48ad09945f10be1c353723322ea6006eb diff --git a/packages/experimental/webworker-runtime/README.md b/packages/experimental/webworker-runtime/README.md index d50a7c43db..09c79e9691 100644 --- a/packages/experimental/webworker-runtime/README.md +++ b/packages/experimental/webworker-runtime/README.md @@ -26,7 +26,7 @@ The browser worker host: the whole harness plugin tree runs inside one dedicated Three artifacts from one tsdown pipeline: - **`lib/index.js` (assembly library)** — `createWorkerHost`/`startWorkerHost` mount the base image and any ordered data overlays (`storage/`), install the module loader (`module-system/`) and the `process` shim, boot the tree through the image's own `dsh-app-boot`, and hand the tunnel its serving seams. Overlays may replace files only under `home/` and `workspace/`; they cannot replace the base manifest, configuration, or modules. The image layout contract (`image-layout.ts`: virtual root, config/manifest paths, empty directories, the `lowered` wrapper-contract gate) is shared with the packer. Boot patches force the deployment-shaped rows: frontend serving off, JSONL session logs on the plaintext path, preset roots onto the image's `config/agent-presets`. -- **`lib/worker.js` (worker bundle)** — the assembly plus this package's Node-compatibility layer as one self-contained ES module. The module proxy table (`module-proxies.ts`) is the only platform fork: `node:*` builtins over VFS/tunnel/browser primitives, structural stubs that fail loud on the console for what a browser cannot do, and native/binary package replacements. `node:module` supplies `createRequire().resolve` and `.resolve.paths()` over the image package root, so unchanged packages can discover manifests without evaluating their modules. The global `process` shim carries Node detection fields including `title`, preventing Worker execution from entering DOM-only branches. The pack-time parser reports statically named module requests, including module-scope direct calls of the form `createRequire(import.meta.url)('pkg')` through a named `node:module` or `module` import, to the packer's reachability walk. Stored, CommonJS-obtained, and rebased `createRequire` calls require image entry seeds. VFS mutations drive `node:fs` callback, polling, and promise watchers; open descriptors retain file identity and access mode across rename, replacement, and unlink, and `FileHandle.stat({ bigint: true })` reports the same device and inode identity as a path stat while the name still refers to that file; `readable-stream` supplies the stream state machine used by file streams and unchanged image packages such as Chokidar and readdirp. AsyncLocalStorage carries sync-stack causality across `await` through the snapshot/restore faces the pack-time lowering injects. The worker holds no compiler: an image the packer did not lower is refused at mount. +- **`lib/worker.js` (worker bundle)** — the assembly plus this package's Node-compatibility layer as one self-contained ES module. The module proxy table (`module-proxies.ts`) is the only platform fork: `node:*` builtins over VFS/tunnel/browser primitives, structural stubs that fail loud on the console for what a browser cannot do, and native/binary package replacements. `node:module` supplies `createRequire().resolve` and `.resolve.paths()` over the image package root, so unchanged packages can discover manifests without evaluating their modules. The global `process` shim carries Node detection fields including `title`, preventing Worker execution from entering DOM-only branches. The pack-time parser reports statically named module requests, including module-scope direct calls of the form `createRequire(import.meta.url)('pkg')` through a named `node:module` or `module` import, to the packer's reachability walk. Stored, CommonJS-obtained, and rebased `createRequire` calls require image entry seeds. VFS mutations drive `node:fs` callback, polling, and promise watchers; open descriptors retain file identity and access mode across rename, replacement, and unlink, `FileHandle.stat({ bigint: true })` reports the same device and inode identity as a path stat while the name still refers to that file, and `FileHandle.chmod()` updates the opened file identity; `readable-stream` supplies the stream state machine used by file streams and unchanged image packages such as Chokidar and readdirp. AsyncLocalStorage carries sync-stack causality across `await` through the snapshot/restore faces the pack-time lowering injects. The worker holds no compiler: an image the packer did not lower is refused at mount. - **`src/shell/` (the worker's own process layer)** — a browser worker cannot fork, so `node:child_process` is not a stub but an implementation: `spawn` starts the command in its own Web Worker — this same bundle, told by its first frame to be a shell process — and reports it through the `ChildProcess` surface the subprocess service consumes. The command runs off the host's thread, `SIGKILL` terminates it whatever it is doing, and it reaches the VFS only by message (the host serves those frames). Worker platform executables preserve native-package protocols such as Landlock without replacing their JavaScript packages or coupling their implementations to `node:child_process`; ordinary commands use the package's evaluator and coreutils command table. The grammar is `@yarnpkg/parsers`' `parseShell`, while `execSync`/`fork` still refuse because they need a real process. - **`lib/client.js` (page half)** — startup has two independent stages. `chooseWorkerHostSource({ image?, fixtureManifest? })` optionally owns the boot barrier and fixture manifest: without `preview-fixture` it waits at the source chooser, while a valid query selects directly; either path returns ordered overlays. `connectWorkerHost(worker, { image?, overlays? })` remains the public base-runtime connector; callers that skip the chooser get an empty overlay list. `apps/web` invokes both and supplies its statically bundled Worker. The opening `init` frame carries the base and ordered overlay URLs, the boot payload delivers the structured index-injection table, and `applyIndexInjections` executes it before the shell entry runs. Script preload rows are advisory and skipped because `/plugins` resources resolve only through the tunnel; `loadBundle` fetches each combo on first demand, embeds its tunnel-only source map as a Base64 data URL, and executes the script as a Blob. The tunnel also exposes fetch-shaped transport, the independent file-upload carrier, and the API client. Request frames preserve Blob bodies through structured clone and transfer `ReadableStream` ownership. The Host Worker streams both forms into the route, so neither browser thread creates a complete byte array for a generic-file upload. diff --git a/packages/experimental/webworker-runtime/README.zh.md b/packages/experimental/webworker-runtime/README.zh.md index 29b92ee280..16267df48a 100644 --- a/packages/experimental/webworker-runtime/README.zh.md +++ b/packages/experimental/webworker-runtime/README.zh.md @@ -26,7 +26,7 @@ kind: "package-library" 一条 tsdown 管线出三个产物: - **`lib/index.js`(装配库)**——`createWorkerHost`/`startWorkerHost` 挂载基础镜像和按序排列的数据 overlays(`storage/`)、安装模块加载器(`module-system/`)与 `process` shim、经镜像自带的 `dsh-app-boot` 启动插件树,并把服务缝隙交给隧道。Overlay 只能替换 `home/` 与 `workspace/` 下的文件,不能替换基础 manifest、配置或模块。镜像布局契约(`image-layout.ts`:虚拟根、config/manifest 路径、空目录、`lowered` 包装契约门)与 packer 共享。boot patch 强制部署形态行:关前端静态服务、JSONL 会话日志走明文、preset 根指向镜像内 `config/agent-presets`。 -- **`lib/worker.js`(worker 束)**——装配库加本包的 Node 兼容层,合成一个自含 ES module。模块代理表(`module-proxies.ts`)是唯一平台叉口:`node:*` 内建走 VFS、隧道和浏览器原语,浏览器做不到的走结构化 stub(调用即在 console 报错并抛出),native/binary 包则替换执行后端。`node:module` 在镜像 package 根之上提供 `createRequire().resolve` 与 `.resolve.paths()`,使未修改的包无需执行目标模块即可发现 manifest。全局 `process` shim 带有包括 `title` 在内的 Node 环境识别字段,避免 Worker 执行误入仅适用于 DOM 的分支。pack 期解析器会把名称静态可知的模块请求报告给 packer 的可达性遍历,其中包括通过 `node:module` 或 `module` 具名导入在模块作用域直接发起的 `createRequire(import.meta.url)('pkg')` 调用。保存、经 CommonJS 获取或另设基准的 `createRequire` 调用需要镜像入口种子。VFS mutation 驱动 `node:fs` 的 callback、polling 和 promise watcher;打开的 descriptor 在 rename、replacement 和 unlink 后仍保留文件身份与访问模式,只要文件名仍指向该文件,`FileHandle.stat({ bigint: true })` 报告的 device 与 inode 身份就与路径 stat 相同;`readable-stream` 提供文件流以及 Chokidar、readdirp 等未修改镜像包所用的流状态机。AsyncLocalStorage 经 pack 时降低注入的 snapshot/restore 面在 `await` 间携带同步栈因果。worker 不带编译器:packer 未降低的镜像在挂载时被拒。 +- **`lib/worker.js`(worker 束)**——装配库加本包的 Node 兼容层,合成一个自含 ES module。模块代理表(`module-proxies.ts`)是唯一平台叉口:`node:*` 内建走 VFS、隧道和浏览器原语,浏览器做不到的走结构化 stub(调用即在 console 报错并抛出),native/binary 包则替换执行后端。`node:module` 在镜像 package 根之上提供 `createRequire().resolve` 与 `.resolve.paths()`,使未修改的包无需执行目标模块即可发现 manifest。全局 `process` shim 带有包括 `title` 在内的 Node 环境识别字段,避免 Worker 执行误入仅适用于 DOM 的分支。pack 期解析器会把名称静态可知的模块请求报告给 packer 的可达性遍历,其中包括通过 `node:module` 或 `module` 具名导入在模块作用域直接发起的 `createRequire(import.meta.url)('pkg')` 调用。保存、经 CommonJS 获取或另设基准的 `createRequire` 调用需要镜像入口种子。VFS mutation 驱动 `node:fs` 的 callback、polling 和 promise watcher;打开的 descriptor 在 rename、replacement 和 unlink 后仍保留文件身份与访问模式,只要文件名仍指向该文件,`FileHandle.stat({ bigint: true })` 报告的 device 与 inode 身份就与路径 stat 相同,`FileHandle.chmod()` 则更新打开文件身份的权限;`readable-stream` 提供文件流以及 Chokidar、readdirp 等未修改镜像包所用的流状态机。AsyncLocalStorage 经 pack 时降低注入的 snapshot/restore 面在 `await` 间携带同步栈因果。worker 不带编译器:packer 未降低的镜像在挂载时被拒。 - **`src/shell/`(worker 自己的进程层)**——浏览器 worker 无法 fork,所以 `node:child_process` 不是 stub 而是实现:`spawn` 把命令放进它自己的 Web Worker——就是这同一个束,由首帧告诉它「你是 shell 进程」——并以 subprocess 服务消费的 `ChildProcess` 面报告结果。命令不占宿主线程,`SIGKILL` 不管它在干什么都能终止它,而它只能靠消息触达 VFS(由宿主应答这些帧)。Worker 平台 executable 在不替换 JavaScript 包、也不把具体实现耦合进 `node:child_process` 的情况下保持 Landlock 等 native 包协议;普通命令使用本包的求值器与 coreutils 命令表。语法来自 `@yarnpkg/parsers` 的 `parseShell`,而 `execSync`/`fork` 依然拒绝,因为它们需要真进程。 - **`lib/client.js`(页面半)**——启动分为相互独立的两段。`chooseWorkerHostSource({ image?, fixtureManifest? })` 可选地拥有 boot barrier 与 fixture manifest:没有 `preview-fixture` 时停在来源选择面板,合法 query 则直接选择;两条路径都返回按序排列的 overlays。`connectWorkerHost(worker, { image?, overlays? })` 仍是公开的基础运行态连接器;调用方跳过选择器时 overlay 列表为空。`apps/web` 调用这两段并提供静态打包的 Worker。开局 `init` 帧携带基础镜像与按序排列的 overlay URL,boot 载荷送达结构化 index 注入表,`applyIndexInjections` 在壳入口运行前逐行执行。脚本 preload 行只是提示,因此会被跳过:`/plugins` 资源只能经 tunnel 解析,`loadBundle` 会在首次需要时获取 combo、把仅 tunnel 可达的 sourcemap 内嵌为 Base64 data URL,再以 Blob 执行脚本。Tunnel 还暴露 fetch 形式的传输、独立文件上传载体和 API 客户端。请求帧通过结构化克隆保留 Blob 请求体,并转移 `ReadableStream` 的所有权。Host Worker 将两种请求体都逐块送入路由,因此通用文件上传不会在任何浏览器线程创建完整字节数组。 From 60a3d0fbc2cb549b0c800b29d020130eb83abe29 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 9 Sep 2026 17:54:53 +0800 Subject: [PATCH 052/111] fix(webworker): support file handle chmod --- apps/web/tests/preview-boot.e2e.ts | 11 ++++++++-- .../node/builtin_modules/implemented/fs.ts | 10 ++++++++- .../webworker-runtime/src/storage/memory.ts | 22 +++++++++++++------ .../webworker-runtime/src/storage/types.ts | 3 +++ .../webworker-runtime/tests/node/fs.spec.ts | 4 ++++ .../tests/storage/memory-vfs.spec.ts | 4 +++- 6 files changed, 43 insertions(+), 11 deletions(-) diff --git a/apps/web/tests/preview-boot.e2e.ts b/apps/web/tests/preview-boot.e2e.ts index ca04c720c2..0819e0e8ce 100644 --- a/apps/web/tests/preview-boot.e2e.ts +++ b/apps/web/tests/preview-boot.e2e.ts @@ -355,7 +355,12 @@ async function bootPreview(origin: string, browser: Browser): Promise { if (!body.result.ok) throw new Error(`${endpoint} failed: ${body.result.error.message}`) return body.result.value } - const renamed = await remote<{ title: string }>('session/rename', { + // Keep the fixture title stable for later UI assertions; increasing seqs + // prove that the cold Session acquired its write lease and appended. + const firstRename = await remote<{ title: string; seq: number }>('session/rename', { + request: { sessionId: seededSessionId, title: seededSessionTitle }, + }) + const secondRename = await remote<{ title: string; seq: number }>('session/rename', { request: { sessionId: seededSessionId, title: seededSessionTitle }, }) const skills = await remote<{ skills: Array<{ name: string }> }>( @@ -390,7 +395,8 @@ async function bootPreview(origin: string, browser: Browser): Promise { await remote('credentials/unset', { ref: 'PREVIEW_TEST_SECRET' }) await new Promise((resolve) => { setTimeout(resolve, 250) }) return { - renamedTitle: renamed.title, + renamedTitle: secondRename.title, + renameAdvanced: secondRename.seq > firstRename.seq, skillCount: skills.skills.length, credentialConfigured: credentials.PREVIEW_TEST_SECRET?.configured, } @@ -399,6 +405,7 @@ async function bootPreview(origin: string, browser: Browser): Promise { seededSessionTitle: SHOWCASE_TITLE, }) expect(exercised.renamedTitle).toBe(SHOWCASE_TITLE) + expect(exercised.renameAdvanced).toBe(true) expect(exercised.skillCount).toBeGreaterThan(0) expect(exercised.credentialConfigured).toBe(true) diff --git a/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs.ts b/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs.ts index 66676be09e..359e008492 100644 --- a/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs.ts +++ b/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs.ts @@ -36,6 +36,9 @@ const encodingOf = (options: EncodingOption): BufferEncoding | undefined => { return options.encoding ?? undefined } +const numericMode = (mode: number | string): number => + typeof mode === 'string' ? Number.parseInt(mode, 8) : mode + const bytesOf = (path: string): Uint8Array => vfs().readFileSync(path) as Uint8Array /** Share the VFS bytes rather than copying them. */ @@ -186,7 +189,7 @@ export function stat( * @param mode - new permission bits (`0o777` mask), numeric or Node's octal string form. */ export function chmodSync(path: PathArg, mode: number | string): void { - vfs().chmodSync(asPath(path), typeof mode === 'string' ? Number.parseInt(mode, 8) : mode) + vfs().chmodSync(asPath(path), numericMode(mode)) } /** @@ -399,6 +402,7 @@ export interface FileHandle { writeFile(data: string | Uint8Array, encoding?: BufferEncoding): Promise write(data: string | Uint8Array): Promise<{ bytesWritten: number }> read(buffer: Uint8Array, offset?: number, length?: number, position?: number | null): Promise<{ bytesRead: number; buffer: Uint8Array }> + chmod(mode: number | string): Promise stat(options?: VfsStatOptions): Promise truncate(length?: number): Promise sync(): Promise @@ -441,6 +445,10 @@ export function openHandleSync(path: PathArg, flags = 'r', mode?: number): FileH bytesRead: readSync(fd, buffer, offset, length, position), buffer, }), + chmod: async (mode: number | string) => { + if (directory) chmodSync(target, mode) + else descriptor('fchmod').file.chmod(numericMode(mode)) + }, stat: async (options?: VfsStatOptions) => directory ? statSync(target, options) : options?.bigint === true diff --git a/packages/experimental/webworker-runtime/src/storage/memory.ts b/packages/experimental/webworker-runtime/src/storage/memory.ts index 3a14c62b49..0486dc84ab 100644 --- a/packages/experimental/webworker-runtime/src/storage/memory.ts +++ b/packages/experimental/webworker-runtime/src/storage/memory.ts @@ -426,6 +426,16 @@ export class MemoryVfs implements Vfs { this.replaceFile(node, resize(node.bytes, length)) } + /** Change one file identity's permission bits and notify every linked path. */ + private chmodFile(node: FileNode, mode: number): void { + node.mode = mode & 0o777 + if (typeof node.paths === 'string') { + this.publish({ kind: 'chmod', path: node.paths, mode: node.mode }) + } else if (node.paths !== undefined) { + for (const path of node.paths) this.publish({ kind: 'chmod', path, mode: node.mode }) + } + } + /** @returns Plain stats for an open file, including after its last name is removed. */ private fileStats(node: FileNode): VfsStats { return statsOf(node.bytes.length, node.mtimeMs, false, this.identityOfFile(node), node.mode) @@ -659,6 +669,7 @@ export class MemoryVfs implements Vfs { truncate: async (length = 0): Promise => { current('ftruncate').truncate(length) }, + chmod: async (mode: number): Promise => { current('fchmod').chmod(mode) }, stat: async (options?: VfsStatOptions): Promise => options?.bigint === true ? current('fstat').statBigInt() : current('fstat').stat(), @@ -705,6 +716,7 @@ export class MemoryVfs implements Vfs { if (!access.writable) fail('EINVAL', 'ftruncate', target) this.truncateFile(node, length) }, + chmod: mode => { this.chmodFile(node, mode) }, stat: () => this.fileStats(node), statBigInt: () => this.fileBigIntStats(node), } @@ -717,8 +729,9 @@ export class MemoryVfs implements Vfs { * @param target - Normalized path the handle was opened on. * @returns Metadata plus the no-op durability and release calls. */ - private handleTail(target: string): Pick { + private handleTail(target: string): Pick { return { + chmod: async (mode: number): Promise => { this.chmodSync(target, mode) }, stat: async (options?: VfsStatOptions): Promise => this.statSync(target, options), sync: async (): Promise => { await this.flush() }, datasync: async (): Promise => { await this.flush() }, @@ -851,12 +864,7 @@ export class MemoryVfs implements Vfs { const target = this.key(path) const node = this.files.get(target) if (node !== undefined) { - node.mode = mode & 0o777 - if (typeof node.paths === 'string') { - this.publish({ kind: 'chmod', path: node.paths, mode: node.mode }) - } else if (node.paths !== undefined) { - for (const path of node.paths) this.publish({ kind: 'chmod', path, mode: node.mode }) - } + this.chmodFile(node, mode) return } if (this.directories.has(target)) { diff --git a/packages/experimental/webworker-runtime/src/storage/types.ts b/packages/experimental/webworker-runtime/src/storage/types.ts index 616d24cf27..085901b12f 100644 --- a/packages/experimental/webworker-runtime/src/storage/types.ts +++ b/packages/experimental/webworker-runtime/src/storage/types.ts @@ -119,6 +119,7 @@ export interface VfsFileHandle { writeFile(data: string | Uint8Array): Promise readFile(options?: VfsReadOptions): Promise truncate(length?: number): Promise + chmod(mode: number): Promise stat(options?: VfsStatOptions): Promise sync(): Promise datasync(): Promise @@ -152,6 +153,8 @@ export interface VfsOpenFile { * @param length - Target byte length. */ truncate(length: number): void + /** Change permission bits on the opened file identity. */ + chmod(mode: number): void /** * Read metadata from the opened file identity. * @returns Current file metadata, including after rename or unlink. diff --git a/packages/experimental/webworker-runtime/tests/node/fs.spec.ts b/packages/experimental/webworker-runtime/tests/node/fs.spec.ts index cb30f33dc2..fc5ed8e8c5 100644 --- a/packages/experimental/webworker-runtime/tests/node/fs.spec.ts +++ b/packages/experimental/webworker-runtime/tests/node/fs.spec.ts @@ -212,6 +212,10 @@ fs.renameSync('/dsh/secrets.tmp', '/dsh/secrets.yaml') check('a wx write with mode 600 stats as 600 after rename', plainMode('/dsh/secrets.yaml'), 0o600) fs.writeFileSync('/dsh/secrets.yaml', 'k: w\n') check('a rewrite keeps the creation bits', plainMode('/dsh/secrets.yaml'), 0o600) +const chmodHandle = await fsp.open('/dsh/secrets.yaml', 'r+') +await chmodHandle.chmod(0o640) +await chmodHandle.close() +check('FileHandle.chmod updates the opened file', plainMode('/dsh/secrets.yaml'), 0o640) fs.chmodSync('/dsh/secrets.yaml', 0o640) check('chmod reads back exactly what was set', plainMode('/dsh/secrets.yaml'), 0o640) await fsp.chmod('/dsh/secrets.yaml', 0o600) diff --git a/packages/experimental/webworker-runtime/tests/storage/memory-vfs.spec.ts b/packages/experimental/webworker-runtime/tests/storage/memory-vfs.spec.ts index 521342f19d..5816a2abfa 100644 --- a/packages/experimental/webworker-runtime/tests/storage/memory-vfs.spec.ts +++ b/packages/experimental/webworker-runtime/tests/storage/memory-vfs.spec.ts @@ -206,11 +206,13 @@ describe('mutation publication', () => { it('reports the path identity through a BigInt file handle stat', async () => { const vfs = new MemoryVfs() vfs.seed('/dsh/session.lock', '') - const handle = vfs.open('/dsh/session.lock', 'r') + const handle = vfs.open('/dsh/session.lock', 'w') const held = await handle.stat({ bigint: true }) as VfsBigIntStats const current = vfs.statSync('/dsh/session.lock', { bigint: true }) as VfsBigIntStats expect([held.dev, held.ino]).toEqual([current.dev, current.ino]) + await handle.chmod(0o600) + expect((vfs.statSync('/dsh/session.lock') as VfsStats).mode & 0o777).toBe(0o600) await handle.close() }) From d7ecdcf02cbb1ff233c4704c6a4bffc61a577c17 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 9 Sep 2026 18:20:36 +0800 Subject: [PATCH 053/111] fix(lint): parenthesize file handle callback --- packages/experimental/webworker-runtime/src/storage/memory.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/experimental/webworker-runtime/src/storage/memory.ts b/packages/experimental/webworker-runtime/src/storage/memory.ts index 0486dc84ab..f7f8fc7a76 100644 --- a/packages/experimental/webworker-runtime/src/storage/memory.ts +++ b/packages/experimental/webworker-runtime/src/storage/memory.ts @@ -716,7 +716,7 @@ export class MemoryVfs implements Vfs { if (!access.writable) fail('EINVAL', 'ftruncate', target) this.truncateFile(node, length) }, - chmod: mode => { this.chmodFile(node, mode) }, + chmod: (mode) => { this.chmodFile(node, mode) }, stat: () => this.fileStats(node), statBigInt: () => this.fileBigIntStats(node), } From 0b3248e67d44d2324b532815c2f5470160bbf6d8 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 9 Sep 2026 18:21:45 +0800 Subject: [PATCH 054/111] test(web): stabilize delivered-file snapshots across CI hosts --- apps/web/tests/navigation-panes.e2e.ts | 5 ++--- apps/web/tests/present-svg.e2e.ts | 2 ++ apps/web/tests/present-svg.overlay.yml | 4 ++++ packages/client/ui-primitives/src/LinkIcon.tsx | 1 + snapshots/web/present-svg/ui.expected.md | 3 ++- 5 files changed, 11 insertions(+), 4 deletions(-) create mode 100644 apps/web/tests/present-svg.overlay.yml diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index d51e08bb49..a73c9dd65b 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -403,14 +403,13 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { // the right column either — the expanded terminal card is read in place. await page.locator('[data-sample="bash"] ~ div [data-terminal] [class*="_copyButton_"]').first().click() await expect.poll(() => frame.getAttribute('data-rightbar-collapsed'), { timeout: 5_000 }).toBe('true') - // Read summaries are file links: one click opens the file as a text-preview - // tab in the right Sidebar, which expands to show it beside the guide tab. + // Opening a file into the empty column creates only its preview tab. const fileLink = page.locator('[data-variant="read"] button').first() await fileLink.waitFor({ timeout: 10_000 }) await fileLink.click() await expect.poll(() => frame.getAttribute('data-rightbar-collapsed'), { timeout: 5_000 }).toBe(null) const column = page.locator('[data-rightbar-col]') - await expect.poll(() => column.locator('[data-dockkit-tab-title]').count(), { timeout: 5_000 }).toBe(2) + await expect.poll(() => column.locator('[data-dockkit-tab-title]').allTextContents(), { timeout: 5_000 }).toEqual(['nav-a.md']) // Put the column back so later cases start from the default frame. await column.locator('[data-sidebar-right-toggle]').click() await expect.poll(() => frame.getAttribute('data-rightbar-collapsed'), { timeout: 5_000 }).toBe('true') diff --git a/apps/web/tests/present-svg.e2e.ts b/apps/web/tests/present-svg.e2e.ts index bb6bd2e0b5..9fc93f131b 100644 --- a/apps/web/tests/present-svg.e2e.ts +++ b/apps/web/tests/present-svg.e2e.ts @@ -40,6 +40,7 @@ describe('web e2e: requested SVG is explicitly delivered', () => { } scaffold = await launchWebScaffold({ compareReplaySession: true, + extraOverlayPath: fileURLToPath(new URL('./present-svg.overlay.yml', import.meta.url)), ...(replayOverride === undefined ? {} : { replayFixture: FIXTURE, replayOverride }), }) browser = await chromium.launch() @@ -114,6 +115,7 @@ describe('web e2e: requested SVG is explicitly delivered', () => { it.skipIf(MODE === 'record')('replays the delivered file and Chinese conversation', async () => { await assertFinalWorkspaceSnapshot(DIR, cwd) + await expect.poll(() => page.getByRole('button', { name: `${FILE} 的更多文件操作`, exact: true }).isDisabled()).toBe(true) // Delivery owns the transcript; navigation and composer chrome have separate scenarios. const aria = await captureExpandedTurnProcessAria(page, '[data-chat-flow]', scaffold.workspaceCwd) await compareOrRefreshGolden(join(DIR, 'ui.expected.md'), aria, MODE) diff --git a/apps/web/tests/present-svg.overlay.yml b/apps/web/tests/present-svg.overlay.yml new file mode 100644 index 0000000000..a575a00026 --- /dev/null +++ b/apps/web/tests/present-svg.overlay.yml @@ -0,0 +1,4 @@ +# This transcript includes the no-desktop state on every test platform. +- id: session-controller + config: + nativeOpen: false diff --git a/packages/client/ui-primitives/src/LinkIcon.tsx b/packages/client/ui-primitives/src/LinkIcon.tsx index 4f645f6514..d46f11c24d 100644 --- a/packages/client/ui-primitives/src/LinkIcon.tsx +++ b/packages/client/ui-primitives/src/LinkIcon.tsx @@ -48,6 +48,7 @@ export function classifyLinkPath(path: string): LinkIconKind { case 'markdown': case 'other': case 'video': return 'other' + /* v8 ignore next -- classifyFileType returns a closed union exhausted above */ default: return assertNever(type) } } diff --git a/snapshots/web/present-svg/ui.expected.md b/snapshots/web/present-svg/ui.expected.md index 1fc1597b18..bede988652 100644 --- a/snapshots/web/present-svg/ui.expected.md +++ b/snapshots/web/present-svg/ui.expected.md @@ -51,10 +51,11 @@ - paragraph: 下方附一行核心思想说明:指令与数据以二进制统一存储在存储器中。用浏览器打开该 SVG 文件即可查看。 - text: 本轮文件改动 - button "打开 {{cwd}}/workspace/von-neumann.svg": von-neumann.svg +- text: 此主机没有可用的桌面,无法打开文件或文件夹 - button "在侧边栏预览 von-neumann.svg" - text: von-neumann.svg 冯诺依曼架构示意图 SVG - button "在侧边栏打开 von-neumann.svg": 打开 -- button "von-neumann.svg 的更多文件操作": +- button "von-neumann.svg 的更多文件操作" [disabled]: - img - button "复制": - img From 63187de80f686d66dd404cfc2ee72f01501fc8ef Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 9 Sep 2026 18:53:55 +0800 Subject: [PATCH 055/111] release: publish experimental Agent Teams packages --- ...xperimental-agent-teams-packages.i18n.yaml | 4 +-- ...08-18-experimental-agent-teams-packages.md | 26 ++++++++-------- ...18-experimental-agent-teams-packages.zh.md | 26 ++++++++-------- .../feature/2026-08-05-agent-teams.i18n.yaml | 4 +-- .../feature/2026-08-05-agent-teams.md | 8 ++--- .../feature/2026-08-05-agent-teams.zh.md | 8 ++--- AGENTS.md | 2 +- apps/web/tests/agent-team-panel.e2e.ts | 2 +- apps/web/tests/scaffold.ts | 4 +-- docs/architecture.i18n.yaml | 4 +-- docs/architecture.md | 2 +- docs/architecture.zh.md | 2 +- packages/experimental/AGENTS.md | 6 ++-- packages/experimental/README.i18n.yaml | 4 +-- packages/experimental/README.md | 10 +++---- packages/experimental/README.zh.md | 10 +++---- .../agent-team-profile/README.i18n.yaml | 4 +-- .../experimental/agent-team-profile/README.md | 14 ++++----- .../agent-team-profile/README.zh.md | 14 ++++----- .../agent-team-profile/cordis.patch.yml | 2 +- .../agent-team-profile/package.json | 6 ++-- .../agent-team-profile/src/index.ts | 2 +- .../agent-team-profile/tests/profile.spec.ts | 8 ++--- .../agent-team-web-profile/README.i18n.yaml | 4 +-- .../agent-team-web-profile/README.md | 14 ++++----- .../agent-team-web-profile/README.zh.md | 14 ++++----- .../agent-team-web-profile/cordis.patch.yml | 2 +- .../agent-team-web-profile/package.json | 6 ++-- .../agent-team-web-profile/src/index.ts | 2 +- .../tests/profile.spec.ts | 8 ++--- .../experimental/agent-team/README.i18n.yaml | 4 +-- packages/experimental/agent-team/README.md | 6 ++-- packages/experimental/agent-team/README.zh.md | 6 ++-- packages/experimental/agent-team/package.json | 4 ++- .../client-ui-agent-team/README.i18n.yaml | 4 +-- .../client-ui-agent-team/README.md | 6 ++-- .../client-ui-agent-team/README.zh.md | 6 ++-- .../client-ui-agent-team/package.json | 4 ++- .../tool-agent-team/README.i18n.yaml | 4 +-- .../experimental/tool-agent-team/README.md | 4 +-- .../experimental/tool-agent-team/README.zh.md | 4 +-- .../experimental/tool-agent-team/package.json | 4 ++- scripts/check-workspace-constraints.spec.ts | 22 ++++++++++++++ scripts/check-workspace-constraints.ts | 30 +++++++++++++------ scripts/experimental-package-policy.ts | 19 ++++++++++++ scripts/publish-npm-baseline.ts | 2 ++ scripts/release/families.spec.ts | 14 +++++++-- scripts/release/families.ts | 7 ++++- .../verify-package-readme-model-experience.ts | 2 +- 49 files changed, 228 insertions(+), 146 deletions(-) create mode 100644 scripts/experimental-package-policy.ts diff --git a/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.i18n.yaml index 3609897421..165a5ca2d0 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.md -2026-08-18-experimental-agent-teams-packages.md: bff91f9c735f541eb6ef055a5bf70c31b5d88912 -2026-08-18-experimental-agent-teams-packages.zh.md: f3753a3be9679962c30b566a5a411ca568e1c982 +2026-08-18-experimental-agent-teams-packages.md: 8ccbd690882cac0a4dc844d253656681e600fb74 +2026-08-18-experimental-agent-teams-packages.zh.md: dd79d8f2171b545977b7b7e776463da0087724bc diff --git a/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.md b/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.md index bff91f9c73..8ccbd69088 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.md +++ b/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.md @@ -1,4 +1,4 @@ -# Agent Note: Incubate Agent Teams as private experimental packages +# Agent Note: Publish Agent Teams under experimental package names Status: implemented @@ -6,34 +6,36 @@ English | [中文](2026-08-18-experimental-agent-teams-packages.zh.md) ## Problem -Agent Teams needs the real Session log, subagent lifecycle, tools, examples, snapshots, and repository checks while its service and tool contracts continue to change. Placing those packages in a product-role group makes them members of the dsh release family and gives them the same publication expectation as stable packages. +Agent Teams needs the real Session log, subagent lifecycle, tools, examples, snapshots, and repository checks while its service and tool contracts continue to change. Users also need to install the complete Team composition from npm without building a source checkout. -An experimental directory without a current package previously imposed placement, dependency, promotion, and release rules on no consumer. Agent Teams supplies the concrete consumer, but the directory needs mechanical release exclusion and dependency isolation rather than a documentation-only status. +Moving the packages into product-role groups would remove their experimental names and imply stable-package ownership. Publishing every package under `packages/experimental/` would instead expose unrelated internal prototypes. The release policy needs an explicit Agent Teams exception while preserving the private default. ## Decision -`packages/experimental/agent-team`, `packages/experimental/tool-agent-team`, `packages/experimental/agent-team-profile`, `packages/experimental/client-ui-agent-team`, and `packages/experimental/agent-team-web-profile` are private workspace packages. The [experimental package rules](../../../../packages/experimental/AGENTS.md) own their npm names and promotion rename; this note owns their placement, release exclusion, and dependency isolation. +`packages/experimental/agent-team`, `packages/experimental/tool-agent-team`, `packages/experimental/agent-team-profile`, `packages/experimental/client-ui-agent-team`, and `packages/experimental/agent-team-web-profile` are public workspace packages. They retain their existing `@deepseek-ai/dsh-experimental-*` names and join the dsh release family. The [experimental package rules](../../../../packages/experimental/AGENTS.md) own the private default, this exception, and later promotion. -The dsh pack and publish set and the local baseline publisher exclude every manifest below `packages/experimental/`. `release:dsh` still advances their manifest versions with the shared dsh version without creating release tags. Workspace constraints require each experimental package to set `private: true` and omit `publishConfig`. The same top-level check rejects `dependencies`, `optionalDependencies`, and `peerDependencies` from release packages, release apps, or the Python runtime to an experimental package. Experimental packages may depend on release packages and each other; tests may use them through `devDependencies`, and examples may load them explicitly. +The dsh pack and publish set and the local baseline publisher include exactly these five experimental package directories. Workspace constraints require them to omit `private`, set `publishConfig.access` to `public`, and keep the experimental npm prefix. Every other experimental package remains private and excluded from publication by default. Release packages and apps outside the experimental group, plus the Python runtime, cannot name experimental packages in `dependencies`, `optionalDependencies`, or `peerDependencies`; experimental packages may depend on release packages and each other. The generic caller-reserved continuable child identity and selective direct-child drain remain in the stable Subagent service. They own Subagent identity and Activation lifecycle without importing or naming Agent Teams; the experimental Team service consumes them in the permitted direction. -The private Host-side Agent Teams profile bundle depends on the Team packages and applies after `dsh-base`. It inserts the Team rows and disables the global continuable-child controls whose model-visible names overlap the Team tools. The separate private Web profile applies after `dsh-web-app` and the Host profile; it inserts the Team UI, which mounts the Remote contribution generated by the Team package. Both layers leave the shipped base, CLI, Web, and Python runtime dependency graphs unchanged. +The published Host-side Agent Teams profile bundle depends on the Team packages and applies after `dsh-base`. It inserts the Team rows and disables the global continuable-child controls whose model-visible names overlap the Team tools. The separate published Web profile applies after `dsh-web-app` and the Host profile; it inserts the Team UI, which mounts the Remote contribution generated by the Team package. Both layers remain opt-in and leave the shipped base, CLI, Web, and Python runtime dependency graphs unchanged. -Profile startup resolves selected bundles before healing module fallbacks. The shared fallback retains the dsh installation's carrier-specific entries: symlinks under plain Node and ESM proxies in a packaged executable. Missing packages from selected bundle closures are linked under the current profile's own `node_modules`, while pnpm-managed profile entries remain authoritative. Closure discovery starts from each explicit external bundle's real package directory and traverses every listed root even when an earlier dependency has the same package name. It excludes dsh-owned profile projections from later discovery, so a projected dependency cannot feed back into its own closure. Link ownership compares canonical parent paths so junction-normalized targets remain removable. A private profile layer can therefore carry experimental plugin rows without adding those plugins to a release app, requiring profile users to install transitive packages directly, weakening packaged-runtime module identity, or changing another profile's resolution. +Profile installation resolves each published bundle and its dependencies through the profile's package manager. The generic profile launcher then applies the selected layers without adding them to any shipped profile or changing another profile's resolution. -Experimental status changes publication and compatibility expectations only. The packages retain the repository's ordinary documentation, invariant, lifecycle, security, unit, real-composition, and snapshot requirements. Promotion requires review of the public contracts, limitations, test evidence, release payload, runtime dependents, and a named owner accepting stable-package obligations. +Experimental status changes compatibility and support expectations, not publication for these five packages. They retain the repository's ordinary documentation, invariant, lifecycle, security, unit, real-composition, and snapshot requirements. Promotion still requires review of the public contracts, limitations, test evidence, runtime dependents, and a named owner accepting stable-package obligations. ## Alternatives considered -**Keep Agent Teams in a product-role group and describe it as opt-in.** Opt-in composition controls model behavior but does not exclude packages from publication or prevent stable packages from taking runtime dependencies on them. +**Move Agent Teams into product-role groups.** This would remove the requested experimental npm names and imply stable-package ownership before the contracts have stabilized. -**Reserve an empty experimental group.** A directory without a current package has no owner or release mechanism to test. The group exists only while concrete packages need its enforced treatment. +**Keep Agent Teams private and source-checkout only.** This preserves the simplest experimental policy but prevents users from installing the complete opt-in composition from npm. + +**Publish every experimental package.** Unrelated prototypes remain internal-only and have not accepted a public package contract. **Move the Subagent prerequisites into the experimental directory.** Child identity allocation and Activation teardown belong to the Subagent owner and contain no Team-specific contract. Moving or duplicating them would invert the dependency or split one lifecycle across packages. ## Consequences -Agent Teams can use the full repository graph and quality checks without entering official tarballs or becoming a supported runtime dependency. A release package cannot expose Team until the Team packages are promoted, so the CLI experiment installs an explicit private profile layer instead of changing shipped bundles. The generic profile launcher accepts that layer without making its plugin dependencies part of the dsh release closure. +Agent Teams publishes as five installable tarballs in the dsh release family without changing package names or enabling Team in a shipped profile. Public availability does not make the packages stable or supported by default, and stable release packages cannot take runtime dependencies on them. -The product-role grouping is less direct while the packages incubate. Promotion creates path and npm-name churn as specified by the experimental package rules. +The release family carries explicitly named experimental exceptions. Promotion still creates path and npm-name churn as specified by the experimental package rules. diff --git a/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.zh.md b/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.zh.md index f3753a3be9..dd79d8f217 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.zh.md @@ -1,4 +1,4 @@ -# Agent Note: 将 Agent Teams 作为私有实验性包孵化 +# Agent Note: 以实验性包名发布 Agent Teams Status: implemented @@ -6,34 +6,36 @@ Status: implemented ## 问题 -Agent Teams 的服务与工具约定仍在变化,但它需要使用真实 Session 日志、subagent 生命周期、工具、示例、快照和仓库检查。把这些包放在产品职责组会使其成为 dsh 发布系列成员,并获得与稳定包相同的发布预期。 +Agent Teams 的服务与工具约定仍在变化,但它需要使用真实 Session 日志、subagent 生命周期、工具、示例、快照和仓库检查。用户还需要直接从 npm 安装完整 Team 组合,而无需构建源码 checkout。 -没有实际包的 experimental 目录曾经让没有消费方的放置、依赖、promotion 和发布规则长期存在。Agent Teams 提供了具体消费方,但该目录需要机械强制的发布排除与依赖隔离,不能只用文档标记状态。 +把这些包移入产品职责组会移除实验性名称,并暗示稳定包 owner 已经就位。发布 `packages/experimental/` 下的所有包又会暴露无关的内部原型。发布策略需要为 Agent Teams 设置显式例外,同时保留默认私有原则。 ## 决策 -`packages/experimental/agent-team`、`packages/experimental/tool-agent-team`、`packages/experimental/agent-team-profile`、`packages/experimental/client-ui-agent-team` 与 `packages/experimental/agent-team-web-profile` 是私有 workspace 包。[实验性包规则](../../../../packages/experimental/AGENTS.md)负责其 npm 名和 promotion 重命名;本记录负责其目录归属、发布排除与依赖隔离。 +`packages/experimental/agent-team`、`packages/experimental/tool-agent-team`、`packages/experimental/agent-team-profile`、`packages/experimental/client-ui-agent-team` 与 `packages/experimental/agent-team-web-profile` 是公开 workspace 包。它们保留现有 `@deepseek-ai/dsh-experimental-*` 名称并加入 dsh 发布系列。[实验性包规则](../../../../packages/experimental/AGENTS.md)负责默认私有原则、本例外与后续 promotion。 -dsh pack 与 publish 集合以及本地 baseline 发布器均排除 `packages/experimental/` 下的所有 manifest。`release:dsh` 仍会让这些 manifest 跟随 dsh 共享版本递增,但不会创建发布 tag。workspace 约束要求每个实验性包设置 `private: true` 并省略 `publishConfig`。同一个顶层检查会拒绝发布包、发布 app 或 Python runtime 通过 `dependencies`、`optionalDependencies` 或 `peerDependencies` 依赖实验性包。实验性包可以依赖发布包和其他实验性包;测试可以通过 `devDependencies` 使用它们,示例可以显式加载它们。 +dsh pack 与 publish 集合以及本地 baseline 发布器只会纳入这五个实验性包目录。workspace 约束要求它们省略 `private`、设置 `publishConfig.access` 为 `public`,并保留实验性 npm 前缀。其他实验性包默认仍为私有且不发布。实验组外的发布包与 app 以及 Python runtime 不得通过 `dependencies`、`optionalDependencies` 或 `peerDependencies` 引用实验性包;实验性包可以依赖发布包和其他实验性包。 通用的调用方预留 continuable child 身份和精确 direct-child drain 仍属于稳定 Subagent 服务。它们负责 Subagent 身份与 Activation 生命周期,不 import 或命名 Agent Teams;实验性 Team 服务沿允许的方向消费这些能力。 -私有 Host 侧 Agent Teams profile bundle 依赖 Team 包,并在 `dsh-base` 之后应用。它会插入 Team 配置行,并禁用模型可见名称与 Team 工具重叠的全局 continuable-child control。独立的私有 Web profile 在 `dsh-web-app` 与 Host profile 之后应用;它会插入 Team UI,后者挂载 Team package 生成的 Remote contribution。两个层都保持已发布 base、CLI、Web 与 Python runtime 的依赖图不变。 +公开发布的 Host 侧 Agent Teams profile bundle 依赖 Team 包,并在 `dsh-base` 之后应用。它会插入 Team 配置行,并禁用模型可见名称与 Team 工具重叠的全局 continuable-child control。独立公开发布的 Web profile 在 `dsh-web-app` 与 Host profile 之后应用;它会插入 Team UI,后者挂载 Team package 生成的 Remote contribution。两个层都保持显式启用,不改变随附 base、CLI、Web 与 Python runtime 的依赖图。 -profile 启动会先解析所选 bundle,再修复模块 fallback。共享 fallback 保留 dsh 安装的载体专用条目:普通 Node 下使用 symlink,打包 executable 中使用 ESM proxy。仅由所选 bundle 闭包携带的缺失包会链接到当前 profile 自己的 `node_modules` 下,而 pnpm 管理的 profile 条目仍具有优先权。闭包发现从每个显式外部 bundle 的真实包目录开始;即使前一个依赖具有相同包名,也会遍历所有列出的根。后续发现会排除 dsh 所有的 profile projection,避免投影后的依赖重新进入自己的闭包。link ownership 通过规范化父路径比较,使 junction 规范化后的 target 仍可删除。因此,私有 profile 层可以携带实验性 plugin 配置行,而无需把这些 plugin 加入发布 app、要求 profile 用户直接安装传递依赖、破坏 packaged-runtime 的模块身份,或改变其他 profile 的解析结果。 +profile 安装通过自身 package manager 解析每个公开 bundle 及其依赖。通用 profile launcher 随后应用所选层,不会把它们加入任何随附 profile,也不会改变其他 profile 的解析结果。 -实验性状态只改变发布与兼容性预期。这些包仍须满足仓库的一般文档、不变式、生命周期、安全、单元测试、真实组合测试和快照要求。promotion 前必须评审公开约定、限制、测试证据、发布 payload、运行时依赖方,并由一名具名 owner 接受稳定包义务。 +对这五个包而言,实验性状态改变兼容性与支持预期,而不阻止发布。这些包仍须满足仓库的一般文档、不变式、生命周期、安全、单元测试、真实组合测试和快照要求。promotion 前仍须评审公开约定、限制、测试证据、运行时依赖方,并由一名具名 owner 接受稳定包义务。 ## 曾考虑的替代方案 -**把 Agent Teams 留在产品职责组,并标为显式启用。** 显式启用的组合可以控制模型行为,但不会阻止包发布,也不能阻止稳定包对其建立运行时依赖。 +**把 Agent Teams 移入产品职责组。** 这会移除要求保留的实验性 npm 名称,并在约定稳定前暗示已有稳定包 owner。 -**预留空的 experimental 组。** 没有实际包的目录没有 owner,也没有可供测试的发布机制。只有具体包需要这套强制处理时,该组才存在。 +**让 Agent Teams 保持私有且仅供源码 checkout 使用。** 这会保留最简单的实验性策略,但用户无法从 npm 安装完整 opt-in 组合。 + +**发布所有实验性包。** 其他原型仍只供内部使用,也没有接受公开包约定。 **把 Subagent 前置能力移入 experimental 目录。** child 身份分配与 Activation teardown 属于 Subagent owner,且不包含 Team 专用约定。移动或复制这些能力会反转依赖方向,或把同一个生命周期拆到多个包中。 ## 后果 -Agent Teams 可以使用完整仓库依赖图与质量检查,而不进入正式 tarball,也不会成为受支持的运行时依赖。在 Team 包 promotion 前,发布包不能暴露 Team,因此 CLI 实验会安装显式的私有 profile 层,而不是修改已发布 bundle。通用 profile launcher 可以接受该层,而不会让它的 plugin 依赖进入 dsh 发布闭包。 +Agent Teams 会作为 dsh 发布系列中的五个可安装 tarball 发布,同时保持包名不变,也不会在随附 profile 中启用 Team。公开可用不代表这些包稳定或默认受支持,稳定发布包也不能对其建立运行时依赖。 -孵化期间的产品职责分组不够直接。promotion 会按照实验性包规则产生路径和 npm 名改动。 +发布系列需要维护显式命名的实验性例外。promotion 仍会按照实验性包规则产生路径和 npm 名改动。 diff --git a/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml index 77dbd7cc75..ccf2075105 100644 --- a/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-05-agent-teams.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-08-05-agent-teams.md -2026-08-05-agent-teams.md: f824fc937a9e8ff55016461bd291843b23a5ff41 -2026-08-05-agent-teams.zh.md: 7f85268495d636a352a2fa74a111edf65d749893 +2026-08-05-agent-teams.md: 1fcbe9c586b78db3d5638074e51745c8c189c80c +2026-08-05-agent-teams.zh.md: 5520b9c1e7fa039be677881f4d2df504f835ae81 diff --git a/.agents/notes/implemented/feature/2026-08-05-agent-teams.md b/.agents/notes/implemented/feature/2026-08-05-agent-teams.md index f824fc937a..1fcbe9c586 100644 --- a/.agents/notes/implemented/feature/2026-08-05-agent-teams.md +++ b/.agents/notes/implemented/feature/2026-08-05-agent-teams.md @@ -10,13 +10,13 @@ The subagent seam supplies fresh/fork providers, durable child Sessions, FIFO fo All same-process Agents also share one checkout. Filesystem edit tools can reject an observed stale version, but Bash, formatters, generators, and external writers bypass that fence. Treating a teammate name or task owner as a file lock would hide rather than solve this concurrency boundary. -Agent Teams needs an explicit source-checkout composition before its public contracts are stable enough for released CLI or Web bundles. The default tool catalog and simple-task behavior must remain unchanged, while an explicitly requested Team must survive child Activation settlement and mailbox delivery races long enough for the Lead to aggregate the result before process teardown. +Agent Teams needs an explicit opt-in composition while its public contracts remain experimental. The default tool catalog and simple-task behavior must remain unchanged, while an explicitly requested Team must survive child Activation settlement and mailbox delivery races long enough for the Lead to aggregate the result before process teardown. ## Decision Every ordinary runtime root is the implicit Lead of a Team identified by that root's `SessionId`. The Team has no creation event: its Lead pseudo-row exists by identity, while durable state begins with the first member, message, or task event. A roster is flat and contains at most the configured number of immutable lowercase-kebab-case names. Each teammate is a continuable direct child with a reserved Session id; only the Lead creates or interrupts teammates. Ordinary provider-owned subagents outside the roster are not Team members, and an ordinary fork is a new root whose inherited Team records are excluded by their ancestor `TeamId`. -The implementation is split into `@deepseek-ai/dsh-experimental-agent-team`, which owns `ctx.agentTeams` and durable semantics, and `@deepseek-ai/dsh-experimental-tool-agent-team`, which owns scoped schemas and model guidance. Every Team tool declares its complete result schema and renders that value as compact JSON, so the compiler checks each `execute` against what the model is promised and no result spends tokens on indentation. Deployments mount both plugins explicitly and may disable legacy continuable controls with the same model-visible names. The explicit delegation policy permits Team creation only when the user asks for Agent Teams or teammates. Both packages are private members of `packages/experimental/`; the [experimental package decision](../architecture/2026-08-18-experimental-agent-teams-packages.md) owns release exclusion, dependency isolation, and promotion. +The implementation is split into `@deepseek-ai/dsh-experimental-agent-team`, which owns `ctx.agentTeams` and durable semantics, and `@deepseek-ai/dsh-experimental-tool-agent-team`, which owns scoped schemas and model guidance. Every Team tool declares its complete result schema and renders that value as compact JSON, so the compiler checks each `execute` against what the model is promised and no result spends tokens on indentation. Deployments mount both plugins explicitly and may disable legacy continuable controls with the same model-visible names. The explicit delegation policy permits Team creation only when the user asks for Agent Teams or teammates. Both packages are public members of `packages/experimental/`; the [experimental package decision](../architecture/2026-08-18-experimental-agent-teams-packages.md) owns publication, dependency isolation, and promotion. The Lead must wait for required work before its final answer. Process teardown remains the final lifecycle owner and drains continuation Activations; a Team task owner is durable state and is not automatically released by idle, interruption, or process exit. @@ -54,7 +54,7 @@ Worktree isolation is not a harness runtime behavior. A deployment or prompt may **Create isolated worktrees automatically.** Rejected because worktree creation, branch naming, merge policy, ignored files, build artifacts, and cleanup are deployment choices. It also changes the same-world behavior existing subagents and sandboxes expose. -**Enable Teams in the default catalog.** Rejected because scoped Team controls would shadow same-named legacy globals and unsolicited delegation would add latency and token cost to simple tasks. A private profile bundle inserts Team and disables the legacy controls without adding Team packages to shipped dependency graphs. +**Enable Teams in the default catalog.** Rejected because scoped Team controls would shadow same-named legacy globals and unsolicited delegation would add latency and token cost to simple tasks. An opt-in profile bundle inserts Team and disables the legacy controls without adding Team packages to shipped dependency graphs. **Use an in-memory board and mailbox.** Rejected because child settlement, HMR, and process interruption would lose accepted coordination state and make retries ambiguous. @@ -62,7 +62,7 @@ Worktree isolation is not a harness runtime behavior. A deployment or prompt may ## Testing -Package tests cover identity, name and authority checks, provider selection, reserved-id persistence collisions, child-before-Lead flush ordering, durable provisioning failure and pending-inbox JSONL reconciliation, concurrent target-local ordering, pending/history de-duplication, mailbox limits, post-flush notification, bounded disposal with in-flight creation and dispatch cancellation, failed-member cleanup, task CAS and DAG validation, write-scope warnings, wait cancellation/timeout, inbox-preserving interruption, ordinary-fork isolation, legacy-control shadowing, compact declared-schema result rendering, and scoped registration HMR at per-file 100% coverage. A keyless product snapshot loads the private Agent Teams profile bundle through `dsh --profile headless` and pins its complete model-visible tool list, Team policy, and durable workflow projection for two teammates, dependent tasks, peer delivery, waiting, completion, and aggregation. A CLI e2e reuses the same deterministic adapter and verifies normal process exit with persisted Team and child logs. +Package tests cover identity, name and authority checks, provider selection, reserved-id persistence collisions, child-before-Lead flush ordering, durable provisioning failure and pending-inbox JSONL reconciliation, concurrent target-local ordering, pending/history de-duplication, mailbox limits, post-flush notification, bounded disposal with in-flight creation and dispatch cancellation, failed-member cleanup, task CAS and DAG validation, write-scope warnings, wait cancellation/timeout, inbox-preserving interruption, ordinary-fork isolation, legacy-control shadowing, compact declared-schema result rendering, and scoped registration HMR at per-file 100% coverage. A keyless product snapshot loads the Agent Teams profile bundle through `dsh --profile headless` and pins its complete model-visible tool list, Team policy, and durable workflow projection for two teammates, dependent tasks, peer delivery, waiting, completion, and aggregation. A CLI e2e reuses the same deterministic adapter and verifies normal process exit with persisted Team and child logs. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md b/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md index 7f85268495..5520b9c1e7 100644 --- a/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md +++ b/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md @@ -10,13 +10,13 @@ subagent seam 已提供 fresh/fork provider、持久 child Session、FIFO foll 同进程 Agent 还共享一个 checkout。文件系统 edit 工具可以拒绝已观察到的陈旧版本,但 Bash、formatter、generator 与外部 writer 会绕过该屏障。把 teammate name 或 task owner 当作文件锁只会掩盖而不是解决该并发边界。 -在公开约定稳定到足以进入已发布 CLI 或 Web bundle 前,Agent Teams 需要显式的源码 checkout 组合。默认工具目录与简单任务行为必须保持不变;而显式请求的 Team 必须能跨越 child Activation settlement 与 mailbox 投递竞争,使 Lead 在进程 teardown 前汇总结果。 +Agent Teams 的公开约定仍处于实验阶段,因此需要显式启用的组合。默认工具目录与简单任务行为必须保持不变;而显式请求的 Team 必须能跨越 child Activation settlement 与 mailbox 投递竞争,使 Lead 在进程 teardown 前汇总结果。 ## Decision 每个普通运行时 Root 都是一个隐式 Team 的 Lead,Team id 等于该 Root 的 `SessionId`。Team 没有 creation event:Lead pseudo-row 由身份直接存在,持久状态从第一条 member、message 或 task event 开始。roster 是扁平结构,最多包含配置数量、不可变且采用小写 kebab-case 的名字。每个 teammate 都是使用预留 Session id 的 continuable 直接 child;只有 Lead 可以创建或 interrupt teammate。roster 外由 provider 管理的普通 subagent 不是 Team member;普通 fork 是新的 Root,继承的 Team 记录会因 ancestor `TeamId` 被排除。 -实现拆分为 `@deepseek-ai/dsh-experimental-agent-team` 与 `@deepseek-ai/dsh-experimental-tool-agent-team`:前者负责 `ctx.agentTeams` 和持久语义,后者负责 scoped schema 与模型指引。每个 Team 工具都声明完整的结果 schema,并把该值渲染为紧凑 JSON,因此编译器会检查每个 `execute` 是否符合对模型的承诺,也没有结果把 token 花在缩进上。部署显式挂载两个插件,并可禁用具有相同模型可见名称的旧 continuable control。显式 delegation 策略只允许在用户要求 Agent Teams 或 teammate 时创建 Team。 两个包都是 `packages/experimental/` 的私有成员;[实验性包决策](../architecture/2026-08-18-experimental-agent-teams-packages.zh.md)负责发布排除、依赖隔离与 promotion。 +实现拆分为 `@deepseek-ai/dsh-experimental-agent-team` 与 `@deepseek-ai/dsh-experimental-tool-agent-team`:前者负责 `ctx.agentTeams` 和持久语义,后者负责 scoped schema 与模型指引。每个 Team 工具都声明完整的结果 schema,并把该值渲染为紧凑 JSON,因此编译器会检查每个 `execute` 是否符合对模型的承诺,也没有结果把 token 花在缩进上。部署显式挂载两个插件,并可禁用具有相同模型可见名称的旧 continuable control。显式 delegation 策略只允许在用户要求 Agent Teams 或 teammate 时创建 Team。两个包都是 `packages/experimental/` 的公开成员;[实验性包决策](../architecture/2026-08-18-experimental-agent-teams-packages.zh.md)负责发布、依赖隔离与 promotion。 Lead 必须等待所需工作后才能给出最终答案。进程 teardown 仍是最终生命周期 owner,并会 drain continuation Activation;Team task owner 是持久状态,不会因 idle、interrupt 或进程退出自动释放。 @@ -54,7 +54,7 @@ Worktree isolation 不是 harness runtime 行为。deployment 或 prompt 可以 **自动创建隔离 worktree。** 拒绝,因为 worktree 创建、branch 命名、merge 策略、ignored file、构建产物与 cleanup 都是 deployment 选择;它也会改变既有 subagent 与 sandbox 暴露的 same-world 行为。 -**在默认工具目录中启用 Team。** 拒绝,因为 scoped Team control 会覆盖同名旧全局工具,主动 delegation 也会给简单任务增加延迟和 token 成本。私有 profile bundle 会插入 Team 并禁用旧 control,同时不向已发布依赖图添加 Team 包。 +**在默认工具目录中启用 Team。** 拒绝,因为 scoped Team control 会覆盖同名旧全局工具,主动 delegation 也会给简单任务增加延迟和 token 成本。opt-in profile bundle 会插入 Team 并禁用旧 control,同时不向随附依赖图添加 Team 包。 **使用内存 task board 与 mailbox。** 拒绝,因为 child settlement、HMR 与进程中断会丢失已接受协调状态,并让重试变得含糊。 @@ -62,7 +62,7 @@ Worktree isolation 不是 harness runtime 行为。deployment 或 prompt 可以 ## Testing -Package test 以逐文件 100% coverage 覆盖身份、名字与权限检查、provider 选择、预留 id 持久化冲突、child-before-Lead flush 顺序、持久 provisioning 失败与 pending-inbox JSONL 对账、target-local 并发顺序、pending/history 去重、mailbox 限额、flush 后 notification、取消在途创建与 dispatch 的有界 dispose、failed member cleanup、task CAS 与 DAG 校验、write-scope warning、wait cancel/timeout、保留 inbox 的 interrupt、普通 fork 隔离、旧 control shadowing、声明 schema 的紧凑结果渲染与 scoped registration HMR。一条 keyless 产品快照会通过 `dsh --profile headless` 加载私有 Agent Teams profile bundle,并为两个 teammate、依赖任务、peer 投递、等待、完成和汇总固定完整的面向模型工具列表、Team policy 与持久 workflow 投影。CLI e2e 会复用同一个确定性 adapter,并验证带持久 Team 与 child 日志的正常退出。 +Package test 以逐文件 100% coverage 覆盖身份、名字与权限检查、provider 选择、预留 id 持久化冲突、child-before-Lead flush 顺序、持久 provisioning 失败与 pending-inbox JSONL 对账、target-local 并发顺序、pending/history 去重、mailbox 限额、flush 后 notification、取消在途创建与 dispatch 的有界 dispose、failed member cleanup、task CAS 与 DAG 校验、write-scope warning、wait cancel/timeout、保留 inbox 的 interrupt、普通 fork 隔离、旧 control shadowing、声明 schema 的紧凑结果渲染与 scoped registration HMR。一条 keyless 产品快照会通过 `dsh --profile headless` 加载 Agent Teams profile bundle,并为两个 teammate、依赖任务、peer 投递、等待、完成和汇总固定完整的面向模型工具列表、Team policy 与持久 workflow 投影。CLI e2e 会复用同一个确定性 adapter,并验证带持久 Team 与 child 日志的正常退出。 ## Consequences diff --git a/AGENTS.md b/AGENTS.md index 83ea425984..b5b8d0295c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,7 +45,7 @@ packages/ @deepseek-ai/dsh- workspaces at packages/// interaction/ approval/interaction capabilities, permission, commands, ask-user boot/ shared profile/application boot glue sdk/ JSON-RPC protocol + TypeScript client/server - experimental/ private prototypes excluded from official releases + experimental/ pre-stable prototypes; private by default with explicit public exceptions support/ dev/test infrastructure util/ zero-dependency utilities python/ Python SDK/runtime (see python/README.md) diff --git a/apps/web/tests/agent-team-panel.e2e.ts b/apps/web/tests/agent-team-panel.e2e.ts index 9ebea7cdd0..b3192c3655 100644 --- a/apps/web/tests/agent-team-panel.e2e.ts +++ b/apps/web/tests/agent-team-panel.e2e.ts @@ -1,4 +1,4 @@ -// Keyless assembled-browser coverage for the private Agent Teams Web profiles +// Keyless assembled-browser coverage for the opt-in Agent Teams Web profiles // over the real Host Typert Remote flow. import { fileURLToPath } from 'node:url' import { join } from 'node:path' diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 4bc734aa4b..2d9b118753 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -296,8 +296,8 @@ export interface LaunchOptions { */ extraOverlayPath?: string /** - * Additional source-checkout package manifests whose dependency closures - * supply private profile layers named by {@link extraOverlayPath}. + * Additional package manifests whose dependency closures supply experimental + * profile layers named by {@link extraOverlayPath}. */ extraInstallAnchors?: string[] /** diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index d9d1bd54f3..d591a9fa36 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/architecture.md -architecture.md: fed98f006471f86f02c43bcb0c6ea4dfe7850da8 -architecture.zh.md: b1ec7ddaf0278a14cd7c18d6cc272fc59c5c3781 +architecture.md: 688341582044e72e8548c8e6b1535450793ddce4 +architecture.zh.md: 6817986015a2aa90d6fcb094da3e972ccfe08483 diff --git a/docs/architecture.md b/docs/architecture.md index fed98f0064..6883415820 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -128,7 +128,7 @@ A **seam** is a swappable capability with three roles: a **Service Definition** Seams are why one provider swap changes the whole product. Filesystem and subprocess providers share one execution world, so pointing them at a remote sandbox moves Bash, PTY, and LSP with them, with no provider forks. [Subagent providers](subsystems/subagent.md) vary just as widely behind one interface, from a fresh child agent to a delegated turn in another product. -[Experimental Agent Teams](subsystems/agent-team.md) is a private opt-in coordination seam on `ctx.agentTeams`, with a durable roster, task board, and mailbox layered over continuable subagents. +[Experimental Agent Teams](subsystems/agent-team.md) is a published opt-in coordination seam on `ctx.agentTeams`, with a durable roster, task board, and mailbox layered over continuable subagents. ## Where new behavior goes diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index b1ec7ddaf0..6817986015 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -132,7 +132,7 @@ Session 消费方只了解当前逻辑格式。仅 header 的 `stat` 与 `list` seam 正是替换一个提供方就能改变整个产品的原因。文件系统与进程提供方共享同一个执行世界,因此把它们指向远程沙箱,也就把 Bash、PTY 和 LSP 一并搬了过去,无需提供方专用 fork。[subagent 提供方](subsystems/subagent.zh.md)在同一个接口之后同样千差万别,从新建一个子 agent,到把一个轮次委派给另一个产品。 -[实验性 Agent Teams](subsystems/agent-team.zh.md) 是 `ctx.agentTeams` 上的私有显式启用协作 seam,在可继续 subagent 之上提供持久 roster、任务板和 mailbox。 +[实验性 Agent Teams](subsystems/agent-team.zh.md) 是 `ctx.agentTeams` 上公开发布、显式启用的协作 seam,在可继续 subagent 之上提供持久 roster、任务板和 mailbox。 ## 新行为的归属位置 diff --git a/packages/experimental/AGENTS.md b/packages/experimental/AGENTS.md index bfb75a7e72..289121f108 100644 --- a/packages/experimental/AGENTS.md +++ b/packages/experimental/AGENTS.md @@ -3,7 +3,7 @@ These rules supplement the [package rules](../AGENTS.md). The [experimental Agent Teams package decision](../../.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.md) owns the rationale. - A package belongs here only when its complete public contract is experimental or internal-only. An experimental option inside a release package stays with its owning product role. -- Every package here uses the `@deepseek-ai/dsh-experimental-*` npm prefix, sets `private: true`, and omits `publishConfig`; the workspace constraints gate enforces these declarations and the dsh release family excludes this directory. -- Release packages and apps must not name packages here in `dependencies`, `optionalDependencies`, or `peerDependencies`. Experimental packages may depend on release packages and each other. Tests may use experimental packages through `devDependencies`; examples may load them explicitly. +- Every package here uses the `@deepseek-ai/dsh-experimental-*` npm prefix. Packages are private by default and omit `publishConfig`; the five Agent Teams packages are explicit public exceptions that keep their experimental names, set `publishConfig.access: public`, and join the dsh release family. +- Release packages and apps outside this group must not name experimental packages in `dependencies`, `optionalDependencies`, or `peerDependencies`. Experimental packages may depend on release packages and each other. Tests may use experimental packages through `devDependencies`; examples may load them explicitly. - Experimental status does not relax engineering, security, documentation, lifecycle, testing, invariant, or snapshot requirements. -- Promotion moves a package to its product-role group and removes `experimental-` from its npm name. Update every import and configuration row atomically, then review its public contract, limitations, test evidence, release payload, runtime dependents, and named stable owner. +- Publishing an explicit exception does not promote it or add a stability promise. Promotion moves a package to its product-role group and removes `experimental-` from its npm name; update every import and configuration row atomically, then review its public contract, limitations, test evidence, release payload, runtime dependents, and named stable owner. diff --git a/packages/experimental/README.i18n.yaml b/packages/experimental/README.i18n.yaml index 2a253523ab..374d205f08 100644 --- a/packages/experimental/README.i18n.yaml +++ b/packages/experimental/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/experimental/README.md -README.md: 1a5f60c84327f47d9edea7d6bccb87d710a766f7 -README.zh.md: b97fd66854bcf8ad435348ddcba605f44eeffe52 +README.md: c4a95f903b8025d4823276bfd5811e94e6a2c419 +README.zh.md: 2cce1bc6e9caf1ce984d9eeb07bf4d543dc15a44 diff --git a/packages/experimental/README.md b/packages/experimental/README.md index 1a5f60c843..c4a95f903b 100644 --- a/packages/experimental/README.md +++ b/packages/experimental/README.md @@ -1,5 +1,5 @@ --- -description: "The experimental group map: private prototypes and internal-only plugins excluded from official releases, for users and maintainers navigating the group." +description: "The experimental group map: pre-stable prototypes that are private by default, with explicit public Agent Teams packages." kind: "package-group" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -The experimental group contains prototype capabilities that are not part of any official release: they run on the real harness, but their contracts can change and they carry no support promise. The group holds Agent Teams, the cross-realm Inspector, the CPython subprocess backend for the code-execution seam, and the browser-worker runtime and image packer used by preview deployments. Use these packages to try an unreleased capability; they carry no stability promise, and released products must not depend on them. +The experimental group contains prototype capabilities whose contracts can change and carry no support promise. Packages are private by default; the five Agent Teams packages are published opt-in exceptions under their existing `@deepseek-ai/dsh-experimental-*` names. The group also holds the private cross-realm Inspector, CPython subprocess backend, and browser-worker preview packages. Released products outside this group must not depend on experimental packages. ## Table of Contents @@ -24,9 +24,9 @@ The experimental group contains prototype capabilities that are not part of any | Package | Role | ctx key | |---|---|---| -| [`agent-team-profile`](agent-team-profile/README.md) | Explicit source-checkout profile layer for Agent Teams | — | +| [`agent-team-profile`](agent-team-profile/README.md) | Published opt-in profile layer for Agent Teams | — | | [`agent-team`](agent-team/README.md) | Named teammates with durable messages and a shared task board | `ctx.agentTeams` | -| [`agent-team-web-profile`](agent-team-web-profile/README.md) | Explicit source-checkout Web layer for Agent Teams | — | +| [`agent-team-web-profile`](agent-team-web-profile/README.md) | Published opt-in Web layer for Agent Teams | — | | [`client-ui-agent-team`](client-ui-agent-team/README.md) | Team roster, task board, and teammate navigation for Web | — | | [`code-runtime-python`](code-runtime-python/README.md) | CPython subprocess backend for the code-execution seam | `ctx.codeRuntime` | | [`inspector`](inspector/README.md) | Cross-realm CDP hub for Host debugging, Client Runtime inspection, network capture, and Cordis trees | `ctx.inspector` | @@ -39,7 +39,7 @@ The experimental group contains prototype capabilities that are not part of any ## Related documentation -- [Experimental package decision](../../.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.md) — placement, release exclusion, and dependency isolation. +- [Experimental package decision](../../.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.md) — private defaults, public Agent Teams exceptions, and dependency isolation. - [Agent Teams subsystem](../../docs/subsystems/agent-team.md) — durable Team types and the `ctx.agentTeams` service API. - [Experimental subtree rules](AGENTS.md) — what experimental status does and does not relax. diff --git a/packages/experimental/README.zh.md b/packages/experimental/README.zh.md index b97fd66854..2cce1bc6e9 100644 --- a/packages/experimental/README.zh.md +++ b/packages/experimental/README.zh.md @@ -1,5 +1,5 @@ --- -description: "实验组地图:不进入正式发布的私有原型与内部专用插件,供浏览本组的用户与维护者阅读。" +description: "实验组地图:默认私有的预稳定原型,以及显式公开发布的 Agent Teams 包。" kind: "package-group" --- @@ -9,7 +9,7 @@ kind: "package-group" ## 概述 -实验组包含不属于任何正式发布的原型能力:它们运行在真实 harness 上,但约定可能变更,也不提供支持承诺。本组包含 Agent Teams、跨 realm Inspector、代码执行 seam 的 CPython 子进程后端,以及预览部署使用的浏览器 worker 运行时与镜像打包器。用这些包来尝试未发布的能力;它们没有稳定性承诺,已发布产品不得依赖它们。 +实验组包含约定可能变更且不提供支持承诺的原型能力。包默认私有;五个 Agent Teams 包是显式公开发布的例外,并保留现有 `@deepseek-ai/dsh-experimental-*` 名称。本组还包含私有的跨 realm Inspector、CPython 子进程后端与浏览器 worker 预览包。组外已发布产品不得依赖实验性包。 ## 目录 @@ -24,9 +24,9 @@ kind: "package-group" | 包 | 职责 | ctx 键 | |---|---|---| -| [`agent-team-profile`](agent-team-profile/README.zh.md) | Agent Teams 的显式源码 checkout profile 层 | — | +| [`agent-team-profile`](agent-team-profile/README.zh.md) | Agent Teams 的公开 opt-in profile 层 | — | | [`agent-team`](agent-team/README.zh.md) | 具名 teammate,成员之间持久消息与共享任务板 | `ctx.agentTeams` | -| [`agent-team-web-profile`](agent-team-web-profile/README.zh.md) | Agent Teams 的显式源码 checkout Web 层 | — | +| [`agent-team-web-profile`](agent-team-web-profile/README.zh.md) | Agent Teams 的公开 opt-in Web 层 | — | | [`client-ui-agent-team`](client-ui-agent-team/README.zh.md) | Web Team roster、任务板与 teammate 导航 | — | | [`code-runtime-python`](code-runtime-python/README.zh.md) | 代码执行 seam 的 CPython 子进程后端 | `ctx.codeRuntime` | | [`inspector`](inspector/README.zh.md) | 用于 Host 调试、Client Runtime 检查、网络采集与 Cordis 树的跨 realm CDP hub | `ctx.inspector` | @@ -39,7 +39,7 @@ kind: "package-group" ## 相关文档 -- [实验包决策](../../.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.zh.md)——位置、发布排除与依赖隔离。 +- [实验包决策](../../.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.zh.md)——默认私有、Agent Teams 公开例外与依赖隔离。 - [Agent Teams 子系统](../../docs/subsystems/agent-team.zh.md)——持久 Team 类型与 `ctx.agentTeams` 服务 API。 - [实验子树规则](AGENTS.md)——实验状态放宽了什么、不放宽什么。 diff --git a/packages/experimental/agent-team-profile/README.i18n.yaml b/packages/experimental/agent-team-profile/README.i18n.yaml index 75b358cecc..7750330eee 100644 --- a/packages/experimental/agent-team-profile/README.i18n.yaml +++ b/packages/experimental/agent-team-profile/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/experimental/agent-team-profile/README.md -README.md: 3a3ce9ba9d1298dc525533c2fddcb968f6823dae -README.zh.md: e33e47f99ea018d5a8a58156d41d23e5a324cf94 +README.md: 16bf341224b590b799c9bc2f17ddd4815346598a +README.zh.md: 1e987933207a004abbfc2b96ac2efcfba6531ff5 diff --git a/packages/experimental/agent-team-profile/README.md b/packages/experimental/agent-team-profile/README.md index 3a3ce9ba9d..16bf341224 100644 --- a/packages/experimental/agent-team-profile/README.md +++ b/packages/experimental/agent-team-profile/README.md @@ -1,5 +1,5 @@ --- -description: "Private Agent Teams profile layer over dsh-base, for source-checkout users who want Team-scoped coordination tools while retaining one-shot delegation." +description: "Published experimental Agent Teams profile layer over dsh-base with Team-scoped coordination tools and one-shot delegation." kind: "package-bundle" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-experimental-agent-team-profile` is a private profile layer that enables [Agent Teams](../agent-team/README.md) over `@deepseek-ai/dsh-base`. Its patch inserts the Team domain and Team-scoped tools, disables the overlapping global continuable-child controls, and keeps the ordinary fresh and fork delegation tools as one-shot operations. Add it explicitly to an initialized source-checkout profile; official releases exclude this package. +`dsh-experimental-agent-team-profile` is a published experimental profile layer that enables [Agent Teams](../agent-team/README.md) over `@deepseek-ai/dsh-base`. Its patch inserts the Team domain and Team-scoped tools, disables the overlapping global continuable-child controls, and keeps the ordinary fresh and fork delegation tools as one-shot operations. Add it explicitly to an initialized profile; no shipped profile enables it by default. ## Table of Contents @@ -27,11 +27,11 @@ English | [中文](README.zh.md) ### Install into a profile -From this repository checkout, add the package to an initialized profile, then run a task that asks the Lead to delegate work: +Add the package to an initialized profile, then run a task that asks the Lead to delegate work: ```sh -pnpm dsh plugin --profile headless add ./packages/experimental/agent-team-profile -pnpm dsh --profile headless "Use Agent Teams to split this task between two teammates, wait, and summarize." +dsh plugin --profile headless add @deepseek-ai/dsh-experimental-agent-team-profile +dsh --profile headless "Use Agent Teams to split this task between two teammates, wait, and summarize." ``` The profile must already contain `@deepseek-ai/dsh-base`, whose Subagent services and provider rows this layer consumes. Removing the package with `dsh plugin --profile remove @deepseek-ai/dsh-experimental-agent-team-profile` removes the bundle from the profile's ordered layer list. @@ -63,7 +63,7 @@ The package's runtime content is [`cordis.patch.yml`](cordis.patch.yml). Applied ## Further Exploration -- [Experimental packages](../README.md) — incubation status and release exclusion. +- [Experimental packages](../README.md) — incubation status and publication policy. - [Agent Teams service](../agent-team/README.md) — durable roster, messaging, and task-board behavior. - [Agent Teams tools](../tool-agent-team/README.md) — the Team-scoped model tool surface. - [Base bundle](../../bundle/base/README.md) — the profile layer this patch extends. @@ -91,7 +91,7 @@ The bundle's composition is prefix-stable while its patch, Team identity, and co -- **Source-checkout only** — this private package is not present in official npm, CLI, Web, or Python release payloads. +- **Opt-in only** — the package is public, but no shipped CLI, Web, SDK, ACP, or Python profile enables it. - **Shared checkout** — every teammate observes the same working directory; this bundle adds no worktree isolation or filesystem locking. - **Base profile required** — the patch depends on row ids and Subagent providers supplied by `dsh-base`; it is not a standalone profile. diff --git a/packages/experimental/agent-team-profile/README.zh.md b/packages/experimental/agent-team-profile/README.zh.md index e33e47f99e..1e98793320 100644 --- a/packages/experimental/agent-team-profile/README.zh.md +++ b/packages/experimental/agent-team-profile/README.zh.md @@ -1,5 +1,5 @@ --- -description: "叠加在 dsh-base 上的私有 Agent Teams profile 层,供源码 checkout 用户使用 Team-scoped 协作工具,同时保留一次性 delegation。" +description: "叠加在 dsh-base 上公开发布的实验性 Agent Teams profile 层,提供 Team-scoped 协作工具并保留一次性 delegation。" kind: "package-bundle" --- @@ -9,7 +9,7 @@ kind: "package-bundle" ## 概述 -`dsh-experimental-agent-team-profile` 是在 `@deepseek-ai/dsh-base` 之上启用 [Agent Teams](../agent-team/README.zh.md) 的私有 profile 层。它的 patch 会插入 Team domain 与 Team-scoped 工具、禁用名称重叠的全局 continuable-child control,并保留普通的一次性 fresh 与 fork delegation 工具。必须将本包显式添加到已初始化的源码 checkout profile;正式发布会排除本包。 +`dsh-experimental-agent-team-profile` 是在 `@deepseek-ai/dsh-base` 之上启用 [Agent Teams](../agent-team/README.zh.md) 的公开实验性 profile 层。它的 patch 会插入 Team domain 与 Team-scoped 工具、禁用名称重叠的全局 continuable-child control,并保留普通的一次性 fresh 与 fork delegation 工具。必须将本包显式添加到已初始化的 profile;随附 profile 默认都不会启用它。 ## 目录 @@ -27,11 +27,11 @@ kind: "package-bundle" ### 安装到 profile -在本仓库 checkout 中,将本包添加到已初始化的 profile,然后运行一个要求 Lead 委派工作的任务: +将本包添加到已初始化的 profile,然后运行一个要求 Lead 委派工作的任务: ```sh -pnpm dsh plugin --profile headless add ./packages/experimental/agent-team-profile -pnpm dsh --profile headless "Use Agent Teams to split this task between two teammates, wait, and summarize." +dsh plugin --profile headless add @deepseek-ai/dsh-experimental-agent-team-profile +dsh --profile headless "Use Agent Teams to split this task between two teammates, wait, and summarize." ``` profile 必须已经包含 `@deepseek-ai/dsh-base`,本层会使用其中的 Subagent service 与 provider 配置行。执行 `dsh plugin --profile remove @deepseek-ai/dsh-experimental-agent-team-profile` 移除本包时,bundle 也会从 profile 的有序层列表中移除。 @@ -63,7 +63,7 @@ profile 必须已经包含 `@deepseek-ai/dsh-base`,本层会使用其中的 Su ## 进一步探索 -- [实验性包](../README.zh.md)——孵化状态与发布排除规则。 +- [实验性包](../README.zh.md)——孵化状态与发布规则。 - [Agent Teams service](../agent-team/README.zh.md)——持久 roster、消息与任务板行为。 - [Agent Teams 工具](../tool-agent-team/README.zh.md)——Team-scoped 模型工具表层。 - [Base bundle](../../bundle/base/README.zh.md)——本 patch 扩展的 profile 层。 @@ -91,7 +91,7 @@ Team 策略与 schema 由 [`@deepseek-ai/dsh-experimental-tool-agent-team`](../t -- **仅限源码 checkout**——正式 npm、CLI、Web 与 Python 发布产物都不包含这个私有包。 +- **仅显式启用**——本包公开发布,但随附 CLI、Web、SDK、ACP 与 Python profile 都不会启用它。 - **共享 checkout**——所有 teammate 都观察同一个工作目录;本 bundle 不提供 worktree 隔离或文件系统锁。 - **需要 base profile**——本 patch 依赖 `dsh-base` 提供的配置行 id 与 Subagent provider;它不是独立 profile。 diff --git a/packages/experimental/agent-team-profile/cordis.patch.yml b/packages/experimental/agent-team-profile/cordis.patch.yml index f113d77917..c5e3730d1e 100644 --- a/packages/experimental/agent-team-profile/cordis.patch.yml +++ b/packages/experimental/agent-team-profile/cordis.patch.yml @@ -1,4 +1,4 @@ -# Private Agent Teams profile layer. Apply after dsh-base so these replacements +# Experimental Agent Teams profile layer. Apply after dsh-base so these replacements # remove the global continuable-child controls before the scoped Team tools # register the overlapping list_agents, send_message, and interrupt_agent names. diff --git a/packages/experimental/agent-team-profile/package.json b/packages/experimental/agent-team-profile/package.json index 3f1e7b181b..699ebc29ec 100644 --- a/packages/experimental/agent-team-profile/package.json +++ b/packages/experimental/agent-team-profile/package.json @@ -1,8 +1,10 @@ { "name": "@deepseek-ai/dsh-experimental-agent-team-profile", - "description": "Private profile bundle enabling Agent Teams over dsh-base", + "description": "Experimental profile bundle enabling Agent Teams over dsh-base", "version": "0.1.5-alpha.1", - "private": true, + "publishConfig": { + "access": "public" + }, "repository": { "type": "git", "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", diff --git a/packages/experimental/agent-team-profile/src/index.ts b/packages/experimental/agent-team-profile/src/index.ts index 405a889b5e..25d4e92224 100644 --- a/packages/experimental/agent-team-profile/src/index.ts +++ b/packages/experimental/agent-team-profile/src/index.ts @@ -1,5 +1,5 @@ /** - * @deepseek-ai/dsh-experimental-agent-team-profile — private Agent Teams profile bundle. + * @deepseek-ai/dsh-experimental-agent-team-profile — experimental Agent Teams profile bundle. * The package's runtime content is its `dsh.bundle.patch` document; this * module exports no runtime API. * @module @deepseek-ai/dsh-experimental-agent-team-profile diff --git a/packages/experimental/agent-team-profile/tests/profile.spec.ts b/packages/experimental/agent-team-profile/tests/profile.spec.ts index d654be80ae..133f7a6d99 100644 --- a/packages/experimental/agent-team-profile/tests/profile.spec.ts +++ b/packages/experimental/agent-team-profile/tests/profile.spec.ts @@ -8,16 +8,16 @@ import * as yaml from 'js-yaml' import { entryListSchema } from '@deepseek-ai/cordis-plugin-include' describe('Agent Teams profile bundle', () => { - it('declares a private parseable layer with Team-owned controls', () => { + it('declares a public parseable layer with Team-owned controls', () => { const root = fileURLToPath(new URL('..', import.meta.url)) const manifest = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')) as { private?: boolean - publishConfig?: unknown + publishConfig?: { access?: string } dependencies?: Record dsh?: { bundle?: { patch?: string } } } - expect(manifest.private).toBe(true) - expect(manifest.publishConfig).toBeUndefined() + expect(manifest.private).toBeUndefined() + expect(manifest.publishConfig?.access).toBe('public') expect(manifest.dsh?.bundle?.patch).toBe('./cordis.patch.yml') expect(manifest.dependencies).toMatchObject({ '@deepseek-ai/dsh-experimental-agent-team': 'workspace:^', diff --git a/packages/experimental/agent-team-web-profile/README.i18n.yaml b/packages/experimental/agent-team-web-profile/README.i18n.yaml index dcf3476976..b196a0de32 100644 --- a/packages/experimental/agent-team-web-profile/README.i18n.yaml +++ b/packages/experimental/agent-team-web-profile/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/experimental/agent-team-web-profile/README.md -README.md: 41a6cc3ae09dc1dd0ac7d30b6a888b3337044ae5 -README.zh.md: 5620833eaf68c3ab891ff16df40fa38deb55ff48 +README.md: 16d71b5b30f5b18cb35e3eb289757d48a5271385 +README.zh.md: 9ffd5e83bc200c52c5ef4364eba131bac3bcd7ac diff --git a/packages/experimental/agent-team-web-profile/README.md b/packages/experimental/agent-team-web-profile/README.md index 41a6cc3ae0..16d71b5b30 100644 --- a/packages/experimental/agent-team-web-profile/README.md +++ b/packages/experimental/agent-team-web-profile/README.md @@ -1,5 +1,5 @@ --- -description: "Add the experimental Agent Teams panel to a source-checkout Web profile after the Host Team layer." +description: "Add the published experimental Agent Teams panel to a Web profile after the Host Team layer." kind: "package-bundle" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-experimental-agent-team-web-profile` is the private Web layer for [Agent Teams](../agent-team/README.md). Add it after `@deepseek-ai/dsh-web-app` and [`@deepseek-ai/dsh-experimental-agent-team-profile`](../agent-team-profile/README.md) to show the Team roster, task board, and teammate navigation in the browser. Removing either experimental layer leaves the stable base and Web composition unchanged. Official releases exclude this package, so it is available only from a source checkout. +`dsh-experimental-agent-team-web-profile` is the published experimental Web layer for [Agent Teams](../agent-team/README.md). Add it after `@deepseek-ai/dsh-web-app` and [`@deepseek-ai/dsh-experimental-agent-team-profile`](../agent-team-profile/README.md) to show the Team roster, task board, and teammate navigation in the browser. Removing either experimental layer leaves the stable base and Web composition unchanged. No shipped Web profile enables it by default. ## Table of Contents @@ -27,11 +27,11 @@ English | [中文](README.zh.md) ### Install into a profile -From this repository checkout, add the Host and Web Agent Teams layers to an initialized `web` profile in this order: +Add the Host and Web Agent Teams layers to an initialized `web` profile in this order: ```sh -pnpm dsh plugin --profile web add ./packages/experimental/agent-team-profile -pnpm dsh plugin --profile web add ./packages/experimental/agent-team-web-profile +dsh plugin --profile web add @deepseek-ai/dsh-experimental-agent-team-profile +dsh plugin --profile web add @deepseek-ai/dsh-experimental-agent-team-web-profile ``` The first command supplies the Team domain, generated Remote methods, and model tools. The second command activates this package's declared patch and its browser presentation. Removing the package with `dsh plugin --profile web remove @deepseek-ai/dsh-experimental-agent-team-web-profile` removes the Web layer from the profile's ordered bundle list. @@ -63,7 +63,7 @@ The package's runtime content is [`cordis.patch.yml`](cordis.patch.yml). Applied ## Further Exploration -- [Experimental packages](../README.md) — incubation status and release exclusion. +- [Experimental packages](../README.md) — incubation status and publication policy. - [Agent Teams Host profile](../agent-team-profile/README.md) — the required domain, Remote, and model-tool layer. - [Agent Teams browser UI](../client-ui-agent-team/README.md) — roster, task-board, and teammate-navigation behavior. - [Web bundle](../../bundle/web-app/README.md) — the stable browser layer this patch extends. @@ -85,7 +85,7 @@ This Web bundle adds no model request content; the Host-side Team tools own prom - **Ordered composition** — `dsh-base`, `dsh-web-app`, `dsh-experimental-agent-team-profile`, and this package must remain in that order. - **Preset-scoped legacy controls** — stable Web presets still mount continuable Subagent controls inside the preset scope. Top-level Host profile overrides do not replace those scoped registrations, so the Team roster and legacy child controls can both appear until Web has a Team-aware preset. The [Web Agent Teams decision](../../../.agents/notes/archived/feature/2026-08-06-agent-teams-web.md) records this deferred composition work. -- **Source-checkout only** — official CLI, Web, npm, and Python release payloads exclude this private package. +- **Opt-in only** — the package is public, but no shipped Web profile enables either Agent Teams layer. ### Dev Note diff --git a/packages/experimental/agent-team-web-profile/README.zh.md b/packages/experimental/agent-team-web-profile/README.zh.md index 5620833eaf..9ffd5e83bc 100644 --- a/packages/experimental/agent-team-web-profile/README.zh.md +++ b/packages/experimental/agent-team-web-profile/README.zh.md @@ -1,5 +1,5 @@ --- -description: "在 Host Team 层之后,为源码 checkout 的 Web profile 添加实验性 Agent Teams 面板。" +description: "在 Host Team 层之后,为 Web profile 添加公开发布的实验性 Agent Teams 面板。" kind: "package-bundle" --- @@ -9,7 +9,7 @@ kind: "package-bundle" ## 概述 -`dsh-experimental-agent-team-web-profile` 是 [Agent Teams](../agent-team/README.zh.md) 的私有 Web 层。把它放在 `@deepseek-ai/dsh-web-app` 与 [`@deepseek-ai/dsh-experimental-agent-team-profile`](../agent-team-profile/README.zh.md) 之后,即可在浏览器中显示 Team roster、任务板与 teammate 导航。移除任一实验层都会让稳定的 base 与 Web composition 保持不变。正式发布会排除本包,因此只能从源码 checkout 使用。 +`dsh-experimental-agent-team-web-profile` 是 [Agent Teams](../agent-team/README.zh.md) 公开发布的实验性 Web 层。把它放在 `@deepseek-ai/dsh-web-app` 与 [`@deepseek-ai/dsh-experimental-agent-team-profile`](../agent-team-profile/README.zh.md) 之后,即可在浏览器中显示 Team roster、任务板与 teammate 导航。移除任一实验层都会让稳定的 base 与 Web composition 保持不变。随附 Web profile 默认不会启用它。 ## 目录 @@ -27,11 +27,11 @@ kind: "package-bundle" ### 安装到 profile -在本仓库 checkout 中,按以下顺序把 Host 与 Web Agent Teams 层添加到已初始化的 `web` profile: +按以下顺序把 Host 与 Web Agent Teams 层添加到已初始化的 `web` profile: ```sh -pnpm dsh plugin --profile web add ./packages/experimental/agent-team-profile -pnpm dsh plugin --profile web add ./packages/experimental/agent-team-web-profile +dsh plugin --profile web add @deepseek-ai/dsh-experimental-agent-team-profile +dsh plugin --profile web add @deepseek-ai/dsh-experimental-agent-team-web-profile ``` 第一条命令提供 Team domain、生成的 Remote 方法与模型工具。第二条命令激活本包声明的 patch 及其浏览器 presentation。执行 `dsh plugin --profile web remove @deepseek-ai/dsh-experimental-agent-team-web-profile` 移除本包时,Web 层也会从 profile 的有序 bundle 列表中移除。 @@ -63,7 +63,7 @@ pnpm dsh plugin --profile web add ./packages/experimental/agent-team-web-profile ## 进一步探索 -- [实验性包](../README.zh.md)——孵化状态与发布排除规则。 +- [实验性包](../README.zh.md)——孵化状态与发布规则。 - [Agent Teams Host profile](../agent-team-profile/README.zh.md)——所需的 domain、Remote 与模型工具层。 - [Agent Teams 浏览器 UI](../client-ui-agent-team/README.zh.md)——roster、任务板与 teammate 导航行为。 - [Web bundle](../../bundle/web-app/README.zh.md)——本 patch 扩展的稳定浏览器层。 @@ -85,7 +85,7 @@ pnpm dsh plugin --profile web add ./packages/experimental/agent-team-web-profile - **有序组合**——`dsh-base`、`dsh-web-app`、`dsh-experimental-agent-team-profile` 与本包必须保持这个顺序。 - **Preset-scoped 旧控制项**——稳定 Web preset 仍会在 preset scope 内挂载 continuable Subagent 控制项。顶层 Host profile override 不会替换这些 scoped registration,因此在 Web 获得 Team-aware preset 前,Team roster 与旧 child 控制项可能同时出现。[Web Agent Teams 决策](../../../.agents/notes/archived/feature/2026-08-06-agent-teams-web.md)记录了这项暂缓的 composition 工作。 -- **仅限源码 checkout**——正式 CLI、Web、npm 与 Python 发布产物都不包含这个私有包。 +- **仅显式启用**——本包公开发布,但随附 Web profile 默认不会启用任何 Agent Teams 层。 ### 开发备注 diff --git a/packages/experimental/agent-team-web-profile/cordis.patch.yml b/packages/experimental/agent-team-web-profile/cordis.patch.yml index 19323fb559..e9cffef438 100644 --- a/packages/experimental/agent-team-web-profile/cordis.patch.yml +++ b/packages/experimental/agent-team-web-profile/cordis.patch.yml @@ -1,4 +1,4 @@ -# Private Agent Teams Web layer. Apply after dsh-web-app and the host-side +# Experimental Agent Teams Web layer. Apply after dsh-web-app and the host-side # dsh-agent-team-profile so the browser mounts only when the Team service is present. - insert: diff --git a/packages/experimental/agent-team-web-profile/package.json b/packages/experimental/agent-team-web-profile/package.json index e83066976a..4ca780b18c 100644 --- a/packages/experimental/agent-team-web-profile/package.json +++ b/packages/experimental/agent-team-web-profile/package.json @@ -1,8 +1,10 @@ { "name": "@deepseek-ai/dsh-experimental-agent-team-web-profile", - "description": "Private Web profile layer for Agent Teams Remote and UI plugins", + "description": "Experimental Web profile layer for Agent Teams Remote and UI plugins", "version": "0.1.5-alpha.1", - "private": true, + "publishConfig": { + "access": "public" + }, "repository": { "type": "git", "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", diff --git a/packages/experimental/agent-team-web-profile/src/index.ts b/packages/experimental/agent-team-web-profile/src/index.ts index 2ca6d6012f..726f066041 100644 --- a/packages/experimental/agent-team-web-profile/src/index.ts +++ b/packages/experimental/agent-team-web-profile/src/index.ts @@ -1,3 +1,3 @@ -/** Private Web profile layer for the Agent Teams Client plugins. */ +/** Experimental Web profile layer for the Agent Teams Client plugins. */ export {} diff --git a/packages/experimental/agent-team-web-profile/tests/profile.spec.ts b/packages/experimental/agent-team-web-profile/tests/profile.spec.ts index 73097a826f..21fde2cab5 100644 --- a/packages/experimental/agent-team-web-profile/tests/profile.spec.ts +++ b/packages/experimental/agent-team-web-profile/tests/profile.spec.ts @@ -8,16 +8,16 @@ import * as yaml from 'js-yaml' import { entryListSchema } from '@deepseek-ai/cordis-plugin-include' describe('Agent Teams Web profile bundle', () => { - it('declares a private parseable layer containing the Team UI', () => { + it('declares a public parseable layer containing the Team UI', () => { const root = fileURLToPath(new URL('..', import.meta.url)) const manifest = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')) as { private?: boolean - publishConfig?: unknown + publishConfig?: { access?: string } dependencies?: Record dsh?: { bundle?: { patch?: string } } } - expect(manifest.private).toBe(true) - expect(manifest.publishConfig).toBeUndefined() + expect(manifest.private).toBeUndefined() + expect(manifest.publishConfig?.access).toBe('public') expect(manifest.dsh?.bundle?.patch).toBe('./cordis.patch.yml') expect(manifest.dependencies).toEqual({ '@deepseek-ai/dsh-experimental-client-ui-agent-team': 'workspace:^', diff --git a/packages/experimental/agent-team/README.i18n.yaml b/packages/experimental/agent-team/README.i18n.yaml index 5dda62d955..6273f9b916 100644 --- a/packages/experimental/agent-team/README.i18n.yaml +++ b/packages/experimental/agent-team/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/experimental/agent-team/README.md -README.md: 1f9f7e98fdd2d8c32c6da2d514ff763367d6eb65 -README.zh.md: d6b57d35b6305c94fad966e3c9b3e20f6a3e81f5 +README.md: 3aa5f8606c76111dc2a85ce505f7989b15cf3b78 +README.zh.md: 342e3be4f09c7987dc0770ad11dffc8fb5278f39 diff --git a/packages/experimental/agent-team/README.md b/packages/experimental/agent-team/README.md index 1f9f7e98fd..3aa5f8606c 100644 --- a/packages/experimental/agent-team/README.md +++ b/packages/experimental/agent-team/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-experimental-agent-team` turns one coding session into a small working team: the session's agent becomes the Lead, creates named teammates for delegated work, exchanges durable messages with them, and tracks shared tasks on a common board. Messages and task state survive crashes, reloads, and interruptions, so a teammate that was offline receives its queued messages when it resumes. It provides no tools of its own — mount the sibling `dsh-experimental-tool-agent-team` so the model can create teammates, message them, and use the task board. It is experimental: excluded from official releases, carries no stability promise, and needs durable session storage to activate. +`dsh-experimental-agent-team` turns one coding session into a small working team: the session's agent becomes the Lead, creates named teammates for delegated work, exchanges durable messages with them, and tracks shared tasks on a common board. Messages and task state survive crashes, reloads, and interruptions, so a teammate that was offline receives its queued messages when it resumes. It provides no tools of its own — mount the sibling `dsh-experimental-tool-agent-team` so the model can create teammates, message them, and use the task board. It is published under its experimental name, carries no stability promise, and needs durable session storage to activate. ## Table of Contents @@ -161,7 +161,7 @@ Read these pages when the package-level contract is not enough. They move from t - [Agent Teams subsystem](../../../docs/subsystems/agent-team.md) — durable Team types and the `ctx.agentTeams` service API. - [tool-agent-team package](../tool-agent-team/README.md) — the tools that let the model create, message, and coordinate teammates. - [Agent Teams Agent Note](../../../.agents/notes/implemented/feature/2026-08-05-agent-teams.md) — identity, mailbox, task, and shared-checkout decisions. -- [Experimental package decision](../../../.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.md) — placement, release exclusion, and dependency isolation. +- [Experimental package decision](../../../.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.md) — placement, publication, and dependency isolation. ----- @@ -194,7 +194,7 @@ Peer messages append after the target's reusable history prefix. Cold resume reu These limits describe what a team cannot do yet or what needs special operational care. They are current package constraints, not a comparison with other coordination mechanisms. -- **Experimental prototype with no stability promise** — the package is private, excluded from official releases, and its contracts change freely while it incubates. +- **Experimental prototype with no stability promise** — the package is public, but its contracts can change freely while it incubates. - **One process and one shared checkout** — members share cwd and observe edits immediately; this package provides no worktree, remote member, merge, or filesystem lock. - **Advisory write scopes** — Bash, formatters, code generators, and direct external writers can bypass filesystem version checks; Leads must coordinate ownership and review the final diff. - **Flat immutable roster** — only the Lead creates direct teammates; there is no nested Team, rename, deletion, or name reuse. diff --git a/packages/experimental/agent-team/README.zh.md b/packages/experimental/agent-team/README.zh.md index d6b57d35b6..342e3be4f0 100644 --- a/packages/experimental/agent-team/README.zh.md +++ b/packages/experimental/agent-team/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -`dsh-experimental-agent-team` 把一个编码会话变成一个小型工作团队:会话中的 agent 成为 Lead,创建具名 teammate 处理委派的工作,与它们交换持久消息,并在公共任务板上跟踪共享任务。消息与任务状态能挺过崩溃、reload 与中断,因此离线的 teammate 会在恢复后收到排队的消息。它本身不提供任何工具——请挂载兄弟包 `dsh-experimental-tool-agent-team`,让模型能够创建 teammate、给它们发消息并使用任务板。它是实验性的:不进入正式发布、不承诺稳定性,并且需要持久会话存储才能激活。 +`dsh-experimental-agent-team` 把一个编码会话变成一个小型工作团队:会话中的 agent 成为 Lead,创建具名 teammate 处理委派的工作,与它们交换持久消息,并在公共任务板上跟踪共享任务。消息与任务状态能挺过崩溃、reload 与中断,因此离线的 teammate 会在恢复后收到排队的消息。它本身不提供任何工具——请挂载兄弟包 `dsh-experimental-tool-agent-team`,让模型能够创建 teammate、给它们发消息并使用任务板。它以实验性名称公开发布、不承诺稳定性,并且需要持久会话存储才能激活。 ## 目录 @@ -161,7 +161,7 @@ dispose 会关闭准入、中止并等待已获准的创建与 mailbox dispatch - [Agent Teams 子系统](../../../docs/subsystems/agent-team.zh.md)——持久 Team 类型与 `ctx.agentTeams` 服务 API。 - [tool-agent-team 包](../tool-agent-team/README.zh.md)——让模型创建、消息与协调 teammate 的工具。 - [Agent Teams Agent Note](../../../.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md)——身份、mailbox、任务与共享 checkout 决策。 -- [实验包决策](../../../.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.zh.md)——位置、发布排除与依赖隔离。 +- [实验包决策](../../../.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.zh.md)——位置、公开发布与依赖隔离。 ----- @@ -194,7 +194,7 @@ Peer 消息追加在 target 可复用历史前缀之后。冷恢复会先复用 这些限制说明一支团队目前不能做什么、或何时需要特别运维。它们是当前包约束,不是与其他协作机制的对比。 -- **实验原型,无稳定性承诺**——本包为私有、不进入正式发布,孵化期间约定可自由变更。 +- **实验原型,无稳定性承诺**——本包公开发布,但孵化期间约定仍可自由变更。 - **单进程、共享 checkout**——成员共享 cwd,修改立即可见;本包不提供 worktree、远端成员、merge 或文件锁。 - **write scope 仅作提示**——Bash、formatter、代码生成器与直接外部写入可以绕过文件版本检查;Lead 必须协调 owner 并检查最终 diff。 - **扁平且不可变的 roster**——只有 Lead 可以创建直接 teammate;不支持嵌套 Team、重命名、删除或名字复用。 diff --git a/packages/experimental/agent-team/package.json b/packages/experimental/agent-team/package.json index ae8a33e996..dee4fc8756 100644 --- a/packages/experimental/agent-team/package.json +++ b/packages/experimental/agent-team/package.json @@ -2,7 +2,9 @@ "name": "@deepseek-ai/dsh-experimental-agent-team", "description": "Implicit-root Agent Teams roster, durable peer mailbox, and shared task DAG", "version": "0.1.5-alpha.1", - "private": true, + "publishConfig": { + "access": "public" + }, "repository": { "type": "git", "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", diff --git a/packages/experimental/client-ui-agent-team/README.i18n.yaml b/packages/experimental/client-ui-agent-team/README.i18n.yaml index ab225f45d5..b7e95bada0 100644 --- a/packages/experimental/client-ui-agent-team/README.i18n.yaml +++ b/packages/experimental/client-ui-agent-team/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/experimental/client-ui-agent-team/README.md -README.md: 04083ae2d2a54dfe0c1f1c43dee227e9ae575a81 -README.zh.md: a75d11af89262e562a91117c9653ef0e89a88cd8 +README.md: ab40e2de09406991c55160005f2f16cf6931e195 +README.zh.md: d0947e94f768d338f011ea25afc00d17880bf781 diff --git a/packages/experimental/client-ui-agent-team/README.md b/packages/experimental/client-ui-agent-team/README.md index 04083ae2d2..ab40e2de09 100644 --- a/packages/experimental/client-ui-agent-team/README.md +++ b/packages/experimental/client-ui-agent-team/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -This package adds an Agent Teams action to the Web conversation header, where a user can inspect the current roster, manage the shared task board, and navigate into a teammate's conversation. It reads authoritative Team state through the generated `ctx.remote.agentTeams` contribution and keeps ordinary child-history navigation on the stable addressed-subagent path. Choose it for the experimental source-checkout Web profile; official releases exclude it. The browser projection does not extend the stable API Proxy, store Team state, or register model-facing input. +This package adds an Agent Teams action to the Web conversation header, where a user can inspect the current roster, manage the shared task board, and navigate into a teammate's conversation. It reads authoritative Team state through the generated `ctx.remote.agentTeams` contribution and keeps ordinary child-history navigation on the stable addressed-subagent path. Choose it through the published experimental Agent Teams Web profile. The browser projection does not extend the stable API Proxy, store Team state, or register model-facing input. ## Table of Contents @@ -61,10 +61,10 @@ Starting a create or update invalidates older refreshes. Success reloads the com ## Further Exploration -- [Agent Teams Web profile](../agent-team-web-profile/README.md) — the source-checkout bundle that mounts this Client plugin. +- [Agent Teams Web profile](../agent-team-web-profile/README.md) — the published opt-in bundle that mounts this Client plugin. - [Agent Teams service](../agent-team/README.md) — authoritative roster, task, and Remote behavior. - [Conversation UI](../../client/ui-conversation/README.md) — the stable header slot and addressed-subagent navigation surface. -- [Experimental packages](../README.md) — incubation status and release exclusion. +- [Experimental packages](../README.md) — incubation status and publication policy. ----- diff --git a/packages/experimental/client-ui-agent-team/README.zh.md b/packages/experimental/client-ui-agent-team/README.zh.md index a75d11af89..d0947e94f7 100644 --- a/packages/experimental/client-ui-agent-team/README.zh.md +++ b/packages/experimental/client-ui-agent-team/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -本包向 Web 会话页头添加 Agent Teams action,让用户检查当前 roster、管理共享任务板并导航到 teammate 会话。它通过生成的 `ctx.remote.agentTeams` contribution 读取权威 Team 状态,并让普通 child history 导航继续使用稳定的 addressed-subagent 路径。需要实验性源码 checkout Web profile 时选择本包;正式发布会排除它。这个浏览器 projection 不扩展稳定 API Proxy、不存储 Team 状态,也不注册面向模型的输入。 +本包向 Web 会话页头添加 Agent Teams action,让用户检查当前 roster、管理共享任务板并导航到 teammate 会话。它通过生成的 `ctx.remote.agentTeams` contribution 读取权威 Team 状态,并让普通 child history 导航继续使用稳定的 addressed-subagent 路径。通过公开发布的实验性 Agent Teams Web profile 选择本包。这个浏览器 projection 不扩展稳定 API Proxy、不存储 Team 状态,也不注册面向模型的输入。 ## 目录 @@ -61,10 +61,10 @@ Client export 挂载来自 [`@deepseek-ai/dsh-experimental-agent-team/remote`](. ## 进一步探索 -- [Agent Teams Web profile](../agent-team-web-profile/README.zh.md)——挂载本 Client plugin 的源码 checkout bundle。 +- [Agent Teams Web profile](../agent-team-web-profile/README.zh.md)——挂载本 Client plugin 的公开 opt-in bundle。 - [Agent Teams service](../agent-team/README.zh.md)——权威 roster、task 与 Remote 行为。 - [会话 UI](../../client/ui-conversation/README.zh.md)——稳定 header slot 与 addressed-subagent 导航表层。 -- [实验性包](../README.zh.md)——孵化状态与发布排除规则。 +- [实验性包](../README.zh.md)——孵化状态与发布规则。 ----- diff --git a/packages/experimental/client-ui-agent-team/package.json b/packages/experimental/client-ui-agent-team/package.json index 18b70a31e1..12fa3b7275 100644 --- a/packages/experimental/client-ui-agent-team/package.json +++ b/packages/experimental/client-ui-agent-team/package.json @@ -2,7 +2,9 @@ "name": "@deepseek-ai/dsh-experimental-client-ui-agent-team", "description": "Web Agent Teams roster, task board, and teammate navigation", "version": "0.1.5-alpha.1", - "private": true, + "publishConfig": { + "access": "public" + }, "repository": { "type": "git", "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", diff --git a/packages/experimental/tool-agent-team/README.i18n.yaml b/packages/experimental/tool-agent-team/README.i18n.yaml index 65a5444bbb..946be84c19 100644 --- a/packages/experimental/tool-agent-team/README.i18n.yaml +++ b/packages/experimental/tool-agent-team/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/experimental/tool-agent-team/README.md -README.md: 5052d0af481b02b34583e9327157e000f1fb07b7 -README.zh.md: 86663729861dda7f97b5068c94c1064ba6cb12ad +README.md: 58e2853a863710ec094f61b0af04a0fe9b6bd4ce +README.zh.md: 63b24c3772ea277a11b2bc4c456d56bbfaa0a772 diff --git a/packages/experimental/tool-agent-team/README.md b/packages/experimental/tool-agent-team/README.md index 5052d0af48..58e2853a86 100644 --- a/packages/experimental/tool-agent-team/README.md +++ b/packages/experimental/tool-agent-team/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -This package lets the model create named teammates, send them messages, inspect availability, wait for progress, interrupt stuck work, and coordinate through a shared task board. Every team member receives the same nine tools and guidance for coordinating in a shared workspace. Choose it when the model should operate a team only after you explicitly request one. It replaces legacy subagent controls with the same tool names, so compositions that need both must disable the legacy definitions. The package is experimental, excluded from official releases, and provides no stability guarantee. +This package lets the model create named teammates, send them messages, inspect availability, wait for progress, interrupt stuck work, and coordinate through a shared task board. Every team member receives the same nine tools and guidance for coordinating in a shared workspace. Choose it when the model should operate a team only after you explicitly request one. It replaces legacy subagent controls with the same tool names, so compositions that need both must disable the legacy definitions. The package is published under its experimental name and provides no stability guarantee. ## Table of Contents @@ -145,7 +145,7 @@ These limits describe what the policy and tools cannot guarantee for a team. The - **Prompt policy is coordination, not confinement** — it cannot stop Bash or external processes from writing overlapping files. - **No autonomous team creation** — ordinary tasks do not trigger delegation unless the user explicitly requests it. - **No Web controls** — browser roster and task-board presentation is outside this runtime package. -- **Experimental prototype with no stability promise** — the package is private, excluded from official releases, and its schemas change freely while it incubates. +- **Experimental prototype with no stability promise** — the package is public, but its schemas can change freely while it incubates. ### Dev Note diff --git a/packages/experimental/tool-agent-team/README.zh.md b/packages/experimental/tool-agent-team/README.zh.md index 8666372986..63b24c3772 100644 --- a/packages/experimental/tool-agent-team/README.zh.md +++ b/packages/experimental/tool-agent-team/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -本包让模型创建具名 teammate、向它们发送消息、查看可用状态、等待进展、中断卡住的工作,并通过共享任务板协调。每个团队成员都会获得相同的九个工具,以及在共享工作区协调的指引。当模型只应在你明确要求后运行团队时,选择本包。它会取代同名的旧版 subagent 控件,因此同时需要两者的组合必须禁用旧定义。本包处于实验阶段,不进入正式发布,也不提供稳定性保证。 +本包让模型创建具名 teammate、向它们发送消息、查看可用状态、等待进展、中断卡住的工作,并通过共享任务板协调。每个团队成员都会获得相同的九个工具,以及在共享工作区协调的指引。当模型只应在你明确要求后运行团队时,选择本包。它会取代同名的旧版 subagent 控件,因此同时需要两者的组合必须禁用旧定义。本包以实验性名称公开发布,但不提供稳定性保证。 ## 目录 @@ -145,7 +145,7 @@ Team 插件 generation、配置、member role/name 与 schema 不变时,前 - **提示词策略只负责协调,不负责 confinement**——它无法阻止 Bash 或外部进程写入重叠文件。 - **不会自主创建 Team**——除非用户明确要求,普通任务不会触发 delegation。 - **没有 Web 控制功能**——浏览器 roster 与任务板呈现不属于该运行时包。 -- **实验原型,无稳定性承诺**——本包为私有、不进入正式发布,孵化期间 schema 可自由变更。 +- **实验原型,无稳定性承诺**——本包公开发布,但孵化期间 schema 仍可自由变更。 ### 开发备注 diff --git a/packages/experimental/tool-agent-team/package.json b/packages/experimental/tool-agent-team/package.json index 7737876026..eb41e65d5f 100644 --- a/packages/experimental/tool-agent-team/package.json +++ b/packages/experimental/tool-agent-team/package.json @@ -2,7 +2,9 @@ "name": "@deepseek-ai/dsh-experimental-tool-agent-team", "description": "Scoped model-facing Agent Teams tools over ctx.agentTeams", "version": "0.1.5-alpha.1", - "private": true, + "publishConfig": { + "access": "public" + }, "repository": { "type": "git", "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", diff --git a/scripts/check-workspace-constraints.spec.ts b/scripts/check-workspace-constraints.spec.ts index 5ac40d3c99..2042754128 100644 --- a/scripts/check-workspace-constraints.spec.ts +++ b/scripts/check-workspace-constraints.spec.ts @@ -14,6 +14,14 @@ const experimental: WorkspaceManifest = { manifest: { name: '@deepseek-ai/dsh-experimental-prototype', private: true }, } +const publicExperimental: WorkspaceManifest = { + dir: 'packages/experimental/agent-team', + manifest: { + name: '@deepseek-ai/dsh-experimental-agent-team', + publishConfig: { access: 'public' }, + }, +} + describe('experimental workspace constraints', () => { it('requires the experimental package-name prefix', () => { expect(checkExperimentalManifest({ @@ -35,6 +43,20 @@ describe('experimental workspace constraints', () => { ]) }) + it('requires public metadata only for the Agent Teams exceptions', () => { + expect(checkExperimentalManifest(publicExperimental)).toEqual([]) + expect(checkExperimentalManifest({ + ...publicExperimental, + manifest: { + name: '@deepseek-ai/dsh-experimental-agent-team', + private: true, + }, + })).toEqual([ + '@deepseek-ai/dsh-experimental-agent-team: public experimental package must not set "private": true', + '@deepseek-ai/dsh-experimental-agent-team: public experimental package must set publishConfig.access to "public"', + ]) + }) + it.each(['dependencies', 'optionalDependencies', 'peerDependencies'] as const)( 'rejects release %s on an experimental package', (section) => { diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index e7b0e948e8..95a8835eeb 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -8,6 +8,7 @@ import { existsSync, readdirSync, readFileSync } from 'node:fs' import { join, relative, resolve } from 'node:path' import { pathToFileURL } from 'node:url' +import { isPublicExperimentalPackageDirectory } from './experimental-package-policy.ts' import { hasTypertRemoteNavigation, isForbiddenPublicationFile } from './publication-payload.ts' import { collectProjectReferenceFaceViolations } from './project-reference-faces.ts' @@ -50,12 +51,12 @@ const repositoryUrl = 'git+https://github.com/deepseek-harness/deepseek-harness. * their trusted publishing against the repository that runs the workflow. */ const publishedRepositoryUrl = 'git+https://github.com/deepseek-ai/deepseek-harness.git' -/** Private packages that participate in workspace checks but not releases. */ +/** Packages that participate in the experimental policy. */ const experimentalPackageDirectory = /^packages\/experimental\/[^/]+$/ -/** npm namespace reserved for private experimental packages. */ +/** npm namespace reserved for experimental packages. */ const experimentalPackageNamePrefix = '@deepseek-ai/dsh-experimental-' -/** Directories whose packages this repository publishes: one release member each. */ -const releaseMemberDirectory = /^(?:packages\/(?!experimental\/)[^/]+\/[^/]+|apps\/(?!desktop(?:-host)?$)[^/]+|vendor\/[^/]+)$/ +/** Ordinary directories whose packages this repository publishes: one release member each. */ +const standardReleaseMemberDirectory = /^(?:packages\/(?!experimental\/)[^/]+\/[^/]+|apps\/(?!desktop(?:-host)?$)[^/]+|vendor\/[^/]+)$/ /** Installable application assembled by electron-builder rather than published to npm. */ const desktopApplicationDirectory = 'apps/desktop' const localArtifactDirs = new Set(['node_modules']) @@ -267,7 +268,7 @@ function usesEmittedTreeDefaults(manifest: PackageManifest): boolean { exportDefault(manifest, subpath)?.startsWith('./lib/types/') === true) } -/** Experimental manifest requirements enforced independently from release metadata. */ +/** Experimental manifest requirements, including explicit public exceptions. */ export function checkExperimentalManifest({ dir, manifest }: WorkspaceManifest): string[] { if (!experimentalPackageDirectory.test(dir)) return [] const label = manifest.name ?? dir @@ -275,11 +276,22 @@ export function checkExperimentalManifest({ dir, manifest }: WorkspaceManifest): if (manifest.name?.startsWith(experimentalPackageNamePrefix) !== true) { errors.push(`${label}: experimental package name must start with ${JSON.stringify(experimentalPackageNamePrefix)}`) } - if (manifest.private !== true) errors.push(`${label}: experimental package must set "private": true`) - if (manifest.publishConfig !== undefined) errors.push(`${label}: experimental package must omit publishConfig`) + if (isPublicExperimentalPackageDirectory(dir)) { + if (manifest.private === true) errors.push(`${label}: public experimental package must not set "private": true`) + if (manifest.publishConfig?.access !== 'public') { + errors.push(`${label}: public experimental package must set publishConfig.access to "public"`) + } + } else { + if (manifest.private !== true) errors.push(`${label}: experimental package must set "private": true`) + if (manifest.publishConfig !== undefined) errors.push(`${label}: experimental package must omit publishConfig`) + } return errors } +function isReleaseMemberDirectory(dir: string): boolean { + return standardReleaseMemberDirectory.test(dir) || isPublicExperimentalPackageDirectory(dir) +} + /** * Require a dsh-family manifest to carry the workspace version. * @@ -330,7 +342,7 @@ export function checkWorkspaceManifest({ dir, manifest }: WorkspaceManifest): st || manifest.repository.directory !== expectedDirectory) { errors.push(`${label}: published Landlock package repository must use ${repositoryUrl} with directory ${expectedDirectory} for trusted publishing`) } - } else if (releaseMemberDirectory.test(dir)) { + } else if (isReleaseMemberDirectory(dir)) { // Release members state that they are publishable: npm refuses a private // package, and the repository field is how a consumer finds the source of // the package it installed. @@ -482,7 +494,7 @@ export function checkExperimentalDependencyIsolation(manifests: readonly Workspa .filter(name => name !== undefined)) const errors: string[] = [] for (const { dir, manifest } of manifests) { - if (!releaseMemberDirectory.test(dir) && dir !== 'python/sdk-runtime') continue + if (!standardReleaseMemberDirectory.test(dir) && dir !== 'python/sdk-runtime') continue for (const section of runtimeDependencySections) { for (const name of Object.keys(manifest[section] ?? {})) { if (!experimentalNames.has(name)) continue diff --git a/scripts/experimental-package-policy.ts b/scripts/experimental-package-policy.ts new file mode 100644 index 0000000000..da0cd08a76 --- /dev/null +++ b/scripts/experimental-package-policy.ts @@ -0,0 +1,19 @@ +/** Experimental packages that publish with the dsh release family without changing names. */ +export const PUBLIC_EXPERIMENTAL_PACKAGE_DIRECTORIES = [ + 'packages/experimental/agent-team', + 'packages/experimental/agent-team-profile', + 'packages/experimental/agent-team-web-profile', + 'packages/experimental/client-ui-agent-team', + 'packages/experimental/tool-agent-team', +] as const + +const publicExperimentalPackageDirectories = new Set(PUBLIC_EXPERIMENTAL_PACKAGE_DIRECTORIES) + +/** + * Whether an experimental package is an explicit public-release exception. + * @param directory - repository-relative package directory. + * @returns Whether the package publishes with the dsh family. + */ +export function isPublicExperimentalPackageDirectory(directory: string): boolean { + return publicExperimentalPackageDirectories.has(directory) +} diff --git a/scripts/publish-npm-baseline.ts b/scripts/publish-npm-baseline.ts index 4e98065719..fd5a594681 100644 --- a/scripts/publish-npm-baseline.ts +++ b/scripts/publish-npm-baseline.ts @@ -18,6 +18,7 @@ import { basename, dirname, isAbsolute, join, normalize, relative, resolve, sep import { createInterface } from 'node:readline/promises' import { pathToFileURL } from 'node:url' import { parseArgs } from 'node:util' +import { PUBLIC_EXPERIMENTAL_PACKAGE_DIRECTORIES } from './experimental-package-policy.ts' import { validateTarballPayload } from './publication-payload.ts' const DEFAULT_REGISTRY = 'https://registry.npm.harnessment.com' @@ -26,6 +27,7 @@ const PACKAGE_PATTERNS = [ 'vendor/*/package.json', 'packages/!(experimental)/*/package.json', 'apps/*/package.json', + ...PUBLIC_EXPERIMENTAL_PACKAGE_DIRECTORIES.map(directory => `${directory}/package.json`), ] as const const DEPENDENCY_SECTIONS = [ 'dependencies', diff --git a/scripts/release/families.spec.ts b/scripts/release/families.spec.ts index 64cd1e268e..2d3aa9250a 100644 --- a/scripts/release/families.spec.ts +++ b/scripts/release/families.spec.ts @@ -42,11 +42,19 @@ afterEach(() => { }) describe('release families', () => { - it('excludes private experimental packages from the dsh release', () => { + it('publishes Agent Teams while excluding private experimental packages', () => { const members = releaseFamily('dsh').members(resolve(import.meta.dirname, '../..')) - expect(members.some(member => member.directory.startsWith('packages/experimental/'))).toBe(false) - expect(members.map(member => member.name)).not.toContain('@deepseek-ai/dsh-experimental-agent-team') + expect(members + .filter(member => member.directory.startsWith('packages/experimental/')) + .map(member => member.name)).toEqual([ + '@deepseek-ai/dsh-experimental-agent-team-profile', + '@deepseek-ai/dsh-experimental-agent-team-web-profile', + '@deepseek-ai/dsh-experimental-agent-team', + '@deepseek-ai/dsh-experimental-client-ui-agent-team', + '@deepseek-ai/dsh-experimental-tool-agent-team', + ]) + expect(members.map(member => member.name)).not.toContain('@deepseek-ai/dsh-experimental-inspector') }) it('excludes private applications from the publish set', () => { diff --git a/scripts/release/families.ts b/scripts/release/families.ts index d1b5d23b82..4c4d1dca65 100644 --- a/scripts/release/families.ts +++ b/scripts/release/families.ts @@ -15,6 +15,7 @@ import { officialClientBuildEnvironment, readClientBuildRecord, } from '../client-build-environment.ts' +import { PUBLIC_EXPERIMENTAL_PACKAGE_DIRECTORIES } from '../experimental-package-policy.ts' import { validateTarballPayload } from '../publication-payload.ts' /** @@ -321,7 +322,11 @@ export abstract class ReleaseFamily { /** Release packages and apps: one shared version across the whole family. */ class DshFamily extends ReleaseFamily { readonly id = 'dsh' - readonly patterns = ['packages/!(experimental)/*/package.json', 'apps/*/package.json'] as const + readonly patterns = [ + 'packages/!(experimental)/*/package.json', + 'apps/*/package.json', + ...PUBLIC_EXPERIMENTAL_PACKAGE_DIRECTORIES.map(directory => `${directory}/package.json`), + ] as const readonly tagPrefix = 'dsh-v' /** Require current artifacts from a complete official client build. */ diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 19be6bbc26..133f8736e5 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -82,7 +82,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/client/connection': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/client/file-upload': { kind: 'none', reason: 'Browser-side request-body transport; registers nothing model-facing.' }, 'packages/api/remotes': { kind: 'none', reason: 'The Remote BFF selects business methods and forwarded events; selected services own any model-visible effect.' }, - 'packages/experimental/agent-team-web-profile': { kind: 'indirect', reason: 'The private Web layer delegates model composition to the Host-side Agent Teams profile.' }, + 'packages/experimental/agent-team-web-profile': { kind: 'indirect', reason: 'The Web layer delegates model composition to the Host-side Agent Teams profile.' }, 'packages/experimental/client-ui-agent-team': { kind: 'none', reason: 'The browser projection and task controls register no model-facing input.' }, 'packages/client/ui-layout': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/client/ui-dockkit': { kind: 'none', reason: 'Browser-side docking layout engine and components; registers nothing model-facing.' }, From 9bae613128a725cc44dc140cf629a57f74524f94 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Wed, 9 Sep 2026 17:31:16 +0800 Subject: [PATCH 056/111] 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 fc7abdcfb51bba72930920774d995ef90b363852 Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Wed, 9 Sep 2026 19:18:34 +0800 Subject: [PATCH 057/111] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=20windows=20?= =?UTF-8?q?=E5=AE=89=E8=A3=85=E5=8C=85=E6=89=93=E5=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...on-desktop-packaging-and-updates.i18n.yaml | 4 +- ...-electron-desktop-packaging-and-updates.md | 28 ++- ...ectron-desktop-packaging-and-updates.zh.md | 28 ++- ...led-runtime-and-external-plugins.i18n.yaml | 4 +- ...op-bundled-runtime-and-external-plugins.md | 14 +- ...bundled-runtime-and-external-plugins.zh.md | 14 +- ...mmediate-window-and-direct-start.i18n.yaml | 6 + ...sktop-immediate-window-and-direct-start.md | 29 +++ ...op-immediate-window-and-direct-start.zh.md | 29 +++ apps/desktop/README.i18n.yaml | 4 +- apps/desktop/README.md | 44 ++-- apps/desktop/README.zh.md | 44 ++-- apps/desktop/electron-builder.config.d.mts | 5 +- apps/desktop/electron-builder.config.mjs | 25 ++- apps/desktop/package.json | 1 + apps/desktop/renderer/plugin-manager.js | 4 +- apps/desktop/renderer/startup.css | 16 ++ apps/desktop/renderer/startup.html | 23 ++ apps/desktop/renderer/startup.js | 37 ++++ apps/desktop/scripts/installer.nsh | 17 ++ apps/desktop/scripts/package-target.ts | 32 ++- apps/desktop/scripts/prepare-dsh.ts | 16 +- apps/desktop/scripts/runtime-file-policy.ts | 40 ++++ apps/desktop/src/backend-controller.ts | 146 +++++++++++++ apps/desktop/src/host-process.ts | 13 +- apps/desktop/src/ipc.ts | 11 +- apps/desktop/src/locale.ts | 8 + apps/desktop/src/main.ts | 192 ++++++++--------- apps/desktop/src/owned-directory.ts | 26 +++ apps/desktop/src/preload-app.ts | 24 ++- apps/desktop/src/preload.ts | 6 + apps/desktop/src/profile-packages.ts | 10 +- apps/desktop/src/project-manager.ts | 33 +-- apps/desktop/src/runtime-tree.ts | 96 +++++++-- apps/desktop/tests/backend-controller.spec.ts | 170 +++++++++++++++ .../fixtures/installer-cleanup-smoke.nsi | 70 ++++++ .../installer-write-failure-smoke.nsi | 55 +++++ .../tests/fixtures/owned-directory-smoke.mjs | 29 +++ .../tests/fixtures/runtime-payload-smoke.mjs | 138 ++++++++++++ apps/desktop/tests/host-process.spec.ts | 26 ++- apps/desktop/tests/macos-signature.spec.ts | 27 ++- apps/desktop/tests/main-startup.spec.ts | 200 ++++++++++++++++++ apps/desktop/tests/owned-directory.spec.ts | 53 +++++ apps/desktop/tests/package-target.spec.ts | 40 ++++ apps/desktop/tests/plugin-manager.spec.ts | 2 +- apps/desktop/tests/plugin-pnpm.spec.ts | 2 +- apps/desktop/tests/preload-app.spec.ts | 37 ++++ apps/desktop/tests/project-manager.spec.ts | 35 ++- .../desktop/tests/runtime-file-policy.spec.ts | 81 +++++++ .../tests/runtime-tree-read-failure.spec.ts | 49 +++++ apps/desktop/tests/runtime-tree.spec.ts | 52 +++-- apps/desktop/tests/startup-renderer.spec.ts | 134 ++++++++++++ apps/desktop/tsdown.config.ts | 13 +- package.json | 1 + 54 files changed, 1984 insertions(+), 259 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.md create mode 100644 .agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.zh.md create mode 100644 apps/desktop/renderer/startup.css create mode 100644 apps/desktop/renderer/startup.html create mode 100644 apps/desktop/renderer/startup.js create mode 100644 apps/desktop/scripts/installer.nsh create mode 100644 apps/desktop/scripts/runtime-file-policy.ts create mode 100644 apps/desktop/src/backend-controller.ts create mode 100644 apps/desktop/src/owned-directory.ts create mode 100644 apps/desktop/tests/backend-controller.spec.ts create mode 100644 apps/desktop/tests/fixtures/installer-cleanup-smoke.nsi create mode 100644 apps/desktop/tests/fixtures/installer-write-failure-smoke.nsi create mode 100644 apps/desktop/tests/fixtures/owned-directory-smoke.mjs create mode 100644 apps/desktop/tests/fixtures/runtime-payload-smoke.mjs create mode 100644 apps/desktop/tests/main-startup.spec.ts create mode 100644 apps/desktop/tests/owned-directory.spec.ts create mode 100644 apps/desktop/tests/preload-app.spec.ts create mode 100644 apps/desktop/tests/runtime-file-policy.spec.ts create mode 100644 apps/desktop/tests/runtime-tree-read-failure.spec.ts create mode 100644 apps/desktop/tests/startup-renderer.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml index fe1bf84c1b..d182ff8d4a 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md -2026-08-25-electron-desktop-packaging-and-updates.md: d7d41aa4d6154f251b56faaa29065db3ee71071f -2026-08-25-electron-desktop-packaging-and-updates.zh.md: 952ed4fc5546524bc088f58e62faccd576194bc7 +2026-08-25-electron-desktop-packaging-and-updates.md: f6aa73d6e708db04c6978690b8fc2b7eb70f270b +2026-08-25-electron-desktop-packaging-and-updates.zh.md: 00c1346ab9b0bca1f704d74e7792acd03c5df9bf diff --git a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md index d7d41aa4d6..f6aa73d6e7 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md +++ b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md @@ -65,30 +65,38 @@ The renderer uses `nodeIntegration: false`, `contextIsolation: true`, and `sandb ## Installation and resolution -The installer never mutates the active profile in place. It copies profile metadata into a transaction staging directory and applies an exact dependency change with the bundled pnpm. Before testing staging, Electron stops the active backend; it starts and stops the staged backend alone, then restores the active backend before activation, so two Desktop backends never concurrently share `.dsh` state. Activation stops the backend again, persists each next `pending.json` phase before its corresponding filesystem move, moves the active profile to `rollback/profile`, moves staging into `.dsh/profiles/desktop`, and restarts. Recovery combines the write-ahead phase with the actual active, rollback, and staging directories so either write-to-move interruption retains or restores a complete profile. +The installer never mutates the active profile in place. It copies profile metadata into a transaction staging directory and applies an exact dependency change with the bundled pnpm. Activation stops the active backend, persists each next `pending.json` phase before its corresponding filesystem move, moves the active profile to `rollback/profile`, moves staging into `.dsh/profiles/desktop`, and restarts. Recovery combines the write-ahead phase with the actual active, rollback, and staging directories so either write-to-move interruption retains or restores a complete profile. The process-lifetime Electron lock is the authoritative Desktop owner. The package transaction lock is depth defense and records the process that can still mutate package state: Electron between package operations and the spawned pnpm PID while pnpm runs. The owner change is truncated, written, and synchronized through the already-open exclusive lock file. If Electron terminates during pnpm execution, a later process observes the live worker and refuses to start a competing store or staging transaction; after that worker exits, the stale PID can be recovered. -Core materialization, first launch, plugin installation, and shared-module resolution follow the [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md). A desktop plugin contributing `dsh.client` code enters the client graph only after the complete profile passes health checking. +Core materialization, first launch, plugin installation, and shared-module resolution follow the [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md). The actual Host composes enabled desktop plugins contributing `dsh.client` code when it starts. ## Updates and recovery Electron update uses one `electron-updater` release stream and signed `electron-builder` artifacts. Its version is the Desktop release version; there is no independent dsh manifest, compatibility range, or dsh-only update operation. A foreground install waits for an in-flight background check rather than reusing its result as an install result. The update dialog downloads and installs the Electron artifact, then restarts into the new release. -Before opening the product window, startup validates the current bundled runtime and reconciles external plugins according to the [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md). Backend failure opens the Electron-owned recovery window instead of booting a mismatched dsh release. +The [immediate-window decision](2026-09-09-desktop-immediate-window-and-direct-start.md) owns the local loading page, direct Host startup, and recovery in the main window. Profile reconciliation follows the [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md). `DSH_DESKTOP_AUTO_UPDATE_ENV` selects the test deployment by default or the production deployment for both the target-specific generic-provider URL and COS destination. Release automation supplies the test HTTPS origin through `DOWNLOAD_TEST_ORIGIN` and each deployment's bucket through `DOWNLOAD_TEST_COS_BUCKET` or `DOWNLOAD_PROD_COS_BUCKET`; keeping mutable test routing and COS storage identities out of source lets deployment infrastructure change without a code release, while the public production origin remains fixed. Packaging resolves only the public updater URL, disables electron-builder publishing, removes every COS credential field from its subprocess environment, and writes a completion record only after electron-builder and every signing or notarization hook succeeds. Target upload additionally requires the selected bucket, then requires the completion record, root dsh version, Desktop version, version-derived channel metadata, artifact names, sizes, and SHA-512 values to agree before it reads the selected credentials or sends data. It uploads immutable versioned updater payloads and any separate blockmaps before replacing the channel metadata emitted by electron-builder, and it never deletes historical objects. Stable versions use the `latest` metadata name; prereleases use the first semantic-version prerelease identifier. NSIS embeds its blockmap in the signed executable; the macOS ZIP carries a separate blockmap. Both let electron-updater download changed blocks when supported, while application replacement and the local pnpm staging transaction remain separate operations. ## Security and release policy -Core dsh and the private Desktop Host come only from the signed application resource tree. Plugin installation accepts registry package specs allowed by desktop policy but never raw pnpm commands. Exact versions, lockfile integrity, a reviewed `allowBuilds` set, user-only directory permissions, and health checking are required before activation. +Core dsh and the private Desktop Host come only from the signed application resource tree. Plugin installation accepts registry package specs allowed by desktop policy but never raw pnpm commands. Exact versions, lockfile integrity, a reviewed `allowBuilds` set, and user-only directory permissions are required before activation. -Electron artifacts are signed; macOS artifacts are notarized. Release automation must supply the application ID, macOS Developer ID qualifier, expected Team ID, and one complete notarytool credential strategy through explicit environment variables. Configuration loading rejects missing or malformed identifiers and incomplete notarization credentials, while macOS packaging requires signing so certificate discovery cannot silently select another installed identity or emit an unsigned release. Runtime preparation verifies the exact Authority and Team ID plus the timestamp and hardened-runtime flags on every embedded Mach-O file. An after-sign hook performs Apple's deep strict application verification and requires the same leaf Authority and Team ID before artifact creation continues. Electron-builder then notarizes and staples the application and signs the DMG. The DMG artifact-completion hook separately notarizes and staples every DMG before requiring the configured identity, a valid ticket, and Gatekeeper acceptance; the upload event runs only after that hook succeeds. DMG blockmaps are disabled because macOS updates consume the signed ZIP, and stapling would otherwise invalidate an already-generated DMG blockmap. The custom protocol serves the installed frontend distribution plus client files named by the active module graph and rejects traversal or access outside those roots. The plugin installer API is available only to the Electron-owned management GUI and is absent from the browser application and backend RPC. +Electron release artifacts are signed; macOS artifacts are notarized. Release automation must supply the application ID, macOS Developer ID qualifier, expected Team ID, and one complete notarytool credential strategy through explicit environment variables. Configuration loading rejects missing or malformed identifiers and incomplete notarization credentials, while macOS packaging requires signing so certificate discovery cannot silently select another installed identity or emit an unsigned release. Runtime preparation verifies the exact Authority and Team ID plus the timestamp and hardened-runtime flags on every embedded Mach-O file. An after-sign hook performs Apple's deep strict application verification and requires the same leaf Authority and Team ID before artifact creation continues. Electron-builder then notarizes and staples the application and signs the DMG. The DMG artifact-completion hook separately notarizes and staples every DMG before requiring the configured identity, a valid ticket, and Gatekeeper acceptance; the upload event runs only after that hook succeeds. DMG blockmaps are disabled because macOS updates consume the signed ZIP, and stapling would otherwise invalidate an already-generated DMG blockmap. The custom protocol serves the installed frontend distribution plus client files named by the active module graph and rejects traversal or access outside those roots. The plugin installer API is available only to the Electron-owned management GUI and is absent from the browser application and backend RPC. The [pinned osx-sign patch](../../../../patches/@electron__osx-sign@1.3.3.patch) uses `lstat` in both published module builds, so Framework file and directory aliases do not trigger duplicate signing. The patch remains necessary until the selected upstream release skips those aliases. PAK files are resources sealed by the enclosing bundle; individual signatures add serial timestamp requests without additional resource integrity. Desktop preserves all locale files and skips only their standalone signatures. Executable code retains Developer ID signatures, secure timestamps, and hardened runtime. The [signer traversal regression](../../../../apps/desktop/tests/macos-signing-walk.spec.ts) exercises the installed dependency with real Framework aliases; release qualification still requires strict application verification, notarization, and startup. Windows release packaging supplies the public EV leaf certificate named by `DSH_DESKTOP_WINDOWS_CER_FILE` to the configured SafeNet-compatible SignTool through `/f` and identifies its matching private key through the required `DSH_DESKTOP_WINDOWS_KEY_CONTAINER`. The certificate file remains outside source control, and the private key remains on the USB token. The electron-builder hook passes each artifact to the CRLF `windows-sign.cmd`, whose single SignTool invocation uses the SafeNet `/kc "[{{PIN}}]=container"` value and CSP, a SHA-256 file digest, and a DigiCert SHA-256 RFC 3161 timestamp. The hook never substitutes another SignTool and never retries a failed request. Package orchestration withholds every `DSH_DESKTOP_WINDOWS_*` field from build and runtime-preparation children and passes only the certificate path, SignTool path, key container, and PIN into electron-builder. The signer supplies only validated signing fields in an otherwise scrubbed CMD environment; the CMD disables delayed expansion, clears those fields before SignTool starts, and preserves the PIN only in the required SignTool command line. Every surfaced diagnostic replaces the PIN, and only the dedicated build account and administrators may inspect the runner. The signer signs electron-builder's temporary NSIS bootstrap before enterprise Code Integrity evaluates that executable and clears a generated executable's certificate-table entry only when it points beyond the file before applying the final signature. Packaging fails before producing unsigned artifacts when the SignTool, certificate, container, PIN, token, or signature is unavailable. The custom protocol serves the installed frontend distribution plus client files named by the active module graph and rejects traversal or access outside those roots. The plugin installer API is available only to the Electron-owned management GUI and is absent from the browser application and backend RPC. +Windows package invocations force `ELECTRON_BUILDER_7Z_FILTER=BCJ`. The bundled 7-Zip 24.09 encoder automatically selects ARM64 filters for ARM64 PE files, but the NSIS decoder from `nsis-resources-3.4.1` omits those entries during extraction. A native extraction probe with the actual NSIS plugin loses both `node-pty` ARM64 binaries under automatic filtering and restores both byte-for-byte with BCJ. Keeping a compatible filter preserves dependency contents and runtime integrity instead of removing architecture-specific files or weakening verification. + +Local Windows installation testing uses an explicit `--unsigned` package invocation with the same build and runtime preparation. It strips certificate inputs, isolates artifacts in `unsigned-artifacts`, and omits updater configuration and the release completion record. The regular package command explicitly selects signed mode even when its parent environment requests unsigned mode. This separation permits installation diagnosis without an EV token while preventing local test output from qualifying for release upload. + +NSIS extracts into its private `7z-out` directory before copying files into the application directory. Its default exit cleanup can overlap the backend's file reads after Finish launches the application. The [installer hook](../../../../apps/desktop/scripts/installer.nsh) removes only that extraction directory during `customInstall`, before interactive and silent launch branches. It preserves package archives, plugin DLLs, rollback directories, registers, and error status; the [native cleanup smoke](../../../../apps/desktop/tests/fixtures/installer-cleanup-smoke.nsi) checks these constraints. Moving cleanup into installation does not remove filesystem work, so total installation time and Finish-to-window time require separate measurements. + +Direct `Nsis7z::Extract` into the application directory is not enabled. A native [locked-file probe](../../../../apps/desktop/tests/fixtures/installer-write-failure-smoke.nsi) leaves an old locked file beside a new asset while reporting no error; the staged `CopyFiles` operation sets the error flag for the same failed replacement. An unchanged 737,557,488-byte payload on Windows took 172.625 seconds through extraction, copying, and cleanup versus 28.031 seconds through direct extraction, but that single sample per path does not justify losing failure detection. The clocks exclude registry changes, old-version removal, and post-extraction verification; system caches were not cleared. Desktop-specific payload filtering reduces the copied file set while retaining the installer's replacement-error handling. This handling is not a promise of complete installation rollback. + Packaged applications ignore development resource and project environment overrides. Only an unpackaged Electron process can replace the Node.js binary, pnpm entry, dsh resources, or active project. The bundled upstream Node.js and pnpm are expected to add about 35–50 MB compressed and 120–165 MB installed before the dsh production tree. Architecture-specific builds must report actual component-level size deltas. @@ -135,8 +143,8 @@ The bundled upstream Node.js and pnpm are expected to add about 35–50 MB compr - The Electron-only GUI installs, removes, and updates ordinary npm plugin packages without exposing raw pnpm arguments. - The backend and browser application cannot mutate desktop packages. - npm/CLI dsh and Electron never resolve or install plugins from each other's `node_modules`. -- The active backend and Web UI report the same dsh version and a compatible shell API before the product window opens. -- Failed installation, health checking, or update leaves the current profile usable or restores `rollback/profile` after restart. +- The active backend and Web UI report the same dsh version and a compatible shell API before the product UI loads. +- Failed installation, backend activation, or update leaves the current profile usable or restores `rollback/profile` after restart. - One Desktop version binds Electron and dsh; every dsh update arrives through one Electron update dialog and one user-visible restart. - Shared `.dsh` data rejects incompatible readers before migration or mutation. - No loopback listener is opened, and the sandboxed renderer cannot access arbitrary filesystem or Electron APIs. @@ -149,10 +157,10 @@ The bundled upstream Node.js and pnpm are expected to add about 35–50 MB compr | Decision | Recommendation | |---|---| -| First launch | Validate bundled production packages and create profile links without installing core dependencies | +| First launch | Check bundled release metadata and create profile links without installing core dependencies | | Desktop profile | One Electron-owned reserved profile for external plugins and shared package links | | Plugin management | Electron-only GUI and package service; no CLI, backend, or browser installation path | -| Activation | Staging project, complete health check, journaled directory replacement, one rollback copy | +| Activation | Staging project, journaled directory replacement, actual Host startup, one rollback copy | | Initial platforms | macOS arm64/x64 and Windows x64; Linux has no supported release target | | Update behavior | Background check, explicit confirmation before differential download and restart, startup dsh reconciliation | @@ -160,7 +168,7 @@ The bundled upstream Node.js and pnpm are expected to add about 35–50 MB compr Plugin lifecycle scripts execute third-party code. The allowed registry, package policy, exact versions, integrity, `allowBuilds`, and diagnostics require security review before GUI installation ships. -Updating the bound dsh can invalidate plugin peer dependencies or native modules. pnpm resolution and full-project health checking must reject the staged project before replacing the active one. +Updating the bound dsh can invalidate plugin peer dependencies or native modules. Dependency validation rejects incompatible metadata before replacement; actual Host startup can fail after replacement and trigger profile rollback. An npm-installed dsh and desktop dsh may have different versions while sharing durable data. Each shared owner must enforce its format version and process lock before reading, migrating, or writing. diff --git a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md index 952ed4fc55..00c1346ab9 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md @@ -65,30 +65,38 @@ Electron 拥有 `.dsh/profiles/desktop` 保留 profile。[内置运行时决策] ## 安装与解析 -安装器绝不原地修改活跃 profile。它把 profile 元数据复制到事务暂存目录,并使用内置 pnpm 应用精确依赖变更。测试 staging 前,Electron 会停止活跃后端;它单独启动并停止 staging 后端,再在激活前恢复活跃后端,因此两个 Desktop 后端绝不会并发共享 `.dsh` 状态。激活过程再次停止后端,在对应目录移动前先持久化 `pending.json` 的每个下一阶段,把活跃 profile 移到 `rollback/profile`,把暂存 profile 移到 `.dsh/profiles/desktop`,然后重启。恢复过程会结合预写阶段与真实的 active、rollback 和 staging 目录,因此任一个写入与移动间隙中断后仍会保留或恢复一个完整 profile。 +安装器绝不原地修改活跃 profile。它把 profile 元数据复制到事务暂存目录,并使用内置 pnpm 应用精确依赖变更。激活过程停止活跃后端,在对应目录移动前先持久化 `pending.json` 的每个下一阶段,把活跃 profile 移到 `rollback/profile`,把暂存 profile 移到 `.dsh/profiles/desktop`,然后重启。恢复过程会结合预写阶段与真实的 active、rollback 和 staging 目录,因此任一个写入与移动间隙中断后仍会保留或恢复一个完整 profile。 进程生命周期 Electron 锁是 Desktop 的权威 owner。包事务锁用于纵深防御,并记录仍能修改包状态的进程:包操作之间记录 Electron,pnpm 运行期间记录已生成的 pnpm PID。Owner 变更通过已经打开的排他锁文件完成截断、写入与同步。如果 Electron 在 pnpm 执行期间终止,后续进程会发现仍存活的 worker,并拒绝启动并发的 store 或 staging 事务;该 worker 退出后,陈旧 PID 才可以恢复。 -核心物化、首次启动、插件安装和共享模块解析遵循[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)。提供 `dsh.client` 代码的桌面插件只有在完整 profile 通过健康检查后才会进入客户端依赖图。 +核心物化、首次启动、插件安装和共享模块解析遵循[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)。实际 Host 启动时会组合已启用且提供 `dsh.client` 代码的桌面插件。 ## 更新与恢复 Electron 更新只使用一个 `electron-updater` 发布流和签名 `electron-builder` 产物。该版本就是 Desktop 发布版本;不存在独立 dsh manifest、兼容范围或仅更新 dsh 的操作。前台安装会等待正在进行的后台检查,而不会把检查结果复用成安装结果。更新弹窗下载并安装 Electron 产物,然后重启进入新发布。 -打开产品窗口前,启动过程会验证当前内置运行时,并根据[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)协调外部插件。后端失败会打开 Electron 拥有的恢复窗口,不会启动版本不匹配的 dsh。 +[立即显示窗口决策](2026-09-09-desktop-immediate-window-and-direct-start.zh.md)负责本地加载页、直接启动 Host 和主窗口恢复。profile 协调遵循[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)。 `DSH_DESKTOP_AUTO_UPDATE_ENV` 默认为测试部署,也可以选择生产部署,并同时决定目标专用的 generic-provider URL 与 COS 目标。发布自动化通过 `DOWNLOAD_TEST_ORIGIN` 提供测试 HTTPS origin,并通过 `DOWNLOAD_TEST_COS_BUCKET` 或 `DOWNLOAD_PROD_COS_BUCKET` 提供各部署的 bucket;可变的测试路由与 COS 存储身份不写入源码,部署基础设施变更时无需发布新代码,而公开的生产 origin 仍固定。打包只解析公开更新 URL、禁止 electron-builder 发布、从子进程环境中删除每个 COS 凭据字段,并且只有在 electron-builder 以及每个签名或公证 hook 成功后才写入完成记录。目标上传还必须提供所选 bucket,随后会先要求完成记录、根 dsh 版本、Desktop 版本、根据版本得出的频道元数据、产物名称、大小与 SHA-512 全部一致,再读取所选凭据或发送数据。它先上传不可变且带版本的更新载荷与所有独立 blockmap,最后替换 electron-builder 生成的频道元数据,并且不会删除历史对象。稳定版本使用 `latest` 元数据名称,预发布版本则使用语义化版本的第一个预发布标识符。NSIS 把 blockmap 嵌入已签名的可执行文件,macOS ZIP 则使用独立 blockmap;两者都让 electron-updater 在平台支持时只下载变化的数据块,而应用替换与本地 pnpm staging 事务仍是两个独立操作。 ## 安全与发布策略 -核心 dsh 和私有 Desktop Host 只来自签名应用的资源树。插件安装接受桌面策略允许的 registry 包规格,不接受原始 pnpm 命令。激活前要求精确版本、锁文件完整性、经过审查的 `allowBuilds` 集合、仅限用户访问的目录权限和健康检查。 +核心 dsh 和私有 Desktop Host 只来自签名应用的资源树。插件安装接受桌面策略允许的 registry 包规格,不接受原始 pnpm 命令。激活前要求精确版本、锁文件完整性、经过审查的 `allowBuilds` 集合和仅限用户访问的目录权限。 -Electron 产物必须签名;macOS 产物必须公证。发布自动化必须通过明确的环境变量提供应用 ID、macOS Developer ID 限定名、预期 Team ID 与一套完整的 notarytool 凭据。配置加载会拒绝缺失或格式错误的标识符和不完整的公证凭据,macOS 打包还会强制签名,避免证书发现过程静默选择其他已安装身份或生成未签名发布。运行时准备会验证每个内嵌 Mach-O 文件的精确 Authority 与 Team ID,以及时间戳和 hardened-runtime 标记。签名后钩子会执行 Apple 的深度严格应用验证,并要求同一叶证书 Authority 与 Team ID 完全匹配,验证通过后才继续生成产物。Electron-builder 随后公证应用并钉票、签署 DMG。DMG 的 artifact-completion hook 会单独公证每个 DMG 并钉票,再要求其使用配置的身份、具备有效票据并通过 Gatekeeper;只有该 hook 成功,上传事件才会执行。macOS 更新使用签名 ZIP,因此 DMG 不生成 blockmap;否则钉票会让已经生成的 DMG blockmap 失效。自定义协议提供已安装的前端分发目录和活跃模块图点名的客户端文件,并拒绝路径穿越或访问这些根目录之外的内容。插件安装器 API 只对 Electron 拥有的管理 GUI 可用,不存在于浏览器应用或后端 RPC 中。 +Electron 发布产物必须签名;macOS 产物必须公证。发布自动化必须通过明确的环境变量提供应用 ID、macOS Developer ID 限定名、预期 Team ID 与一套完整的 notarytool 凭据。配置加载会拒绝缺失或格式错误的标识符和不完整的公证凭据,macOS 打包还会强制签名,避免证书发现过程静默选择其他已安装身份或生成未签名发布。运行时准备会验证每个内嵌 Mach-O 文件的精确 Authority 与 Team ID,以及时间戳和 hardened-runtime 标记。签名后钩子会执行 Apple 的深度严格应用验证,并要求同一叶证书 Authority 与 Team ID 完全匹配,验证通过后才继续生成产物。Electron-builder 随后公证应用并钉票、签署 DMG。DMG 的 artifact-completion hook 会单独公证每个 DMG 并钉票,再要求其使用配置的身份、具备有效票据并通过 Gatekeeper;只有该 hook 成功,上传事件才会执行。macOS 更新使用签名 ZIP,因此 DMG 不生成 blockmap;否则钉票会让已经生成的 DMG blockmap 失效。自定义协议提供已安装的前端分发目录和活跃模块图点名的客户端文件,并拒绝路径穿越或访问这些根目录之外的内容。插件安装器 API 只对 Electron 拥有的管理 GUI 可用,不存在于浏览器应用或后端 RPC 中。 [固定版本的 osx-sign 补丁](../../../../patches/@electron__osx-sign@1.3.3.patch)在两种已发布模块构建中使用 `lstat`,因此 Framework 的文件和目录别名不会触发重复签名。选定的上游版本能够跳过这些别名前,仍需保留该补丁。PAK 文件由外层 bundle 签名记录完整性;逐个签名会增加串行时间戳请求,但不会增加资源完整性保护。Desktop 保留全部语言文件,只跳过其单独签名。可执行代码仍使用 Developer ID 签名、安全时间戳和 hardened runtime。[签名器遍历回归测试](../../../../apps/desktop/tests/macos-signing-walk.spec.ts)使用真实 Framework 别名执行已安装依赖;发布验收仍要求严格应用验证、公证和启动。 Windows 发布打包通过 `/f` 向已配置且与 SafeNet 兼容的 SignTool 提供 `DSH_DESKTOP_WINDOWS_CER_FILE` 指定的公开 EV 叶证书,并通过必需的 `DSH_DESKTOP_WINDOWS_KEY_CONTAINER` 标识匹配的私钥。证书文件保留在源码仓库之外,私钥仍留在 USB Token 上。electron-builder hook 把每个产物交给采用 CRLF 的 `windows-sign.cmd`;该 CMD 只调用一次 SignTool,并指定 SafeNet `/kc "[{{PIN}}]=容器"` 值与 CSP、SHA-256 文件摘要和 DigiCert SHA-256 RFC 3161 时间戳。hook 不会改用其他 SignTool,也不会重试失败的请求。打包编排不会把任何 `DSH_DESKTOP_WINDOWS_*` 字段传给构建与 运行时准备子进程,只会把证书路径、SignTool 路径、密钥容器和 PIN 传入 electron-builder。签名器在已清理的 CMD 环境中只提供经过校验的签名字段;CMD 会禁用延迟展开,在 SignTool 启动前清除这些字段,并仅在 SignTool 必需的命令行中保留 PIN。所有对外诊断都会替换 PIN,而且只能允许专用构建账号和管理员检查该 runner。签名器会在企业 Code Integrity 检查 electron-builder 的临时 NSIS bootstrap 前先为该可执行文件签名;对于生成的可执行文件,只有证书表条目指向文件末尾之外时,才会在最终签名前清除该条目。SignTool、证书、容器、PIN、Token 或签名不可用时,打包会在产生未签名产物前失败。自定义协议提供已安装的前端分发目录和活跃模块图点名的客户端文件,并拒绝路径穿越或访问这些根目录之外的内容。插件安装器 API 只对 Electron 持有的管理 GUI 可用,不存在于浏览器应用或后端 RPC 中。 +Windows 打包调用强制设置 `ELECTRON_BUILDER_7Z_FILTER=BCJ`。内置的 7-Zip 24.09 编码器会为 ARM64 PE 文件自动选择 ARM64 过滤器,但 `nsis-resources-3.4.1` 中的 NSIS 解码器会在解压时遗漏这些条目。使用实际 NSIS 插件的原生解压验证表明,自动过滤会丢失两个 `node-pty` ARM64 二进制文件,而 BCJ 可以逐字节还原二者。使用兼容的过滤器能够保留依赖内容与运行时完整性,无需删除特定架构的文件或削弱校验。 + +本地 Windows 安装测试使用显式的 `--unsigned` 打包调用,并执行相同的构建和运行时准备。它清除证书输入,将产物隔离到 `unsigned-artifacts`,并省略更新器配置和发布完成记录。即使父进程环境请求未签名模式,常规打包命令也会显式选择签名模式。这样既能在没有 EV Token 时诊断安装问题,也能防止本地测试产物通过发布上传校验。 + +NSIS 先解压到私有的 `7z-out` 目录,再把文件复制到应用目录。Finish 启动应用后,默认退出清理可能与后端的文件读取重叠。[安装器 hook](../../../../apps/desktop/scripts/installer.nsh) 在 `customInstall` 阶段仅删除该解压目录,早于交互和静默启动分支。它保留包归档、插件 DLL、回滚目录、寄存器和错误状态;[原生清理 smoke](../../../../apps/desktop/tests/fixtures/installer-cleanup-smoke.nsi) 检查这些约束。把清理移入安装阶段并不会减少文件系统工作,因此必须分别测量安装总耗时与点击 Finish 到窗口出现的耗时。 + +安装器不开启直接向应用目录执行 `Nsis7z::Extract`。原生[文件占用探针](../../../../apps/desktop/tests/fixtures/installer-write-failure-smoke.nsi)会在未报错的情况下留下被占用的旧文件和新资源;暂存后执行的 `CopyFiles` 在相同替换失败时会设置错误标志。Windows 上同一份 737,557,488 字节载荷经过解压、复制和清理耗时 172.625 秒,直接解压耗时 28.031 秒,但每条路径的单次样本不足以支持放弃失败检测。计时不包括注册表修改、旧版删除及解压后的验证,也没有清空系统缓存。桌面专用载荷过滤减少需要复制的文件,同时保留安装器的替换错误处理。这种处理并不承诺完整的安装回滚。 + 打包应用会忽略开发资源和项目环境变量覆盖。只有未打包的 Electron 进程可以替换 Node.js 可执行文件、pnpm 入口、dsh 资源 或活跃项目。 在种子 store 子集之外,内置上游 Node.js 与 pnpm 预计增加约 35–50 MB 压缩体积和 120–165 MB 安装体积。分架构构建必须报告实际组件级体积增量。 @@ -135,8 +143,8 @@ Windows 发布打包通过 `/f` 向已配置且与 SafeNet 兼容的 SignTool - Electron-only GUI 安装、删除和更新普通 npm 插件包,而不暴露原始 pnpm 参数。 - 后端与浏览器应用不能修改桌面包。 - npm/CLI dsh 与 Electron 绝不从对方的 `node_modules` 解析或安装插件。 -- 在产品窗口打开前,活跃后端与 Web UI 报告相同 dsh 版本和兼容壳 API。 -- 安装、健康检查或更新失败后,当前 profile 仍然可用,或在重启后恢复 `rollback/profile`。 +- 在产品 UI 加载前,活跃后端与 Web UI 报告相同 dsh 版本和兼容壳 API。 +- 安装、后端激活或更新失败后,当前 profile 仍然可用,或在重启后恢复 `rollback/profile`。 - 一个 Desktop 版本绑定 Electron 与 dsh;每次 dsh 更新都通过一个 Electron 更新弹窗交付,并产生一次用户可见的重启。 - 共享 `.dsh` 数据在迁移或修改前拒绝不兼容的读取方。 - 不打开回环监听端口,沙箱渲染进程不能访问任意文件系统或 Electron API。 @@ -149,10 +157,10 @@ Windows 发布打包通过 `/f` 向已配置且与 SafeNet 兼容的 SignTool | 决策 | 建议 | |---|---| -| 首次启动 | 验证内置生产包并创建 profile 链接,不安装核心依赖 | +| 首次启动 | 检查内置发布元数据并创建 profile 链接,不安装核心依赖 | | 桌面 profile | 一个由 Electron 拥有的保留 profile,保存外部插件和共享包链接 | | 插件管理 | Electron-only GUI 与包服务;没有 CLI、后端或浏览器安装路径 | -| 激活 | 暂存项目、完整健康检查、记录式目录替换和一个回滚副本 | +| 激活 | 暂存项目、记录式目录替换、实际 Host 启动和一个回滚副本 | | 初始平台 | macOS arm64/x64 与 Windows x64;Linux 尚无受支持的发布目标 | | 更新行为 | 后台检查,差分下载与重启前显式确认,启动时校准 dsh | @@ -160,7 +168,7 @@ Windows 发布打包通过 `/f` 向已配置且与 SafeNet 兼容的 SignTool 插件生命周期脚本会执行第三方代码。在 GUI 安装功能交付前,获准 registry、包策略、精确版本、完整性、`allowBuilds` 和诊断都需要安全评审。 -更新绑定的 dsh 可能使插件 peer dependency 或原生模块失效。pnpm 解析与完整项目健康检查必须在替换活跃项目前拒绝暂存项目。 +更新绑定的 dsh 可能使插件 peer dependency 或原生模块失效。依赖验证在替换前拒绝不兼容的元数据;实际 Host 启动可能在替换后失败,并触发 profile 回滚。 通过 npm 安装的 dsh 与桌面 dsh 可能在共享持久化数据时使用不同版本。每个共享 owner 都必须在读取、迁移或写入前执行格式版本与进程锁。 diff --git a/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.i18n.yaml index 9729df077f..8c5be1e7e1 100644 --- a/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.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-08-desktop-bundled-runtime-and-external-plugins.md -2026-09-08-desktop-bundled-runtime-and-external-plugins.md: 8278107fb3c37aca909541886d9379a1bfd64bac -2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md: b29eb1e6cbe19d4a5b4ea23a5d6159cfd3973bcd +2026-09-08-desktop-bundled-runtime-and-external-plugins.md: a662af570c017d6fd3d38cf066c0a02e0049a84f +2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md: 6284949f6747cbe47a5de149ff598354c9b9cc58 diff --git a/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md b/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md index 8278107fb3..a662af570c 100644 --- a/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md +++ b/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md @@ -20,6 +20,10 @@ Desktop has not been released. This is its first installation format; there are The resource descriptor records the exact release, Node version, platform, architecture, shared package versions, and final file hashes. The runtime tree contains ordinary files and directories, without links back to pnpm’s build store. Native Mach-O files are signed before hashing; the application signer preserves their bytes and checks the inventory after signing. An explicit `dsh/node_modules` resource mapping bypasses electron-builder’s root `node_modules` exclusion, and the copied tree is verified before any signing or notarization. +The [Desktop file policy](../../../../apps/desktop/scripts/runtime-file-policy.ts) applies after production npm installation and before native signing or descriptor generation. npm publication lists serve library consumers and can include declarations, maps, tests, and native build inputs; they do not identify the files needed by the Desktop process. The Desktop copy omits declarations and recognized source maps because Host execution uses JavaScript and generated Typert artifacts, clears inherited `NODE_OPTIONS`, and does not enable source mapping. Reviewed plugin lifecycle builds cover native dependencies, not arbitrary TypeScript compilation. Published npm packages and external plugin directories retain their own files. Source debugger navigation is a development-package capability. + +Package-specific exclusions remove Domino tests, fs-ext compilation outputs, Koffi's Windows import library, and non-target node-pty prebuilds and debug symbols. The policy retains native executable dependencies, node-pty's ConPTY source distribution, licenses, and unrecognized assets; broad `src`, `test`, `.ts`, or `.map` exclusions could remove executable code or runtime data. Copy tests preserve sentinel assets and seal the filtered inventory; the bundled-Node [payload smoke](../../../../apps/desktop/tests/fixtures/runtime-payload-smoke.mjs) verifies PTY output, native file seeking, FFI, image conversion, and HTML parsing. Runtime preparation still verifies every retained byte and boots the complete Host with an external plugin. + Every first-party package in the dsh and private Host production closures is shared. The profile contains directory symlinks to those resource packages, or junctions on Windows. Links resolve to real host package directories under normal Node resolution. Host and plugin imports therefore share the same module instance for each resolved export. Distinct ESM and CommonJS conditional exports remain distinct entry points; a link cannot merge a package’s dual implementations. External plugins declare shared host packages as peers. Ordinary dependencies remain plugin-owned and may differ from the versions used by dsh. Validation rejects incompatible enabled peers, nested or aliased copies of shared packages, private package links, and dependency resolution through CLI or other ancestor directories. A third-party package requiring host-wide instance identity must be explicitly added to the runtime’s shared inventory; matching version numbers alone are insufficient. @@ -28,16 +32,20 @@ The profile manifest records exact installed plugin dependencies separately from ## Transactions and upgrades -First launch creates profile metadata and host links without running pnpm. A compatible release change or application relocation copies plugin files into staging, refreshes links, validates enabled peers, and boots the staged backend. Node version, platform, or architecture changes reinstall the locked plugin graph before activation. Writable staging and rollback files are independent copies, never hardlinks into the active profile. +First launch creates profile metadata and host links without running pnpm. A compatible release change or application relocation copies plugin files into staging, refreshes links, and validates enabled peers. Node version, platform, or architecture changes reinstall the locked plugin graph before activation. Writable staging and rollback files are independent copies, never hardlinks into the active profile. + +Native canonical paths identify shared package directories. Windows launchers can vary path casing without moving the application; string equality would trigger unnecessary staging and backend replacement. Transaction cleanup explicitly unlinks every nested directory link before removing real directories. A Windows fixture under Electron 44 reproduces recursive `fs.rmSync` deleting files through a nested junction, while bundled upstream Node 24.17 preserves them. Cleanup qualification therefore includes the real Electron runtime; Node-only tests do not establish target preservation. Dependency mutations install with scripts disabled, validate the plugin graph and host links, run the reviewed pending lifecycle builds, and validate again. This permits approved native dependencies to resolve host peers while preventing accidental duplicate host packages from reaching startup. The `allowBuilds` policy remains explicit; unsupported build-requiring dependencies fail the transaction. The transaction journal records source and target runtime identities and directory-move phases. Desktop waits for pnpm exit and stops the active backend before replacement. A failed or interrupted activation restores a complete profile. A runtime identity mismatch prevents the restored profile from booting under a different application release. The journal covers profile files, not arbitrary plugin startup effects or durable Session writes. -An Electron-owned recovery window remains available when the backend cannot start. Users can update, remove, disable, or re-enable plugins and retry startup. Incompatible plugins are not silently deleted or automatically downgraded. Application rollback and profile rollback are separate operations; each backend launch requires the current runtime identity. +The [immediate-window decision](2026-09-09-desktop-immediate-window-and-direct-start.md) owns direct Host startup and recovery in the main window, replacing staged backend probes. Users can open plugin management to update, remove, disable, or re-enable plugins and retry startup. Incompatible plugins are not silently deleted or automatically downgraded. Application rollback and profile rollback are separate operations; each backend launch requires the current runtime identity. ## Alternatives considered +Full runtime verification belongs to packaging. Startup reads the descriptor, validates release and target compatibility, checks shared manifests and required Host entries, and uses the recorded runtime identity for profile reuse. It neither enumerates nor hashes installed runtime files, including on first launch or after an upgrade. Reading every file before backend loading adds startup I/O proportional to the distribution size. Installed content changes therefore are not detected by a startup checksum comparison; unusable modules fail when loaded. Build-time verification still rejects changed, missing, extra, or linked files against the recorded inventory. + - **Install the bundled offline seed at startup.** This preserves an ordinary pnpm installation procedure but repeats core extraction and installation on every affected machine. Materialized resources remove that work at the cost of more application files and release-builder responsibility. - **Link all host dependencies into plugins.** This unnecessarily couples ordinary plugin dependencies to the host. Only the explicit shared inventory is linked; private packages retain independent versions. - **Use hardlinks.** They cannot represent directories, may not cross volumes, share writable bytes, and retain old inodes after application replacement. Directory symlinks and Windows junctions express the intended package target. @@ -46,6 +54,6 @@ An Electron-owned recovery window remains available when the backend cannot star ## Consequences -Core package installation is absent from first launch and compatible upgrades. Runtime integrity verification and a complete backend health check still cost startup time; no release latency or download-size improvement is claimed without measurement. Plugin preservation is conditional on host API and native runtime compatibility, with a visible recovery path when that condition fails. +Core package installation is absent from first launch and compatible upgrades. Metadata checks and backend loading still cost startup time; no release latency or download-size improvement is claimed without measurement. Plugin preservation is conditional on host API and native runtime compatibility, with a visible recovery path when that condition fails. The [Desktop README](../../../../apps/desktop/README.md) owns operational guidance. Focused tests cover real pnpm installation and approved builds, shared ESM instance identity, private dependency versions, relocation, disabled plugins, native rebuild selection, activation failures, and transaction locking. Signed installed-artifact upgrades, macOS notarization, Windows junction/native behavior, release size and startup benchmarks, and real-model GUI recordings remain release-environment qualification requirements; unit fixtures do not substitute for them. diff --git a/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md b/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md index b29eb1e6cb..6284949f67 100644 --- a/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md @@ -20,6 +20,10 @@ Desktop 尚未发布。这是它的第一种安装格式;不提供未发布 se 资源描述文件记录精确发布版本、Node 版本、平台、架构、共享包版本和最终文件哈希。运行时树包含普通文件和目录,不包含指回 pnpm 构建 store 的链接。原生 Mach-O 文件先签名再哈希;应用签名器保留其字节,并在签名后检查清单。明确的 `dsh/node_modules` 资源映射绕过 electron-builder 对根 `node_modules` 的排除,并在任何签名或公证前验证复制后的依赖树。 +[桌面文件规则](../../../../apps/desktop/scripts/runtime-file-policy.ts)在生产 npm 依赖安装之后、原生签名或描述文件生成之前执行。npm 发布列表服务于库的使用者,可以包含声明、map、测试和原生构建输入,不能直接表示桌面进程需要哪些文件。桌面副本排除声明和已识别的 source map,因为 Host 执行 JavaScript 和生成的 Typert 产物,清除继承的 `NODE_OPTIONS`,且不开启源码映射。经过审核的插件生命周期构建面向原生依赖,不执行任意 TypeScript 编译。已发布的 npm 包和外部插件目录保留各自的文件。源码调试导航由开发包提供。 + +包专用排除项包括 Domino 测试、fs-ext 编译产物、Koffi 的 Windows 导入库,以及非目标平台的 node-pty 预构建文件和调试符号。规则保留原生可执行依赖、node-pty 的 ConPTY 源分发内容、许可证和未知资源;宽泛排除 `src`、`test`、`.ts` 或 `.map` 可能移除可执行代码或运行时数据。复制测试保留哨兵资源并封存过滤后的清单;内置 Node 的[产物 smoke](../../../../apps/desktop/tests/fixtures/runtime-payload-smoke.mjs)验证 PTY 输出、原生文件定位、FFI、图像转换和 HTML 解析。运行时准备仍会验证每个保留字节,并携带外部插件启动完整 Host。 + dsh 与私有 Host 生产闭包中的每个第一方包都共享。profile 包含指向这些资源包的目录软链接,在 Windows 上使用 junction。正常 Node 解析会把链接解析到实际宿主包目录。因此,宿主与插件对每个已解析导出的导入共享同一模块实例。不同的 ESM 与 CommonJS 条件导出仍是不同入口;链接不能合并包的两套实现。 外部插件把共享宿主包声明为 peer。普通依赖由插件拥有,可以不同于 dsh 使用的版本。验证拒绝已启用插件的不兼容 peer、共享包的嵌套或别名副本、私有包链接,以及通过 CLI 或其他祖先目录解析依赖。如果第三方包需要宿主范围的实例身份,必须明确加入运行时共享清单;版本号相同并不足够。 @@ -28,16 +32,20 @@ profile manifest 分别记录精确的已安装插件依赖和已启用 bundle ## 事务与升级 -首次启动创建 profile 元数据和宿主链接,不运行 pnpm。兼容的发布变化或应用移动会把插件文件复制到 staging,刷新链接,验证已启用的 peer,并启动 staging 后端。Node 版本、平台或架构变化时,激活前会重新安装锁定的插件依赖图。可写 staging 与回滚文件使用独立副本,不使用指向活动 profile 的硬链接。 +首次启动创建 profile 元数据和宿主链接,不运行 pnpm。兼容的发布变化或应用移动会把插件文件复制到 staging,刷新链接,并验证已启用的 peer。Node 版本、平台或架构变化时,激活前会重新安装锁定的插件依赖图。可写 staging 与回滚文件使用独立副本,不使用指向活动 profile 的硬链接。 + +共享包目录使用原生规范路径识别。Windows 启动器可能改变路径大小写而不移动应用;字符串相等判断会触发不必要的 staging 和后端替换。事务清理在移除真实目录前,显式解除每一个嵌套目录链接。Windows 夹具在 Electron 44 下复现了递归 `fs.rmSync` 沿嵌套 junction 删除目标文件,而内置上游 Node 24.17 会保留它们。因此清理验收包含真实 Electron 运行时;仅在 Node 下测试不能证明目标文件会保留。 依赖修改先禁用脚本安装,验证插件依赖图和宿主链接,运行经过审查的待执行生命周期构建,再次验证。这允许已批准的原生依赖解析宿主 peer,同时阻止意外的重复宿主包进入启动过程。`allowBuilds` 策略保持明确;不受支持且需要构建的依赖会使事务失败。 事务日志记录源与目标运行时身份以及目录移动阶段。Desktop 等待 pnpm 退出,并在替换前停止活动后端。失败或中断的激活会恢复完整 profile。运行时身份不匹配会阻止恢复的 profile 在其他应用版本下启动。事务日志覆盖 profile 文件,不覆盖任意插件启动副作用或持久 Session 写入。 -后端无法启动时,Electron 拥有的恢复窗口仍可使用。用户可以更新、移除、停用或重新启用插件,并重试启动。不兼容插件不会被静默删除或自动降级。应用回滚与 profile 回滚是独立操作;每次后端启动都要求当前运行时身份。 +[立即显示窗口决策](2026-09-09-desktop-immediate-window-and-direct-start.zh.md)负责直接启动 Host 和主窗口恢复,取代 staging 后端探针。用户可以打开插件管理,更新、移除、停用或重新启用插件,并重试启动。不兼容插件不会被静默删除或自动降级。应用回滚与 profile 回滚是独立操作;每次后端启动都要求当前运行时身份。 ## 考虑过的替代方案 +完整运行时验证属于打包流程。启动读取描述文件,验证发布版本与目标平台的兼容性,检查共享包 manifest 和必要的 Host 入口,并使用记录的运行时身份复用 profile。首次启动和升级后启动都不枚举已安装运行时文件或计算其哈希。在后端加载前读取每个文件,会增加与分发体积成正比的启动 I/O。因此,启动不会通过校验和比较检测已安装内容的变化;不可用模块在加载时失败。构建时验证仍按记录的清单拒绝内容变化、缺失、多余或链接文件。 + - **启动时安装内置离线 seed。** 这保留普通 pnpm 安装流程,但会在每台受影响机器上重复核心解压与安装。物化资源消除了这部分工作,代价是更多应用文件和发布构建器责任。 - **把所有宿主依赖链接给插件。** 这会让普通插件依赖与宿主产生不必要的耦合。只链接明确的共享清单;私有包保留独立版本。 - **使用硬链接。** 它不能表示目录,可能无法跨卷,共享可写字节,并在应用替换后保留旧 inode。目录软链接和 Windows junction 能表达预期的包目标。 @@ -46,6 +54,6 @@ profile manifest 分别记录精确的已安装插件依赖和已启用 bundle ## 影响 -首次启动和兼容升级不安装核心包。运行时完整性验证和完整后端健康检查仍需要启动时间;没有测量前,不声称发布启动延迟或下载体积改善。插件保留以宿主 API 和原生运行时兼容为条件,条件不满足时提供可见的恢复入口。 +首次启动和兼容升级不安装核心包。元数据检查和后端加载仍需要启动时间;没有测量前,不声称发布启动延迟或下载体积改善。插件保留以宿主 API 和原生运行时兼容为条件,条件不满足时提供可见的恢复入口。 [Desktop README](../../../../apps/desktop/README.zh.md)负责操作说明。定向测试覆盖真实 pnpm 安装与已批准构建、共享 ESM 实例身份、私有依赖版本、应用移动、停用插件、原生重建选择、激活失败和事务锁。签名安装产物升级、macOS 公证、Windows junction 与原生行为、发布体积与启动基准,以及真实模型 GUI 录制仍是发布环境验收要求;单元夹具不能替代这些验证。 diff --git a/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.i18n.yaml new file mode 100644 index 0000000000..9a562164c3 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.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-09-desktop-immediate-window-and-direct-start.md +2026-09-09-desktop-immediate-window-and-direct-start.md: 19443bb22e5fa3e50ce027b474cd439928db9992 +2026-09-09-desktop-immediate-window-and-direct-start.zh.md: 0d79638d331bb8b66a80f37f532b36ebb1e65316 diff --git a/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.md b/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.md new file mode 100644 index 0000000000..19443bb22e --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.md @@ -0,0 +1,29 @@ +# Agent Note: Show the Desktop window before starting the Host + +Status: implemented + +English | [中文](2026-09-09-desktop-immediate-window-and-direct-start.zh.md) + +## Problem + +Waiting for backend readiness leaves users without a window during profile preparation and module loading. A complete staged health-check process repeats backend startup before the application starts its serving process, while plugin startup can still fail in the serving process. + +## Decision + +Electron creates the main window with a local loading page before profile reconciliation or Host startup. The page depends only on packaged shell assets and receives starting, ready, or error state through the owned preload. Readiness loads the product UI in that window; an actual startup failure displays its diagnostic, retry, and plugin-management actions there. Closing during loading cancels further startup work and waits for the pending child to exit. + +Profile activation starts the actual Host after journaled directory replacement. Desktop does not boot and stop a separate health-check backend. Dependency metadata and graph validation, reviewed lifecycle builds, runtime identity checks, transaction locking, and profile rollback remain in place. A failed actual startup can restore the previous profile; rollback cannot undo plugin side effects or durable Session writes. + +This partially supersedes staged backend probes and waiting to create the main window in the [packaging decision](2026-08-25-electron-desktop-packaging-and-updates.md) and [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md). Those notes retain release, signing, transport, resource ownership, and dependency-transaction rationale. Full runtime file verification remains a packaging operation. + +## Alternatives considered + +**Keep a complete staged health check.** It can reject a startup failure before replacing the active profile, but executes plugin initialization twice and cannot guarantee that the subsequent serving process will start. The actual startup result and journaled rollback provide recovery without the extra probe. + +**Keep the main window hidden until readiness.** This avoids presenting a loading page but gives users no visible progress or interaction while the backend loads. A shell-owned page can remain available when Host startup fails. + +## Consequences + +Users can see startup progress and recover from failures before the product UI is available. A responsive window does not imply that the backend is ready, and startup latency still requires installed-artifact measurement. Activation can fail after profile replacement; the journal retains recovery for profile files. + +Verification covers a delayed Host with a visible loading page, one serving startup for a fresh profile, failure and retry in the same window, plugin management during recovery, and closing while a child is starting. Installed GUI evidence complements lifecycle and transaction tests. diff --git a/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.zh.md b/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.zh.md new file mode 100644 index 0000000000..0d79638d33 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.zh.md @@ -0,0 +1,29 @@ +# Agent Note: Show the Desktop window before starting the Host + +Status: implemented + +[English](2026-09-09-desktop-immediate-window-and-direct-start.md) | 中文 + +## 问题 + +等待后端就绪会让用户在准备 profile 和加载模块期间看不到窗口。完整的 staging 健康检查进程会在应用启动服务进程前重复启动后端,而插件在实际服务进程中仍然可能启动失败。 + +## 决策 + +Electron 在协调 profile 或启动 Host 前,先创建显示本地加载页的主窗口。该页面只依赖打包的壳资源,并通过受控 preload 接收 starting、ready 或 error 状态。就绪后在同一窗口加载产品 UI;实际启动失败时,在其中显示诊断、重试和插件管理操作。加载期间关闭应用会取消后续启动工作,并等待正在启动的子进程退出。 + +profile 激活在记录式目录替换后启动实际 Host。Desktop 不会另行启动并停止一个健康检查后端。依赖元数据与依赖图验证、经过审查的生命周期构建、运行时身份检查、事务锁和 profile 回滚仍然保留。实际启动失败可以恢复旧 profile;回滚不能撤销插件副作用或持久 Session 写入。 + +本决策部分取代[打包决策](2026-08-25-electron-desktop-packaging-and-updates.zh.md)和[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)中的 staging 后端探针与延迟创建主窗口。这两份记录仍保留发布、签名、传输、资源归属与依赖事务的理由。完整运行时文件验证仍属于打包操作。 + +## 考虑过的替代方案 + +**保留完整 staging 健康检查。** 它可以在替换活动 profile 前拒绝启动失败,但会执行两次插件初始化,也不能保证后续服务进程能够启动。实际启动结果与记录式回滚无需额外探针即可提供恢复能力。 + +**在就绪前隐藏主窗口。** 这避免显示加载页,但后端加载期间用户看不到进度,也无法交互。壳拥有的页面可以在 Host 启动失败时继续使用。 + +## 后果 + +产品 UI 可用前,用户就能看到启动进度并从失败中恢复。窗口可响应并不表示后端已就绪,启动延迟仍需测量安装产物。激活可能在替换 profile 后失败;事务日志保留 profile 文件的恢复能力。 + +验证覆盖 Host 延迟时可见的加载页、新 profile 只启动一次服务进程、同一窗口中的失败与重试、恢复期间的插件管理,以及子进程正在启动时关闭应用。安装后 GUI 证据补充生命周期与事务测试。 diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 30b7fcabe7..c2b596749a 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/desktop/README.md -README.md: 6ef18d302531beb80877f23d657d8524ff396b2b -README.zh.md: 94669b2697af894f91f33068526a0c61d6ed7a8f +README.md: 86ef7d9eb63f3b7aee89a86b5c3a4866af034ac5 +README.zh.md: ea8976b880a5d5a3df5932c700237505d5f19d5a diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 6ef18d3025..86ef7d9eb6 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -14,7 +14,7 @@ The desktop application is an Electron shell around the dsh Web UI. It opens no | Shared modules | Host APIs can depend on module identity. | Desktop links every bundled first-party package into the profile using directory symlinks, or Windows junctions; ordinary plugin dependencies remain local. | | State ownership | Sharing executable dependency graphs would let CLI and Desktop change each other's dsh, Cordis, plugin, or native-module versions, while two desktop processes could race on the same profile. | Electron acquires its process-lifetime single-instance lock before any profile access and exclusively owns `$DSH_HOME/profiles/desktop` plus its package-manager state. CLI and Desktop share supported product data under `$DSH_HOME`, but never executable packages, plugin activation, lockfiles, or `node_modules`. | | Transport | A listening Web service adds port ownership, authentication, CORS, and exposure concerns; Electron and upstream Node.js also need an explicit cross-process protocol. | The application opens no Web port. `dsh-app://` carries Web assets and Fetch traffic; framed byte pipes carry bounded request and response chunks with backpressure, while Node IPC carries only child lifecycle control. | -| Activation | Dependency resolution, lifecycle scripts, native modules, and plugin startup can fail, and a process can stop during directory replacement. | Release and plugin changes install in staging, boot a complete backend health check, and replace the active profile only after success; a journal and one rollback profile cover interrupted replacement. | +| Activation | Dependency resolution, lifecycle scripts, native modules, and plugin startup can fail, and a process can stop during directory replacement. | Release and plugin changes prepare in staging and start the actual backend after profile replacement; a journal and one rollback profile cover failed or interrupted activation. | | Updates | Independent shell and dsh updates would recreate version splits, while unchanged shell blocks should not require a complete transfer. | The Electron shell, matching dsh runtime, Node.js, and pnpm form one signed update unit. Platform update artifacts may reuse unchanged blocks, but runtime version selection never splits from the Desktop release. | The [Electron packaging and update Agent Note](../../.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md) owns the rationale, alternatives, security constraints, and release qualification requirements behind these decisions. @@ -23,23 +23,23 @@ The [Electron packaging and update Agent Note](../../.agents/notes/implemented/a Electron owns `$DSH_HOME/profiles/desktop`. Its `dependencies` contains only installed external plugins at exact versions; `dsh.profile.bundles` contains the built-in bundles followed by enabled plugins. The signed application supplies dsh, the private Desktop Host, and their production packages from `resources/dsh`. Shared package links resolve to those actual directories. Both host and plugins execute in the same bundled upstream Node process, with normal realpath resolution; Desktop does not enable `--preserve-symlinks`. The CLI cannot boot or mutate this profile. -The main dsh renderer receives only the desktop protocol marker. The separate plugin window receives structured list, install, remove, update, and update-check operations; neither renderer receives filesystem access, raw Electron IPC, a shell, or arbitrary pnpm arguments. +The local startup page exposes startup status, retry, and plugin-management actions; the loaded dsh renderer receives only the desktop protocol marker. The separate plugin window receives structured list, install, remove, update, and update-check operations; neither renderer receives filesystem access, raw Electron IPC, a shell, or arbitrary pnpm arguments. -Electron chooses typed English or Chinese shell copy from its application locale and falls back to English. Menus, native dialogs, and the plugin-management renderer use the same locale payload; the repository Client UI i18n gate checks these desktop sources. +Electron chooses typed English or Chinese shell copy from its application locale and falls back to English. Menus, native dialogs, the startup page, and the plugin-management renderer use the same locale payload; the repository Client UI i18n gate checks these desktop sources. ### Runtime and plugin activation -The signed `resources/dsh/desktop-runtime.json` binds the shell version, bundled Node version, platform, architecture, shared package versions, and final file inventory. Startup verifies the tree before creating host links. Core packages are never copied into profile storage or installed by pnpm at first launch. +The signed `resources/dsh/desktop-runtime.json` binds the shell version, bundled Node version, platform, architecture, shared package versions, and final file inventory. Startup checks release metadata, shared package manifests, and required Host entry files without enumerating or hashing the runtime tree. Full file integrity verification runs during packaging. Core packages are never copied into profile storage or installed by pnpm at first launch. -1. A fresh profile creates its manifest and shared package links, then boots and stops the complete backend before activation. -2. A compatible application upgrade copies plugin files and configuration into staging, refreshes shared links, checks enabled plugins’ peer requirements, and runs the same backend health check. Plugin versions and lockfile remain unchanged; pnpm does not run. +1. The main window displays a local loading page before profile preparation or backend startup. A fresh profile creates its manifest and shared package links, then activates and starts the actual backend once. +2. A compatible application upgrade copies plugin files and configuration into staging, refreshes shared links, and checks enabled plugins’ peer requirements. Plugin versions and lockfile remain unchanged; pnpm does not run. 3. A changed bundled Node version, platform, or architecture reinstalls the locked plugin graph with scripts disabled, validates and links host packages, then runs approved pending builds and validates again. 4. Plugin add, update, and remove operations use bundled pnpm and Desktop-owned package-manager state. Reserved host packages must be peers; nested copies and aliases of shared packages fail validation. Ordinary plugin dependencies must resolve inside the profile. -5. Activation stops the active backend, records runtime identities and the next directory move, and replaces the profile while retaining one rollback copy. Copies do not share writable hardlinks. A failed activation restores the previous profile; a profile belonging to another runtime cannot boot under the current shell. +5. Activation stops the active backend, records runtime identities and the next directory move, and replaces the profile while retaining one rollback copy. Copies do not share writable hardlinks. The actual backend starts after replacement; failed activation restores the previous profile; a profile belonging to another runtime cannot boot under the current shell. -The Electron-owned plugin window remains available when backend startup fails. Disable one plugin or all plugins to retry with their files, exact versions, and configuration retained. Disabling removes only activation entries. Updating or removing an installed plugin remains available while it is disabled. Application rollback and profile rollback are separate; runtime compatibility is checked before any backend starts. +The loading page does not depend on the Host. Startup failures appear in the same main window with retry and plugin-management actions. Closing during loading waits for the pending child to exit. The Electron-owned plugin window remains available when backend startup fails. Disable one plugin or all plugins to retry with their files, exact versions, and configuration retained. Disabling removes only activation entries. Updating or removing an installed plugin remains available while it is disabled. Application rollback and profile rollback are separate; runtime compatibility is checked before any backend starts. -Package transactions hold an exclusive lock through pnpm process exit. Shared links use directory symlinks on macOS/Linux and junctions on Windows; hardlinks are unsuitable because they cannot represent package directories and would retain old file content after application replacement. Native builds follow the profile’s reviewed `allowBuilds` list; installing a new build-requiring package without approval in that list fails the transaction. +Package transactions hold an exclusive lock through pnpm process exit. Shared links use directory symlinks on macOS/Linux and junctions on Windows; cleanup removes links without deleting their targets. Canonical filesystem paths identify shared packages, so Windows path casing alone does not trigger profile activation. Native builds follow the profile’s reviewed `allowBuilds` list; installing a new build-requiring package without approval in that list fails the transaction. ## Develop @@ -90,6 +90,12 @@ The macOS arm64 command requires Apple Silicon. The macOS x64 command runs on In Each target owns its packed package inputs, prepared runtime, package set, dsh tree, pnpm preparation state, unpacked application, update metadata, and final artifacts under `apps/desktop/.desktop-build/targets//`. The Node.js archive cache remains shared under `.desktop-build/downloads` because every archive name includes its version, platform, and architecture and is verified before extraction. A target build never consumes another target's mutable preparation state. +### Runtime file selection + +Production packages first pass through npm's publication rules and dependency installation. [Desktop's file policy](scripts/runtime-file-policy.ts) then filters the immutable `resources/dsh/node_modules` copy before signing and integrity sealing. It omits TypeScript declarations, recognized JavaScript/CSS/TypeScript source maps, TypeScript build caches, Domino's test directory, selected native compiler outputs, and node-pty prebuilds for other platforms. It preserves runtime JavaScript, native modules and their DLL/EXE helpers, WASM, unknown assets, licenses, and notices. The policy does not alter npm tarballs, the bundled package manager, or user-installed plugin files. + +The packaged application runs compiled JavaScript and pre-generated Typert metadata; it does not compile TypeScript plugins. Source-level debugger navigation and editor declarations remain available in development packages. [Copy-policy tests](tests/runtime-file-policy.spec.ts) cover exclusions and retained assets; the [payload smoke](tests/fixtures/runtime-payload-smoke.mjs) exercises the resulting native modules and HTML converter under the bundled Node. + ### Upload updates `DSH_DESKTOP_AUTO_UPDATE_ENV` selects `test` or `production` for both the URL embedded during packaging and the later COS upload; an absent value selects `test`. Test packaging requires its HTTPS origin in `DOWNLOAD_TEST_ORIGIN`, while the production origin remains `https://download.deepseek.com`. Upload additionally requires the selected deployment's COS bucket in `DOWNLOAD_TEST_COS_BUCKET` or `DOWNLOAD_PROD_COS_BUCKET`. The target path is `_/harness/desktop/stable//`, where `target` is `mac-arm64`, `mac-x64`, or `win-x64`. @@ -119,8 +125,22 @@ The macOS configuration uses the required release environment instead of accepti macOS signing visits real files without following Framework symlink aliases. PAK resources retain all shipped languages and are sealed by the enclosing Framework or application signature instead of receiving individual signatures. The [release policy](../../.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md) owns the dependency patch and verification requirements. +### Unsigned Windows test installer + +On Windows x64, use the complete unsigned packaging command for local installation testing: + +```sh +pnpm run package:desktop:win:x64:unsigned +``` + +The command requires `DSH_DESKTOP_APP_ID` and the normal build dependencies, including Python and Visual C++ build tools for native modules. Set `PYTHON` to the Python executable when it is absent from `PATH`. It writes the installer to `.desktop-build/targets/win-x64/unsigned-artifacts/`, omits automatic-update configuration, strips signing credentials, and creates no release completion record. It does not require EV credentials or an update origin. The signed packaging and upload commands retain their release requirements. + ### Windows EV signing +Windows packaging fixes the 7-Zip filter to `BCJ` for compatibility with the bundled NSIS decoder. This preserves ARM64 binaries carried by dependencies in x64 installers; automatic ARM64 filtering produces entries that this decoder cannot extract. + +NSIS removes its temporary extraction tree during installation, before the completion page or an automatic launch. The installed production packages remain ordinary files; startup does not extract them again. Installation still writes the complete application tree. + Windows release packaging requires `DSH_DESKTOP_WINDOWS_CER_FILE` to identify the public GlobalSign EV leaf certificate, `DSH_DESKTOP_WINDOWS_SIGNTOOL` to identify the SafeNet-compatible SignTool executable, `DSH_DESKTOP_WINDOWS_KEY_CONTAINER` to identify the matching private-key container, and `DSH_DESKTOP_WINDOWS_TOKEN_PIN` to contain the SafeNet Token Password. The certificate file remains outside source control, and the matching private key stays on the USB token. Set the four inputs before running the fixed Windows target: ```powershell @@ -131,7 +151,7 @@ $env:DSH_DESKTOP_WINDOWS_TOKEN_PIN = '' pnpm run package:desktop:win:x64 ``` -Insert and unlock the token before packaging. The electron-builder hook passes each artifact to the CRLF `scripts/windows-sign.cmd`, which invokes the configured SignTool once with `/f`, SafeNet `/kc "[{{PIN}}]=container"`, `/csp "eToken Base Cryptographic Provider"`, a SHA-256 file digest, and a DigiCert SHA-256 RFC 3161 timestamp. The hook never substitutes electron-builder's bundled SignTool and never retries a failed signing request. Windows packaging fails instead of emitting unsigned artifacts when the SignTool, certificate, container, PIN, token, or signature is unavailable. +Insert and unlock the token before packaging. The electron-builder hook passes each artifact to the CRLF `scripts/windows-sign.cmd`, which invokes the configured SignTool once with `/f`, SafeNet `/kc "[{{PIN}}]=container"`, `/csp "eToken Base Cryptographic Provider"`, a SHA-256 file digest, and a DigiCert SHA-256 RFC 3161 timestamp. The hook never substitutes electron-builder's bundled SignTool and never retries a failed signing request. Windows release packaging fails instead of emitting unsigned artifacts when the SignTool, certificate, container, PIN, token, or signature is unavailable. The PIN cannot contain `]`, a quote, or a line break because those characters delimit the SafeNet `/kc` value or its CMD argument. The CMD disables delayed expansion so a PIN containing `!` reaches SafeNet unchanged. Packaging withholds every `DSH_DESKTOP_WINDOWS_*` field from build and runtime-preparation subprocesses, gives electron-builder only the four configured inputs, gives the signing CMD only the validated signing fields in an otherwise scrubbed environment, clears those fields before SignTool starts, and redacts SignTool diagnostics. SafeNet still requires the PIN in the SignTool process command line. Inject it as an ephemeral secret only on a controlled self-hosted Windows runner with the physical token attached; never commit it, put it in `.env`, or persist it as a Windows user or system environment variable. @@ -156,9 +176,9 @@ An unpacked artifact contains Electron, the materialized dsh production tree, up ## Updates -A packaged application checks its target-specific release stream ten seconds after the main window opens; the localized **Check for Updates…** menu item triggers the same check manually. An available release opens one native confirmation dialog. Accepting it waits for an in-flight check, downloads and verifies the signed Desktop release, stops the dsh child, and hands installation plus restart to electron-updater. The next launch reconciles the version-bound runtime before reopening the product window. +A packaged application checks its target-specific release stream ten seconds after the main window opens; the localized **Check for Updates…** menu item triggers the same check manually. An available release opens one native confirmation dialog. Accepting it waits for an in-flight check, downloads and verifies the signed Desktop release, stops the dsh child, and hands installation plus restart to electron-updater. The next launch displays the local loading page while reconciling the version-bound runtime. -Electron-builder always emits generic-provider channel metadata for the deployment selected by `DSH_DESKTOP_AUTO_UPDATE_ENV`. NSIS differential packages and the macOS ZIP target allow electron-updater to reuse unchanged blocks; the manually installed DMG is notarized without a blockmap because it is not a macOS updater payload. The runtime and shell still form one signed Desktop release. macOS signing and notarization credentials use electron-builder's standard environment; Windows EV signing uses the public certificate, validated SignTool, SafeNet container, and runner PIN described above. The required Desktop release environment selects the application and platform signature identities that the build verifies. +Signed packaging emits generic-provider channel metadata for the deployment selected by `DSH_DESKTOP_AUTO_UPDATE_ENV`. NSIS differential packages and the macOS ZIP target allow electron-updater to reuse unchanged blocks; the manually installed DMG is notarized without a blockmap because it is not a macOS updater payload. The runtime and shell still form one signed Desktop release. macOS signing and notarization credentials use electron-builder's standard environment; Windows EV signing uses the public certificate, validated SignTool, SafeNet container, and runner PIN described above. The required Desktop release environment selects the application and platform signature identities that the build verifies. ## Low-level development overrides diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 94669b2697..ea8976b880 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -14,7 +14,7 @@ | 共享模块 | 宿主 API 可能依赖模块实例身份。 | Desktop 用目录软链接或 Windows junction 把每个内置第一方包连接到 profile;普通插件依赖保留在本地。 | | 状态归属 | 共享可执行依赖图会让 CLI 与 Desktop 相互改变 dsh、Cordis、插件或原生模块版本,而两个桌面进程还可能争用同一个 profile。 | Electron 在访问任何 profile 前获取进程生命周期单实例锁,并独占 `$DSH_HOME/profiles/desktop` 及其包管理器状态。CLI 与 Desktop 共享 `$DSH_HOME` 下受支持的产品数据,但绝不共享可执行包、插件激活、锁文件或 `node_modules`。 | | 通信 | 监听 Web 服务会引入端口归属、认证、CORS 与暴露风险;Electron 与上游 Node.js 之间也需要明确的跨进程协议。 | 应用不打开 Web 端口。`dsh-app://` 承载 Web 资源和 Fetch 流量;分帧字节管道以背压传输有界请求与响应分块,Node IPC 只承载子进程生命周期控制。 | -| 激活 | 依赖解析、生命周期脚本、原生模块与插件启动都可能失败,目录替换期间进程也可能中断。 | 发布与插件变更先安装到 staging,并启动完整后端执行健康检查;只有成功后才替换活跃 profile,中断替换由事务日志和一个 rollback profile 恢复。 | +| 激活 | 依赖解析、生命周期脚本、原生模块与插件启动都可能失败,目录替换期间进程也可能中断。 | 发布与插件变更先在 staging 准备,替换 profile 后启动实际后端;失败或中断的激活由事务日志和一个 rollback profile 恢复。 | | 更新 | 桌面壳与 dsh 独立更新会重新产生版本分裂,而桌面壳未变化的数据块不应强制完整传输。 | Electron 壳、匹配的 dsh 运行时、Node.js 与 pnpm 组成一个已签名更新单元。平台更新产物可以复用未变化的数据块,但运行时版本选择绝不脱离 Desktop 发布。 | [Electron 打包与更新 Agent Note](../../.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md)记录了这些决策背后的理由、替代方案、安全约束和发布验证要求。 @@ -23,23 +23,23 @@ Electron 拥有 `$DSH_HOME/profiles/desktop`。其 `dependencies` 只包含已安装外部插件的精确版本;`dsh.profile.bundles` 包含内置 bundle,后接已启用插件。签名应用从 `resources/dsh` 提供 dsh、私有 Desktop Host 及其生产依赖。共享包链接解析到这些实际目录。宿主与插件在同一个内置上游 Node 进程中执行,使用正常的 realpath 解析;Desktop 不启用 `--preserve-symlinks`。CLI 不能启动或修改此 profile。 -dsh 主渲染进程只获得桌面协议标记。独立插件窗口获得结构化的列出、安装、移除、更新和更新检查操作;两个渲染进程都拿不到文件系统、原始 Electron IPC、shell 或任意 pnpm 参数。 +本地启动页提供启动状态、重试和插件管理操作;加载后的 dsh 渲染进程只获得桌面协议标记。独立插件窗口获得结构化的列出、安装、移除、更新和更新检查操作;两个渲染进程都拿不到文件系统、原始 Electron IPC、shell 或任意 pnpm 参数。 -Electron 根据应用 locale 选择类型化的中英文字典,并以英文作为 fallback。菜单、原生对话框与插件管理渲染进程使用同一 locale 数据;仓库的 Client UI i18n gate 会检查这些桌面源文件。 +Electron 根据应用 locale 选择类型化的中英文字典,并以英文作为 fallback。菜单、原生对话框、启动页与插件管理渲染进程使用同一 locale 数据;仓库的 Client UI i18n gate 会检查这些桌面源文件。 ### 运行时与插件激活 -签名资源中的 `resources/dsh/desktop-runtime.json` 绑定 shell 版本、内置 Node 版本、平台、架构、共享包版本和最终文件清单。启动会先验证依赖树,再创建宿主链接。首次启动不会把核心包复制到 profile 存储或通过 pnpm 安装核心包。 +签名资源中的 `resources/dsh/desktop-runtime.json` 绑定 shell 版本、内置 Node 版本、平台、架构、共享包版本和最终文件清单。启动检查发布元数据、共享包 manifest 和必要的 Host 入口文件,不枚举运行时目录树或计算其文件哈希。完整的文件完整性验证在打包时执行。首次启动不会把核心包复制到 profile 存储或通过 pnpm 安装核心包。 -1. 新 profile 创建 manifest 和共享包链接,然后启动并停止完整后端,通过后才激活。 -2. 兼容的应用升级把插件文件和配置复制到 staging,刷新共享链接,检查已启用插件的 peer 要求,并执行相同的后端健康检查。插件版本和锁文件保持不变;不运行 pnpm。 +1. 主窗口在准备 profile 或启动后端前显示本地加载页。新 profile 创建 manifest 和共享包链接,然后激活并启动一次实际后端。 +2. 兼容的应用升级把插件文件和配置复制到 staging,刷新共享链接,并检查已启用插件的 peer 要求。插件版本和锁文件保持不变;不运行 pnpm。 3. 内置 Node 版本、平台或架构变化时,禁用脚本重新安装锁定的插件依赖图,验证并链接宿主包,然后运行已批准的待执行构建并再次验证。 4. 插件添加、更新和删除使用内置 pnpm 及 Desktop 独有的包管理器状态。保留的宿主包必须声明为 peer;共享包的嵌套副本和别名会被验证拒绝。普通插件依赖必须解析到 profile 内部。 -5. 激活会停止活动后端,记录运行时身份和下一次目录移动,并替换 profile,同时保留一个回滚副本。复制不共享可写硬链接。激活失败会恢复旧 profile;属于其他运行时的 profile 不能在当前 shell 下启动。 +5. 激活会停止活动后端,记录运行时身份和下一次目录移动,并替换 profile,同时保留一个回滚副本。复制不共享可写硬链接。替换后启动实际后端;激活失败会恢复旧 profile;属于其他运行时的 profile 不能在当前 shell 下启动。 -后端启动失败时,Electron 拥有的插件窗口仍然可用。可以停用一个或全部插件后重试,同时保留其文件、精确版本和配置。停用只移除激活条目。已停用插件仍可更新或删除。应用回滚和 profile 回滚分别处理;每次启动后端前都会检查运行时兼容性。 +加载页不依赖 Host。启动失败在同一个主窗口显示,并提供重试和插件管理操作。加载期间关闭应用会等待正在启动的子进程退出。后端启动失败时,Electron 拥有的插件窗口仍然可用。可以停用一个或全部插件后重试,同时保留其文件、精确版本和配置。停用只移除激活条目。已停用插件仍可更新或删除。应用回滚和 profile 回滚分别处理;每次启动后端前都会检查运行时兼容性。 -包事务持有独占锁直到 pnpm 进程退出。共享链接在 macOS/Linux 使用目录软链接,在 Windows 使用 junction;硬链接无法表示包目录,而且应用替换后仍会保留旧文件内容,因此不适合此用途。原生构建遵循 profile 中经过审查的 `allowBuilds` 列表;新安装的包如果需要构建但未在列表中获准,事务会失败。 +包事务持有独占锁直到 pnpm 进程退出。共享链接在 macOS/Linux 使用目录软链接,在 Windows 使用 junction;清理只移除链接,不删除其目标。共享包使用文件系统的规范路径识别,因此 Windows 路径大小写变化不会单独触发 profile 激活。原生构建遵循 profile 中经过审查的 `allowBuilds` 列表;新安装的包如果需要构建但未在列表中获准,事务会失败。 ## 开发 @@ -90,6 +90,12 @@ macOS arm64 命令要求 Apple Silicon。macOS x64 命令可以在 Intel macOS 每个目标都在 `apps/desktop/.desktop-build/targets//` 下持有自己的打包输入、已准备运行时、包集合、dsh 依赖树、pnpm 准备状态、未打包应用、更新元数据和最终产物。Node.js 归档缓存继续由 `.desktop-build/downloads` 共享,因为每个归档文件名都包含版本、平台和架构,并且在解包前经过验证。目标构建绝不读取其他目标的可变准备状态。 +### 运行时文件筛选 + +生产包首先经过 npm 发布规则和依赖安装。[桌面文件规则](scripts/runtime-file-policy.ts)随后在签名和完整性封存之前过滤不可变的 `resources/dsh/node_modules` 副本。它排除 TypeScript 声明、明确属于 JavaScript/CSS/TypeScript 的 source map、TypeScript 构建缓存、Domino 测试目录、指定的原生编译产物,以及其他平台的 node-pty 预构建文件。它保留运行时 JavaScript、原生模块及其 DLL/EXE 辅助程序、WASM、未知资源、许可证和声明。规则不会修改 npm tarball、内置包管理器或用户安装的插件文件。 + +打包应用运行编译后的 JavaScript 和预生成的 Typert 元数据,不编译 TypeScript 插件。源码级调试导航和编辑器声明仍可从开发包中获取。[复制规则测试](tests/runtime-file-policy.spec.ts)覆盖排除项和保留资源;[产物 smoke](tests/fixtures/runtime-payload-smoke.mjs)使用内置 Node 验证过滤后的原生模块和 HTML 转换器。 + ### 上传更新 `DSH_DESKTOP_AUTO_UPDATE_ENV` 同时选择打包时写入的更新 URL 与后续 COS 上传目标,可取 `test` 或 `production`;未设置时使用 `test`。测试打包必须通过 `DOWNLOAD_TEST_ORIGIN` 提供 HTTPS origin,生产 origin 仍为 `https://download.deepseek.com`。上传还必须通过 `DOWNLOAD_TEST_COS_BUCKET` 或 `DOWNLOAD_PROD_COS_BUCKET` 提供所选环境的 COS bucket。目标路径为 `_/harness/desktop/stable//`,其中 `target` 为 `mac-arm64`、`mac-x64` 或 `win-x64`。 @@ -119,8 +125,22 @@ macOS 配置使用必填发布环境,不会接受钥匙串中最先发现的 macOS 签名遍历真实文件,不跟随 Framework 的软链接别名。PAK 资源保留全部随附语言,由外层 Framework 或应用签名记录完整性,不逐个签名。[发布策略](../../.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md)负责依赖补丁和验证要求。 +### 未签名 Windows 测试安装包 + +在 Windows x64 上,使用完整的未签名打包命令进行本地安装测试: + +```sh +pnpm run package:desktop:win:x64:unsigned +``` + +该命令要求设置 `DSH_DESKTOP_APP_ID` 并具备常规构建依赖,包括编译原生模块所需的 Python 和 Visual C++ 构建工具。Python 不在 `PATH` 中时,将 `PYTHON` 设置为其可执行文件路径。命令将安装包写入 `.desktop-build/targets/win-x64/unsigned-artifacts/`,省略自动更新配置,清除签名凭据,且不生成发布完成记录。它不需要 EV 凭据或更新源地址。签名打包和上传命令仍遵循正式发布要求。 + ### Windows EV 签名 +Windows 打包将 7-Zip 过滤器固定为 `BCJ`,以兼容内置的 NSIS 解码器。这样可以保留 x64 安装包中由依赖携带的 ARM64 二进制文件;自动 ARM64 过滤会生成该解码器无法解压的条目。 + +NSIS 在安装阶段清理临时解压目录,完成后才显示完成页或自动启动应用。已安装的生产依赖保持为普通文件;启动时不会再次解压。安装仍会写入完整的应用目录树。 + Windows 发布打包要求 `DSH_DESKTOP_WINDOWS_CER_FILE` 标识公开的 GlobalSign EV 叶证书,要求 `DSH_DESKTOP_WINDOWS_SIGNTOOL` 标识与 SafeNet 兼容的 SignTool 可执行文件,要求 `DSH_DESKTOP_WINDOWS_KEY_CONTAINER` 标识匹配的私钥容器,并要求 `DSH_DESKTOP_WINDOWS_TOKEN_PIN` 包含 SafeNet Token Password。证书文件保留在源码仓库之外,匹配的私钥仍位于 USB Token。运行固定 Windows 目标前设置这四个输入: ```powershell @@ -131,7 +151,7 @@ $env:DSH_DESKTOP_WINDOWS_TOKEN_PIN = '' pnpm run package:desktop:win:x64 ``` -打包前插入并解锁 Token。electron-builder hook 把每个产物交给采用 CRLF 的 `scripts/windows-sign.cmd`;该 CMD 只调用一次已配置的 SignTool,并指定 `/f`、SafeNet `/kc "[{{PIN}}]=容器"`、`/csp "eToken Base Cryptographic Provider"`、SHA-256 文件摘要和 DigiCert SHA-256 RFC 3161 时间戳。hook 不会改用 electron-builder 内置的 SignTool,也不会重试失败的签名请求。SignTool、证书、容器、PIN、Token 或签名不可用时,Windows 打包会失败,不会生成未签名产物。 +打包前插入并解锁 Token。electron-builder hook 把每个产物交给采用 CRLF 的 `scripts/windows-sign.cmd`;该 CMD 只调用一次已配置的 SignTool,并指定 `/f`、SafeNet `/kc "[{{PIN}}]=容器"`、`/csp "eToken Base Cryptographic Provider"`、SHA-256 文件摘要和 DigiCert SHA-256 RFC 3161 时间戳。hook 不会改用 electron-builder 内置的 SignTool,也不会重试失败的签名请求。SignTool、证书、容器、PIN、Token 或签名不可用时,Windows 发布打包会失败,不会生成未签名产物。 PIN 不能包含 `]`、引号或换行,因为这些字符用于分隔 SafeNet `/kc` 值或对应的 CMD 参数。CMD 会禁用延迟展开,因此包含 `!` 的 PIN 可以原样到达 SafeNet。打包流程不会把任何 `DSH_DESKTOP_WINDOWS_*` 字段传给构建与 运行时准备子进程;它只向 electron-builder 提供四个配置输入,在其他字段已经清理的环境中只向签名 CMD 提供经过校验的签名字段,在 SignTool 启动前清除这些字段,并遮盖 SignTool 诊断。SafeNet 仍要求 PIN 出现在 SignTool 进程命令行中。只能在连接了物理 Token 的受控 self-hosted Windows runner 上把它注入为临时 secret;绝不能提交该值、把它写进 `.env`,或持久保存为 Windows 用户或系统环境变量。 @@ -156,9 +176,9 @@ pnpm run prepare:desktop ## 更新 -打包应用会在主窗口打开十秒后检查目标专用的发布流;本地化的 **检查更新…** 菜单项会手动触发同一检查。发现可用版本时,应用打开一个原生确认弹窗。用户确认后,应用等待正在进行的检查完成,下载并验证已签名的 Desktop 发布、停止 dsh 子进程,并把安装与重启交给 electron-updater。下次启动会先校准版本绑定的运行时,再重新打开产品窗口。 +打包应用会在主窗口打开十秒后检查目标专用的发布流;本地化的 **检查更新…** 菜单项会手动触发同一检查。发现可用版本时,应用打开一个原生确认弹窗。用户确认后,应用等待正在进行的检查完成,下载并验证已签名的 Desktop 发布、停止 dsh 子进程,并把安装与重启交给 electron-updater。下次启动在显示本地加载页的同时校准版本绑定的运行时。 -Electron-builder 始终为 `DSH_DESKTOP_AUTO_UPDATE_ENV` 选择的部署生成 generic-provider 频道元数据。NSIS 差分包与 macOS ZIP 目标让 electron-updater 可以复用未变化的数据块;供手动安装的 DMG 经过公证,但不生成 blockmap,因为它不是 macOS updater 的载荷。运行时与桌面壳仍属于同一个签名 Desktop 发布。macOS 签名与公证凭据使用 electron-builder 的标准环境变量;Windows EV 签名使用上文所述的公开证书、已验证 SignTool、SafeNet 容器和 runner PIN。必填 Desktop 发布环境选择构建所验证的应用身份与平台签名身份。 +签名打包为 `DSH_DESKTOP_AUTO_UPDATE_ENV` 选择的部署生成 generic-provider 频道元数据。NSIS 差分包与 macOS ZIP 目标让 electron-updater 可以复用未变化的数据块;供手动安装的 DMG 经过公证,但不生成 blockmap,因为它不是 macOS updater 的载荷。运行时与桌面壳仍属于同一个签名 Desktop 发布。macOS 签名与公证凭据使用 electron-builder 的标准环境变量;Windows EV 签名使用上文所述的公开证书、已验证 SignTool、SafeNet 容器和 runner PIN。必填 Desktop 发布环境选择构建所验证的应用身份与平台签名身份。 ## 底层开发覆盖项 diff --git a/apps/desktop/electron-builder.config.d.mts b/apps/desktop/electron-builder.config.d.mts index 760f8cb8a1..8a78f3a68a 100644 --- a/apps/desktop/electron-builder.config.d.mts +++ b/apps/desktop/electron-builder.config.d.mts @@ -19,8 +19,11 @@ export interface DesktopElectronBuilderConfig { readonly sign: boolean readonly writeUpdateInfo: boolean } + readonly nsis: { + readonly include: string + } readonly artifactBuildCompleted: (artifact: { readonly file: string }) => Promise | undefined - readonly publish: readonly [{ readonly provider: 'generic', readonly url: string }] + readonly publish: readonly [{ readonly provider: 'generic', readonly url: string }] | null } /** diff --git a/apps/desktop/electron-builder.config.mjs b/apps/desktop/electron-builder.config.mjs index aee7a5b375..4dfb363693 100644 --- a/apps/desktop/electron-builder.config.mjs +++ b/apps/desktop/electron-builder.config.mjs @@ -1,4 +1,5 @@ import { join } from 'node:path' +import { fileURLToPath } from 'node:url' import { resolveDesktopAppId, resolveMacOSNotarizationEnvironment, @@ -11,7 +12,7 @@ import { installWindowsNsisBootstrapSigner, } from './scripts/windows-sign.mjs' import { resolveDesktopAutoUpdateConfig } from './scripts/desktop-auto-update-environment.mjs' -import { desktopTargetBuildPaths } from './scripts/desktop-build-paths.mjs' +import { desktopTargetBuildPaths, resolveDesktopBuildTarget } from './scripts/desktop-build-paths.mjs' /** * Create electron-builder configuration from one release environment. @@ -29,11 +30,16 @@ export function createElectronBuilderConfig( const targetPlatform = env.DSH_DESKTOP_TARGET_PLATFORM const resolvedPlatform = targetPlatform ?? hostPlatform const resolvedArch = env.DSH_DESKTOP_TARGET_ARCH ?? hostArch + if (env.DSH_DESKTOP_UNSIGNED !== undefined && !['0', '1'].includes(env.DSH_DESKTOP_UNSIGNED)) { + throw new Error('desktop package: DSH_DESKTOP_UNSIGNED must be 0 or 1') + } + const unsigned = env.DSH_DESKTOP_UNSIGNED === '1' + if (unsigned && resolvedPlatform !== 'win32') throw new Error('desktop package: unsigned builds require Windows') const packagesMacOS = targetPlatform === 'darwin' || (targetPlatform === undefined && hostPlatform === 'darwin') const packagesWindows = targetPlatform === 'win32' const macOSSigning = packagesMacOS ? resolveMacOSSigningEnvironment(env) : undefined if (packagesMacOS) resolveMacOSNotarizationEnvironment(env) - const windowsSigner = packagesWindows + const windowsSigner = packagesWindows && !unsigned ? createWindowsTokenSigner({ certificateFile: env.DSH_DESKTOP_WINDOWS_CER_FILE, signTool: env.DSH_DESKTOP_WINDOWS_SIGNTOOL, @@ -44,13 +50,13 @@ export function createElectronBuilderConfig( if (windowsSigner !== undefined) { installWindowsNsisBootstrapSigner({ sign: windowsSigner }) } - const update = resolveDesktopAutoUpdateConfig(env, resolvedPlatform, resolvedArch) - const buildPaths = desktopTargetBuildPaths(update.target) + const update = unsigned ? undefined : resolveDesktopAutoUpdateConfig(env, resolvedPlatform, resolvedArch) + const buildPaths = desktopTargetBuildPaths(resolveDesktopBuildTarget(env, hostPlatform, hostArch)) return { appId, productName: 'DeepSeek Harness', artifactName: 'deepseek-harness-${version}-${os}-${arch}.${ext}', - directories: { output: buildPaths.artifacts }, + directories: { output: unsigned ? join(buildPaths.root, 'unsigned-artifacts') : buildPaths.artifacts }, asar: true, files: [ 'lib/*.js', @@ -80,13 +86,13 @@ export function createElectronBuilderConfig( }, afterPack: async context => { const { verifyDesktopRuntime } = await import('./lib/types/runtime-tree.js') - verifyDesktopRuntime(join(context.packager.getResourcesDir(context.appOutDir), 'dsh'), + await verifyDesktopRuntime(join(context.packager.getResourcesDir(context.appOutDir), 'dsh'), context.packager.appInfo.version, { platform: resolvedPlatform, arch: resolvedArch }) }, afterSign: async context => { if (context.electronPlatformName !== 'darwin') return const { verifyDesktopRuntime } = await import('./lib/types/runtime-tree.js') - verifyDesktopRuntime(join(context.appOutDir, `${context.packager.appInfo.productFilename}.app`, 'Contents', 'Resources', 'dsh'), + await verifyDesktopRuntime(join(context.appOutDir, `${context.packager.appInfo.productFilename}.app`, 'Contents', 'Resources', 'dsh'), context.packager.appInfo.version, { platform: 'darwin', arch: resolvedArch }) verifyMacOSSignatureAfterSign(context, macOSSigning ?? resolveMacOSSigningEnvironment(env)) }, @@ -99,7 +105,7 @@ export function createElectronBuilderConfig( ) }, win: { - forceCodeSigning: true, + forceCodeSigning: !unsigned, signtoolOptions: { sign: windowsSigner, signingHashAlgorithms: ['sha256'], @@ -111,11 +117,12 @@ export function createElectronBuilderConfig( target: ['AppImage'], }, nsis: { + include: fileURLToPath(new URL('./scripts/installer.nsh', import.meta.url)), oneClick: false, allowToChangeInstallationDirectory: true, differentialPackage: true, }, - publish: [{ provider: 'generic', url: update.publicUrl }], + publish: update === undefined ? null : [{ provider: 'generic', url: update.publicUrl }], } } diff --git a/apps/desktop/package.json b/apps/desktop/package.json index e8d38981c5..240644a84f 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -22,6 +22,7 @@ "package:mac:x64": "tsx scripts/package-target.ts mac-x64", "package:mac:x64:dir": "tsx scripts/package-target.ts mac-x64 --dir", "package:win:x64": "tsx scripts/package-target.ts win-x64", + "package:win:x64:unsigned": "tsx scripts/package-target.ts win-x64 --unsigned", "package:win:x64:dir": "tsx scripts/package-target.ts win-x64 --dir", "upload:mac:arm64": "tsx scripts/upload-target.ts mac-arm64", "upload:mac:x64": "tsx scripts/upload-target.ts mac-x64", diff --git a/apps/desktop/renderer/plugin-manager.js b/apps/desktop/renderer/plugin-manager.js index 6599c5b9d5..c87460a49f 100644 --- a/apps/desktop/renderer/plugin-manager.js +++ b/apps/desktop/renderer/plugin-manager.js @@ -32,8 +32,8 @@ async function main() { async function render() { const backend = await api.backend.status() - document.querySelector('#recovery').hidden = backend.ready - document.querySelector('#startup-error').textContent = backend.error ?? '' + document.querySelector('#recovery').hidden = backend.phase !== 'error' + document.querySelector('#startup-error').textContent = backend.phase === 'error' ? backend.message : '' const plugins = await api.plugins.list() list.replaceChildren(...plugins.map(plugin => { const item = document.createElement('li') diff --git a/apps/desktop/renderer/startup.css b/apps/desktop/renderer/startup.css new file mode 100644 index 0000000000..a15f246ee3 --- /dev/null +++ b/apps/desktop/renderer/startup.css @@ -0,0 +1,16 @@ +:root { color-scheme: light dark; font-family: system-ui, sans-serif; color: #202124; background: #fafafa; } +body { margin: 0; min-height: 100vh; display: grid; place-items: center; } +main { width: min(560px, calc(100vw - 64px)); padding: 40px 0; text-align: center; } +h1 { margin: 24px 0 12px; font-size: 22px; font-weight: 600; } +p { color: #666; line-height: 1.6; } +#spinner { width: 32px; height: 32px; margin: auto; border: 3px solid #dedee3; border-top-color: #4d6bfe; border-radius: 50%; animation: spin 0.9s linear infinite; } +#error { padding: 16px; border: 1px solid #e0e0e5; border-radius: 10px; max-height: 220px; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; text-align: left; font: 13px/1.6 ui-monospace, monospace; } +#actions { margin-top: 24px; } +button { margin: 4px; padding: 10px 18px; border: 1px solid transparent; border-radius: 8px; color: #fff; background: #4d6bfe; font: inherit; cursor: pointer; } +button.secondary { color: inherit; background: transparent; border-color: #c8c8d0; } +button:disabled { opacity: 0.5; cursor: default; } +button:focus-visible { outline: 2px solid #4d6bfe; outline-offset: 3px; } +[hidden] { display: none !important; } +@keyframes spin { to { transform: rotate(360deg); } } +@media (prefers-color-scheme: dark) { :root { color: #ededf0; background: #171719; } p { color: #aaaab3; } #error { border-color: #38383f; } #spinner { border-color: #38383f; border-top-color: #6a85ff; } } +@media (prefers-reduced-motion: reduce) { #spinner { animation: none; } } diff --git a/apps/desktop/renderer/startup.html b/apps/desktop/renderer/startup.html new file mode 100644 index 0000000000..e0980a5b49 --- /dev/null +++ b/apps/desktop/renderer/startup.html @@ -0,0 +1,23 @@ + + + + + + + + + + +
+ +

+

+ + +
+ + + diff --git a/apps/desktop/renderer/startup.js b/apps/desktop/renderer/startup.js new file mode 100644 index 0000000000..ce8c64361a --- /dev/null +++ b/apps/desktop/renderer/startup.js @@ -0,0 +1,37 @@ +const api = window.dshDesktop + +async function main() { + const { id, messages } = await api.locale() + document.documentElement.lang = id + document.querySelector('#page-title').textContent = messages.startupLoading + document.querySelector('#retry').textContent = messages.retry + document.querySelector('#plugins').textContent = messages.managePlugins + function render(state) { + const failed = state.phase === 'error' + document.querySelector('main').setAttribute('aria-busy', String(!failed)) + document.querySelector('#spinner').hidden = failed + document.querySelector('#title').textContent = failed ? messages.startupFailed : messages.startupLoading + document.querySelector('#description').textContent = failed ? messages.startupErrorDescription : messages.startupLoadingDescription + document.querySelector('#error').hidden = !failed + document.querySelector('#error').textContent = failed ? state.message : '' + document.querySelector('#actions').hidden = !failed + document.querySelector('#retry').disabled = !failed + document.querySelector('#plugins').disabled = !failed + } + let changed = false + const unsubscribe = api.backend.subscribe(state => { changed = true; render(state) }) + window.addEventListener('pagehide', unsubscribe, { once: true }) + const initial = await api.backend.status() + if (!changed) render(initial) + document.querySelector('#retry').addEventListener('click', async () => { + render({ phase: 'starting' }) + try { await api.backend.retry() } + catch (error) { render({ phase: 'error', message: error instanceof Error ? error.message : String(error) }) } + }) + document.querySelector('#plugins').addEventListener('click', async () => { + try { await api.openPlugins() } + catch (error) { render({ phase: 'error', message: error instanceof Error ? error.message : String(error) }) } + }) +} + +void main() diff --git a/apps/desktop/scripts/installer.nsh b/apps/desktop/scripts/installer.nsh new file mode 100644 index 0000000000..3bd8622c72 --- /dev/null +++ b/apps/desktop/scripts/installer.nsh @@ -0,0 +1,17 @@ +!include "LogicLib.nsh" + +!macro customInstall + Push $0 + StrCpy $0 0 + ${If} ${Errors} + StrCpy $0 1 + ${EndIf} + ; Finish can launch the app while NSIS removes its remaining plugin directory. + RMDir /r "$PLUGINSDIR\7z-out" + ${If} $0 == 1 + SetErrors + ${Else} + ClearErrors + ${EndIf} + Pop $0 +!macroend diff --git a/apps/desktop/scripts/package-target.ts b/apps/desktop/scripts/package-target.ts index c5b0bc2667..cdbe8d167a 100644 --- a/apps/desktop/scripts/package-target.ts +++ b/apps/desktop/scripts/package-target.ts @@ -72,6 +72,25 @@ export function withoutWindowsSigningEnvironment(environment: NodeJS.ProcessEnv) .filter(([name]) => !name.startsWith(WINDOWS_SIGNING_ENV_PREFIX))) } +/** + * Select signing and NSIS-compatible archive filters for electron-builder. + * @param environment - Target packaging environment. + * @param unsigned - Whether to create a local unsigned Windows artifact. + * @returns Packaging environment without certificate inputs for unsigned builds. + */ +export function desktopElectronBuilderEnvironment(environment: NodeJS.ProcessEnv, unsigned: boolean): NodeJS.ProcessEnv { + const selected: NodeJS.ProcessEnv = { ...environment, DSH_DESKTOP_UNSIGNED: unsigned ? '1' : '0' } + // The bundled NSIS decoder cannot extract 7-Zip's automatic ARM64-filtered entries. + if (environment.DSH_DESKTOP_TARGET_PLATFORM === 'win32') selected.ELECTRON_BUILDER_7Z_FILTER = 'BCJ' + if (!unsigned) return selected + return { + ...Object.fromEntries(Object.entries(withoutWindowsSigningEnvironment(selected)) + .filter(([name]) => !/^(?:WIN_)?CSC_/iu.test(name))), + CSC_IDENTITY_AUTO_DISCOVERY: 'false', + DSH_DESKTOP_UNSIGNED: '1', + } +} + /** * Remove upload-only COS credentials from every packaging subprocess. * @param environment - Packaging command environment. @@ -152,6 +171,7 @@ interface DesktopPackageInvocation { readonly target: DesktopPackageTarget readonly directory: boolean readonly prepareOnly: boolean + readonly unsigned: boolean } function hostTargetName(platform: NodeJS.Platform, arch: string): DesktopPackageTargetName { @@ -178,14 +198,18 @@ export function parseDesktopPackageInvocation( options: { dir: { type: 'boolean', default: false }, 'prepare-only': { type: 'boolean', default: false }, + unsigned: { type: 'boolean', default: false }, }, }) if (positionals.length > 1) throw new Error('desktop package: expected at most one target') const name = positionals[0] ?? hostTargetName(hostPlatform, hostArch) + if (values.unsigned && name !== 'win-x64') throw new Error('desktop package: --unsigned requires win-x64') + if (values.unsigned && values['prepare-only']) throw new Error('desktop package: --unsigned cannot use --prepare-only') return { target: resolveDesktopPackageTarget(name, hostPlatform, hostArch), directory: values.dir, prepareOnly: values['prepare-only'], + unsigned: values.unsigned, } } @@ -240,7 +264,7 @@ async function main(): Promise { const { target } = invocation const buildPaths = desktopTargetBuildPaths(target.name) const releaseRecordPath = join(buildPaths.artifacts, desktopBuildRecordFilename(target.name)) - if (!invocation.prepareOnly) { + if (!invocation.prepareOnly && !invocation.unsigned) { rmSync(releaseRecordPath, { force: true }) rmSync(`${releaseRecordPath}.tmp`, { force: true }) } @@ -250,9 +274,9 @@ async function main(): Promise { DSH_DESKTOP_TARGET_PLATFORM: target.platform, DSH_DESKTOP_TARGET_ARCH: target.arch, } - const electronBuilderEnv = { ...targetEnv } + const electronBuilderEnv = desktopElectronBuilderEnvironment(targetEnv, invocation.unsigned) for (const name of WINDOWS_SIGNING_ENV_NAMES) { - if (process.env[name] !== undefined) electronBuilderEnv[name] = process.env[name] + if (!invocation.unsigned && process.env[name] !== undefined) electronBuilderEnv[name] = process.env[name] } await runPnpm(['run', 'build:official'], buildEnv, REPOSITORY_ROOT) await runPnpm(['run', 'release:pack', '--family', 'dsh', '--out', buildPaths.packedDsh], buildEnv, REPOSITORY_ROOT) @@ -279,7 +303,7 @@ async function main(): Promise { await runPnpm(['run', 'prepare:dsh'], targetEnv) if (invocation.prepareOnly) return await runPnpm(desktopElectronBuilderArguments(target, invocation.directory), electronBuilderEnv) - if (!invocation.directory) writeReleaseRecord(target, electronBuilderEnv, buildPaths.artifacts) + if (!invocation.directory && !invocation.unsigned) writeReleaseRecord(target, electronBuilderEnv, buildPaths.artifacts) } if (process.argv[1] !== undefined && import.meta.filename === resolve(process.argv[1])) await main() diff --git a/apps/desktop/scripts/prepare-dsh.ts b/apps/desktop/scripts/prepare-dsh.ts index d030a05e1a..eb223fbc7e 100644 --- a/apps/desktop/scripts/prepare-dsh.ts +++ b/apps/desktop/scripts/prepare-dsh.ts @@ -3,7 +3,7 @@ import { spawn } from 'node:child_process' import { copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' -import { delimiter, dirname, join, resolve } from 'node:path' +import { delimiter, dirname, join, relative, resolve } from 'node:path' import { createRuntimeProjectMetadata } from '../src/project-manager.ts' import { DESKTOP_HOST_PROTOCOL_VERSION } from '../src/host-protocol.ts' import { parseDesktopRelease, type DesktopRelease } from '../src/release.ts' @@ -25,6 +25,7 @@ import { signMacOSRuntime, } from './macos-runtime.ts' import { resolveDesktopBuildTarget, resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs' +import { desktopRuntimeFileExclusion } from './runtime-file-policy.ts' const APP_ROOT = resolve(import.meta.dirname, '..') const BUILD_PATHS = resolveDesktopTargetBuildPaths() @@ -115,10 +116,13 @@ async function main(): Promise { ) await runPnpm(['install', '--prod', '--frozen-lockfile', '--trust-lockfile']) const packageSet = readDesktopCorePackageSet(BUILD_ROOT, release.version) + const targetName = resolveDesktopBuildTarget() + const target = { platform: process.platform, arch: targetName.endsWith('arm64') ? 'arm64' : 'x64' } + const modules = join(BUILD_ROOT, 'node_modules') mkdirSync(DSH_OUTPUT_ROOT, { recursive: true }) - cpSync(join(BUILD_ROOT, 'node_modules'), join(DSH_OUTPUT_ROOT, 'node_modules'), { + cpSync(modules, join(DSH_OUTPUT_ROOT, 'node_modules'), { recursive: true, dereference: true, - filter: source => !source.split(/[\\/]/u).some(part => part === '.bin' || part === '.pnpm' || part === '.modules.yaml' || part === '.pnpm-workspace-state-v1.json'), + filter: source => desktopRuntimeFileExclusion(relative(modules, source), target) === undefined, }) writeFileSync(join(DSH_OUTPUT_ROOT, 'package.json'), `${JSON.stringify({ name: '@deepseek-ai/dsh-desktop-runtime', private: true, version: release.version, type: 'module', @@ -132,12 +136,10 @@ async function main(): Promise { if (process.platform === 'darwin') { await signMacOSRuntime(DSH_OUTPUT_ROOT, resolveDesktopAppId(process.env), resolveMacOSSigningEnvironment(process.env)) } - const targetName = resolveDesktopBuildTarget() - const target = { platform: process.platform, arch: targetName.endsWith('arm64') ? 'arm64' : 'x64' } writeDesktopRuntime(DSH_OUTPUT_ROOT, release, packageSet.packages.map(entry => entry.name), target) - const descriptor = verifyDesktopRuntime(DSH_OUTPUT_ROOT, release.version, target) + const descriptor = await verifyDesktopRuntime(DSH_OUTPUT_ROOT, release.version, target) await smokeDesktopRuntime(DSH_OUTPUT_ROOT, NODE, descriptor) - verifyDesktopRuntime(DSH_OUTPUT_ROOT, release.version, target) + await verifyDesktopRuntime(DSH_OUTPUT_ROOT, release.version, target) } catch (error) { rmSync(DSH_OUTPUT_ROOT, { recursive: true, force: true }) throw error diff --git a/apps/desktop/scripts/runtime-file-policy.ts b/apps/desktop/scripts/runtime-file-policy.ts new file mode 100644 index 0000000000..9e6722009d --- /dev/null +++ b/apps/desktop/scripts/runtime-file-policy.ts @@ -0,0 +1,40 @@ +/** Desktop-only omissions from already installed production npm packages. */ + +/** + * Identify build and diagnostic files omitted from the immutable Desktop runtime. + * Unrecognized assets and target runtime binaries are retained. Paths name the copied + * node_modules tree, including nested package containers. + * @param path - Path relative to the production node_modules directory. + * @param target - Platform and architecture of the bundled Node executable. + * @returns Omission reason, or undefined when the entry must be copied. + */ +export function desktopRuntimeFileExclusion( + path: string, target: { platform: NodeJS.Platform; arch: string }, +): string | undefined { + const parts = path.split(/[\\/]/u) + if (parts.some(part => ['.bin', '.pnpm', '.modules.yaml', '.pnpm-workspace-state-v1.json'].includes(part))) { + return 'package-manager metadata' + } + const file = parts.at(-1) ?? '' + if (/\.(?:[cm]?[jt]s|css)\.map$/u.test(file)) return 'source map' + if (/\.d\.[cm]?ts$/u.test(file)) return 'TypeScript declaration' + if (/\.tsbuildinfo$/u.test(file)) return 'TypeScript build cache' + const packageParts = parts.slice(parts.lastIndexOf('node_modules') + 1) + const nameParts = packageParts[0]?.startsWith('@') ? 2 : 1 + const name = packageParts.slice(0, nameParts).join('/') + const entry = packageParts.slice(nameParts).join('/') + if (name === 'fs-ext' && /^build\/(?:Release|Debug)\/(?:obj(?:\/|$)|fs_ext\.(?:exp|lib|pdb|iobj|ipdb)$)/u.test(entry)) { + return 'fs-ext compiler output' + } + if (name === 'fs-ext' && /^build\/(?:binding\.sln|config\.gypi|fs_ext\.vcxproj(?:\.filters)?)$/u.test(entry)) { + return 'fs-ext build configuration' + } + if (name === '@mixmark-io/domino' && (entry === 'test' || entry.startsWith('test/'))) return 'Domino test fixtures' + if (name === 'node-pty' && entry.startsWith('prebuilds/')) { + const platform = packageParts[nameParts + 1] + if (platform !== undefined && platform !== `${target.platform}-${target.arch}`) return 'node-pty other platform' + if (file.endsWith('.pdb')) return 'node-pty debug symbols' + } + if (name === '@koromix/koffi-win32-x64' && entry === 'win32_x64/koffi.lib') return 'Koffi import library' + return undefined +} diff --git a/apps/desktop/src/backend-controller.ts b/apps/desktop/src/backend-controller.ts new file mode 100644 index 0000000000..03c49369ab --- /dev/null +++ b/apps/desktop/src/backend-controller.ts @@ -0,0 +1,146 @@ +/** Owns one backend startup and its quiescent teardown independently of windows. */ + +/** Backend availability presented by the desktop window. */ +export type DesktopBackendState = { readonly phase: 'starting' } | { readonly phase: 'ready' } | { readonly phase: 'error'; readonly message: string } + +/** Child lifecycle owned by the desktop backend controller. */ +export interface DesktopBackendHost { + /** @returns Readiness after the child accepts application requests. */ + start(): Promise + /** @returns Completion of child exit. */ + stop(): Promise +} + +interface Attempt { + cancelled: boolean + failure?: Error + host?: Host + cleanup?: Promise +} + +/** Serializes retries and prevents children from outliving a closed window. */ +export class DesktopBackendController { + private current: DesktopBackendState = { phase: 'starting' } + private attempt: Attempt | undefined + private pending: Promise | undefined + private stopping: Promise | undefined + private closed = false + + /** + * @param createHost - Allocate a child and route its fatal failures to the supplied callback. + * @param publish - Receive availability changes until the controller closes. + */ + constructor( + private readonly createHost: (onFailure: (error: Error) => void) => Host, + private readonly publish: (state: DesktopBackendState) => void, + ) {} + + /** Current availability, including the last startup or child failure. */ + get state(): DesktopBackendState { return this.current } + + /** Child available to application requests; absent during startup and teardown. */ + get host(): Host | undefined { return !this.closed && !this.attempt?.cancelled && this.current.phase === 'ready' ? this.attempt?.host : undefined } + + /** + * Prepare the profile and start one child; concurrent callers share the attempt. + * @param prepare - Profile preparation that must finish before spawning. + * @returns Completion of startup, rejecting on preparation, startup, or cleanup failure. + */ + start(prepare: () => Promise): Promise { + if (this.closed) return Promise.reject(new Error('desktop backend is closed')) + if (this.stopping !== undefined) return Promise.reject(new Error('desktop backend is stopping')) + if (this.pending !== undefined) return this.pending + if (this.current.phase === 'ready') return Promise.resolve() + const previous = this.attempt + const attempt: Attempt = { cancelled: false, ...(previous?.cleanup === undefined ? {} : { cleanup: previous.cleanup }) } + this.attempt = attempt + this.update({ phase: 'starting' }) + const pending = Promise.resolve().then(async () => { + try { + await previous?.cleanup + delete attempt.cleanup + if (attempt.cancelled) return + await prepare() + // stop() can cancel this attempt while preparation is pending. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (attempt.cancelled) return + const host = this.createHost((error) => { this.failed(attempt, error) }) + attempt.host = host + await host.start() + if (attempt.failure !== undefined) throw attempt.failure + // stop() can cancel this attempt while the child starts. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (!attempt.cancelled) this.update({ phase: 'ready' }) + } catch (error) { + const cancelled = attempt.cancelled + attempt.cancelled = true + let failure = error + try { await this.cleanup(attempt) } catch (cleanupError) { + if (cleanupError !== error) failure = new AggregateError([error, cleanupError], 'desktop backend startup and cleanup failed') + } + if (!cancelled) this.update({ phase: 'error', message: failure instanceof Error ? failure.message : String(failure) }) + throw failure + } + }).finally(() => { if (this.pending === pending) this.pending = undefined }) + this.pending = pending + return pending + } + + /** + * Stop pending preparation and the child before allowing another start. + * @returns Completion of pending work and child exit; rejects if cleanup fails. + */ + stop(): Promise { + if (this.stopping !== undefined) return this.stopping + const attempt = this.attempt + if (attempt !== undefined) attempt.cancelled = true + if (!this.closed) this.update({ phase: 'starting' }) + const pending = this.pending + const stopping = Promise.allSettled([ + attempt === undefined ? Promise.resolve() : this.cleanup(attempt), + pending, + ]).then((results) => { + const cleanup = results[0] + if (cleanup.status === 'rejected') throw cleanup.reason + if (this.attempt === attempt) this.attempt = undefined + }).finally(() => { if (this.stopping === stopping) this.stopping = undefined }) + this.stopping = stopping + return stopping + } + + /** + * Permanently prevent startup and suppress further availability notifications. + * @returns Completion of pending work and child exit; rejects if cleanup fails. + */ + close(): Promise { + this.closed = true + return this.stop() + } + + private cleanup(attempt: Attempt): Promise { + if (attempt.cleanup === undefined) { + attempt.cleanup = Promise.resolve().then(async () => { await attempt.host?.stop() }) + } + return attempt.cleanup + } + + private failed(attempt: Attempt, error: Error): void { + if (this.attempt !== attempt || attempt.cancelled) return + attempt.failure = error + if (this.current.phase !== 'ready') return + attempt.cancelled = true + const cleanup = this.cleanup(attempt) + this.update({ phase: 'error', message: error.message }) + void cleanup.catch((cleanupError: unknown) => { + if (this.attempt === attempt) this.update({ phase: 'error', message: cleanupError instanceof Error ? cleanupError.message : String(cleanupError) }) + }) + } + + private update(state: DesktopBackendState): void { + if (this.closed) return + this.current = state + try { this.publish(state) } catch (error) { + console.error('desktop backend state listener failed', error) + } + } +} diff --git a/apps/desktop/src/host-process.ts b/apps/desktop/src/host-process.ts index 20c59cf7e1..7442c71172 100644 --- a/apps/desktop/src/host-process.ts +++ b/apps/desktop/src/host-process.ts @@ -83,6 +83,7 @@ export class DesktopHostProcess { }) private exitPromise: Promise | undefined private stderr = '' + private failureReported = false /** * @param node - absolute bundled upstream Node.js executable. @@ -90,6 +91,7 @@ export class DesktopHostProcess { * @param projectDir - active or staged desktop plugin profile. * @param inspectPort - optional loopback inspector port for workspace development. * @param environment - Child environment; runtime and package-manager overrides are removed. + * @param onFailure - Receives the first fatal child or transport failure, including after readiness. */ constructor( private readonly node: string, @@ -97,6 +99,7 @@ export class DesktopHostProcess { private readonly projectDir: string, private readonly inspectPort?: number, private readonly environment: NodeJS.ProcessEnv = process.env, + private readonly onFailure?: (error: Error) => void, ) {} /** Start the child once and resolve only after its complete composition is active. */ @@ -149,7 +152,7 @@ export class DesktopHostProcess { }) child.once('error', (error) => { this.fail(error) }) this.exitPromise = new Promise((resolve) => { - child.once('exit', (code) => { + child.once('close', (code) => { const suffix = this.stderr.trim() === '' ? '' : `: ${this.stderr.trim()}` if (code !== 0 && code !== null) this.fail(new Error(`dsh desktop host exited with ${String(code)}${suffix}`)) else this.fail(new Error(`dsh desktop host stopped${suffix}`)) @@ -278,7 +281,7 @@ export class DesktopHostProcess { private send(message: DesktopHostCommand): void { const child = this.child if (child === undefined || !child.connected) throw new Error('dsh desktop host IPC is unavailable') - child.send(message) + child.send(message, (error) => { if (error !== null) this.fail(error) }) } private acceptResponseBytes(chunk: Buffer): void { @@ -405,6 +408,12 @@ export class DesktopHostProcess { private fail(error: Error): void { this.readyReject(error) + if (!this.failureReported) { + this.failureReported = true + try { this.onFailure?.(error) } catch (listenerError) { + console.error('desktop host failure listener failed', listenerError) + } + } for (const pending of this.pending.values()) { void pending.requestReader?.cancel(error).catch(() => undefined) if (pending.controller === undefined) pending.reject(error) diff --git a/apps/desktop/src/ipc.ts b/apps/desktop/src/ipc.ts index f98bf22902..7ac65344ea 100644 --- a/apps/desktop/src/ipc.ts +++ b/apps/desktop/src/ipc.ts @@ -2,11 +2,13 @@ import type { DesktopPluginRecord } from './project-manager.ts' import type { DesktopLocale } from './locale.ts' +import type { DesktopBackendState } from './backend-controller.ts' /** IPC channel names kept private to the desktop application bundle. */ export const DESKTOP_IPC = { localeGet: 'dsh-desktop:locale-get', pluginsList: 'dsh-desktop:plugins-list', + pluginsOpen: 'dsh-desktop:plugins-open', pluginsAdd: 'dsh-desktop:plugins-add', pluginsRemove: 'dsh-desktop:plugins-remove', pluginsUpdate: 'dsh-desktop:plugins-update', @@ -14,6 +16,7 @@ export const DESKTOP_IPC = { pluginsDisableAll: 'dsh-desktop:plugins-disable-all', backendStatus: 'dsh-desktop:backend-status', backendRetry: 'dsh-desktop:backend-retry', + backendState: 'dsh-desktop:backend-state', updatesCheck: 'dsh-desktop:updates-check', updatesInstall: 'dsh-desktop:updates-install', updatesState: 'dsh-desktop:updates-state', @@ -39,8 +42,9 @@ export interface DshDesktopApi { disableAll(): Promise } readonly backend: { - status(): Promise<{ readonly ready: boolean; readonly error?: string }> + status(): Promise retry(): Promise + subscribe(listener: (state: DesktopBackendState) => void): () => void } readonly updates: { check(): Promise @@ -48,3 +52,8 @@ export interface DshDesktopApi { subscribe(listener: (state: DesktopUpdateState) => void): () => void } } + +/** Startup-page controls, unavailable to backend-provided application documents. */ +export interface DshDesktopStartupApi extends Pick { + openPlugins(): Promise +} diff --git a/apps/desktop/src/locale.ts b/apps/desktop/src/locale.ts index 65e5083313..c937474beb 100644 --- a/apps/desktop/src/locale.ts +++ b/apps/desktop/src/locale.ts @@ -3,6 +3,10 @@ export const en = { application: 'Application', startupFailed: 'DeepSeek Harness could not start', + startupLoading: 'Starting DeepSeek Harness…', + startupLoadingDescription: 'Your workspace will open when it is ready.', + startupErrorDescription: 'The application could not start. Retry or manage Desktop plugins to resolve the problem.', + managePlugins: 'Manage plugins', pluginsMenu: 'Desktop Plugins…', pluginsMenuPackagedOnly: 'Desktop Plugins… (available in packaged applications)', checkUpdatesMenu: 'Check for Updates…', @@ -49,6 +53,10 @@ export type DesktopMessages = { readonly [Key in keyof typeof en]: string } export const zh = { application: '应用', startupFailed: 'DeepSeek Harness 无法启动', + startupLoading: '正在启动 DeepSeek Harness…', + startupLoadingDescription: '准备就绪后将自动打开工作区。', + startupErrorDescription: '应用未能启动。你可以重试,或管理桌面插件以解决问题。', + managePlugins: '管理插件', pluginsMenu: '桌面插件…', pluginsMenuPackagedOnly: '桌面插件…(打包应用中可用)', checkUpdatesMenu: '检查更新…', diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index a995d6818e..f132afb3b2 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -15,6 +15,7 @@ import { import { resolveDesktopPaths } from './paths.ts' import { DesktopProjectManager, type DesktopProjectHooks } from './project-manager.ts' import { DesktopHostProcess } from './host-process.ts' +import { DesktopBackendController, type DesktopBackendState } from './backend-controller.ts' import { DESKTOP_IPC, type DesktopUpdateState } from './ipc.ts' import { formatDesktopMessage, resolveDesktopLocale } from './locale.ts' import { claimDesktopSingleInstance } from './single-instance.ts' @@ -79,13 +80,13 @@ function developmentHostInspectPort(enabled: boolean): number | undefined { return port } -function createWindow(preload: string): BrowserWindow { +function createWindow(preload: string, show = false): BrowserWindow { const window = new BrowserWindow({ width: 1280, height: 840, minWidth: 880, minHeight: 600, - show: false, + show, webPreferences: { preload, nodeIntegration: false, @@ -137,8 +138,9 @@ async function main(): Promise { const activeProject = development ?? paths.profile const hostInspectPort = developmentHostInspectPort(development !== undefined) const manager = new DesktopProjectManager(paths, resources) - let host: DesktopHostProcess | undefined - let startupError: string | undefined + let pageError: string | undefined + let quitting = false + let startup: Promise | undefined let mainWindow: BrowserWindow | undefined let pluginWindow: BrowserWindow | undefined let shellInstallerOwnsQuit = false @@ -147,6 +149,38 @@ async function main(): Promise { const messages = locale.messages const appPreload = fileURLToPath(new URL('./preload-app.cjs', import.meta.url)) const managementPreload = fileURLToPath(new URL('./preload.cjs', import.meta.url)) + const startupUrl = `${SCHEME}://shell/startup.html` + const applicationUrl = `${SCHEME}://app/index.html` + let navigation: { window: BrowserWindow; url: string; promise: Promise } | undefined + + const navigateMain = (url: string): Promise => { + const window = mainWindow + if (quitting || window === undefined || window.isDestroyed()) return Promise.resolve() + if (navigation?.window === window && navigation.url === url) return navigation.promise + const next = { window, url, promise: Promise.resolve() } + next.promise = window.loadURL(url).catch((error: unknown) => { + if (quitting || window.isDestroyed() || navigation !== next) return + navigation = undefined + throw error + }) + navigation = next + return next.promise + } + const backendState = (): DesktopBackendState => pageError === undefined + ? backend.state : { phase: 'error', message: pageError } + const publishBackend = (state: DesktopBackendState): void => { + for (const window of BrowserWindow.getAllWindows()) { + window.webContents.send(DESKTOP_IPC.backendState, state) + } + } + const backend = new DesktopBackendController((onFailure) => { + if (development === undefined) manager.assertProfileRuntime(activeProject) + return new DesktopHostProcess(resources.node, development ?? resources.dsh, activeProject, hostInspectPort, process.env, onFailure) + }, (state) => { + if (state.phase === 'starting') pageError = undefined + publishBackend(backendState()) + if (state.phase === 'error') void navigateMain(startupUrl).catch((error: unknown) => { console.error(error) }) + }) const publishUpdate = (state: DesktopUpdateState): DesktopUpdateState => { updateState = state @@ -156,78 +190,39 @@ async function main(): Promise { return state } - const startHost = async (projectDir = activeProject): Promise => { - if (development === undefined) manager.assertProfileRuntime(projectDir) - const next = new DesktopHostProcess(resources.node, development ?? resources.dsh, projectDir, hostInspectPort) - try { - await next.start() - return next - } catch (error) { - await next.stop() - throw error - } - } const hooks: DesktopProjectHooks = { - healthCheck: async (projectDir) => { - const active = host - host = undefined - await active?.stop() - let healthFailure: unknown - let probe: DesktopHostProcess | undefined - try { - probe = await startHost(projectDir) - await probe.stop() - } catch (error) { - healthFailure = error - await probe?.stop().catch(() => undefined) - } - let restartFailure: unknown - if (active !== undefined) { - try { - host = await startHost() - } catch (error) { - restartFailure = error - } - } - if (healthFailure !== undefined && restartFailure !== undefined) { - throw new AggregateError([ - errorOf(healthFailure, 'desktop project: staged health check failed'), - errorOf(restartFailure, 'desktop project: active backend restart failed'), - ], 'desktop project: staged health check and active backend restart failed') - } - if (healthFailure !== undefined) throw errorOf(healthFailure, 'desktop project: staged health check failed') - if (restartFailure !== undefined) throw errorOf(restartFailure, 'desktop project: active backend restart failed') - }, - beforeActivate: async () => { - const active = host - host = undefined - await active?.stop() - }, - afterActivate: async () => { - host = await startHost() - }, + beforeActivate: () => backend.stop(), + afterActivate: () => backend.start(async () => {}), } - const reconcileBackend = async (): Promise => { - try { - if (development === undefined) { - await manager.applyRelease(app.getVersion(), { ...hooks, beforeActivate: async () => {}, afterActivate: async () => {} }) - } - host = await startHost() - startupError = undefined - } catch (error) { - startupError = errorOf(error, messages.startupFailed).message + const showStartupError = async (error: unknown): Promise => { + if (quitting) return + pageError = errorOf(error, messages.startupFailed).message + await navigateMain(startupUrl) + publishBackend(backendState()) + } + const reconcileBackend = (): Promise => { + startup ??= (async () => { + pageError = undefined + await navigateMain(startupUrl) + await backend.start(async () => { + if (development === undefined) { + await manager.applyRelease(app.getVersion(), { beforeActivate: async () => {}, afterActivate: async () => {} }) + } + }) + if (backend.host !== undefined) await navigateMain(applicationUrl) + })().catch(async (error: unknown) => { + await showStartupError(error) throw error - } + }).finally(() => { startup = undefined }) + return startup } const updates = new DesktopUpdateCoordinator( publishUpdate, async () => { shellInstallerOwnsQuit = true - const active = host - host = undefined - await active?.stop() + await backend.stop() }, ) @@ -235,7 +230,7 @@ async function main(): Promise { const url = new URL(request.url) if (url.hostname === 'shell') return serveShellAsset(request) if (url.hostname !== 'app') return Promise.resolve(new Response(null, { status: 404 })) - const active = host + const active = backend.host if (active === undefined) return Promise.resolve(new Response('backend unavailable', { status: 503 })) return active.fetch(request) }) @@ -245,10 +240,16 @@ async function main(): Promise { if (development !== undefined) { throw new Error('dsh desktop: plugin package changes require a packaged application') } - await manager.mutate(mutation, hooks) - startupError = undefined - if (mainWindow !== undefined && !mainWindow.isDestroyed()) mainWindow.webContents.reload() - else focusPrimaryWindow() + await startup?.catch(() => undefined) + pageError = undefined + await navigateMain(startupUrl) + try { + await manager.mutate(mutation, hooks) + await navigateMain(applicationUrl) + } catch (error) { + await showStartupError(error) + throw error + } } ipcMain.handle(DESKTOP_IPC.localeGet, (event) => { assertDesktopSender(event, ['shell']) @@ -280,11 +281,11 @@ async function main(): Promise { ipcMain.handle(DESKTOP_IPC.pluginsDisableAll, event => mutate(event, { type: 'plugins-disable-all' })) ipcMain.handle(DESKTOP_IPC.backendStatus, (event) => { assertDesktopSender(event, ['shell']) - return { ready: host !== undefined, ...(startupError === undefined ? {} : { error: startupError }) } + return backendState() }) ipcMain.handle(DESKTOP_IPC.backendRetry, async (event) => { assertDesktopSender(event, ['shell']) - if (host === undefined) await reconcileBackend() + await reconcileBackend() focusPrimaryWindow() }) ipcMain.handle(DESKTOP_IPC.updatesCheck, async (event) => { @@ -350,6 +351,10 @@ async function main(): Promise { pluginWindow.once('closed', () => { pluginWindow = undefined }) void pluginWindow.loadURL(`${SCHEME}://shell/plugin-manager.html`) } + ipcMain.handle(DESKTOP_IPC.pluginsOpen, (event) => { + assertDesktopSender(event, ['shell']) + openPluginWindow() + }) Menu.setApplicationMenu(Menu.buildFromTemplate([{ label: process.platform === 'darwin' ? app.name : messages.application, @@ -367,18 +372,17 @@ async function main(): Promise { }])) const createMainWindow = (): BrowserWindow => { - const window = createWindow(appPreload) + const window = createWindow(appPreload, true) mainWindow = window - window.once('ready-to-show', () => { if (!window.isDestroyed()) window.show() }) window.on('closed', () => { if (mainWindow === window) mainWindow = undefined }) return window } focusPrimaryWindow = () => { - if (host === undefined) { openPluginWindow(); return } const window = mainWindow if (window === undefined || window.isDestroyed()) { - const replacement = createMainWindow() - void replacement.loadURL(`${SCHEME}://app/index.html`) + createMainWindow() + void navigateMain(backendState().phase === 'ready' ? applicationUrl : startupUrl) + .catch((error: unknown) => { console.error(error) }) return } if (window.isMinimized()) window.restore() @@ -386,18 +390,6 @@ async function main(): Promise { window.focus() } - await reconcileBackend().catch(() => undefined) - if (host === undefined) openPluginWindow() - else { - mainWindow = createMainWindow() - await mainWindow.loadURL(`${SCHEME}://app/index.html`) - } - if (mainWindow !== undefined && development !== undefined && process.env.DSH_DESKTOP_OPEN_DEVTOOLS !== '0') { - mainWindow.webContents.openDevTools({ mode: 'detach' }) - } - publishUpdate(updateState) - setTimeout(() => { void checkAndPrompt(false) }, 10_000) - app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) focusPrimaryWindow() }) @@ -405,13 +397,23 @@ async function main(): Promise { if (process.platform !== 'darwin') app.quit() }) app.on('before-quit', (event) => { - if (shellInstallerOwnsQuit) return - if (host === undefined) return + if (shellInstallerOwnsQuit || quitting) return event.preventDefault() - const active = host - host = undefined - void active.stop().finally(() => { app.quit() }) + quitting = true + void backend.close().catch((error: unknown) => { console.error(error) }).finally(() => { app.quit() }) }) + + mainWindow = createMainWindow() + await reconcileBackend().catch(() => undefined) + // Window lifecycle callbacks run while backend startup is pending. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (quitting) return + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (mainWindow !== undefined && development !== undefined && process.env.DSH_DESKTOP_OPEN_DEVTOOLS !== '0') { + mainWindow.webContents.openDevTools({ mode: 'detach' }) + } + publishUpdate(updateState) + setTimeout(() => { void checkAndPrompt(false) }, 10_000) } const ownsDesktopInstance = claimDesktopSingleInstance(app, () => { focusPrimaryWindow() }) diff --git a/apps/desktop/src/owned-directory.ts b/apps/desktop/src/owned-directory.ts new file mode 100644 index 0000000000..0caad07ca8 --- /dev/null +++ b/apps/desktop/src/owned-directory.ts @@ -0,0 +1,26 @@ +/** Desktop transaction cleanup that unlinks directory links without visiting their targets. */ + +import { lstatSync, readdirSync, rmdirSync, unlinkSync } from 'node:fs' +import { join } from 'node:path' + +/** + * Remove an owned directory and its contents, unlinking root and nested links. + * Missing roots are ignored; existing roots must be directories or links. + * @param path - Owned directory or link to remove; link targets are preserved. + */ +export function removeOwnedDirectory(path: string): void { + const stat = lstatSync(path, { throwIfNoEntry: false }) + if (stat === undefined) return + if (stat.isSymbolicLink()) { + unlinkSync(path) + return + } + if (!stat.isDirectory()) throw new Error(`desktop project: owned directory path is not a directory: ${path}`) + // Electron's recursive rm follows nested Windows junctions into installed resources. + for (const entry of readdirSync(path, { withFileTypes: true })) { + const child = join(path, entry.name) + if (entry.isDirectory()) removeOwnedDirectory(child) + else unlinkSync(child) + } + rmdirSync(path) +} diff --git a/apps/desktop/src/preload-app.ts b/apps/desktop/src/preload-app.ts index c6a839aba6..140a7369a4 100644 --- a/apps/desktop/src/preload-app.ts +++ b/apps/desktop/src/preload-app.ts @@ -1,5 +1,23 @@ -/** Minimal marker that selects the desktop custom-protocol API carrier. */ +/** Startup controls for shell documents; application documents receive only the carrier marker. */ -import { contextBridge } from 'electron' +import { contextBridge, ipcRenderer } from 'electron' +import { DESKTOP_IPC, type DshDesktopStartupApi } from './ipc.ts' +import type { DesktopBackendState } from './backend-controller.ts' -contextBridge.exposeInMainWorld('dshDesktop', { protocolVersion: 1 }) +const startup: DshDesktopStartupApi = { + protocolVersion: 1, + locale: () => ipcRenderer.invoke(DESKTOP_IPC.localeGet) as ReturnType, + backend: { + status: () => ipcRenderer.invoke(DESKTOP_IPC.backendStatus) as ReturnType, + retry: () => ipcRenderer.invoke(DESKTOP_IPC.backendRetry) as Promise, + subscribe(listener) { + const handle = (_event: Electron.IpcRendererEvent, state: DesktopBackendState): void => { listener(state) } + ipcRenderer.on(DESKTOP_IPC.backendState, handle) + return () => { ipcRenderer.off(DESKTOP_IPC.backendState, handle) } + }, + }, + openPlugins: () => ipcRenderer.invoke(DESKTOP_IPC.pluginsOpen) as Promise, +} + +contextBridge.exposeInMainWorld('dshDesktop', location.protocol === 'dsh-app:' && location.hostname === 'shell' + ? startup : { protocolVersion: 1 }) diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 4ad4243bdb..b9c04bd40a 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -2,6 +2,7 @@ import { contextBridge, ipcRenderer } from 'electron' import { DESKTOP_IPC, type DshDesktopApi, type DesktopUpdateState } from './ipc.ts' +import type { DesktopBackendState } from './backend-controller.ts' const api: DshDesktopApi = { protocolVersion: 1, @@ -17,6 +18,11 @@ const api: DshDesktopApi = { backend: { status: () => ipcRenderer.invoke(DESKTOP_IPC.backendStatus) as ReturnType, retry: () => ipcRenderer.invoke(DESKTOP_IPC.backendRetry) as Promise, + subscribe(listener) { + const handle = (_event: Electron.IpcRendererEvent, state: DesktopBackendState): void => { listener(state) } + ipcRenderer.on(DESKTOP_IPC.backendState, handle) + return () => { ipcRenderer.off(DESKTOP_IPC.backendState, handle) } + }, }, updates: { check: () => ipcRenderer.invoke(DESKTOP_IPC.updatesCheck) as Promise, diff --git a/apps/desktop/src/profile-packages.ts b/apps/desktop/src/profile-packages.ts index 6326747b43..0ec5ca573e 100644 --- a/apps/desktop/src/profile-packages.ts +++ b/apps/desktop/src/profile-packages.ts @@ -175,7 +175,7 @@ function packageFrom(anchor: string, name: string): string | undefined { if (!PACKAGE_NAME.test(name)) throw new Error(`desktop profile: invalid package name ${name}`) for (const modules of createRequire(join(anchor, 'package.json')).resolve.paths(name) ?? []) { const path = join(modules, name) - if (existsSync(join(path, 'package.json'))) return realpathSync(path) + if (existsSync(join(path, 'package.json'))) return realpathSync.native(path) } return undefined } @@ -190,8 +190,8 @@ function packageFrom(anchor: string, name: string): string | undefined { export function validateDesktopPluginGraph( profile: string, root: string, runtime: DesktopRuntimeDescriptor, activePlugins: readonly string[], ): void { - const profileRoot = realpathSync(profile) - const shared = new Map(runtime.sharedPackages.map(entry => [entry.name, realpathSync(runtimePath(root, entry.path))])) + const profileRoot = realpathSync.native(profile) + const shared = new Map(runtime.sharedPackages.map(entry => [entry.name, realpathSync.native(runtimePath(root, entry.path))])) for (const [name, path] of shared) { if (packageFrom(profile, name) !== path) throw new Error(`desktop profile: missing or incorrect host link ${name}`) } @@ -199,7 +199,7 @@ export function validateDesktopPluginGraph( const scan = (modules: string): void => { if (!existsSync(modules)) return if (lstatSync(modules).isSymbolicLink()) throw new Error(`desktop profile: linked package container ${modules}`) - const directory = realpathSync(modules) + const directory = realpathSync.native(modules) if (scanned.has(directory)) return scanned.add(directory) for (const entry of readdirSync(modules, { withFileTypes: true })) { @@ -207,7 +207,7 @@ export function validateDesktopPluginGraph( const path = join(modules, entry.name) if (entry.name.startsWith('@')) { scan(path); continue } if (!existsSync(join(path, 'package.json'))) throw new Error(`desktop profile: invalid installed package ${path}`) - const canonical = realpathSync(path) + const canonical = realpathSync.native(path) const info = manifest(canonical) const host = shared.get(info.name) if (host !== undefined) { diff --git a/apps/desktop/src/project-manager.ts b/apps/desktop/src/project-manager.ts index 89c9e86c73..f6759775e1 100644 --- a/apps/desktop/src/project-manager.ts +++ b/apps/desktop/src/project-manager.ts @@ -12,8 +12,8 @@ import { openSync, closeSync, readFileSync, + realpathSync, renameSync, - rmSync, unlinkSync, writeFileSync, writeSync, @@ -25,8 +25,9 @@ import { verifyDesktopCorePackageSet, } from './core-package-set.ts' import type { DesktopPaths } from './paths.ts' +import { removeOwnedDirectory } from './owned-directory.ts' import type { DesktopRelease } from './release.ts' -import { desktopRuntimeId, verifyDesktopRuntime, type DesktopRuntimeDescriptor } from './runtime-tree.ts' +import { desktopRuntimeId, readDesktopRuntime, type DesktopRuntimeDescriptor } from './runtime-tree.ts' import { copyDesktopProfile, desktopPluginLockHash, linkDesktopHostPackages, readDesktopProfileState, unlinkDesktopHostPackages, validateDesktopPluginGraph, type DesktopProfileState, @@ -69,10 +70,8 @@ export interface DesktopRuntimeExecutables { readonly dsh: string } -/** Hooks that bind project replacement to backend lifecycle and health. */ +/** Hooks that bind project replacement to the active backend lifecycle. */ export interface DesktopProjectHooks { - /** Prove the staged dependency graph while the active backend is stopped. */ - healthCheck(projectDir: string): Promise /** Stop the active backend and await process exit before directory moves. */ beforeActivate(): Promise /** Start the selected active project after commit or rollback. */ @@ -163,17 +162,6 @@ export function packageNameFromSpec(spec: string): string | undefined { return name } -function removeOwnedDirectory(path: string): void { - if (!existsSync(path)) return - const stat = lstatSync(path) - if (stat.isSymbolicLink()) { - unlinkSync(path) - return - } - if (!stat.isDirectory()) throw new Error(`desktop project: owned directory path is not a directory: ${path}`) - rmSync(path, { recursive: true }) -} - function projectManifest(projectDir: string): DesktopProjectManifest { const path = join(projectDir, 'package.json') const value = readJson(path) @@ -324,7 +312,7 @@ export class DesktopProjectManager { } private currentRuntime(): DesktopRuntimeDescriptor { - if (this.descriptor === undefined) throw new Error('desktop project: runtime has not been verified') + if (this.descriptor === undefined) throw new Error('desktop project: runtime metadata has not been loaded') return this.descriptor } @@ -334,11 +322,11 @@ export class DesktopProjectManager { validateDesktopPluginGraph(projectDir, this.runtime.dsh, runtime, profilePluginNames(projectDir)) } - /** Verify this release and reconcile its external profile without installing core packages. */ + /** Read release metadata and reconcile its external profile without installing core packages. */ async applyRelease(electronVersion: string, hooks: DesktopProjectHooks): Promise { return this.withLock(async () => { this.recover() - const target = verifyDesktopRuntime(this.runtime.dsh, electronVersion) + const target = readDesktopRuntime(this.runtime.dsh, electronVersion) this.descriptor = target const previous = readDesktopProfileState(this.paths.profile) if (existsSync(this.paths.profile) && previous === undefined) { @@ -347,8 +335,9 @@ export class DesktopProjectManager { if (previous?.runtimeId === desktopRuntimeId(target) && previous.lockHash === desktopPluginLockHash(this.paths.profile) && previous.links.length === target.sharedPackages.length - && previous.links.every(link => link.target === join(this.runtime.dsh, 'node_modules', link.name) - && existsSync(join(this.paths.profile, 'node_modules', link.name)))) { + && previous.links.every(link => existsSync(link.target) + && existsSync(join(this.paths.profile, 'node_modules', link.name)) + && realpathSync.native(link.target) === realpathSync.native(join(this.runtime.dsh, 'node_modules', link.name)))) { validateDesktopPluginGraph(this.paths.profile, this.runtime.dsh, target, profilePluginNames(this.paths.profile)) return false } @@ -357,7 +346,6 @@ export class DesktopProjectManager { if (previous === undefined) createPluginProfile(stagingProfile) else copyDesktopProfile(this.paths.profile, stagingProfile) await this.reconcileProfile(stagingProfile, previous) - await hooks.healthCheck(stagingProfile) await this.activate(stagingProfile, hooks) return true } catch (error) { @@ -379,7 +367,6 @@ export class DesktopProjectManager { await this.applyMutation(stagingProfile, mutation) await this.reconcileProfile(stagingProfile, readDesktopProfileState(stagingProfile), mutation.type !== 'plugin-toggle' && mutation.type !== 'plugins-disable-all') - await hooks.healthCheck(stagingProfile) await this.activate(stagingProfile, hooks) } catch (error) { removeOwnedDirectory(stagingProfile) diff --git a/apps/desktop/src/runtime-tree.ts b/apps/desktop/src/runtime-tree.ts index 471d603964..8f02a46497 100644 --- a/apps/desktop/src/runtime-tree.ts +++ b/apps/desktop/src/runtime-tree.ts @@ -1,8 +1,9 @@ /** Relocatable, integrity-recorded production packages carried by one Desktop release. */ import { createHash } from 'node:crypto' -import { lstatSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' +import { existsSync, lstatSync, readdirSync, readFile, readFileSync, writeFileSync } from 'node:fs' import { isAbsolute, join, relative, sep } from 'node:path' +import { promisify } from 'node:util' import { valid } from 'semver' import { DESKTOP_HOST_PACKAGE, DESKTOP_HOST_RUNTIME_FILES } from './core-package-set.ts' import { parseDesktopRelease, type DesktopRelease } from './release.ts' @@ -36,6 +37,7 @@ export interface DesktopRuntimeDescriptor { } const PACKAGE_NAME = /^(?:@[a-z0-9][a-z0-9._~-]*\/[a-z0-9][a-z0-9._~-]*|[a-z0-9][a-z0-9._~-]*)$/u +const readRuntimeFile = promisify(readFile) function record(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) @@ -55,27 +57,59 @@ export function runtimePath(root: string, path: string): string { return join(root, ...path.split('/')) } -/** - * Inventory a materialized runtime without following links or including its descriptor. - * @param root - Self-contained runtime directory. - * @returns Sorted final-file inventory. - */ -export function inventoryDesktopRuntime(root: string): DesktopRuntimeFile[] { - const files: DesktopRuntimeFile[] = [] +function runtimeFiles(root: string): { path: string; name: string }[] { + const files: { path: string; name: string }[] = [] const visit = (directory: string): void => { for (const entry of readdirSync(directory, { withFileTypes: true })) { const path = join(directory, entry.name) const name = relative(root, path).split(sep).join('/') if (name === DESKTOP_RUNTIME_FILE) continue if (entry.isDirectory()) visit(path) - else if (entry.isFile()) { - const body = readFileSync(path) - files.push({ path: name, bytes: body.byteLength, sha256: createHash('sha256').update(body).digest('hex'), - executable: (lstatSync(path).mode & 0o111) !== 0 }) - } else throw new Error(`desktop runtime: unsupported filesystem entry ${name}`) + else if (entry.isFile()) files.push({ path, name }) + else throw new Error(`desktop runtime: unsupported filesystem entry ${name}`) } } visit(root) + return files +} + +function runtimeFile(path: string, name: string, body: Buffer): DesktopRuntimeFile { + return { path: name, bytes: body.byteLength, sha256: createHash('sha256').update(body).digest('hex'), + // Windows has no portable Unix executable permission bits. + executable: process.platform !== 'win32' && (lstatSync(path).mode & 0o111) !== 0 } +} + +/** + * Inventory a materialized runtime without following links or including its descriptor. + * @param root - Self-contained runtime directory. + * @returns Sorted final-file inventory; executable permissions are false on Windows. + */ +export function inventoryDesktopRuntime(root: string): DesktopRuntimeFile[] { + return runtimeFiles(root).map(({ path, name }) => runtimeFile(path, name, readFileSync(path))) + .sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0) +} + +async function inventoryRuntimeForVerification(root: string): Promise { + const entries = runtimeFiles(root) + const files: DesktopRuntimeFile[] = [] + const remaining = entries.values() + let failed = false + const workers = Array.from({ length: Math.min(8, entries.length) }, async () => { + while (!failed) { + const next = remaining.next() + if (next.done) return + const { path, name } = next.value + try { + files.push(runtimeFile(path, name, await readRuntimeFile(path))) + } catch (error) { + failed = true + throw error + } + } + }) + // Failure returns only after every outstanding file read has closed its descriptor. + const results = await Promise.allSettled(workers) + for (const result of results) if (result.status === 'rejected') throw result.reason return files.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0) } @@ -109,13 +143,13 @@ export function writeDesktopRuntime( } /** - * Verify the runtime before linking any of its packages into writable state. + * Read release metadata and check shared manifests and Host entries without scanning runtime contents. * @param root - Current application's runtime resources. * @param electronVersion - Expected shell version. * @param target - Required execution target; defaults to the current process. * @returns Validated runtime descriptor. */ -export function verifyDesktopRuntime( +export function readDesktopRuntime( root: string, electronVersion: string, target: { platform: NodeJS.Platform; arch: string } = process, ): DesktopRuntimeDescriptor { const value: unknown = JSON.parse(readFileSync(join(root, DESKTOP_RUNTIME_FILE), 'utf8')) @@ -144,13 +178,6 @@ export function verifyDesktopRuntime( runtimePath(root, entry.path) return { path: entry.path, bytes: entry.bytes, sha256: entry.sha256, executable: entry.executable } }).sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0) - const actual = inventoryDesktopRuntime(root) - // Windows has no portable Unix executable permission bits. - const comparable = (items: readonly DesktopRuntimeFile[]): unknown => process.platform === 'win32' - ? items.map(({ executable: _executable, ...item }) => item) : items - if (JSON.stringify(comparable(files)) !== JSON.stringify(comparable(actual))) { - throw new Error('desktop runtime: integrity verification failed') - } for (const entry of sharedPackages) { const manifest: unknown = JSON.parse(readFileSync(join(runtimePath(root, entry.path), 'package.json'), 'utf8')) if (!record(manifest) || manifest.name !== entry.name || manifest.version !== entry.version) { @@ -163,13 +190,36 @@ export function verifyDesktopRuntime( } } for (const file of DESKTOP_HOST_RUNTIME_FILES) { - if (!files.some(entry => entry.path === `node_modules/${DESKTOP_HOST_PACKAGE}/${file}`)) { + const path = `node_modules/${DESKTOP_HOST_PACKAGE}/${file}` + if (!files.some(entry => entry.path === path) || !existsSync(runtimePath(root, path)) + || !lstatSync(runtimePath(root, path)).isFile()) { throw new Error(`desktop runtime: missing Host file ${file}`) } } return { schemaVersion: 1, release, platform: target.platform, arch: target.arch, sharedPackages, files } } +/** + * Verify every packaged runtime file against its recorded bytes and permissions at build time. + * @param root - Materialized runtime resources. + * @param electronVersion - Expected shell version. + * @param target - Required execution target; defaults to the current process. + * @returns Validated runtime descriptor. + */ +export async function verifyDesktopRuntime( + root: string, electronVersion: string, target: { platform: NodeJS.Platform; arch: string } = process, +): Promise { + const descriptor = readDesktopRuntime(root, electronVersion, target) + const actual = await inventoryRuntimeForVerification(root) + // Windows has no portable Unix executable permission bits. + const comparable = (items: readonly DesktopRuntimeFile[]): unknown => process.platform === 'win32' + ? items.map(({ executable: _executable, ...item }) => item) : items + if (JSON.stringify(comparable(descriptor.files)) !== JSON.stringify(comparable(actual))) { + throw new Error('desktop runtime: integrity verification failed') + } + return descriptor +} + /** * Identify exact runtime content independently of its installation path. * @param descriptor - Validated runtime metadata. diff --git a/apps/desktop/tests/backend-controller.spec.ts b/apps/desktop/tests/backend-controller.spec.ts new file mode 100644 index 0000000000..abdc78a9e7 --- /dev/null +++ b/apps/desktop/tests/backend-controller.spec.ts @@ -0,0 +1,170 @@ +import { describe, expect, it, vi } from 'vitest' +import { DesktopBackendController, type DesktopBackendState } from '../src/backend-controller.ts' + +function deferred() { + let resolve!: () => void + let reject!: (error: Error) => void + const promise = new Promise((accept, decline) => { resolve = accept; reject = decline }) + return { promise, resolve, reject } +} + +function fixture() { + const started = deferred() + const ready = deferred() + const stopping = deferred() + const exited = deferred() + const states: DesktopBackendState[] = [] + let fail!: (error: Error) => void + const host = { + start: vi.fn(() => { started.resolve(); return ready.promise }), + stop: vi.fn(() => { stopping.resolve(); return exited.promise }), + } + const create = vi.fn((onFailure: (error: Error) => void) => { fail = onFailure; return host }) + const controller = new DesktopBackendController(create, state => states.push(state)) + return { controller, host, create, states, started, ready, stopping, exited, fail: (error: Error) => { fail(error) } } +} + +describe('desktop backend controller', () => { + it('shares preparation and startup between concurrent retries', async () => { + const f = fixture() + const prepare = vi.fn(async () => {}) + const first = f.controller.start(prepare) + expect(f.controller.start(prepare)).toBe(first) + await f.started.promise + expect(f.controller.host).toBeUndefined() + expect(f.states).toEqual([{ phase: 'starting' }]) + f.ready.resolve() + await first + expect(f.controller.host).toBe(f.host) + await f.controller.start(prepare) + expect(prepare).toHaveBeenCalledTimes(1) + expect(f.host.start).toHaveBeenCalledTimes(1) + f.exited.resolve() + await f.controller.close() + }) + + it('waits for pending preparation on close and never spawns afterward', async () => { + const f = fixture() + const preparing = deferred() + const prepared = deferred() + const start = f.controller.start(() => { preparing.resolve(); return prepared.promise }) + await preparing.promise + let closed = false + const close = f.controller.close().then(() => { closed = true }) + await Promise.resolve() + expect(closed).toBe(false) + prepared.resolve() + await Promise.all([start, close]) + expect(f.create).not.toHaveBeenCalled() + expect(f.states).toEqual([{ phase: 'starting' }]) + await expect(f.controller.start(async () => {})).rejects.toThrow('closed') + }) + + it('stops a pending child once and waits for startup and child exit on close', async () => { + const f = fixture() + const start = f.controller.start(async () => {}) + const rejected = expect(start).rejects.toThrow('stopped') + await f.started.promise + const close = f.controller.close() + expect(f.controller.close()).toBe(close) + await f.stopping.promise + let closed = false + void close.then(() => { closed = true }) + f.ready.reject(new Error('stopped')) + await Promise.resolve() + expect(closed).toBe(false) + f.exited.resolve() + await Promise.all([rejected, close]) + expect(f.host.stop).toHaveBeenCalledTimes(1) + expect(f.states).toEqual([{ phase: 'starting' }]) + expect(f.controller.host).toBeUndefined() + }) + + it('publishes preparation errors and permits retry', async () => { + const f = fixture() + await expect(f.controller.start(async () => { throw new Error('invalid profile') })).rejects.toThrow('invalid profile') + expect(f.controller.state).toEqual({ phase: 'error', message: 'invalid profile' }) + expect(f.create).not.toHaveBeenCalled() + f.ready.resolve() + await f.controller.start(async () => {}) + expect(f.controller.state).toEqual({ phase: 'ready' }) + f.exited.resolve() + await f.controller.close() + }) + + it('cleans up a failed startup before rejecting and displays the actual failure', async () => { + const f = fixture() + const start = f.controller.start(async () => {}) + const rejected = expect(start).rejects.toThrow('plugin failed') + await f.started.promise + f.ready.reject(new Error('plugin failed')) + await f.stopping.promise + f.exited.resolve() + await rejected + expect(f.controller.state).toEqual({ phase: 'error', message: 'plugin failed' }) + expect(f.host.stop).toHaveBeenCalledTimes(1) + await f.controller.close() + }) + + it('waits for failed ready child cleanup before preparing a retry', async () => { + const f = fixture() + f.ready.resolve() + await f.controller.start(async () => {}) + f.fail(new Error('transport failed')) + expect(f.controller.state).toEqual({ phase: 'error', message: 'transport failed' }) + expect(f.controller.host).toBeUndefined() + await f.stopping.promise + const prepare = vi.fn(async () => {}) + const retry = f.controller.start(prepare) + await Promise.resolve() + expect(prepare).not.toHaveBeenCalled() + f.exited.resolve() + await retry + expect(prepare).toHaveBeenCalledTimes(1) + expect(f.create).toHaveBeenCalledTimes(2) + await f.controller.close() + }) + + it('does not publish ready when the child fails as readiness settles', async () => { + const f = fixture() + const start = f.controller.start(async () => {}) + const rejected = expect(start).rejects.toThrow('immediate failure') + await f.started.promise + f.ready.resolve() + f.fail(new Error('immediate failure')) + await f.stopping.promise + f.exited.resolve() + await rejected + expect(f.states).toEqual([{ phase: 'starting' }, { phase: 'error', message: 'immediate failure' }]) + await f.controller.close() + }) + + it('ignores failure callbacks caused by intentional stop and permits subsequent start', async () => { + const f = fixture() + f.ready.resolve() + await f.controller.start(async () => {}) + const stop = f.controller.stop() + await f.stopping.promise + f.fail(new Error('stopped')) + await expect(f.controller.start(async () => {})).rejects.toThrow('stopping') + expect(f.controller.state).toEqual({ phase: 'starting' }) + f.exited.resolve() + await stop + await f.controller.start(async () => {}) + expect(f.create).toHaveBeenCalledTimes(2) + await f.controller.close() + }) + + it('propagates cleanup failure and prevents retry from overlapping the remaining child', async () => { + const f = fixture() + f.ready.resolve() + await f.controller.start(async () => {}) + f.fail(new Error('fatal')) + await f.stopping.promise + f.exited.reject(new Error('child did not exit')) + await expect(f.controller.start(async () => {})).rejects.toThrow('child did not exit') + await expect(f.controller.start(async () => {})).rejects.toThrow('child did not exit') + expect(f.create).toHaveBeenCalledTimes(1) + await expect(f.controller.close()).rejects.toThrow('child did not exit') + }) +}) diff --git a/apps/desktop/tests/fixtures/installer-cleanup-smoke.nsi b/apps/desktop/tests/fixtures/installer-cleanup-smoke.nsi new file mode 100644 index 0000000000..2c1c47ae34 --- /dev/null +++ b/apps/desktop/tests/fixtures/installer-cleanup-smoke.nsi @@ -0,0 +1,70 @@ +; Compile with OUTPUT_FILE and RESULT_FILE in a private test directory, then run silently. +Unicode true +RequestExecutionLevel user +SilentInstall silent +Name "Desktop installer cleanup smoke" +OutFile "${OUTPUT_FILE}" +!include "..\..\scripts\installer.nsh" + +Section + InitPluginsDir + SetOutPath "$PLUGINSDIR" + CreateDirectory "$PLUGINSDIR\7z-out\nested" + FileOpen $1 "$PLUGINSDIR\7z-out\nested\extracted-file" w + FileWrite $1 "remove" + FileClose $1 + FileOpen $1 "$PLUGINSDIR\archive.7z" w + FileWrite $1 "archive sentinel" + FileClose $1 + FileOpen $1 "$PLUGINSDIR\plugin.dll" w + FileWrite $1 "plugin sentinel" + FileClose $1 + CreateDirectory "$PLUGINSDIR\old-install" + FileOpen $1 "$PLUGINSDIR\old-install\sentinel" w + FileWrite $1 "rollback sentinel" + FileClose $1 + + StrCpy $0 "register sentinel" + ClearErrors + !insertmacro customInstall + IfErrors failed + StrCmp $0 "register sentinel" 0 failed + StrCmp $OUTDIR $PLUGINSDIR 0 failed + IfFileExists "$PLUGINSDIR\7z-out\*.*" failed + + FileOpen $1 "$PLUGINSDIR\archive.7z" r + FileRead $1 $2 + FileClose $1 + StrCmp $2 "archive sentinel" 0 failed + FileOpen $1 "$PLUGINSDIR\plugin.dll" r + FileRead $1 $2 + FileClose $1 + StrCmp $2 "plugin sentinel" 0 failed + FileOpen $1 "$PLUGINSDIR\old-install\sentinel" r + FileRead $1 $2 + FileClose $1 + StrCmp $2 "rollback sentinel" 0 failed + + CreateDirectory "$PLUGINSDIR\7z-out" + SetErrors + !insertmacro customInstall + IfErrors +2 + Goto failed + IfFileExists "$PLUGINSDIR\7z-out\*.*" failed + + ClearErrors + !insertmacro customInstall + IfErrors failed + FileOpen $1 "${RESULT_FILE}" w + FileWrite $1 "scratch removed; archive, plugin, rollback, registers and error flags preserved" + FileClose $1 + SetErrorLevel 0 + Quit + + failed: + FileOpen $1 "${RESULT_FILE}" w + FileWrite $1 "cleanup smoke failed" + FileClose $1 + SetErrorLevel 1 + Quit +SectionEnd diff --git a/apps/desktop/tests/fixtures/installer-write-failure-smoke.nsi b/apps/desktop/tests/fixtures/installer-write-failure-smoke.nsi new file mode 100644 index 0000000000..d7976e51ad --- /dev/null +++ b/apps/desktop/tests/fixtures/installer-write-failure-smoke.nsi @@ -0,0 +1,55 @@ +; Compile with OUTPUT_FILE, RESULT_FILE, PAYLOAD_FILE, TARGET_DIR and PLUGIN_DIR. +; All paths must belong to a private test directory, except the NSIS plugin directory. +; PAYLOAD_FILE contains locked.txt="new runtime" and asset.txt="new asset". +; Before running /S, create TARGET_DIR\locked.txt="old runtime" and hold it open +; for reading with FileShare.Read until the native process exits. asset.txt is absent. +; Define DIRECT for direct extraction; otherwise extract to temporary files and copy. +; Both modes retain "old runtime" and write "new asset" under that lock. The copy +; sets errorFlag=true; direct extraction reports false despite the mixed versions. +; Exit code 0 alone cannot establish successful extraction; inspect RESULT_FILE. +Unicode true +RequestExecutionLevel user +SilentInstall silent +Name "Desktop occupied-file extraction smoke" +OutFile "${OUTPUT_FILE}" +!addplugindir /x86-unicode "${PLUGIN_DIR}" + +Section + InitPluginsDir + SetOutPath "${TARGET_DIR}" + !ifdef DIRECT + ClearErrors + Nsis7z::Extract "${PAYLOAD_FILE}" + !else + SetOutPath "$PLUGINSDIR\7z-out" + Nsis7z::Extract "${PAYLOAD_FILE}" + SetOutPath "${TARGET_DIR}" + ClearErrors + CopyFiles /SILENT "$PLUGINSDIR\7z-out\*" $OUTDIR + !endif + + StrCpy $0 "false" + IfErrors 0 +2 + StrCpy $0 "true" + ClearErrors + FileOpen $1 "${RESULT_FILE}" w + IfErrors failed + FileWrite $1 "errorFlag=$0$\r$\n" + FileOpen $2 "${TARGET_DIR}\locked.txt" r + IfErrors failed + FileRead $2 $3 + FileClose $2 + FileWrite $1 "locked=$3$\r$\n" + FileOpen $2 "${TARGET_DIR}\asset.txt" r + IfErrors failed + FileRead $2 $3 + FileClose $2 + FileWrite $1 "asset=$3$\r$\n" + FileClose $1 + SetErrorLevel 0 + Quit + + failed: + SetErrorLevel 1 + Quit +SectionEnd diff --git a/apps/desktop/tests/fixtures/owned-directory-smoke.mjs b/apps/desktop/tests/fixtures/owned-directory-smoke.mjs new file mode 100644 index 0000000000..52315992ba --- /dev/null +++ b/apps/desktop/tests/fixtures/owned-directory-smoke.mjs @@ -0,0 +1,29 @@ +/** Built Desktop cleanup smoke; run with Electron and ELECTRON_RUN_AS_NODE=1. */ + +import assert from 'node:assert/strict' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { removeOwnedDirectory } from '../../lib/types/owned-directory.js' +import { inventoryDesktopRuntime } from '../../lib/types/runtime-tree.js' + +assert.ok(process.versions.electron, 'This regression must run under Electron') +const root = mkdtempSync(join(tmpdir(), 'desktop-electron-cleanup-')) +try { + const resources = join(root, 'resources') + mkdirSync(resources) + writeFileSync(join(resources, 'sentinel'), 'installed runtime bytes') + const inventory = inventoryDesktopRuntime(resources) + const profile = join(root, 'rollback-profile') + const packages = join(profile, 'node_modules', '@unknown') + mkdirSync(packages, { recursive: true }) + symlinkSync(resources, join(packages, 'host-package'), process.platform === 'win32' ? 'junction' : 'dir') + writeFileSync(join(packages, 'private-file'), 'removed') + removeOwnedDirectory(profile) + assert.equal(existsSync(profile), false) + assert.deepEqual(inventoryDesktopRuntime(resources), inventory) + assert.equal(readFileSync(join(resources, 'sentinel'), 'utf8'), 'installed runtime bytes') + console.log(JSON.stringify({ electron: process.versions.electron, node: process.version, preservedLinkTarget: true })) +} finally { + removeOwnedDirectory(root) +} diff --git a/apps/desktop/tests/fixtures/runtime-payload-smoke.mjs b/apps/desktop/tests/fixtures/runtime-payload-smoke.mjs new file mode 100644 index 0000000000..da2d5c478d --- /dev/null +++ b/apps/desktop/tests/fixtures/runtime-payload-smoke.mjs @@ -0,0 +1,138 @@ +/** Exercise filtered Desktop native and HTML dependencies under its bundled Node. */ + +import assert from 'node:assert/strict' +import { closeSync, mkdtempSync, openSync, readFileSync, readSync, writeFileSync } from 'node:fs' +import { rm } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' + +const runtime = process.argv[2] +assert.ok(runtime, 'Pass the filtered resources/dsh directory') +const root = resolve(runtime) +const descriptor = JSON.parse(readFileSync(join(root, 'desktop-runtime.json'), 'utf8')) +assert.equal(process.versions.node, descriptor.release.nodeVersion, 'Run with the bundled Node version') +assert.equal(process.platform, descriptor.platform) +assert.equal(process.arch, descriptor.arch) +const requireRuntime = createRequire(join(root, 'package.json')) +const scratch = mkdtempSync(join(tmpdir(), 'dsh-runtime-payload-')) + +/** Spawn only a fixed Node program and await the terminal's drained exit event. */ +async function checkPty() { + const pty = requireRuntime('node-pty') + const script = join(scratch, 'pty.cjs') + writeFileSync(script, "process.stdout.write('runtime-payload-pty-ok\\n')\n", { flag: 'wx', mode: 0o600 }) + const env = Object.fromEntries(Object.entries(process.env).filter(([name]) => ( + /^(?:path|systemroot|windir|comspec)$/iu.test(name) + ))) + Object.assign(env, { HOME: scratch, USERPROFILE: scratch, TMP: scratch, TEMP: scratch, TMPDIR: scratch }) + const terminal = pty.spawn(process.execPath, [script], { cwd: scratch, env, cols: 80, rows: 24 }) + let output = '' + let exited = false + let timedOut = false + let exitSubscription + const exit = new Promise(resolveExit => { + exitSubscription = terminal.onExit(event => { + exited = true + resolveExit(event) + }) + }) + const dataSubscription = terminal.onData(data => { output += data }) + let timer + try { + const deadline = new Promise((_, reject) => { + timer = setTimeout(() => { + timedOut = true + reject(new Error('Packaged PTY did not exit within 45 seconds')) + }, 45_000) + }) + const result = await Promise.race([exit, deadline]) + assert.equal(timedOut, false) + assert.ok(result.signal === undefined || result.signal === 0, 'PTY exited without a signal') + assert.equal(result.exitCode, 0) + assert.match(output, /runtime-payload-pty-ok/u) + } finally { + clearTimeout(timer) + dataSubscription.dispose() + try { + // node-pty's Windows natural-exit event closes output but leaves its ConPTY worker owned by kill(). + if (!exited || process.platform === 'win32') terminal.kill() + await exit + } finally { + exitSubscription.dispose() + } + } +} + +/** fs-ext implements seek on Windows through SetFilePointerEx and on POSIX through lseek. */ +function checkFsExt() { + const fsExt = requireRuntime('fs-ext') + const file = join(scratch, 'seek.txt') + writeFileSync(file, 'abcdef', { flag: 'wx', mode: 0o600 }) + const fd = openSync(file, 'r') + try { + assert.equal(fsExt.seekSync(fd, 2, fsExt.constants.SEEK_SET), 2) + const bytes = Buffer.alloc(4) + assert.equal(readSync(fd, bytes, 0, bytes.length, null), 4) + assert.equal(bytes.toString(), 'cdef') + } finally { + closeSync(fd) + } +} + +/** Resolve one system function through Koffi's packaged native module. */ +function checkKoffi() { + const koffi = requireRuntime('koffi') + const library = koffi.load(process.platform === 'win32' ? 'kernel32.dll' : null) + try { + const getPid = process.platform === 'win32' + ? library.func('uint32_t __stdcall GetCurrentProcessId(void)') + : library.func('int getpid(void)') + assert.equal(getPid(), process.pid) + } finally { + library.unload() + } +} + +/** Encode and decode a pixel through the packaged libvips binary. */ +async function checkSharp() { + const sharp = requireRuntime('sharp') + const pixel = Buffer.from([17, 103, 231]) + const png = await sharp(pixel, { raw: { width: 1, height: 1, channels: 3 } }).png().toBuffer() + const decoded = await sharp(png).raw().toBuffer({ resolveWithObject: true }) + assert.equal(decoded.info.width, 1) + assert.equal(decoded.info.height, 1) + assert.equal(decoded.info.channels, 3) + assert.deepEqual(decoded.data, pixel) +} + +/** Exercise Domino parsing through the HTML converter and GFM plugin used by web_fetch. */ +function checkHtml() { + const Turndown = requireRuntime('turndown') + const { gfm } = requireRuntime('@joplin/turndown-plugin-gfm') + const converter = new Turndown({ bulletListMarker: '-' }) + converter.use(gfm) + const markdown = converter.turndown('

A & B ©

  • first
  • second
' + + '
NameValue
x7
') + assert.match(markdown, /A & B ©/u) + assert.match(markdown, /-\s+first\n-\s+second/u) + assert.match(markdown, /\| Name \| Value \|/u) + assert.match(markdown, /\| x\s+\| 7\s+\|/u) +} + +try { + checkFsExt() + checkKoffi() + await checkSharp() + checkHtml() + await checkPty() +} finally { + // This private tree contains only fixture files; Windows may release handles after terminal exit. + await rm(scratch, { recursive: true, force: true, maxRetries: 20, retryDelay: 50 }) +} + +// Natural event-loop drain includes node-pty's worker and console-list helper teardown. +process.once('beforeExit', () => { + console.log(JSON.stringify({ node: process.versions.node, platform: process.platform, arch: process.arch, + fsExt: true, koffi: true, sharp: true, html: true, pty: true })) +}) diff --git a/apps/desktop/tests/host-process.spec.ts b/apps/desktop/tests/host-process.spec.ts index 9a9a2d4730..f41650c26e 100644 --- a/apps/desktop/tests/host-process.spec.ts +++ b/apps/desktop/tests/host-process.spec.ts @@ -1,7 +1,7 @@ import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { DesktopHostProcess } from '../src/host-process.ts' const roots: string[] = [] @@ -75,6 +75,30 @@ afterEach(() => { }) describe('desktop host process', () => { + it('reports a fatal event after readiness once and stops the child', async () => { + const runtime = projectWithHost(` +process.send({ type: 'ready', protocolVersion: 3, dshVersion: '1.0.0' }) +function onRequestFrame(frame) { + if (frame.type === 1) process.send({ type: 'fatal', message: 'plugin unavailable' }) +} +`) + const failure = vi.fn() + const host = new DesktopHostProcess(process.execPath, runtime, runtime, undefined, process.env, failure) + try { + await host.start() + await expect(host.fetch(new Request('dsh-app://app/'))).rejects.toThrow('plugin unavailable') + await host.stop() + expect(failure).toHaveBeenCalledTimes(1) + expect(failure).toHaveBeenCalledWith(new Error('plugin unavailable')) + } finally { await host.stop() } + }) + + it('settles teardown when the executable cannot be spawned', async () => { + const runtime = projectWithHost('function onRequestFrame() {}') + const host = new DesktopHostProcess(join(runtime, 'missing-node'), runtime, runtime) + try { await expect(host.start()).rejects.toThrow() } finally { await host.stop() } + }) + it('loads the resource entry with a separate profile and scrubs Node resolution overrides', async () => { const runtime = projectWithHost(` process.send({ type: 'ready', protocolVersion: 3, dshVersion: 'split-runtime' }) diff --git a/apps/desktop/tests/macos-signature.spec.ts b/apps/desktop/tests/macos-signature.spec.ts index ada386cc38..e481d9a9f0 100644 --- a/apps/desktop/tests/macos-signature.spec.ts +++ b/apps/desktop/tests/macos-signature.spec.ts @@ -105,10 +105,10 @@ describe('desktop macOS release signature', () => { join(source, relative(sourceRoot, entry.from)), join(destination, entry.to), value => value, )) await copyFiles(matchers.slice(0, 1)) - expect(() => verifyDesktopRuntime(join(destination, 'dsh'), '1.0.0')).toThrow(/integrity/u) + await expect(verifyDesktopRuntime(join(destination, 'dsh'), '1.0.0')).rejects.toThrow(/ENOENT/u) rmSync(destination, { recursive: true }) await copyFiles(matchers) - expect(() => verifyDesktopRuntime(join(destination, 'dsh'), '1.0.0')).not.toThrow() + await expect(verifyDesktopRuntime(join(destination, 'dsh'), '1.0.0')).resolves.toMatchObject({ release: { version: '1.0.0' } }) } finally { rmSync(root, { recursive: true, force: true }) } }) @@ -120,6 +120,29 @@ describe('desktop macOS release signature', () => { }, 'win32')).toThrow(/DSH_DESKTOP_WINDOWS_CER_FILE/u) }) + it('isolates unsigned Windows artifacts and omits updater metadata without release credentials', async () => { + const { createElectronBuilderConfig } = await import('../electron-builder.config.mjs') + const config = createElectronBuilderConfig({ + DSH_DESKTOP_APP_ID: RELEASE_ENVIRONMENT.DSH_DESKTOP_APP_ID, + DSH_DESKTOP_TARGET_PLATFORM: 'win32', + DSH_DESKTOP_UNSIGNED: '1', + }, 'win32', 'x64') + expect(portablePath(config.directories.output)).toContain('/targets/win-x64/unsigned-artifacts') + expect(portablePath(config.nsis.include)).toMatch(/\/scripts\/installer\.nsh$/u) + expect(config).toMatchObject({ + win: { forceCodeSigning: false, signtoolOptions: { sign: undefined } }, + publish: null, + }) + }) + + it('rejects unsigned macOS builds and malformed signing modes', async () => { + const { createElectronBuilderConfig } = await import('../electron-builder.config.mjs') + expect(() => createElectronBuilderConfig({ ...RELEASE_ENVIRONMENT, DSH_DESKTOP_UNSIGNED: '1' })) + .toThrow(/unsigned builds require Windows/u) + expect(() => createElectronBuilderConfig({ ...RELEASE_ENVIRONMENT, DSH_DESKTOP_UNSIGNED: 'yes' })) + .toThrow(/must be 0 or 1/u) + }) + it('accepts the configured authority and team', () => { const expected = resolveMacOSSigningEnvironment(RELEASE_ENVIRONMENT) expect(() => { diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts new file mode 100644 index 0000000000..252db0a18c --- /dev/null +++ b/apps/desktop/tests/main-startup.spec.ts @@ -0,0 +1,200 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { DESKTOP_IPC } from '../src/ipc.ts' + +const harness = await vi.hoisted(async () => { + const { EventEmitter } = await import('node:events') + function deferred() { + let resolve!: () => void + let reject!: (error: Error) => void + const promise = new Promise((accept, decline) => { resolve = accept; reject = decline }) + return { promise, resolve, reject } + } + const windows: FakeWindow[] = [] + const hosts: FakeHost[] = [] + const handlers = new Map unknown>() + let preparing = deferred() + let prepared = deferred() + let hostStarted = deferred() + let navigated = deferred() + let errorPublished = deferred() + let quitCompleted = deferred() + class FakeWindow extends EventEmitter { + destroyed = false + readonly urls: string[] = [] + readonly webContents = Object.assign(new EventEmitter(), { + setWindowOpenHandler: vi.fn(), + openDevTools: vi.fn(), + send: vi.fn((channel: string, state: { phase?: string }) => { + if (channel === 'dsh-desktop:backend-state' && state.phase === 'error') errorPublished.resolve() + }), + }) + readonly show = vi.fn() + readonly focus = vi.fn() + readonly restore = vi.fn() + constructor(readonly options: { show: boolean }) { super(); windows.push(this) } + isDestroyed() { return this.destroyed } + isMinimized() { return false } + async loadURL(url: string) { + this.urls.push(url) + if (url === 'dsh-app://app/index.html') navigated.resolve() + } + static getAllWindows() { return windows.filter(window => !window.destroyed) } + close() { this.destroyed = true; this.emit('closed') } + } + class FakeHost { + readonly ready = deferred() + readonly exited = deferred() + readonly stopping = deferred() + readonly start = vi.fn(() => { hostStarted.resolve(); return this.ready.promise }) + readonly stop = vi.fn(() => { + this.stopping.resolve() + this.ready.reject(new Error('child stopped')) + return this.exited.promise + }) + constructor(..._args: unknown[]) { hosts.push(this) } + } + const app = Object.assign(new EventEmitter(), { + isPackaged: false, + name: 'Desktop test', + whenReady: () => Promise.resolve(), + getLocale: () => 'en-US', + getVersion: () => '1.0.0', + requestSingleInstanceLock: () => true, + exit: vi.fn(), + quit: vi.fn(() => { + const event = { preventDefault: vi.fn() } + app.emit('before-quit', event) + if (event.preventDefault.mock.calls.length === 0) quitCompleted.resolve() + }), + }) + return { + windows, hosts, handlers, app, FakeWindow, FakeHost, + dialog: { showErrorBox: vi.fn(), showMessageBox: vi.fn() }, + applyRelease: vi.fn(() => { preparing.resolve(); return prepared.promise }), + get preparing() { return preparing }, get prepared() { return prepared }, + get hostStarted() { return hostStarted }, get navigated() { return navigated }, + get errorPublished() { return errorPublished }, get quitCompleted() { return quitCompleted }, + nextHostStart() { hostStarted = deferred(); return hostStarted.promise }, + reset() { + windows.length = 0; hosts.length = 0; handlers.clear(); app.removeAllListeners() + preparing = deferred(); prepared = deferred(); hostStarted = deferred() + navigated = deferred(); errorPublished = deferred(); quitCompleted = deferred() + }, + } +}) + +vi.mock('electron', () => ({ + app: harness.app, + BrowserWindow: harness.FakeWindow, + dialog: harness.dialog, + ipcMain: { + handle: (channel: string, handler: (event: { senderFrame: { url: string } }) => unknown) => { harness.handlers.set(channel, handler) }, + }, + Menu: { setApplicationMenu: vi.fn(), buildFromTemplate: vi.fn() }, + protocol: { registerSchemesAsPrivileged: vi.fn(), handle: vi.fn() }, +})) +vi.mock('../src/paths.ts', () => ({ resolveDesktopPaths: () => ({ profile: 'desktop-test-profile' }) })) +vi.mock('../src/project-manager.ts', () => ({ + DesktopProjectManager: class { + readonly applyRelease = harness.applyRelease + readonly assertProfileRuntime = vi.fn() + }, +})) +vi.mock('../src/host-process.ts', () => ({ DesktopHostProcess: harness.FakeHost })) +vi.mock('../src/update-coordinator.ts', () => ({ DesktopUpdateCoordinator: vi.fn() })) + +function invoke(channel: string): unknown { + const handler = harness.handlers.get(channel) + if (handler === undefined) throw new Error(`missing handler ${channel}`) + return handler({ senderFrame: { url: 'dsh-app://shell/startup.html' } }) +} + +beforeEach(() => { + vi.resetModules() + vi.clearAllMocks() + vi.useFakeTimers() + harness.reset() + vi.stubEnv('DSH_DESKTOP_NODE_BINARY', 'test-node') + vi.stubEnv('DSH_DESKTOP_PNPM_ENTRY', 'test-pnpm') + vi.stubEnv('DSH_DESKTOP_DSH_DIR', 'test-runtime') + vi.stubEnv('DSH_DESKTOP_DEV_PROJECT_DIR', undefined) +}) + +afterEach(async () => { + harness.prepared.resolve() + for (const host of harness.hosts) { host.ready.resolve(); host.exited.resolve() } + harness.app.quit() + await harness.quitCompleted.promise + vi.clearAllTimers() + vi.useRealTimers() + vi.unstubAllEnvs() +}) + +describe('desktop main startup', () => { + it('shows the loading window before profile preparation and starts one actual Host', async () => { + await import('../src/main.ts') + await harness.preparing.promise + expect(harness.windows).toHaveLength(1) + const window = harness.windows[0]! + expect(window.options.show).toBe(true) + expect(window.urls).toEqual(['dsh-app://shell/startup.html']) + expect(harness.hosts).toHaveLength(0) + const retry = invoke(DESKTOP_IPC.backendRetry) + const secondRetry = invoke(DESKTOP_IPC.backendRetry) + harness.prepared.resolve() + await harness.hostStarted.promise + expect(harness.hosts).toHaveLength(1) + expect(window.urls).toEqual(['dsh-app://shell/startup.html']) + harness.hosts[0]!.ready.resolve() + await Promise.all([retry, secondRetry, harness.navigated.promise]) + expect(harness.applyRelease).toHaveBeenCalledTimes(1) + expect(harness.hosts[0]!.start).toHaveBeenCalledTimes(1) + expect(harness.windows).toHaveLength(1) + expect(window.urls).toEqual(['dsh-app://shell/startup.html', 'dsh-app://app/index.html']) + expect(invoke(DESKTOP_IPC.backendStatus)).toEqual({ phase: 'ready' }) + }) + + it('keeps startup errors and a successful retry in the same window', async () => { + await import('../src/main.ts') + await harness.preparing.promise + harness.prepared.resolve() + await harness.hostStarted.promise + const first = harness.hosts[0]! + const failedRetry = expect(Promise.resolve(invoke(DESKTOP_IPC.backendRetry))).rejects.toThrow('plugin composition failed') + first.exited.resolve() + first.ready.reject(new Error('plugin composition failed')) + await harness.errorPublished.promise + await failedRetry + expect(invoke(DESKTOP_IPC.backendStatus)).toEqual({ phase: 'error', message: 'plugin composition failed' }) + expect(harness.windows[0]!.urls).toEqual(['dsh-app://shell/startup.html']) + const nextStarted = harness.nextHostStart() + const retry = Promise.resolve(invoke(DESKTOP_IPC.backendRetry)) + await nextStarted + expect(harness.hosts).toHaveLength(2) + harness.hosts[1]!.ready.resolve() + await retry + expect(harness.windows).toHaveLength(1) + expect(harness.windows[0]!.urls.at(-1)).toBe('dsh-app://app/index.html') + expect(harness.dialog.showErrorBox).not.toHaveBeenCalled() + }) + + it('waits for a pending child to exit on quit without late window navigation', async () => { + await import('../src/main.ts') + await harness.preparing.promise + harness.prepared.resolve() + await harness.hostStarted.promise + const window = harness.windows[0]! + const host = harness.hosts[0]! + host.stop.mockImplementation(() => { host.stopping.resolve(); return host.exited.promise }) + window.close() + harness.app.quit() + await host.stopping.promise + expect(harness.app.quit).toHaveBeenCalledTimes(1) + host.ready.resolve() + host.exited.resolve() + await harness.quitCompleted.promise + expect(host.stop).toHaveBeenCalledTimes(1) + expect(window.urls).toEqual(['dsh-app://shell/startup.html']) + expect(harness.windows).toHaveLength(1) + }) +}) diff --git a/apps/desktop/tests/owned-directory.spec.ts b/apps/desktop/tests/owned-directory.spec.ts new file mode 100644 index 0000000000..081e5adc76 --- /dev/null +++ b/apps/desktop/tests/owned-directory.spec.ts @@ -0,0 +1,53 @@ +import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it } from 'vitest' +import { removeOwnedDirectory } from '../src/owned-directory.ts' + +const roots: string[] = [] +function fixture(): string { + const root = mkdtempSync(join(tmpdir(), 'desktop-owned-directory-')) + roots.push(root) + return root +} + +afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) }) + +it('removes nested files and directories while preserving unknown directory-link targets', () => { + const root = fixture() + const target = join(root, 'target') + mkdirSync(target) + writeFileSync(join(target, 'sentinel'), 'preserved') + const owned = join(root, 'owned') + const nested = join(owned, 'node_modules', '@unknown', 'private') + mkdirSync(nested, { recursive: true }) + writeFileSync(join(nested, 'file'), 'remove') + symlinkSync(target, join(nested, 'link'), process.platform === 'win32' ? 'junction' : 'dir') + symlinkSync(join(root, 'missing-target'), join(owned, 'broken'), process.platform === 'win32' ? 'junction' : 'dir') + removeOwnedDirectory(owned) + expect(existsSync(owned)).toBe(false) + expect(readFileSync(join(target, 'sentinel'), 'utf8')).toBe('preserved') +}) + +it.each([false, true])('unlinks a root directory link with missing target: %s', (missing) => { + const root = fixture() + const target = join(root, 'target') + if (!missing) { + mkdirSync(target) + writeFileSync(join(target, 'sentinel'), 'preserved') + } + const link = join(root, 'link') + symlinkSync(target, link, process.platform === 'win32' ? 'junction' : 'dir') + removeOwnedDirectory(link) + expect(lstatSync(link, { throwIfNoEntry: false })).toBeUndefined() + if (!missing) expect(readFileSync(join(target, 'sentinel'), 'utf8')).toBe('preserved') +}) + +it('ignores a missing root and refuses to delete a regular-file root', () => { + const root = fixture() + expect(() => { removeOwnedDirectory(join(root, 'missing')) }).not.toThrow() + const file = join(root, 'file') + writeFileSync(file, 'preserved') + expect(() => { removeOwnedDirectory(file) }).toThrow(/not a directory/u) + expect(readFileSync(file, 'utf8')).toBe('preserved') +}) diff --git a/apps/desktop/tests/package-target.spec.ts b/apps/desktop/tests/package-target.spec.ts index ae5e0ca800..d284747e86 100644 --- a/apps/desktop/tests/package-target.spec.ts +++ b/apps/desktop/tests/package-target.spec.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { desktopElectronBuilderArguments, + desktopElectronBuilderEnvironment, parseDesktopPackageInvocation, resolveDesktopPackageTarget, withoutDesktopUploadCredentials, @@ -56,6 +57,45 @@ describe('desktop package target', () => { expect(desktopElectronBuilderArguments(target, true)).toContain('--dir') }) + it('accepts unsigned Windows artifacts and rejects other targets or preparation-only use', () => { + expect(parseDesktopPackageInvocation(['win-x64', '--unsigned'], 'win32', 'x64').unsigned).toBe(true) + expect(parseDesktopPackageInvocation(['win-x64'], 'win32', 'x64').unsigned).toBe(false) + expect(parseDesktopPackageInvocation(['--unsigned', '--dir'], 'win32', 'x64')).toMatchObject({ + unsigned: true, directory: true, + }) + expect(() => parseDesktopPackageInvocation(['mac-arm64', '--unsigned'], 'darwin', 'arm64')) + .toThrow(/requires win-x64/u) + expect(() => parseDesktopPackageInvocation(['--unsigned', '--prepare-only'], 'win32', 'x64')) + .toThrow(/cannot use --prepare-only/u) + }) + + it('removes ambient certificate inputs for unsigned builds and overrides an inherited signing mode', () => { + const environment = { + DSH_DESKTOP_APP_ID: 'com.example.desktop', + DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'token-secret', + CSC_LINK: 'private.pfx', + CSC_KEY_PASSWORD: 'secret', + WIN_CSC_LINK: 'windows.pfx', + CSC_IDENTITY_AUTO_DISCOVERY: 'true', + DSH_DESKTOP_UNSIGNED: '1', + } + expect(desktopElectronBuilderEnvironment(environment, true)).toEqual({ + DSH_DESKTOP_APP_ID: 'com.example.desktop', + CSC_IDENTITY_AUTO_DISCOVERY: 'false', + DSH_DESKTOP_UNSIGNED: '1', + }) + expect(desktopElectronBuilderEnvironment(environment, false)).toEqual({ ...environment, DSH_DESKTOP_UNSIGNED: '0' }) + }) + + it.each([false, true])('pins the Windows archive filter for the NSIS decoder (unsigned: %s)', (unsigned) => { + expect(desktopElectronBuilderEnvironment({ + DSH_DESKTOP_TARGET_PLATFORM: 'win32', ELECTRON_BUILDER_7Z_FILTER: 'ARM64', + }, unsigned).ELECTRON_BUILDER_7Z_FILTER).toBe('BCJ') + expect(desktopElectronBuilderEnvironment({ + DSH_DESKTOP_TARGET_PLATFORM: 'darwin', ELECTRON_BUILDER_7Z_FILTER: 'ARM', + }, unsigned).ELECTRON_BUILDER_7Z_FILTER).toBe('ARM') + }) + it('keeps Windows signing fields out of build and runtime preparation subprocesses', () => { expect(withoutWindowsSigningEnvironment({ DSH_DESKTOP_WINDOWS_CER_FILE: 'C:\\release\\server.cer', diff --git a/apps/desktop/tests/plugin-manager.spec.ts b/apps/desktop/tests/plugin-manager.spec.ts index 376b523f58..d870b7ecc7 100644 --- a/apps/desktop/tests/plugin-manager.spec.ts +++ b/apps/desktop/tests/plugin-manager.spec.ts @@ -12,7 +12,7 @@ it('keeps disabled packages visible and offers recovery without a running backen const toggle = vi.fn(async (_name: string, active: boolean) => { enabled = active }) const api = { locale: async () => resolveDesktopLocale('en'), - backend: { status: async () => ({ ready, error: ready ? undefined : 'plugin requires Cordis ^2.0.0' }), retry: vi.fn() }, + backend: { status: async () => ready ? { phase: 'ready' } : { phase: 'error', message: 'plugin requires Cordis ^2.0.0' }, retry: vi.fn() }, plugins: { list: async () => [{ name: 'example-plugin', version: '1.0.0', enabled }], disableAll, toggle }, } Object.defineProperty(dom.window, 'dshDesktop', { value: api }) diff --git a/apps/desktop/tests/plugin-pnpm.spec.ts b/apps/desktop/tests/plugin-pnpm.spec.ts index 1a455e3453..f565967cee 100644 --- a/apps/desktop/tests/plugin-pnpm.spec.ts +++ b/apps/desktop/tests/plugin-pnpm.spec.ts @@ -47,7 +47,7 @@ it('installs a real pnpm graph, then executes approved scripts with the shared h const realPnpm = join(import.meta.dirname, '../node_modules/pnpm/bin/pnpm.mjs') writeFileSync(pnpm, `process.argv = process.argv.map(arg => arg === '--config.registry=https://registry.npmjs.org/' ? ${JSON.stringify(`--config.registry=${origin}`)} : arg); await import(${JSON.stringify(pathToFileURL(realPnpm).href)})`) const manager = new DesktopProjectManager(resolveDesktopPaths(join(root, '.dsh')), { node: process.execPath, pnpm, dsh }) - const hooks: DesktopProjectHooks = { healthCheck: async () => {}, beforeActivate: async () => {}, afterActivate: async () => {} } + const hooks: DesktopProjectHooks = { beforeActivate: async () => {}, afterActivate: async () => {} } await manager.applyRelease('1.0.0', hooks) await manager.mutate({ type: 'plugin-add', spec: 'fixture-plugin@1.0.0' }, hooks) expect(manager.listPlugins()).toEqual([{ name: 'fixture-plugin', version: '1.0.0', enabled: true }]) diff --git a/apps/desktop/tests/preload-app.spec.ts b/apps/desktop/tests/preload-app.spec.ts new file mode 100644 index 0000000000..81eac02cc3 --- /dev/null +++ b/apps/desktop/tests/preload-app.spec.ts @@ -0,0 +1,37 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { DESKTOP_IPC, type DshDesktopStartupApi } from '../src/ipc.ts' + +const electron = vi.hoisted(() => ({ + contextBridge: { exposeInMainWorld: vi.fn() }, + ipcRenderer: { invoke: vi.fn(), on: vi.fn(), off: vi.fn() }, +})) +vi.mock('electron', () => electron) + +afterEach(() => { vi.unstubAllGlobals(); vi.clearAllMocks(); vi.resetModules() }) + +it.each(['dsh-app://app/index.html', 'https://shell/startup.html'])('exposes only the carrier marker to %s', async (url) => { + vi.stubGlobal('location', new URL(url)) + await import('../src/preload-app.ts') + expect(electron.contextBridge.exposeInMainWorld).toHaveBeenCalledWith('dshDesktop', { protocolVersion: 1 }) +}) + +it('provides startup controls and a removable state subscription to shell documents', async () => { + vi.stubGlobal('location', new URL('dsh-app://shell/startup.html')) + await import('../src/preload-app.ts') + const api = electron.contextBridge.exposeInMainWorld.mock.calls[0]?.[1] as DshDesktopStartupApi + await api.locale() + await api.backend.status() + await api.backend.retry() + await api.openPlugins() + expect(electron.ipcRenderer.invoke.mock.calls).toEqual([ + [DESKTOP_IPC.localeGet], [DESKTOP_IPC.backendStatus], [DESKTOP_IPC.backendRetry], [DESKTOP_IPC.pluginsOpen], + ]) + const listener = vi.fn() + const dispose = api.backend.subscribe(listener) + const handler = electron.ipcRenderer.on.mock.calls[0]?.[1] as (event: unknown, state: unknown) => void + handler({}, { phase: 'error', message: 'startup failed' }) + expect(listener).toHaveBeenCalledWith({ phase: 'error', message: 'startup failed' }) + dispose() + expect(electron.ipcRenderer.off).toHaveBeenCalledWith(DESKTOP_IPC.backendState, handler) + expect(api).not.toHaveProperty('plugins') +}) diff --git a/apps/desktop/tests/project-manager.spec.ts b/apps/desktop/tests/project-manager.spec.ts index 18e5c26c7a..9d141efa2d 100644 --- a/apps/desktop/tests/project-manager.spec.ts +++ b/apps/desktop/tests/project-manager.spec.ts @@ -48,7 +48,7 @@ if (command !== 'rebuild') { return path } function hooks(overrides: Partial = {}): DesktopProjectHooks { - return { healthCheck: async () => {}, beforeActivate: async () => {}, afterActivate: async () => {}, ...overrides } + return { beforeActivate: async () => {}, afterActivate: async () => {}, ...overrides } } function setup(): { root: string; manager: DesktopProjectManager } { const root = temporaryRoot() @@ -91,6 +91,36 @@ describe('desktop external plugin profile', () => { expect(calls(root)).toEqual([]) }) + it.skipIf(process.platform !== 'win32')('reuses the profile when the launch path changes only Windows letter casing', async () => { + const { manager } = setup() + await manager.applyRelease('1.0.0', hooks()) + const relaunched = new DesktopProjectManager(manager.paths, { ...manager.runtime, dsh: manager.runtime.dsh.toUpperCase() }) + let started = false + await expect(relaunched.applyRelease('1.0.0', hooks({ + afterActivate: async () => { started = true }, + }))).resolves.toBe(false) + expect(started).toBe(false) + }) + + it.each(['changed', 'same-size', 'extra', 'missing'])('starts and reuses a profile without checking %s runtime bytes', async (operation) => { + const { root, manager } = setup() + if (operation === 'changed') writeFileSync(join(manager.runtime.dsh, 'package.json'), '{}') + if (operation === 'same-size') writeFileSync(join(manager.runtime.dsh, 'package.json'), '{"type":"Module"}\n') + if (operation === 'extra') writeFileSync(join(manager.runtime.dsh, 'extra'), '') + if (operation === 'missing') unlinkSync(join(manager.runtime.dsh, 'package.json')) + let starts = 0 + await expect(manager.applyRelease('1.0.0', hooks({ + afterActivate: async () => { starts++ }, + }))).resolves.toBe(true) + const relaunched = new DesktopProjectManager(manager.paths, manager.runtime) + await expect(relaunched.applyRelease('1.0.0', hooks({ + afterActivate: async () => { starts++ }, + }))).resolves.toBe(false) + expect(starts).toBe(1) + expect(existsSync(manager.paths.profile)).toBe(true) + expect(calls(root)).toEqual([]) + }) + it('installs only plugins and checks the graph before running lifecycle scripts', async () => { const { root, manager } = setup() await manager.applyRelease('1.0.0', hooks()) @@ -166,12 +196,11 @@ describe('desktop external plugin profile', () => { expect(next.listPlugins()).toEqual([{ name: 'plugin', version: '1.0.0', enabled: false }]) }) - it.each(['health', 'before', 'after'] as const)('keeps the active profile when %s activation fails', async (phase) => { + it.each(['before', 'after'] as const)('keeps the active profile when %s activation fails', async (phase) => { const { manager } = setup() await manager.applyRelease('1.0.0', hooks()) let starts = 0 await expect(manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks({ - healthCheck: async () => { if (phase === 'health') throw new Error('health failed') }, beforeActivate: async () => { if (phase === 'before') throw new Error('before failed') }, afterActivate: async () => { if (phase === 'after' && starts++ === 0) throw new Error('after failed') }, }))).rejects.toThrow(`${phase} failed`) diff --git a/apps/desktop/tests/runtime-file-policy.spec.ts b/apps/desktop/tests/runtime-file-policy.spec.ts new file mode 100644 index 0000000000..837fd54bf1 --- /dev/null +++ b/apps/desktop/tests/runtime-file-policy.spec.ts @@ -0,0 +1,81 @@ +import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, relative } from 'node:path' +import { expect, it } from 'vitest' +import { desktopRuntimeFileExclusion } from '../scripts/runtime-file-policy.ts' +import { verifyDesktopRuntime, writeDesktopRuntime } from '../src/runtime-tree.ts' +import { runtimeFixture } from './runtime-fixture.ts' + +const windows = { platform: 'win32' as const, arch: 'x64' } + +it('omits development artifacts while preserving executable modules, assets and license files', async () => { + const root = mkdtempSync(join(tmpdir(), 'desktop-file-policy-')) + const source = join(root, 'source') + const output = join(root, 'output') + const removed = [ + 'example/index.d.ts', 'example/index.d.mts', 'example/index.d.cts', + 'example/index.js.map', 'example/index.mjs.map', 'example/index.cjs.map', + 'example/style.css.map', 'example/index.d.ts.map', 'example/index.d.mts.map', + 'example/index.d.cts.map', 'example/tsconfig.tsbuildinfo', + 'fs-ext/build/Release/obj/fs_ext/native.obj', 'fs-ext/build/Release/fs_ext.pdb', + 'fs-ext/build/Release/fs_ext.lib', 'fs-ext/build/Release/fs_ext.exp', + 'fs-ext/build/Release/fs_ext.iobj', 'fs-ext/build/Release/fs_ext.ipdb', + 'fs-ext/build/binding.sln', 'fs-ext/build/config.gypi', + 'fs-ext/build/fs_ext.vcxproj', 'fs-ext/build/fs_ext.vcxproj.filters', + 'node-pty/prebuilds/win32-arm64/conpty.node', + 'node-pty/prebuilds/linux-x64/pty.node', 'node-pty/prebuilds/darwin-x64/pty.node', + 'node-pty/prebuilds/win32-x64/conpty.pdb', + '@koromix/koffi-win32-x64/win32_x64/koffi.lib', + '@mixmark-io/domino/test/entities.html', + '.modules.yaml', '.pnpm-workspace-state-v1.json', '.bin/tool', '.pnpm/cache', + ] + const retained = [ + 'example/index.js', 'example/index.cjs', 'example/index.mjs', 'example/worker.js', + 'example/source.ts', 'example/src/entry.ts', 'example/test/runtime-fixture.json', + 'example/locale.json', 'example/data.map', 'example/module.wasm', + 'example/package.json', 'example/LICENSE', 'example/NOTICE', 'example/README.md', + 'example/native.lib', 'example/native.obj', 'example/symbols.pdb', + 'fs-ext/build/Release/fs_ext.node', 'fs-ext/build/Release/runtime.dll', + 'node-pty/prebuilds/win32-x64/conpty.node', + 'node-pty/prebuilds/win32-x64/conpty_console_list.node', + 'node-pty/prebuilds/win32-x64/conpty/conpty.dll', + 'node-pty/prebuilds/win32-x64/conpty/OpenConsole.exe', + 'node-pty/third_party/conpty/win10-x64/OpenConsole.exe', + '@koromix/koffi-win32-x64/win32_x64/koffi.node', + '@mixmark-io/domino/lib/HTMLParser.js', '@mixmark-io/domino/lib/EntityParser.js', + '@img/sharp-win32-x64/lib/libvips-42.dll', + ] + try { + const runtime = runtimeFixture(source) + const modules = join(source, 'node_modules') + for (const path of [...removed, ...retained]) { + mkdirSync(join(modules, path, '..'), { recursive: true }) + writeFileSync(join(modules, path), `payload:${path}`) + } + cpSync(modules, join(output, 'node_modules'), { + recursive: true, dereference: true, + filter: path => desktopRuntimeFileExclusion(relative(modules, path), windows) === undefined, + }) + for (const path of removed) expect(existsSync(join(output, 'node_modules', path)), path).toBe(false) + for (const path of retained) expect(readFileSync(join(output, 'node_modules', path), 'utf8'), path).toBe(`payload:${path}`) + const sealed = writeDesktopRuntime(output, runtime.release, runtime.sharedPackages.map(entry => entry.name)) + expect(await verifyDesktopRuntime(output, runtime.release.version)).toEqual(sealed) + expect(readFileSync(join(modules, removed[0]!), 'utf8')).toBe(`payload:${removed[0]}`) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) + +it('applies package-specific rules inside scoped and nested dependency containers', () => { + expect(desktopRuntimeFileExclusion('outer/node_modules/@mixmark-io/domino/test/data.html', windows)).toBeDefined() + expect(desktopRuntimeFileExclusion('outer\\node_modules\\node-pty\\prebuilds\\win32-arm64\\conpty.node', windows)).toBeDefined() + expect(desktopRuntimeFileExclusion('outer/node_modules/unrelated/test/data.html', windows)).toBeUndefined() + expect(desktopRuntimeFileExclusion('outer/fs-ext/build/Release/fs_ext.lib', windows)).toBeUndefined() +}) + +it('retains native prebuilds for the selected macOS architecture', () => { + const mac = { platform: 'darwin' as const, arch: 'arm64' } + expect(desktopRuntimeFileExclusion('node-pty/prebuilds/darwin-arm64/pty.node', mac)).toBeUndefined() + expect(desktopRuntimeFileExclusion('node-pty/prebuilds/darwin-x64/pty.node', mac)).toBeDefined() + expect(desktopRuntimeFileExclusion('node-pty/prebuilds/win32-x64/conpty.node', mac)).toBeDefined() +}) diff --git a/apps/desktop/tests/runtime-tree-read-failure.spec.ts b/apps/desktop/tests/runtime-tree-read-failure.spec.ts new file mode 100644 index 0000000000..042ff77c6d --- /dev/null +++ b/apps/desktop/tests/runtime-tree-read-failure.spec.ts @@ -0,0 +1,49 @@ +import { mkdtempSync, readFile, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { setImmediate } from 'node:timers/promises' +import { expect, it, vi } from 'vitest' +import { verifyDesktopRuntime } from '../src/runtime-tree.ts' +import { runtimeFixture } from './runtime-fixture.ts' + +vi.mock('node:fs', async importOriginal => ({ + ...await importOriginal(), + readFile: vi.fn(), +})) + +it('drains outstanding reads before rejecting and stops scheduling after a read failure', async () => { + const root = mkdtempSync(join(tmpdir(), 'desktop-runtime-read-failure-')) + const pending: ((error?: Error) => void)[] = [] + vi.mocked(readFile).mockImplementation((...args: unknown[]) => { + const path = args[0] + const callback = args.at(-1) + if (typeof path !== 'string' || typeof callback !== 'function') throw new Error('unexpected readFile arguments') + const complete = callback as (error: Error | null, body: Buffer | undefined) => void + pending.push((error) => { complete(error ?? null, error === undefined ? readFileSync(path) : undefined) }) + }) + let outcome: Promise | undefined + try { + runtimeFixture(root) + let settled = false + outcome = verifyDesktopRuntime(root, '1.0.0').finally(() => { settled = true }).catch((error: unknown) => error) + const inFlight = pending.length + expect(inFlight).toBeGreaterThan(1) + const failure = new Error('runtime read failed') + pending.shift()!(failure) + try { + // Pending callbacks keep the reads open across a complete event-loop turn. + await setImmediate() + expect(settled).toBe(false) + expect(pending).toHaveLength(inFlight - 1) + } finally { + for (const complete of pending.splice(0)) complete() + } + expect(await outcome).toBe(failure) + expect(pending).toHaveLength(0) + } finally { + for (const complete of pending.splice(0)) complete(new Error('test cleanup')) + await outcome + vi.mocked(readFile).mockReset() + rmSync(root, { recursive: true, force: true }) + } +}) diff --git a/apps/desktop/tests/runtime-tree.spec.ts b/apps/desktop/tests/runtime-tree.spec.ts index 4c4dddf52e..20bfa289a2 100644 --- a/apps/desktop/tests/runtime-tree.spec.ts +++ b/apps/desktop/tests/runtime-tree.spec.ts @@ -1,8 +1,9 @@ -import { cpSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, expect, it } from 'vitest' -import { DESKTOP_RUNTIME_FILE, desktopRuntimeId, runtimePath, verifyDesktopRuntime } from '../src/runtime-tree.ts' +import { DESKTOP_HOST_PACKAGE, DESKTOP_HOST_RUNTIME_FILES } from '../src/core-package-set.ts' +import { DESKTOP_RUNTIME_FILE, desktopRuntimeId, readDesktopRuntime, runtimePath, verifyDesktopRuntime } from '../src/runtime-tree.ts' import { runtimeFixture } from './runtime-fixture.ts' const roots: string[] = [] @@ -14,32 +15,59 @@ function fixture(): string { } afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) }) -it('verifies a runtime after relocation without depending on build paths', () => { +it('verifies a runtime after relocation without depending on build paths', async () => { const root = fixture() - const before = verifyDesktopRuntime(join(root, 'dsh'), '1.0.0') + const before = await verifyDesktopRuntime(join(root, 'dsh'), '1.0.0') cpSync(join(root, 'dsh'), join(root, 'moved'), { recursive: true }) - expect(desktopRuntimeId(verifyDesktopRuntime(join(root, 'moved'), '1.0.0'))).toBe(desktopRuntimeId(before)) + expect(desktopRuntimeId(await verifyDesktopRuntime(join(root, 'moved'), '1.0.0'))).toBe(desktopRuntimeId(before)) }) -it.each(['changed', 'extra', 'missing'])('rejects %s runtime bytes', (operation) => { +it.each(['changed', 'same-size', 'extra', 'missing'])('checks %s runtime bytes only during build verification', async (operation) => { const dsh = join(fixture(), 'dsh') + const before = readDesktopRuntime(dsh, '1.0.0') if (operation === 'changed') writeFileSync(join(dsh, 'package.json'), '{}') + if (operation === 'same-size') writeFileSync(join(dsh, 'package.json'), '{"type":"Module"}\n') if (operation === 'extra') writeFileSync(join(dsh, 'extra'), '') if (operation === 'missing') rmSync(join(dsh, 'package.json')) - expect(() => verifyDesktopRuntime(dsh, '1.0.0')).toThrow(/integrity/u) + expect(readDesktopRuntime(dsh, '1.0.0')).toEqual(before) + await expect(verifyDesktopRuntime(dsh, '1.0.0')).rejects.toThrow(/integrity/u) }) -it('rejects filesystem links and incompatible targets', () => { +it('rejects filesystem links and incompatible targets', async () => { const dsh = join(fixture(), 'dsh') - expect(() => verifyDesktopRuntime(dsh, '1.0.0', { platform: process.platform, arch: 'wrong' })).toThrow(/incompatible/u) + await expect(verifyDesktopRuntime(dsh, '1.0.0', { platform: process.platform, arch: 'wrong' })).rejects.toThrow(/incompatible/u) symlinkSync(join(dsh, 'node_modules'), join(dsh, 'outside'), process.platform === 'win32' ? 'junction' : 'dir') - expect(() => verifyDesktopRuntime(dsh, '1.0.0')).toThrow(/unsupported filesystem/u) + expect(readDesktopRuntime(dsh, '1.0.0').release.version).toBe('1.0.0') + await expect(verifyDesktopRuntime(dsh, '1.0.0')).rejects.toThrow(/unsupported filesystem/u) }) -it('rejects a descriptor that maps a shared package outside node_modules', () => { +it.each(['missing', 'directory'])('rejects a %s Host entry during startup metadata loading', (operation) => { + const dsh = join(fixture(), 'dsh') + const path = join(dsh, 'node_modules', DESKTOP_HOST_PACKAGE, DESKTOP_HOST_RUNTIME_FILES[0]) + rmSync(path) + if (operation === 'directory') mkdirSync(path) + expect(() => readDesktopRuntime(dsh, '1.0.0')).toThrow(/missing Host file/u) +}) +it('rejects incompatible targets and shared package metadata during startup metadata loading', () => { + const dsh = join(fixture(), 'dsh') + expect(() => readDesktopRuntime(dsh, '2.0.0')).toThrow(/does not match Electron/u) + expect(() => readDesktopRuntime(dsh, '1.0.0', { platform: process.platform, arch: 'wrong' })).toThrow(/incompatible/u) + writeFileSync(join(dsh, 'node_modules', DESKTOP_HOST_PACKAGE, 'package.json'), '{}') + expect(() => readDesktopRuntime(dsh, '1.0.0')).toThrow(/shared package metadata mismatch/u) +}) +it('rejects a descriptor that maps a shared package outside node_modules', async () => { const dsh = join(fixture(), 'dsh') const path = join(dsh, DESKTOP_RUNTIME_FILE) const descriptor = JSON.parse(readFileSync(path, 'utf8')) as { sharedPackages: { path: string }[] } descriptor.sharedPackages[0]!.path = '../outside' writeFileSync(path, JSON.stringify(descriptor)) - expect(() => verifyDesktopRuntime(dsh, '1.0.0')).toThrow(/shared package record/u) + await expect(verifyDesktopRuntime(dsh, '1.0.0')).rejects.toThrow(/shared package record/u) +}) +it('verifies recorded executable permissions only on Unix', async () => { + const dsh = join(fixture(), 'dsh') + const path = join(dsh, DESKTOP_RUNTIME_FILE) + const descriptor = JSON.parse(readFileSync(path, 'utf8')) as { files: { executable: boolean }[] } + descriptor.files[0]!.executable = !descriptor.files[0]!.executable + writeFileSync(path, JSON.stringify(descriptor)) + if (process.platform === 'win32') await expect(verifyDesktopRuntime(dsh, '1.0.0')).resolves.toMatchObject(descriptor) + else await expect(verifyDesktopRuntime(dsh, '1.0.0')).rejects.toThrow(/integrity/u) }) it.each(['../outside', '/absolute', 'C:/absolute', 'a\\b', 'a//b', './a'])('rejects nonportable path %s', (path) => { expect(() => runtimePath('/runtime', path)).toThrow(/invalid relative path/u) diff --git a/apps/desktop/tests/startup-renderer.spec.ts b/apps/desktop/tests/startup-renderer.spec.ts new file mode 100644 index 0000000000..dbbb180ca9 --- /dev/null +++ b/apps/desktop/tests/startup-renderer.spec.ts @@ -0,0 +1,134 @@ +import { readFileSync } from 'node:fs' +import { runInContext } from 'node:vm' +import { JSDOM } from 'jsdom' +import { expect, it, onTestFinished, vi } from 'vitest' +import type { DesktopBackendState } from '../src/backend-controller.ts' +import type { DshDesktopStartupApi } from '../src/ipc.ts' +import { resolveDesktopLocale } from '../src/locale.ts' + +function startup(locale = 'en', status: Promise = Promise.resolve({ phase: 'starting' })) { + const dom = new JSDOM(readFileSync(new URL('../renderer/startup.html', import.meta.url), 'utf8'), { runScripts: 'outside-only' }) + onTestFinished(() => { + dom.window.dispatchEvent(new dom.window.Event('pagehide')) + dom.window.close() + }) + const listeners = new Set<(state: DesktopBackendState) => void>() + const unsubscribe = vi.fn(() => { listeners.clear() }) + const retry = vi.fn(async () => {}) + const openPlugins = vi.fn(async () => {}) + const queried = Promise.withResolvers() + const api: DshDesktopStartupApi = { + protocolVersion: 1, + locale: async () => resolveDesktopLocale(locale), + backend: { + status: () => { queried.resolve(undefined); return status }, + retry, + subscribe: (listener) => { listeners.add(listener); return unsubscribe }, + }, + openPlugins, + } + Object.defineProperty(dom.window, 'dshDesktop', { value: api }) + runInContext(readFileSync(new URL('../renderer/startup.js', import.meta.url), 'utf8'), dom.getInternalVMContext()) + const document = dom.window.document + const element = (selector: string): HTMLElement => { + const result = document.querySelector(selector) + if (result === null) throw new Error(`Missing startup element: ${selector}`) + return result + } + const button = (selector: string): HTMLButtonElement => { + const result = document.querySelector(selector) + if (result === null) throw new Error(`Missing startup button: ${selector}`) + return result + } + const publish = (state: DesktopBackendState): void => { for (const listener of listeners) listener(state) } + const copy = (): string => [element('#title').textContent, element('#description').textContent, + ...['#error', '#actions'].filter(selector => !element(selector).hidden) + .flatMap(selector => selector === '#actions' + ? [button('#retry').textContent, button('#plugins').textContent] + : [element(selector).textContent]), + ].join('\n') + return { dom, document, element, button, publish, copy, retry, openPlugins, unsubscribe, queried: queried.promise } +} + +it('shows English loading and recovery actions without a Host document', async () => { + const page = startup() + await expect.poll(() => page.element('#title').textContent).not.toBe('') + expect(page.copy()).toMatchInlineSnapshot(` + "Starting DeepSeek Harness… + Your workspace will open when it is ready." + `) + expect(page.element('main').getAttribute('aria-busy')).toBe('true') + expect(page.element('#spinner').hidden).toBe(false) + expect(page.element('#actions').hidden).toBe(true) + expect(page.button('#retry').disabled).toBe(true) + expect(page.button('#plugins').disabled).toBe(true) + page.publish({ phase: 'error', message: 'Plugin failed to load' }) + expect(page.copy()).toMatchInlineSnapshot(` + "DeepSeek Harness could not start + The application could not start. Retry or manage Desktop plugins to resolve the problem. + Plugin failed to load + Retry startup + Manage plugins" + `) + expect(page.element('main').getAttribute('aria-busy')).toBe('false') + expect(page.element('#spinner').hidden).toBe(true) + page.button('#plugins').click() + expect(page.openPlugins).toHaveBeenCalledOnce() + page.button('#retry').click() + expect(page.retry).toHaveBeenCalledOnce() + expect(page.element('#actions').hidden).toBe(true) + expect(page.element('#error').textContent).toBe('') + expect(page.element('main').getAttribute('aria-busy')).toBe('true') +}) + +it('shows Chinese loading and recovery copy', async () => { + const page = startup('zh-CN') + await expect.poll(() => page.element('#title').textContent).not.toBe('') + expect(page.document.documentElement.lang).toBe('zh-CN') + expect(page.copy()).toMatchInlineSnapshot(` + "正在启动 DeepSeek Harness… + 准备就绪后将自动打开工作区。" + `) + page.publish({ phase: 'error', message: '插件加载失败' }) + expect(page.copy()).toMatchInlineSnapshot(` + "DeepSeek Harness 无法启动 + 应用未能启动。你可以重试,或管理桌面插件以解决问题。 + 插件加载失败 + 重试启动 + 管理插件" + `) +}) + +it('renders diagnostic markup as text and exposes failures from recovery actions', async () => { + const page = startup() + await expect.poll(() => page.element('#title').textContent).not.toBe('') + const diagnostic = '' + page.publish({ phase: 'error', message: diagnostic }) + expect(page.element('#error').textContent).toBe(diagnostic) + expect(page.element('#error').childElementCount).toBe(0) + page.openPlugins.mockRejectedValueOnce(new page.dom.window.Error('Cannot open plugin manager')) + page.button('#plugins').click() + await expect.poll(() => page.element('#error').textContent).toBe('Cannot open plugin manager') + page.retry.mockRejectedValueOnce(new page.dom.window.Error('Retry failed')) + page.button('#retry').click() + await expect.poll(() => page.element('#error').textContent).toBe('Retry failed') + expect(page.button('#retry').disabled).toBe(false) + expect(page.button('#plugins').disabled).toBe(false) +}) + +it('keeps subscribed state when initial status arrives late and detaches on pagehide', async () => { + const initial = Promise.withResolvers() + const page = startup('en', initial.promise) + await page.queried + page.publish({ phase: 'error', message: 'Fresh startup failure' }) + initial.resolve({ phase: 'starting' }) + await initial.promise + expect(page.element('#error').textContent).toBe('Fresh startup failure') + expect(page.element('#actions').hidden).toBe(false) + page.dom.window.dispatchEvent(new page.dom.window.Event('pagehide')) + expect(page.unsubscribe).toHaveBeenCalledOnce() + page.publish({ phase: 'starting' }) + expect(page.element('#error').textContent).toBe('Fresh startup failure') + page.dom.window.dispatchEvent(new page.dom.window.Event('pagehide')) + expect(page.unsubscribe).toHaveBeenCalledOnce() +}) diff --git a/apps/desktop/tsdown.config.ts b/apps/desktop/tsdown.config.ts index 6f7fa58c8d..d34cf1bf26 100644 --- a/apps/desktop/tsdown.config.ts +++ b/apps/desktop/tsdown.config.ts @@ -12,19 +12,16 @@ export default defineConfig([ clean: false, deps: { neverBundle: ['electron'] }, }, - { + ...(['preload', 'preload-app'] as const).map(name => ({ // Sandboxed Electron preloads run as CommonJS even though the application package is ESM. - entry: { - preload: 'lib/types/preload.js', - 'preload-app': 'lib/types/preload-app.js', - }, + entry: { [name]: `lib/types/${name}.js` }, outDir: 'lib', - format: ['cjs'], - platform: 'node', + format: ['cjs'] as const, + platform: 'node' as const, target: 'es2024', fixedExtension: false, dts: false, clean: false, deps: { neverBundle: ['electron'] }, - }, + })), ]) diff --git a/package.json b/package.json index 1c4c8f6f86..662e7f8935 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "package:desktop:mac:x64": "pnpm --filter @deepseek-ai/dsh-desktop run package:mac:x64", "package:desktop:mac:x64:dir": "pnpm --filter @deepseek-ai/dsh-desktop run package:mac:x64:dir", "package:desktop:win:x64": "pnpm --filter @deepseek-ai/dsh-desktop run package:win:x64", + "package:desktop:win:x64:unsigned": "pnpm --filter @deepseek-ai/dsh-desktop run package:win:x64:unsigned", "package:desktop:win:x64:dir": "pnpm --filter @deepseek-ai/dsh-desktop run package:win:x64:dir", "upload:mac:arm64": "pnpm --filter @deepseek-ai/dsh-desktop run upload:mac:arm64", "upload:mac:x64": "pnpm --filter @deepseek-ai/dsh-desktop run upload:mac:x64", From d79294f23a2fd62d37f7c997ec97e51fec9d905a Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Wed, 9 Sep 2026 17:31:28 +0800 Subject: [PATCH 058/111] 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 059/111] 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 516bfd5f30fd4d388fddb7f73da9f4bc38b923eb Mon Sep 17 00:00:00 2001 From: Yudong Date: Wed, 9 Sep 2026 19:49:56 +0800 Subject: [PATCH 060/111] test(web): cover preview import recovery and color schemes --- .../tests/fence-previews.client.spec.tsx | 12 ++++++++++++ .../tests/mermaid-runtime.client.spec.ts | 11 +++++++++++ 2 files changed, 23 insertions(+) diff --git a/packages/client/ui-primitives/tests/fence-previews.client.spec.tsx b/packages/client/ui-primitives/tests/fence-previews.client.spec.tsx index f7fe436821..cc240f75c1 100644 --- a/packages/client/ui-primitives/tests/fence-previews.client.spec.tsx +++ b/packages/client/ui-primitives/tests/fence-previews.client.spec.tsx @@ -81,6 +81,18 @@ describe('Markdown fence previews', () => { }) describe('static preview documents', () => { + it.each(['light', 'dark'])('uses the document %s color scheme for HTML previews', (scheme) => { + const previous = document.documentElement.style.colorScheme + try { + document.documentElement.style.colorScheme = scheme + const doc = renderHtml('

Example

', new AbortController().signal) + const parsed = new DOMParser().parseFromString(doc, 'text/html') + expect(parsed.documentElement.style.colorScheme).toBe(scheme) + } finally { + document.documentElement.style.colorScheme = previous + } + }) + it('preserves HTML styling while removing scripts, navigation, and nested documents', () => { const doc = renderHtml(` diff --git a/packages/client/ui-primitives/tests/mermaid-runtime.client.spec.ts b/packages/client/ui-primitives/tests/mermaid-runtime.client.spec.ts index 863abde53d..c5a4ccd6d3 100644 --- a/packages/client/ui-primitives/tests/mermaid-runtime.client.spec.ts +++ b/packages/client/ui-primitives/tests/mermaid-runtime.client.spec.ts @@ -13,6 +13,17 @@ beforeEach(() => { afterEach(() => { vi.doUnmock('mermaid') }) describe('Mermaid runtime', () => { + it('allows another attempt after the runtime import fails', async () => { + vi.doMock('mermaid', () => { throw new Error('runtime unavailable') }) + const { renderMermaid } = await import('../src/markdown/mermaid.ts') + await expect(renderMermaid('first', new AbortController().signal)).rejects.toThrow() + expect(initialize).not.toHaveBeenCalled() + vi.doMock('mermaid', () => ({ default: { initialize, render: renderDiagram } })) + renderDiagram.mockResolvedValue({ svg: '' }) + await expect(renderMermaid('retry', new AbortController().signal)).resolves.toContain('data:image/svg+xml') + expect(renderDiagram).toHaveBeenCalledOnce() + }) + it('accepts native configuration without weakening preview restrictions', async () => { const { renderMermaid } = await import('../src/markdown/mermaid.ts') renderDiagram.mockResolvedValue({ svg: '' }) From 8870a13aa941fee98455fc4e82877b0bc0ca69a7 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 9 Sep 2026 20:01:24 +0800 Subject: [PATCH 061/111] feat(sidebar): preview image files --- ...9-08-document-preview-operations.i18n.yaml | 4 +- .../2026-09-08-document-preview-operations.md | 6 +- ...26-09-08-document-preview-operations.zh.md | 6 +- ...debar-text-preview-and-file-tree.i18n.yaml | 4 +- ...9-05-sidebar-text-preview-and-file-tree.md | 4 +- ...5-sidebar-text-preview-and-file-tree.zh.md | 4 +- apps/web/tests/document-preview.e2e.ts | 57 ++++++++- .../README.i18n.yaml | 4 +- .../ui-sidebar-documentpreview/README.md | 14 ++- .../ui-sidebar-documentpreview/README.zh.md | 14 ++- .../ui-sidebar-documentpreview/package.json | 2 +- .../src/client/image/ImageBody.module.css | 40 +++++++ .../src/client/image/ImageBody.tsx | 106 +++++++++++++++++ .../src/client/image/index.ts | 41 +++++++ .../src/client/image/locales.ts | 27 +++++ .../src/client/index.ts | 2 + .../tests/apply.client.spec.ts | 3 + .../tests/image-body.client.spec.tsx | 111 ++++++++++++++++++ .../tests/image-registration.client.spec.ts | 63 ++++++++++ .../src/client/slot-catalog.ts | 1 + 20 files changed, 485 insertions(+), 28 deletions(-) create mode 100644 packages/client/ui-sidebar-documentpreview/src/client/image/ImageBody.module.css create mode 100644 packages/client/ui-sidebar-documentpreview/src/client/image/ImageBody.tsx create mode 100644 packages/client/ui-sidebar-documentpreview/src/client/image/index.ts create mode 100644 packages/client/ui-sidebar-documentpreview/src/client/image/locales.ts create mode 100644 packages/client/ui-sidebar-documentpreview/tests/image-body.client.spec.tsx create mode 100644 packages/client/ui-sidebar-documentpreview/tests/image-registration.client.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.i18n.yaml index 70fc2a365d..7f1944282d 100644 --- a/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.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-08-document-preview-operations.md -2026-09-08-document-preview-operations.md: 316f33371d393846cce5b598ee23289cecd6c178 -2026-09-08-document-preview-operations.zh.md: 97759db32d139a44fe33fd2c5e2eb7ec0c8960fd +2026-09-08-document-preview-operations.md: 3703933273e743c8df32bf0352fc276fe21dcb93 +2026-09-08-document-preview-operations.zh.md: b4896e95959d0f276ee69dfeaee9528319981714 diff --git a/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md b/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md index 316f33371d..3703933273 100644 --- a/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md +++ b/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md @@ -18,7 +18,7 @@ Readable files use `dsh-resource://file/session//`. The path ma [Document Preview](../../../../packages/client/ui-sidebar-documentpreview/README.md) owns format selection and loading policy. Metadata registers with `ctx.documentPreviews`; components register separately into the keyed `sidebar.right.tab.document` Slot. Extension registrations precede builtins, then longer suffixes and registration order decide. The toolbar lists matching alternatives and remembers a manual choice per tab; plain text is the fallback. The child receives accumulated text or complete native bytes, the original resource address, and the standard `useResource` and `useTabInfo` hooks. Preview calls existing `read`, `readAll`, and `readRelated` through ordinary injection and decodes bytes in its own `rpc.ts`. Refresh remains per tab, with no resource reload, shared `changed` acknowledgement, extra resource wrapper, or content Session. -Markdown and code reuse the incremental primitives with cumulative paged text. HTML and PDF read complete `Uint8Array` data; Host transport remains base64. Published buffers are borrowed read-only and never persist into layout or Session JSON. PDF.js runs in an owned Worker with version-matched bundled font and decoder data, and copies input before transfer to preserve Preview's retained buffer. HTML runs in a Blob iframe with `sandbox="allow-scripts"`, without same-origin, popup, form, download, or top-navigation privileges. The browser retains its normal external-network rules. Bounded static local JS/CSS reads stay in the parent; the opaque frame creates its own asset Blobs, because it cannot load parent-origin Blobs. Replacing the document replaces the browsing context and revokes its root Blob. +Markdown and code reuse the incremental primitives with cumulative paged text. HTML, PDF, and images read complete `Uint8Array` data; Host transport remains base64. Published buffers are borrowed read-only and never persist into layout or Session JSON. PDF.js runs in an owned Worker with version-matched bundled font and decoder data, and copies input before transfer to preserve Preview's retained buffer. HTML runs in a Blob iframe with `sandbox="allow-scripts"`, without same-origin, popup, form, download, or top-navigation privileges. The browser retains its normal external-network rules. Bounded static local JS/CSS reads stay in the parent; the opaque frame creates its own asset Blobs, because it cannot load parent-origin Blobs. PNG, JPEG, GIF, WebP, BMP, ICO, and SVG use image-specific Blob URLs in an `` static-image context. They retain intrinsic CSS-pixel dimensions; auto margins centre images smaller than the shared scroller, while larger dimensions extend its horizontal or vertical scroll range. The renderer provides no zoom or drag-to-pan. SVG markup never enters the application DOM or an iframe, so scripts remain inert and cannot reach the parent page. Replacing HTML or an image revokes its root Blob URL. ## Alternatives considered @@ -34,6 +34,8 @@ Markdown and code reuse the incremental primitives with cumulative paged text. H **A local server, virtual host, or `file:` iframe.** These require extra hosting or filesystem authority. The preview is for static generated pages, not a complete application runtime; modules, dynamic filesystem requests, and arbitrary nested asset graphs are outside its support. +**Sanitize SVG into the application DOM or an iframe.** A sanitizer would add a second SVG parser and an evolving active-content policy before placing untrusted markup in an interactive document. The `` static-image context preserves native SVG rendering and intrinsic dimensions without giving the markup a script-capable DOM. + ## Consequences -Renderers can be replaced without changing the tab or file protocol. Full-file formats pay bounded whole-file memory and PDF adds bundled Worker/font/decoder bytes. Format selection and view state are page-local, not durable Session data. Preview owns RPC cancellation and native buffers independently of metadata observation. A tab retains its read version and the observation version captured at read start; refreshing it neither discards another tab's content nor clears its change notice. File reads remain non-transactional, and opaque versions are compared for equality, not ordering. The [recorded browser scenario](../../../../apps/web/tests/document-preview.e2e.ts) exercises the shared toolbar, incremental text, isolated HTML dependencies, and lazy continuous PDF Worker rendering. +Renderers can be replaced without changing the tab or file protocol. Full-file formats pay bounded whole-file memory and PDF adds bundled Worker/font/decoder bytes. Format selection and view state are page-local, not durable Session data. Preview owns RPC cancellation and native buffers independently of metadata observation. A tab retains its read version and the observation version captured at read start; refreshing it neither discards another tab's content nor clears its change notice. File reads remain non-transactional, and opaque versions are compared for equality, not ordering. The [recorded browser scenario](../../../../apps/web/tests/document-preview.e2e.ts) exercises the shared toolbar, incremental text, isolated HTML dependencies, intrinsic raster and SVG rendering with two-axis scrolling, inert SVG scripts, and lazy continuous PDF Worker rendering. diff --git a/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md b/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md index 97759db32d..b4896e9595 100644 --- a/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md @@ -18,7 +18,7 @@ Document Preview 将资源观察与内容读取分开。[资源模型](2026-09-0 [Document Preview](../../../../packages/client/ui-sidebar-documentpreview/README.zh.md) 负责格式选择和加载策略。元数据通过 `ctx.documentPreviews` 注册;组件单独注册到 keyed `sidebar.right.tab.document` Slot。扩展注册优先于内置注册,其次比较后缀长度和注册顺序。工具栏列出匹配候选,按 tab 记住手动选择;纯文本是兜底。子组件收到累积文本或完整原生字节、原始资源地址,以及标准 `useResource` 和 `useTabInfo` 钩子。Preview 经普通注入调用既有 `read`、`readAll` 与 `readRelated`,在自己的 `rpc.ts` 解码字节。刷新仍按 tab 独立进行,不引入资源 reload、共享 `changed` 确认、额外资源包装层或内容 Session。 -Markdown 和代码通过累积的分页文本复用增量渲染原语。HTML 和 PDF 读取完整 `Uint8Array` 数据;Host 传输保持 base64。发布后的缓冲区只读借用,绝不持久化进布局或 Session JSON。PDF.js 在自有 Worker 中运行,字体和解码数据以相同版本随包发布,转移输入前先复制,以保留 Preview 的缓冲区。HTML 在 Blob iframe 中运行,设置 `sandbox="allow-scripts"`,不授予同源、弹窗、表单、下载或顶层导航权限。浏览器保持正常的外部网络规则。有上限的静态本地 JS/CSS 读取由父页面负责;不透明源 iframe 创建自己的资源 Blob,因为它不能加载父源创建的 Blob。替换文档会替换浏览上下文,并撤销其根 Blob。 +Markdown 和代码通过累积的分页文本复用增量渲染原语。HTML、PDF 和图片读取完整 `Uint8Array` 数据;Host 传输保持 base64。发布后的缓冲区只读借用,绝不持久化进布局或 Session JSON。PDF.js 在自有 Worker 中运行,字体和解码数据以相同版本随包发布,转移输入前先复制,以保留 Preview 的缓冲区。HTML 在 Blob iframe 中运行,设置 `sandbox="allow-scripts"`,不授予同源、弹窗、表单、下载或顶层导航权限。浏览器保持正常的外部网络规则。有上限的静态本地 JS/CSS 读取由父页面负责;不透明源 iframe 创建自己的资源 Blob,因为它不能加载父源创建的 Blob。PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 使用图片专用 Blob URL,在 `` 静态图片上下文中渲染。它们保留固有 CSS 像素尺寸;auto margin 让小于共享滚动区的图片居中,较大的尺寸则扩展横向或纵向滚动范围。渲染器不提供缩放或拖拽平移。SVG 标记绝不进入应用 DOM 或 iframe,因此脚本保持不可执行,也无法访问父页面。替换 HTML 或图片时会撤销其根 Blob URL。 ## 考虑过的替代方案 @@ -34,6 +34,8 @@ Markdown 和代码通过累积的分页文本复用增量渲染原语。HTML 和 **本地服务器、虚拟主机或 `file:` iframe。** 这些方案需要额外托管或文件系统权限。预览面向静态生成页面,而非完整应用运行时;模块、动态文件系统请求和任意嵌套资源图不在支持范围内。 +**清理 SVG 后放入应用 DOM 或 iframe。** sanitizer 会增加第二套 SVG parser 和一套持续演进的主动内容策略,之后仍要把不可信标记放进可交互文档。`` 静态图片上下文保留浏览器原生 SVG 渲染与固有尺寸,同时不给标记一个能运行脚本的 DOM。 + ## 影响 -替换渲染器不需要改变 Tab 或文件协议。全文格式承担有上限的整文件内存成本,PDF 增加随包发布的 Worker、字体和解码器字节。格式选择和查看状态仅属于当前页面,不是持久 Session 数据。Preview 独立于元数据观察,拥有 RPC 取消和原生缓冲区。tab 保留读取版本及读取开始时捕获的观察版本;刷新它既不丢弃其他 tab 的内容,也不清除其变更提示。文件读取仍非事务,不透明版本只比较相等性、不排序。[录制的浏览器场景](../../../../apps/web/tests/document-preview.e2e.ts) 覆盖共用工具栏、增量文本、隔离的 HTML 依赖,以及惰性连续 PDF Worker 渲染。 +替换渲染器不需要改变 Tab 或文件协议。全文格式承担有上限的整文件内存成本,PDF 增加随包发布的 Worker、字体和解码器字节。格式选择和查看状态仅属于当前页面,不是持久 Session 数据。Preview 独立于元数据观察,拥有 RPC 取消和原生缓冲区。tab 保留读取版本及读取开始时捕获的观察版本;刷新它既不丢弃其他 tab 的内容,也不清除其变更提示。文件读取仍非事务,不透明版本只比较相等性、不排序。[录制的浏览器场景](../../../../apps/web/tests/document-preview.e2e.ts) 覆盖共用工具栏、增量文本、隔离的 HTML 依赖、可双轴滚动的固有尺寸位图与 SVG 渲染、不可执行的 SVG 脚本,以及惰性连续 PDF Worker 渲染。 diff --git a/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.i18n.yaml b/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.i18n.yaml index 4e4b039406..5c04c6fdb7 100644 --- a/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.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-05-sidebar-text-preview-and-file-tree.md -2026-09-05-sidebar-text-preview-and-file-tree.md: 9c0b6db832f19ba758ba2d3851db72f9a1fdb785 -2026-09-05-sidebar-text-preview-and-file-tree.zh.md: a5173c20b5203942df3c6f86b2bef5961946fa08 +2026-09-05-sidebar-text-preview-and-file-tree.md: e25e1c55f5434f2b3b1b2703f886cf3c1e9fe80c +2026-09-05-sidebar-text-preview-and-file-tree.zh.md: f313d30fa68f9d8afba84a470e5e80902b13d1e3 diff --git a/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.md b/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.md index 9c0b6db832..e25e1c55f5 100644 --- a/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.md +++ b/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.md @@ -108,12 +108,12 @@ Copy is the `sidebarFiles` namespace, thirteen keys. Row states: `loading` 「 ## Testing -The text preview's `tests/` cover the registry claim and yielding (through the real `SidebarRightTabRegistry`), the address translation (`sessionFileOf` accepting the `session` scope and throwing on others), the store's page, version, reset, view, and forget actions, the face's in-flight, failure, aborted, and reload paths, the page arithmetic (`linesOf`, `offsetsOf`, `lastLineLoaded`), the body's first read, load-more, retry, change bar, navigation walk, jump-once, remount, wrap default and toggle, header controls, and forget-on-abort, the failure-line mapping, and the plugin's registrations and their removal on dispose. A Chromium probe against the built app recorded the fill and scroll numbers (`.artifacts/sidebar-tab-types/app-probe.log`, `ROUND3`): a short file's preview is the pane body's content height, a long file scrolls inside the preview body, and the pane body never scrolls. The file tree's `tests/` cover ordering, lazy loading, collapse memory, reload, the three entry types, truncation and failure rows, and forget-on-abort. `apps/web/tests/sidebar-right.e2e.ts` opens a produced file from the conversation into the preview over the real Remote carrier. +The text preview's `tests/` cover the registry claim and yielding (through the real `SidebarRightTabRegistry`), the address translation (`sessionFileOf` accepting the `session` scope and throwing on others), the store's page, version, reset, view, and forget actions, the face's in-flight, failure, aborted, and reload paths, the page arithmetic (`linesOf`, `offsetsOf`, `lastLineLoaded`), the body's first read, load-more, retry, change bar, navigation walk, jump-once, remount, wrap default and toggle, header controls, and forget-on-abort, the failure-line mapping, and the plugin's registrations and their removal on dispose. A Chromium probe against the built app recorded the fill and scroll numbers (`.artifacts/sidebar-tab-types/app-probe.log`, `ROUND3`): a short file's preview is the pane body's content height, a long file scrolls inside the preview body, and the pane body never scrolls. The file tree's `tests/` cover ordering, lazy loading, collapse memory, reload, the three entry types, truncation and failure rows, and forget-on-abort. `apps/web/tests/sidebar-right.e2e.ts` opens a produced file from the conversation into the preview over the real Remote carrier. `apps/web/tests/document-preview.e2e.ts` covers centred intrinsic-size images, two-axis image scrolling, and inert SVG scripts. ## Deferred - Virtualized or seekable page loading (pages load in order), a reload that restores the loaded range, throttled scroll persistence, and a wrap icon in `ui-primitives`. -- Images, search, a total line count, and an end-of-file marker. +- Search, a total line count, and an end-of-file marker. - Search, an artifact filter, drag-and-drop, rename, a context menu, current-file highlight, filesystem watching, and browsing above the workspace root in the file tree. - Product review of the guide's copy, and the guide's behaviour when a type contributes several entries. diff --git a/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md b/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md index a5173c20b5..f313d30fa6 100644 --- a/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md +++ b/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md @@ -108,12 +108,12 @@ face 是树唯一的异步半边。`start(tabId, root, signal)` 以根展开态 ## Testing -文本预览的 `tests/` 覆盖:注册表认领与让位(经真实的 `SidebarRightTabRegistry`)、地址翻译(`sessionFileOf` 接受 `session` 作用域、其他一律抛错)、store 的页、版本、reset、视图与 forget 各 action、face 的进行中、失败、abort 与重载路径、页算术(`linesOf`、`offsetsOf`、`lastLineLoaded`)、体的首读、加载更多、重试、变更提示条、导航补页、只跳一次、重新挂载、换行默认与切换、头部控件与 abort 即忘、失败行映射,以及插件的各项注册与 dispose 时的撤销。针对已构建应用的 Chromium 探针记录了撑满与滚动的数字(`.artifacts/sidebar-tab-types/app-probe.log`,`ROUND3`):短文件的预览高度等于 pane 体内容区高度,长文件在预览体内滚动,pane 体从不滚动。文件树的 `tests/` 覆盖排序、懒加载、折叠记忆、重新读取、三种条目类型、截断与失败行,以及 abort 即忘。`apps/web/tests/sidebar-right.e2e.ts` 经真实 Remote 载体把会话里的产物文件打开进预览。 +文本预览的 `tests/` 覆盖:注册表认领与让位(经真实的 `SidebarRightTabRegistry`)、地址翻译(`sessionFileOf` 接受 `session` 作用域、其他一律抛错)、store 的页、版本、reset、视图与 forget 各 action、face 的进行中、失败、abort 与重载路径、页算术(`linesOf`、`offsetsOf`、`lastLineLoaded`)、体的首读、加载更多、重试、变更提示条、导航补页、只跳一次、重新挂载、换行默认与切换、头部控件与 abort 即忘、失败行映射,以及插件的各项注册与 dispose 时的撤销。针对已构建应用的 Chromium 探针记录了撑满与滚动的数字(`.artifacts/sidebar-tab-types/app-probe.log`,`ROUND3`):短文件的预览高度等于 pane 体内容区高度,长文件在预览体内滚动,pane 体从不滚动。文件树的 `tests/` 覆盖排序、懒加载、折叠记忆、重新读取、三种条目类型、截断与失败行,以及 abort 即忘。`apps/web/tests/sidebar-right.e2e.ts` 经真实 Remote 载体把会话里的产物文件打开进预览。`apps/web/tests/document-preview.e2e.ts` 覆盖居中的固有尺寸图片、双轴图片滚动和不可执行的 SVG 脚本。 ## Deferred - 虚拟化或可 seek 的分页加载(页按顺序加载)、恢复已加载范围的重新载入、节流的滚动位置持久化,以及 `ui-primitives` 里的换行图标。 -- 图片、搜索、总行数与文件末尾标记。 +- 搜索、总行数与文件末尾标记。 - 文件树的搜索、产物过滤、拖拽、重命名、右键菜单、高亮当前文件、文件系统监听,以及浏览到工作区根之上。 - 引导页文案的产品评审,以及一个类型贡献多个入口时引导页的行为。 diff --git a/apps/web/tests/document-preview.e2e.ts b/apps/web/tests/document-preview.e2e.ts index 58b9913ce6..41af1afa6b 100644 --- a/apps/web/tests/document-preview.e2e.ts +++ b/apps/web/tests/document-preview.e2e.ts @@ -18,6 +18,10 @@ const PAGE_LINES = 64 const SHOT_DIR = fileURLToPath(new URL('../../../.artifacts/screenshots/0908-document-preview', import.meta.url)) const PROMPT = 'Reply with the single word LIGHTHOUSE and stop.' const MODE = webSnapshotMode() +const TINY_PNG = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', + 'base64', +) /** Successful render evidence stays outside the committed snapshot inventory. */ async function successShot(page: Page, name: string): Promise { @@ -76,7 +80,7 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', () } }) - it('opens Markdown, isolated HTML, and a rendered PDF from the Session workspace', async () => { + it('opens text, isolated HTML, intrinsic images, and rendered PDF from the Session workspace', async () => { onTestFailed(async () => { await mkdir(SHOT_DIR, { recursive: true }) await saveFailureShot(page, `screenshots/0908-document-preview/smoke-${process.pid}`) @@ -118,6 +122,13 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', () writeFile(join(cwd, 'local.js'), 'document.getElementById("local-result").textContent="LOCAL_JS_OK";'), writeFile(join(cwd, 'local.css'), '#local-result { color: rgb(12, 34, 56); }'), writeFile(outsideScript, 'document.getElementById("outside-result").textContent="OUTSIDE_JS_OK";'), + writeFile(join(cwd, 'tiny.png'), TINY_PNG), + writeFile(join(cwd, 'large.svg'), [ + '', + '', + '', + '', + ].join('')), writeFile(join(cwd, 'smoke.pdf'), pdfFixture()), ]) @@ -292,6 +303,50 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', () `- Same tab: ${String(await pdfTab.getAttribute('data-dockkit-tab') === pdfTabId)}`, ].join('\n')) + await openFile('tiny.png') + await expect.poll(() => viewer.innerText()).toBe('Image') + const tinyImage = preview.getByRole('img', { name: 'Image preview: tiny.png', exact: true }) + await tinyImage.waitFor({ state: 'visible', timeout: 15_000 }) + expect(await tinyImage.evaluate(node => ({ + width: (node as HTMLImageElement).naturalWidth, + height: (node as HTMLImageElement).naturalHeight, + draggable: (node as HTMLImageElement).draggable, + }))).toEqual({ width: 1, height: 1, draggable: false }) + const centering = await tinyImage.evaluate((node) => { + const image = node.getBoundingClientRect() + const scroller = node.closest('[data-textpreview-body]')?.getBoundingClientRect() + if (scroller === undefined) throw new Error('image document scroller is unavailable') + return { + horizontal: Math.abs((image.left + image.width / 2) - (scroller.left + scroller.width / 2)), + vertical: Math.abs((image.top + image.height / 2) - (scroller.top + scroller.height / 2)), + } + }) + expect(centering.horizontal).toBeLessThan(10) + expect(centering.vertical).toBeLessThan(10) + + await openFile('large.svg') + await expect.poll(() => viewer.innerText()).toBe('Image') + const largeImage = preview.getByRole('img', { name: 'Image preview: large.svg', exact: true }) + await largeImage.waitFor({ state: 'visible', timeout: 15_000 }) + expect(await largeImage.evaluate(node => ({ + naturalWidth: (node as HTMLImageElement).naturalWidth, + naturalHeight: (node as HTMLImageElement).naturalHeight, + width: getComputedStyle(node).width, + height: getComputedStyle(node).height, + }))).toEqual({ naturalWidth: 1200, naturalHeight: 1600, width: '1200px', height: '1600px' }) + expect(await body.evaluate(node => ({ + horizontal: node.scrollWidth > node.clientWidth, + vertical: node.scrollHeight > node.clientHeight, + }))).toEqual({ horizontal: true, vertical: true }) + const scrolled = await body.evaluate((node) => { + node.scrollLeft = node.scrollWidth + node.scrollTop = node.scrollHeight + return { left: node.scrollLeft, top: node.scrollTop } + }) + expect(scrolled.left).toBeGreaterThan(0) + expect(scrolled.top).toBeGreaterThan(0) + expect(await page.locator('html').getAttribute('data-image-preview-escape')).toBeNull() + const releaseRead = Promise.withResolvers() let waitingForRead = false const readPage = scaffold.ctx.workspaceFiles.read.bind(scaffold.ctx.workspaceFiles) diff --git a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml index 68e01296cc..91a920665d 100644 --- a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml +++ b/packages/client/ui-sidebar-documentpreview/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-sidebar-documentpreview/README.md -README.md: 9f664664a5d00add4d2f360c1c6b6af2e885bf72 -README.zh.md: 2cdad6964ae26e8e10597a405b0925cf70d7a278 +README.md: fbfbfaf4d80e425e5fbc62c082509fb6c4a93962 +README.zh.md: c27a359b608d8b72ace0700c55c34a58a302ec60 diff --git a/packages/client/ui-sidebar-documentpreview/README.md b/packages/client/ui-sidebar-documentpreview/README.md index 9f664664a5..fbfbfaf4d8 100644 --- a/packages/client/ui-sidebar-documentpreview/README.md +++ b/packages/client/ui-sidebar-documentpreview/README.md @@ -1,5 +1,5 @@ --- -description: "Document previews in the right Sidebar: shared file loading and controls, selectable Markdown, code, PDF and HTML renderers, and plain-text fallback." +description: "Document previews in the right Sidebar: shared file loading and controls, selectable Markdown, code, image, PDF and HTML renderers, and plain-text fallback." kind: "package-reference" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Preview readable files in the right Sidebar and choose among registered renderers without opening another tab. Markdown and code receive accumulated text pages; PDF and HTML receive complete bytes; unknown file extensions use plain text. The tab owns loading, file status, renderer selection, wrap, and reload, while document bodies register through the same metadata registry and child slot. The Sidebar tab kind is `text`. +Preview readable files in the right Sidebar and choose among registered renderers without opening another tab. Markdown and code receive accumulated text pages; PDF, HTML, and common images receive complete bytes; unknown file extensions use plain text. The tab owns loading, file status, renderer selection, wrap, and reload, while document bodies register through the same metadata registry and child slot. The Sidebar tab kind is `text`. ## Table of Contents @@ -44,14 +44,16 @@ The body reads its record, navigation and lifetime through `useTabInfo().tab`. ` - The resource snapshot contains only `status`, `value`, and `failure`; `value` is `WorkspaceFileStat` metadata. Content reads do not wait for the first metadata frame once the provider is available. Observation failures take precedence over Preview's change notice; neither automatically replaces loaded content. - **Text pages** — plain text, Markdown, and code read through an inject callback to `remote.workspaceFiles.read(sessionId, path, { offset }, signal)`. The first mount reads page one; scrolling to the body end or **Load more** requests the next page until `eof`. The owner delivers the accumulated prefix as `{ kind: 'text', text, pages, eof }`, including source offsets and line counts. Markdown and code render that prefix incrementally; they do not render each page as a separate document. A newer-version page past page one restarts from the beginning rather than mixing versions. A failure before any content fills the body with the file-type icon, explanation, and retry; a later failure retains loaded content and adds the retry below it. -- **Complete bytes** — PDF and HTML use an inject callback to `remote.workspaceFiles.readAll(sessionId, path, signal)`. `rpc.ts` decodes the wire base64 into `data: Uint8Array` for `{ kind: 'bytes', data }`. The Host's `maxFileBytes` cap rejects oversized files rather than truncating them. PDF copies retained bytes before worker transfer, keeping the Preview buffer usable. Bytes stay in transient view state, never persisted layouts or Session JSONL. Loading-mode changes retire previous results. +- **Complete bytes** — PDF, HTML, and common images use an inject callback to `remote.workspaceFiles.readAll(sessionId, path, signal)`. `rpc.ts` decodes the wire base64 into `data: Uint8Array` for `{ kind: 'bytes', data }`. The Host's `maxFileBytes` cap rejects oversized files rather than truncating them. PDF copies retained bytes before worker transfer, keeping the Preview buffer usable. Bytes stay in transient view state, never persisted layouts or Session JSONL. Loading-mode changes retire previous results. - **Reload** — only the current Preview tab rereads through its Remote callbacks, preserving its scroll preference and retiring older requests. Its change notice compares the read version and the observation captured at read start with later `resource.value.version`; an already observed version does not become a new change after refresh. Reads neither refresh shared metadata nor clear another tab's notice. HTML runs in a Blob iframe with exactly `sandbox="allow-scripts"`, without `allow-same-origin`; scripts cannot access the parent application's origin or file reader. The renderer loads directly declared relative `.js` classic scripts and `.css` stylesheets through its ordinary inject callback to `remote.workspaceFiles.readRelated`, with fixed safety limits of 4 MiB per asset, 32 MiB total, and 64 distinct assets. Host code resolves the related path; `rpc.ts` decodes the returned bytes. Inside the renderer, base64 is used only to embed the iframe bootstrap payload in script text. A `` leaves dependency resolution to the browser, as do HTTPS resources. Local module imports, CSS `url()`/`@import`, and dynamic `fetch` do not use Host file access. Read failures, invalid UTF-8, or exceeded limits fail the preview rather than publishing a partial asset package. Replacing or unmounting the document releases its Blob URL. +PNG, JPEG, GIF, WebP, BMP, ICO, and SVG render through Blob URLs in an `` static-image context. The image keeps its intrinsic CSS-pixel dimensions; a smaller image centres in the shared scroller, and larger dimensions scroll on either axis. The renderer provides neither zoom nor drag-to-pan. SVG markup never enters the application DOM or an iframe, so its scripts cannot execute or reach the parent page. Replacing or unmounting the image revokes its Blob URL. + Shared copy comes from `sidebarDocumentPreview`; each builtin renderer owns its localized labels. -Initial reads, additional pages, and HTML/PDF preparation share a loading indicator that respects reduced-motion preferences. Loaded pages stay visible while another page loads. PDF pages form one vertical, width-fitted sequence and render lazily near the viewport. Code previews show source line numbers by default without including them in copied text; plain text uses the same font size and line height as code. Code sits on the pane's own background rather than the chat card's fill, and with wrap off both axes scroll in the shared document scroller while the copy banner stays pinned to the visible width. +Initial reads, additional pages, and HTML/PDF/image preparation share a loading indicator that respects reduced-motion preferences. Loaded pages stay visible while another page loads. PDF pages form one vertical, width-fitted sequence and render lazily near the viewport. Code previews show source line numbers by default without including them in copied text; plain text uses the same font size and line height as code. Code sits on the pane's own background rather than the chat card's fill, and with wrap off both axes scroll in the shared document scroller while the copy banner stays pinned to the visible width. ## Navigation @@ -71,8 +73,8 @@ No direct effect; what the user reads here never enters a model request. - **Preview, not editing.** The viewers provide no file editing or shared search interface; a directory address fails with `not-regular-file`. Unknown extensions use the plain-text reader and remain subject to its UTF-8/NUL checks. -- **Sequential text and bounded complete files.** Deep source lines require the preceding pages; PDF and HTML require a complete result within the Host's `maxFileBytes` cap. -- **Byte-view scroll state is not restored.** PDF and HTML can return to the top when their renderer remounts or reloads; HTML iframe scrolling belongs to its opaque browsing context. +- **Sequential text and bounded complete files.** Deep source lines require the preceding pages; PDF, HTML, and images require a complete result within the Host's `maxFileBytes` cap. +- **Byte-view scroll state is not restored.** PDF, HTML, and images can return to the top when their renderer remounts or reloads; image horizontal position is never restored, and HTML iframe scrolling belongs to its opaque browsing context. - **Finite local HTML dependencies.** Only direct classic `.js` and stylesheet `.css` references are packed. Browser-resolved resources retain browser origin and network restrictions; no runtime file-read bridge is exposed to the iframe. - **Package-local wrap glyphs.** `IconWrapFill16` and `IconNowrapFill16` live in `src/client/icons.tsx` until the shared icon set carries them; their props already match the shared icon contract. - **Scroll writes are unthrottled.** Every scroll event records its offset in the store; the line blocks are memoized so the resulting re-render hands React the same elements back. diff --git a/packages/client/ui-sidebar-documentpreview/README.zh.md b/packages/client/ui-sidebar-documentpreview/README.zh.md index 2cdad6964a..c27a359b60 100644 --- a/packages/client/ui-sidebar-documentpreview/README.zh.md +++ b/packages/client/ui-sidebar-documentpreview/README.zh.md @@ -1,5 +1,5 @@ --- -description: "右侧 Sidebar 的文档预览:共享文件加载与控件,可选 Markdown、代码、PDF 和 HTML 渲染器,并以纯文本兜底。" +description: "右侧 Sidebar 的文档预览:共享文件加载与控件,可选 Markdown、代码、图片、PDF 和 HTML 渲染器,并以纯文本兜底。" kind: "package-reference" --- @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -在右侧 Sidebar 预览可读文件,无需另开 tab 即可切换已注册的渲染器。Markdown 和代码接收累计文本页;PDF 和 HTML 接收完整字节;未知文件扩展名使用纯文本。tab 负责加载、文件状态、渲染器选择、换行和重新载入,文档正文通过同一元数据注册表与子 slot 注册。Sidebar tab 的 kind 为 `text`。 +在右侧 Sidebar 预览可读文件,无需另开 tab 即可切换已注册的渲染器。Markdown 和代码接收累计文本页;PDF、HTML 和常见图片接收完整字节;未知文件扩展名使用纯文本。tab 负责加载、文件状态、渲染器选择、换行和重新载入,文档正文通过同一元数据注册表与子 slot 注册。Sidebar tab 的 kind 为 `text`。 ## 目录 @@ -44,14 +44,16 @@ tab 使用 `fileAddressFor` 构造的 Session 地址,携带相对或绝对路 - 资源快照仅包含 `status`、`value` 和 `failure`;`value` 是 `WorkspaceFileStat` 元数据。提供方可用后,内容读取无需等待首个元数据帧。观察失败优先于 Preview 的变更提示显示;两者都不会自动替换已加载内容。 - **文本页** —— 纯文本、Markdown 和代码通过 inject 回调调用 `remote.workspaceFiles.read(sessionId, path, { offset }, signal)`。首次挂载读取第一页;滚动到正文末尾或点击 **加载更多** 会读取下一页,直到 `eof`。owner 以 `{ kind: 'text', text, pages, eof }` 提供累计前缀,包含源码行偏移和行数。Markdown 和代码增量渲染此前缀,不把每页当成独立文档。第一页之后到达的更新版本页会使读取从头开始,避免混合版本。尚无内容时,失败会以文件类型图标、说明与重试按钮填满正文;较晚的失败保留已有内容并在其下提供重试。 -- **完整字节** —— PDF 和 HTML 通过 inject 回调调用 `remote.workspaceFiles.readAll(sessionId, path, signal)`。`rpc.ts` 将线路上的 base64 解码为 `data: Uint8Array`,供 `{ kind: 'bytes', data }` 使用。Host 的 `maxFileBytes` 上限拒绝超大文件,不截断。PDF 在传给 worker 前复制保留的字节,使 Preview 缓冲区仍可使用。字节仅保存在临时视图状态中,绝不进入持久布局或 Session JSONL。加载模式变化会淘汰先前结果。 +- **完整字节** —— PDF、HTML 和常见图片通过 inject 回调调用 `remote.workspaceFiles.readAll(sessionId, path, signal)`。`rpc.ts` 将线路上的 base64 解码为 `data: Uint8Array`,供 `{ kind: 'bytes', data }` 使用。Host 的 `maxFileBytes` 上限拒绝超大文件,不截断。PDF 在传给 worker 前复制保留的字节,使 Preview 缓冲区仍可使用。字节仅保存在临时视图状态中,绝不进入持久布局或 Session JSONL。加载模式变化会淘汰先前结果。 - **重新载入** —— 仅当前 Preview tab 通过自己的 Remote 回调重读,保留滚动偏好并淘汰旧请求。变更提示将读取版本及起读时的观察版本与后续 `resource.value.version` 比较;刷新前已观察到的版本不会被当成新变化。读取既不刷新共享元数据,也不清除其它 tab 的提示。 HTML 在 Blob iframe 中运行,沙箱属性严格为 `sandbox="allow-scripts"`,不含 `allow-same-origin`;脚本无法访问父应用的源或文件读取接口。渲染器通过普通 inject 回调调用 `remote.workspaceFiles.readRelated`,加载直接声明的相对 `.js` 经典脚本和 `.css` 样式表;固定安全上限为单个资源 4 MiB、总计 32 MiB、64 个不同资源。Host 代码解析关联路径,`rpc.ts` 解码返回的字节。在渲染器内部,base64 仅用于把 iframe 引导载荷嵌入脚本文本。`` 将依赖解析交给浏览器,HTTPS 资源也由浏览器处理。本地模块 import、CSS `url()`/`@import` 和动态 `fetch` 不使用 Host 文件访问。读取失败、无效 UTF-8 或超出上限都使预览失败,不发布部分资源包。替换或卸载文档会释放其 Blob URL。 +PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 通过 Blob URL 在 `` 静态图片上下文中渲染。图片保持固有 CSS 像素尺寸;小图在共享滚动区内居中,大图可沿任一轴滚动。渲染器既不提供缩放,也不提供拖拽平移。SVG 标记绝不进入应用 DOM 或 iframe,因此其中的脚本无法执行,也无法访问父页面。替换或卸载图片会撤销其 Blob URL。 + 共享文案来自 `sidebarDocumentPreview`;各内置渲染器拥有自己的本地化标签。 -首次读取、追加页及 HTML/PDF 准备共用加载指示器,并遵循减少动态效果偏好。下一页加载期间保留已显示的内容。PDF 页面组成一个纵向、适配宽度的连续序列,并在接近视口时惰性渲染。代码预览默认显示源码行号,但复制文本不包含行号;纯文本与代码使用相同字号和行高。代码直接坐在分栏自身的背景上,而不是会话卡片的填充色;关闭折行时两个轴都在共享文档滚动区滚动,复制条钉在可视宽度内不随横向滚动偏移。 +首次读取、追加页及 HTML/PDF/图片准备共用加载指示器,并遵循减少动态效果偏好。下一页加载期间保留已显示的内容。PDF 页面组成一个纵向、适配宽度的连续序列,并在接近视口时惰性渲染。代码预览默认显示源码行号,但复制文本不包含行号;纯文本与代码使用相同字号和行高。代码直接坐在分栏自身的背景上,而不是会话卡片的填充色;关闭折行时两个轴都在共享文档滚动区滚动,复制条钉在可视宽度内不随横向滚动偏移。 ## 导航 @@ -71,8 +73,8 @@ HTML 在 Blob iframe 中运行,沙箱属性严格为 `sandbox="allow-scripts"` - **预览而非编辑。** 查看器不提供文件编辑或共享搜索接口;目录地址以 `not-regular-file` 失败。未知扩展名使用纯文本读取,仍受其 UTF-8/NUL 检查限制。 -- **文本顺序分页,完整文件受限。** 定位深处源码行需要先加载此前各页;PDF 和 HTML 必须取得 Host `maxFileBytes` 上限内的完整结果。 -- **字节视图不恢复滚动位置。** PDF 与 HTML 的渲染器重新挂载或重新载入时可能回到顶部;HTML iframe 的滚动属于其不透明浏览上下文。 +- **文本顺序分页,完整文件受限。** 定位深处源码行需要先加载此前各页;PDF、HTML 和图片必须取得 Host `maxFileBytes` 上限内的完整结果。 +- **字节视图不恢复滚动位置。** PDF、HTML 与图片的渲染器重新挂载或重新载入时可能回到顶部;图片的横向位置始终不恢复,HTML iframe 的滚动属于其不透明浏览上下文。 - **本地 HTML 依赖集合有限。** 只打包直接引用的经典 `.js` 脚本和 `.css` 样式表。浏览器解析的资源仍受浏览器源与网络规则限制;iframe 不获得运行时文件读取桥接。 - **换行图标为包内自绘。** `IconWrapFill16` 与 `IconNowrapFill16` 住在 `src/client/icons.tsx`,直到共享图标集提供为止;它们的 props 已与共享图标契约一致。 - **滚动写入未节流。** 每次滚动事件都把偏移记进 store;行块已 memo 化,于是由此引发的重渲染交还给 React 的是同一批元素。 diff --git a/packages/client/ui-sidebar-documentpreview/package.json b/packages/client/ui-sidebar-documentpreview/package.json index a008ef19ee..8d592decc0 100644 --- a/packages/client/ui-sidebar-documentpreview/package.json +++ b/packages/client/ui-sidebar-documentpreview/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-client-ui-sidebar-documentpreview", - "description": "Extensible document previews for Sidebar files: Markdown, highlighted code, PDF, HTML, and plain text", + "description": "Extensible document previews for Sidebar files: Markdown, highlighted code, images, PDF, HTML, and plain text", "version": "0.1.5-alpha.1", "publishConfig": { "access": "public" diff --git a/packages/client/ui-sidebar-documentpreview/src/client/image/ImageBody.module.css b/packages/client/ui-sidebar-documentpreview/src/client/image/ImageBody.module.css new file mode 100644 index 0000000000..b26bb81a61 --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/src/client/image/ImageBody.module.css @@ -0,0 +1,40 @@ +.frame { + display: flex; + box-sizing: border-box; + width: max-content; + min-width: 100%; + height: max-content; + min-height: 100%; + font-family: var(--dsw-font, sans-serif); + white-space: normal; +} + +.image { + display: block; + flex: none; + width: auto; + max-width: none; + height: auto; + max-height: none; + margin: auto; + user-select: none; +} + +.image[hidden] { + display: none; +} + +.status { + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + width: 100%; + min-height: 100%; + margin: 0; + padding: 10px; + color: var(--dsw-alias-label-secondary); + font-size: 13px; + line-height: 1.5; + white-space: normal; +} diff --git a/packages/client/ui-sidebar-documentpreview/src/client/image/ImageBody.tsx b/packages/client/ui-sidebar-documentpreview/src/client/image/ImageBody.tsx new file mode 100644 index 0000000000..f01ec88d1c --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/src/client/image/ImageBody.tsx @@ -0,0 +1,106 @@ +/** Complete image bytes rendered at their intrinsic CSS-pixel dimensions. */ +import { useEffect, useMemo, useState, type ReactNode } from 'react' +import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' +import { pathPartsOf } from '@deepseek-ai/dsh-util-workspace-path' +import type { DocumentPreviewProps } from '../document/contract.ts' +import { LoadingIndicator } from '../LoadingIndicator.tsx' +import { hostFileOf } from '../rpc.ts' +import type {} from './locales.ts' +import css from './ImageBody.module.css' + +const IMAGE_MEDIA_TYPES = { + png: 'image/png', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + gif: 'image/gif', + webp: 'image/webp', + bmp: 'image/bmp', + ico: 'image/x-icon', + svg: 'image/svg+xml', +} as const + +type ImageMediaType = typeof IMAGE_MEDIA_TYPES[keyof typeof IMAGE_MEDIA_TYPES] + +/** Standard document props plus the image renderer's dictionary. */ +export type ImageBodyProps = DocumentPreviewProps & PropsLocale<'sidebarImage'> + +type ImageSource = + | { + readonly kind: 'ready' + readonly data: Uint8Array + readonly mediaType: ImageMediaType + readonly url: string + } + | { readonly kind: 'failed'; readonly data: Uint8Array; readonly mediaType: ImageMediaType } + +/** + * Resolve a supported filename to the media type assigned to its Blob. + * @param path - decoded workspace file path. + * @returns the image media type, or undefined for an unregistered suffix. + */ +export function imageMediaType(path: string): ImageMediaType | undefined { + const normalized = path.replaceAll('\\', '/') + const name = normalized.slice(normalized.lastIndexOf('/') + 1).toLowerCase() + const extension = name.slice(name.lastIndexOf('.') + 1) as keyof typeof IMAGE_MEDIA_TYPES + return IMAGE_MEDIA_TYPES[extension] +} + +/** + * Present complete image bytes without fitting or scaling them to the pane. + * @param props - document bytes, resource identity, and locale. + * @returns an intrinsic-size image whose containing document body provides scrolling. + */ +export function ImageBody({ content, resourceAddress, t }: ImageBodyProps): ReactNode { + const path = useMemo(() => hostFileOf(resourceAddress).path, [resourceAddress]) + const mediaType = imageMediaType(path) + const data = content.kind === 'bytes' ? content.data : undefined + const [source, setSource] = useState() + + useEffect(() => { + if (data === undefined || mediaType === undefined) return + let url: string | undefined + try { + url = URL.createObjectURL(new Blob([data], { type: mediaType })) + setSource({ kind: 'ready', data, mediaType, url }) + } catch { + setSource({ kind: 'failed', data, mediaType }) + } + return () => { + if (url !== undefined) URL.revokeObjectURL(url) + } + }, [data, mediaType]) + + if (data === undefined || mediaType === undefined) { + return

{t('unsupported')}

+ } + if (source?.data !== data || source.mediaType !== mediaType) { + return + } + if (source.kind === 'failed') return

{t('failed')}

+ const { name } = pathPartsOf(path) + return +} + +/** SVG stays in the browser's static image mode because its bytes only reach an img Blob URL. */ +function LoadedImage({ url, name, t }: { + readonly url: string + readonly name: string + readonly t: ImageBodyProps['t'] +}): ReactNode { + const [state, setState] = useState<'loading' | 'ready' | 'failed'>('loading') + return
+ {state === 'loading' && } + {state === 'failed' &&

{t('failed')}

} + { setState('ready') }} + onError={() => { setState('failed') }} + /> +
+} diff --git a/packages/client/ui-sidebar-documentpreview/src/client/image/index.ts b/packages/client/ui-sidebar-documentpreview/src/client/image/index.ts new file mode 100644 index 0000000000..28586d1398 --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/src/client/image/index.ts @@ -0,0 +1,41 @@ +/** Builtin image metadata and keyed document-body registration. */ +import type { Context } from '@deepseek-ai/cordis' +import type {} from '../index.ts' +import type { DocumentPreviewDefinition } from '../document/registry.ts' +import { ImageBody } from './ImageBody.tsx' +import { en, zh } from './locales.ts' + +/** Image implementation identity, shared by metadata and the keyed slot. */ +export const IMAGE_BODY_ID = '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/image' + +/** File suffixes rendered by the builtin image body. */ +export const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'ico', 'svg'] as const + +/** + * Describe the builtin image renderer independently from its keyed body slot. + * @param title - locale-owned implementation name. + * @returns metadata for complete image files. + */ +export function imageBodyDefinition(title: () => string): DocumentPreviewDefinition { + return { + id: IMAGE_BODY_ID, + extensions: IMAGE_EXTENSIONS, + priority: 'builtin', + title, + loading: 'bytes-complete', + wrap: false, + } +} + +/** + * Register the image dictionary, metadata, and body with reversible effects. + * @param ctx - owning plugin context. + */ +export function apply(ctx: Context): void { + const t = ctx.locale.bind('sidebarImage') + ctx.effect(() => ctx.locale.register('sidebarImage', { zh, en }), 'document-image: dictionaries') + ctx.effect(() => ctx.documentPreviews.register(imageBodyDefinition(() => t('title'))), 'document-image: metadata') + ctx.effect(() => ctx.slots.inject('sidebar.right.tab.document', () => ctx.slots.register( + { name: 'sidebar.right.tab.document', key: IMAGE_BODY_ID, locale: 'sidebarImage' }, ImageBody, + )), 'document-image: body') +} diff --git a/packages/client/ui-sidebar-documentpreview/src/client/image/locales.ts b/packages/client/ui-sidebar-documentpreview/src/client/image/locales.ts new file mode 100644 index 0000000000..9859985ac8 --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/src/client/image/locales.ts @@ -0,0 +1,27 @@ +/** Locale-owned image renderer labels and status text. */ +export const zh = { + title: '图片', + preview: '图片预览:{name}', + loading: '正在打开图片…', + failed: '无法显示这张图片。', + unsupported: '图片预览需要完整文件内容。', +} satisfies Record + +/** Image renderer dictionary keys. */ +export type ImagePreviewKey = keyof typeof zh + +/** English dictionary with the same keys as the Chinese dictionary. */ +export const en = { + title: 'Image', + preview: 'Image preview: {name}', + loading: 'Opening image…', + failed: 'This image could not be displayed.', + unsupported: 'Image preview requires the complete file contents.', +} satisfies Record + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** Image preview selection, accessible name, and status text. */ + sidebarImage: ImagePreviewKey + } +} diff --git a/packages/client/ui-sidebar-documentpreview/src/client/index.ts b/packages/client/ui-sidebar-documentpreview/src/client/index.ts index 1e25723c13..ef76084e07 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/index.ts +++ b/packages/client/ui-sidebar-documentpreview/src/client/index.ts @@ -33,6 +33,7 @@ import { documentTabInfoFactory } from './document/contract.ts' import { apply as registerText } from './text/index.ts' import { apply as registerMarkdown } from './markdown/index.ts' import { apply as registerHtml } from './html/index.ts' +import { apply as registerImage } from './image/index.ts' import { apply as registerPdf } from './pdf/index.ts' import { apply as registerCode } from './code/index.ts' @@ -111,6 +112,7 @@ export function apply(ctx: ClientContext): void { registerText(ctx) registerMarkdown(ctx) registerHtml(ctx) + registerImage(ctx) registerPdf(ctx) registerCode(ctx) } diff --git a/packages/client/ui-sidebar-documentpreview/tests/apply.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/apply.client.spec.ts index 649f4ba868..c6a3093b03 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/apply.client.spec.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/apply.client.spec.ts @@ -21,6 +21,8 @@ import { MarkdownBody } from '../src/client/markdown/MarkdownBody.tsx' import { MARKDOWN_BODY_ID } from '../src/client/markdown/index.ts' import { HtmlBody } from '../src/client/html/HtmlBody.tsx' import { HTML_BODY_ID } from '../src/client/html/index.ts' +import { ImageBody } from '../src/client/image/ImageBody.tsx' +import { IMAGE_BODY_ID } from '../src/client/image/index.ts' import { PdfBody } from '../src/client/pdf/PdfBody.tsx' import { PDF_BODY_ID } from '../src/client/pdf/index.ts' import { CodeBody } from '../src/client/code/CodeBody.tsx' @@ -93,6 +95,7 @@ describe('ui-sidebar-documentpreview apply', () => { ['sidebar.right.tab.document', PLAIN_BODY_ID, undefined, TextBody], ['sidebar.right.tab.document', MARKDOWN_BODY_ID, 'documentMarkdown', MarkdownBody], ['sidebar.right.tab.document', HTML_BODY_ID, 'documentHtml', HtmlBody], + ['sidebar.right.tab.document', IMAGE_BODY_ID, 'sidebarImage', ImageBody], ['sidebar.right.tab.document', PDF_BODY_ID, 'sidebarPdf', PdfBody], ['sidebar.right.tab.document', '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/code', 'sidebarCodePreview', CodeBody], ]) diff --git a/packages/client/ui-sidebar-documentpreview/tests/image-body.client.spec.tsx b/packages/client/ui-sidebar-documentpreview/tests/image-body.client.spec.tsx new file mode 100644 index 0000000000..377f93514a --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/tests/image-body.client.spec.tsx @@ -0,0 +1,111 @@ +// @vitest-environment jsdom +/** Image Blob ownership, media types, intrinsic rendering, and failure states. */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { ImageBody, imageMediaType, type ImageBodyProps } from '../src/client/image/ImageBody.tsx' +import { en } from '../src/client/image/locales.ts' + +const translations: ReadonlyMap = new Map(Object.entries(en)) +let createDescriptor: PropertyDescriptor | undefined +let revokeDescriptor: PropertyDescriptor | undefined +const create = vi.fn<(blob: Blob) => string>() +const revoke = vi.fn<(url: string) => void>() + +beforeEach(() => { + createDescriptor = Object.getOwnPropertyDescriptor(URL, 'createObjectURL') + revokeDescriptor = Object.getOwnPropertyDescriptor(URL, 'revokeObjectURL') + create.mockReset().mockImplementation(() => `blob:https://preview.invalid/${create.mock.calls.length}`) + revoke.mockReset() + Object.defineProperty(URL, 'createObjectURL', { configurable: true, value: create }) + Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: revoke }) +}) + +afterEach(() => { + try { cleanup() } finally { + if (createDescriptor === undefined) Reflect.deleteProperty(URL, 'createObjectURL') + else Object.defineProperty(URL, 'createObjectURL', createDescriptor) + if (revokeDescriptor === undefined) Reflect.deleteProperty(URL, 'revokeObjectURL') + else Object.defineProperty(URL, 'revokeObjectURL', revokeDescriptor) + } +}) + +function props(path = 'asset.png', data: Uint8Array = new Uint8Array([1, 2, 3])): ImageBodyProps { + return { + resourceAddress: `dsh-resource://file/session/image/${path}`, + content: { kind: 'bytes', data }, + wrap: false, + sessionId: 'image' as SessionId, + useTabInfo: () => ({ tab: { signal: new AbortController().signal } }), + useResource: () => ({ value: undefined }), + t: (key, params) => { + const value = translations.get(key) ?? key + return params === undefined ? value : value.replace('{name}', String(params.name)) + }, + } as ImageBodyProps +} + +describe('ImageBody', () => { + it.each([ + ['png', 'image/png'], + ['jpg', 'image/jpeg'], + ['jpeg', 'image/jpeg'], + ['gif', 'image/gif'], + ['webp', 'image/webp'], + ['bmp', 'image/bmp'], + ['ico', 'image/x-icon'], + ['svg', 'image/svg+xml'], + ] as const)('assigns .%s bytes the %s Blob media type', async (extension, mediaType) => { + const view = render() + const image = await screen.findByRole('img', { hidden: true }) + expect(create.mock.calls[0]?.[0].type).toBe(mediaType) + expect(image.getAttribute('src')).toBe('blob:https://preview.invalid/1') + view.unmount() + expect(revoke).toHaveBeenCalledExactlyOnceWith('blob:https://preview.invalid/1') + }) + + it('keeps the image unscaled and non-draggable, then shows it after decoding succeeds', async () => { + render() + const image = await screen.findByRole('img', { hidden: true }) + expect(image.getAttribute('alt')).toBe('Image preview: photo.svg') + expect(image.getAttribute('decoding')).toBe('async') + expect(image.getAttribute('draggable')).toBe('false') + expect(image.getAttribute('referrerpolicy')).toBe('no-referrer') + expect(image.hasAttribute('hidden')).toBe(true) + fireEvent.load(image) + expect(image.hasAttribute('hidden')).toBe(false) + expect(screen.queryByRole('status')).toBeNull() + }) + + it('revokes replaced bytes and reports image decode and Blob creation failures', async () => { + const initial = props() + const view = render() + const first = await screen.findByRole('img', { hidden: true }) + fireEvent.error(first) + expect(screen.getByRole('alert').textContent).toBe(en.failed) + const changed = props('asset.png', new Uint8Array([4, 5, 6])) + view.rerender() + await screen.findByRole('img', { hidden: true }) + expect(revoke).toHaveBeenCalledWith('blob:https://preview.invalid/1') + create.mockImplementationOnce(() => { throw new Error('Blob unavailable') }) + view.rerender() + expect((await screen.findByRole('alert')).textContent).toBe(en.failed) + view.unmount() + expect(revoke).toHaveBeenCalledWith('blob:https://preview.invalid/2') + }) + + it('rejects text delivery and an unregistered suffix without creating a Blob', () => { + const initial = props() + const view = render() + expect(screen.getByRole('alert').textContent).toBe(en.unsupported) + view.rerender() + expect(screen.getByRole('alert').textContent).toBe(en.unsupported) + expect(create).not.toHaveBeenCalled() + }) + + it('matches media types case-insensitively on decoded path suffixes', () => { + expect(imageMediaType('folder/PHOTO.JPEG')).toBe('image/jpeg') + expect(imageMediaType('folder/no-extension')).toBeUndefined() + expect(imageMediaType('folder/photo.avif')).toBeUndefined() + }) +}) diff --git a/packages/client/ui-sidebar-documentpreview/tests/image-registration.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/image-registration.client.spec.ts new file mode 100644 index 0000000000..eb7ac01dd1 --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/tests/image-registration.client.spec.ts @@ -0,0 +1,63 @@ +/** Image metadata, keyed slot, dictionary, and disposal registration. */ +import { Context } from '@deepseek-ai/cordis' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { DocumentPreviewRegistry } from '../src/client/document/registry.ts' +import { ImageBody } from '../src/client/image/ImageBody.tsx' +import { apply, IMAGE_BODY_ID, IMAGE_EXTENSIONS, imageBodyDefinition } from '../src/client/image/index.ts' +import { en, zh } from '../src/client/image/locales.ts' + +let dispose: (() => Promise) | undefined +afterEach(async () => { await dispose?.(); dispose = undefined }) + +describe('image registration', () => { + it('claims common image suffixes as a builtin complete-byte renderer without wrap', () => { + const title = vi.fn(() => 'localized image') + const definition = imageBodyDefinition(title) + expect(definition).toEqual({ + id: IMAGE_BODY_ID, + extensions: IMAGE_EXTENSIONS, + priority: 'builtin', + title, + loading: 'bytes-complete', + wrap: false, + }) + expect(title).not.toHaveBeenCalled() + expect(definition.title()).toBe('localized image') + }) + + it('registers its dictionary and matching keyed body, then removes every contribution', async () => { + const ctx = new Context() + const registry = new DocumentPreviewRegistry() + const dictionaries = new Map() + const bodies = new Map() + const register = vi.fn((options: { key: string }, body: unknown) => { + bodies.set(options.key, body) + return () => { bodies.delete(options.key) } + }) + ctx.provide('documentPreviews', registry) + ctx.provide('slots', { inject: (_key: string, callback: () => () => void) => callback(), register } as never) + ctx.provide('locale', { + bind: () => (key: keyof typeof en) => en[key], + register: (name: string, value: unknown) => { + dictionaries.set(name, value) + return () => { dictionaries.delete(name) } + }, + } as never) + const fiber = ctx.plugin({ apply }) + dispose = async () => { await fiber.dispose() } + await fiber.await() + for (const extension of IMAGE_EXTENSIONS) { + expect(registry.candidates(`ASSET.${extension}`).map(entry => entry.id)).toEqual([IMAGE_BODY_ID]) + } + expect(registry.getSnapshot()[0]?.title()).toBe(en.title) + expect(dictionaries.get('sidebarImage')).toEqual({ zh, en }) + expect(register).toHaveBeenCalledExactlyOnceWith( + { name: 'sidebar.right.tab.document', key: IMAGE_BODY_ID, locale: 'sidebarImage' }, + ImageBody, + ) + await dispose() + expect(registry.getSnapshot()).toEqual([]) + expect(bodies.size).toBe(0) + expect(dictionaries.size).toBe(0) + }) +}) diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index 0945d530e8..c0299970cf 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -2356,6 +2356,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [ 'client-ui-sidebar-documentpreview CodeBody', 'client-ui-sidebar-documentpreview HtmlBody', + 'client-ui-sidebar-documentpreview ImageBody', 'client-ui-sidebar-documentpreview MarkdownBody', 'client-ui-sidebar-documentpreview PdfBody', 'client-ui-sidebar-documentpreview TextBody', From 220678e57d135e21817dc31bc621e6b8111005e3 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 9 Sep 2026 21:03:44 +0800 Subject: [PATCH 062/111] 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 d09b4accd31ccd1d19b62ae9c5881d41de0270e2 Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Wed, 9 Sep 2026 21:10:02 +0800 Subject: [PATCH 063/111] =?UTF-8?q?feat:=20=E6=89=93=E5=8C=85=20+=20?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E5=A4=84=E7=90=86=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...on-desktop-packaging-and-updates.i18n.yaml | 4 +- ...-electron-desktop-packaging-and-updates.md | 2 + ...ectron-desktop-packaging-and-updates.zh.md | 2 + ...led-runtime-and-external-plugins.i18n.yaml | 4 +- ...op-bundled-runtime-and-external-plugins.md | 4 +- ...bundled-runtime-and-external-plugins.zh.md | 4 +- ...desktop-build-release-validation.i18n.yaml | 6 + ...-09-09-desktop-build-release-validation.md | 23 ++ ...-09-desktop-build-release-validation.zh.md | 23 ++ ...mmediate-window-and-direct-start.i18n.yaml | 4 +- ...sktop-immediate-window-and-direct-start.md | 4 + ...op-immediate-window-and-direct-start.zh.md | 6 +- ...6-09-09-desktop-in-place-profile.i18n.yaml | 6 + .../2026-09-09-desktop-in-place-profile.md | 25 ++ .../2026-09-09-desktop-in-place-profile.zh.md | 25 ++ apps/desktop/README.i18n.yaml | 4 +- apps/desktop/README.md | 18 +- apps/desktop/README.zh.md | 18 +- apps/desktop/renderer/startup.html | 7 +- apps/desktop/renderer/startup.js | 30 ++- apps/desktop/scripts/dev.ts | 7 +- apps/desktop/src/backend-controller.ts | 10 +- apps/desktop/src/ipc.ts | 7 + apps/desktop/src/locale.ts | 20 +- apps/desktop/src/main.ts | 153 ++++++++++-- apps/desktop/src/paths.ts | 8 +- apps/desktop/src/preload-app.ts | 4 + apps/desktop/src/profile-packages.ts | 45 ++-- apps/desktop/src/project-manager.ts | 221 +++++++----------- apps/desktop/src/runtime-tree.ts | 64 +++-- apps/desktop/src/startup-document.ts | 25 ++ apps/desktop/src/startup-error.ts | 31 +++ apps/desktop/tests/backend-controller.spec.ts | 8 +- .../expected/startup-en-configuration.txt | 8 + .../tests/expected/startup-en-plugins.txt | 8 + .../tests/expected/startup-en-reinstall.txt | 8 + .../tests/expected/startup-en-restart.txt | 8 + .../expected/startup-zh-CN-configuration.txt | 8 + .../tests/expected/startup-zh-CN-plugins.txt | 8 + .../expected/startup-zh-CN-reinstall.txt | 8 + .../tests/expected/startup-zh-CN-restart.txt | 8 + apps/desktop/tests/main-startup.spec.ts | 138 ++++++++++- apps/desktop/tests/plugin-pnpm.spec.ts | 4 +- apps/desktop/tests/preload-app.spec.ts | 5 + apps/desktop/tests/profile-packages.spec.ts | 9 +- apps/desktop/tests/project-manager.spec.ts | 188 ++++++++++----- apps/desktop/tests/runtime-tree.spec.ts | 53 ++++- apps/desktop/tests/startup-renderer.spec.ts | 84 +++++-- 48 files changed, 979 insertions(+), 388 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-09-09-desktop-build-release-validation.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-09-09-desktop-build-release-validation.md create mode 100644 .agents/notes/implemented/architecture/2026-09-09-desktop-build-release-validation.zh.md create mode 100644 .agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.md create mode 100644 .agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.zh.md create mode 100644 apps/desktop/src/startup-document.ts create mode 100644 apps/desktop/src/startup-error.ts create mode 100644 apps/desktop/tests/expected/startup-en-configuration.txt create mode 100644 apps/desktop/tests/expected/startup-en-plugins.txt create mode 100644 apps/desktop/tests/expected/startup-en-reinstall.txt create mode 100644 apps/desktop/tests/expected/startup-en-restart.txt create mode 100644 apps/desktop/tests/expected/startup-zh-CN-configuration.txt create mode 100644 apps/desktop/tests/expected/startup-zh-CN-plugins.txt create mode 100644 apps/desktop/tests/expected/startup-zh-CN-reinstall.txt create mode 100644 apps/desktop/tests/expected/startup-zh-CN-restart.txt diff --git a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml index d182ff8d4a..494f94041f 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md -2026-08-25-electron-desktop-packaging-and-updates.md: f6aa73d6e708db04c6978690b8fc2b7eb70f270b -2026-08-25-electron-desktop-packaging-and-updates.zh.md: 00c1346ab9b0bca1f704d74e7792acd03c5df9bf +2026-08-25-electron-desktop-packaging-and-updates.md: 2e266b74c2c1a5de62312ddce2c43de0908b2675 +2026-08-25-electron-desktop-packaging-and-updates.zh.md: 0a48e80b47450db7e45ab96f3a7b6e465ca271b6 diff --git a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md index f6aa73d6e7..2e266b74c2 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md +++ b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md @@ -4,6 +4,8 @@ Status: implemented English | [中文](2026-08-25-electron-desktop-packaging-and-updates.zh.md) +Profile staging, directory-swap recovery, and automatic rollback described here are superseded by the [in-place profile decision](2026-09-09-desktop-in-place-profile.md). Other decisions remain active. + ## Problem DeepSeek Harness needs an Electron desktop application that reuses the Web UI, works without system Node.js or pnpm, installs dsh and desktop plugins through an application-bundled pnpm, and updates the complete desktop release through one user-facing flow. diff --git a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md index 00c1346ab9..0a48e80b47 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md @@ -4,6 +4,8 @@ Status: implemented [English](2026-08-25-electron-desktop-packaging-and-updates.md) | 中文 +本记录中的 profile staging、目录切换恢复和自动回滚由[直接修改 profile 决策](2026-09-09-desktop-in-place-profile.zh.md)取代。其他决策继续有效。 + ## 问题 DeepSeek Harness 需要一个复用 Web UI 的 Electron 桌面应用。该应用无需系统 Node.js 或 pnpm 即可工作,通过应用内置 pnpm 安装 dsh 与桌面插件,并通过一个面向用户的流程更新完整桌面发布。 diff --git a/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.i18n.yaml index 8c5be1e7e1..5c992fd72d 100644 --- a/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.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-08-desktop-bundled-runtime-and-external-plugins.md -2026-09-08-desktop-bundled-runtime-and-external-plugins.md: a662af570c017d6fd3d38cf066c0a02e0049a84f -2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md: 6284949f6747cbe47a5de149ff598354c9b9cc58 +2026-09-08-desktop-bundled-runtime-and-external-plugins.md: 9fcee1d49d4d2595fa9dfc929b58ee3e5262ac54 +2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md: f448029cbf840e8003ce26d67788663168629da4 diff --git a/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md b/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md index a662af570c..9fcee1d49d 100644 --- a/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md +++ b/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md @@ -4,6 +4,8 @@ Status: implemented English | [中文](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md) +Profile staging, directory-swap recovery, and automatic rollback described here are superseded by the [in-place profile decision](2026-09-09-desktop-in-place-profile.md). Other decisions remain active. + ## Problem Installing the core dependency graph during Desktop initialization repeats work already done by the release builder. An offline store eliminates downloads but retains extraction, package-manager startup, and installation costs. Users need the application to start with its production packages present while retaining ordinary npm plugin installation and plugin state across application upgrades. @@ -44,7 +46,7 @@ The [immediate-window decision](2026-09-09-desktop-immediate-window-and-direct-s ## Alternatives considered -Full runtime verification belongs to packaging. Startup reads the descriptor, validates release and target compatibility, checks shared manifests and required Host entries, and uses the recorded runtime identity for profile reuse. It neither enumerates nor hashes installed runtime files, including on first launch or after an upgrade. Reading every file before backend loading adds startup I/O proportional to the distribution size. Installed content changes therefore are not detected by a startup checksum comparison; unusable modules fail when loaded. Build-time verification still rejects changed, missing, extra, or linked files against the recorded inventory. +Full runtime verification belongs to packaging. Startup reads the descriptor, checks shared package records and required Host entries, and uses the recorded runtime identity for profile reuse. The [release-validation decision](2026-09-09-desktop-build-release-validation.md) assigns release and target compatibility checks to packaging. It neither enumerates nor hashes installed runtime files, including on first launch or after an upgrade. Reading every file before backend loading adds startup I/O proportional to the distribution size. Installed content changes therefore are not detected by a startup checksum comparison; unusable modules fail when loaded. Build-time verification still rejects changed, missing, extra, or linked files against the recorded inventory. - **Install the bundled offline seed at startup.** This preserves an ordinary pnpm installation procedure but repeats core extraction and installation on every affected machine. Materialized resources remove that work at the cost of more application files and release-builder responsibility. - **Link all host dependencies into plugins.** This unnecessarily couples ordinary plugin dependencies to the host. Only the explicit shared inventory is linked; private packages retain independent versions. diff --git a/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md b/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md index 6284949f67..f448029cbf 100644 --- a/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md @@ -4,6 +4,8 @@ Status: implemented [English](2026-09-08-desktop-bundled-runtime-and-external-plugins.md) | 中文 +本记录中的 profile staging、目录切换恢复和自动回滚由[直接修改 profile 决策](2026-09-09-desktop-in-place-profile.zh.md)取代。其他决策继续有效。 + ## 问题 Desktop 初始化时安装核心依赖图,会重复发布构建器已经完成的工作。离线 store 消除了下载,但仍有解压、包管理器启动和安装成本。用户需要应用在生产依赖已就绪时启动,同时保留普通 npm 插件安装能力,以及跨应用升级的插件状态。 @@ -44,7 +46,7 @@ profile manifest 分别记录精确的已安装插件依赖和已启用 bundle ## 考虑过的替代方案 -完整运行时验证属于打包流程。启动读取描述文件,验证发布版本与目标平台的兼容性,检查共享包 manifest 和必要的 Host 入口,并使用记录的运行时身份复用 profile。首次启动和升级后启动都不枚举已安装运行时文件或计算其哈希。在后端加载前读取每个文件,会增加与分发体积成正比的启动 I/O。因此,启动不会通过校验和比较检测已安装内容的变化;不可用模块在加载时失败。构建时验证仍按记录的清单拒绝内容变化、缺失、多余或链接文件。 +完整运行时验证属于打包流程。启动读取描述文件,检查共享包记录和必要的 Host 入口,并使用记录的运行时身份复用 profile。[发布验证决策](2026-09-09-desktop-build-release-validation.zh.md)把发布与目标兼容性检查交给打包流程。首次启动和升级后启动都不枚举已安装运行时文件或计算其哈希。在后端加载前读取每个文件,会增加与分发体积成正比的启动 I/O。因此,启动不会通过校验和比较检测已安装内容的变化;不可用模块在加载时失败。构建时验证仍按记录的清单拒绝内容变化、缺失、多余或链接文件。 - **启动时安装内置离线 seed。** 这保留普通 pnpm 安装流程,但会在每台受影响机器上重复核心解压与安装。物化资源消除了这部分工作,代价是更多应用文件和发布构建器责任。 - **把所有宿主依赖链接给插件。** 这会让普通插件依赖与宿主产生不必要的耦合。只链接明确的共享清单;私有包保留独立版本。 diff --git a/.agents/notes/implemented/architecture/2026-09-09-desktop-build-release-validation.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-09-desktop-build-release-validation.i18n.yaml new file mode 100644 index 0000000000..c41d31830d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-09-desktop-build-release-validation.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-09-desktop-build-release-validation.md +2026-09-09-desktop-build-release-validation.md: 23aeeaa98d2647f8b9f54e94ea2aedaa82b19e55 +2026-09-09-desktop-build-release-validation.zh.md: 266bca5bbd593907c9a8fadd43050113ede94100 diff --git a/.agents/notes/implemented/architecture/2026-09-09-desktop-build-release-validation.md b/.agents/notes/implemented/architecture/2026-09-09-desktop-build-release-validation.md new file mode 100644 index 0000000000..23aeeaa98d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-09-desktop-build-release-validation.md @@ -0,0 +1,23 @@ +# Agent Note: Verify Desktop release compatibility during packaging + +Status: implemented + +English | [中文](2026-09-09-desktop-build-release-validation.zh.md) + +## Problem + +The shell and runtime descriptor ship together. Comparing their release facts on every launch repeats packaging checks without proving that installed executable bytes match the descriptor. + +## Decision + +The packaging verifier owns descriptor schema, shell version, platform, architecture, declared Host protocol version, and Node/pnpm semver validation. Startup reads the fields needed for profile preparation and retains shared-package record and Host-entry checks. The actual Host ready message still validates its protocol version. + +This partially supersedes startup release compatibility checks in the [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md). That note retains package ownership and distribution rationale. + +## Alternatives considered + +Repeating descriptor comparisons can diagnose a mixed installation earlier, but cannot establish executable integrity. Reintroducing them requires a concrete installation failure that packaging validation and actual Host diagnostics cannot adequately explain. + +## Consequences + +Startup does not reject a descriptor solely because its declared release schema, target, or Host protocol differs, or its Node/pnpm version strings are not semver. Packaging still rejects these cases and shell-version mismatch. Tests distinguish startup reads from packaging verification. diff --git a/.agents/notes/implemented/architecture/2026-09-09-desktop-build-release-validation.zh.md b/.agents/notes/implemented/architecture/2026-09-09-desktop-build-release-validation.zh.md new file mode 100644 index 0000000000..266bca5bbd --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-09-desktop-build-release-validation.zh.md @@ -0,0 +1,23 @@ +# Agent Note: 在打包时验证 Desktop 发布兼容性 + +Status: implemented + +[English](2026-09-09-desktop-build-release-validation.md) | 中文 + +## 问题 + +壳与运行时描述文件一起发布。每次启动比较其中的发布信息会重复打包检查,却不能证明已安装的可执行文件字节与描述文件一致。 + +## 决策 + +打包验证器负责描述文件 schema、shell 版本、平台、架构、声明的 Host 协议版本,以及 Node/pnpm semver 验证。启动读取准备 profile 所需的字段,并保留共享包记录和 Host 入口检查。实际 Host ready 消息仍然验证其协议版本。 + +本决策部分取代[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)中的启动发布兼容性检查。该记录继续负责包归属与分发的理由。 + +## 考虑过的替代方案 + +重复比较描述文件能更早诊断混装,但不能证明可执行文件完整性。重新引入这些比较需要具体的安装故障,且打包验证和实际 Host 诊断无法充分解释该故障。 + +## 后果 + +启动不会仅因描述文件声明的发布 schema、目标或 Host 协议不同,或 Node/pnpm 版本字符串不是 semver 而拒绝运行。打包仍拒绝这些情况和 shell 版本不匹配。测试区分启动读取与打包验证。 diff --git a/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.i18n.yaml index 9a562164c3..e26d213c6e 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.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-09-desktop-immediate-window-and-direct-start.md -2026-09-09-desktop-immediate-window-and-direct-start.md: 19443bb22e5fa3e50ce027b474cd439928db9992 -2026-09-09-desktop-immediate-window-and-direct-start.zh.md: 0d79638d331bb8b66a80f37f532b36ebb1e65316 +2026-09-09-desktop-immediate-window-and-direct-start.md: 7491e4cedf058bee4dc81b7734f54bc4caf6aa72 +2026-09-09-desktop-immediate-window-and-direct-start.zh.md: 37ec49e737cd5f765ab97de8c534bb08b230a45d diff --git a/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.md b/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.md index 19443bb22e..7491e4cedf 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.md +++ b/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.md @@ -4,6 +4,8 @@ Status: implemented English | [中文](2026-09-09-desktop-immediate-window-and-direct-start.zh.md) +Profile staging, directory-swap recovery, and automatic rollback described here are superseded by the [in-place profile decision](2026-09-09-desktop-in-place-profile.md). Other decisions remain active. + ## Problem Waiting for backend readiness leaves users without a window during profile preparation and module loading. A complete staged health-check process repeats backend startup before the application starts its serving process, while plugin startup can still fail in the serving process. @@ -12,6 +14,8 @@ Waiting for backend readiness leaves users without a window during profile prepa Electron creates the main window with a local loading page before profile reconciliation or Host startup. The page depends only on packaged shell assets and receives starting, ready, or error state through the owned preload. Readiness loads the product UI in that window; an actual startup failure displays its diagnostic, retry, and plugin-management actions there. Closing during loading cancels further startup work and waits for the pending child to exit. +The main window owns recovery because the failed Host cannot supply its own controls. Every error page retains diagnostics and exposes restart, disable-third-party-plugins, and reset-Desktop actions, plus reinstallation guidance. The actions remain available regardless of error classification. Reset removes all profile contents except its held lock, without a backup; shared product data and the Harness-home environment file remain intact. The lock lives inside the profile, and reset preserves its directory so another transaction cannot acquire a replacement lock during cleanup. Disabled third-party package files cannot block startup when no third-party bundles are enabled. Self-contained shell recovery controls use intercepted form navigation so a failed preload cannot disable them. + Profile activation starts the actual Host after journaled directory replacement. Desktop does not boot and stop a separate health-check backend. Dependency metadata and graph validation, reviewed lifecycle builds, runtime identity checks, transaction locking, and profile rollback remain in place. A failed actual startup can restore the previous profile; rollback cannot undo plugin side effects or durable Session writes. This partially supersedes staged backend probes and waiting to create the main window in the [packaging decision](2026-08-25-electron-desktop-packaging-and-updates.md) and [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md). Those notes retain release, signing, transport, resource ownership, and dependency-transaction rationale. Full runtime file verification remains a packaging operation. diff --git a/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.zh.md b/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.zh.md index 0d79638d33..37ec49e737 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.zh.md @@ -4,13 +4,17 @@ Status: implemented [English](2026-09-09-desktop-immediate-window-and-direct-start.md) | 中文 +本记录中的 profile staging、目录切换恢复和自动回滚由[直接修改 profile 决策](2026-09-09-desktop-in-place-profile.zh.md)取代。其他决策继续有效。 + ## 问题 等待后端就绪会让用户在准备 profile 和加载模块期间看不到窗口。完整的 staging 健康检查进程会在应用启动服务进程前重复启动后端,而插件在实际服务进程中仍然可能启动失败。 ## 决策 -Electron 在协调 profile 或启动 Host 前,先创建显示本地加载页的主窗口。该页面只依赖打包的壳资源,并通过受控 preload 接收 starting、ready 或 error 状态。就绪后在同一窗口加载产品 UI;实际启动失败时,在其中显示诊断、重试和插件管理操作。加载期间关闭应用会取消后续启动工作,并等待正在启动的子进程退出。 +Electron 在协调 profile 或启动 Host 前,先创建显示本地加载页的主窗口。该页面只依赖打包的壳资源,并通过受控 preload 接收 starting、ready 或 error 状态。就绪后在同一窗口加载产品 UI;实际启动失败时,在其中显示诊断和恢复操作。加载期间关闭应用会取消后续启动工作,并等待正在启动的子进程退出。 + +主窗口负责恢复,因为故障 Host 无法提供自己的操作界面。每个错误页保留诊断,提供重启、禁用第三方插件和重置 Desktop 三个操作,以及重装提示。所有操作均不依赖错误分类而保持可用。重置删除 profile 内除当前持有的锁之外的全部内容,不留备份;共享产品数据和 Harness home 环境文件保持不变。锁位于 profile 内,重置保留目录,防止其他事务在清理期间取得替代锁。没有启用第三方 bundle 时,已禁用插件的文件不能阻止启动。独立内嵌错误页通过拦截表单导航执行恢复,因此 preload 故障不会使按钮失效。 profile 激活在记录式目录替换后启动实际 Host。Desktop 不会另行启动并停止一个健康检查后端。依赖元数据与依赖图验证、经过审查的生命周期构建、运行时身份检查、事务锁和 profile 回滚仍然保留。实际启动失败可以恢复旧 profile;回滚不能撤销插件副作用或持久 Session 写入。 diff --git a/.agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.i18n.yaml new file mode 100644 index 0000000000..25f423496f --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.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-09-desktop-in-place-profile.md +2026-09-09-desktop-in-place-profile.md: c83bb9826a4068f699e330f911c52064790b08b6 +2026-09-09-desktop-in-place-profile.zh.md: 481dcc11d6c4dcc033f68186c88bfd96028fdbe8 diff --git a/.agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.md b/.agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.md new file mode 100644 index 0000000000..c83bb9826a --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.md @@ -0,0 +1,25 @@ +# Agent Note: Modify the Desktop profile in place + +Status: implemented + +English | [中文](2026-09-09-desktop-in-place-profile.zh.md) + +## Problem + +Staging preserves an old plugin installation but adds profile copying, directory moves, a recovery journal, and rollback state. Local plugin changes accept explicit repair after failure instead of this complexity. + +## Decision + +Desktop stops the Host and modifies the current profile directly. Shared host links are detached for package changes and restored when the operation settles. Package locking, dependency validation, and approved native builds remain. Compatible upgrades refresh links without copying plugin files. + +Package or Host failures retain partial changes for repair and retry. There is no staging profile, activation journal, directory-swap recovery, or automatic rollback. Existing scratch directories are not interpreted or deleted. + +This supersedes staging and rollback in [2026-08-25-electron-desktop-packaging-and-updates](2026-08-25-electron-desktop-packaging-and-updates.md), [2026-09-08-desktop-bundled-runtime-and-external-plugins](2026-09-08-desktop-bundled-runtime-and-external-plugins.md), [2026-09-09-desktop-immediate-window-and-direct-start](2026-09-09-desktop-immediate-window-and-direct-start.md). Other release, module-identity, and window-lifecycle decisions remain active. + +## Alternatives considered + +Staging protects the previous installation at the cost of copying and crash recovery. Versioned directories still need preparation, selection, and cleanup. Direct writes give up automatic recovery; reintroduction requires an unattended-recovery product requirement that justifies these costs. + +## Consequences + +Tests cover offline initialization, in-place upgrades, failure before writes, retained changes after Host failure, partial pnpm failure, restored host links, and exclusive package ownership. Signed application and GUI acceptance remain release-environment checks. diff --git a/.agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.zh.md b/.agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.zh.md new file mode 100644 index 0000000000..481dcc11d6 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.zh.md @@ -0,0 +1,25 @@ +# Agent Note: 直接修改 Desktop profile + +Status: implemented + +[English](2026-09-09-desktop-in-place-profile.md) | 中文 + +## 问题 + +staging 能保留旧插件安装,但增加 profile 复制、目录移动、恢复日志和回滚状态。本地插件变更接受失败后显式修复,以避免这些复杂度。 + +## 决策 + +Desktop 停止 Host 后直接修改当前 profile。修改包前解除宿主共享链接,操作结束后恢复链接。保留包锁、依赖验证和已批准的原生构建。兼容升级只刷新链接,不复制插件文件。 + +包操作或 Host 失败会保留部分修改,供修复和重试。不使用 staging profile、激活日志、目录切换恢复或自动回滚。已有临时目录不会被解释或删除。 + +本决策取代以下记录中的 staging 和回滚:[2026-08-25-electron-desktop-packaging-and-updates](2026-08-25-electron-desktop-packaging-and-updates.zh.md), [2026-09-08-desktop-bundled-runtime-and-external-plugins](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md), [2026-09-09-desktop-immediate-window-and-direct-start](2026-09-09-desktop-immediate-window-and-direct-start.zh.md)。其他发布、模块实例和窗口生命周期决策继续有效。 + +## 考虑过的替代方案 + +staging 以复制和崩溃恢复为代价保护旧安装。版本化目录仍需要准备、选择和清理。直接写入放弃自动恢复;只有无人值守恢复的产品要求能证明这些成本合理时,才重新引入。 + +## 后果 + +测试覆盖离线初始化、原地升级、写入前失败、Host 失败后保留修改、pnpm 部分失败、宿主链接恢复及独占包操作。签名应用和 GUI 验收仍由发布环境负责。 diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index c2b596749a..007cd91375 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/desktop/README.md -README.md: 86ef7d9eb63f3b7aee89a86b5c3a4866af034ac5 -README.zh.md: ea8976b880a5d5a3df5932c700237505d5f19d5a +README.md: 91cdd6d4ea0e001dd9ae81dc9f8e64aa5a912879 +README.zh.md: bd1381eb08477b1541b584eca7c5c5cb7c3f1cac diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 86ef7d9eb6..91cdd6d4ea 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -14,7 +14,7 @@ The desktop application is an Electron shell around the dsh Web UI. It opens no | Shared modules | Host APIs can depend on module identity. | Desktop links every bundled first-party package into the profile using directory symlinks, or Windows junctions; ordinary plugin dependencies remain local. | | State ownership | Sharing executable dependency graphs would let CLI and Desktop change each other's dsh, Cordis, plugin, or native-module versions, while two desktop processes could race on the same profile. | Electron acquires its process-lifetime single-instance lock before any profile access and exclusively owns `$DSH_HOME/profiles/desktop` plus its package-manager state. CLI and Desktop share supported product data under `$DSH_HOME`, but never executable packages, plugin activation, lockfiles, or `node_modules`. | | Transport | A listening Web service adds port ownership, authentication, CORS, and exposure concerns; Electron and upstream Node.js also need an explicit cross-process protocol. | The application opens no Web port. `dsh-app://` carries Web assets and Fetch traffic; framed byte pipes carry bounded request and response chunks with backpressure, while Node IPC carries only child lifecycle control. | -| Activation | Dependency resolution, lifecycle scripts, native modules, and plugin startup can fail, and a process can stop during directory replacement. | Release and plugin changes prepare in staging and start the actual backend after profile replacement; a journal and one rollback profile cover failed or interrupted activation. | +| Plugin changes | Package installation and Host startup can fail. | Desktop stops the Host and modifies the current profile directly. Failures retain partial changes for explicit repair; there is no automatic profile rollback. | | Updates | Independent shell and dsh updates would recreate version splits, while unchanged shell blocks should not require a complete transfer. | The Electron shell, matching dsh runtime, Node.js, and pnpm form one signed update unit. Platform update artifacts may reuse unchanged blocks, but runtime version selection never splits from the Desktop release. | The [Electron packaging and update Agent Note](../../.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md) owns the rationale, alternatives, security constraints, and release qualification requirements behind these decisions. @@ -29,17 +29,19 @@ Electron chooses typed English or Chinese shell copy from its application locale ### Runtime and plugin activation -The signed `resources/dsh/desktop-runtime.json` binds the shell version, bundled Node version, platform, architecture, shared package versions, and final file inventory. Startup checks release metadata, shared package manifests, and required Host entry files without enumerating or hashing the runtime tree. Full file integrity verification runs during packaging. Core packages are never copied into profile storage or installed by pnpm at first launch. +The signed `resources/dsh/desktop-runtime.json` binds the shell version, bundled Node version, platform, architecture, shared package versions, and final file inventory. Startup reads the metadata and checks shared package records. Release schema, shell version, target compatibility, and file integrity are verified during packaging. Core packages are never copied into profile storage or installed by pnpm at first launch. 1. The main window displays a local loading page before profile preparation or backend startup. A fresh profile creates its manifest and shared package links, then activates and starts the actual backend once. -2. A compatible application upgrade copies plugin files and configuration into staging, refreshes shared links, and checks enabled plugins’ peer requirements. Plugin versions and lockfile remain unchanged; pnpm does not run. +2. A compatible application upgrade refreshes shared links in the current profile and checks enabled plugins’ peer requirements. Plugin files, configuration, versions, and lockfile remain in place; pnpm does not run. 3. A changed bundled Node version, platform, or architecture reinstalls the locked plugin graph with scripts disabled, validates and links host packages, then runs approved pending builds and validates again. 4. Plugin add, update, and remove operations use bundled pnpm and Desktop-owned package-manager state. Reserved host packages must be peers; nested copies and aliases of shared packages fail validation. Ordinary plugin dependencies must resolve inside the profile. -5. Activation stops the active backend, records runtime identities and the next directory move, and replaces the profile while retaining one rollback copy. Copies do not share writable hardlinks. The actual backend starts after replacement; failed activation restores the previous profile; a profile belonging to another runtime cannot boot under the current shell. +5. Plugin changes stop the backend before modifying the current profile. Successful preparation starts the Host. Package or Host startup failures retain modified files and report the error; repair the installation and retry. Desktop creates no staging directories, activation journals, or rollback copies. -The loading page does not depend on the Host. Startup failures appear in the same main window with retry and plugin-management actions. Closing during loading waits for the pending child to exit. The Electron-owned plugin window remains available when backend startup fails. Disable one plugin or all plugins to retry with their files, exact versions, and configuration retained. Disabling removes only activation entries. Updating or removing an installed plugin remains available while it is disabled. Application rollback and profile rollback are separate; runtime compatibility is checked before any backend starts. +The loading page does not depend on the Host. Every startup and backend error page retains diagnostics and offers three actions: restart the application, disable all third-party plugins and restart the Host, or reset Desktop and restart the Host. Reinstallation guidance is always visible for missing or damaged application files. Disabling plugins retains their files and skips their installation graph when none are enabled. The plugin manager remains available through the application menu. Runtime compatibility is checked before any backend starts; plugin changes have no automatic rollback. -Package transactions hold an exclusive lock through pnpm process exit. Shared links use directory symlinks on macOS/Linux and junctions on Windows; cleanup removes links without deleting their targets. Canonical filesystem paths identify shared packages, so Windows path casing alone does not trigger profile activation. Native builds follow the profile’s reviewed `allowBuilds` list; installing a new build-requiring package without approval in that list fails the transaction. +Reset deletes every entry in `$DSH_HOME/profiles/desktop` except the held transaction lock, then initializes the built-in profile. It removes Desktop configuration and installed third-party packages without a backup. Shared tasks, settings, and the Harness-home `.env` are untouched. Shell resource and preload failures use a self-contained document with the same three actions and diagnostics; its controls do not require preload. + +Package transactions hold `$DSH_HOME/profiles/desktop/lock` exclusively through pnpm process exit. Reset preserves the directory and its lock until initialization and Host startup finish. Shared links use directory symlinks on macOS/Linux and junctions on Windows; cleanup removes links without deleting their targets. Canonical filesystem paths identify shared packages, so Windows path casing alone does not trigger profile activation. Native builds follow the profile’s reviewed `allowBuilds` list; installing a new build-requiring package without approval in that list fails the transaction. ## Develop @@ -57,7 +59,7 @@ After an explicit build, `start:desktop` reconstructs the disposable project and pnpm run start:desktop ``` -Workspace development runs the current CLI and private Desktop Host packages under the invoking Node.js and disables desktop package mutations. Its explicitly linked disposable profile is the only mode allowed to resolve bundles outside its own directory. Use an unpacked application to exercise the bundled Node.js, bundled pnpm, bundled dsh resources, plugin installation, staging, and rollback paths. +Workspace development runs the current CLI and private Desktop Host packages under the invoking Node.js and disables desktop package mutations. Its explicitly linked disposable profile is the only mode allowed to resolve bundles outside its own directory. Use an unpacked application to exercise the bundled Node.js, bundled pnpm, bundled dsh resources, plugin installation and repair paths. ## Package @@ -182,7 +184,7 @@ Signed packaging emits generic-provider channel metadata for the deployment sele ## Low-level development overrides -`DSH_DESKTOP_NODE_BINARY`, `DSH_DESKTOP_PNPM_ENTRY`, `DSH_DESKTOP_DSH_DIR`, and `DSH_DESKTOP_DEV_PROJECT_DIR` select explicit resources for an unpackaged Electron process. Packaged applications ignore these variables and resolve signed resources from `process.resourcesPath`. +An unpackaged Electron process uses `.desktop-build/development/project` under its application directory as its development project. `DSH_DESKTOP_NODE_BINARY`, `DSH_DESKTOP_PNPM_ENTRY`, and `DSH_DESKTOP_DSH_DIR` select explicit runtime resources. Packaged applications ignore these variables, resolve signed resources from `process.resourcesPath`, and use the managed Desktop profile. ## Known limitations diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index ea8976b880..bd1381eb08 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -14,7 +14,7 @@ | 共享模块 | 宿主 API 可能依赖模块实例身份。 | Desktop 用目录软链接或 Windows junction 把每个内置第一方包连接到 profile;普通插件依赖保留在本地。 | | 状态归属 | 共享可执行依赖图会让 CLI 与 Desktop 相互改变 dsh、Cordis、插件或原生模块版本,而两个桌面进程还可能争用同一个 profile。 | Electron 在访问任何 profile 前获取进程生命周期单实例锁,并独占 `$DSH_HOME/profiles/desktop` 及其包管理器状态。CLI 与 Desktop 共享 `$DSH_HOME` 下受支持的产品数据,但绝不共享可执行包、插件激活、锁文件或 `node_modules`。 | | 通信 | 监听 Web 服务会引入端口归属、认证、CORS 与暴露风险;Electron 与上游 Node.js 之间也需要明确的跨进程协议。 | 应用不打开 Web 端口。`dsh-app://` 承载 Web 资源和 Fetch 流量;分帧字节管道以背压传输有界请求与响应分块,Node IPC 只承载子进程生命周期控制。 | -| 激活 | 依赖解析、生命周期脚本、原生模块与插件启动都可能失败,目录替换期间进程也可能中断。 | 发布与插件变更先在 staging 准备,替换 profile 后启动实际后端;失败或中断的激活由事务日志和一个 rollback profile 恢复。 | +| 插件变更 | 包安装和 Host 启动可能失败。 | Desktop 停止 Host 后直接修改当前 profile。失败保留部分修改供用户修复,不自动回滚 profile。 | | 更新 | 桌面壳与 dsh 独立更新会重新产生版本分裂,而桌面壳未变化的数据块不应强制完整传输。 | Electron 壳、匹配的 dsh 运行时、Node.js 与 pnpm 组成一个已签名更新单元。平台更新产物可以复用未变化的数据块,但运行时版本选择绝不脱离 Desktop 发布。 | [Electron 打包与更新 Agent Note](../../.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md)记录了这些决策背后的理由、替代方案、安全约束和发布验证要求。 @@ -29,17 +29,19 @@ Electron 根据应用 locale 选择类型化的中英文字典,并以英文作 ### 运行时与插件激活 -签名资源中的 `resources/dsh/desktop-runtime.json` 绑定 shell 版本、内置 Node 版本、平台、架构、共享包版本和最终文件清单。启动检查发布元数据、共享包 manifest 和必要的 Host 入口文件,不枚举运行时目录树或计算其文件哈希。完整的文件完整性验证在打包时执行。首次启动不会把核心包复制到 profile 存储或通过 pnpm 安装核心包。 +签名资源中的 `resources/dsh/desktop-runtime.json` 绑定 shell 版本、内置 Node 版本、平台、架构、共享包版本和最终文件清单。启动读取元数据,并检查共享包记录。发布 schema、shell 版本、目标兼容性和文件完整性在打包时验证。首次启动不会把核心包复制到 profile 存储或通过 pnpm 安装核心包。 1. 主窗口在准备 profile 或启动后端前显示本地加载页。新 profile 创建 manifest 和共享包链接,然后激活并启动一次实际后端。 -2. 兼容的应用升级把插件文件和配置复制到 staging,刷新共享链接,并检查已启用插件的 peer 要求。插件版本和锁文件保持不变;不运行 pnpm。 +2. 兼容的应用升级在当前 profile 中刷新共享链接,并检查已启用插件的 peer 要求。插件文件、配置、版本和锁文件留在原处;不运行 pnpm。 3. 内置 Node 版本、平台或架构变化时,禁用脚本重新安装锁定的插件依赖图,验证并链接宿主包,然后运行已批准的待执行构建并再次验证。 4. 插件添加、更新和删除使用内置 pnpm 及 Desktop 独有的包管理器状态。保留的宿主包必须声明为 peer;共享包的嵌套副本和别名会被验证拒绝。普通插件依赖必须解析到 profile 内部。 -5. 激活会停止活动后端,记录运行时身份和下一次目录移动,并替换 profile,同时保留一个回滚副本。复制不共享可写硬链接。替换后启动实际后端;激活失败会恢复旧 profile;属于其他运行时的 profile 不能在当前 shell 下启动。 +5. 插件变更在修改当前 profile 前停止后端。准备成功后启动 Host。包操作或 Host 启动失败保留已修改文件并报告错误;修复安装后重试。Desktop 不创建 staging 目录、激活日志或回滚副本。 -加载页不依赖 Host。启动失败在同一个主窗口显示,并提供重试和插件管理操作。加载期间关闭应用会等待正在启动的子进程退出。后端启动失败时,Electron 拥有的插件窗口仍然可用。可以停用一个或全部插件后重试,同时保留其文件、精确版本和配置。停用只移除激活条目。已停用插件仍可更新或删除。应用回滚和 profile 回滚分别处理;每次启动后端前都会检查运行时兼容性。 +加载页不依赖 Host。每个启动和后端错误页均保留诊断,并提供三个操作:重启应用、禁用全部第三方插件并重启 Host、重置 Desktop 并重启 Host。页面始终显示应用文件缺失或损坏时的重装提示。禁用插件会保留文件;没有启用的第三方插件时,不再检查它们的安装依赖图。插件管理仍可从应用菜单打开。每次启动后端前都会检查运行时兼容性;插件变更不自动回滚。 -包事务持有独占锁直到 pnpm 进程退出。共享链接在 macOS/Linux 使用目录软链接,在 Windows 使用 junction;清理只移除链接,不删除其目标。共享包使用文件系统的规范路径识别,因此 Windows 路径大小写变化不会单独触发 profile 激活。原生构建遵循 profile 中经过审查的 `allowBuilds` 列表;新安装的包如果需要构建但未在列表中获准,事务会失败。 +重置会删除 `$DSH_HOME/profiles/desktop` 内除当前持有的事务锁之外的全部内容,再初始化内置 profile。Desktop 配置和已安装第三方插件会被删除,不保留备份。共享任务、设置和 Harness home 的 `.env` 不受影响。壳资源或 preload 故障使用独立内嵌页面,保留相同的三个操作和诊断;其按钮不依赖 preload。 + +包事务独占持有 `$DSH_HOME/profiles/desktop/lock`,直到 pnpm 进程退出。重置保留目录及其锁,直到初始化和 Host 启动完成。共享链接在 macOS/Linux 使用目录软链接,在 Windows 使用 junction;清理只移除链接,不删除其目标。共享包使用文件系统的规范路径识别,因此 Windows 路径大小写变化不会单独触发 profile 激活。原生构建遵循 profile 中经过审查的 `allowBuilds` 列表;新安装的包如果需要构建但未在列表中获准,事务会失败。 ## 开发 @@ -57,7 +59,7 @@ pnpm run dev:desktop pnpm run start:desktop ``` -Workspace 开发使用调用命令的 Node.js 运行当前 CLI 与私有 Desktop Host 包,并禁用桌面包修改;只有该模式明确链接的一次性 profile 可以从自身目录外解析 bundle。需要验证内置 Node.js、内置 pnpm、内置 dsh 资源、插件安装、staging 和 rollback 时,应运行未封装安装器的应用目录。 +Workspace 开发使用调用命令的 Node.js 运行当前 CLI 与私有 Desktop Host 包,并禁用桌面包修改;只有该模式明确链接的一次性 profile 可以从自身目录外解析 bundle。需要验证内置 Node.js、内置 pnpm、内置 dsh 资源、插件安装和修复时,应运行未封装安装器的应用目录。 ## 打包 @@ -182,7 +184,7 @@ pnpm run prepare:desktop ## 底层开发覆盖项 -`DSH_DESKTOP_NODE_BINARY`、`DSH_DESKTOP_PNPM_ENTRY`、`DSH_DESKTOP_DSH_DIR` 和 `DSH_DESKTOP_DEV_PROJECT_DIR` 可以为未打包 Electron 进程选择明确的资源。打包应用会忽略这些变量,并从 `process.resourcesPath` 解析签名资源。 +未打包的 Electron 进程使用应用目录下的 `.desktop-build/development/project` 作为开发项目。`DSH_DESKTOP_NODE_BINARY`、`DSH_DESKTOP_PNPM_ENTRY` 和 `DSH_DESKTOP_DSH_DIR` 用于选择明确的运行时资源。打包应用会忽略这些变量,从 `process.resourcesPath` 解析签名资源,并使用受管 Desktop profile。 ## 已知限制 diff --git a/apps/desktop/renderer/startup.html b/apps/desktop/renderer/startup.html index e0980a5b49..098efb4f92 100644 --- a/apps/desktop/renderer/startup.html +++ b/apps/desktop/renderer/startup.html @@ -12,10 +12,13 @@

+ + diff --git a/apps/desktop/renderer/startup.js b/apps/desktop/renderer/startup.js index ce8c64361a..036b950b0a 100644 --- a/apps/desktop/renderer/startup.js +++ b/apps/desktop/renderer/startup.js @@ -4,8 +4,11 @@ async function main() { const { id, messages } = await api.locale() document.documentElement.lang = id document.querySelector('#page-title').textContent = messages.startupLoading - document.querySelector('#retry').textContent = messages.retry - document.querySelector('#plugins').textContent = messages.managePlugins + document.querySelector('#restart').textContent = messages.restartApplication + document.querySelector('#disable-plugins').textContent = messages.disableThirdPartyPlugins + document.querySelector('#reset-configuration').textContent = messages.resetConfiguration + document.querySelector('#reset-advice').textContent = messages.startupConfigurationAdvice + document.querySelector('#reinstall-advice').textContent = messages.startupReinstallAdvice function render(state) { const failed = state.phase === 'error' document.querySelector('main').setAttribute('aria-busy', String(!failed)) @@ -15,23 +18,26 @@ async function main() { document.querySelector('#error').hidden = !failed document.querySelector('#error').textContent = failed ? state.message : '' document.querySelector('#actions').hidden = !failed - document.querySelector('#retry').disabled = !failed - document.querySelector('#plugins').disabled = !failed + for (const button of document.querySelectorAll('#actions button')) button.disabled = !failed + document.querySelector('#reset-advice').hidden = !failed + document.querySelector('#reinstall-advice').hidden = !failed } let changed = false const unsubscribe = api.backend.subscribe(state => { changed = true; render(state) }) window.addEventListener('pagehide', unsubscribe, { once: true }) const initial = await api.backend.status() if (!changed) render(initial) - document.querySelector('#retry').addEventListener('click', async () => { + async function recover(operation) { render({ phase: 'starting' }) - try { await api.backend.retry() } - catch (error) { render({ phase: 'error', message: error instanceof Error ? error.message : String(error) }) } - }) - document.querySelector('#plugins').addEventListener('click', async () => { - try { await api.openPlugins() } - catch (error) { render({ phase: 'error', message: error instanceof Error ? error.message : String(error) }) } - }) + try { await operation() } + catch (error) { + const current = await api.backend.status().catch(() => undefined) + render(current?.phase === 'error' ? current : { phase: 'error', message: error instanceof Error ? error.message : String(error) }) + } + } + document.querySelector('#disable-plugins').addEventListener('click', () => { void recover(() => api.disablePlugins()) }) + document.querySelector('#reset-configuration').addEventListener('click', () => { void recover(() => api.resetConfiguration()) }) + document.querySelector('#restart').addEventListener('click', () => { void recover(() => api.restart()) }) } void main() diff --git a/apps/desktop/scripts/dev.ts b/apps/desktop/scripts/dev.ts index 3ae65d12aa..85bea68633 100644 --- a/apps/desktop/scripts/dev.ts +++ b/apps/desktop/scripts/dev.ts @@ -53,7 +53,7 @@ async function runPackageScript(script: string, cwd: string): Promise { await run(process.execPath, [packageManager, 'run', script], cwd) } -async function launchElectron(projectDir: string): Promise { +async function launchElectron(): Promise { const require = createRequire(import.meta.url) const electron: unknown = require('electron') if (typeof electron !== 'string') throw new Error('desktop development: electron executable is unavailable') @@ -65,7 +65,6 @@ async function launchElectron(projectDir: string): Promise { const environment: NodeJS.ProcessEnv = { ...process.env, DSH_HOME: home, - DSH_DESKTOP_DEV_PROJECT_DIR: projectDir, DSH_DESKTOP_HOST_INSPECT_PORT: String(hostPort), DSH_DESKTOP_NODE_BINARY: process.execPath, DSH_DESKTOP_OPEN_DEVTOOLS: process.env.DSH_DESKTOP_OPEN_DEVTOOLS ?? '1', @@ -102,14 +101,14 @@ async function main(): Promise { nodeVersion: process.versions.node, pnpmVersion, } - const projectDir = prepareDevelopmentProject({ + prepareDevelopmentProject({ projectDir: join(DEVELOPMENT_ROOT, 'project'), cliDir: join(REPOSITORY_ROOT, 'apps', 'cli'), hostDir: join(REPOSITORY_ROOT, 'apps', 'desktop-host'), dependencyDir: join(REPOSITORY_ROOT, 'node_modules', '.pnpm', 'node_modules'), release, }) - await launchElectron(projectDir) + await launchElectron() } main().catch((error: unknown) => { diff --git a/apps/desktop/src/backend-controller.ts b/apps/desktop/src/backend-controller.ts index 03c49369ab..91850ef926 100644 --- a/apps/desktop/src/backend-controller.ts +++ b/apps/desktop/src/backend-controller.ts @@ -1,7 +1,9 @@ /** Owns one backend startup and its quiescent teardown independently of windows. */ +import { desktopErrorState, type DesktopRecovery } from './startup-error.ts' + /** Backend availability presented by the desktop window. */ -export type DesktopBackendState = { readonly phase: 'starting' } | { readonly phase: 'ready' } | { readonly phase: 'error'; readonly message: string } +export type DesktopBackendState = { readonly phase: 'starting' } | { readonly phase: 'ready' } | { readonly phase: 'error'; readonly message: string; readonly recovery?: DesktopRecovery } /** Child lifecycle owned by the desktop backend controller. */ export interface DesktopBackendHost { @@ -78,7 +80,7 @@ export class DesktopBackendController { try { await this.cleanup(attempt) } catch (cleanupError) { if (cleanupError !== error) failure = new AggregateError([error, cleanupError], 'desktop backend startup and cleanup failed') } - if (!cancelled) this.update({ phase: 'error', message: failure instanceof Error ? failure.message : String(failure) }) + if (!cancelled) this.update(desktopErrorState(failure)) throw failure } }).finally(() => { if (this.pending === pending) this.pending = undefined }) @@ -130,9 +132,9 @@ export class DesktopBackendController { if (this.current.phase !== 'ready') return attempt.cancelled = true const cleanup = this.cleanup(attempt) - this.update({ phase: 'error', message: error.message }) + this.update(desktopErrorState(error)) void cleanup.catch((cleanupError: unknown) => { - if (this.attempt === attempt) this.update({ phase: 'error', message: cleanupError instanceof Error ? cleanupError.message : String(cleanupError) }) + if (this.attempt === attempt) this.update(desktopErrorState(new AggregateError([error, cleanupError], 'Desktop backend failed and could not stop'))) }) } diff --git a/apps/desktop/src/ipc.ts b/apps/desktop/src/ipc.ts index 7ac65344ea..88735a2ad4 100644 --- a/apps/desktop/src/ipc.ts +++ b/apps/desktop/src/ipc.ts @@ -16,6 +16,9 @@ export const DESKTOP_IPC = { pluginsDisableAll: 'dsh-desktop:plugins-disable-all', backendStatus: 'dsh-desktop:backend-status', backendRetry: 'dsh-desktop:backend-retry', + applicationRestart: 'dsh-desktop:application-restart', + applicationClose: 'dsh-desktop:application-close', + configurationReset: 'dsh-desktop:configuration-reset', backendState: 'dsh-desktop:backend-state', updatesCheck: 'dsh-desktop:updates-check', updatesInstall: 'dsh-desktop:updates-install', @@ -56,4 +59,8 @@ export interface DshDesktopApi { /** Startup-page controls, unavailable to backend-provided application documents. */ export interface DshDesktopStartupApi extends Pick { openPlugins(): Promise + disablePlugins(): Promise + restart(): Promise + close(): Promise + resetConfiguration(): Promise } diff --git a/apps/desktop/src/locale.ts b/apps/desktop/src/locale.ts index c937474beb..142d263d3c 100644 --- a/apps/desktop/src/locale.ts +++ b/apps/desktop/src/locale.ts @@ -5,7 +5,15 @@ export const en = { startupFailed: 'DeepSeek Harness could not start', startupLoading: 'Starting DeepSeek Harness…', startupLoadingDescription: 'Your workspace will open when it is ready.', - startupErrorDescription: 'The application could not start. Retry or manage Desktop plugins to resolve the problem.', + startupErrorDescription: 'Choose a recovery action below. Disabling third-party plugins retains their files.', + startupRestartAdvice: 'Close and restart the application. If the problem continues, keep the error details for troubleshooting.', + startupReinstallAdvice: 'If application files are missing or damaged, close the application and reinstall it. Your tasks are stored separately.', + startupPluginsAdvice: 'A third-party plugin may be preventing startup. Disable all third-party plugins and try again. Plugin files and tasks are retained.', + startupConfigurationAdvice: 'Reset Desktop deletes all Desktop profile configuration and third-party plugins without a backup, then starts a fresh profile. Shared tasks and settings are retained.', + restartApplication: 'Close and restart', + closeApplication: 'Close application', + resetConfiguration: 'Reset Desktop and retry', + disableThirdPartyPlugins: 'Disable all third-party plugins and retry', managePlugins: 'Manage plugins', pluginsMenu: 'Desktop Plugins…', pluginsMenuPackagedOnly: 'Desktop Plugins… (available in packaged applications)', @@ -55,7 +63,15 @@ export const zh = { startupFailed: 'DeepSeek Harness 无法启动', startupLoading: '正在启动 DeepSeek Harness…', startupLoadingDescription: '准备就绪后将自动打开工作区。', - startupErrorDescription: '应用未能启动。你可以重试,或管理桌面插件以解决问题。', + startupErrorDescription: '请选择下方的恢复操作。禁用第三方插件会保留插件文件。', + startupRestartAdvice: '请关闭并重启应用。如果问题持续出现,请保留错误详情以便排查。', + startupReinstallAdvice: '如果应用文件缺失或损坏,请关闭应用并重新安装。任务数据存储在独立位置。', + startupPluginsAdvice: '第三方插件可能导致应用无法启动。请禁用全部第三方插件后重试。插件文件和任务数据会保留。', + startupConfigurationAdvice: '重置 Desktop 会删除桌面端的全部 profile 配置和第三方插件,不保留备份,然后重新初始化并启动。共享任务和设置会保留。', + restartApplication: '关闭并重启', + closeApplication: '关闭应用', + resetConfiguration: '重置 Desktop 并重试', + disableThirdPartyPlugins: '禁用全部第三方插件并重试', managePlugins: '管理插件', pluginsMenu: '桌面插件…', pluginsMenuPackagedOnly: '桌面插件…(打包应用中可用)', diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index f132afb3b2..012f444e03 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -20,9 +20,26 @@ import { DESKTOP_IPC, type DesktopUpdateState } from './ipc.ts' import { formatDesktopMessage, resolveDesktopLocale } from './locale.ts' import { claimDesktopSingleInstance } from './single-instance.ts' import { DesktopUpdateCoordinator } from './update-coordinator.ts' +import { desktopErrorState, DesktopStartupError } from './startup-error.ts' +import { startupFailureDocument } from './startup-document.ts' const SCHEME = 'dsh-app' let focusPrimaryWindow = (): void => {} +type RecoveryAction = 'restart' | 'plugins' | 'reset' +const emergencyPages = new WeakMap() +let recoverApplication = (action: RecoveryAction): Promise => { + if (action !== 'restart') return Promise.reject(new Error('Desktop recovery could not initialize; reinstall the application')) + app.relaunch() + app.quit() + return Promise.resolve() +} + +async function showEmergencyDocument(window: BrowserWindow, message: string): Promise { + const document = startupFailureDocument(resolveDesktopLocale(app.getLocale()), message) + const url = `data:text/html;charset=utf-8,${encodeURIComponent(document)}` + emergencyPages.set(window, { url, message, busy: false }) + await window.loadURL(url) +} function errorOf(reason: unknown, fallback: string): Error { return reason instanceof Error ? reason : new Error(fallback) @@ -63,13 +80,6 @@ function runtimeResources(): RuntimeResources { return { node, pnpm, dsh } } -function developmentProject(): string | undefined { - const configured = process.env.DSH_DESKTOP_DEV_PROJECT_DIR - if (configured === undefined || configured === '') return undefined - if (app.isPackaged) throw new Error('dsh desktop: development project override is unavailable in packaged applications') - return resolve(configured) -} - function developmentHostInspectPort(enabled: boolean): number | undefined { const configured = process.env.DSH_DESKTOP_HOST_INSPECT_PORT if (!enabled || configured === undefined || configured === '') return undefined @@ -98,6 +108,14 @@ function createWindow(preload: string, show = false): BrowserWindow { window.webContents.setWindowOpenHandler(() => ({ action: 'deny' })) window.webContents.on('will-navigate', (event, url) => { if (new URL(url).protocol !== `${SCHEME}:`) event.preventDefault() + const page = emergencyPages.get(window) + if (page === undefined || page.busy || window.webContents.getURL() !== page.url) return + const action = new URL(url) + if (action.protocol !== 'dsh-recovery:' || !['restart', 'plugins', 'reset'].includes(action.hostname)) return + page.busy = true + void recoverApplication(action.hostname as RecoveryAction).catch(async (error: unknown) => { + if (!window.isDestroyed()) await showEmergencyDocument(window, `${page.message}\n${desktopErrorState(error).message}`) + }).catch((error: unknown) => { console.error(error) }).finally(() => { page.busy = false }) }) return window } @@ -134,11 +152,10 @@ async function serveShellAsset(request: Request): Promise { async function main(): Promise { const resources = runtimeResources() const paths = resolveDesktopPaths() - const development = developmentProject() + const development = app.isPackaged ? undefined : join(app.getAppPath(), '.desktop-build', 'development', 'project') const activeProject = development ?? paths.profile - const hostInspectPort = developmentHostInspectPort(development !== undefined) const manager = new DesktopProjectManager(paths, resources) - let pageError: string | undefined + let pageError: Extract | undefined let quitting = false let startup: Promise | undefined let mainWindow: BrowserWindow | undefined @@ -152,10 +169,19 @@ async function main(): Promise { const startupUrl = `${SCHEME}://shell/startup.html` const applicationUrl = `${SCHEME}://app/index.html` let navigation: { window: BrowserWindow; url: string; promise: Promise } | undefined + let emergencyDocument = false + + const showEmergencyError = async (error: unknown): Promise => { + if (quitting || emergencyDocument) return + emergencyDocument = true + const diagnostic = desktopErrorState(error).message + pageError = { phase: 'error', message: diagnostic, recovery: 'reinstall' } + if (mainWindow !== undefined) await showEmergencyDocument(mainWindow, diagnostic) + } const navigateMain = (url: string): Promise => { const window = mainWindow - if (quitting || window === undefined || window.isDestroyed()) return Promise.resolve() + if (quitting || emergencyDocument || window === undefined || window.isDestroyed()) return Promise.resolve() if (navigation?.window === window && navigation.url === url) return navigation.promise const next = { window, url, promise: Promise.resolve() } next.promise = window.loadURL(url).catch((error: unknown) => { @@ -167,7 +193,7 @@ async function main(): Promise { return next.promise } const backendState = (): DesktopBackendState => pageError === undefined - ? backend.state : { phase: 'error', message: pageError } + ? backend.state : pageError const publishBackend = (state: DesktopBackendState): void => { for (const window of BrowserWindow.getAllWindows()) { window.webContents.send(DESKTOP_IPC.backendState, state) @@ -175,9 +201,32 @@ async function main(): Promise { } const backend = new DesktopBackendController((onFailure) => { if (development === undefined) manager.assertProfileRuntime(activeProject) - return new DesktopHostProcess(resources.node, development ?? resources.dsh, activeProject, hostInspectPort, process.env, onFailure) + const hostInspectPort = developmentHostInspectPort(development !== undefined) + const pluginsEnabled = development === undefined && manager.hasEnabledPlugins() + const classify = (error: Error): Error => { + if (error instanceof DesktopStartupError) return error + if ((error.message.includes(resources.node) || error.message.includes(resources.dsh)) + && /ENOENT|Cannot find|failed to (?:read|parse) overlay/u.test(error.message)) { + return new DesktopStartupError('reinstall', error) + } + if (error.message.includes('only the launching environment may set') + || /failed to (?:read|parse) (?:overlay|patches)|must be a top-level YAML array|patchReload must be/u.test(error.message)) { + return new DesktopStartupError('configuration', error) + } + return pluginsEnabled ? new DesktopStartupError('plugins', error) : error + } + const host = new DesktopHostProcess(resources.node, development ?? resources.dsh, activeProject, hostInspectPort, process.env, + (error) => { onFailure(classify(error)) }) + return { + async start() { + try { return await host.start() } + catch (error) { throw classify(errorOf(error, messages.startupFailed)) } + }, + stop: () => host.stop(), + fetch: (request: Request) => host.fetch(request), + } }, (state) => { - if (state.phase === 'starting') pageError = undefined + if (state.phase === 'starting' && !emergencyDocument) pageError = undefined publishBackend(backendState()) if (state.phase === 'error') void navigateMain(startupUrl).catch((error: unknown) => { console.error(error) }) }) @@ -191,14 +240,34 @@ async function main(): Promise { } const hooks: DesktopProjectHooks = { - beforeActivate: () => backend.stop(), - afterActivate: () => backend.start(async () => {}), + beforeChange: () => backend.stop(), + afterChange: () => backend.start(async () => {}), + } + + recoverApplication = async (action): Promise => { + await startup?.catch(() => undefined) + await backend.stop() + if (action === 'restart') { + app.relaunch() + app.quit() + return + } + if (development !== undefined) throw new Error('Desktop profile recovery requires a packaged application') + if (action === 'reset') await manager.resetConfiguration(hooks) + else await manager.mutate({ type: 'plugins-disable-all' }, hooks) + emergencyDocument = false + pageError = undefined + navigation = undefined + await navigateMain(applicationUrl) } const showStartupError = async (error: unknown): Promise => { if (quitting) return - pageError = errorOf(error, messages.startupFailed).message - await navigateMain(startupUrl) + pageError = desktopErrorState(error) + try { await navigateMain(startupUrl) } + catch (navigationError) { + await showEmergencyError(new AggregateError([error, navigationError], messages.startupFailed)) + } publishBackend(backendState()) } const reconcileBackend = (): Promise => { @@ -207,7 +276,7 @@ async function main(): Promise { await navigateMain(startupUrl) await backend.start(async () => { if (development === undefined) { - await manager.applyRelease(app.getVersion(), { beforeActivate: async () => {}, afterActivate: async () => {} }) + await manager.applyRelease() } }) if (backend.host !== undefined) await navigateMain(applicationUrl) @@ -228,7 +297,13 @@ async function main(): Promise { protocol.handle(SCHEME, (request) => { const url = new URL(request.url) - if (url.hostname === 'shell') return serveShellAsset(request) + if (url.hostname === 'shell') return serveShellAsset(request).then((response) => { + if (response.status >= 400 && ['/startup.html', '/startup.js', '/startup.css'].includes(url.pathname)) { + void showEmergencyError(new Error(`Desktop recovery resource could not be loaded: ${url.pathname} (HTTP ${response.status})`)) + .catch((error: unknown) => { console.error(error) }) + } + return response + }) if (url.hostname !== 'app') return Promise.resolve(new Response(null, { status: 404 })) const active = backend.host if (active === undefined) return Promise.resolve(new Response('backend unavailable', { status: 503 })) @@ -288,6 +363,32 @@ async function main(): Promise { await reconcileBackend() focusPrimaryWindow() }) + ipcMain.handle(DESKTOP_IPC.applicationClose, (event) => { + assertDesktopSender(event, ['shell']) + app.quit() + }) + ipcMain.handle(DESKTOP_IPC.applicationRestart, async (event) => { + assertDesktopSender(event, ['shell']) + try { + await recoverApplication('restart') + } catch (error) { + await showStartupError(error) + } + }) + ipcMain.handle(DESKTOP_IPC.configurationReset, async (event) => { + assertDesktopSender(event, ['shell']) + if (development !== undefined) throw new Error('Desktop configuration reset requires a packaged application') + const failure = backendState() + if (failure.phase !== 'error') { + throw new Error('Desktop profile reset requires a startup failure') + } + await startup?.catch(() => undefined) + try { + await recoverApplication('reset') + } catch (error) { + await showStartupError(error) + } + }) ipcMain.handle(DESKTOP_IPC.updatesCheck, async (event) => { assertDesktopSender(event, ['shell']) return updates.check() @@ -375,6 +476,13 @@ async function main(): Promise { const window = createWindow(appPreload, true) mainWindow = window window.on('closed', () => { if (mainWindow === window) mainWindow = undefined }) + window.webContents.on('preload-error', (_event, _path, error) => { + void showEmergencyError(error).catch((failure: unknown) => { console.error(failure) }) + }) + window.webContents.on('render-process-gone', (_event, details) => { + void showStartupError(new Error(`Desktop renderer exited: ${details.reason}`)) + .catch((failure: unknown) => { console.error(failure) }) + }) return window } focusPrimaryWindow = () => { @@ -425,6 +533,7 @@ if (ownsDesktopInstance) void app.whenReady().then(main).catch(async (error: unk if (diagnosticFile !== undefined) { await writeFile(diagnosticFile, `${error instanceof Error ? error.stack ?? message : message}\n`).catch(() => undefined) } - dialog.showErrorBox(resolveDesktopLocale(app.getLocale()).messages.startupFailed, message) - app.exit(1) + const window = BrowserWindow.getAllWindows()[0] ?? createWindow(fileURLToPath(new URL('./preload-app.cjs', import.meta.url)), true) + window.once('closed', () => { app.quit() }) + await showEmergencyDocument(window, message) }) diff --git a/apps/desktop/src/paths.ts b/apps/desktop/src/paths.ts index 4f883929db..5727e5c013 100644 --- a/apps/desktop/src/paths.ts +++ b/apps/desktop/src/paths.ts @@ -7,9 +7,6 @@ import { resolveDshHome } from '@deepseek-ai/dsh-home-paths' export interface DesktopPaths { readonly root: string readonly profile: string - readonly staging: string - readonly rollback: string - readonly pending: string readonly lock: string readonly pnpm: { readonly root: string @@ -32,10 +29,7 @@ export function resolveDesktopPaths(dshHome: string = resolveDshHome()): Desktop return { root, profile: join(dshHome, 'profiles', 'desktop'), - staging: join(root, 'staging'), - rollback: join(root, 'rollback', 'profile'), - pending: join(root, 'pending.json'), - lock: join(root, 'lock'), + lock: join(dshHome, 'profiles', 'desktop', 'lock'), pnpm: { root: pnpm, store: join(pnpm, 'store'), diff --git a/apps/desktop/src/preload-app.ts b/apps/desktop/src/preload-app.ts index 140a7369a4..65c55ccf11 100644 --- a/apps/desktop/src/preload-app.ts +++ b/apps/desktop/src/preload-app.ts @@ -17,6 +17,10 @@ const startup: DshDesktopStartupApi = { }, }, openPlugins: () => ipcRenderer.invoke(DESKTOP_IPC.pluginsOpen) as Promise, + disablePlugins: () => ipcRenderer.invoke(DESKTOP_IPC.pluginsDisableAll) as Promise, + restart: () => ipcRenderer.invoke(DESKTOP_IPC.applicationRestart) as Promise, + close: () => ipcRenderer.invoke(DESKTOP_IPC.applicationClose) as Promise, + resetConfiguration: () => ipcRenderer.invoke(DESKTOP_IPC.configurationReset) as Promise, } contextBridge.exposeInMainWorld('dshDesktop', location.protocol === 'dsh-app:' && location.hostname === 'shell' diff --git a/apps/desktop/src/profile-packages.ts b/apps/desktop/src/profile-packages.ts index 0ec5ca573e..18aa4ffaf6 100644 --- a/apps/desktop/src/profile-packages.ts +++ b/apps/desktop/src/profile-packages.ts @@ -1,10 +1,11 @@ /** Desktop-owned host links and validation of the external plugin dependency graph. */ import { createHash } from 'node:crypto' -import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, realpathSync, readdirSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs' +import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, realpathSync, readdirSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs' import { createRequire } from 'node:module' import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' import { satisfies } from 'semver' +import { DesktopStartupError } from './startup-error.ts' import { desktopRuntimeId, runtimePath, type DesktopRuntimeDescriptor } from './runtime-tree.ts' /** Applied runtime identity and the only links Desktop may replace. */ @@ -52,6 +53,11 @@ function inside(root: string, path: string): boolean { * @returns Validated state, or undefined for an uninitialized profile. */ export function readDesktopProfileState(profile: string): DesktopProfileState | undefined { + try { return readProfileState(profile) } + catch (error) { throw new DesktopStartupError('configuration', error) } +} + +function readProfileState(profile: string): DesktopProfileState | undefined { const path = join(profile, DESKTOP_PROFILE_STATE) if (!existsSync(path)) return undefined const value: unknown = JSON.parse(readFileSync(path, 'utf8')) @@ -85,7 +91,7 @@ export function desktopPluginLockHash(profile: string): string { /** * Remove only recorded host links, without following even broken targets. - * @param profile - Active or copied profile. + * @param profile - Desktop profile. */ export function unlinkDesktopHostPackages(profile: string): void { for (const link of readDesktopProfileState(profile)?.links ?? []) { @@ -99,25 +105,6 @@ export function unlinkDesktopHostPackages(profile: string): void { } } -/** - * Copy plugin files without copying host packages or sharing writable hardlinks. - * @param source - Active Desktop profile. - * @param target - Empty transaction profile directory. - */ -export function copyDesktopProfile(source: string, target: string): void { - const owned = readDesktopProfileState(source)?.links ?? [] - for (const link of owned) { - const path = join(source, 'node_modules', link.name) - const entry = stat(path) - if (entry !== undefined && (!entry.isSymbolicLink() || resolve(dirname(path), readlinkSync(path)) !== resolve(link.target))) { - throw new Error(`desktop profile: refusing to copy unowned package ${link.name}`) - } - } - const links = new Set(owned.map(link => join(source, 'node_modules', link.name))) - cpSync(source, target, { recursive: true, verbatimSymlinks: true, force: false, errorOnExist: true, - filter: path => !links.has(path) }) -} - /** * Bind an external profile to this application's real package directories. * @param profile - Candidate profile. @@ -191,10 +178,14 @@ export function validateDesktopPluginGraph( profile: string, root: string, runtime: DesktopRuntimeDescriptor, activePlugins: readonly string[], ): void { const profileRoot = realpathSync.native(profile) - const shared = new Map(runtime.sharedPackages.map(entry => [entry.name, realpathSync.native(runtimePath(root, entry.path))])) + const shared = new Map(runtime.sharedPackages.map((entry) => { + try { return [entry.name, realpathSync.native(runtimePath(root, entry.path))] as const } + catch (error) { throw new DesktopStartupError('reinstall', error) } + })) for (const [name, path] of shared) { if (packageFrom(profile, name) !== path) throw new Error(`desktop profile: missing or incorrect host link ${name}`) } + if (activePlugins.length === 0) return const scanned = new Set() const scan = (modules: string): void => { if (!existsSync(modules)) return @@ -247,8 +238,12 @@ export function validateDesktopPluginGraph( } } for (const name of activePlugins) { - const path = packageFrom(profile, name) - if (path === undefined || !inside(profileRoot, path)) throw new Error(`desktop profile: missing local plugin ${name}`) - visit(path, name) + try { + const path = packageFrom(profile, name) + if (path === undefined || !inside(profileRoot, path)) throw new Error(`desktop profile: missing local plugin ${name}`) + visit(path, name) + } catch (error) { + throw new DesktopStartupError('plugins', error) + } } } diff --git a/apps/desktop/src/project-manager.ts b/apps/desktop/src/project-manager.ts index f6759775e1..d87623dcba 100644 --- a/apps/desktop/src/project-manager.ts +++ b/apps/desktop/src/project-manager.ts @@ -1,8 +1,7 @@ -/** Transactional owner of the reserved desktop profile and its private pnpm state. */ +/** In-place owner of the reserved desktop profile and its private pnpm state. */ import { valid } from 'semver' import { spawn } from 'node:child_process' -import { randomUUID } from 'node:crypto' import { existsSync, fsyncSync, @@ -12,13 +11,13 @@ import { openSync, closeSync, readFileSync, + readdirSync, realpathSync, - renameSync, unlinkSync, writeFileSync, writeSync, } from 'node:fs' -import { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' +import { delimiter, dirname, join, resolve, sep } from 'node:path' import { DESKTOP_HOST_PACKAGE, desktopCorePackageOverrides, @@ -27,9 +26,10 @@ import { import type { DesktopPaths } from './paths.ts' import { removeOwnedDirectory } from './owned-directory.ts' import type { DesktopRelease } from './release.ts' +import { DesktopStartupError } from './startup-error.ts' import { desktopRuntimeId, readDesktopRuntime, type DesktopRuntimeDescriptor } from './runtime-tree.ts' import { - copyDesktopProfile, desktopPluginLockHash, linkDesktopHostPackages, readDesktopProfileState, + desktopPluginLockHash, linkDesktopHostPackages, readDesktopProfileState, unlinkDesktopHostPackages, validateDesktopPluginGraph, type DesktopProfileState, } from './profile-packages.ts' @@ -53,16 +53,6 @@ interface DesktopProjectManifest { } } -/** Journaled activation step used for crash recovery. */ -interface DesktopPendingTransaction { - readonly schemaVersion: 1 - readonly id: string - readonly stagingProfile: string - readonly fromRuntimeId: string | null - readonly toRuntimeId: string - readonly step: 'prepared' | 'active-moved' | 'staging-activated' -} - /** Exact executables the desktop shell bundles. */ export interface DesktopRuntimeExecutables { readonly node: string @@ -70,12 +60,12 @@ export interface DesktopRuntimeExecutables { readonly dsh: string } -/** Hooks that bind project replacement to the active backend lifecycle. */ +/** Hooks that stop the backend before profile writes and restart it after success. */ export interface DesktopProjectHooks { - /** Stop the active backend and await process exit before directory moves. */ - beforeActivate(): Promise - /** Start the selected active project after commit or rollback. */ - afterActivate(): Promise + /** Stop the active backend and await process exit before modifying its files. */ + beforeChange(): Promise + /** Start the modified profile after package preparation succeeds. */ + afterChange(): Promise } /** Supported dependency mutation. */ @@ -124,11 +114,6 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null } -function isDescendant(root: string, target: string): boolean { - const child = relative(root, target) - return child !== '' && child !== '..' && !child.startsWith(`..${sep}`) && !isAbsolute(child) -} - function assertPackageName(name: string): void { if (!PACKAGE_NAME_PATTERN.test(name)) throw new Error(`desktop project: invalid npm package name ${JSON.stringify(name)}`) } @@ -163,6 +148,11 @@ export function packageNameFromSpec(spec: string): string | undefined { } function projectManifest(projectDir: string): DesktopProjectManifest { + try { return readProjectManifest(projectDir) } + catch (error) { throw new DesktopStartupError('configuration', error) } +} + +function readProjectManifest(projectDir: string): DesktopProjectManifest { const path = join(projectDir, 'package.json') const value = readJson(path) const dsh = isRecord(value) && isRecord(value.dsh) ? value.dsh : undefined @@ -183,11 +173,11 @@ function projectManifest(projectDir: string): DesktopProjectManifest { function profilePluginNames(projectDir: string): readonly string[] { const bundles = projectManifest(projectDir).dsh.profile.bundles if (!DESKTOP_PROFILE_BUNDLES.every((bundle, index) => bundles[index] === bundle)) { - throw new Error('desktop project: profile must begin with the built-in desktop bundle list') + throw new DesktopStartupError('configuration', new Error('desktop project: profile must begin with the built-in desktop bundle list')) } const plugins = bundles.slice(DESKTOP_PROFILE_BUNDLES.length) if (new Set(bundles).size !== bundles.length) { - throw new Error('desktop project: profile bundle list contains a duplicate package') + throw new DesktopStartupError('configuration', new Error('desktop project: profile bundle list contains a duplicate package')) } for (const plugin of plugins) assertPackageName(plugin) return plugins @@ -234,7 +224,7 @@ function inspectPlugin(projectDir: string, requestedName: string): DesktopPlugin return { name: requestedName, version: manifest.version, enabled: profilePluginNames(projectDir).includes(requestedName) } } -/** Transactional desktop npm project manager. */ +/** Desktop npm project manager with direct writes and no rollback. */ export class DesktopProjectManager { private lockDescriptor: number | undefined private descriptor: DesktopRuntimeDescriptor | undefined @@ -248,50 +238,38 @@ export class DesktopProjectManager { readonly runtime: DesktopRuntimeExecutables, ) {} - /** Recover an interrupted directory replacement before reading the active project. */ - recover(): void { - if (!existsSync(this.paths.pending)) return - const value = readJson(this.paths.pending) - if (!isRecord(value) || value.schemaVersion !== 1 - || typeof value.id !== 'string' || typeof value.stagingProfile !== 'string' - || !isDescendant(this.paths.staging, value.stagingProfile) - || value.stagingProfile !== join(this.paths.staging, value.id, 'profile') - || (value.fromRuntimeId !== null && (typeof value.fromRuntimeId !== 'string' || !/^[a-f0-9]{64}$/u.test(value.fromRuntimeId))) - || typeof value.toRuntimeId !== 'string' || !/^[a-f0-9]{64}$/u.test(value.toRuntimeId) - || (value.step !== 'prepared' && value.step !== 'active-moved' && value.step !== 'staging-activated')) { - throw new Error(`desktop project: invalid activation journal ${this.paths.pending}`) - } - const pending: DesktopPendingTransaction = { - schemaVersion: 1, - id: value.id, - stagingProfile: value.stagingProfile, - fromRuntimeId: value.fromRuntimeId, - toRuntimeId: value.toRuntimeId, - step: value.step, - } - if (pending.step === 'staging-activated' && !existsSync(pending.stagingProfile) && existsSync(this.paths.profile)) { - if (readDesktopProfileState(this.paths.profile)?.runtimeId !== pending.toRuntimeId) { - throw new Error('desktop project: activated profile does not match its journal') - } - removeOwnedDirectory(this.paths.profile) - } - if (!existsSync(this.paths.profile) && pending.fromRuntimeId !== null && existsSync(this.paths.rollback)) { - if (readDesktopProfileState(this.paths.rollback)?.runtimeId !== pending.fromRuntimeId) { - throw new Error('desktop project: rollback profile does not match its journal') - } - mkdirSync(dirname(this.paths.profile), { recursive: true }) - renameSync(this.paths.rollback, this.paths.profile) - } - removeOwnedDirectory(pending.stagingProfile) - unlinkSync(this.paths.pending) - } - /** Read the active desktop plugin inventory. */ listPlugins(): readonly DesktopPluginRecord[] { if (!existsSync(this.paths.profile)) return [] return pluginRecords(this.paths.profile) } + /** @returns Whether the profile enables any third-party bundle, without loading plugin files. */ + hasEnabledPlugins(): boolean { + return existsSync(this.paths.profile) && profilePluginNames(this.paths.profile).length > 0 + } + + /** + * Reinitialize the profile, deleting configuration and third-party packages without a backup. + * @param hooks - Stop the Host before resetting files; restart after preparation succeeds. + * @returns Completion of reset; the held lock and shared product data are preserved. + */ + async resetConfiguration(hooks: DesktopProjectHooks): Promise { + await this.withLock(async () => { + await hooks.beforeChange() + this.descriptor = this.readRuntime() + for (const entry of readdirSync(this.paths.profile, { withFileTypes: true })) { + const path = join(this.paths.profile, entry.name) + if (path === this.paths.lock) continue + if (entry.isDirectory()) removeOwnedDirectory(path) + else unlinkSync(path) + } + createPluginProfile(this.paths.profile) + this.prepareProfile(this.paths.profile) + await hooks.afterChange() + }) + } + /** Read the dsh version supplied by this application's verified resources. */ dshVersion(): string { return this.currentRuntime().release.version @@ -316,6 +294,11 @@ export class DesktopProjectManager { return this.descriptor } + private readRuntime(): DesktopRuntimeDescriptor { + try { return readDesktopRuntime(this.runtime.dsh) } + catch (error) { throw new DesktopStartupError('reinstall', error) } + } + private prepareProfile(projectDir: string): void { const runtime = this.currentRuntime() linkDesktopHostPackages(projectDir, this.runtime.dsh, runtime) @@ -323,14 +306,13 @@ export class DesktopProjectManager { } /** Read release metadata and reconcile its external profile without installing core packages. */ - async applyRelease(electronVersion: string, hooks: DesktopProjectHooks): Promise { + async applyRelease(): Promise { return this.withLock(async () => { - this.recover() - const target = readDesktopRuntime(this.runtime.dsh, electronVersion) + const target = this.readRuntime() this.descriptor = target const previous = readDesktopProfileState(this.paths.profile) - if (existsSync(this.paths.profile) && previous === undefined) { - throw new Error('desktop project: existing profile is not a Desktop plugin profile') + if (previous === undefined && readdirSync(this.paths.profile).some(name => join(this.paths.profile, name) !== this.paths.lock)) { + throw new DesktopStartupError('configuration', new Error('desktop project: existing profile is not a Desktop plugin profile')) } if (previous?.runtimeId === desktopRuntimeId(target) && previous.lockHash === desktopPluginLockHash(this.paths.profile) @@ -341,37 +323,38 @@ export class DesktopProjectManager { validateDesktopPluginGraph(this.paths.profile, this.runtime.dsh, target, profilePluginNames(this.paths.profile)) return false } - const stagingProfile = this.newStagingProfile() - try { - if (previous === undefined) createPluginProfile(stagingProfile) - else copyDesktopProfile(this.paths.profile, stagingProfile) - await this.reconcileProfile(stagingProfile, previous) - await this.activate(stagingProfile, hooks) - return true - } catch (error) { - removeOwnedDirectory(stagingProfile) - throw error - } + if (previous === undefined) createPluginProfile(this.paths.profile) + await this.reconcileProfile(this.paths.profile, previous) + return true }) } - /** Apply an exact plugin dependency or activation change through a staging project. */ + /** Modify the current profile while its backend is stopped; failures retain partial changes. */ async mutate(mutation: DesktopProjectMutation, hooks: DesktopProjectHooks): Promise { await this.withLock(async () => { - this.recover() this.currentRuntime() if (!existsSync(this.paths.profile)) throw new Error('desktop project: active profile is not installed') - const stagingProfile = this.newStagingProfile() - try { - copyDesktopProfile(this.paths.profile, stagingProfile) - await this.applyMutation(stagingProfile, mutation) - await this.reconcileProfile(stagingProfile, readDesktopProfileState(stagingProfile), - mutation.type !== 'plugin-toggle' && mutation.type !== 'plugins-disable-all') - await this.activate(stagingProfile, hooks) - } catch (error) { - removeOwnedDirectory(stagingProfile) - throw error + await hooks.beforeChange() + if (mutation.type === 'plugins-disable-all') { + const manifest = projectManifest(this.paths.profile) + writeJson(join(this.paths.profile, 'package.json'), { + ...manifest, + dsh: { ...manifest.dsh, profile: { ...manifest.dsh.profile, bundles: [...DESKTOP_PROFILE_BUNDLES] } }, + }) + this.prepareProfile(this.paths.profile) + await hooks.afterChange() + return } + const previous = readDesktopProfileState(this.paths.profile) + const packagesChanged = mutation.type !== 'plugin-toggle' + if (packagesChanged) unlinkDesktopHostPackages(this.paths.profile) + try { + await this.applyMutation(this.paths.profile, mutation) + } finally { + if (packagesChanged) linkDesktopHostPackages(this.paths.profile, this.runtime.dsh, this.currentRuntime()) + } + await this.reconcileProfile(this.paths.profile, previous, packagesChanged) + await hooks.afterChange() }) } @@ -394,13 +377,7 @@ export class DesktopProjectManager { this.prepareProfile(projectDir) } - private newStagingProfile(): string { - const path = join(this.paths.staging, randomUUID(), 'profile') - mkdirSync(path, { recursive: true, mode: 0o700 }) - return path - } - - private async applyMutation(projectDir: string, mutation: DesktopProjectMutation): Promise { + private async applyMutation(projectDir: string, mutation: Exclude): Promise { switch (mutation.type) { case 'plugin-add': { const requestedName = packageNameFromSpec(mutation.spec) @@ -442,9 +419,6 @@ export class DesktopProjectManager { ) } return - case 'plugins-disable-all': - writeProfilePlugins(projectDir, pluginRecords(projectDir).map(plugin => ({ ...plugin, enabled: false }))) - return case 'plugin-toggle': { assertPackageName(mutation.name) const plugins = pluginRecords(projectDir) @@ -459,42 +433,6 @@ export class DesktopProjectManager { } } - private async activate(stagingProfile: string, hooks: DesktopProjectHooks): Promise { - const pending: DesktopPendingTransaction = { - schemaVersion: 1, - id: basename(dirname(stagingProfile)), - stagingProfile, - fromRuntimeId: readDesktopProfileState(this.paths.profile)?.runtimeId ?? null, - toRuntimeId: desktopRuntimeId(this.currentRuntime()), - step: 'prepared', - } - writeJson(this.paths.pending, pending) - let activeMoved = false - let stagingActivated = false - try { - await hooks.beforeActivate() - removeOwnedDirectory(this.paths.rollback) - mkdirSync(dirname(this.paths.rollback), { recursive: true, mode: 0o700 }) - writeJson(this.paths.pending, { ...pending, step: 'active-moved' } satisfies DesktopPendingTransaction) - if (existsSync(this.paths.profile)) { - renameSync(this.paths.profile, this.paths.rollback) - activeMoved = true - } - mkdirSync(dirname(this.paths.profile), { recursive: true, mode: 0o700 }) - writeJson(this.paths.pending, { ...pending, step: 'staging-activated' } satisfies DesktopPendingTransaction) - renameSync(stagingProfile, this.paths.profile) - stagingActivated = true - await hooks.afterActivate() - unlinkSync(this.paths.pending) - } catch (error) { - if (stagingActivated) removeOwnedDirectory(this.paths.profile) - if (activeMoved && existsSync(this.paths.rollback)) renameSync(this.paths.rollback, this.paths.profile) - if (existsSync(this.paths.pending)) unlinkSync(this.paths.pending) - await hooks.afterActivate().catch(() => undefined) - throw error - } - } - private async runPnpm(projectDir: string, args: readonly string[]): Promise { const [command, ...commandArgs] = args if (command === undefined) throw new Error('desktop project: pnpm command is required') @@ -586,7 +524,8 @@ export class DesktopProjectManager { } private async withLock(operation: () => Promise): Promise { - mkdirSync(this.paths.root, { recursive: true, mode: 0o700 }) + mkdirSync(this.paths.profile, { recursive: true, mode: 0o700 }) + if (lstatSync(this.paths.profile).isSymbolicLink()) throw new Error('desktop project: profile directory must not be a link') let descriptor: number try { descriptor = openSync(this.paths.lock, 'wx', 0o600) diff --git a/apps/desktop/src/runtime-tree.ts b/apps/desktop/src/runtime-tree.ts index 8f02a46497..30b083bbbb 100644 --- a/apps/desktop/src/runtime-tree.ts +++ b/apps/desktop/src/runtime-tree.ts @@ -1,11 +1,11 @@ /** Relocatable, integrity-recorded production packages carried by one Desktop release. */ import { createHash } from 'node:crypto' -import { existsSync, lstatSync, readdirSync, readFile, readFileSync, writeFileSync } from 'node:fs' +import { lstatSync, readdirSync, readFile, readFileSync, writeFileSync } from 'node:fs' import { isAbsolute, join, relative, sep } from 'node:path' import { promisify } from 'node:util' import { valid } from 'semver' -import { DESKTOP_HOST_PACKAGE, DESKTOP_HOST_RUNTIME_FILES } from './core-package-set.ts' +import { DESKTOP_HOST_PACKAGE } from './core-package-set.ts' import { parseDesktopRelease, type DesktopRelease } from './release.ts' /** Descriptor at the root of the immutable Desktop resource tree. */ @@ -143,22 +143,21 @@ export function writeDesktopRuntime( } /** - * Read release metadata and check shared manifests and Host entries without scanning runtime contents. + * Read packaged metadata and check shared package records. * @param root - Current application's runtime resources. - * @param electronVersion - Expected shell version. - * @param target - Required execution target; defaults to the current process. - * @returns Validated runtime descriptor. + * @returns Runtime metadata whose release compatibility is verified during packaging. */ -export function readDesktopRuntime( - root: string, electronVersion: string, target: { platform: NodeJS.Platform; arch: string } = process, -): DesktopRuntimeDescriptor { +export function readDesktopRuntime(root: string): DesktopRuntimeDescriptor { const value: unknown = JSON.parse(readFileSync(join(root, DESKTOP_RUNTIME_FILE), 'utf8')) - if (!record(value) || value.schemaVersion !== 1 || value.platform !== target.platform || value.arch !== target.arch + if (!record(value) || typeof value.platform !== 'string' || typeof value.arch !== 'string' || !Array.isArray(value.sharedPackages) || !Array.isArray(value.files)) { - throw new Error('desktop runtime: invalid descriptor or incompatible platform/architecture') + throw new Error('desktop runtime: invalid descriptor') } - const release = parseDesktopRelease(value.release) - if (release.version !== electronVersion) throw new Error(`desktop runtime: ${release.version} does not match Electron ${electronVersion}`) + if (!record(value.release) || typeof value.release.version !== 'string' + || typeof value.release.nodeVersion !== 'string' || typeof value.release.pnpmVersion !== 'string') { + throw new Error('desktop runtime: invalid release fields') + } + const release = value.release as unknown as DesktopRelease const sharedPackages = value.sharedPackages.map((entry: unknown): DesktopSharedPackage => { if (!record(entry) || typeof entry.name !== 'string' || !PACKAGE_NAME.test(entry.name) || typeof entry.version !== 'string' || valid(entry.version) === null || entry.path !== `node_modules/${entry.name}`) { @@ -169,34 +168,14 @@ export function readDesktopRuntime( if (new Set(sharedPackages.map(entry => entry.name)).size !== sharedPackages.length) { throw new Error('desktop runtime: duplicate shared package') } - const files = value.files.map((entry: unknown): DesktopRuntimeFile => { - if (!record(entry) || typeof entry.path !== 'string' || typeof entry.bytes !== 'number' - || !Number.isSafeInteger(entry.bytes) || entry.bytes < 0 || typeof entry.executable !== 'boolean' - || typeof entry.sha256 !== 'string' || !/^[a-f0-9]{64}$/u.test(entry.sha256)) { - throw new Error('desktop runtime: invalid file inventory') - } - runtimePath(root, entry.path) - return { path: entry.path, bytes: entry.bytes, sha256: entry.sha256, executable: entry.executable } - }).sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0) - for (const entry of sharedPackages) { - const manifest: unknown = JSON.parse(readFileSync(join(runtimePath(root, entry.path), 'package.json'), 'utf8')) - if (!record(manifest) || manifest.name !== entry.name || manifest.version !== entry.version) { - throw new Error(`desktop runtime: shared package metadata mismatch for ${entry.name}`) - } - } + const files = value.files as DesktopRuntimeFile[] for (const name of ['@deepseek-ai/dsh', DESKTOP_HOST_PACKAGE]) { if (sharedPackages.find(entry => entry.name === name)?.version !== release.version) { throw new Error(`desktop runtime: missing or mismatched ${name}`) } } - for (const file of DESKTOP_HOST_RUNTIME_FILES) { - const path = `node_modules/${DESKTOP_HOST_PACKAGE}/${file}` - if (!files.some(entry => entry.path === path) || !existsSync(runtimePath(root, path)) - || !lstatSync(runtimePath(root, path)).isFile()) { - throw new Error(`desktop runtime: missing Host file ${file}`) - } - } - return { schemaVersion: 1, release, platform: target.platform, arch: target.arch, sharedPackages, files } + return { schemaVersion: value.schemaVersion as 1, release, platform: value.platform as NodeJS.Platform, + arch: value.arch, sharedPackages, files } } /** @@ -209,7 +188,18 @@ export function readDesktopRuntime( export async function verifyDesktopRuntime( root: string, electronVersion: string, target: { platform: NodeJS.Platform; arch: string } = process, ): Promise { - const descriptor = readDesktopRuntime(root, electronVersion, target) + const descriptor = readDesktopRuntime(root) + if (descriptor.schemaVersion !== 1 || descriptor.platform !== target.platform || descriptor.arch !== target.arch) { + throw new Error('desktop runtime: invalid descriptor or incompatible platform/architecture') + } + const release = parseDesktopRelease(descriptor.release) + if (release.version !== electronVersion) throw new Error(`desktop runtime: ${release.version} does not match Electron ${electronVersion}`) + for (const entry of descriptor.sharedPackages) { + const manifest: unknown = JSON.parse(readFileSync(join(runtimePath(root, entry.path), 'package.json'), 'utf8')) + if (!record(manifest) || manifest.name !== entry.name || manifest.version !== entry.version) { + throw new Error(`desktop runtime: shared package metadata mismatch for ${entry.name}`) + } + } const actual = await inventoryRuntimeForVerification(root) // Windows has no portable Unix executable permission bits. const comparable = (items: readonly DesktopRuntimeFile[]): unknown => process.platform === 'win32' diff --git a/apps/desktop/src/startup-document.ts b/apps/desktop/src/startup-document.ts new file mode 100644 index 0000000000..65245649e2 --- /dev/null +++ b/apps/desktop/src/startup-document.ts @@ -0,0 +1,25 @@ +/** Self-contained recovery document for an unavailable shell renderer or preload. */ + +import type { DesktopLocale } from './locale.ts' + +/** + * Render escaped diagnostics without depending on application resource files. + * @param locale - Shell-owned translations. + * @param message - Failure details displayed as plain text. + * @returns An HTML document suitable for an isolated emergency window. + */ +export function startupFailureDocument(locale: DesktopLocale, message: string): string { + const escape = (value: string): string => value.replaceAll('&', '&').replaceAll('<', '<') + .replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", ''') + return ` + +${escape(locale.messages.startupFailed)} + +

${escape(locale.messages.startupFailed)}

${escape(locale.messages.startupReinstallAdvice)}

+

${escape(locale.messages.startupConfigurationAdvice)}

+
${escape(message)}
+
+
+
+
` +} diff --git a/apps/desktop/src/startup-error.ts b/apps/desktop/src/startup-error.ts new file mode 100644 index 0000000000..feb19c0dda --- /dev/null +++ b/apps/desktop/src/startup-error.ts @@ -0,0 +1,31 @@ +/** Recovery guidance attached by the owner of a failed Desktop startup operation. */ + +/** Actions that can restore an unavailable Desktop backend. */ +export type DesktopRecovery = 'restart' | 'reinstall' | 'plugins' | 'configuration' + +/** An operation failure with recovery guidance independent of its diagnostic wording. */ +export class DesktopStartupError extends Error { + /** + * @param recovery - Recovery supported by the failed operation. + * @param cause - Original diagnostic, retained for troubleshooting. + */ + constructor(readonly recovery: DesktopRecovery, cause: unknown) { + super(cause instanceof Error ? cause.message : String(cause), { cause }) + } +} + +/** + * Preserve diagnostics and recovery guidance when sending failures to a renderer. + * @param error - Startup or runtime failure. + * @returns Serializable error state; unclassified failures suggest restarting. + */ +export function desktopErrorState(error: unknown): { + phase: 'error' + message: string + recovery: DesktopRecovery +} { + const message = error instanceof AggregateError + ? [error.message, ...error.errors.map(item => desktopErrorState(item).message)].join('\n') + : error instanceof Error ? error.message : String(error) + return { phase: 'error', message, recovery: error instanceof DesktopStartupError ? error.recovery : 'restart' } +} diff --git a/apps/desktop/tests/backend-controller.spec.ts b/apps/desktop/tests/backend-controller.spec.ts index abdc78a9e7..c955ddc1e3 100644 --- a/apps/desktop/tests/backend-controller.spec.ts +++ b/apps/desktop/tests/backend-controller.spec.ts @@ -83,7 +83,7 @@ describe('desktop backend controller', () => { it('publishes preparation errors and permits retry', async () => { const f = fixture() await expect(f.controller.start(async () => { throw new Error('invalid profile') })).rejects.toThrow('invalid profile') - expect(f.controller.state).toEqual({ phase: 'error', message: 'invalid profile' }) + expect(f.controller.state).toEqual({ phase: 'error', message: 'invalid profile', recovery: 'restart' }) expect(f.create).not.toHaveBeenCalled() f.ready.resolve() await f.controller.start(async () => {}) @@ -101,7 +101,7 @@ describe('desktop backend controller', () => { await f.stopping.promise f.exited.resolve() await rejected - expect(f.controller.state).toEqual({ phase: 'error', message: 'plugin failed' }) + expect(f.controller.state).toEqual({ phase: 'error', message: 'plugin failed', recovery: 'restart' }) expect(f.host.stop).toHaveBeenCalledTimes(1) await f.controller.close() }) @@ -111,7 +111,7 @@ describe('desktop backend controller', () => { f.ready.resolve() await f.controller.start(async () => {}) f.fail(new Error('transport failed')) - expect(f.controller.state).toEqual({ phase: 'error', message: 'transport failed' }) + expect(f.controller.state).toEqual({ phase: 'error', message: 'transport failed', recovery: 'restart' }) expect(f.controller.host).toBeUndefined() await f.stopping.promise const prepare = vi.fn(async () => {}) @@ -135,7 +135,7 @@ describe('desktop backend controller', () => { await f.stopping.promise f.exited.resolve() await rejected - expect(f.states).toEqual([{ phase: 'starting' }, { phase: 'error', message: 'immediate failure' }]) + expect(f.states).toEqual([{ phase: 'starting' }, { phase: 'error', message: 'immediate failure', recovery: 'restart' }]) await f.controller.close() }) diff --git a/apps/desktop/tests/expected/startup-en-configuration.txt b/apps/desktop/tests/expected/startup-en-configuration.txt new file mode 100644 index 0000000000..29bfba4506 --- /dev/null +++ b/apps/desktop/tests/expected/startup-en-configuration.txt @@ -0,0 +1,8 @@ +DeepSeek Harness could not start +Choose a recovery action below. Disabling third-party plugins retains their files. +Reset Desktop deletes all Desktop profile configuration and third-party plugins without a backup, then starts a fresh profile. Shared tasks and settings are retained. +If application files are missing or damaged, close the application and reinstall it. Your tasks are stored separately. +Failure details +Close and restart +Disable all third-party plugins and retry +Reset Desktop and retry diff --git a/apps/desktop/tests/expected/startup-en-plugins.txt b/apps/desktop/tests/expected/startup-en-plugins.txt new file mode 100644 index 0000000000..29bfba4506 --- /dev/null +++ b/apps/desktop/tests/expected/startup-en-plugins.txt @@ -0,0 +1,8 @@ +DeepSeek Harness could not start +Choose a recovery action below. Disabling third-party plugins retains their files. +Reset Desktop deletes all Desktop profile configuration and third-party plugins without a backup, then starts a fresh profile. Shared tasks and settings are retained. +If application files are missing or damaged, close the application and reinstall it. Your tasks are stored separately. +Failure details +Close and restart +Disable all third-party plugins and retry +Reset Desktop and retry diff --git a/apps/desktop/tests/expected/startup-en-reinstall.txt b/apps/desktop/tests/expected/startup-en-reinstall.txt new file mode 100644 index 0000000000..29bfba4506 --- /dev/null +++ b/apps/desktop/tests/expected/startup-en-reinstall.txt @@ -0,0 +1,8 @@ +DeepSeek Harness could not start +Choose a recovery action below. Disabling third-party plugins retains their files. +Reset Desktop deletes all Desktop profile configuration and third-party plugins without a backup, then starts a fresh profile. Shared tasks and settings are retained. +If application files are missing or damaged, close the application and reinstall it. Your tasks are stored separately. +Failure details +Close and restart +Disable all third-party plugins and retry +Reset Desktop and retry diff --git a/apps/desktop/tests/expected/startup-en-restart.txt b/apps/desktop/tests/expected/startup-en-restart.txt new file mode 100644 index 0000000000..29bfba4506 --- /dev/null +++ b/apps/desktop/tests/expected/startup-en-restart.txt @@ -0,0 +1,8 @@ +DeepSeek Harness could not start +Choose a recovery action below. Disabling third-party plugins retains their files. +Reset Desktop deletes all Desktop profile configuration and third-party plugins without a backup, then starts a fresh profile. Shared tasks and settings are retained. +If application files are missing or damaged, close the application and reinstall it. Your tasks are stored separately. +Failure details +Close and restart +Disable all third-party plugins and retry +Reset Desktop and retry diff --git a/apps/desktop/tests/expected/startup-zh-CN-configuration.txt b/apps/desktop/tests/expected/startup-zh-CN-configuration.txt new file mode 100644 index 0000000000..f37bdf417f --- /dev/null +++ b/apps/desktop/tests/expected/startup-zh-CN-configuration.txt @@ -0,0 +1,8 @@ +DeepSeek Harness 无法启动 +请选择下方的恢复操作。禁用第三方插件会保留插件文件。 +重置 Desktop 会删除桌面端的全部 profile 配置和第三方插件,不保留备份,然后重新初始化并启动。共享任务和设置会保留。 +如果应用文件缺失或损坏,请关闭应用并重新安装。任务数据存储在独立位置。 +Failure details +关闭并重启 +禁用全部第三方插件并重试 +重置 Desktop 并重试 diff --git a/apps/desktop/tests/expected/startup-zh-CN-plugins.txt b/apps/desktop/tests/expected/startup-zh-CN-plugins.txt new file mode 100644 index 0000000000..f37bdf417f --- /dev/null +++ b/apps/desktop/tests/expected/startup-zh-CN-plugins.txt @@ -0,0 +1,8 @@ +DeepSeek Harness 无法启动 +请选择下方的恢复操作。禁用第三方插件会保留插件文件。 +重置 Desktop 会删除桌面端的全部 profile 配置和第三方插件,不保留备份,然后重新初始化并启动。共享任务和设置会保留。 +如果应用文件缺失或损坏,请关闭应用并重新安装。任务数据存储在独立位置。 +Failure details +关闭并重启 +禁用全部第三方插件并重试 +重置 Desktop 并重试 diff --git a/apps/desktop/tests/expected/startup-zh-CN-reinstall.txt b/apps/desktop/tests/expected/startup-zh-CN-reinstall.txt new file mode 100644 index 0000000000..f37bdf417f --- /dev/null +++ b/apps/desktop/tests/expected/startup-zh-CN-reinstall.txt @@ -0,0 +1,8 @@ +DeepSeek Harness 无法启动 +请选择下方的恢复操作。禁用第三方插件会保留插件文件。 +重置 Desktop 会删除桌面端的全部 profile 配置和第三方插件,不保留备份,然后重新初始化并启动。共享任务和设置会保留。 +如果应用文件缺失或损坏,请关闭应用并重新安装。任务数据存储在独立位置。 +Failure details +关闭并重启 +禁用全部第三方插件并重试 +重置 Desktop 并重试 diff --git a/apps/desktop/tests/expected/startup-zh-CN-restart.txt b/apps/desktop/tests/expected/startup-zh-CN-restart.txt new file mode 100644 index 0000000000..f37bdf417f --- /dev/null +++ b/apps/desktop/tests/expected/startup-zh-CN-restart.txt @@ -0,0 +1,8 @@ +DeepSeek Harness 无法启动 +请选择下方的恢复操作。禁用第三方插件会保留插件文件。 +重置 Desktop 会删除桌面端的全部 profile 配置和第三方插件,不保留备份,然后重新初始化并启动。共享任务和设置会保留。 +如果应用文件缺失或损坏,请关闭应用并重新安装。任务数据存储在独立位置。 +Failure details +关闭并重启 +禁用全部第三方插件并重试 +重置 Desktop 并重试 diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index 252db0a18c..65a651d45e 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { join } from 'node:path' import { DESKTOP_IPC } from '../src/ipc.ts' const harness = await vi.hoisted(async () => { @@ -12,6 +13,7 @@ const harness = await vi.hoisted(async () => { const windows: FakeWindow[] = [] const hosts: FakeHost[] = [] const handlers = new Map unknown>() + let pluginsEnabled = false let preparing = deferred() let prepared = deferred() let hostStarted = deferred() @@ -24,6 +26,7 @@ const harness = await vi.hoisted(async () => { readonly webContents = Object.assign(new EventEmitter(), { setWindowOpenHandler: vi.fn(), openDevTools: vi.fn(), + getURL: () => this.urls.at(-1) ?? '', send: vi.fn((channel: string, state: { phase?: string }) => { if (channel === 'dsh-desktop:backend-state' && state.phase === 'error') errorPublished.resolve() }), @@ -51,16 +54,18 @@ const harness = await vi.hoisted(async () => { this.ready.reject(new Error('child stopped')) return this.exited.promise }) - constructor(..._args: unknown[]) { hosts.push(this) } + constructor(readonly node: string, readonly runtime: string, readonly profile: string) { hosts.push(this) } } const app = Object.assign(new EventEmitter(), { - isPackaged: false, + isPackaged: true, name: 'Desktop test', whenReady: () => Promise.resolve(), getLocale: () => 'en-US', getVersion: () => '1.0.0', + getAppPath: () => 'desktop-test-app', requestSingleInstanceLock: () => true, exit: vi.fn(), + relaunch: vi.fn(), quit: vi.fn(() => { const event = { preventDefault: vi.fn() } app.emit('before-quit', event) @@ -71,12 +76,17 @@ const harness = await vi.hoisted(async () => { windows, hosts, handlers, app, FakeWindow, FakeHost, dialog: { showErrorBox: vi.fn(), showMessageBox: vi.fn() }, applyRelease: vi.fn(() => { preparing.resolve(); return prepared.promise }), + assertProfileRuntime: vi.fn(), get preparing() { return preparing }, get prepared() { return prepared }, get hostStarted() { return hostStarted }, get navigated() { return navigated }, get errorPublished() { return errorPublished }, get quitCompleted() { return quitCompleted }, nextHostStart() { hostStarted = deferred(); return hostStarted.promise }, + get pluginsEnabled() { return pluginsEnabled }, + set pluginsEnabled(value: boolean) { pluginsEnabled = value }, reset() { windows.length = 0; hosts.length = 0; handlers.clear(); app.removeAllListeners() + app.isPackaged = true + pluginsEnabled = false preparing = deferred(); prepared = deferred(); hostStarted = deferred() navigated = deferred(); errorPublished = deferred(); quitCompleted = deferred() }, @@ -97,7 +107,16 @@ vi.mock('../src/paths.ts', () => ({ resolveDesktopPaths: () => ({ profile: 'desk vi.mock('../src/project-manager.ts', () => ({ DesktopProjectManager: class { readonly applyRelease = harness.applyRelease - readonly assertProfileRuntime = vi.fn() + readonly assertProfileRuntime = harness.assertProfileRuntime + hasEnabledPlugins() { return harness.pluginsEnabled } + async mutate(_mutation: unknown, hooks: { beforeChange(): Promise; afterChange(): Promise }) { + await hooks.beforeChange() + harness.pluginsEnabled = false + await hooks.afterChange() + } + async resetConfiguration(hooks: { beforeChange(): Promise; afterChange(): Promise }) { + await this.mutate(undefined, hooks) + } }, })) vi.mock('../src/host-process.ts', () => ({ DesktopHostProcess: harness.FakeHost })) @@ -117,7 +136,8 @@ beforeEach(() => { vi.stubEnv('DSH_DESKTOP_NODE_BINARY', 'test-node') vi.stubEnv('DSH_DESKTOP_PNPM_ENTRY', 'test-pnpm') vi.stubEnv('DSH_DESKTOP_DSH_DIR', 'test-runtime') - vi.stubEnv('DSH_DESKTOP_DEV_PROJECT_DIR', undefined) + vi.stubGlobal('process', { ...process, resourcesPath: 'desktop-test-resources' }) + vi.stubEnv('DSH_DESKTOP_HOST_INSPECT_PORT', undefined) }) afterEach(async () => { @@ -128,9 +148,98 @@ afterEach(async () => { vi.clearAllTimers() vi.useRealTimers() vi.unstubAllEnvs() + vi.unstubAllGlobals() }) describe('desktop main startup', () => { + it.each(['plugins', 'reset'])('runs %s recovery from a document with a broken preload', async (action) => { + await import('../src/main.ts') + await harness.preparing.promise + const window = harness.windows[0]! + window.webContents.emit('preload-error', {}, 'preload-app.cjs', new Error('preload unavailable')) + harness.prepared.resolve() + await harness.hostStarted.promise + harness.hosts[0]!.ready.resolve() + await Promise.resolve(invoke(DESKTOP_IPC.backendRetry)) + const started = harness.nextHostStart() + const event = { preventDefault: vi.fn() } + window.webContents.emit('will-navigate', event, `dsh-recovery://${action}/?`) + await harness.hosts[0]!.stopping.promise + harness.hosts[0]!.exited.resolve() + await started + harness.hosts[1]!.ready.resolve() + await harness.navigated.promise + expect(event.preventDefault).toHaveBeenCalled() + expect(window.urls.at(-1)).toBe('dsh-app://app/index.html') + }) + + it('allows a full profile reset for an unclassified startup failure', async () => { + await import('../src/main.ts') + await harness.preparing.promise + harness.prepared.resolve() + await harness.hostStarted.promise + harness.hosts[0]!.exited.resolve() + harness.hosts[0]!.ready.reject(new Error('Unknown startup failure')) + await harness.errorPublished.promise + const started = harness.nextHostStart() + const reset = Promise.resolve(invoke(DESKTOP_IPC.configurationReset)) + await started + harness.hosts[1]!.ready.resolve() + await reset + expect(invoke(DESKTOP_IPC.backendStatus)).toEqual({ phase: 'ready' }) + }) + + it('keeps a self-contained reinstall document in the main window after preload failure', async () => { + await import('../src/main.ts') + await harness.preparing.promise + const window = harness.windows[0]! + window.webContents.emit('preload-error', {}, 'preload-app.cjs', new Error('preload unavailable')) + expect(window.urls.at(-1)).toContain('data:text/html') + expect(decodeURIComponent(window.urls.at(-1)!)).toContain('preload unavailable') + harness.prepared.resolve() + await harness.hostStarted.promise + harness.hosts[0]!.ready.resolve() + await Promise.resolve(invoke(DESKTOP_IPC.backendRetry)) + expect(harness.windows).toHaveLength(1) + expect(window.urls.at(-1)).toContain('data:text/html') + expect(harness.dialog.showErrorBox).not.toHaveBeenCalled() + }) + + it('offers plugin recovery and disables plugins before restarting in the same window', async () => { + harness.pluginsEnabled = true + await import('../src/main.ts') + await harness.preparing.promise + harness.prepared.resolve() + await harness.hostStarted.promise + harness.hosts[0]!.exited.resolve() + harness.hosts[0]!.ready.reject(new Error('Plugin initialization failed')) + await harness.errorPublished.promise + expect(invoke(DESKTOP_IPC.backendStatus)).toMatchObject({ recovery: 'plugins' }) + const nextStarted = harness.nextHostStart() + const recovery = Promise.resolve(invoke(DESKTOP_IPC.pluginsDisableAll)) + await nextStarted + expect(harness.pluginsEnabled).toBe(false) + harness.hosts[1]!.ready.resolve() + await recovery + expect(harness.windows).toHaveLength(1) + expect(invoke(DESKTOP_IPC.backendStatus)).toEqual({ phase: 'ready' }) + }) + + it('waits for Host exit before relaunching the application', async () => { + await import('../src/main.ts') + await harness.preparing.promise + harness.prepared.resolve() + await harness.hostStarted.promise + harness.hosts[0]!.ready.resolve() + await harness.navigated.promise + const restart = Promise.resolve(invoke(DESKTOP_IPC.applicationRestart)) + await harness.hosts[0]!.stopping.promise + expect(harness.app.relaunch).not.toHaveBeenCalled() + harness.hosts[0]!.exited.resolve() + await restart + expect(harness.app.relaunch).toHaveBeenCalledOnce() + }) + it('shows the loading window before profile preparation and starts one actual Host', async () => { await import('../src/main.ts') await harness.preparing.promise @@ -148,12 +257,31 @@ describe('desktop main startup', () => { harness.hosts[0]!.ready.resolve() await Promise.all([retry, secondRetry, harness.navigated.promise]) expect(harness.applyRelease).toHaveBeenCalledTimes(1) + expect(harness.assertProfileRuntime).toHaveBeenCalledWith('desktop-test-profile') + expect(harness.hosts[0]).toMatchObject({ + node: join('desktop-test-resources', 'runtime', 'node', process.platform === 'win32' ? 'node.exe' : 'node'), + runtime: join('desktop-test-resources', 'dsh'), + profile: 'desktop-test-profile', + }) expect(harness.hosts[0]!.start).toHaveBeenCalledTimes(1) expect(harness.windows).toHaveLength(1) expect(window.urls).toEqual(['dsh-app://shell/startup.html', 'dsh-app://app/index.html']) expect(invoke(DESKTOP_IPC.backendStatus)).toEqual({ phase: 'ready' }) }) + it('starts the unpackaged Host from the application development directory', async () => { + harness.app.isPackaged = false + await import('../src/main.ts') + await harness.hostStarted.promise + const project = join(harness.app.getAppPath(), '.desktop-build', 'development', 'project') + expect(harness.hosts[0]).toMatchObject({ node: 'test-node', runtime: project, profile: project }) + expect(harness.applyRelease).not.toHaveBeenCalled() + expect(harness.assertProfileRuntime).not.toHaveBeenCalled() + harness.hosts[0]!.ready.resolve() + await harness.navigated.promise + expect(harness.dialog.showErrorBox).not.toHaveBeenCalled() + }) + it('keeps startup errors and a successful retry in the same window', async () => { await import('../src/main.ts') await harness.preparing.promise @@ -165,7 +293,7 @@ describe('desktop main startup', () => { first.ready.reject(new Error('plugin composition failed')) await harness.errorPublished.promise await failedRetry - expect(invoke(DESKTOP_IPC.backendStatus)).toEqual({ phase: 'error', message: 'plugin composition failed' }) + expect(invoke(DESKTOP_IPC.backendStatus)).toEqual({ phase: 'error', message: 'plugin composition failed', recovery: 'restart' }) expect(harness.windows[0]!.urls).toEqual(['dsh-app://shell/startup.html']) const nextStarted = harness.nextHostStart() const retry = Promise.resolve(invoke(DESKTOP_IPC.backendRetry)) diff --git a/apps/desktop/tests/plugin-pnpm.spec.ts b/apps/desktop/tests/plugin-pnpm.spec.ts index f565967cee..351387160e 100644 --- a/apps/desktop/tests/plugin-pnpm.spec.ts +++ b/apps/desktop/tests/plugin-pnpm.spec.ts @@ -47,8 +47,8 @@ it('installs a real pnpm graph, then executes approved scripts with the shared h const realPnpm = join(import.meta.dirname, '../node_modules/pnpm/bin/pnpm.mjs') writeFileSync(pnpm, `process.argv = process.argv.map(arg => arg === '--config.registry=https://registry.npmjs.org/' ? ${JSON.stringify(`--config.registry=${origin}`)} : arg); await import(${JSON.stringify(pathToFileURL(realPnpm).href)})`) const manager = new DesktopProjectManager(resolveDesktopPaths(join(root, '.dsh')), { node: process.execPath, pnpm, dsh }) - const hooks: DesktopProjectHooks = { beforeActivate: async () => {}, afterActivate: async () => {} } - await manager.applyRelease('1.0.0', hooks) + const hooks: DesktopProjectHooks = { beforeChange: async () => {}, afterChange: async () => {} } + await manager.applyRelease() await manager.mutate({ type: 'plugin-add', spec: 'fixture-plugin@1.0.0' }, hooks) expect(manager.listPlugins()).toEqual([{ name: 'fixture-plugin', version: '1.0.0', enabled: true }]) const built = JSON.parse(readFileSync(join(manager.paths.profile, 'node_modules/node-pty/built.json'), 'utf8')) as { node: string; host: string } diff --git a/apps/desktop/tests/preload-app.spec.ts b/apps/desktop/tests/preload-app.spec.ts index 81eac02cc3..d6e7a63b07 100644 --- a/apps/desktop/tests/preload-app.spec.ts +++ b/apps/desktop/tests/preload-app.spec.ts @@ -23,8 +23,13 @@ it('provides startup controls and a removable state subscription to shell docume await api.backend.status() await api.backend.retry() await api.openPlugins() + await api.disablePlugins() + await api.resetConfiguration() + await api.restart() + await api.close() expect(electron.ipcRenderer.invoke.mock.calls).toEqual([ [DESKTOP_IPC.localeGet], [DESKTOP_IPC.backendStatus], [DESKTOP_IPC.backendRetry], [DESKTOP_IPC.pluginsOpen], + [DESKTOP_IPC.pluginsDisableAll], [DESKTOP_IPC.configurationReset], [DESKTOP_IPC.applicationRestart], [DESKTOP_IPC.applicationClose], ]) const listener = vi.fn() const dispose = api.backend.subscribe(listener) diff --git a/apps/desktop/tests/profile-packages.spec.ts b/apps/desktop/tests/profile-packages.spec.ts index 4f3fe1308a..d419c962f2 100644 --- a/apps/desktop/tests/profile-packages.spec.ts +++ b/apps/desktop/tests/profile-packages.spec.ts @@ -1,11 +1,11 @@ import { execFileSync } from 'node:child_process' -import { mkdtempSync, readFileSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs' +import { mkdtempSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' import { afterEach, expect, it } from 'vitest' import { createPluginProfile } from '../src/project-manager.ts' -import { copyDesktopProfile, linkDesktopHostPackages, unlinkDesktopHostPackages, validateDesktopPluginGraph } from '../src/profile-packages.ts' +import { linkDesktopHostPackages, unlinkDesktopHostPackages, validateDesktopPluginGraph } from '../src/profile-packages.ts' import { runtimeFixture, writePackage } from './runtime-fixture.ts' const roots: string[] = [] @@ -58,12 +58,9 @@ it('refuses to satisfy a plugin dependency from an ancestor CLI project', () => writePackage(join(profile, 'node_modules'), 'plugin', { dependencies: { ambient: '1.0.0' } }) expect(() =>{ validateDesktopPluginGraph(profile, dsh, runtime, ['plugin']) }).toThrow(/outside its owned packages/u) }) -it('copies writable plugin bytes independently and removes broken owned links without following them', () => { +it('removes broken owned links without following them', () => { const { root, profile } = fixture() writePackage(join(profile, 'node_modules'), 'plugin') - copyDesktopProfile(profile, join(root, 'copy')) - writeFileSync(join(root, 'copy/node_modules/plugin/index.js'), 'changed') - expect(readFileSync(join(profile, 'node_modules/plugin/index.js'), 'utf8')).toContain('identity') rmSync(join(root, 'dsh'), { recursive: true }) expect(() =>{ unlinkDesktopHostPackages(profile) }).not.toThrow() }) diff --git a/apps/desktop/tests/project-manager.spec.ts b/apps/desktop/tests/project-manager.spec.ts index 9d141efa2d..cb4fee6389 100644 --- a/apps/desktop/tests/project-manager.spec.ts +++ b/apps/desktop/tests/project-manager.spec.ts @@ -1,11 +1,10 @@ -import { existsSync, mkdtempSync, mkdirSync, readFileSync, renameSync, rmSync, unlinkSync, realpathSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, unlinkSync, realpathSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' +import { join } from 'node:path' import { pathToFileURL } from 'node:url' import { afterEach, describe, expect, it } from 'vitest' import { resolveDesktopPaths } from '../src/paths.ts' import { DesktopProjectManager, packageNameFromSpec, type DesktopProjectHooks } from '../src/project-manager.ts' -import { readDesktopProfileState } from '../src/profile-packages.ts' import { runtimeFixture } from './runtime-fixture.ts' const roots: string[] = [] @@ -48,7 +47,7 @@ if (command !== 'rebuild') { return path } function hooks(overrides: Partial = {}): DesktopProjectHooks { - return { beforeActivate: async () => {}, afterActivate: async () => {}, ...overrides } + return { beforeChange: async () => {}, afterChange: async () => {}, ...overrides } } function setup(): { root: string; manager: DesktopProjectManager } { const root = temporaryRoot() @@ -63,6 +62,85 @@ function calls(root: string): { args: string[]; registry: string }[] { afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) }) describe('desktop external plugin profile', () => { + it('starts with disabled plugins even when their installed manifests are corrupt', async () => { + const { manager } = setup() + await manager.applyRelease() + await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks()) + const manifest = join(manager.paths.profile, 'node_modules/plugin/package.json') + writeFileSync(manifest, '{broken') + await expect(manager.applyRelease()).rejects.toThrow() + await manager.mutate({ type: 'plugins-disable-all' }, hooks()) + await expect(manager.applyRelease()).resolves.toBe(false) + expect(readFileSync(manifest, 'utf8')).toBe('{broken') + await manager.resetConfiguration(hooks()) + expect(existsSync(manifest)).toBe(false) + await expect(manager.applyRelease()).resolves.toBe(false) + }) + + it('disables every third-party bundle without reading a broken plugin patch declaration', async () => { + const { root, manager } = setup() + await manager.applyRelease() + await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks()) + const patch = join(manager.paths.profile, 'node_modules/plugin/bundle.yml') + unlinkSync(patch) + await manager.mutate({ type: 'plugins-disable-all' }, hooks({ afterChange: async () => { + expect(manager.hasEnabledPlugins()).toBe(false) + } })) + expect(manager.hasEnabledPlugins()).toBe(false) + expect(existsSync(join(manager.paths.profile, 'node_modules/plugin/package.json'))).toBe(true) + expect(calls(root)).toHaveLength(2) + await expect(manager.applyRelease()).resolves.toBe(false) + }) + + it('resets the entire profile without backups while retaining its lock and shared data', async () => { + const { root, manager } = setup() + await manager.applyRelease() + await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks()) + const profile = manager.paths.profile + expect(manager.paths.lock).toBe(join(profile, 'lock')) + const task = join(root, '.dsh', 'task-sentinel') + const homeEnvironment = join(root, '.dsh', '.env') + writeFileSync(homeEnvironment, 'HOME_SETTING=retained') + writeFileSync(task, 'retained task') + writeFileSync(join(profile, 'desktop-runtime-state.json'), '{broken') + writeFileSync(join(profile, 'cordis.patch.yml'), ': broken') + writeFileSync(join(profile, '.env'), 'NODE_OPTIONS=--bad') + mkdirSync(join(profile, '.extra')) + writeFileSync(join(profile, '.extra', 'custom-file'), 'remove') + const shared = join(root, 'shared-data') + mkdirSync(shared) + writeFileSync(join(shared, 'sentinel'), 'preserve') + symlinkSync(shared, join(profile, 'external-link'), process.platform === 'win32' ? 'junction' : 'dir') + await expect(manager.applyRelease()).rejects.toMatchObject({ recovery: 'configuration' }) + await manager.resetConfiguration(hooks({ + beforeChange: async () => { expect(readFileSync(join(profile, 'cordis.patch.yml'), 'utf8')).toBe(': broken') }, + afterChange: async () => { + manager.assertProfileRuntime(profile) + expect(readFileSync(manager.paths.lock, 'utf8').trim()).toBe(String(process.pid)) + await expect(manager.applyRelease()).rejects.toThrow('another package transaction is active') + }, + })) + expect(manager.listPlugins()).toEqual([]) + expect(existsSync(join(profile, 'node_modules/plugin'))).toBe(false) + expect(existsSync(join(profile, 'cordis.patch.yml'))).toBe(false) + expect(existsSync(join(profile, '.env'))).toBe(false) + expect(existsSync(join(profile, '.extra'))).toBe(false) + expect(existsSync(join(profile, 'external-link'))).toBe(false) + expect(readFileSync(join(shared, 'sentinel'), 'utf8')).toBe('preserve') + expect(readFileSync(task, 'utf8')).toBe('retained task') + expect(readFileSync(homeEnvironment, 'utf8')).toBe('HOME_SETTING=retained') + expect(readdirSync(profile).some(name => name.includes('backup'))).toBe(false) + expect(calls(root)).toHaveLength(2) + await expect(manager.applyRelease()).resolves.toBe(false) + expect(existsSync(homeEnvironment)).toBe(true) + }) + + it('reports damaged application metadata as a reinstall failure', async () => { + const { manager } = setup() + writeFileSync(join(manager.runtime.dsh, 'desktop-runtime.json'), '{broken') + await expect(manager.applyRelease()).rejects.toMatchObject({ recovery: 'reinstall' }) + }) + it('accepts registry names and tags but rejects alternate sources and flags', () => { expect(packageNameFromSpec('@scope/plugin@1.2.3')).toBe('@scope/plugin') expect(packageNameFromSpec('plugin@next')).toBe('plugin') @@ -73,9 +151,8 @@ describe('desktop external plugin profile', () => { it('initializes and restarts offline without executing pnpm', async () => { const { root, manager } = setup() - await expect(manager.applyRelease('2.0.0', hooks())).rejects.toThrow(/does not match Electron/u) - await expect(manager.applyRelease('1.0.0', hooks())).resolves.toBe(true) - await expect(manager.applyRelease('1.0.0', hooks())).resolves.toBe(false) + await expect(manager.applyRelease()).resolves.toBe(true) + await expect(manager.applyRelease()).resolves.toBe(false) expect(manager.listPlugins()).toEqual([]) expect(calls(root)).toEqual([]) expect(existsSync(manager.paths.pnpm.store)).toBe(false) @@ -85,21 +162,17 @@ describe('desktop external plugin profile', () => { it('repairs a removed managed link without running pnpm', async () => { const { root, manager } = setup() - await manager.applyRelease('1.0.0', hooks()) + await manager.applyRelease() unlinkSync(join(manager.paths.profile, 'node_modules/@deepseek-ai/cordis')) - await expect(manager.applyRelease('1.0.0', hooks())).resolves.toBe(true) + await expect(manager.applyRelease()).resolves.toBe(true) expect(calls(root)).toEqual([]) }) it.skipIf(process.platform !== 'win32')('reuses the profile when the launch path changes only Windows letter casing', async () => { const { manager } = setup() - await manager.applyRelease('1.0.0', hooks()) + await manager.applyRelease() const relaunched = new DesktopProjectManager(manager.paths, { ...manager.runtime, dsh: manager.runtime.dsh.toUpperCase() }) - let started = false - await expect(relaunched.applyRelease('1.0.0', hooks({ - afterActivate: async () => { started = true }, - }))).resolves.toBe(false) - expect(started).toBe(false) + await expect(relaunched.applyRelease()).resolves.toBe(false) }) it.each(['changed', 'same-size', 'extra', 'missing'])('starts and reuses a profile without checking %s runtime bytes', async (operation) => { @@ -108,22 +181,16 @@ describe('desktop external plugin profile', () => { if (operation === 'same-size') writeFileSync(join(manager.runtime.dsh, 'package.json'), '{"type":"Module"}\n') if (operation === 'extra') writeFileSync(join(manager.runtime.dsh, 'extra'), '') if (operation === 'missing') unlinkSync(join(manager.runtime.dsh, 'package.json')) - let starts = 0 - await expect(manager.applyRelease('1.0.0', hooks({ - afterActivate: async () => { starts++ }, - }))).resolves.toBe(true) + await expect(manager.applyRelease()).resolves.toBe(true) const relaunched = new DesktopProjectManager(manager.paths, manager.runtime) - await expect(relaunched.applyRelease('1.0.0', hooks({ - afterActivate: async () => { starts++ }, - }))).resolves.toBe(false) - expect(starts).toBe(1) + await expect(relaunched.applyRelease()).resolves.toBe(false) expect(existsSync(manager.paths.profile)).toBe(true) expect(calls(root)).toEqual([]) }) it('installs only plugins and checks the graph before running lifecycle scripts', async () => { const { root, manager } = setup() - await manager.applyRelease('1.0.0', hooks()) + await manager.applyRelease() await manager.mutate({ type: 'plugin-add', spec: '@scope/plugin@2.0.0' }, hooks()) expect(manager.listPlugins()).toEqual([{ name: '@scope/plugin', version: '2.0.0', enabled: true }]) expect(calls(root).map(call => call.args.filter(arg => !arg.startsWith('--config.')))).toEqual([ @@ -136,7 +203,7 @@ describe('desktop external plugin profile', () => { it('retains disabled plugin versions through updates and enables them explicitly', async () => { const { root, manager } = setup() - await manager.applyRelease('1.0.0', hooks()) + await manager.applyRelease() await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks()) await manager.mutate({ type: 'plugins-disable-all' }, hooks()) expect(calls(root)).toHaveLength(2) @@ -151,30 +218,29 @@ describe('desktop external plugin profile', () => { it('keeps plugin files and patches through a compatible release and application relocation', async () => { const { root, manager } = setup() - await manager.applyRelease('1.0.0', hooks()) + await manager.applyRelease() await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks()) writeFileSync(join(manager.paths.profile, 'cordis.patch.yml'), '[]\n') const nextRoot = join(root, 'relocated', 'dsh') runtimeFixture(nextRoot, '1.1.0') const next = new DesktopProjectManager(manager.paths, { ...manager.runtime, dsh: nextRoot }) - await expect(next.applyRelease('1.1.0', hooks())).resolves.toBe(true) + await expect(next.applyRelease()).resolves.toBe(true) expect(next.listPlugins()).toEqual(manager.listPlugins()) expect(next.releaseVersion()).toBe('1.1.0') expect(readFileSync(join(manager.paths.profile, 'cordis.patch.yml'), 'utf8')).toBe('[]\n') expect(calls(root)).toHaveLength(2) expect(realpathSync(join(manager.paths.profile, 'node_modules/@deepseek-ai/cordis'))).toBe(realpathSync(join(nextRoot, 'node_modules/@deepseek-ai/cordis'))) - writeFileSync(join(manager.paths.rollback, 'node_modules/plugin/bundle.yml'), 'rollback only') expect(readFileSync(join(manager.paths.profile, 'node_modules/plugin/bundle.yml'), 'utf8')).toBe('[]\n') }) it('reinstalls the locked plugin graph when bundled Node changes', async () => { const { root, manager } = setup() - await manager.applyRelease('1.0.0', hooks()) + await manager.applyRelease() await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks()) const dsh = join(root, 'new-node') runtimeFixture(dsh, '1.1.0', '24.18.0') const next = new DesktopProjectManager(manager.paths, { ...manager.runtime, dsh }) - await next.applyRelease('1.1.0', hooks()) + await next.applyRelease() expect(calls(root).slice(2).map(call => call.args.filter(arg => !arg.startsWith('--config.')))).toEqual([ ['install', '--frozen-lockfile', '--ignore-scripts'], ['rebuild', '--pending'], ]) @@ -183,60 +249,70 @@ describe('desktop external plugin profile', () => { it('allows incompatible plugins to be disabled in recovery without deleting them', async () => { const { root, manager } = setup() - await manager.applyRelease('1.0.0', hooks()) + await manager.applyRelease() await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks()) const dsh = join(root, 'next-major') runtimeFixture(dsh, '2.0.0') const next = new DesktopProjectManager(manager.paths, { ...manager.runtime, dsh }) - await expect(next.applyRelease('2.0.0', hooks())).rejects.toThrow(/requires @deepseek-ai\/cordis/u) - expect(next.releaseVersion()).toBe('1.0.0') - expect(() =>{ next.assertProfileRuntime(manager.paths.profile) }).toThrow(/does not match/u) + await expect(next.applyRelease()).rejects.toThrow(/requires @deepseek-ai\/cordis/u) + expect(next.releaseVersion()).toBe('2.0.0') await next.mutate({ type: 'plugins-disable-all' }, hooks()) expect(next.releaseVersion()).toBe('2.0.0') expect(next.listPlugins()).toEqual([{ name: 'plugin', version: '1.0.0', enabled: false }]) }) - it.each(['before', 'after'] as const)('keeps the active profile when %s activation fails', async (phase) => { + it.each(['before', 'after'] as const)('retains direct writes when the %s change hook fails', async (phase) => { const { manager } = setup() - await manager.applyRelease('1.0.0', hooks()) + await manager.applyRelease() let starts = 0 await expect(manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks({ - beforeActivate: async () => { if (phase === 'before') throw new Error('before failed') }, - afterActivate: async () => { if (phase === 'after' && starts++ === 0) throw new Error('after failed') }, + beforeChange: async () => { + expect(manager.listPlugins()).toEqual([]) + if (phase === 'before') throw new Error('before failed') + }, + afterChange: async () => { starts++; throw new Error('after failed') }, }))).rejects.toThrow(`${phase} failed`) - expect(manager.listPlugins()).toEqual([]) - expect(existsSync(manager.paths.pending)).toBe(false) + expect(manager.listPlugins()).toEqual(phase === 'before' ? [] : [{ name: 'plugin', version: '1.0.0', enabled: true }]) + expect(starts).toBe(phase === 'before' ? 0 : 1) + expect(existsSync(join(manager.paths.root, 'staging'))).toBe(false) + expect(existsSync(join(manager.paths.root, 'rollback'))).toBe(false) + expect(existsSync(join(manager.paths.root, 'pending.json'))).toBe(false) }) - it('recovers a directory move using recorded runtime identities', async () => { - const { manager } = setup() - await manager.applyRelease('1.0.0', hooks()) - const id = readDesktopProfileState(manager.paths.profile)?.runtimeId - const stagingProfile = join(manager.paths.staging, 'interrupted', 'profile') - mkdirSync(stagingProfile, { recursive: true }) - mkdirSync(dirname(manager.paths.rollback), { recursive: true }) - renameSync(manager.paths.profile, manager.paths.rollback) - writeFileSync(manager.paths.pending, JSON.stringify({ schemaVersion: 1, id: 'interrupted', stagingProfile, fromRuntimeId: id, toRuntimeId: id, step: 'active-moved' })) - manager.recover() - expect(manager.releaseVersion()).toBe('1.0.0') - expect(existsSync(stagingProfile)).toBe(false) - expect(existsSync(manager.paths.pending)).toBe(false) + it('keeps partial package changes and restores host links after pnpm fails', async () => { + const { root, manager } = setup() + await manager.applyRelease() + const failingPnpm = join(root, 'failing.mjs') + writeFileSync(failingPnpm, `await import(${JSON.stringify(pathToFileURL(manager.runtime.pnpm).href)}); process.exitCode = 1`) + const worker = new DesktopProjectManager(manager.paths, { ...manager.runtime, pnpm: failingPnpm }) + await worker.applyRelease() + let starts = 0 + await expect(worker.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks({ + afterChange: async () => { starts++ }, + }))).rejects.toThrow(/pnpm exited with 1/u) + expect(worker.listPlugins()).toEqual([{ name: 'plugin', version: '1.0.0', enabled: false }]) + expect(starts).toBe(0) + expect(existsSync(manager.paths.lock)).toBe(false) + expect(realpathSync(join(manager.paths.profile, 'node_modules/@deepseek-ai/cordis'))) + .toBe(realpathSync(join(manager.runtime.dsh, 'node_modules/@deepseek-ai/cordis'))) + await manager.mutate({ type: 'plugin-remove', name: 'plugin' }, hooks()) + expect(manager.listPlugins()).toEqual([]) }) it('holds the transaction lock until the pnpm worker exits', async () => { const { root, manager } = setup() - await manager.applyRelease('1.0.0', hooks()) + await manager.applyRelease() const ready = join(root, 'ready') const release = join(root, 'release') const blocker = join(root, 'blocking.mjs') writeFileSync(blocker, `import {existsSync, writeFileSync} from 'node:fs'; import {setTimeout as sleep} from 'node:timers/promises'; writeFileSync(${JSON.stringify(ready)}, String(process.pid)); while (!existsSync(${JSON.stringify(release)})) await sleep(10); await import(${JSON.stringify(pathToFileURL(manager.runtime.pnpm).href)})`) const worker = new DesktopProjectManager(manager.paths, { ...manager.runtime, pnpm: blocker }) - await worker.applyRelease('1.0.0', hooks()) + await worker.applyRelease() const pending = worker.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks()) try { await expect.poll(() => existsSync(ready)).toBe(true) expect(readFileSync(manager.paths.lock, 'utf8').trim()).toBe(readFileSync(ready, 'utf8')) - await expect(manager.applyRelease('1.0.0', hooks())).rejects.toThrow(/another package transaction/u) + await expect(manager.applyRelease()).rejects.toThrow(/another package transaction/u) } finally { writeFileSync(release, 'continue') await pending diff --git a/apps/desktop/tests/runtime-tree.spec.ts b/apps/desktop/tests/runtime-tree.spec.ts index 20bfa289a2..04a5145d00 100644 --- a/apps/desktop/tests/runtime-tree.spec.ts +++ b/apps/desktop/tests/runtime-tree.spec.ts @@ -23,34 +23,67 @@ it('verifies a runtime after relocation without depending on build paths', async }) it.each(['changed', 'same-size', 'extra', 'missing'])('checks %s runtime bytes only during build verification', async (operation) => { const dsh = join(fixture(), 'dsh') - const before = readDesktopRuntime(dsh, '1.0.0') + const before = readDesktopRuntime(dsh) if (operation === 'changed') writeFileSync(join(dsh, 'package.json'), '{}') if (operation === 'same-size') writeFileSync(join(dsh, 'package.json'), '{"type":"Module"}\n') if (operation === 'extra') writeFileSync(join(dsh, 'extra'), '') if (operation === 'missing') rmSync(join(dsh, 'package.json')) - expect(readDesktopRuntime(dsh, '1.0.0')).toEqual(before) + expect(readDesktopRuntime(dsh)).toEqual(before) await expect(verifyDesktopRuntime(dsh, '1.0.0')).rejects.toThrow(/integrity/u) }) it('rejects filesystem links and incompatible targets', async () => { const dsh = join(fixture(), 'dsh') await expect(verifyDesktopRuntime(dsh, '1.0.0', { platform: process.platform, arch: 'wrong' })).rejects.toThrow(/incompatible/u) symlinkSync(join(dsh, 'node_modules'), join(dsh, 'outside'), process.platform === 'win32' ? 'junction' : 'dir') - expect(readDesktopRuntime(dsh, '1.0.0').release.version).toBe('1.0.0') + expect(readDesktopRuntime(dsh).release.version).toBe('1.0.0') await expect(verifyDesktopRuntime(dsh, '1.0.0')).rejects.toThrow(/unsupported filesystem/u) }) -it.each(['missing', 'directory'])('rejects a %s Host entry during startup metadata loading', (operation) => { +it('reads file inventory records unchanged during startup', () => { + const dsh = join(fixture(), 'dsh') + const path = join(dsh, DESKTOP_RUNTIME_FILE) + const descriptor = JSON.parse(readFileSync(path, 'utf8')) as { files: unknown[] } + descriptor.files.unshift({ path: '../outside', bytes: -1.5, sha256: 'unchecked', executable: 'unchecked' }) + writeFileSync(path, JSON.stringify(descriptor)) + expect(readDesktopRuntime(dsh).files).toEqual(descriptor.files) +}) +it.each(['missing', 'directory'])('checks a %s Host entry only during build verification', async (operation) => { const dsh = join(fixture(), 'dsh') const path = join(dsh, 'node_modules', DESKTOP_HOST_PACKAGE, DESKTOP_HOST_RUNTIME_FILES[0]) rmSync(path) if (operation === 'directory') mkdirSync(path) - expect(() => readDesktopRuntime(dsh, '1.0.0')).toThrow(/missing Host file/u) + expect(readDesktopRuntime(dsh).release.version).toBe('1.0.0') + await expect(verifyDesktopRuntime(dsh, '1.0.0')).rejects.toThrow(/integrity/u) }) -it('rejects incompatible targets and shared package metadata during startup metadata loading', () => { +it('checks the shell version only during build verification', async () => { const dsh = join(fixture(), 'dsh') - expect(() => readDesktopRuntime(dsh, '2.0.0')).toThrow(/does not match Electron/u) - expect(() => readDesktopRuntime(dsh, '1.0.0', { platform: process.platform, arch: 'wrong' })).toThrow(/incompatible/u) - writeFileSync(join(dsh, 'node_modules', DESKTOP_HOST_PACKAGE, 'package.json'), '{}') - expect(() => readDesktopRuntime(dsh, '1.0.0')).toThrow(/shared package metadata mismatch/u) + expect(readDesktopRuntime(dsh).release.version).toBe('1.0.0') + await expect(verifyDesktopRuntime(dsh, '2.0.0')).rejects.toThrow(/does not match Electron/u) +}) +it.each([ + { schemaVersion: 2 }, + { platform: 'other' }, + { arch: 'other' }, + { release: { schemaVersion: 2 } }, + { release: { hostProtocolVersion: 999 } }, + { release: { nodeVersion: 'invalid' } }, + { release: { pnpmVersion: 'invalid' } }, +])('checks release compatibility only during build verification: %j', async (patch) => { + const dsh = join(fixture(), 'dsh') + const path = join(dsh, DESKTOP_RUNTIME_FILE) + const original = readDesktopRuntime(dsh) + const descriptor = { ...original, ...patch, release: { ...original.release, ...patch.release } } + writeFileSync(path, JSON.stringify(descriptor)) + expect(readDesktopRuntime(dsh)).toEqual(descriptor) + await expect(verifyDesktopRuntime(dsh, '1.0.0')).rejects.toThrow(/invalid|incompatible/u) +}) +it.each(['missing', 'invalid-json', 'mismatched'])('checks %s shared manifests only during build verification', async (operation) => { + const dsh = join(fixture(), 'dsh') + const before = readDesktopRuntime(dsh) + const path = join(dsh, 'node_modules', DESKTOP_HOST_PACKAGE, 'package.json') + if (operation === 'missing') rmSync(path) + else writeFileSync(path, operation === 'invalid-json' ? '{' : '{}') + expect(readDesktopRuntime(dsh)).toEqual(before) + await expect(verifyDesktopRuntime(dsh, '1.0.0')).rejects.toThrow() }) it('rejects a descriptor that maps a shared package outside node_modules', async () => { const dsh = join(fixture(), 'dsh') diff --git a/apps/desktop/tests/startup-renderer.spec.ts b/apps/desktop/tests/startup-renderer.spec.ts index dbbb180ca9..584c9f9587 100644 --- a/apps/desktop/tests/startup-renderer.spec.ts +++ b/apps/desktop/tests/startup-renderer.spec.ts @@ -1,10 +1,12 @@ import { readFileSync } from 'node:fs' import { runInContext } from 'node:vm' +import { fileURLToPath } from 'node:url' import { JSDOM } from 'jsdom' import { expect, it, onTestFinished, vi } from 'vitest' import type { DesktopBackendState } from '../src/backend-controller.ts' import type { DshDesktopStartupApi } from '../src/ipc.ts' import { resolveDesktopLocale } from '../src/locale.ts' +import { startupFailureDocument } from '../src/startup-document.ts' function startup(locale = 'en', status: Promise = Promise.resolve({ phase: 'starting' })) { const dom = new JSDOM(readFileSync(new URL('../renderer/startup.html', import.meta.url), 'utf8'), { runScripts: 'outside-only' }) @@ -16,6 +18,10 @@ function startup(locale = 'en', status: Promise = Promise.r const unsubscribe = vi.fn(() => { listeners.clear() }) const retry = vi.fn(async () => {}) const openPlugins = vi.fn(async () => {}) + const disablePlugins = vi.fn(async () => {}) + const resetConfiguration = vi.fn(async () => {}) + const restart = vi.fn(async () => {}) + const close = vi.fn(async () => {}) const queried = Promise.withResolvers() const api: DshDesktopStartupApi = { protocolVersion: 1, @@ -26,6 +32,7 @@ function startup(locale = 'en', status: Promise = Promise.r subscribe: (listener) => { listeners.add(listener); return unsubscribe }, }, openPlugins, + disablePlugins, resetConfiguration, restart, close, } Object.defineProperty(dom.window, 'dshDesktop', { value: api }) runInContext(readFileSync(new URL('../renderer/startup.js', import.meta.url), 'utf8'), dom.getInternalVMContext()) @@ -42,12 +49,13 @@ function startup(locale = 'en', status: Promise = Promise.r } const publish = (state: DesktopBackendState): void => { for (const listener of listeners) listener(state) } const copy = (): string => [element('#title').textContent, element('#description').textContent, - ...['#error', '#actions'].filter(selector => !element(selector).hidden) + ...['#reset-advice', '#reinstall-advice', '#error', '#actions'].filter(selector => !element(selector).hidden) .flatMap(selector => selector === '#actions' - ? [button('#retry').textContent, button('#plugins').textContent] + ? [...document.querySelectorAll('#actions button')].filter(button => !button.hidden).map(button => button.textContent) : [element(selector).textContent]), ].join('\n') - return { dom, document, element, button, publish, copy, retry, openPlugins, unsubscribe, queried: queried.promise } + return { dom, document, element, button, publish, copy, retry, openPlugins, + disablePlugins, resetConfiguration, restart, close, unsubscribe, queried: queried.promise } } it('shows English loading and recovery actions without a Host document', async () => { @@ -60,22 +68,23 @@ it('shows English loading and recovery actions without a Host document', async ( expect(page.element('main').getAttribute('aria-busy')).toBe('true') expect(page.element('#spinner').hidden).toBe(false) expect(page.element('#actions').hidden).toBe(true) - expect(page.button('#retry').disabled).toBe(true) - expect(page.button('#plugins').disabled).toBe(true) + expect(page.button('#restart').disabled).toBe(true) + expect(page.button('#disable-plugins').disabled).toBe(true) page.publish({ phase: 'error', message: 'Plugin failed to load' }) expect(page.copy()).toMatchInlineSnapshot(` "DeepSeek Harness could not start - The application could not start. Retry or manage Desktop plugins to resolve the problem. + Choose a recovery action below. Disabling third-party plugins retains their files. + Reset Desktop deletes all Desktop profile configuration and third-party plugins without a backup, then starts a fresh profile. Shared tasks and settings are retained. + If application files are missing or damaged, close the application and reinstall it. Your tasks are stored separately. Plugin failed to load - Retry startup - Manage plugins" + Close and restart + Disable all third-party plugins and retry + Reset Desktop and retry" `) expect(page.element('main').getAttribute('aria-busy')).toBe('false') expect(page.element('#spinner').hidden).toBe(true) - page.button('#plugins').click() - expect(page.openPlugins).toHaveBeenCalledOnce() - page.button('#retry').click() - expect(page.retry).toHaveBeenCalledOnce() + page.button('#restart').click() + expect(page.restart).toHaveBeenCalledOnce() expect(page.element('#actions').hidden).toBe(true) expect(page.element('#error').textContent).toBe('') expect(page.element('main').getAttribute('aria-busy')).toBe('true') @@ -92,10 +101,13 @@ it('shows Chinese loading and recovery copy', async () => { page.publish({ phase: 'error', message: '插件加载失败' }) expect(page.copy()).toMatchInlineSnapshot(` "DeepSeek Harness 无法启动 - 应用未能启动。你可以重试,或管理桌面插件以解决问题。 + 请选择下方的恢复操作。禁用第三方插件会保留插件文件。 + 重置 Desktop 会删除桌面端的全部 profile 配置和第三方插件,不保留备份,然后重新初始化并启动。共享任务和设置会保留。 + 如果应用文件缺失或损坏,请关闭应用并重新安装。任务数据存储在独立位置。 插件加载失败 - 重试启动 - 管理插件" + 关闭并重启 + 禁用全部第三方插件并重试 + 重置 Desktop 并重试" `) }) @@ -106,14 +118,11 @@ it('renders diagnostic markup as text and exposes failures from recovery actions page.publish({ phase: 'error', message: diagnostic }) expect(page.element('#error').textContent).toBe(diagnostic) expect(page.element('#error').childElementCount).toBe(0) - page.openPlugins.mockRejectedValueOnce(new page.dom.window.Error('Cannot open plugin manager')) - page.button('#plugins').click() - await expect.poll(() => page.element('#error').textContent).toBe('Cannot open plugin manager') - page.retry.mockRejectedValueOnce(new page.dom.window.Error('Retry failed')) - page.button('#retry').click() + page.restart.mockRejectedValueOnce(new page.dom.window.Error('Retry failed')) + page.button('#restart').click() await expect.poll(() => page.element('#error').textContent).toBe('Retry failed') - expect(page.button('#retry').disabled).toBe(false) - expect(page.button('#plugins').disabled).toBe(false) + expect(page.button('#restart').disabled).toBe(false) + expect(page.button('#disable-plugins').disabled).toBe(false) }) it('keeps subscribed state when initial status arrives late and detaches on pagehide', async () => { @@ -132,3 +141,34 @@ it('keeps subscribed state when initial status arrives late and detaches on page page.dom.window.dispatchEvent(new page.dom.window.Event('pagehide')) expect(page.unsubscribe).toHaveBeenCalledOnce() }) + +it.each(['en', 'zh-CN'])('offers recovery actions with %s guidance and preserves diagnostic text', async (locale) => { + const page = startup(locale) + await expect.poll(() => page.button('#restart').disabled).toBe(true) + for (const recovery of ['plugins', 'configuration', 'reinstall', 'restart'] as const) { + page.publish({ phase: 'error', recovery, message: 'Failure details' }) + await expect(`${page.copy()}\n`).toMatchFileSnapshot(fileURLToPath(new URL(`./expected/startup-${locale}-${recovery}.txt`, import.meta.url))) + expect(page.button('#disable-plugins').hidden).toBe(false) + expect(page.button('#reset-configuration').hidden).toBe(false) + const action = recovery === 'plugins' ? '#disable-plugins' : recovery === 'configuration' + ? '#reset-configuration' : '#restart' + page.button(action).click() + expect(page.element('#actions').hidden).toBe(true) + } + expect(page.disablePlugins).toHaveBeenCalledOnce() + expect(page.resetConfiguration).toHaveBeenCalledOnce() + expect(page.close).not.toHaveBeenCalled() + expect(page.restart).toHaveBeenCalledTimes(2) +}) + +it('keeps emergency diagnostics inert without shell assets', () => { + const html = startupFailureDocument(resolveDesktopLocale('zh-CN'), '') + const dom = new JSDOM(html) + expect(dom.window.document.querySelector('script')).toBeNull() + expect(dom.window.document.querySelector('pre')?.textContent).toBe('') + expect(dom.window.document.querySelector('p')?.textContent).toContain('重新安装') + expect([...dom.window.document.querySelectorAll('form')].map(form => form.action)).toEqual([ + 'dsh-recovery://restart', 'dsh-recovery://plugins', 'dsh-recovery://reset', + ]) + dom.window.close() +}) From 58956c1a8a3643677fe2820a8a40f3aee5612b39 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 9 Sep 2026 22:01:10 +0800 Subject: [PATCH 064/111] Revert "feat(web): preview Mermaid, Graphviz, SVG, and HTML code fences" --- ...9-content-sized-diagram-previews.i18n.yaml | 6 - ...26-09-09-content-sized-diagram-previews.md | 25 - ...09-09-content-sized-diagram-previews.zh.md | 25 - ...6-09-09-markdown-static-previews.i18n.yaml | 6 - .../2026-09-09-markdown-static-previews.md | 43 -- .../2026-09-09-markdown-static-previews.zh.md | 43 -- THIRD_PARTY_NOTICES.md | 6 +- .../expected/markdown-mermaid/ui.expected.md | 24 - .../expected/markdown-mermaid/zh.expected.md | 23 - apps/web/tests/markdown-mermaid.e2e.ts | 320 -------- apps/web/tsconfig.json | 1 - apps/web/vite.config.ts | 16 +- packages/client/locale/src/locales/en.ts | 10 - packages/client/locale/src/locales/zh.ts | 10 - packages/client/ui-chat/README.i18n.yaml | 4 +- packages/client/ui-chat/README.md | 8 - packages/client/ui-chat/README.zh.md | 8 - .../ui-chat/src/client/markdown-labels.ts | 13 - .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 10 - packages/client/ui-primitives/README.zh.md | 10 - .../THIRD_PARTY_PREVIEW_NOTICES.txt | 688 ------------------ packages/client/ui-primitives/package.json | 6 +- .../src/markdown/CodeBlock.module.css | 84 --- .../ui-primitives/src/markdown/CodeBlock.tsx | 53 +- .../src/markdown/SourcePreview.module.css | 35 - .../src/markdown/SourcePreview.tsx | 71 -- .../ui-primitives/src/markdown/graphviz.ts | 37 - .../ui-primitives/src/markdown/mermaid.ts | 97 --- .../src/markdown/preview-document.ts | 44 -- .../src/markdown/preview-theme.ts | 27 - .../ui-primitives/src/markdown/render.tsx | 30 - .../tests/code-block.client.spec.tsx | 2 +- .../tests/fence-previews.client.spec.tsx | 126 ---- .../tests/graphviz-runtime.client.spec.ts | 67 -- .../tests/mermaid-runtime.client.spec.ts | 133 ---- .../tests/source-preview.client.spec.tsx | 162 ----- pnpm-lock.yaml | 14 - scripts/check-workspace-constraints.spec.ts | 14 - scripts/check-workspace-constraints.ts | 2 +- scripts/gen-third-party-notices.spec.ts | 24 - scripts/gen-third-party-notices.ts | 34 - tsconfig.host.json | 1 - 43 files changed, 20 insertions(+), 2346 deletions(-) delete mode 100644 .agents/notes/implemented/bug-fix/2026-09-09-content-sized-diagram-previews.i18n.yaml delete mode 100644 .agents/notes/implemented/bug-fix/2026-09-09-content-sized-diagram-previews.md delete mode 100644 .agents/notes/implemented/bug-fix/2026-09-09-content-sized-diagram-previews.zh.md delete mode 100644 .agents/notes/implemented/feature/2026-09-09-markdown-static-previews.i18n.yaml delete mode 100644 .agents/notes/implemented/feature/2026-09-09-markdown-static-previews.md delete mode 100644 .agents/notes/implemented/feature/2026-09-09-markdown-static-previews.zh.md delete mode 100644 apps/web/tests/expected/markdown-mermaid/ui.expected.md delete mode 100644 apps/web/tests/expected/markdown-mermaid/zh.expected.md delete mode 100644 apps/web/tests/markdown-mermaid.e2e.ts delete mode 100644 packages/client/ui-primitives/THIRD_PARTY_PREVIEW_NOTICES.txt delete mode 100644 packages/client/ui-primitives/src/markdown/SourcePreview.module.css delete mode 100644 packages/client/ui-primitives/src/markdown/SourcePreview.tsx delete mode 100644 packages/client/ui-primitives/src/markdown/graphviz.ts delete mode 100644 packages/client/ui-primitives/src/markdown/mermaid.ts delete mode 100644 packages/client/ui-primitives/src/markdown/preview-document.ts delete mode 100644 packages/client/ui-primitives/src/markdown/preview-theme.ts delete mode 100644 packages/client/ui-primitives/tests/fence-previews.client.spec.tsx delete mode 100644 packages/client/ui-primitives/tests/graphviz-runtime.client.spec.ts delete mode 100644 packages/client/ui-primitives/tests/mermaid-runtime.client.spec.ts delete mode 100644 packages/client/ui-primitives/tests/source-preview.client.spec.tsx diff --git a/.agents/notes/implemented/bug-fix/2026-09-09-content-sized-diagram-previews.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-09-content-sized-diagram-previews.i18n.yaml deleted file mode 100644 index be4173fd02..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-09-09-content-sized-diagram-previews.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-09-content-sized-diagram-previews.md -2026-09-09-content-sized-diagram-previews.md: ceb29238eec09f2dde312835dbf376855998a507 -2026-09-09-content-sized-diagram-previews.zh.md: 2689602bfe3469f53e532367877f7e49d52d3d5d diff --git a/.agents/notes/implemented/bug-fix/2026-09-09-content-sized-diagram-previews.md b/.agents/notes/implemented/bug-fix/2026-09-09-content-sized-diagram-previews.md deleted file mode 100644 index ceb29238ee..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-09-09-content-sized-diagram-previews.md +++ /dev/null @@ -1,25 +0,0 @@ -# Agent Note: Content-sized diagram previews - -Status: implemented - -English | [中文](2026-09-09-content-sized-diagram-previews.zh.md) - -## Problem - -Short Graphviz and SVG images leave large empty regions when displayed inside 400px frames. A 59px Graphviz diagram occupies a 400px preview even though its rendered dimensions are already available to the browser. - -## Decision - -Mermaid, Graphviz, and SVG share the inert image canvas in `SourcePreview`. The browser derives its height from the image, preserves aspect ratio when shrinking to the available width, and adds 16px padding. Source switching retains the image and copying reads the original code. Theme changes regenerate Graphviz colors without introducing a separate sizing observer. - -This replaces the diagram-frame choice in [Static Markdown fence previews](../feature/2026-09-09-markdown-static-previews.md); that note continues to own HTML isolation, rendering lifecycle, and license obligations. HTML retains its opaque, script-free, 400px frame. SVG image mode disables scripts, link interaction, and external resource loading without inserting source-controlled markup into the application document. - -## Alternatives considered - -**Set iframe height to auto.** An iframe does not derive its outer height from its inner document. This still reserves an unrelated viewport. - -**Add a frame measurement script or same-origin access.** A diagram already has image dimensions. Extra execution or origin permissions and resize messaging are unnecessary for that content. - -## Consequences - -Short diagrams occupy only their rendered height and canvas padding. The browser geometry regression compares image and container height in light and dark modes, then verifies proportional scaling in a narrow viewport. Browser security checks include SVG scripts, event handlers, nested HTML, and external images; copy and source toggling remain covered. HTML automatic height remains outside this change because arbitrary document layout has different measurement and isolation requirements. diff --git a/.agents/notes/implemented/bug-fix/2026-09-09-content-sized-diagram-previews.zh.md b/.agents/notes/implemented/bug-fix/2026-09-09-content-sized-diagram-previews.zh.md deleted file mode 100644 index 2689602bfe..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-09-09-content-sized-diagram-previews.zh.md +++ /dev/null @@ -1,25 +0,0 @@ -# Agent Note: 按内容定高的图表预览 - -Status: implemented - -[English](2026-09-09-content-sized-diagram-previews.md) | 中文 - -## Problem - -较矮的 Graphviz 与 SVG 图片显示在 400px iframe 中时,会留下大块空白。浏览器已经知道图片的渲染尺寸,但一张 59px 高的 Graphviz 图表仍占据 400px 的预览区域。 - -## Decision - -Mermaid、Graphviz 与 SVG 共享 `SourcePreview` 的不可执行图片画布。浏览器根据图片决定高度,在可用宽度不足时按比例缩小,并添加 16px 内边距。源码切换保留图片,复制读取原始代码。主题变化重新生成 Graphviz 配色,无需单独的尺寸观察器。 - -这替代了[静态 Markdown fence 预览](../feature/2026-09-09-markdown-static-previews.zh.md)中的图表 iframe 选择;该记录继续维护 HTML 隔离、渲染生命周期与许可证义务。HTML 保留不透明来源、禁用脚本的 400px iframe。SVG 图片模式禁用脚本、链接交互与外部资源加载,无需把源码控制的标记插入应用文档。 - -## Alternatives considered - -**把 iframe 高度设为 auto。** iframe 不会根据内部文档决定外部高度,仍然会保留与内容无关的视口。 - -**添加 iframe 测量脚本或同源访问。** 图表已经具备图片尺寸,不需要额外执行权限、来源权限或尺寸消息协议。 - -## Consequences - -较矮图表只占据渲染高度与画布内边距。浏览器几何回归用例在浅色和深色下比较图片与容器高度,再验证窄视口中的等比例缩放。浏览器安全检查包含 SVG 脚本、事件处理器、嵌套 HTML 与外部图片,并保留复制和源码切换覆盖。HTML 自动高度不属于本次改动,因为任意文档布局具有不同的测量与隔离要求。 diff --git a/.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.i18n.yaml b/.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.i18n.yaml deleted file mode 100644 index 8a24afcd59..0000000000 --- a/.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-09-markdown-static-previews.md -2026-09-09-markdown-static-previews.md: f3a0363e3d7c4cb19ca67dfc5011e936f7c024cf -2026-09-09-markdown-static-previews.zh.md: 24f87cd73504c1fbd274e1420a3692b3ca55ed5e diff --git a/.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.md b/.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.md deleted file mode 100644 index f3a0363e3d..0000000000 --- a/.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.md +++ /dev/null @@ -1,43 +0,0 @@ -# Agent Note: Static Markdown fence previews - -Status: implemented - -English | [中文](2026-09-09-markdown-static-previews.zh.md) - -## Problem - -Readers need to see Mermaid and DOT diagrams, SVG artwork, and HTML examples directly in Assistant replies without interpreting source or copying it to another renderer. These sources are untrusted, and a renderer's npm license field may omit compiled components with separate distribution obligations. - -## Decision - -The shared Markdown renderer enables settled `mermaid`, `graphviz`/`dot`, `svg`, and `html` fences through localized `MarkdownLabels.preview` and the parsed fence language. The [UI primitives package](../../../../packages/client/ui-primitives/README.md) owns `SourcePreview`, which accepts a renderer, source, and labels without Session, file, or Cordis dependencies. Mermaid and Graphviz each supply a `.ts` renderer to that same component. Consumers without preview labels and streaming messages retain code. - -`CodeBlock.preview` owns default visualization, source switching, and verbatim copying. Previews omit the language banner and expose compact icon actions on hover or keyboard focus; devices with any touch input keep actions visible below the diagram. Switching to source hides the mounted preview, preserving completed rendering and pending work. Both views use the same focused toggle button, and copying always reads the source prop. - -`SourcePreview` owns loading, failure, and cancellation of stale result publication. Source replacement and unmounting cancel publication; cancellation before runtime loading completes skips layout. Failures show a localized error and the original source, and replacing invalid source with valid input recovers the preview. - -Mermaid loads on demand. A shared queue serializes theme initialization with diagram work, and each call removes its temporary measurement DOM in `finally`. Strict security, disabled HTML labels, the application palette, and error-rendering policy cannot be overridden by diagram configuration. Generated SVG is displayed as an image without installing links or scripts. Intrinsic dimensions come from the SVG viewBox; large diagrams shrink to fit, and the canvas follows the code-block background. Mounted previews observe document theme attributes and regenerate only when resolved colors change; obsolete renders cannot publish. Graphviz default colors follow the same palette, while authored DOT, SVG, and HTML colors remain intact. - -HTML uses DOMPurify with navigation attributes and document-loading elements forbidden, followed by an opaque `sandbox=""` iframe. A trusted CSP precedes source markup and permits only inline CSS, data images, and data fonts. Scripts, external resources, child frames, form submission, and same-origin access are unavailable. SVG is parsed as XML and displayed with Graphviz output as inert images in a [content-sized canvas](../bug-fix/2026-09-09-content-sized-diagram-previews.md), so SVG scripts and links never become active. HTML frames have a fixed scrollable viewport: measuring their content would require additional origin access or trusted frame scripts. - -Graphviz uses the pinned, unmodified `@viz-js/viz` 3.30.0 WebAssembly distribution with the `dot` layout engine. Its npm MIT declaration covers the wrapper; its build provenance identifies Graphviz 16.0.0 (EPL-2.0), Expat 2.8.4 (MIT), and Emscripten 5.0.7 (MIT/NCSA). Distribution retains these component terms and the exact Graphviz source download under EPL-2.0 section 3.1. [Full preview notices](../../../../packages/client/ui-primitives/THIRD_PARTY_PREVIEW_NOTICES.txt) also retain Mermaid's MIT text and select Apache-2.0 for DOMPurify. The UI primitives package ships the notices and the Web build emits the same bytes alongside its assets. License review covers embedded payloads separately from the general permissive npm-metadata policy. - -## Alternatives considered - -**Keep the code banner above diagrams.** A language label and permanent text controls distract from the diagram. Overlay actions retain source access without reserving a title row. - -**Render each streamed chunk.** Incomplete diagrams are frequently invalid, and repeated layout work competes with text streaming. Message settlement provides complete source. - -**Put rendering inside Chat or add a general preview registry.** A shared primitive with plain props satisfies reuse without another registry or feature-plugin dependency. This follows the [shared-control rule](../architecture/2026-09-05-shared-client-control-primitives.md). - -**Execute HTML scripts in a frame.** Static chat examples do not need script execution. An empty sandbox plus sanitization and CSP gives the preview fewer capabilities and avoids introducing a frame messaging protocol. - -**Insert rendered markup into Chat.** The requested preview needs diagram display and source access; HTML styles and executable SVG would share the application document. Iframes isolate layout and origin; SVG image mode additionally disables SVG behavior. - -**Treat Viz.js as MIT-only or use a remote Graphviz service.** The compiled Graphviz license still applies locally; a remote renderer would send conversation content off-device. The bundled renderer retains source availability and legal notices without network rendering. - -## Consequences - -The feature changes presentation without changing persisted messages, provider requests, tools, or Host APIs. Inline HTML styles work, while interactive scripts, external assets, and links do not. Mermaid and Graphviz add lazy browser assets and run layout on the browser thread. Cancellation cannot preempt active layout; Mermaid finishes and releases its measurement DOM even when its result cannot be published. The previews have no editing, export, zoom controls, or interactive diagram links. - -Component tests cover source copying, default preview, delayed completion, stale success and failure, unmounting, fallback, cancellation, runtime loading failure, and recovery. The keyless [browser scenario](../../../../apps/web/tests/markdown-mermaid.e2e.ts) verifies Chinese Mermaid flowcharts, sequence diagrams, malformed source, configuration overrides, opaque frames, real image decoding, source toggling, blocked script/navigation/resource attempts, English/Chinese UI snapshots, and served license text. Notices checks pin the reviewed wrapper and native provenance so upgrades require renewed review. diff --git a/.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.zh.md b/.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.zh.md deleted file mode 100644 index 24f87cd735..0000000000 --- a/.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.zh.md +++ /dev/null @@ -1,43 +0,0 @@ -# Agent Note: 静态 Markdown fence 预览 - -Status: implemented - -[English](2026-09-09-markdown-static-previews.md) | 中文 - -## Problem - -读者需要直接在 Assistant 回复中查看 Mermaid 和 DOT 图表、SVG 图像和 HTML 示例,而不必解读源码或复制到其他渲染器。这些源码不可信,而渲染器的 npm 许可证字段可能遗漏编译组件各自的分发义务。 - -## Decision - -共享 Markdown 渲染器通过本地化的 `MarkdownLabels.preview` 和解析后的 fence 语言启用定稿后的 `mermaid`、`graphviz`/`dot`、`svg` 和 `html` fence。[UI primitives 包](../../../../packages/client/ui-primitives/README.zh.md)拥有 `SourcePreview`,它接收渲染器、源码和文案,不依赖 Session、文件或 Cordis。Mermaid 与 Graphviz 各自向同一组件提供 `.ts` 渲染器。未提供预览文案的调用方与流式消息保留代码显示。 - -`CodeBlock.preview` 负责默认可视化、源码切换与原样复制。预览省略语言横幅,在悬停或键盘聚焦时显示紧凑的图标操作;具有任何触控输入的设备始终在图表下方显示操作。切换到源码会隐藏已挂载的预览,保留完成的渲染和待完成的工作。两种视图使用同一个保持焦点的切换按钮,复制始终读取源码属性。 - -`SourcePreview` 负责加载、失败和取消过期结果发布。替换源码和卸载组件会取消结果发布;在运行时加载完成前取消会跳过布局。失败时显示本地化错误与原始源码,用有效输入替换无效源码后可以恢复预览。 - -Mermaid 按需加载。共享队列将主题初始化与图表渲染一起串行执行,每次调用都在 `finally` 中删除临时测量 DOM。图表配置无法覆盖严格安全模式、禁用 HTML 标签、应用配色和错误渲染策略。生成的 SVG 作为图片显示,不安装链接或脚本。固有尺寸来自 SVG viewBox;大图缩小以适应宽度,画布使用代码块背景。已挂载的预览观察文档主题属性,仅在解析后的配色变化时重新生成;过期渲染不能发布结果。Graphviz 的默认颜色采用同一配色,DOT、SVG 和 HTML 中明确指定的颜色保持原样。 - -HTML 先由 DOMPurify 清理,禁止导航属性与文档加载元素,再进入不透明来源的 `sandbox=""` iframe。可信 CSP 位于源码标记之前,仅允许内联 CSS、data 图片与 data 字体。脚本、外部资源、子 iframe、表单提交与同源访问均不可用。SVG 按 XML 解析后,与 Graphviz 输出一同作为不可执行图片显示在[按内容定高的画布](../bug-fix/2026-09-09-content-sized-diagram-previews.zh.md)中,因此 SVG 脚本与链接不会激活。HTML iframe 使用固定的可滚动视口:测量内容需要额外的来源访问权限或可信 iframe 脚本。 - -Graphviz 使用固定且未修改的 `@viz-js/viz` 3.30.0 WebAssembly 发布包,布局引擎为 `dot`。其 npm MIT 声明覆盖包装层;构建来源记录标明 Graphviz 16.0.0(EPL-2.0)、Expat 2.8.4(MIT)与 Emscripten 5.0.7(MIT/NCSA)。分发保留各组件条款,并按 EPL-2.0 第 3.1 节提供准确的 Graphviz 源码下载地址。[完整预览声明](../../../../packages/client/ui-primitives/THIRD_PARTY_PREVIEW_NOTICES.txt)还保留 Mermaid 的 MIT 文本,并为 DOMPurify 选择 Apache-2.0。UI primitives 包携带该声明,Web 构建在资源旁输出相同字节。内嵌产物的许可证检查独立于通用的宽松 npm 元数据策略。 - -## Alternatives considered - -**在图表上方保留代码横幅。** 语言标签和常驻文字控件会分散对图表的注意力。浮层操作保留源码访问,不占用标题行。 - -**渲染每个流式片段。** 未完成的图表通常无效,反复布局会与文本流式输出争用资源。消息定稿可提供完整源码。 - -**在 Chat 内部渲染,或添加通用预览注册表。** 使用普通属性的共享基础组件即可满足复用,无需另加注册表或功能插件依赖。这遵循[共享控件规则](../architecture/2026-09-05-shared-client-control-primitives.zh.md)。 - -**在 iframe 内执行 HTML 脚本。** 静态聊天示例不需要脚本执行。空 sandbox、清理与 CSP 降低预览权限,也避免引入 iframe 消息协议。 - -**把渲染标记插入 Chat。** 所需预览只需显示图表和访问源码;HTML 样式和可执行 SVG 会与应用共享文档。iframe 隔离布局与来源,SVG 图片模式额外禁用 SVG 行为。 - -**将 Viz.js 视为仅使用 MIT,或使用远程 Graphviz 服务。** 本地编译的 Graphviz 仍受其许可证约束;远程渲染会把对话内容发送到设备之外。内置渲染器保留源码可获取性与法律声明,无需网络渲染。 - -## Consequences - -该功能改变呈现,不改变持久化消息、provider 请求、工具或 Host API。HTML 内联样式可用,交互脚本、外部资源和链接不可用。Mermaid 和 Graphviz 增加按需加载的浏览器资源,并在浏览器线程上执行布局。取消无法抢占进行中的布局;即使结果无法发布,Mermaid 仍会完成并释放测量 DOM。预览不提供编辑、导出、缩放控件或交互式图表链接。 - -组件测试覆盖源码复制、默认预览、延迟完成、过期成功与失败、卸载、回退、取消、运行时加载失败与恢复。无密钥[浏览器场景](../../../../apps/web/tests/markdown-mermaid.e2e.ts)验证中文 Mermaid 流程图、时序图、无效源码、配置覆盖、不透明来源 iframe、真实图片解码、源码切换、脚本/导航/资源请求被阻止、中英文 UI 快照和已提供的许可证文本。声明检查固定已审查的包装层与原生构建来源,升级时须重新审查。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 2452325d62..31eebdab8d 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -55,7 +55,6 @@ External packages installed for runtime use or distributed inside the prebuilt b | [`@shikijs/langs`](https://github.com/shikijs/shiki) | MIT | | [`@standard-schema/spec`](https://github.com/standard-schema/standard-schema) | MIT | | [`@tanstack/react-virtual`](https://github.com/TanStack/virtual) | MIT | -| [`@viz-js/viz`](https://github.com/mdaines/viz-js) | MIT | | [`@vscode/ripgrep`](https://github.com/microsoft/vscode-ripgrep) | MIT | | [`@xterm/headless`](https://github.com/xtermjs/xterm.js) | MIT | | [`@yarnpkg/parsers`](https://github.com/yarnpkg/berry) | BSD-2-Clause | @@ -67,7 +66,6 @@ External packages installed for runtime use or distributed inside the prebuilt b | [`commander`](https://github.com/tj/commander.js) | MIT | | [`compression`](https://github.com/expressjs/compression) | MIT | | [`diff`](https://github.com/kpdecker/jsdiff) | BSD-3-Clause | -| [`dompurify`](https://github.com/cure53/DOMPurify) | (MPL-2.0 OR Apache-2.0) | | [`e2b`](https://github.com/e2b-dev/e2b) | MIT | | [`electron-updater`](https://github.com/electron-userland/electron-builder) | MIT | | [`eventsource-parser`](https://github.com/rexxars/eventsource-parser) | MIT | @@ -81,7 +79,6 @@ External packages installed for runtime use or distributed inside the prebuilt b | [`mdast-util-from-markdown`](https://github.com/syntax-tree/mdast-util-from-markdown) | MIT | | [`mdast-util-gfm`](https://github.com/syntax-tree/mdast-util-gfm) | MIT | | [`mdast-util-math`](https://github.com/syntax-tree/mdast-util-math) | MIT | -| [`mermaid`](https://github.com/mermaid-js/mermaid) | MIT | | [`micromark-core-commonmark`](https://github.com/micromark/micromark/tree/main/packages/micromark-core-commonmark) | MIT | | [`micromark-extension-gfm`](https://github.com/micromark/micromark-extension-gfm) | MIT | | [`micromark-extension-math`](https://github.com/micromark/micromark-extension-math) | MIT | @@ -115,8 +112,6 @@ External packages installed for runtime use or distributed inside the prebuilt b | [`zod`](https://github.com/colinhacks/zod) | MIT | | [`zustand`](https://github.com/pmndrs/zustand) | MIT | -The Markdown preview distribution also contains Graphviz 16.0.0 (EPL-2.0), Expat 2.8.4 (MIT), and Emscripten 5.0.7 runtime code (MIT/NCSA) inside `@viz-js/viz` 3.30.0. The wrapper's MIT metadata does not relicense these components. [Preview notices](packages/client/ui-primitives/THIRD_PARTY_PREVIEW_NOTICES.txt) preserve their full license texts and Graphviz source availability, together with Mermaid's MIT and DOMPurify's selected Apache-2.0 terms. The UI primitives npm package includes this file; the Web build emits it as `preview-third-party-notices.txt`. - pnpm applies local patches to the following packages at install time, so shipped artifacts carry modified copies; each patch file is the complete record of the modification: - `@yao-pkg/pkg@6.21.0` — [`patches/@yao-pkg__pkg@6.21.0.patch`](patches/@yao-pkg__pkg@6.21.0.patch) @@ -194,6 +189,7 @@ External packages **directly declared** for development, tests, types, or toolin | [`jsdom`](https://github.com/jsdom/jsdom) | MIT | | [`lefthook`](https://github.com/evilmartians/lefthook) | MIT | | [`lightningcss`](https://github.com/parcel-bundler/lightningcss) | MPL-2.0 | +| [`mermaid`](https://github.com/mermaid-js/mermaid) | MIT | | [`micromark-util-types`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-types) | MIT | | [`msgpackr`](http://github.com/kriszyp/msgpackr) | MIT | | [`oxlint`](https://github.com/oxc-project/oxc) | MIT | diff --git a/apps/web/tests/expected/markdown-mermaid/ui.expected.md b/apps/web/tests/expected/markdown-mermaid/ui.expected.md deleted file mode 100644 index 985f68fee0..0000000000 --- a/apps/web/tests/expected/markdown-mermaid/ui.expected.md +++ /dev/null @@ -1,24 +0,0 @@ -- heading "Mermaid previews" [level=1] -- button "Source" -- button "Copy" -- tooltip "Copy" -- img "Mermaid diagram" -- button "Source" -- button "Copy" -- img "Mermaid diagram" -- button "Source" -- button "Copy" -- status: Unable to render this diagram. The source is shown below. -- code: flowchart LR A[unfinished -- button "Source" -- button "Copy" -- img "Mermaid diagram" -- button "Source" -- button "Copy" -- img "Graphviz diagram" -- button "Source" -- button "Copy" -- img "SVG preview" -- button "Source" -- button "Copy" -- iframe diff --git a/apps/web/tests/expected/markdown-mermaid/zh.expected.md b/apps/web/tests/expected/markdown-mermaid/zh.expected.md deleted file mode 100644 index 52d7053ded..0000000000 --- a/apps/web/tests/expected/markdown-mermaid/zh.expected.md +++ /dev/null @@ -1,23 +0,0 @@ -- heading "Mermaid previews" [level=1] -- button "源码" -- button "复制" -- img "Mermaid 图表" -- button "源码" -- button "复制" -- img "Mermaid 图表" -- button "源码" -- button "复制" -- status: 无法渲染此图表,源码如下。 -- code: flowchart LR A[unfinished -- button "源码" -- button "复制" -- img "Mermaid 图表" -- button "源码" -- button "复制" -- img "Graphviz 图表" -- button "源码" -- button "复制" -- img "SVG 预览" -- button "源码" -- button "复制" -- iframe diff --git a/apps/web/tests/markdown-mermaid.e2e.ts b/apps/web/tests/markdown-mermaid.e2e.ts deleted file mode 100644 index 95249740bf..0000000000 --- a/apps/web/tests/markdown-mermaid.e2e.ts +++ /dev/null @@ -1,320 +0,0 @@ -import { fileURLToPath } from 'node:url' -import { join } from 'node:path' -import type { Browser, Page } from 'playwright' -import { chromium } from 'playwright' -import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' -import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm' -import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session' -import type {} from '@deepseek-ai/dsh-session-title' -import { - assertFixtureInventory, captureStableAria, compareOrRefreshGolden, launchWebScaffold, - seedSession, watchConsole, webSnapshotMode, type WebScaffold, -} from './scaffold.ts' -import { newEnglishPage, saveFailureShot } from './support.ts' - -const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/markdown-mermaid', import.meta.url)) -const MODE = webSnapshotMode() -const SEED_ID = 'markdown-mermaid-web-e2e' -const DIAGRAM_MESSAGE = '[class*="markdown"]:has(.md-code-block)' -const FLOW = 'flowchart LR\n A[输入] --> B[共享渲染器] --> C[图形预览]' -const SEQUENCE = 'sequenceDiagram\n participant U as User\n participant R as Renderer\n U->>R: Mermaid source\n R-->>U: Diagram' -const INVALID = 'flowchart LR\n A[unfinished' -const UNTRUSTED = [ - '%%{init: {"securityLevel":"loose","htmlLabels":true,"themeCSS":"body {display:none!important}"}}%%', - 'flowchart LR', - ' A[""] --> B[Safe]', - ' click B "javascript:alert(1)"', -].join('\n') - -const DOT = 'digraph { rankdir=LR; Input -> Preview }' -const SVG = ` -SVG preview - - -
-
` -const HTML = `

Static HTML

- - -Disabled navigation - -` - -function fixture(): string { - const session = Session.create(SessionId('markdown-mermaid-source')) - session.append('turn/start', { turn: 1 }) - const user = session.append('user/message', createUserMessage({ - content: [{ type: 'text', text: 'Preview these Mermaid diagrams.' }], source: { kind: 'user' }, - }), { surfaceOp: 'append' }) - session.append('session/title', { title: 'Mermaid previews', messageSeqs: [user.seq], source: { kind: 'fallback' } }) - session.append('step/start', { turn: 1, step: 1 }) - session.append('assistant/message', { - stream: [], turn: 1, step: 1, - message: createMessage({ - role: 'assistant', - content: [{ type: 'text', text: [ - '# Mermaid previews', - ...[FLOW, SEQUENCE, INVALID, UNTRUSTED].map(code => `\`\`\`mermaid\n${code}\n\`\`\``), - ...[['dot', DOT], ['svg', SVG], ['html', HTML]].map(([lang, code]) => `\`\`\`${lang}\n${code}\n\`\`\``), - ].join('\n\n') }], - source: { kind: 'model', provider: 'fixture', model: 'fixture' }, - }), - }, { surfaceOp: 'append' }) - session.append('step/end', { turn: 1, step: 1 }) - session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - const noon = new Date().setHours(12, 0, 0, 0) - return [ - JSON.stringify({ - type: 'session', version: SESSION_FORMAT_VERSION, id: '{{sessionId}}', createdAt: 0, - cwd: '{{cwd}}', isSeeded: false, delegationDepth: 0, - }), - ...session.snapshotEvents().map(event => JSON.stringify({ ...event, time: noon + event.seq * 1000 })), - '', - ].join('\n') -} - -async function openConversation(page: Page, scaffold: WebScaffold): Promise { - await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) - await page.getByRole('treeitem').first().click({ timeout: 30_000 }) - await page.getByRole('treeitem').nth(1).click() - await page.getByRole('heading', { name: 'Mermaid previews' }).waitFor() -} - -describe('web e2e: Mermaid chat previews', () => { - let scaffold: WebScaffold - let browser: Browser - beforeAll(async () => { - scaffold = await launchWebScaffold({}) - await seedSession(scaffold, fixture(), SEED_ID) - browser = await chromium.launch() - }, 120_000) - afterAll(async () => { - await browser?.close() - await scaffold?.close() - }) - - it.skipIf(MODE === 'record').each(['light', 'dark'] as const)( - 'tightly wraps short diagram previews in %s mode', async (scheme) => { - const page = await newEnglishPage(browser) - try { - await page.emulateMedia({ colorScheme: scheme }) - await openConversation(page, scaffold) - await expect.poll(() => page.evaluate(() => getComputedStyle(document.documentElement).colorScheme)).toBe(scheme) - - const mermaid = page.getByRole('img', { name: 'Mermaid diagram' }).first() - await mermaid.evaluate(async (node: HTMLImageElement) => { await node.decode() }) - const mermaidHeight = await mermaid.evaluate(node => node.getBoundingClientRect().height) - const canvasHeight = await mermaid.evaluate(node => node.parentElement!.getBoundingClientRect().height) - // Compact diagrams need at most the Mermaid canvas's 16px padding on each side. - expect(canvasHeight - mermaidHeight).toBeLessThanOrEqual(32) - - for (const title of ['Graphviz diagram', 'SVG preview']) { - const image = page.getByRole('img', { name: title, exact: true }) - await image.evaluate(async (node: HTMLImageElement) => { await node.decode() }) - const imageHeight = await image.evaluate(node => node.getBoundingClientRect().height) - const containerHeight = await image.evaluate(node => node.parentElement!.getBoundingClientRect().height) - expect(imageHeight).toBeGreaterThan(0) - expect(containerHeight).toBeGreaterThanOrEqual(imageHeight) - expect.soft(containerHeight - imageHeight, - `${scheme} ${title}: ${containerHeight}px container around ${imageHeight}px content`, - ).toBeLessThanOrEqual(32) - } - await page.setViewportSize({ width: 360, height: 800 }) - const svg = page.getByRole('img', { name: 'SVG preview', exact: true }) - await expect.poll(() => svg.evaluate((node) => { - const { width, height } = node.getBoundingClientRect() - return width > 0 && height > 0 && width < 460 - })).toBe(true) - const compact = await svg.evaluate((node: HTMLImageElement) => ({ - width: node.getBoundingClientRect().width, - height: node.getBoundingClientRect().height, - canvasHeight: node.parentElement!.getBoundingClientRect().height, - ratio: node.naturalWidth / node.naturalHeight, - })) - expect(compact.width / compact.height).toBeCloseTo(compact.ratio, 1) - expect(compact.canvasHeight - compact.height).toBeLessThanOrEqual(32) - } finally { - await page.close() - } - }, - ) - - it.skipIf(MODE === 'record')('updates existing diagrams when the document switches between light and dark', async () => { - const page = await newEnglishPage(browser) - try { - await openConversation(page, scaffold) - const image = page.getByRole('img', { name: 'Mermaid diagram' }).first() - await image.waitFor() - let previous = await image.getAttribute('src') - for (const scheme of ['dark', 'light'] as const) { - await page.evaluate((value) => { - document.documentElement.style.colorScheme = value - document.body.toggleAttribute('data-ds-dark-theme', value === 'dark') - }, scheme) - await expect.poll(() => image.getAttribute('src')).not.toBe(previous) - await expect.poll(() => image.evaluate(node => (node as HTMLImageElement).naturalWidth)).toBeGreaterThan(0) - const colors = await image.evaluate((node) => { - const reference = document.createElement('span') - reference.style.backgroundColor = 'var(--dsw-alias-markdown-code-block)' - document.body.append(reference) - try { - return { - canvas: getComputedStyle(node.parentElement!).backgroundColor, - expected: getComputedStyle(reference).backgroundColor, - } - } finally { - reference.remove() - } - }) - expect(colors.canvas).toBe(colors.expected) - await expect.poll(() => page.getByTitle('HTML preview', { exact: true }).getAttribute('srcdoc')) - .toContain(`color-scheme:${scheme}`) - previous = await image.getAttribute('src') - } - } finally { - await page.close() - } - }) - - it.skipIf(MODE === 'record')('renders diagrams, switches to source, copies source, and contains malformed content', async () => { - const page = await newEnglishPage(browser) - onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-mermaid')) - const tripwire = watchConsole(page) - const dialogs: string[] = [] - page.on('dialog', (dialog) => { dialogs.push(dialog.message()); void dialog.dismiss() }) - await page.context().grantPermissions(['clipboard-read', 'clipboard-write']) - await openConversation(page, scaffold) - const images = page.getByRole('img', { name: 'Mermaid diagram' }) - await expect.poll(() => images.count(), { timeout: 20_000 }).toBe(3) - await expect.poll(() => images.evaluateAll(nodes => nodes.every(node => (node as HTMLImageElement).naturalWidth > 0))).toBe(true) - expect(await images.evaluateAll(nodes => nodes.every(node => - node.getBoundingClientRect().width <= (node as HTMLImageElement).naturalWidth))).toBe(true) - expect(await page.getByText('Unable to render this diagram. The source is shown below.', { exact: true }).count()).toBe(1) - expect(await page.locator('pre code').allTextContents()).toContain(INVALID) - expect(await images.first().evaluate(node => decodeURIComponent((node as HTMLImageElement).src))).toContain('共享渲染器') - const first = page.locator('.md-code-block').first() - const diagram = await first.getByRole('img').elementHandle() - const controls = first.locator('[class*="bannerWrap"]') - expect(await first.getByText('mermaid', { exact: true }).count()).toBe(0) - await page.mouse.move(0, 0) - expect(await controls.evaluate(node => getComputedStyle(node).opacity)).toBe('0') - await first.hover() - expect(await controls.evaluate(node => getComputedStyle(node).opacity)).toBe('1') - await first.getByRole('button', { name: 'Copy', exact: true }).click() - expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(FLOW) - await page.mouse.move(0, 0) - await first.getByRole('button', { name: 'Source', exact: true }).focus() - expect(await controls.evaluate(node => getComputedStyle(node).opacity)).toBe('1') - await page.keyboard.press('Enter') - expect(await first.getByRole('button', { name: 'Preview', exact: true }) - .evaluate(node => node === document.activeElement)).toBe(true) - expect(await first.locator('pre code').textContent()).toBe(FLOW) - expect(await diagram!.evaluate(node => node.isConnected)).toBe(true) - expect(await diagram!.isVisible()).toBe(false) - await first.getByRole('button', { name: 'Preview', exact: true }).click() - expect(await diagram!.isVisible()).toBe(true) - await first.getByRole('button', { name: 'Copy', exact: true }).waitFor() - expect(await page.locator('body').evaluate(node => getComputedStyle(node).display)).not.toBe('none') - expect(await page.locator('[id^="dsh-mermaid-"]').count()).toBe(0) - expect(dialogs).toEqual([]) - expect(tripwire.pageErrors).toEqual([]) - expect(tripwire.warnings).toEqual([]) - await page.getByRole('heading', { name: 'Mermaid previews' }).click() - await page.mouse.move(0, 0) - const snapshot = await captureStableAria(page, DIAGRAM_MESSAGE, scaffold.workspaceCwd) - await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'ui.expected.md'), snapshot, MODE) - await page.close() - }, 60_000) - - it.skipIf(MODE === 'record')('localizes the preview and failure states in Chinese', async () => { - const page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: 'zh-CN', timezoneId: 'Asia/Shanghai', hasTouch: true }) - await openConversation(page, scaffold) - await expect.poll(() => page.getByRole('img', { name: 'Mermaid 图表' }).count(), { timeout: 20_000 }).toBe(3) - expect(await page.getByRole('button', { name: '源码', exact: true }).count()).toBe(7) - const controls = page.locator('.md-code-block').first().locator('[class*="bannerWrap"]') - expect(await controls.evaluate(node => getComputedStyle(node).opacity)).toBe('1') - expect(await page.getByRole('button', { name: '源码', exact: true }).first() - .evaluate(node => node.getBoundingClientRect().width)).toBe(44) - expect(await page.getByText('无法渲染此图表,源码如下。', { exact: true }).count()).toBe(1) - const firstImage = page.getByRole('img', { name: 'Mermaid 图表' }).first() - await expect.poll(() => firstImage.evaluate(node => (node as HTMLImageElement).naturalWidth)).toBeGreaterThan(0) - expect(await controls.evaluate(node => node.getBoundingClientRect().top)) - .toBeGreaterThanOrEqual(await firstImage.evaluate(node => node.getBoundingClientRect().bottom)) - const snapshot = await captureStableAria(page, DIAGRAM_MESSAGE, scaffold.workspaceCwd) - await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'zh.expected.md'), snapshot, MODE) - await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md', 'zh.expected.md']) - await page.close() - }, 60_000) - - it.skipIf(MODE === 'record')('previews inert DOT and SVG images and isolates static HTML', async () => { - const page = await newEnglishPage(browser) - const requests: string[] = [] - const dialogs: string[] = [] - // Routing observes requests eligible for transport; Chromium also emits 'request' for CSP-blocked URLs. - await page.route('https://preview.invalid/**', async (route) => { - requests.push(route.request().url()) - await route.abort() - }) - page.on('dialog', (dialog) => { dialogs.push(dialog.message()); void dialog.dismiss() }) - await page.context().grantPermissions(['clipboard-read', 'clipboard-write']) - await openConversation(page, scaffold) - for (const [title, code] of [['Graphviz diagram', DOT], ['SVG preview', SVG], ['HTML preview', HTML]] as const) { - const preview = title === 'HTML preview' - ? page.getByTitle(title, { exact: true }) - : page.getByRole('img', { name: title, exact: true, includeHidden: true }) - await preview.waitFor() - if (title === 'HTML preview') { - expect(await preview.getAttribute('sandbox')).toBe('') - const body = preview.contentFrame() - await body.getByRole('heading', { name: 'Static HTML' }).waitFor() - expect(await body.getByRole('heading').evaluate(node => getComputedStyle(node).color)).toBe('rgb(0, 128, 0)') - await body.getByText('Disabled navigation').click() - expect(await preview.evaluate(node => (node as HTMLIFrameElement).contentDocument)).toBeNull() - } else { - await expect.poll(() => preview.evaluate(node => (node as HTMLImageElement).naturalWidth)).toBeGreaterThan(0) - } - const block = page.locator('.md-code-block').filter({ has: preview }) - await block.hover() - await block.getByRole('button', { name: 'Copy', exact: true }).click() - expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(code) - await block.getByRole('button', { name: 'Source', exact: true }).click() - expect(await block.locator('pre code').textContent()).toBe(code) - await block.getByRole('button', { name: 'Preview', exact: true }).click() - expect(await preview.isVisible()).toBe(true) - } - expect(await page.locator('body').getAttribute('data-preview-escaped')).toBeNull() - expect(dialogs).toEqual([]) - expect(requests).toEqual([]) - const notices = await page.request.get(new URL('/preview-third-party-notices.txt', scaffold.authenticatedUrl).href) - expect(notices.ok()).toBe(true) - expect(await notices.text()).toContain('Eclipse Public License - v 2.0') - await page.close() - }) - - it.skipIf(MODE === 'record')('keeps actions visible when a mouse and touchscreen are both available', async () => { - const hybridBrowser = await chromium.launch({ - args: ['--blink-settings=availablePointerTypes=6,primaryPointerType=4,availableHoverTypes=2,primaryHoverType=2'], - }) - try { - const page = await newEnglishPage(hybridBrowser) - expect(await page.evaluate(() => - matchMedia('(hover: hover) and (pointer: fine) and (any-pointer: coarse)').matches)).toBe(true) - await openConversation(page, scaffold) - const first = page.locator('.md-code-block').first() - const diagram = first.getByRole('img', { name: 'Mermaid diagram' }) - await expect.poll(() => diagram.evaluate(node => (node as HTMLImageElement).naturalWidth)).toBeGreaterThan(0) - await page.mouse.move(0, 0) - const controls = first.locator('[class*="bannerWrap"]') - expect(await controls.evaluate(node => getComputedStyle(node).opacity)).toBe('1') - expect(await controls.evaluate(node => getComputedStyle(node).pointerEvents)).toBe('auto') - expect(await first.getByRole('button', { name: 'Source', exact: true }) - .evaluate(node => node.getBoundingClientRect().width)).toBe(44) - expect(await controls.evaluate(node => node.getBoundingClientRect().top)) - .toBeGreaterThanOrEqual(await diagram.evaluate(node => node.getBoundingClientRect().bottom)) - await first.getByRole('button', { name: 'Source', exact: true }).click() - expect(await first.locator('pre code').textContent()).toBe(FLOW) - } finally { - await hybridBrowser.close() - } - }, 60_000) -}) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index a6e3b24bfd..3b01e3990c 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -79,7 +79,6 @@ "tests/math-rendering.e2e.ts", "tests/markdown-cjk-strong.e2e.ts", "tests/markdown-inline-code-links.e2e.ts", - "tests/markdown-mermaid.e2e.ts", "tests/clickable-links-gallery.e2e.ts", "tests/queue-actions.e2e.ts", "tests/queue-image.e2e.ts", diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index f6c90861f1..0e15b4f088 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -27,20 +27,6 @@ function clientDocumentTitle(): Plugin { } } -/** Preserve preview licenses and Graphviz source availability in every browser distribution. */ -function previewNotices(): Plugin { - return { - name: 'dsh-preview-notices', - async generateBundle() { - this.emitFile({ - type: 'asset', - fileName: 'preview-third-party-notices.txt', - source: await readFile(src('../../packages/client/ui-primitives/THIRD_PARTY_PREVIEW_NOTICES.txt'), 'utf8'), - }) - }, - } -} - /** Fail before a Vite dev or preview server can expose the boot-manifest-free shell. */ function rejectStandaloneServe(): Plugin { return { @@ -161,7 +147,7 @@ export default defineConfig({ // Relative asset URLs: preview.html mounts the same output under any base // directory, and the served index resolves identically from the site root. base: './', - plugins: [previewNotices(), rejectStandaloneServe(), clientDocumentTitle(), react(), emitPreviewPage()], + plugins: [rejectStandaloneServe(), clientDocumentTitle(), react(), emitPreviewPage()], build: { // The worker bootstrap holds its page at top-level await; Vite's default // `modules` target (es2020-era) rejects that syntax. diff --git a/packages/client/locale/src/locales/en.ts b/packages/client/locale/src/locales/en.ts index 374de7c7b0..70ce31598c 100644 --- a/packages/client/locale/src/locales/en.ts +++ b/packages/client/locale/src/locales/en.ts @@ -38,16 +38,6 @@ export const en = { 'json.expandNode': 'Expand JSON node', 'json.label': 'JSON', 'markdown.footnotes': 'Footnotes', - 'markdown.preview.graphviz': 'Graphviz diagram', - 'markdown.preview.svg': 'SVG preview', - 'markdown.preview.html': 'HTML preview', - 'markdown.preview.loading': 'Rendering preview…', - 'markdown.preview.error': 'Unable to render this preview. The source is shown below.', - 'markdown.mermaid.diagram': 'Mermaid diagram', - 'markdown.preview.preview': 'Preview', - 'markdown.preview.source': 'Source', - 'markdown.mermaid.loading': 'Rendering diagram…', - 'markdown.mermaid.error': 'Unable to render this diagram. The source is shown below.', 'markdown.truncatedCharacters': '… truncated at {total} characters', 'number.thousand': '{value}K', 'number.million': '{value}M', diff --git a/packages/client/locale/src/locales/zh.ts b/packages/client/locale/src/locales/zh.ts index df1aa1d19e..30cee308d5 100644 --- a/packages/client/locale/src/locales/zh.ts +++ b/packages/client/locale/src/locales/zh.ts @@ -36,16 +36,6 @@ export const zh = { 'json.expandNode': '展开 JSON 节点', 'json.label': 'JSON', 'markdown.footnotes': '脚注', - 'markdown.preview.graphviz': 'Graphviz 图表', - 'markdown.preview.svg': 'SVG 预览', - 'markdown.preview.html': 'HTML 预览', - 'markdown.preview.loading': '正在渲染预览…', - 'markdown.preview.error': '无法渲染此预览,源码如下。', - 'markdown.mermaid.diagram': 'Mermaid 图表', - 'markdown.preview.preview': '预览', - 'markdown.preview.source': '源码', - 'markdown.mermaid.loading': '正在渲染图表…', - 'markdown.mermaid.error': '无法渲染此图表,源码如下。', 'markdown.truncatedCharacters': '… 已截断,共 {total} 字符', 'number.thousand': '{value}K', 'number.million': '{value}M', diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index 554709bb19..4db27677d0 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-chat/README.md -README.md: 1bd7f008e46b695899c703c928ebe8bb76fbe4c8 -README.zh.md: 5919622396dde5b0c8cbf51f71999672a11f13ec +README.md: 860dcb9eeb9c92a14d9128d9d8c95c29796726f8 +README.zh.md: 4dd06a16b19b14cacc0ded603cc1cf8869dd1a43 diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index 1bd7f008e4..860dcb9eeb 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -15,7 +15,6 @@ File-mention providers receive the viewed Session ID with the closing-turn owner ## Table of Contents - [System prompt row](#system-prompt-row) -- [Mermaid previews](#mermaid-previews) - [Turn token usage](#turn-token-usage) - [Turn Process Folding](#turn-process-folding) - [Scroll ownership](#scroll-ownership) @@ -30,13 +29,6 @@ File-mention providers receive the viewed Session ID with the closing-turn owner Each nonempty appended `system/message` owns a collapsed prompt row, including a complete prompt at the start of a headerless window; the same-step header does not duplicate it. Chat also shows a collapsed `System prompt` row for a non-empty initial request, explicit message-series start, or `system/message` surface node replacement whose text differs, reading the last nonempty surviving system node in surface order at the `request/header`; a non-initial request whose preceding header is outside the loaded history window also shows one. A resume repeats the row even when its system text is unchanged, including after pagination supplies the preceding header and system node; same-series config-only or tool-only changes, tool steps, and retries create no repetition, and a `system/message` event is never rendered as a transcript message. The row appears before that request's user messages, matching the provider envelope, and expands to the exact model-visible text with its original line breaks. A request whose system node is empty or outside the loaded window creates no row until the page holding the node arrives. - -## Mermaid previews - -Settled Assistant Markdown shows `mermaid` code fences as diagrams. Diagrams have no language banner. Source/Preview and Copy icon actions appear on hover or keyboard focus and remain visible on touch devices; both views copy the original Mermaid source. Streaming fences remain code until the message settles; invalid diagrams show an error and their source without disrupting other blocks. Chat enables the shared [Mermaid preview primitive](../ui-primitives/README.md) through localized labels. Chat also previews `graphviz`/`dot`, `svg`, and static `html` fences in isolated iframes, with the same default visualization, source switching, and copying. HTML scripts, navigation, and remote resources are disabled. This is a presentation change and adds no model prompt, tool, or Session event. - ------ - ## Turn token usage diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 5919622396..4dd06a16b1 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -15,7 +15,6 @@ kind: "package-reference" ## 目录 - [系统提示词行](#system-prompt-row) -- [Mermaid 预览](#mermaid-previews) - [轮次 token 用量](#turn-token-usage) - [轮次过程折叠](#turn-process-folding) - [滚动归属](#scroll-ownership) @@ -32,13 +31,6 @@ kind: "package-reference" ----- - -## Mermaid 预览 - -已定稿的 Assistant Markdown 把 `mermaid` 代码 fence 显示为图表。图表不显示语言标题栏。源码/预览切换与复制图标在悬停或键盘聚焦时出现,触屏上保持可见;两种视图复制的都是原始 Mermaid 源码。流式 fence 在消息定稿前保持代码显示;非法图表显示错误与源码,不影响其他块。Chat 通过本地化 label 启用共享的 [Mermaid 预览原语](../ui-primitives/README.zh.md)。Chat 还在隔离 iframe 中预览 `graphviz`/`dot`、`svg` 和静态 `html` fence,共用默认可视化、源码切换与复制操作。HTML 脚本、导航与远程资源均被禁用。这项展示变更不增加模型提示词、工具或 Session 事件。 - ------ - ## 轮次 token 用量 diff --git a/packages/client/ui-chat/src/client/markdown-labels.ts b/packages/client/ui-chat/src/client/markdown-labels.ts index c77cb8e9e4..6c30b99396 100644 --- a/packages/client/ui-chat/src/client/markdown-labels.ts +++ b/packages/client/ui-chat/src/client/markdown-labels.ts @@ -9,21 +9,8 @@ import type { ChatViewSlotProps } from './contract/slots.ts' * @returns Labels for code fences and footnotes. */ export function markdownLabels(t: ChatViewSlotProps['t']): MarkdownLabels { - const status = { loading: t('markdown.preview.loading'), error: t('markdown.preview.error') } return { code: { copyLabel: t('copy'), copiedLabel: t('copied') }, footnotes: t('markdown.footnotes'), - preview: { - preview: t('markdown.preview.preview'), - source: t('markdown.preview.source'), - mermaid: { - diagram: t('markdown.mermaid.diagram'), - loading: t('markdown.mermaid.loading'), - error: t('markdown.mermaid.error'), - }, - graphviz: { ...status, diagram: t('markdown.preview.graphviz') }, - svg: { ...status, diagram: t('markdown.preview.svg') }, - html: { ...status, diagram: t('markdown.preview.html') }, - }, } } diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 2c354d584f..5672f45bb3 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md -README.md: 22f450914078799f2f9da31389670f83cefe66cd -README.zh.md: c9fd355d4b16ae98bd0f194d13d4331a52513044 +README.md: aebb0be860ebaba4543a8fd262599507ead64556 +README.zh.md: a545aff9837036890b6250ab1aa32fb651af0a18 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 22f4509140..aebb0be860 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -73,15 +73,6 @@ The catalog above lists what each export is for; this section covers the behavio `MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. When the owner passes a `pathImages` vocabulary, image destinations that are local media paths rewrite to displayable URLs on settled renders only (the same streaming gate as file mentions); without a vocabulary, local destinations remain inert alt text. A load or decode failure replaces the image with its authored alt text, or the original destination when alt is empty. Changing the image source permits a fresh load. While a reply streams, it freezes completed blocks, advances a top-level open fence by completed lines, and highlights that fence from saved Shiki grammar state. Completed token lines enter fixed-size React groups, so later chunks reconcile only the growing group; an unchanged fence retains that DOM when the final full parse resolves cross-document syntax. `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, and `WebBlock` render the matching tool-result intent with copy controls, overflow handling, and ANSI processing where applicable. `JsonTree` and `JsonBlock` inspect JSON values read-only, while `projectUserText` projects sent user text into inline plain runs and reference chips for the message bubble and queue rows. -Supply `MarkdownLabels.preview` to enable settled `mermaid`, `graphviz`/`dot`, `svg`, and `html` fences. Each language supplies localized preview states, and consumers without these labels retain code. Mermaid and Graphviz use the active document palette on the code-block background and update mounted previews when the theme changes. Explicit colors in DOT, SVG, and HTML remain authored content. Rendering loads Mermaid on demand, uses strict security, and exposes the generated SVG as an image with no diagram link handlers. A render failure shows the original source with the supplied error label; replacing the source discards late results from the previous render. The diagram keeps its intrinsic size and shrinks to fit the available width. - -`CodeBlock.preview` supplies an optional headerless body with icon actions for source switching and copying. Actions appear on hover or keyboard focus, and stay visible below the diagram whenever a touchscreen is available, including with a mouse attached. Switching to source keeps the preview mounted, so returning reuses its result; copying always retains the source. - -Each settled fence defaults to visualization and shares the source/copy controls. Graphviz loads `@viz-js/viz` on demand and renders DOT with the `dot` engine. SVG and Graphviz output are inert images in the same content-sized canvas as Mermaid; malformed SVG and DOT retain their source with an error. HTML accepts complete documents or fragments and preserves inline styles, but strips scripts and navigation. HTML frames have no sandbox permissions, and their Content Security Policy blocks external resources, embedded frames, forms, and scripts. Diagram canvases follow the rendered image height with 16px padding and shrink images proportionally to fit the available width. HTML frames use a 400px scrollable viewport. Raw HTML outside a code fence remains ordinary Markdown input. - -Internal render functions accept optional native `MermaidConfig` and Viz.js `RenderOptions` for developer customization. Attribute and theme-variable overrides merge with document defaults; Mermaid preview restrictions and Graphviz SVG output remain fixed. These parameters have no settings UI. - -[Preview license notices](THIRD_PARTY_PREVIEW_NOTICES.txt) ship with this package and the Web frontend. Mermaid is MIT; Viz.js is MIT but embeds Graphviz under EPL-2.0. The notices retain license texts and the exact Graphviz source download. Dependency upgrades require reviewing the embedded payload as well as npm metadata; the [preview decision](../../../.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.md) records the distribution and sandbox choices. ### Localizing copy @@ -150,7 +141,6 @@ None; this package neither assembles nor sends a provider request. These limits define how the atoms behave at the edges; they are current package constraints, not a component roadmap. -- **Diagram rendering runs on the browser thread** — every mounted settled preview starts rendering, including off-screen diagrams. Mermaid serializes layouts; Graphviz performs synchronous WebAssembly layout. Submitted layout work cannot be interrupted. Preview virtualization and worker rendering are not provided. - **Streaming defers cross-boundary reference resolution** — a reference-style link or footnote whose definition sits on the other side of the incremental freeze boundary renders as literal text while the reply streams; the settled full parse at finalize resolves it. - **A long highlighted fence retains its complete token DOM** — streaming avoids re-parsing, re-tokenizing, and reconciling the completed prefix, but it does not discard old colors or virtualize token spans. Final DOM cardinality therefore still follows the fence's token count; nested/container fences and a pathological single long line remain on the general tail path. - **Glyph-level icons are redrawn approximations** — the fish logo and the sparkle mark come from font glyphs whose vector geometry is not exportable from the local design data; hand-authored recreations stand in until an exact export path exists. diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index c9fd355d4b..a545aff983 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -73,15 +73,6 @@ kind: "package-library" `MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。当 owner 传入 `pathImages` 词表时,本地媒体路径的图片目标只在落定渲染阶段重写为可展示 URL(与 file mentions 相同的流式门);不传词表时本地目标保持惰性 alt 文本。加载或解码失败后,图片替换为作者的 alt 文本;alt 为空时显示原始目标路径。图片源变化后可重新加载。回复流式输出时,它冻结已完成的块、按已完成行推进顶层未闭合 fence,并从保存的 Shiki grammar state 为该 fence 增量高亮。已完成的 token 行进入固定大小的 React 分组,后续分片只 reconcile 正在增长的分组;最终全量解析解决跨文档语法时,未变化的 fence 会保留该 DOM。`TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock` 与 `WebBlock` 把对应的工具结果意图渲染为带复制控件、溢出处理及适用时 ANSI 处理的卡片。`JsonTree` 与 `JsonBlock` 以只读方式检查 JSON 值;`projectUserText` 把已发送的用户文本投影为行内普通文本段与引用 chip,供消息气泡和排队行使用。 -提供 `MarkdownLabels.preview` 可启用定稿后的 `mermaid`、`graphviz`/`dot`、`svg` 和 `html` fence。每种语言提供本地化预览状态,未提供这些文案的调用方保留代码显示。Mermaid 与 Graphviz 在代码块背景上使用当前文档配色,主题切换时同步更新已挂载的预览。DOT、SVG 与 HTML 中明确指定的颜色仍保留原样。渲染按需加载 Mermaid、使用严格安全模式,并把生成的 SVG 显示为图片,不绑定图内链接处理器。渲染失败时显示原始源码与传入的错误文案;替换源码后会丢弃前一次渲染的延迟结果。图表保留固有尺寸,并在可用宽度不足时缩小。 - -`CodeBlock.preview` 提供无标题栏的替代正文,以及切换源码和复制的图标操作。操作在悬停或键盘聚焦时出现;只要设备具备触屏,就在图表下方保持可见,包括同时连接鼠标的情况。切换到源码时保留已挂载的预览,返回时复用其结果;复制始终保留源码。 - -每个定稿 fence 默认显示可视化,并共享源码切换与复制操作。Graphviz 按需加载 `@viz-js/viz`,用 `dot` 引擎渲染 DOT。SVG 与 Graphviz 输出作为不可执行的图片,使用与 Mermaid 相同的按内容定高画布;非法 SVG 与 DOT 显示错误并保留源码。HTML 接受完整文档或片段、保留内联样式,同时移除脚本与导航。HTML iframe 不授予任何 sandbox 权限,Content Security Policy 阻止外部资源、嵌套 iframe、表单与脚本。图表画布随渲染后的图片高度收缩,保留 16px 内边距,并在可用宽度不足时等比例缩小图片。HTML iframe 使用可滚动的 400px 视口。代码 fence 外的原始 HTML 仍按普通 Markdown 输入处理。 - -内部渲染函数接受可选的原生 `MermaidConfig` 与 Viz.js `RenderOptions`,供开发者定制。属性与主题变量覆盖项和文档默认值合并;Mermaid 预览限制与 Graphviz SVG 输出保持固定。这些参数没有设置界面。 - -[预览许可证声明](THIRD_PARTY_PREVIEW_NOTICES.txt)随本包与 Web 前端分发。Mermaid 使用 MIT;Viz.js 使用 MIT,但内嵌 Graphviz 使用 EPL-2.0。声明保留完整许可证文本及准确的 Graphviz 源码下载地址。升级依赖时必须同时检查内嵌产物和 npm 元数据;[预览决策](../../../.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.zh.md)记录分发与 sandbox 选择。 ### 本地化文案 @@ -150,7 +141,6 @@ kind: "package-library" 这些限制说明原子组件在边缘情况下的行为;它们是当前包约束,不是组件路线图。 -- **图表渲染在浏览器线程上执行**:每个已挂载的定稿预览都会开始渲染,包括视口外的图表。Mermaid 串行执行布局;Graphviz 同步执行 WebAssembly 布局。已提交的布局工作无法中断。当前不提供预览虚拟化或 worker 渲染。 - **流式期间跨边界引用解析被推迟**:定义落在增量冻结边界另一侧的引用式链接或脚注,在回复流式输出期间渲染为字面文本;定稿时的全量解析会将其解析。 - **长高亮 fence 会保留完整 token DOM**:流式路径避免重新解析、重新 tokenize 和 reconcile 已完成前缀,但不会丢弃旧颜色或虚拟化 token span。因此最终 DOM 数量仍随 fence 的 token 数增长;嵌套/容器内 fence 与病态的单个超长行仍走通用尾部路径。 - **字形级图标是重新绘制的近似版本**:鱼形标志与闪光标记来自字体字形,而本地设计数据无法导出其矢量几何;在获得精确导出路径前,使用手工重建版本代替。 diff --git a/packages/client/ui-primitives/THIRD_PARTY_PREVIEW_NOTICES.txt b/packages/client/ui-primitives/THIRD_PARTY_PREVIEW_NOTICES.txt deleted file mode 100644 index 3e1156a536..0000000000 --- a/packages/client/ui-primitives/THIRD_PARTY_PREVIEW_NOTICES.txt +++ /dev/null @@ -1,688 +0,0 @@ -Markdown preview third-party notices - -Mermaid 11.16.0: MIT; source https://github.com/mermaid-js/mermaid/tree/mermaid%4011.16.0 -@viz-js/viz 3.30.0: MIT JavaScript wrapper; source and build instructions: -https://github.com/mdaines/viz-js/tree/99da545270e6e7b127a5c7ba65974b8a604a6358 - -The Viz.js WebAssembly payload contains Graphviz 16.0.0 under EPL-2.0, -Expat 2.8.4 under MIT, and Emscripten 5.0.7 runtime code under MIT/NCSA. -Graphviz remains under EPL-2.0; it is not relicensed as MIT by DeepSeek Harness. -Graphviz source code is available under EPL-2.0 at: -https://gitlab.com/api/v4/projects/4207231/packages/generic/graphviz-releases/16.0.0/graphviz-16.0.0.tar.gz -SHA-256: 36a1de1aaf5a2023b14f95170a5f8f0b12522d1c305b517fc261966597050749 -Viz.js's backend build instructions and additions are available at the commit above. -DeepSeek Harness uses the unmodified upstream Viz.js package. - -Expat source: https://github.com/libexpat/libexpat/tree/R_2_8_4 -Emscripten source: https://github.com/emscripten-core/emscripten/tree/5.0.7 -DOMPurify 3.4.11: Apache-2.0 OR MPL-2.0; this distribution selects Apache-2.0. -Source: https://github.com/cure53/DOMPurify/tree/3.4.11 - -The following license texts and copyright notices apply to their respective components. - - -===== Mermaid ===== - -The MIT License (MIT) - -Copyright (c) 2014 - 2022 Knut Sveidqvist - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - -===== Viz.js ===== - -MIT License - -Copyright (c) Michael Daines - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - -===== Graphviz ===== - -Eclipse Public License - v 2.0 - - THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE - PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION - OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. - -1. DEFINITIONS - -"Contribution" means: - - a) in the case of the initial Contributor, the initial content - Distributed under this Agreement, and - - b) in the case of each subsequent Contributor: - i) changes to the Program, and - ii) additions to the Program; - where such changes and/or additions to the Program originate from - and are Distributed by that particular Contributor. A Contribution - "originates" from a Contributor if it was added to the Program by - such Contributor itself or anyone acting on such Contributor's behalf. - Contributions do not include changes or additions to the Program that - are not Modified Works. - -"Contributor" means any person or entity that Distributes the Program. - -"Licensed Patents" mean patent claims licensable by a Contributor which -are necessarily infringed by the use or sale of its Contribution alone -or when combined with the Program. - -"Program" means the Contributions Distributed in accordance with this -Agreement. - -"Recipient" means anyone who receives the Program under this Agreement -or any Secondary License (as applicable), including Contributors. - -"Derivative Works" shall mean any work, whether in Source Code or other -form, that is based on (or derived from) the Program and for which the -editorial revisions, annotations, elaborations, or other modifications -represent, as a whole, an original work of authorship. - -"Modified Works" shall mean any work in Source Code or other form that -results from an addition to, deletion from, or modification of the -contents of the Program, including, for purposes of clarity any new file -in Source Code form that contains any contents of the Program. Modified -Works shall not include works that contain only declarations, -interfaces, types, classes, structures, or files of the Program solely -in each case in order to link to, bind by name, or subclass the Program -or Modified Works thereof. - -"Distribute" means the acts of a) distributing or b) making available -in any manner that enables the transfer of a copy. - -"Source Code" means the form of a Program preferred for making -modifications, including but not limited to software source code, -documentation source, and configuration files. - -"Secondary License" means either the GNU General Public License, -Version 2.0, or any later versions of that license, including any -exceptions or additional permissions as identified by the initial -Contributor. - -2. GRANT OF RIGHTS - - a) Subject to the terms of this Agreement, each Contributor hereby - grants Recipient a non-exclusive, worldwide, royalty-free copyright - license to reproduce, prepare Derivative Works of, publicly display, - publicly perform, Distribute and sublicense the Contribution of such - Contributor, if any, and such Derivative Works. - - b) Subject to the terms of this Agreement, each Contributor hereby - grants Recipient a non-exclusive, worldwide, royalty-free patent - license under Licensed Patents to make, use, sell, offer to sell, - import and otherwise transfer the Contribution of such Contributor, - if any, in Source Code or other form. This patent license shall - apply to the combination of the Contribution and the Program if, at - the time the Contribution is added by the Contributor, such addition - of the Contribution causes such combination to be covered by the - Licensed Patents. The patent license shall not apply to any other - combinations which include the Contribution. No hardware per se is - licensed hereunder. - - c) Recipient understands that although each Contributor grants the - licenses to its Contributions set forth herein, no assurances are - provided by any Contributor that the Program does not infringe the - patent or other intellectual property rights of any other entity. - Each Contributor disclaims any liability to Recipient for claims - brought by any other entity based on infringement of intellectual - property rights or otherwise. As a condition to exercising the - rights and licenses granted hereunder, each Recipient hereby - assumes sole responsibility to secure any other intellectual - property rights needed, if any. For example, if a third party - patent license is required to allow Recipient to Distribute the - Program, it is Recipient's responsibility to acquire that license - before distributing the Program. - - d) Each Contributor represents that to its knowledge it has - sufficient copyright rights in its Contribution, if any, to grant - the copyright license set forth in this Agreement. - - e) Notwithstanding the terms of any Secondary License, no - Contributor makes additional grants to any Recipient (other than - those set forth in this Agreement) as a result of such Recipient's - receipt of the Program under the terms of a Secondary License - (if permitted under the terms of Section 3). - -3. REQUIREMENTS - -3.1 If a Contributor Distributes the Program in any form, then: - - a) the Program must also be made available as Source Code, in - accordance with section 3.2, and the Contributor must accompany - the Program with a statement that the Source Code for the Program - is available under this Agreement, and informs Recipients how to - obtain it in a reasonable manner on or through a medium customarily - used for software exchange; and - - b) the Contributor may Distribute the Program under a license - different than this Agreement, provided that such license: - i) effectively disclaims on behalf of all other Contributors all - warranties and conditions, express and implied, including - warranties or conditions of title and non-infringement, and - implied warranties or conditions of merchantability and fitness - for a particular purpose; - - ii) effectively excludes on behalf of all other Contributors all - liability for damages, including direct, indirect, special, - incidental and consequential damages, such as lost profits; - - iii) does not attempt to limit or alter the recipients' rights - in the Source Code under section 3.2; and - - iv) requires any subsequent distribution of the Program by any - party to be under a license that satisfies the requirements - of this section 3. - -3.2 When the Program is Distributed as Source Code: - - a) it must be made available under this Agreement, or if the - Program (i) is combined with other material in a separate file or - files made available under a Secondary License, and (ii) the initial - Contributor attached to the Source Code the notice described in - Exhibit A of this Agreement, then the Program may be made available - under the terms of such Secondary Licenses, and - - b) a copy of this Agreement must be included with each copy of - the Program. - -3.3 Contributors may not remove or alter any copyright, patent, -trademark, attribution notices, disclaimers of warranty, or limitations -of liability ("notices") contained within the Program from any copy of -the Program which they Distribute, provided that Contributors may add -their own appropriate notices. - -4. COMMERCIAL DISTRIBUTION - -Commercial distributors of software may accept certain responsibilities -with respect to end users, business partners and the like. While this -license is intended to facilitate the commercial use of the Program, -the Contributor who includes the Program in a commercial product -offering should do so in a manner which does not create potential -liability for other Contributors. Therefore, if a Contributor includes -the Program in a commercial product offering, such Contributor -("Commercial Contributor") hereby agrees to defend and indemnify every -other Contributor ("Indemnified Contributor") against any losses, -damages and costs (collectively "Losses") arising from claims, lawsuits -and other legal actions brought by a third party against the Indemnified -Contributor to the extent caused by the acts or omissions of such -Commercial Contributor in connection with its distribution of the Program -in a commercial product offering. The obligations in this section do not -apply to any claims or Losses relating to any actual or alleged -intellectual property infringement. In order to qualify, an Indemnified -Contributor must: a) promptly notify the Commercial Contributor in -writing of such claim, and b) allow the Commercial Contributor to control, -and cooperate with the Commercial Contributor in, the defense and any -related settlement negotiations. The Indemnified Contributor may -participate in any such claim at its own expense. - -For example, a Contributor might include the Program in a commercial -product offering, Product X. That Contributor is then a Commercial -Contributor. If that Commercial Contributor then makes performance -claims, or offers warranties related to Product X, those performance -claims and warranties are such Commercial Contributor's responsibility -alone. Under this section, the Commercial Contributor would have to -defend claims against the other Contributors related to those performance -claims and warranties, and if a court requires any other Contributor to -pay any damages as a result, the Commercial Contributor must pay -those damages. - -5. NO WARRANTY - -EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, AND TO THE EXTENT -PERMITTED BY APPLICABLE LAW, THE PROGRAM IS PROVIDED ON AN "AS IS" -BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, EITHER EXPRESS OR -IMPLIED INCLUDING, WITHOUT LIMITATION, ANY WARRANTIES OR CONDITIONS OF -TITLE, NON-INFRINGEMENT, MERCHANTABILITY OR FITNESS FOR A PARTICULAR -PURPOSE. Each Recipient is solely responsible for determining the -appropriateness of using and distributing the Program and assumes all -risks associated with its exercise of rights under this Agreement, -including but not limited to the risks and costs of program errors, -compliance with applicable laws, damage to or loss of data, programs -or equipment, and unavailability or interruption of operations. - -6. DISCLAIMER OF LIABILITY - -EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, AND TO THE EXTENT -PERMITTED BY APPLICABLE LAW, NEITHER RECIPIENT NOR ANY CONTRIBUTORS -SHALL HAVE ANY LIABILITY FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, -EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING WITHOUT LIMITATION LOST -PROFITS), HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN -CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) -ARISING IN ANY WAY OUT OF THE USE OR DISTRIBUTION OF THE PROGRAM OR THE -EXERCISE OF ANY RIGHTS GRANTED HEREUNDER, EVEN IF ADVISED OF THE -POSSIBILITY OF SUCH DAMAGES. - -7. GENERAL - -If any provision of this Agreement is invalid or unenforceable under -applicable law, it shall not affect the validity or enforceability of -the remainder of the terms of this Agreement, and without further -action by the parties hereto, such provision shall be reformed to the -minimum extent necessary to make such provision valid and enforceable. - -If Recipient institutes patent litigation against any entity -(including a cross-claim or counterclaim in a lawsuit) alleging that the -Program itself (excluding combinations of the Program with other software -or hardware) infringes such Recipient's patent(s), then such Recipient's -rights granted under Section 2(b) shall terminate as of the date such -litigation is filed. - -All Recipient's rights under this Agreement shall terminate if it -fails to comply with any of the material terms or conditions of this -Agreement and does not cure such failure in a reasonable period of -time after becoming aware of such noncompliance. If all Recipient's -rights under this Agreement terminate, Recipient agrees to cease use -and distribution of the Program as soon as reasonably practicable. -However, Recipient's obligations under this Agreement and any licenses -granted by Recipient relating to the Program shall continue and survive. - -Everyone is permitted to copy and distribute copies of this Agreement, -but in order to avoid inconsistency the Agreement is copyrighted and -may only be modified in the following manner. The Agreement Steward -reserves the right to publish new versions (including revisions) of -this Agreement from time to time. No one other than the Agreement -Steward has the right to modify this Agreement. The Eclipse Foundation -is the initial Agreement Steward. The Eclipse Foundation may assign the -responsibility to serve as the Agreement Steward to a suitable separate -entity. Each new version of the Agreement will be given a distinguishing -version number. The Program (including Contributions) may always be -Distributed subject to the version of the Agreement under which it was -received. In addition, after a new version of the Agreement is published, -Contributor may elect to Distribute the Program (including its -Contributions) under the new version. - -Except as expressly stated in Sections 2(a) and 2(b) above, Recipient -receives no rights or licenses to the intellectual property of any -Contributor under this Agreement, whether expressly, by implication, -estoppel or otherwise. All rights in the Program not expressly granted -under this Agreement are reserved. Nothing in this Agreement is intended -to be enforceable by any entity that is not a Contributor or Recipient. -No third-party beneficiary rights are created under this Agreement. - -Exhibit A - Form of Secondary Licenses Notice - -"This Source Code may also be made available under the following -Secondary Licenses when the conditions for such availability set forth -in the Eclipse Public License, v. 2.0 are satisfied: {name license(s), -version(s), and exceptions or additional permissions here}." - - Simply including a copy of this Agreement, including this Exhibit A - is not sufficient to license the Source Code under Secondary Licenses. - - If it is not possible or desirable to put the notice in a particular - file, then You may include the notice in a location (such as a LICENSE - file in a relevant directory) where a recipient would be likely to - look for such a notice. - - You may add additional accurate notices of copyright ownership. - - -===== Expat ===== - -Copyright (c) 1998-2000 Thai Open Source Software Center Ltd and Clark Cooper -Copyright (c) 2001-2025 Expat maintainers - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -"Software"), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be included -in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - -===== Emscripten ===== - -Emscripten is available under 2 licenses, the MIT license and the -University of Illinois/NCSA Open Source License. - -Both are permissive open source licenses, with little if any -practical difference between them. - -The reason for offering both is that (1) the MIT license is -well-known, while (2) the University of Illinois/NCSA Open Source -License allows Emscripten's code to be integrated upstream into -LLVM, which uses that license, should the opportunity arise. - -The full text of both licenses follows. - -============================================================================== - -Copyright (c) 2010-2014 Emscripten authors, see AUTHORS file. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - -============================================================================== - -Copyright (c) 2010-2014 Emscripten authors, see AUTHORS file. -All rights reserved. - -Permission is hereby granted, free of charge, to any person obtaining a -copy of this software and associated documentation files (the -"Software"), to deal with the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - - Redistributions of source code must retain the above copyright - notice, this list of conditions and the following disclaimers. - - Redistributions in binary form must reproduce the above - copyright notice, this list of conditions and the following disclaimers - in the documentation and/or other materials provided with the - distribution. - - Neither the names of Mozilla, - nor the names of its contributors may be used to endorse - or promote products derived from this Software without specific prior - written permission. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE CONTRIBUTORS OR COPYRIGHT HOLDERS BE LIABLE FOR -ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS WITH THE SOFTWARE. - -============================================================================== - -This program uses portions of Node.js source code located in src/library_path.js, -in accordance with the terms of the MIT license. Node's license follows: - - """ - Copyright Joyent, Inc. and other Node contributors. All rights reserved. - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to - deal in the Software without restriction, including without limitation the - rights to use, copy, modify, merge, publish, distribute, sublicense, and/or - sell copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in - all copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING - FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS - IN THE SOFTWARE. - """ - -The musl libc project is bundled in this repo, and it has the MIT license, see -system/lib/libc/musl/COPYRIGHT - -The third_party/ subdirectory contains code with other licenses. None of it is -used by default, but certain options use it (e.g., the optional closure compiler -flag will run closure compiler from third_party/). - - -===== DOMPurify ===== - - - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. diff --git a/packages/client/ui-primitives/package.json b/packages/client/ui-primitives/package.json index 587cd95ee6..2613c3aa98 100644 --- a/packages/client/ui-primitives/package.json +++ b/packages/client/ui-primitives/package.json @@ -45,15 +45,11 @@ "mdast-util-gfm": "^3.1.0", "mdast-util-math": "^3.0.0", "micromark-extension-gfm": "^3.0.0", - "micromark-util-sanitize-uri": "^2.0.1", - "mermaid": "11.16.0", - "@viz-js/viz": "3.30.0", - "dompurify": "3.4.11" + "micromark-util-sanitize-uri": "^2.0.1" }, "files": [ "lib/index.js", "lib/**/*.css", - "THIRD_PARTY_PREVIEW_NOTICES.txt", "lib/types/**/*.d.ts" ], "peerDependencies": { diff --git a/packages/client/ui-primitives/src/markdown/CodeBlock.module.css b/packages/client/ui-primitives/src/markdown/CodeBlock.module.css index a443ff1e07..a6e25ba39f 100644 --- a/packages/client/ui-primitives/src/markdown/CodeBlock.module.css +++ b/packages/client/ui-primitives/src/markdown/CodeBlock.module.css @@ -57,7 +57,6 @@ display: flex; align-items: center; flex-shrink: 0; - gap: 12px; } .copyButton { @@ -99,89 +98,6 @@ color: var(--dsw-alias-label-primary); } -/* Alternate content is a figure; its actions overlay instead of taking a title row. */ -.preview.showingPreview { - background: transparent; -} - -.preview .bannerWrap { - position: absolute; - top: 8px; - right: 8px; - z-index: 1; - background: transparent; -} - -.preview .banner { - padding: 2px; - background: var(--dsw-alias-bg-base); - border: 0; - border-radius: 8px; - box-shadow: var(--dsw-elevation-panel); -} - -.preview .action { - gap: 2px; -} - -.iconButton { - display: inline-flex; - align-items: center; - justify-content: center; - width: 32px; - height: 32px; - border-radius: 6px; - color: var(--dsw-alias-label-secondary); -} - -.iconButton span { - display: flex; -} - -.iconButton:hover { - color: var(--dsw-alias-label-primary); - background: var(--dsw-alias-interactive-bg-hover); -} - -.preview:not(.showingPreview) :where(pre) { - padding-top: 48px; -} - -@media (hover: hover) and (pointer: fine) { - .preview .bannerWrap { - opacity: 0; - pointer-events: none; - } - - .preview:hover .bannerWrap, - .preview:focus-within .bannerWrap { - opacity: 1; - pointer-events: auto; - } -} - -@media (any-pointer: coarse) { - .preview { - padding-bottom: 56px; - } - - .preview .bannerWrap { - top: auto; - bottom: 8px; - opacity: 1; - pointer-events: auto; - } - - .preview .iconButton { - width: 44px; - height: 44px; - } - - .preview:not(.showingPreview) :where(pre) { - padding-top: 16px; - } -} - .numbered :where(pre) code { display: block; counter-reset: source-line; diff --git a/packages/client/ui-primitives/src/markdown/CodeBlock.tsx b/packages/client/ui-primitives/src/markdown/CodeBlock.tsx index 367ae5febe..02cdbe683a 100644 --- a/packages/client/ui-primitives/src/markdown/CodeBlock.tsx +++ b/packages/client/ui-primitives/src/markdown/CodeBlock.tsx @@ -2,8 +2,6 @@ import { Fragment, useCallback, useMemo, useRef, useState, useSyncExternalStore import type { CSSProperties, ReactNode } from 'react' import clsx from 'clsx' import { writeClipboard } from '../clipboard.ts' -import { Tooltip } from '../Tooltip.tsx' -import { IconCheckOutline16, IconCodeOutline16, IconCopyOutline16 } from '../icons/index.tsx' import { StreamingHighlightSession, grammarLoadCount, highlightToHtml, subscribeGrammarLoaded, } from './highlight.ts' @@ -33,8 +31,6 @@ export interface CodeBlockProps { copyLabel: string /** Copy-button label during the post-copy confirmation window. */ copiedLabel: string - /** Headerless settled preview with hover/focus actions; copying always retains the source text. */ - preview?: { content: ReactNode; previewLabel: string; sourceLabel: string } | undefined } /** @@ -63,7 +59,7 @@ function renderLine(line: readonly HighlightSpan[], index: number): ReactNode { ) } -export function CodeBlock({ code, lang, streaming, className, lineNumbers = false, copyLabel, copiedLabel, preview }: CodeBlockProps) { +export function CodeBlock({ code, lang, streaming, className, lineNumbers = false, copyLabel, copiedLabel }: CodeBlockProps) { const trimmed = code.endsWith('\n') ? code.slice(0, -1) : code const sourceLines = lineNumbers ? trimmed.split('\n') : undefined const rootRef = useRef(null) @@ -147,13 +143,13 @@ export function CodeBlock({ code, lang, streaming, className, lineNumbers = fals [streaming, highlighting, streamedBody, trimmed, lang, loaded], ) const [copied, setCopied] = useState(false) - const [showSource, setShowSource] = useState(false) - const previewAvailable = preview !== undefined && streaming !== true - const showingPreview = previewAvailable && !showSource const onCopy = useCallback(() => { if (copied) return - void writeClipboard(trimmed).then((ok) => { + /* v8 ignore next -- both arms always mount a
; trimmed is the
+       typed fallback if the DOM shape ever diverges. */
+    const text = rootRef.current?.querySelector('pre')?.textContent ?? trimmed
+    void writeClipboard(text).then((ok) => {
       if (!ok) return
       setCopied(true)
       window.setTimeout(() => { setCopied(false) }, 1000)
@@ -175,22 +171,8 @@ export function CodeBlock({ code, lang, streaming, className, lineNumbers = fals
         
) - const copyActionLabel = copied ? copiedLabel : copyLabel - const copyAction = ( - - ) - return ( -
{/* The attribute carries no style here; owner stylesheets use it to pin the banner (sidebar code preview, horizontal scroll). */}
- {!previewAvailable &&
{lang ?? ''}
} +
{lang ?? ''}
- {previewAvailable && ( - - - - )} - {previewAvailable - ? {copyAction} - : copyAction} +
- {previewAvailable && } - {!showingPreview && body} + {body}
) } diff --git a/packages/client/ui-primitives/src/markdown/SourcePreview.module.css b/packages/client/ui-primitives/src/markdown/SourcePreview.module.css deleted file mode 100644 index 46a674ed0a..0000000000 --- a/packages/client/ui-primitives/src/markdown/SourcePreview.module.css +++ /dev/null @@ -1,35 +0,0 @@ -.canvas { - overflow: auto; - padding: 16px; - background: var(--dsw-alias-markdown-code-block); - border-radius: 12px; -} - -.diagram { - display: block; - max-width: 100%; - height: auto; - margin: 0 auto; -} - -.status { - padding: 16px; - color: var(--dsw-alias-label-secondary); -} - -.staging { - position: fixed; - top: 0; - left: -100000px; - visibility: hidden; - pointer-events: none; -} - -.frame { - display: block; - width: 100%; - height: 400px; - border: 0; - border-radius: 12px; - background: var(--dsw-alias-markdown-code-block); -} diff --git a/packages/client/ui-primitives/src/markdown/SourcePreview.tsx b/packages/client/ui-primitives/src/markdown/SourcePreview.tsx deleted file mode 100644 index 3ce07fb34d..0000000000 --- a/packages/client/ui-primitives/src/markdown/SourcePreview.tsx +++ /dev/null @@ -1,71 +0,0 @@ -/** Read-only diagram preview with source fallback and per-source async ownership. */ - -import { useEffect, useState } from 'react' -import css from './SourcePreview.module.css' -import { readPreviewTheme } from './preview-theme.ts' - -/** Localized preview states; the diagram source remains verbatim. */ -export interface PreviewLabels { - diagram: string - loading: string - error: string -} - -type Result = { kind: 'ok'; code: string; src: string } | { kind: 'error'; code: string } - -/* v8 ignore next 3 -- closed-union backstop; only reached if a result is forged */ -function assertNever(value: never): never { - throw new Error(`unreachable preview result: ${String(value)}`) -} - -/** - * Display a complete document or diagram, retaining the source when rendering fails. - * @param props - Source and complete localized labels. Source and document theme changes cancel obsolete renders. - * @returns A loading status, an inert image or sandboxed document, or an error with the original source. - */ -export function SourcePreview({ code, labels, render, document: isDocument = false }: { - code: string - labels: PreviewLabels - render: (code: string, signal: AbortSignal) => string | Promise - document?: boolean -}) { - const [result, setResult] = useState(null) - useEffect(() => { - let controller: AbortController | undefined - let palette: string | undefined - const update = () => { - const next = JSON.stringify(readPreviewTheme()) - if (next === palette) return - palette = next - controller?.abort() - const current = new AbortController() - controller = current - void (async () => await render(code, current.signal))().then( - (src) => { if (!current.signal.aborted) setResult({ kind: 'ok', code, src }) }, - () => { if (!current.signal.aborted) setResult({ kind: 'error', code }) }, - ) - } - // Isolated image/frame documents cannot inherit the host's CSS variables. - const observer = new MutationObserver(update) - observer.observe(document.documentElement, { attributes: true, attributeFilter: ['style'] }) - observer.observe(document.body, { attributes: true, attributeFilter: ['style', 'data-ds-dark-theme'] }) - update() - return () => { observer.disconnect(); controller?.abort() } - }, [code, render]) - - if (result?.code !== code) return
{labels.loading}
- switch (result.kind) { - case 'ok': - if (isDocument) return -
`, new AbortController().signal) - const parsed = new DOMParser().parseFromString(doc, 'text/html') - expect(parsed.querySelector('h1')?.textContent).toBe('Example') - expect(parsed.querySelector('style')?.textContent).toBe('h1 { color: green }') - expect(parsed.querySelector('script, iframe, base, [href], [action], [formaction], [onload]')).toBeNull() - expect(parsed.querySelectorAll('meta[http-equiv]').length).toBe(1) - expect(parsed.querySelector('meta[http-equiv]')?.getAttribute('content')).toContain("default-src 'none'") - }) - - it('encodes SVG as an image rather than executable document markup', () => { - const code = '' - const url = renderSvg(code, new AbortController().signal) - expect(url).toMatch(/^data:image\/svg\+xml;charset=utf-8,/) - expect(decodeURIComponent(url.slice(url.indexOf(',') + 1))).toBe(code) - }) - - it.each(['', '', ''])('rejects non-SVG or malformed XML: %s', (code) => { - expect(() => renderSvg(code, new AbortController().signal)).toThrow('Invalid SVG document') - }) - - it.each([renderHtml, renderSvg])('does not prepare a cancelled document', (renderDocument) => { - const controller = new AbortController() - controller.abort() - expect(() => renderDocument(svg, controller.signal)).toThrow(controller.signal.reason) - }) -}) diff --git a/packages/client/ui-primitives/tests/graphviz-runtime.client.spec.ts b/packages/client/ui-primitives/tests/graphviz-runtime.client.spec.ts deleted file mode 100644 index 1a7b170516..0000000000 --- a/packages/client/ui-primitives/tests/graphviz-runtime.client.spec.ts +++ /dev/null @@ -1,67 +0,0 @@ -// @vitest-environment jsdom - -import { afterEach, beforeEach, expect, it, vi } from 'vitest' - -const instance = vi.fn() -beforeEach(() => { - vi.resetModules() - instance.mockReset() - vi.doMock('@viz-js/viz', () => ({ instance })) -}) -afterEach(() => { vi.doUnmock('@viz-js/viz') }) - -it('retries failed initialization and reuses the loaded runtime', async () => { - const renderString = vi.fn().mockReturnValue('') - instance.mockRejectedValueOnce(new Error('WASM unavailable')).mockResolvedValue({ renderString }) - const { renderGraphviz } = await import('../src/markdown/graphviz.ts') - await expect(renderGraphviz('digraph {}', new AbortController().signal)).rejects.toThrow('WASM unavailable') - await expect(renderGraphviz('digraph {}', new AbortController().signal)).resolves.toContain('data:image/svg+xml') - await renderGraphviz('digraph {}', new AbortController().signal) - expect(instance).toHaveBeenCalledTimes(2) -}) - -it('skips layout after cancellation during runtime loading', async () => { - const pending = Promise.withResolvers<{ renderString: ReturnType }>() - instance.mockReturnValue(pending.promise) - const { renderGraphviz } = await import('../src/markdown/graphviz.ts') - const controller = new AbortController() - const result = renderGraphviz('digraph {}', controller.signal) - const rejection = expect(result).rejects.toMatchObject({ name: 'AbortError' }) - controller.abort() - const renderString = vi.fn() - pending.resolve({ renderString }) - await rejection - expect(renderString).not.toHaveBeenCalled() -}) - -it('uses theme defaults without rewriting authored DOT attributes', async () => { - const previous = document.body.getAttribute('style') - try { - document.body.style.setProperty('--dsw-alias-label-primary', 'rgb(230, 231, 232)') - document.body.style.setProperty('--dsw-alias-label-secondary', '#aabbcc') - document.body.style.setProperty('--dsw-alias-label-tertiary', '#8899aa') - document.body.style.setProperty('--dsw-alias-bg-layer-2', '#222222') - const renderString = vi.fn().mockReturnValue('') - instance.mockResolvedValue({ renderString }) - const { renderGraphviz } = await import('../src/markdown/graphviz.ts') - const code = 'digraph { a [color="red"]; a -> b }' - await renderGraphviz(code, new AbortController().signal) - expect(renderString).toHaveBeenCalledWith(code, expect.objectContaining({ - nodeAttributes: { color: '#8899aa', fontcolor: '#e6e7e8', fillcolor: '#222222', style: 'filled' }, - edgeAttributes: { color: '#aabbcc', fontcolor: '#e6e7e8' }, - })) - await renderGraphviz(code, new AbortController().signal, { - engine: 'neato', format: 'plain', yInvert: true, - graphAttributes: { ranksep: 1 }, nodeAttributes: { shape: 'box' }, edgeAttributes: { color: 'red' }, - }) - expect(renderString).toHaveBeenLastCalledWith(code, { - engine: 'neato', format: 'svg', yInvert: true, - graphAttributes: { bgcolor: 'transparent', fontcolor: '#e6e7e8', ranksep: 1 }, - nodeAttributes: { color: '#8899aa', fontcolor: '#e6e7e8', fillcolor: '#222222', style: 'filled', shape: 'box' }, - edgeAttributes: { color: 'red', fontcolor: '#e6e7e8' }, - }) - } finally { - if (previous === null) document.body.removeAttribute('style') - else document.body.setAttribute('style', previous) - } -}) diff --git a/packages/client/ui-primitives/tests/mermaid-runtime.client.spec.ts b/packages/client/ui-primitives/tests/mermaid-runtime.client.spec.ts deleted file mode 100644 index c5a4ccd6d3..0000000000 --- a/packages/client/ui-primitives/tests/mermaid-runtime.client.spec.ts +++ /dev/null @@ -1,133 +0,0 @@ -// @vitest-environment jsdom - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const initialize = vi.fn() -const renderDiagram = vi.fn() - -beforeEach(() => { - vi.resetModules() - vi.resetAllMocks() - vi.doMock('mermaid', () => ({ default: { initialize, render: renderDiagram } })) -}) -afterEach(() => { vi.doUnmock('mermaid') }) - -describe('Mermaid runtime', () => { - it('allows another attempt after the runtime import fails', async () => { - vi.doMock('mermaid', () => { throw new Error('runtime unavailable') }) - const { renderMermaid } = await import('../src/markdown/mermaid.ts') - await expect(renderMermaid('first', new AbortController().signal)).rejects.toThrow() - expect(initialize).not.toHaveBeenCalled() - vi.doMock('mermaid', () => ({ default: { initialize, render: renderDiagram } })) - renderDiagram.mockResolvedValue({ svg: '' }) - await expect(renderMermaid('retry', new AbortController().signal)).resolves.toContain('data:image/svg+xml') - expect(renderDiagram).toHaveBeenCalledOnce() - }) - - it('accepts native configuration without weakening preview restrictions', async () => { - const { renderMermaid } = await import('../src/markdown/mermaid.ts') - renderDiagram.mockResolvedValue({ svg: '' }) - await renderMermaid('flowchart LR', new AbortController().signal, { - // oxlint-disable-next-line typescript/no-deprecated - layout: 'dagre', flowchart: { nodeSpacing: 40, htmlLabels: true }, - themeVariables: { primaryColor: '#123456' }, - securityLevel: 'loose', startOnLoad: true, suppressErrorRendering: false, htmlLabels: true, secure: [], - }) - expect(initialize.mock.calls[0]![0]).toMatchObject({ - layout: 'dagre', flowchart: { nodeSpacing: 40, htmlLabels: false }, - themeVariables: { primaryColor: '#123456', darkMode: false }, - securityLevel: 'strict', startOnLoad: false, suppressErrorRendering: true, htmlLabels: false, - }) - expect(initialize.mock.calls[0]![0]).toHaveProperty('secure', expect.arrayContaining(['securityLevel'])) - }) - - it('reuses the runtime and removes each measurement container after rendering', async () => { - const { renderMermaid } = await import('../src/markdown/mermaid.ts') - expect(initialize).not.toHaveBeenCalled() - const stages: HTMLElement[] = [] - const ids: string[] = [] - renderDiagram.mockImplementation(async (id: string, code: string, stage: HTMLElement) => { - expect(stage.isConnected).toBe(true) - expect(stage.getAttribute('aria-hidden')).toBe('true') - stages.push(stage) - ids.push(id) - return { svg: `${code}` } - }) - const results = await Promise.all([ - renderMermaid('中文', new AbortController().signal), - renderMermaid('second', new AbortController().signal), - ]) - expect(initialize).toHaveBeenCalledTimes(2) - expect(initialize).toHaveBeenCalledWith(expect.objectContaining({ - startOnLoad: false, securityLevel: 'strict', suppressErrorRendering: true, htmlLabels: false, - secure: [ - 'secure', 'securityLevel', 'startOnLoad', 'maxTextSize', 'maxEdges', - 'suppressErrorRendering', 'theme', 'themeVariables', 'themeCSS', 'htmlLabels', 'flowchart', - ], - })) - expect(results.map(url => decodeURIComponent(url.split(',')[1]!))).toEqual(['中文', 'second']) - expect(new Set(ids).size).toBe(2) - expect(stages.every(stage => !stage.isConnected)).toBe(true) - }) - - it('keeps theme initialization with its render and recovers the queue after failure', async () => { - const { renderMermaid } = await import('../src/markdown/mermaid.ts') - const first = Promise.withResolvers<{ svg: string }>() - renderDiagram.mockReturnValueOnce(first.promise).mockResolvedValue({ svg: '' }) - const light = renderMermaid('first', new AbortController().signal) - const failure = expect(light).rejects.toThrow('invalid') - await vi.waitFor(() => { expect(renderDiagram).toHaveBeenCalledOnce() }) - const previous = document.documentElement.style.colorScheme - try { - document.documentElement.style.colorScheme = 'dark' - const dark = renderMermaid('second', new AbortController().signal) - await Promise.resolve() - expect(initialize).toHaveBeenCalledOnce() - first.reject(new Error('invalid')) - await failure - await dark - expect(initialize.mock.calls[1]![0]).toMatchObject({ themeVariables: { darkMode: true } }) - } finally { - document.documentElement.style.colorScheme = previous - } - }) - - it('removes measurement DOM even when Mermaid rejects', async () => { - const { renderMermaid } = await import('../src/markdown/mermaid.ts') - let stage: HTMLElement | undefined - const error = new Error('bad diagram') - renderDiagram.mockImplementation(async (_id: string, _code: string, target: HTMLElement) => { - stage = target - target.innerHTML = 'partial render' - throw error - }) - await expect(renderMermaid('bad', new AbortController().signal)).rejects.toBe(error) - expect(stage?.isConnected).toBe(false) - }) - - it('preserves intrinsic diagram size instead of stretching percentage-width SVG images', async () => { - const { renderMermaid } = await import('../src/markdown/mermaid.ts') - renderDiagram.mockResolvedValue({ svg: '' }) - const url = await renderMermaid('flowchart LR', new AbortController().signal) - const svg = new DOMParser().parseFromString(decodeURIComponent(url.split(',')[1]!), 'image/svg+xml').documentElement - expect(svg.getAttribute('width')).toBe('420') - expect(svg.getAttribute('height')).toBe('180') - }) - - it('does not start cancelled work after loading the runtime', async () => { - const { renderMermaid } = await import('../src/markdown/mermaid.ts') - const controller = new AbortController() - controller.abort() - await expect(renderMermaid('unused', controller.signal)).rejects.toMatchObject({ name: 'AbortError' }) - expect(renderDiagram).not.toHaveBeenCalled() - }) - - it('allows another attempt after runtime initialization fails', async () => { - const { renderMermaid } = await import('../src/markdown/mermaid.ts') - initialize.mockImplementationOnce(() => { throw new Error('initialization failed') }) - await expect(renderMermaid('first', new AbortController().signal)).rejects.toThrow('initialization failed') - renderDiagram.mockResolvedValue({ svg: '' }) - await expect(renderMermaid('retry', new AbortController().signal)).resolves.toContain('data:image/svg+xml') - expect(initialize).toHaveBeenCalledTimes(2) - }) -}) diff --git a/packages/client/ui-primitives/tests/source-preview.client.spec.tsx b/packages/client/ui-primitives/tests/source-preview.client.spec.tsx deleted file mode 100644 index 7e63f06929..0000000000 --- a/packages/client/ui-primitives/tests/source-preview.client.spec.tsx +++ /dev/null @@ -1,162 +0,0 @@ -// @vitest-environment jsdom - -import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { SourcePreview } from '../src/markdown/SourcePreview.tsx' -import { CodeBlock } from '../src/markdown/CodeBlock.tsx' -import { renderMermaid } from '../src/markdown/mermaid.ts' -import { MarkdownText } from '../src/markdown/MarkdownText.tsx' -import { markdownLabels } from './labels.client.ts' - -vi.mock('../src/markdown/mermaid.ts', () => ({ renderMermaid: vi.fn() })) - -const labels = { - diagram: 'Mermaid diagram', loading: 'Rendering diagram…', error: 'Unable to render this diagram.', - preview: 'Preview', source: 'Source', -} -const preview = { - preview: labels.preview, source: labels.source, - mermaid: labels, graphviz: labels, svg: labels, html: labels, -} -const source = 'flowchart LR\n A[Input] --> B[Preview]' -const imageUrl = 'data:image/svg+xml;charset=utf-8,%3Csvg%2F%3E' - -afterEach(() => { cleanup(); vi.unstubAllGlobals() }) -beforeEach(() => { vi.resetAllMocks() }) - -describe('SourcePreview', () => { - it('updates mounted previews on theme changes and ignores obsolete completions', async () => { - const previous = document.documentElement.style.colorScheme - const old = Promise.withResolvers() - vi.mocked(renderMermaid).mockReturnValueOnce(old.promise).mockResolvedValue(imageUrl) - const view = render() - try { - const oldSignal = vi.mocked(renderMermaid).mock.calls[0]![1] - await act(async () => { document.documentElement.style.colorScheme = 'dark' }) - expect(oldSignal.aborted).toBe(true) - const image = await screen.findByRole('img') - await act(async () => { old.resolve('obsolete') }) - expect(image.getAttribute('src')).toBe(imageUrl) - await act(async () => { document.body.style.setProperty('--unrelated', '1') }) - expect(renderMermaid).toHaveBeenCalledTimes(2) - await act(async () => { document.documentElement.style.colorScheme = 'light' }) - expect(renderMermaid).toHaveBeenCalledTimes(3) - view.unmount() - await act(async () => { document.documentElement.style.colorScheme = 'dark' }) - expect(renderMermaid).toHaveBeenCalledTimes(3) - } finally { - view.unmount() - document.documentElement.style.colorScheme = previous - document.body.style.removeProperty('--unrelated') - } - }) - it('shows loading until rendering completes, then displays an image without inserting SVG', async () => { - const pending = Promise.withResolvers() - vi.mocked(renderMermaid).mockReturnValue(pending.promise) - const view = render() - expect(screen.getByRole('status').textContent).toBe(labels.loading) - expect(screen.queryByRole('img')).toBeNull() - await act(async () => { pending.resolve(imageUrl) }) - expect(screen.getByRole('img', { name: labels.diagram }).getAttribute('src')).toBe(imageUrl) - expect(view.container.querySelector('svg')).toBeNull() - expect(screen.queryByRole('status')).toBeNull() - }) - - it('retains invalid source and recovers when the source changes', async () => { - vi.mocked(renderMermaid).mockRejectedValueOnce(new Error('Parse error')) - const view = render() - expect((await screen.findByText(labels.error)).getAttribute('role')).toBe('status') - expect(view.container.querySelector('pre code')?.textContent).toBe('invalid') - const next = Promise.withResolvers() - vi.mocked(renderMermaid).mockReturnValue(next.promise) - view.rerender() - expect(screen.getByRole('status').textContent).toBe(labels.loading) - expect(view.container.querySelector('pre')).toBeNull() - await act(async () => { next.resolve(imageUrl) }) - expect(screen.getByRole('img').getAttribute('src')).toBe(imageUrl) - }) - - it.each(['resolve', 'reject'] as const)('ignores a stale %s after a newer source finishes', async (outcome) => { - const old = Promise.withResolvers() - const next = Promise.withResolvers() - vi.mocked(renderMermaid).mockReturnValueOnce(old.promise).mockReturnValueOnce(next.promise) - const view = render() - const oldSignal = vi.mocked(renderMermaid).mock.calls[0]![1] - view.rerender() - expect(oldSignal.aborted).toBe(true) - await act(async () => { next.resolve(imageUrl) }) - await act(async () => { - if (outcome === 'resolve') old.resolve('obsolete-image') - else old.reject(new Error('obsolete-error')) - }) - expect(screen.getByRole('img').getAttribute('src')).toBe(imageUrl) - expect(screen.queryByRole('status')).toBeNull() - }) - - it('cancels the owner when unmounted while loading', async () => { - const pending = Promise.withResolvers() - vi.mocked(renderMermaid).mockReturnValue(pending.promise) - const view = render() - const signal = vi.mocked(renderMermaid).mock.calls[0]![1] - view.unmount() - expect(signal.aborted).toBe(true) - await act(async () => { pending.reject(new Error('cancelled')) }) - expect(screen.queryByRole('img')).toBeNull() - }) -}) - -describe('Markdown Mermaid fences', () => { - it('retains a preview when switching to numbered source', async () => { - vi.mocked(renderMermaid).mockResolvedValue(imageUrl) - const view = render(, - previewLabel: labels.preview, sourceLabel: labels.source, - }} />) - const diagram = await screen.findByRole('img', { name: labels.diagram }) - expect(view.container.querySelector('[data-line-numbers]')).not.toBeNull() - fireEvent.click(screen.getByRole('button', { name: labels.source })) - expect([...view.container.querySelectorAll('code > .line')].map(line => line.textContent)).toEqual(source.split('\n')) - expect(diagram.isConnected).toBe(true) - expect(screen.queryByRole('img')).toBeNull() - fireEvent.click(screen.getByRole('button', { name: labels.preview })) - expect(screen.getByRole('img', { name: labels.diagram })).toBe(diagram) - expect(renderMermaid).toHaveBeenCalledOnce() - }) - - it('keeps streaming source literal, then offers preview, source and source copying', async () => { - const writeText = vi.fn().mockResolvedValue(undefined) - vi.stubGlobal('navigator', Object.create(navigator, { clipboard: { value: { writeText } } })) - vi.mocked(renderMermaid).mockResolvedValue(imageUrl) - const props = { text: `\`\`\`mermaid\n${source}\n\`\`\``, labels: { ...markdownLabels, preview } } - const view = render() - expect(view.container.querySelector('pre code')?.textContent).toBe(source) - expect(renderMermaid).not.toHaveBeenCalled() - expect(screen.queryByRole('button', { name: labels.source })).toBeNull() - view.rerender() - const diagram = await screen.findByRole('img', { name: labels.diagram }) - expect(screen.queryByText('mermaid')).toBeNull() - expect(screen.getByRole('button', { name: labels.source }).textContent).toBe('') - expect(screen.getByRole('button', { name: markdownLabels.code.copyLabel }).textContent).toBe('') - fireEvent.click(screen.getByRole('button', { name: markdownLabels.code.copyLabel })) - await screen.findByRole('button', { name: markdownLabels.code.copiedLabel }) - expect(writeText).toHaveBeenCalledWith(source) - fireEvent.click(screen.getByRole('button', { name: labels.source })) - expect(view.container.querySelector('pre code')?.textContent).toBe(source) - expect(screen.queryByRole('img')).toBeNull() - expect(diagram.isConnected).toBe(true) - expect(screen.queryByText('mermaid')).toBeNull() - fireEvent.click(screen.getByRole('button', { name: labels.preview })) - expect(screen.getByRole('img', { name: labels.diagram })).toBe(diagram) - expect(screen.queryByRole('status')).toBeNull() - expect(renderMermaid).toHaveBeenCalledOnce() - }) - - it('leaves other languages and consumers without preview labels as code', () => { - const view = render() - expect(view.container.querySelector('pre code')?.textContent).toBe(source) - view.rerender() - expect(view.container.querySelector('pre code')?.textContent).toBe('flowchart LR') - expect(renderMermaid).not.toHaveBeenCalled() - }) -}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 90df336c0a..04b7a7e33a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3327,18 +3327,12 @@ importers: '@types/react-dom': specifier: ~18.3.0 version: 18.3.7(@types/react@18.3.31) - '@viz-js/viz': - specifier: 3.30.0 - version: 3.30.0 anser: specifier: ^2.3.5 version: 2.3.5 clsx: specifier: ^2.0.0 version: 2.1.1 - dompurify: - specifier: 3.4.11 - version: 3.4.11 katex: specifier: ^0.16.47 version: 0.16.47 @@ -3351,9 +3345,6 @@ importers: mdast-util-math: specifier: ^3.0.0 version: 3.0.0 - mermaid: - specifier: 11.16.0 - version: 11.16.0 micromark-core-commonmark: specifier: ^2.0.3 version: 2.0.3 @@ -14295,9 +14286,6 @@ packages: '@vitest/utils@4.1.8': resolution: {integrity: sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==} - '@viz-js/viz@3.30.0': - resolution: {integrity: sha512-2zmcP55QY44uQfZ+GvgNHmGd8S6KUD+2eMur9AbYybSzrOd/m8nMUg0ZircJNiJRKGgVBKAE8kh0hAwpUFUFQQ==} - '@vscode/ripgrep-darwin-arm64@1.18.0': resolution: {integrity: sha512-r3ktHSvbFycQNF6sl7sNDPocpsI7J+mEzh1IaZFkY0spm3k2Z9t8hPAeOK7+p0l6p6/swkQC14XWX01low+94Q==} cpu: [arm64] @@ -20907,8 +20895,6 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.0 - '@viz-js/viz@3.30.0': {} - '@vscode/ripgrep-darwin-arm64@1.18.0': optional: true diff --git a/scripts/check-workspace-constraints.spec.ts b/scripts/check-workspace-constraints.spec.ts index 7d0177eb45..2042754128 100644 --- a/scripts/check-workspace-constraints.spec.ts +++ b/scripts/check-workspace-constraints.spec.ts @@ -3,7 +3,6 @@ import { describe, expect, it } from 'vitest' import { checkDshFamilyVersion, - checkWorkspaceManifest, checkExperimentalDependencyIsolation, checkExperimentalManifest, expectedDshPackageFiles, @@ -144,16 +143,3 @@ describe('package payload constraints', () => { ]) }) }) - -it('requires preview license notices in the published UI primitives payload', () => { - const name = '@deepseek-ai/dsh-client-ui-primitives' - const files = expectedDshPackageFiles({ name }) - expect(files).toContain('THIRD_PARTY_PREVIEW_NOTICES.txt') - const check = (entries: readonly string[]) => checkWorkspaceManifest({ - dir: 'packages/client/ui-primitives', manifest: { name, files: [...entries] }, - }).filter(message => message.includes('package.json files')) - expect(check(files)).toEqual([]) - expect(check(files.filter(file => file !== 'THIRD_PARTY_PREVIEW_NOTICES.txt'))).toEqual([ - expect.stringContaining('package.json files must be'), - ]) -}) diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index 744b5be9ca..95a8835eeb 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -155,7 +155,7 @@ const packageFileExtras: Readonly> = { // them through its own CSS pipeline, so the sheets are published artifacts. // The glob covers whichever sheets a package emits; sourcemaps stay // unpublished, as everywhere else in the repository. - '@deepseek-ai/dsh-client-ui-primitives': ['lib/**/*.css', 'THIRD_PARTY_PREVIEW_NOTICES.txt'], + '@deepseek-ai/dsh-client-ui-primitives': ['lib/**/*.css'], '@deepseek-ai/dsh-client-ui-dockkit': ['lib/**/*.css'], '@deepseek-ai/dsh-client-web': ['lib/**/*.css'], '@deepseek-ai/dsh-client-ui-theme': ['lib/styles'], diff --git a/scripts/gen-third-party-notices.spec.ts b/scripts/gen-third-party-notices.spec.ts index 46b1b35d11..fabce6e8c9 100644 --- a/scripts/gen-third-party-notices.spec.ts +++ b/scripts/gen-third-party-notices.spec.ts @@ -4,7 +4,6 @@ import { tmpdir } from 'node:os' import { describe, expect, it } from 'vitest' import { CLAUDE_AGENT_SDK_PACKAGE, - assertPreviewDistribution, assertRuntimeLicenses, claudeDistributionFromManifest, collectPythonDependencies, @@ -396,26 +395,3 @@ describe('manifestPatterns', () => { ]) }) }) - -describe('preview distribution licenses', () => { - const dependencies = { mermaid: '11.16.0', '@viz-js/viz': '3.30.0', dompurify: '3.4.11' } - const sources = [ - 'pkg:docker/emscripten/emsdk@5.0.7?platform=linux%2Famd64', - 'https://github.com/libexpat/libexpat/releases/download/R_2_8_4/expat-2.8.4.tar.gz', - 'https://gitlab.com/api/v4/projects/4207231/packages/generic/graphviz-releases/16.0.0/graphviz-16.0.0.tar.gz', - ] - it('accepts the reviewed wrapper and native sources', () => { - expect(() => { assertPreviewDistribution(dependencies, sources) }).not.toThrow() - const notices = readFileSync(resolve(root, 'packages/client/ui-primitives/THIRD_PARTY_PREVIEW_NOTICES.txt'), 'utf8') - expect(notices).toContain('Eclipse Public License - v 2.0') - expect(notices).toContain(sources[2]) - expect(notices).toContain(readFileSync(resolve(root, 'packages/client/ui-primitives/node_modules/mermaid/LICENSE'), 'utf8').trim()) - expect(notices).toContain(readFileSync(resolve(root, 'packages/client/ui-primitives/node_modules/dompurify/LICENSE'), 'utf8').trim()) - }) - it.each(Object.keys(dependencies))('rejects an unreviewed %s upgrade', (name) => { - expect(() => { assertPreviewDistribution({ ...dependencies, [name]: '99.0.0' }, sources) }).toThrow('preview notices: review') - }) - it.each([sources.slice(1), [...sources, 'https://example.com/new-library'], [...sources.slice(0, 2), 'graphviz-next']])('rejects changed embedded sources', (...uris) => { - expect(() => { assertPreviewDistribution(dependencies, uris) }).toThrow('native sources') - }) -}) diff --git a/scripts/gen-third-party-notices.ts b/scripts/gen-third-party-notices.ts index 2130723202..9e83956e5e 100644 --- a/scripts/gen-third-party-notices.ts +++ b/scripts/gen-third-party-notices.ts @@ -685,28 +685,6 @@ ${rows.join('\n')} ` } -/** - * Require renewed license review when a preview dependency or embedded source changes. - * @param dependencies - UI primitives' browser build dependencies. - * @param sources - Native source URIs from the installed Viz.js build provenance. - * @returns Nothing; throws when a version or native source differs from the reviewed distribution. - */ -export function assertPreviewDistribution(dependencies: Record, sources: readonly string[]): void { - for (const [name, version] of Object.entries({ mermaid: '11.16.0', '@viz-js/viz': '3.30.0', dompurify: '3.4.11' })) { - if (dependencies[name] !== version) { - throw new Error(`preview notices: review ${name} ${String(dependencies[name])} licenses and update THIRD_PARTY_PREVIEW_NOTICES.txt`) - } - } - const expected = [ - 'pkg:docker/emscripten/emsdk@5.0.7?platform=linux%2Famd64', - 'https://github.com/libexpat/libexpat/releases/download/R_2_8_4/expat-2.8.4.tar.gz', - 'https://gitlab.com/api/v4/projects/4207231/packages/generic/graphviz-releases/16.0.0/graphviz-16.0.0.tar.gz', - ] - if (sources.length !== expected.length || expected.some(uri => !sources.includes(uri))) { - throw new Error('preview notices: review the changed Viz.js native sources and their distribution licenses') - } -} - /** * Render the complete notices document. * @returns The exact bytes THIRD_PARTY_NOTICES.md must hold after resolving browser inputs. @@ -717,16 +695,6 @@ export async function render(): Promise { // the manifests map it was resolved from; render() owns that single load. workspaceLinkedManifestCache.clear() const { manifests, names } = loadWorkspaceManifests() - const previewPackage = 'packages/client/ui-primitives' - const provenance = JSON.parse(readFileSync(resolve(root, previewPackage, 'node_modules/@viz-js/viz/lib/provenance.json'), 'utf8')) as { - predicate: { buildDefinition: { resolvedDependencies: { uri: string }[] } } - } - const previewDependencies = manifests.get(`${previewPackage}/package.json`)?.devDependencies - if (previewDependencies === undefined) throw new Error('preview notices: missing UI primitives browser dependencies') - assertPreviewDistribution( - previewDependencies, - provenance.predicate.buildDefinition.resolvedDependencies.map(source => source.uri), - ) const npm = collectNpmDeps(manifests, names, browser) const runtimeDeps = npm.filter(dep => dep.runtime) const devDeps = npm.filter(dep => !dep.runtime) @@ -767,8 +735,6 @@ External packages installed for runtime use or distributed inside the prebuilt b ${renderNpmTable(runtimeDeps)} -The Markdown preview distribution also contains Graphviz 16.0.0 (EPL-2.0), Expat 2.8.4 (MIT), and Emscripten 5.0.7 runtime code (MIT/NCSA) inside \`@viz-js/viz\` 3.30.0. The wrapper's MIT metadata does not relicense these components. [Preview notices](packages/client/ui-primitives/THIRD_PARTY_PREVIEW_NOTICES.txt) preserve their full license texts and Graphviz source availability, together with Mermaid's MIT and DOMPurify's selected Apache-2.0 terms. The UI primitives npm package includes this file; the Web build emits it as \`preview-third-party-notices.txt\`. - pnpm applies local patches to the following packages at install time, so shipped artifacts carry modified copies; each patch file is the complete record of the modification: ${patchedLines.join('\n')} diff --git a/tsconfig.host.json b/tsconfig.host.json index e7abea1600..c5a3f118b3 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -67,7 +67,6 @@ "apps/web/tests/math-rendering.e2e.ts", "apps/web/tests/markdown-cjk-strong.e2e.ts", "apps/web/tests/markdown-inline-code-links.e2e.ts", - "apps/web/tests/markdown-mermaid.e2e.ts", "apps/web/tests/clickable-links-gallery.e2e.ts", "apps/web/tests/queue-actions.e2e.ts", "apps/web/tests/queue-image.e2e.ts", From dd393c18202b25f9734d51cb61aac8fa8b2c5ecf Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 9 Sep 2026 20:18:50 +0800 Subject: [PATCH 065/111] release(dsh): 0.1.5-alpha.2 --- apps/cli/package.json | 2 +- apps/desktop-host/package.json | 2 +- apps/desktop/package.json | 2 +- apps/web/package.json | 2 +- package.json | 2 +- packages/acp/acp/package.json | 2 +- packages/api/gateway/package.json | 2 +- packages/api/remotes/package.json | 2 +- packages/api/session-controller/package.json | 2 +- packages/api/settings-controller/package.json | 2 +- packages/api/workspace-controller/package.json | 2 +- packages/api/workspace-files/package.json | 2 +- packages/attachment/attachment-local/package.json | 2 +- packages/attachment/attachment/package.json | 2 +- packages/boot/app-boot/package.json | 2 +- packages/boot/cmdline/package.json | 2 +- packages/bundle/acp-app/package.json | 2 +- packages/bundle/base/package.json | 2 +- packages/bundle/headless/package.json | 2 +- packages/bundle/sdk-app/package.json | 2 +- packages/bundle/sdk-minimal/package.json | 2 +- packages/bundle/web-app/package.json | 2 +- packages/client/connection/package.json | 2 +- packages/client/file-upload/package.json | 2 +- packages/client/hmr/package.json | 2 +- packages/client/locale/package.json | 2 +- packages/client/modules/package.json | 2 +- packages/client/resources/package.json | 2 +- packages/client/store/package.json | 2 +- packages/client/ui-agent-preset/package.json | 2 +- packages/client/ui-approval/package.json | 2 +- packages/client/ui-attachment/package.json | 2 +- packages/client/ui-brand-official/package.json | 2 +- packages/client/ui-chat/package.json | 2 +- packages/client/ui-commands/package.json | 2 +- packages/client/ui-conversation/package.json | 2 +- packages/client/ui-deliverables/package.json | 2 +- packages/client/ui-directory-picker-browse/package.json | 2 +- packages/client/ui-directory-picker-native/package.json | 2 +- packages/client/ui-dockkit/package.json | 2 +- packages/client/ui-goal/package.json | 2 +- packages/client/ui-input-trigger/package.json | 2 +- packages/client/ui-jobs/package.json | 2 +- packages/client/ui-layout/package.json | 2 +- packages/client/ui-message-feedback/package.json | 2 +- packages/client/ui-model-selection/package.json | 2 +- packages/client/ui-open-in-app/package.json | 2 +- packages/client/ui-permission-presets/package.json | 2 +- packages/client/ui-plan/package.json | 2 +- packages/client/ui-primitives/package.json | 2 +- packages/client/ui-reference/package.json | 2 +- packages/client/ui-renderer/package.json | 2 +- packages/client/ui-schedule/package.json | 2 +- packages/client/ui-session/package.json | 2 +- packages/client/ui-settings-general/package.json | 2 +- packages/client/ui-settings-models/package.json | 2 +- packages/client/ui-settings-plugin-inventory/package.json | 2 +- packages/client/ui-settings-plugins/package.json | 2 +- packages/client/ui-settings/package.json | 2 +- packages/client/ui-sidebar-documentpreview/package.json | 2 +- packages/client/ui-sidebar-files/package.json | 2 +- packages/client/ui-sidebar-right/package.json | 2 +- packages/client/ui-sidebar/package.json | 2 +- packages/client/ui-skill/package.json | 2 +- packages/client/ui-slots/package.json | 2 +- packages/client/ui-subagent/package.json | 2 +- packages/client/ui-theme/package.json | 2 +- packages/client/ui-tool/package.json | 2 +- packages/client/ui-trajectory/package.json | 2 +- packages/client/ui-user-questions/package.json | 2 +- packages/client/ui-workflow-run/package.json | 2 +- packages/client/ui-workspace/package.json | 2 +- packages/client/web/package.json | 2 +- packages/code-runtime/code-runtime-worker-thread/package.json | 2 +- packages/code-runtime/code-runtime/package.json | 2 +- packages/compaction/command-compact/package.json | 2 +- packages/compaction/compaction-basic/package.json | 2 +- packages/compaction/compaction-tool-result-pruner/package.json | 2 +- packages/compaction/compaction/package.json | 2 +- packages/context/agent-instructions/package.json | 2 +- packages/context/file-reference-local/package.json | 2 +- packages/context/file-reference/package.json | 2 +- packages/context/session-reference/package.json | 2 +- packages/context/time-context/package.json | 2 +- packages/context/tmux-context/package.json | 2 +- packages/core/agent-default-model/package.json | 2 +- packages/core/agent-loop/package.json | 2 +- packages/core/agent-tool-presentation/package.json | 2 +- packages/core/agent/package.json | 2 +- packages/core/scope/package.json | 2 +- packages/core/session/package.json | 2 +- packages/core/system-prompt/package.json | 2 +- packages/core/tools/package.json | 2 +- packages/credentials/authorization/package.json | 2 +- packages/credentials/credentials-local/package.json | 2 +- packages/credentials/credentials/package.json | 2 +- packages/e2b/e2b/package.json | 2 +- packages/e2b/fs-e2b/package.json | 2 +- packages/e2b/subprocess-e2b/package.json | 2 +- packages/experimental/agent-team-profile/package.json | 2 +- packages/experimental/agent-team-web-profile/package.json | 2 +- packages/experimental/agent-team/package.json | 2 +- packages/experimental/client-ui-agent-team/package.json | 2 +- packages/experimental/code-runtime-python/package.json | 2 +- packages/experimental/inspector/package.json | 2 +- packages/experimental/tool-agent-team/package.json | 2 +- packages/experimental/webworker-packer/package.json | 2 +- packages/experimental/webworker-runtime/package.json | 2 +- packages/extensions/cordis-client-runner/package.json | 2 +- packages/extensions/cordis-host-runner/package.json | 2 +- packages/extensions/tool-cordis/package.json | 2 +- packages/extensions/ui-cordis/package.json | 2 +- packages/feedback/command-feedback/package.json | 2 +- packages/feedback/message-feedback/package.json | 2 +- packages/fs/fs-local/package.json | 2 +- packages/fs/fs-observation-policy/package.json | 2 +- packages/fs/fs-sandbox/package.json | 2 +- packages/fs/fs/package.json | 2 +- packages/fs/tool-fs-search/package.json | 2 +- packages/fs/tool-fs/package.json | 2 +- packages/fs/tool-present/package.json | 2 +- packages/fs/tool-str-replace-editor/package.json | 2 +- packages/goal/command-goal/package.json | 2 +- packages/goal/goal-round-driver/package.json | 2 +- packages/goal/goal/package.json | 2 +- packages/goal/tool-goal/package.json | 2 +- packages/guard/repeat-tool-reminder/package.json | 2 +- packages/guard/timeout-policy/package.json | 2 +- packages/hooks/hook-protocol/package.json | 2 +- packages/hooks/hooks-claude-code/package.json | 2 +- packages/hooks/hooks-codex/package.json | 2 +- packages/host/directory-picker-auto/package.json | 2 +- packages/host/directory-picker-browse/package.json | 2 +- packages/host/directory-picker-native/package.json | 2 +- packages/host/directory-picker/package.json | 2 +- packages/host/frontend-static/package.json | 2 +- packages/host/open-in-app/package.json | 2 +- packages/host/plugin-inventory/package.json | 2 +- packages/host/webserver/package.json | 2 +- packages/identity/anonymous-user-id/package.json | 2 +- packages/interaction/commands/package.json | 2 +- packages/interaction/permission-presets/package.json | 2 +- packages/interaction/tool-ask-user/package.json | 2 +- packages/interaction/user-approval/package.json | 2 +- packages/interaction/user-questions/package.json | 2 +- packages/jobs/jobs-local/package.json | 2 +- packages/jobs/jobs/package.json | 2 +- packages/jobs/tool-jobs/package.json | 2 +- packages/llm/deepseek-llm-api-extensions/package.json | 2 +- packages/llm/llm-deepseek/package.json | 2 +- packages/llm/llm-pi-ai/package.json | 2 +- packages/llm/llm-retry/package.json | 2 +- packages/llm/llm/package.json | 2 +- packages/llm/plugin-package-inventory-deepseek/package.json | 2 +- packages/llm/token-meter/package.json | 2 +- packages/lsp/lsp-stdio/package.json | 2 +- packages/lsp/lsp/package.json | 2 +- packages/lsp/tool-lsp/package.json | 2 +- packages/mcp/mcp-client/package.json | 2 +- packages/plan/plan-mode/package.json | 2 +- packages/preset/agent-presets/package.json | 2 +- packages/preset/persona/package.json | 2 +- packages/runtime-diagnostics/invariants/package.json | 2 +- packages/sandbox/sandbox-local/package.json | 2 +- packages/sandbox/sandbox-policy/package.json | 2 +- packages/sandbox/sandbox-windows-acl/package.json | 2 +- packages/sandbox/sandbox/package.json | 2 +- packages/schedule/schedule/package.json | 2 +- packages/sdk/client/package.json | 2 +- packages/sdk/protocol/package.json | 2 +- packages/sdk/server/package.json | 2 +- packages/session-query/session-log-export/package.json | 2 +- packages/session-query/session-query-sqlite/package.json | 2 +- packages/session-query/session-query/package.json | 2 +- packages/session-query/tool-session-query/package.json | 2 +- packages/session/session-checkpoint-policy/package.json | 2 +- packages/session/session-format-catalog/package.json | 2 +- packages/session/session-format-v0-to-v1/package.json | 2 +- packages/session/session-format-v1-to-v2/package.json | 2 +- packages/session/session-format-v2-to-v3/package.json | 2 +- packages/session/session-format/package.json | 2 +- packages/session/session-log-deepseek/package.json | 2 +- packages/session/session-persistence-jsonl/package.json | 2 +- packages/session/session-persistence/package.json | 2 +- packages/session/session-projection-cache/package.json | 2 +- packages/session/session-projection/package.json | 2 +- packages/session/session-stats/package.json | 2 +- packages/session/session-telemetry-otel/package.json | 2 +- packages/session/session-telemetry/package.json | 2 +- packages/session/session-title-all-prompts-llm/package.json | 2 +- packages/session/session-title-first-prompt-llm/package.json | 2 +- packages/session/session-title-llm/package.json | 2 +- packages/session/session-title/package.json | 2 +- packages/session/session-turn-outline/package.json | 2 +- packages/settings/settings-file/package.json | 2 +- packages/settings/settings/package.json | 2 +- packages/shell/bash-local/package.json | 2 +- packages/shell/bash-sandbox/package.json | 2 +- packages/shell/pwsh-local/package.json | 2 +- packages/shell/pwsh-sandbox/package.json | 2 +- packages/shell/shell-env/package.json | 2 +- packages/shell/shell/package.json | 2 +- packages/shell/tool-bash-persistent/package.json | 2 +- packages/shell/tool-bash/package.json | 2 +- packages/shell/tool-pwsh-persistent/package.json | 2 +- packages/shell/tool-pwsh/package.json | 2 +- packages/skill/skill-badge/package.json | 2 +- packages/skill/skill-filesystem/package.json | 2 +- packages/skill/skill/package.json | 2 +- packages/skill/tool-skill/package.json | 2 +- packages/spill/spill-local/package.json | 2 +- packages/spill/spill-policy/package.json | 2 +- packages/spill/spill/package.json | 2 +- packages/storage/storage-domain/package.json | 2 +- packages/storage/storage-json/package.json | 2 +- packages/storage/storage-sqlite/package.json | 2 +- packages/storage/storage/package.json | 2 +- packages/subagent/subagent-acp/package.json | 2 +- packages/subagent/subagent-claude-code/package.json | 2 +- packages/subagent/subagent-codex/package.json | 2 +- packages/subagent/subagent-dsh-sdk/package.json | 2 +- packages/subagent/subagent-fork-in-process/package.json | 2 +- packages/subagent/subagent-in-process-driver/package.json | 2 +- packages/subagent/subagent-spawn-in-process/package.json | 2 +- packages/subagent/subagent/package.json | 2 +- packages/subagent/tool-subagent-control/package.json | 2 +- packages/subagent/tool-subagent/package.json | 2 +- packages/subprocess/subprocess-local/package.json | 2 +- packages/subprocess/subprocess/package.json | 2 +- packages/subprocess/win32-process/package.json | 2 +- packages/terminal/terminal-bash/package.json | 2 +- packages/terminal/terminal/package.json | 2 +- packages/terminal/tool-terminal/package.json | 2 +- packages/test-support/agent-loop-testkit/package.json | 2 +- packages/test-support/client-runtime/package.json | 2 +- packages/test-support/llm-mock-server/package.json | 2 +- packages/test-support/llm-replay/package.json | 2 +- packages/test-support/loader-smoke/package.json | 2 +- packages/test-support/session-snapshot/package.json | 2 +- packages/todo/tool-todo/package.json | 2 +- packages/typert/generator/package.json | 2 +- packages/typert/loader/package.json | 2 +- packages/typert/protocol/package.json | 2 +- packages/typert/registry/package.json | 2 +- packages/util/atomic-write/package.json | 2 +- packages/util/brand/package.json | 2 +- packages/util/chunked-list/package.json | 2 +- packages/util/crypto/package.json | 2 +- packages/util/deque/package.json | 2 +- packages/util/home-paths/package.json | 2 +- packages/util/http-proxy/package.json | 2 +- packages/util/launch-environment/package.json | 2 +- packages/util/native-command/package.json | 2 +- packages/util/output-retention/package.json | 2 +- packages/util/package-manifest/package.json | 2 +- packages/util/time/package.json | 2 +- packages/util/timeout/package.json | 2 +- packages/util/values/package.json | 2 +- packages/util/workspace-path/package.json | 2 +- packages/web/tool-web/package.json | 2 +- packages/web/web-fetch-http/package.json | 2 +- packages/web/web-search-deepseek/package.json | 2 +- packages/web/web-search-exa/package.json | 2 +- packages/web/web-search-perplexity/package.json | 2 +- packages/web/web/package.json | 2 +- packages/webhook/webhook-github/package.json | 2 +- packages/webhook/webhook/package.json | 2 +- packages/workflow/tool-ralph/package.json | 2 +- packages/workflow/tool-workflow/package.json | 2 +- packages/workflow/workflow-worker-thread/package.json | 2 +- packages/workflow/workflow/package.json | 2 +- packages/workspace/workspace/package.json | 2 +- 272 files changed, 272 insertions(+), 272 deletions(-) diff --git a/apps/cli/package.json b/apps/cli/package.json index 57084f2ac5..430451a4d4 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh", "description": "dsh CLI: profile boot, plugin management, and the browser UI alias", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/apps/desktop-host/package.json b/apps/desktop-host/package.json index fc44413c3d..68996a4894 100644 --- a/apps/desktop-host/package.json +++ b/apps/desktop-host/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-desktop-host", "description": "Private upstream-Node host process for the Electron desktop application", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "private": true, "license": "MIT", "type": "module", diff --git a/apps/desktop/package.json b/apps/desktop/package.json index d4a8146a1c..b428a0550c 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-desktop", "description": "Electron desktop shell for an isolated pnpm-installed dsh runtime", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "private": true, "license": "MIT", "type": "module", diff --git a/apps/web/package.json b/apps/web/package.json index 6c6dbc31aa..439036b0db 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-frontend", "description": "Web application entry: vite build over the @deepseek-ai/dsh-client-web shell library; dist/ served by apps/cli's dsh web", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/package.json b/package.json index a09fa3f997..09627ea0d6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-root", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "license": "MIT", "private": true, "type": "module", diff --git a/packages/acp/acp/package.json b/packages/acp/acp/package.json index c7a821c5d5..a5cf5a2d64 100644 --- a/packages/acp/acp/package.json +++ b/packages/acp/acp/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-acp", "description": "Automation-only Agent Client Protocol server for driving DeepSeek Harness agents over JSON-RPC stdio", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/api/gateway/package.json b/packages/api/gateway/package.json index 8530dfd990..8228fefce4 100644 --- a/packages/api/gateway/package.json +++ b/packages/api/gateway/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-gateway", "description": "Typert Remote Host dispatcher and Client API endpoint", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json index 0d7280d631..fafd62f2e3 100644 --- a/packages/api/remotes/package.json +++ b/packages/api/remotes/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-remotes", "description": "Remote BFF assembly for application-selected Host capabilities", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/api/session-controller/package.json b/packages/api/session-controller/package.json index 3603490bf4..0376282f77 100644 --- a/packages/api/session-controller/package.json +++ b/packages/api/session-controller/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-session-controller", "description": "Session Remote commands, cold reads, and live control transport", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/api/settings-controller/package.json b/packages/api/settings-controller/package.json index 2cfeb1e89a..91da932736 100644 --- a/packages/api/settings-controller/package.json +++ b/packages/api/settings-controller/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-settings-controller", "description": "Remote owner for the configuration surfaces over the settings-domain seams", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/api/workspace-controller/package.json b/packages/api/workspace-controller/package.json index fcfd014594..ffdc9e2080 100644 --- a/packages/api/workspace-controller/package.json +++ b/packages/api/workspace-controller/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-workspace-controller", "description": "Workspace Remote commands and reconnect-safe state transport", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/api/workspace-files/package.json b/packages/api/workspace-files/package.json index f867c86a1e..5ee4f54e67 100644 --- a/packages/api/workspace-files/package.json +++ b/packages/api/workspace-files/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-workspace-files", "description": "Workspace file service and Client resource provider: bounded reads, directory listing, and live metadata over the workspaceFiles Remote namespace", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/attachment/attachment-local/package.json b/packages/attachment/attachment-local/package.json index 71034eb684..afa52d914f 100644 --- a/packages/attachment/attachment-local/package.json +++ b/packages/attachment/attachment-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-attachment-local", "description": "Private content-addressed DSH_HOME attachment storage", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/attachment/attachment/package.json b/packages/attachment/attachment/package.json index 48cde83037..d98db07b61 100644 --- a/packages/attachment/attachment/package.json +++ b/packages/attachment/attachment/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-attachment", "description": "Durable immutable attachment storage seam for the DeepSeek Harness", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/boot/app-boot/package.json b/packages/boot/app-boot/package.json index 2e49da08de..64eedc7ae1 100644 --- a/packages/boot/app-boot/package.json +++ b/packages/boot/app-boot/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-app-boot", "description": "Shared boot glue for the app bins: .env loading, fail-loud Loader guards, snapshot-aware config resolution, and the Loader boot sequence", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/boot/cmdline/package.json b/packages/boot/cmdline/package.json index 6a4a9679d9..ea10cc6960 100644 --- a/packages/boot/cmdline/package.json +++ b/packages/boot/cmdline/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-cmdline", "description": "Immutable command-line handoff from a dsh launcher to any app plugin that injects cmdlineArgs", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/acp-app/package.json b/packages/bundle/acp-app/package.json index 3fb7dfaa3b..085b7341e5 100644 --- a/packages/bundle/acp-app/package.json +++ b/packages/bundle/acp-app/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-acp-app", "description": "The dsh ACP profile bundle: automation-only JSON-RPC stdio and process lifecycle over dsh-base", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json index 4f3407903a..64e33f55b1 100644 --- a/packages/bundle/base/package.json +++ b/packages/bundle/base/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-base", "description": "The shared dsh core as a profile bundle: the first patch layer of base-backed profiles, inserting core rows over the empty profile root", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/headless/package.json b/packages/bundle/headless/package.json index b8f809ebf3..1b9e611457 100644 --- a/packages/bundle/headless/package.json +++ b/packages/bundle/headless/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-headless", "description": "The dsh one-shot bundle: a direct core Agent/Session runner over dsh-base with no Host, HTTP, or browser layer", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/sdk-app/package.json b/packages/bundle/sdk-app/package.json index 1f39ae000b..4fc726caa8 100644 --- a/packages/bundle/sdk-app/package.json +++ b/packages/bundle/sdk-app/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sdk-app", "description": "The dsh SDK profile bundle: stdio JSON-RPC serving and process lifecycle over dsh-base", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/sdk-minimal/package.json b/packages/bundle/sdk-minimal/package.json index d95375faed..630f448daf 100644 --- a/packages/bundle/sdk-minimal/package.json +++ b/packages/bundle/sdk-minimal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sdk-minimal", "description": "The standalone minimal SDK profile bundle: JSON-RPC, one DeepSeek adapter, persistent shell, and JSONL sessions", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 692a6be952..6782a6e444 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-app", "description": "The dsh browser-surface bundle: the web patch layer over dsh-base plus the runtime glue plugin (frontend dist serving, web-surface prompt, bash runtime variables, URL line)", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/connection/package.json b/packages/client/connection/package.json index 9c999a025b..16dceff71f 100644 --- a/packages/client/connection/package.json +++ b/packages/client/connection/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-connection", "description": "Authenticated RPC transport, generation lifecycle, and browser fixture", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/file-upload/package.json b/packages/client/file-upload/package.json index 015a125351..fce7fd086d 100644 --- a/packages/client/file-upload/package.json +++ b/packages/client/file-upload/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-file-upload", "description": "Agent-scoped browser file upload, streaming intake, and staged receipt service", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/hmr/package.json b/packages/client/hmr/package.json index 6295fa479f..a3df146d0f 100644 --- a/packages/client/hmr/package.json +++ b/packages/client/hmr/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-hmr", "description": "Dev-only hot-reload driver for script-loaded client entries: SSE rebuilt frames → invalidate/prefetch → fiber swap through the vendored Loader entry", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/locale/package.json b/packages/client/locale/package.json index 10177f91e2..49928c9321 100644 --- a/packages/client/locale/package.json +++ b/packages/client/locale/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-locale", "description": "Locale plugin: Host-backed preference, extensible language catalog, browser fallback, and typed built-in dictionaries", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/modules/package.json b/packages/client/modules/package.json index 1c09baf8a6..fadb06d13e 100644 --- a/packages/client/modules/package.json +++ b/packages/client/modules/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-modules", "description": "Client module system, dual-face: node half composes the __DSH_BOOT__ entry graph (incremental dsh.client scan, bundle route, index tap, webPlugins service); browser half is the lazy-CJS module table the vendored cordis Loader consumes as its internal seam", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/resources/package.json b/packages/client/resources/package.json index cdcd4eed2d..21a47b7544 100644 --- a/packages/client/resources/package.json +++ b/packages/client/resources/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-resources", "description": "Unified client resource model: protocol-registered providers turn URL addresses into live values, consumed through the useResource global standard hook", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/store/package.json b/packages/client/store/package.json index 6bb0c0a212..fe71ce96ce 100644 --- a/packages/client/store/package.json +++ b/packages/client/store/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-store", "description": "React-free observable and snapshot-store contracts with the shared Zustand/Immer engine", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-agent-preset/package.json b/packages/client/ui-agent-preset/package.json index 9984d47613..857bc9c906 100644 --- a/packages/client/ui-agent-preset/package.json +++ b/packages/client/ui-agent-preset/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-agent-preset", "description": "Agent-preset surfaces: the default for later sessions, this session's seat, and the composition editor", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-approval/package.json b/packages/client/ui-approval/package.json index b4cecaa31d..00138c179e 100644 --- a/packages/client/ui-approval/package.json +++ b/packages/client/ui-approval/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-approval", "description": "Approval composer takeover over the scoped Remote Event waterfall", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-attachment/package.json b/packages/client/ui-attachment/package.json index 9adf41af13..429f1955b3 100644 --- a/packages/client/ui-attachment/package.json +++ b/packages/client/ui-attachment/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-attachment", "description": "Dynamic attachment presentation plugin for conversation input, message-image, and trajectory image slots", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-brand-official/package.json b/packages/client/ui-brand-official/package.json index 0db54ad4f0..62ddf9c91e 100644 --- a/packages/client/ui-brand-official/package.json +++ b/packages/client/ui-brand-official/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-brand-official", "description": "Official DeepSeek Harness brand occupants for the Web client's sidebar slots", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-chat/package.json b/packages/client/ui-chat/package.json index 7a5cb18771..ea9e994e91 100644 --- a/packages/client/ui-chat/package.json +++ b/packages/client/ui-chat/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-chat", "description": "Chat Conversation target, node definitions, renderers, and details surface", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-commands/package.json b/packages/client/ui-commands/package.json index 40ce5a3bff..4dc37a8261 100644 --- a/packages/client/ui-commands/package.json +++ b/packages/client/ui-commands/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-commands", "description": "Client command surface: global directory cache, '/' source, three command UI kinds, popupSelect registry", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index 987aa9574f..c1e62e4100 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-conversation", "description": "Target-neutral Conversation assembly, shell, composer, queue, and view navigation", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-deliverables/package.json b/packages/client/ui-deliverables/package.json index 16534d6f8d..dcac6ce807 100644 --- a/packages/client/ui-deliverables/package.json +++ b/packages/client/ui-deliverables/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-deliverables", "description": "Produced-files turn tail and clickable final-response file references for Web", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-directory-picker-browse/package.json b/packages/client/ui-directory-picker-browse/package.json index ec0a9d8ed0..f6544117ae 100644 --- a/packages/client/ui-directory-picker-browse/package.json +++ b/packages/client/ui-directory-picker-browse/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-directory-picker-browse", "description": "In-app directory browsing surface: the workspace directory-flow owner rendering the host's listing and creation primitives", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-directory-picker-native/package.json b/packages/client/ui-directory-picker-native/package.json index 9b370e32bb..1b81a714a8 100644 --- a/packages/client/ui-directory-picker-native/package.json +++ b/packages/client/ui-directory-picker-native/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-directory-picker-native", "description": "Native directory-picker surface: the renderless workspace directory-flow occupant driving the host's OS chooser", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-dockkit/package.json b/packages/client/ui-dockkit/package.json index d4ce40915b..df264e82f8 100644 --- a/packages/client/ui-dockkit/package.json +++ b/packages/client/ui-dockkit/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-dockkit", "description": "Docking layout kit: split-tree engine with invertible operations, and the React components that render and drive it (zero cordis)", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-goal/package.json b/packages/client/ui-goal/package.json index 5d516249f9..49f1105462 100644 --- a/packages/client/ui-goal/package.json +++ b/packages/client/ui-goal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-goal", "description": "Session goal surface: GoalBar docked above the composer, read from the goal session projection", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-input-trigger/package.json b/packages/client/ui-input-trigger/package.json index 65e30abb16..d6f3bd7fb6 100644 --- a/packages/client/ui-input-trigger/package.json +++ b/packages/client/ui-input-trigger/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-input-trigger", "description": "Input trigger pipeline: '/' and '@' detection, candidate menu, pick routing to registered sources", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-jobs/package.json b/packages/client/ui-jobs/package.json index cd0ea7ebe9..196567bb7a 100644 --- a/packages/client/ui-jobs/package.json +++ b/packages/client/ui-jobs/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-jobs", "description": "Session-header background-job list: live registry state mirrored from session/jobs frames", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", diff --git a/packages/client/ui-layout/package.json b/packages/client/ui-layout/package.json index a2c025f39d..1beae6f3b0 100644 --- a/packages/client/ui-layout/package.json +++ b/packages/client/ui-layout/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-layout", "description": "Shell plugin: three-column AppFrame with drag handles, ctx.layout viewing-state service (navigation + panels)", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-message-feedback/package.json b/packages/client/ui-message-feedback/package.json index d5fd7aed7e..343f289faa 100644 --- a/packages/client/ui-message-feedback/package.json +++ b/packages/client/ui-message-feedback/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-message-feedback", "description": "The Web feedback surface: per-message Like/Dislike in the assistant-message action strip and the feedback dialog behind Dislike and /feedback, backed by the messageFeedback and sessionFeedback Host Remotes", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-model-selection/package.json b/packages/client/ui-model-selection/package.json index d3c89480e8..d0951c3d7c 100644 --- a/packages/client/ui-model-selection/package.json +++ b/packages/client/ui-model-selection/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-model-selection", "description": "Model selection over the shared model catalog, Session projection, and session.selectModel", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-open-in-app/package.json b/packages/client/ui-open-in-app/package.json index 3b39d1e5d8..97c063097d 100644 --- a/packages/client/ui-open-in-app/package.json +++ b/packages/client/ui-open-in-app/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-open-in-app", "description": "Web Session-header \"Open In...\" split button opening the session workspace directory in a locally installed application", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-permission-presets/package.json b/packages/client/ui-permission-presets/package.json index e065186bcd..708f3fba3f 100644 --- a/packages/client/ui-permission-presets/package.json +++ b/packages/client/ui-permission-presets/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-permission-presets", "description": "Permission surfaces: a new-session default in General settings and a current-session /permission popup over the permissions projection", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-plan/package.json b/packages/client/ui-plan/package.json index 7080b89644..6fa0ddcc2f 100644 --- a/packages/client/ui-plan/package.json +++ b/packages/client/ui-plan/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-plan", "description": "Plan-mode composer control: the conversation.input.plan seat over the plan projection and the /plan command channel", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-primitives/package.json b/packages/client/ui-primitives/package.json index 2613c3aa98..ca37e9b16f 100644 --- a/packages/client/ui-primitives/package.json +++ b/packages/client/ui-primitives/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-primitives", "description": "Pure React atoms for the dsh web UI: controls, icons, markdown, and JSON inspectors (zero cordis)", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-reference/package.json b/packages/client/ui-reference/package.json index 2e57deca88..7bfe165621 100644 --- a/packages/client/ui-reference/package.json +++ b/packages/client/ui-reference/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-reference", "description": "Unified Web @file and @session reference source", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-renderer/package.json b/packages/client/ui-renderer/package.json index 2aefdb4b1e..f1f6d4721d 100644 --- a/packages/client/ui-renderer/package.json +++ b/packages/client/ui-renderer/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-renderer", "description": "Browser UI renderer: React slot bindings, ctx.uiRenderer, and the assembled application root", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-schedule/package.json b/packages/client/ui-schedule/package.json index e47d9ad54a..a2a6536d3d 100644 --- a/packages/client/ui-schedule/package.json +++ b/packages/client/ui-schedule/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-schedule", "description": "Read-only active Schedule catalog in the Web Session header", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", diff --git a/packages/client/ui-session/package.json b/packages/client/ui-session/package.json index 9996019293..3e09735e19 100644 --- a/packages/client/ui-session/package.json +++ b/packages/client/ui-session/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-session", "description": "Session Controller adapter for React and session-scoped slots", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-settings-general/package.json b/packages/client/ui-settings-general/package.json index 2d33e995f4..8327d2ca1e 100644 --- a/packages/client/ui-settings-general/package.json +++ b/packages/client/ui-settings-general/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-settings-general", "description": "Settings ownerless-copy and product onboarding plugin: the General section, shell trigger/header chrome content, settings dictionaries, and the versioned welcome notice", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-settings-models/package.json b/packages/client/ui-settings-models/package.json index da707bee60..a3d882b2e8 100644 --- a/packages/client/ui-settings-models/package.json +++ b/packages/client/ui-settings-models/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-settings-models", "description": "Models settings and shared product-onboarding dialogs over existing settings and credential joins", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-settings-plugin-inventory/package.json b/packages/client/ui-settings-plugin-inventory/package.json index 84d0b26e40..0755e07977 100644 --- a/packages/client/ui-settings-plugin-inventory/package.json +++ b/packages/client/ui-settings-plugin-inventory/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-settings-plugin-inventory", "description": "Read-only Cordis Loader inventory tab in Web Plugins settings", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-settings-plugins/package.json b/packages/client/ui-settings-plugins/package.json index c4b27fae88..acc7a836dd 100644 --- a/packages/client/ui-settings-plugins/package.json +++ b/packages/client/ui-settings-plugins/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-settings-plugins", "description": "Plugins settings section with feature-owned tabs and configurable host-plane plugin cards", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-settings/package.json b/packages/client/ui-settings/package.json index 305ecef2fa..dffc545328 100644 --- a/packages/client/ui-settings/package.json +++ b/packages/client/ui-settings/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-settings", "description": "Settings domain base plugin: the settings-namespace scope service and the canonical settings slot-type contract", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-sidebar-documentpreview/package.json b/packages/client/ui-sidebar-documentpreview/package.json index 8d592decc0..af3d91613f 100644 --- a/packages/client/ui-sidebar-documentpreview/package.json +++ b/packages/client/ui-sidebar-documentpreview/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-sidebar-documentpreview", "description": "Extensible document previews for Sidebar files: Markdown, highlighted code, images, PDF, HTML, and plain text", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-sidebar-files/package.json b/packages/client/ui-sidebar-files/package.json index ce97ee1ffc..619884d036 100644 --- a/packages/client/ui-sidebar-files/package.json +++ b/packages/client/ui-sidebar-files/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-sidebar-files", "description": "Workspace file tree tab type for the right Sidebar: lazy directory listing over the workspaceFiles Remote namespace, opening files into the Sidebar", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-sidebar-right/package.json b/packages/client/ui-sidebar-right/package.json index c8fad0215c..31b8055429 100644 --- a/packages/client/ui-sidebar-right/package.json +++ b/packages/client/ui-sidebar-right/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-sidebar-right", "description": "Right Sidebar: the docking surface's session-bound state, its panel and header expand control, and the navigation service over it", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-sidebar/package.json b/packages/client/ui-sidebar/package.json index 3fb78453b2..6fda5a97db 100644 --- a/packages/client/ui-sidebar/package.json +++ b/packages/client/ui-sidebar/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-sidebar", "description": "Sidebar plugin: session multi-level tree, search, grouping, state dots", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-skill/package.json b/packages/client/ui-skill/package.json index cefa0086f2..9db4e24efd 100644 --- a/packages/client/ui-skill/package.json +++ b/packages/client/ui-skill/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-skill", "description": "Web skill references and the dedicated skill tool row", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-slots/package.json b/packages/client/ui-slots/package.json index fce80c249e..f01270278e 100644 --- a/packages/client/ui-slots/package.json +++ b/packages/client/ui-slots/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-slots", "description": "Slot registry pure core: SlotMap declaration merging, single register composition API, four-share props types, store-seat types, renderer install seam", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-subagent/package.json b/packages/client/ui-subagent/package.json index fe75323062..1835c7f7a2 100644 --- a/packages/client/ui-subagent/package.json +++ b/packages/client/ui-subagent/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-subagent", "description": "Subagent conversation catalog, continuation routing UI, and '@' reference source", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-theme/package.json b/packages/client/ui-theme/package.json index 5f9dd4940c..610bd399ab 100644 --- a/packages/client/ui-theme/package.json +++ b/packages/client/ui-theme/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-theme", "description": "Theme plugin: Host bootstrap for the pre-plugin palette; DOM-free ThemeRuntime for light/dark/system state; --dsw-* token styles and Appearance settings row", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-tool/package.json b/packages/client/ui-tool/package.json index 3a3722d64d..14030de47a 100644 --- a/packages/client/ui-tool/package.json +++ b/packages/client/ui-tool/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-tool", "description": "Client Tool call-tree renderer and keyed per-tool presentation slot", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-trajectory/package.json b/packages/client/ui-trajectory/package.json index 7f9752a6a8..417c8a47e3 100644 --- a/packages/client/ui-trajectory/package.json +++ b/packages/client/ui-trajectory/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-trajectory", "description": "Trajectory event ledger with an interactive timing overview: pure-consumer plugin registering into the conversation ViewMap (no service)", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-user-questions/package.json b/packages/client/ui-user-questions/package.json index b32352c522..16883d3686 100644 --- a/packages/client/ui-user-questions/package.json +++ b/packages/client/ui-user-questions/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-user-questions", "description": "Web ask_user_question composer takeover and plan-review presentation UI", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-workflow-run/package.json b/packages/client/ui-workflow-run/package.json index 15ca5a0d2d..a44c93a427 100644 --- a/packages/client/ui-workflow-run/package.json +++ b/packages/client/ui-workflow-run/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-workflow-run", "description": "Durable workflow-run Conversation Node and nested member disclosure for dsh web", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-workspace/package.json b/packages/client/ui-workspace/package.json index 3a35cae4d4..72b3b8ff33 100644 --- a/packages/client/ui-workspace/package.json +++ b/packages/client/ui-workspace/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-workspace", "description": "Workspace picker plugin: one WorkspacePicker registered into the sidebar and empty-state workspace slots", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/web/package.json b/packages/client/web/package.json index 64b36efc5c..6a9a8364c0 100644 --- a/packages/client/web/package.json +++ b/packages/client/web/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-web", "description": "Web boot kernel: static module table, Cordis loader, framework-free boot page, and UI-renderer handoff", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/code-runtime/code-runtime-worker-thread/package.json b/packages/code-runtime/code-runtime-worker-thread/package.json index e69ccc2f52..a31e670376 100644 --- a/packages/code-runtime/code-runtime-worker-thread/package.json +++ b/packages/code-runtime/code-runtime-worker-thread/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-code-runtime-worker-thread", "description": "Worker-thread implementation of the DeepSeek Harness code-execution seam", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/code-runtime/code-runtime/package.json b/packages/code-runtime/code-runtime/package.json index a49f2cf0a9..4321c52d38 100644 --- a/packages/code-runtime/code-runtime/package.json +++ b/packages/code-runtime/code-runtime/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-code-runtime", "description": "Abstract code-execution seam (ctx.codeRuntime) for the DeepSeek Harness", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/compaction/command-compact/package.json b/packages/compaction/command-compact/package.json index faffbe4943..a18140fa85 100644 --- a/packages/compaction/command-compact/package.json +++ b/packages/compaction/command-compact/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-command-compact", "description": "Human-facing slash command for explicit session compaction", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/compaction/compaction-basic/package.json b/packages/compaction/compaction-basic/package.json index 69de3038dd..8de83e4f12 100644 --- a/packages/compaction/compaction-basic/package.json +++ b/packages/compaction/compaction-basic/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-compaction-basic", "description": "Token-meter-driven compaction policy and LLM summarization backend for the DeepSeek Harness", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/compaction/compaction-tool-result-pruner/package.json b/packages/compaction/compaction-tool-result-pruner/package.json index 3cf8b5d169..57dd414347 100644 --- a/packages/compaction/compaction-tool-result-pruner/package.json +++ b/packages/compaction/compaction-tool-result-pruner/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-compaction-tool-result-pruner", "description": "Replay-safe model-free head/middle/tail pruning for tool-result surface nodes", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/compaction/compaction/package.json b/packages/compaction/compaction/package.json index 54ad37fdd1..110298ffbf 100644 --- a/packages/compaction/compaction/package.json +++ b/packages/compaction/compaction/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-compaction", "description": "Abstract compaction service seam (ctx.compaction) for the DeepSeek Harness", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/context/agent-instructions/package.json b/packages/context/agent-instructions/package.json index 6c94153e57..a9b57dc9c0 100644 --- a/packages/context/agent-instructions/package.json +++ b/packages/context/agent-instructions/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent-instructions", "description": "Workspace context loader for AGENTS.md/CLAUDE.md instruction files", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/context/file-reference-local/package.json b/packages/context/file-reference-local/package.json index 8f419495dd..cb46191bc6 100644 --- a/packages/context/file-reference-local/package.json +++ b/packages/context/file-reference-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-file-reference-local", "description": "Local-filesystem ctx.fileReferences provider with bounded fuzzy indexes", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/context/file-reference/package.json b/packages/context/file-reference/package.json index c33a4cfece..890676082d 100644 --- a/packages/context/file-reference/package.json +++ b/packages/context/file-reference/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-file-reference", "description": "File-reference discovery contract and shared @file grammar", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/context/session-reference/package.json b/packages/context/session-reference/package.json index 444f4f5d7e..c5899f1094 100644 --- a/packages/context/session-reference/package.json +++ b/packages/context/session-reference/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-reference", "description": "Cross-session snapshot references and durable untrusted model context (ctx.sessionReferenceResolver)", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/context/time-context/package.json b/packages/context/time-context/package.json index 1d6e5a374c..634bfc2058 100644 --- a/packages/context/time-context/package.json +++ b/packages/context/time-context/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-time-context", "description": "Opt-in durable per-step context with the current time and elapsed time", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/context/tmux-context/package.json b/packages/context/tmux-context/package.json index 113aa345c3..9a3696c994 100644 --- a/packages/context/tmux-context/package.json +++ b/packages/context/tmux-context/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tmux-context", "description": "Opt-in durable per-step context with this agent's tmux pane and window location", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/core/agent-default-model/package.json b/packages/core/agent-default-model/package.json index 409b297f35..02f25d90b0 100644 --- a/packages/core/agent-default-model/package.json +++ b/packages/core/agent-default-model/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent-default-model", "description": "Default model selection shared by Agent entry points", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/core/agent-loop/package.json b/packages/core/agent-loop/package.json index d73c8085ea..627c46ff44 100644 --- a/packages/core/agent-loop/package.json +++ b/packages/core/agent-loop/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent-loop", "description": "The concrete agent loop plugin for the DeepSeek Harness", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/core/agent-tool-presentation/package.json b/packages/core/agent-tool-presentation/package.json index 55f24b532e..b1069260d4 100644 --- a/packages/core/agent-tool-presentation/package.json +++ b/packages/core/agent-tool-presentation/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent-tool-presentation", "description": "Agent-plane presentation selector: composes one agent's tools as PTC mode, native, or both", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/core/agent/package.json b/packages/core/agent/package.json index 2df9571f97..e654ac0d2f 100644 --- a/packages/core/agent/package.json +++ b/packages/core/agent/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent", "description": "Agent interface, registry, initiator scope, and event vocabulary for the DeepSeek Harness", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/core/scope/package.json b/packages/core/scope/package.json index 2922794f45..bb0d51aa0e 100644 --- a/packages/core/scope/package.json +++ b/packages/core/scope/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-scope", "description": "Scoped-context registration primitive (scope tags, scope-filtered event dispatch) for the DeepSeek Harness", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/core/session/package.json b/packages/core/session/package.json index 65db598136..7b7858cb3d 100644 --- a/packages/core/session/package.json +++ b/packages/core/session/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session", "description": "Event-sourced session store for the DeepSeek Harness", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/core/system-prompt/package.json b/packages/core/system-prompt/package.json index 7ca95e44c7..cb431d3fbd 100644 --- a/packages/core/system-prompt/package.json +++ b/packages/core/system-prompt/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-system-prompt", "description": "System prompt assembly registry for the DeepSeek Harness", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/core/tools/package.json b/packages/core/tools/package.json index ebb966cb43..174d6f6902 100644 --- a/packages/core/tools/package.json +++ b/packages/core/tools/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tools", "description": "Tool registry and execution pipeline for the DeepSeek Harness", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/credentials/authorization/package.json b/packages/credentials/authorization/package.json index 728f091539..2bfb81078c 100644 --- a/packages/credentials/authorization/package.json +++ b/packages/credentials/authorization/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-authorization", "description": "Authorization seam (ctx.authorization): plugin-owned flows that obtain a credential through a conversation with the human", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/credentials/credentials-local/package.json b/packages/credentials/credentials-local/package.json index 4bd2a9daa0..5e0a6ce461 100644 --- a/packages/credentials/credentials-local/package.json +++ b/packages/credentials/credentials-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-credentials-local", "description": "File-backed credentials provider ($DSH_HOME/.env under the live process environment) for the DeepSeek Harness", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/credentials/credentials/package.json b/packages/credentials/credentials/package.json index 0ccc71df24..d41f37d3ec 100644 --- a/packages/credentials/credentials/package.json +++ b/packages/credentials/credentials/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-credentials", "description": "Abstract credential seam (ctx.credentials): settings carry references to secrets, providers own the values", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/e2b/e2b/package.json b/packages/e2b/e2b/package.json index efcaa3d50c..373e619db7 100644 --- a/packages/e2b/e2b/package.json +++ b/packages/e2b/e2b/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-e2b", "description": "Shared E2B sandbox lifecycle for DeepSeek Harness provider adapters", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/e2b/fs-e2b/package.json b/packages/e2b/fs-e2b/package.json index c646180f50..e63f687f60 100644 --- a/packages/e2b/fs-e2b/package.json +++ b/packages/e2b/fs-e2b/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-fs-e2b", "description": "E2B filesystem implementation for DeepSeek Harness", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/e2b/subprocess-e2b/package.json b/packages/e2b/subprocess-e2b/package.json index e75003e983..a5f999e7d5 100644 --- a/packages/e2b/subprocess-e2b/package.json +++ b/packages/e2b/subprocess-e2b/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subprocess-e2b", "description": "E2B subprocess implementation for DeepSeek Harness", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/experimental/agent-team-profile/package.json b/packages/experimental/agent-team-profile/package.json index 699ebc29ec..219b268ddd 100644 --- a/packages/experimental/agent-team-profile/package.json +++ b/packages/experimental/agent-team-profile/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-agent-team-profile", "description": "Experimental profile bundle enabling Agent Teams over dsh-base", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/experimental/agent-team-web-profile/package.json b/packages/experimental/agent-team-web-profile/package.json index 4ca780b18c..48ea6d71ba 100644 --- a/packages/experimental/agent-team-web-profile/package.json +++ b/packages/experimental/agent-team-web-profile/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-agent-team-web-profile", "description": "Experimental Web profile layer for Agent Teams Remote and UI plugins", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/experimental/agent-team/package.json b/packages/experimental/agent-team/package.json index dee4fc8756..5f04d02601 100644 --- a/packages/experimental/agent-team/package.json +++ b/packages/experimental/agent-team/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-agent-team", "description": "Implicit-root Agent Teams roster, durable peer mailbox, and shared task DAG", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/experimental/client-ui-agent-team/package.json b/packages/experimental/client-ui-agent-team/package.json index 12fa3b7275..3584f6493c 100644 --- a/packages/experimental/client-ui-agent-team/package.json +++ b/packages/experimental/client-ui-agent-team/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-client-ui-agent-team", "description": "Web Agent Teams roster, task board, and teammate navigation", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/experimental/code-runtime-python/package.json b/packages/experimental/code-runtime-python/package.json index b179c1a16b..7db8fe5dfe 100644 --- a/packages/experimental/code-runtime-python/package.json +++ b/packages/experimental/code-runtime-python/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-code-runtime-python", "description": "CPython subprocess implementation of the DeepSeek Harness code-execution seam", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "repository": { "type": "git", "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", diff --git a/packages/experimental/inspector/package.json b/packages/experimental/inspector/package.json index e195c6f09a..a9c1e10725 100644 --- a/packages/experimental/inspector/package.json +++ b/packages/experimental/inspector/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-inspector", "description": "Experimental cross-realm CDP hub for Host debugging and Client Runtime inspection", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "private": true, "repository": { "type": "git", diff --git a/packages/experimental/tool-agent-team/package.json b/packages/experimental/tool-agent-team/package.json index eb41e65d5f..fb5779f90c 100644 --- a/packages/experimental/tool-agent-team/package.json +++ b/packages/experimental/tool-agent-team/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-tool-agent-team", "description": "Scoped model-facing Agent Teams tools over ctx.agentTeams", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/experimental/webworker-packer/package.json b/packages/experimental/webworker-packer/package.json index ba52e9e94e..264e8edea7 100644 --- a/packages/experimental/webworker-packer/package.json +++ b/packages/experimental/webworker-packer/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-webworker-packer", "description": "Build-time packer for the browser runtime's base VFS image and ordered data-overlay archives", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "private": true, "repository": { "type": "git", diff --git a/packages/experimental/webworker-runtime/package.json b/packages/experimental/webworker-runtime/package.json index a1a54e1e0f..a0d088ce71 100644 --- a/packages/experimental/webworker-runtime/package.json +++ b/packages/experimental/webworker-runtime/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-webworker-runtime", "description": "Browser-only harness runtime: in-memory VFS, module transform and loader, postMessage tunnel, and the dedicated Web Worker assembly, with the Node-compatibility layer that lets the host tree run unchanged", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "private": true, "repository": { "type": "git", diff --git a/packages/extensions/cordis-client-runner/package.json b/packages/extensions/cordis-client-runner/package.json index b6b0e3c9ac..c57e78e8ab 100644 --- a/packages/extensions/cordis-client-runner/package.json +++ b/packages/extensions/cordis-client-runner/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-cordis-client-runner", "description": "Browser half of dynamic dual-half plugin packages: event subscription, closure evaluation, guard facade, and loader entries", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/extensions/cordis-host-runner/package.json b/packages/extensions/cordis-host-runner/package.json index c673c2faaf..53e27100c2 100644 --- a/packages/extensions/cordis-host-runner/package.json +++ b/packages/extensions/cordis-host-runner/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-cordis-host-runner", "description": "Dynamic package definition registry, host-half sandbox lifecycle, and invoke handler table for model-mounted dual-half packages", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/extensions/tool-cordis/package.json b/packages/extensions/tool-cordis/package.json index 1499e1db10..da941bf469 100644 --- a/packages/extensions/tool-cordis/package.json +++ b/packages/extensions/tool-cordis/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-cordis", "description": "Self-referential cordis toolset: inspect the live runtime, mount and dispose model-written plugins", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/extensions/ui-cordis/package.json b/packages/extensions/ui-cordis/package.json index 20b473f2f6..07b34e3d7b 100644 --- a/packages/extensions/ui-cordis/package.json +++ b/packages/extensions/ui-cordis/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-cordis", "description": "Cordis dynamic-plugin definition card: the keyed cordis_define tool row with its run/stop switch", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/feedback/command-feedback/package.json b/packages/feedback/command-feedback/package.json index 39e700ddae..357e06639c 100644 --- a/packages/feedback/command-feedback/package.json +++ b/packages/feedback/command-feedback/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-command-feedback", "description": "Log-only session feedback: the record event, the sessionFeedback Host Remote, and the human-facing slash command", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/feedback/message-feedback/package.json b/packages/feedback/message-feedback/package.json index 456800ba92..7b0031ab1f 100644 --- a/packages/feedback/message-feedback/package.json +++ b/packages/feedback/message-feedback/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-message-feedback", "description": "Canonical Session-log ratings and notes for finalized assistant messages", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/fs/fs-local/package.json b/packages/fs/fs-local/package.json index ea0ba6695b..3b7492c05b 100644 --- a/packages/fs/fs-local/package.json +++ b/packages/fs/fs-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-fs-local", "description": "Local-filesystem implementation of the DeepSeek Harness filesystem seam (ctx.fs)", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/fs/fs-observation-policy/package.json b/packages/fs/fs-observation-policy/package.json index 4e59ada1ac..bb2f3d986f 100644 --- a/packages/fs/fs-observation-policy/package.json +++ b/packages/fs/fs-observation-policy/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-fs-observation-policy", "description": "File-context policy plugin for the DeepSeek Harness — observed-state, read-before-edit, and version-guarded write/edit added over the ctx.fs provider seam through the fs/* event gate (no service API)", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/fs/fs-sandbox/package.json b/packages/fs/fs-sandbox/package.json index 841e8ca041..f1eda99e86 100644 --- a/packages/fs/fs-sandbox/package.json +++ b/packages/fs/fs-sandbox/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-fs-sandbox", "description": "Sandbox-enforcing implementation of the DeepSeek Harness filesystem seam: fences write/edit by the per-call sandbox mode (read-only denies mutation, workspace-write contains it to the workspace + temp roots) while reads pass through", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/fs/fs/package.json b/packages/fs/fs/package.json index 6d4a55441d..4944f3f7db 100644 --- a/packages/fs/fs/package.json +++ b/packages/fs/fs/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-fs", "description": "Abstract filesystem capability seam (ctx.fs) for the DeepSeek Harness — vocabulary types, the FileSystem service (text IO + optional version-guarded atomic mutations), and the fs/* policy event vocabulary", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/fs/tool-fs-search/package.json b/packages/fs/tool-fs-search/package.json index 5d9842a174..db3715b502 100644 --- a/packages/fs/tool-fs-search/package.json +++ b/packages/fs/tool-fs-search/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-fs-search", "description": "Model-facing filesystem discovery tools (glob, grep) backed by the packaged ripgrep binary (@vscode/ripgrep)", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/fs/tool-fs/package.json b/packages/fs/tool-fs/package.json index e44c7a7f07..062a553050 100644 --- a/packages/fs/tool-fs/package.json +++ b/packages/fs/tool-fs/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-fs", "description": "Model-facing filesystem tools (read, write, edit) over the DeepSeek Harness filesystem seam (ctx.fs)", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/fs/tool-present/package.json b/packages/fs/tool-present/package.json index d1847ded9d..0baffa2345 100644 --- a/packages/fs/tool-present/package.json +++ b/packages/fs/tool-present/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-present", "description": "Explicit workspace file delivery declarations for the DeepSeek Harness", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/fs/tool-str-replace-editor/package.json b/packages/fs/tool-str-replace-editor/package.json index 9eb68b3a95..669f8da8a5 100644 --- a/packages/fs/tool-str-replace-editor/package.json +++ b/packages/fs/tool-str-replace-editor/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-str-replace-editor", "description": "Model-facing view, create, literal replace, and line insert tool over the Harness filesystem service", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/goal/command-goal/package.json b/packages/goal/command-goal/package.json index 238a65e732..c279129914 100644 --- a/packages/goal/command-goal/package.json +++ b/packages/goal/command-goal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-command-goal", "description": "Human-facing slash command for persisted same-session goals", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/goal/goal-round-driver/package.json b/packages/goal/goal-round-driver/package.json index 7fe8fb72ff..43704663e2 100644 --- a/packages/goal/goal-round-driver/package.json +++ b/packages/goal/goal-round-driver/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-goal-round-driver", "description": "Race-fenced same-session goal-round driver", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/goal/goal/package.json b/packages/goal/goal/package.json index 4558efc896..013acc8c31 100644 --- a/packages/goal/goal/package.json +++ b/packages/goal/goal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-goal", "description": "Event-sourced same-session goal state and lifecycle service for the DeepSeek Harness", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/goal/tool-goal/package.json b/packages/goal/tool-goal/package.json index 1204bbee09..c6836cf722 100644 --- a/packages/goal/tool-goal/package.json +++ b/packages/goal/tool-goal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-goal", "description": "Model-facing same-session goal tools with execution-time authority checks", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/guard/repeat-tool-reminder/package.json b/packages/guard/repeat-tool-reminder/package.json index 9f4876b47c..76a61f7a6d 100644 --- a/packages/guard/repeat-tool-reminder/package.json +++ b/packages/guard/repeat-tool-reminder/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-repeat-tool-reminder", "description": "Repeat-tool-call guard plugin: advisory reminders when an agent loops on identical tool calls", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/guard/timeout-policy/package.json b/packages/guard/timeout-policy/package.json index 59f19a67ad..50c9064807 100644 --- a/packages/guard/timeout-policy/package.json +++ b/packages/guard/timeout-policy/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-call-timeout-policy", "description": "Tool-call timeout policy: a tools/execute wrapper that arms a per-tool deadline on exec.signal and returns TOOL_TIMEOUT when it wins", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/hooks/hook-protocol/package.json b/packages/hooks/hook-protocol/package.json index 90fa06db8d..953ff92fce 100644 --- a/packages/hooks/hook-protocol/package.json +++ b/packages/hooks/hook-protocol/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-hook-protocol", "description": "Shared Claude Code / Codex hook wire protocol: matcher engine, stdin/exit-code/stdout codec, multi-hook merge, and hook/* session events", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/hooks/hooks-claude-code/package.json b/packages/hooks/hooks-claude-code/package.json index 6b7cd0594b..8e77285198 100644 --- a/packages/hooks/hooks-claude-code/package.json +++ b/packages/hooks/hooks-claude-code/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-hooks-claude-code", "description": "Bridge plugin: run a Claude Code hooks.json / settings hook config on the DeepSeek Harness interception seams", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/hooks/hooks-codex/package.json b/packages/hooks/hooks-codex/package.json index 4da41ec1da..846f035e97 100644 --- a/packages/hooks/hooks-codex/package.json +++ b/packages/hooks/hooks-codex/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-hooks-codex", "description": "Bridge plugin: run a Codex hooks.json hook config on the DeepSeek Harness interception seams", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/host/directory-picker-auto/package.json b/packages/host/directory-picker-auto/package.json index 68d31835d3..32278ecaba 100644 --- a/packages/host/directory-picker-auto/package.json +++ b/packages/host/directory-picker-auto/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-host-directory-picker-auto", "description": "Adaptive chooser of the directory-picker seam: resolves the host situation at boot and mounts the native or browse backend for the DeepSeek Harness web GUI host", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/host/directory-picker-browse/package.json b/packages/host/directory-picker-browse/package.json index 66198388ae..8ea96e6341 100644 --- a/packages/host/directory-picker-browse/package.json +++ b/packages/host/directory-picker-browse/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-host-directory-picker-browse", "description": "In-app browsing backend of the directory-picker seam (listing/creation primitives over the host filesystem)", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/host/directory-picker-native/package.json b/packages/host/directory-picker-native/package.json index 9e1547073c..b6bb6d9110 100644 --- a/packages/host/directory-picker-native/package.json +++ b/packages/host/directory-picker-native/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-host-directory-picker-native", "description": "Native-OS-chooser backend of the directory-picker seam for the DeepSeek Harness web GUI host", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/host/directory-picker/package.json b/packages/host/directory-picker/package.json index 10d3b4e2a0..ef0e68372d 100644 --- a/packages/host/directory-picker/package.json +++ b/packages/host/directory-picker/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-host-directory-picker", "description": "Abstract workspace-directory picking seam (ctx.directoryPicker) for the DeepSeek Harness web GUI host", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/host/frontend-static/package.json b/packages/host/frontend-static/package.json index f1413302a0..e348e08d2a 100644 --- a/packages/host/frontend-static/package.json +++ b/packages/host/frontend-static/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-host-frontend-static", "description": "SPA dist server for the Web shell: owns the webserver fallback seat, serving explicit index entries and static assets with traversal rejection and 404 misses", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/host/open-in-app/package.json b/packages/host/open-in-app/package.json index ceb0c74b33..c88582a6fd 100644 --- a/packages/host/open-in-app/package.json +++ b/packages/host/open-in-app/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-host-open-in-app", "description": "Host half of open-in-app: resolved application catalog, icons, and the launch endpoint as three webServer routes", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/host/plugin-inventory/package.json b/packages/host/plugin-inventory/package.json index a3bdbe1e8f..65ed93c1d9 100644 --- a/packages/host/plugin-inventory/package.json +++ b/packages/host/plugin-inventory/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-host-plugin-inventory", "description": "Read-only Remote projection of current Cordis Loader plugin state", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/host/webserver/package.json b/packages/host/webserver/package.json index 01c1dd58aa..96ab1c4704 100644 --- a/packages/host/webserver/package.json +++ b/packages/host/webserver/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-host-webserver", "description": "Web route-registration plugin: HTTP and upgrade routes, index transform taps, and static dist fallback; knows no harness concepts", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/identity/anonymous-user-id/package.json b/packages/identity/anonymous-user-id/package.json index 1873a8dbb7..2d9d2d82b4 100644 --- a/packages/identity/anonymous-user-id/package.json +++ b/packages/identity/anonymous-user-id/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-anonymous-user-id", "description": "Shared anonymous user identity for DeepSeek Harness telemetry and feedback correlation", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/interaction/commands/package.json b/packages/interaction/commands/package.json index c29b99c7f0..1fe03def46 100644 --- a/packages/interaction/commands/package.json +++ b/packages/interaction/commands/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-commands", "description": "Plugin-owned human command registry for DeepSeek Harness UIs", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/interaction/permission-presets/package.json b/packages/interaction/permission-presets/package.json index fb5c7cd1aa..ef46950a5b 100644 --- a/packages/interaction/permission-presets/package.json +++ b/packages/interaction/permission-presets/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-permission-presets", "description": "User-facing permission presets (ctx.permissionPresets) for the DeepSeek Harness: one product-level Permissions select bundling the sandbox-mode and approval-policy knobs, written through to their own session events", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/interaction/tool-ask-user/package.json b/packages/interaction/tool-ask-user/package.json index bcc4d7c002..91b43a476d 100644 --- a/packages/interaction/tool-ask-user/package.json +++ b/packages/interaction/tool-ask-user/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-ask-user", "description": "Model-facing ask_user_question tool over the ctx.userQuestions seam", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/interaction/user-approval/package.json b/packages/interaction/user-approval/package.json index 786ac6f456..30ac058e29 100644 --- a/packages/interaction/user-approval/package.json +++ b/packages/interaction/user-approval/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-user-approval", "description": "User-approval seam (ctx.approval) for the DeepSeek Harness: one-shot permission decisions dispatched to composed answerers over the approval/request waterfall, fail-closed by default", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/interaction/user-questions/package.json b/packages/interaction/user-questions/package.json index c65c17c91d..c9936649b9 100644 --- a/packages/interaction/user-questions/package.json +++ b/packages/interaction/user-questions/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-user-questions", "description": "Abstract user-questions seam (ctx.userQuestions) for asking the human during agent runs", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/jobs/jobs-local/package.json b/packages/jobs/jobs-local/package.json index 208bb4f66e..5a262665d7 100644 --- a/packages/jobs/jobs-local/package.json +++ b/packages/jobs/jobs-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-jobs-local", "description": "Process-local implementation of the DeepSeek Harness background job registry seam", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/jobs/jobs/package.json b/packages/jobs/jobs/package.json index 94744c6a2a..b567121e03 100644 --- a/packages/jobs/jobs/package.json +++ b/packages/jobs/jobs/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-jobs", "description": "Background job registry (ctx.jobs) for the DeepSeek Harness — shared ids, owner isolation, polling, cancellation, and completion listeners for long-running tool work", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/jobs/tool-jobs/package.json b/packages/jobs/tool-jobs/package.json index d908add0a6..03615aea99 100644 --- a/packages/jobs/tool-jobs/package.json +++ b/packages/jobs/tool-jobs/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-jobs", "description": "Model-facing background job control tools (job_output, job_list, job_kill) over the ctx.jobs registry", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/llm/deepseek-llm-api-extensions/package.json b/packages/llm/deepseek-llm-api-extensions/package.json index 24804f96db..411cd64bba 100644 --- a/packages/llm/deepseek-llm-api-extensions/package.json +++ b/packages/llm/deepseek-llm-api-extensions/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-deepseek-llm-api-extensions", "description": "Additive request-field registry for the official DeepSeek LLM API adapter", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/llm/llm-deepseek/package.json b/packages/llm/llm-deepseek/package.json index b10ec1ff14..f9a8d5c09a 100644 --- a/packages/llm/llm-deepseek/package.json +++ b/packages/llm/llm-deepseek/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-llm-deepseek", "description": "DeepSeek chat-completions adapter for the DeepSeek Harness LLM seam", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/llm/llm-pi-ai/package.json b/packages/llm/llm-pi-ai/package.json index 28652bf860..2c7d5ca5c6 100644 --- a/packages/llm/llm-pi-ai/package.json +++ b/packages/llm/llm-pi-ai/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-llm-pi-ai", "description": "pi-ai-backed DeepSeek adapter for the DeepSeek Harness LLM seam (design-verification twin of dsh-llm-deepseek)", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/llm/llm-retry/package.json b/packages/llm/llm-retry/package.json index 5e3fef8315..b0033c09a7 100644 --- a/packages/llm/llm-retry/package.json +++ b/packages/llm/llm-retry/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-llm-retry", "description": "Provider-routed LLM request retry policy for the DeepSeek Harness", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/llm/llm/package.json b/packages/llm/llm/package.json index 73235b3521..9e4a322e0b 100644 --- a/packages/llm/llm/package.json +++ b/packages/llm/llm/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-llm", "description": "Provider-neutral LLM service interface for the DeepSeek Harness", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/llm/plugin-package-inventory-deepseek/package.json b/packages/llm/plugin-package-inventory-deepseek/package.json index 16cd26163a..46193fec94 100644 --- a/packages/llm/plugin-package-inventory-deepseek/package.json +++ b/packages/llm/plugin-package-inventory-deepseek/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-plugin-package-inventory-deepseek", "description": "Active Loader-backed plugin package inventory for official DeepSeek LLM API requests", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/llm/token-meter/package.json b/packages/llm/token-meter/package.json index 5a19084762..983f61a42f 100644 --- a/packages/llm/token-meter/package.json +++ b/packages/llm/token-meter/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-token-meter", "description": "Replay-aware token measurement service (ctx.tokenMeter) for the DeepSeek Harness", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/lsp/lsp-stdio/package.json b/packages/lsp/lsp-stdio/package.json index 99f8f79231..5ede6bd195 100644 --- a/packages/lsp/lsp-stdio/package.json +++ b/packages/lsp/lsp-stdio/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-lsp-stdio", "description": "Generic stdio language-server provider for the DeepSeek Harness LSP capability seam (ctx.lsp) — spawns configured servers, translates JSON-RPC, and serves transient-open goToDefinition/findReferences/goToImplementation/hover queries in the host filesystem namespace", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/lsp/lsp/package.json b/packages/lsp/lsp/package.json index 175508a801..71a7be6754 100644 --- a/packages/lsp/lsp/package.json +++ b/packages/lsp/lsp/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-lsp", "description": "Abstract LSP capability seam (ctx.lsp) for the DeepSeek Harness — language-server provider registry keyed by branded id and extension mapping, order-independent per-query selection, normalized definition/references/implementation/hover requests and results, and the LspError taxonomy", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/lsp/tool-lsp/package.json b/packages/lsp/tool-lsp/package.json index aef7b01bc8..2cb4805bc0 100644 --- a/packages/lsp/tool-lsp/package.json +++ b/packages/lsp/tool-lsp/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-lsp", "description": "Model-facing lsp tool over the DeepSeek Harness LSP capability seam (ctx.lsp) — one read-only tool with goToDefinition/findReferences/goToImplementation/hover operations, one-based UTF-16 cursor coordinates, bounded location rendering, and hover normalization", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/mcp/mcp-client/package.json b/packages/mcp/mcp-client/package.json index 3cc384ca25..b4d921a813 100644 --- a/packages/mcp/mcp-client/package.json +++ b/packages/mcp/mcp-client/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-mcp-client", "description": "MCP client bridge: connects to MCP servers and registers their tools on ctx.tools", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/plan/plan-mode/package.json b/packages/plan/plan-mode/package.json index cab618dccd..759f290dda 100644 --- a/packages/plan/plan-mode/package.json +++ b/packages/plan/plan-mode/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-plan-mode", "description": "Logged per-agent plan mode with deployment guidance, a direct slash command, and a user-reviewed exit", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/preset/agent-presets/package.json b/packages/preset/agent-presets/package.json index a632b21ee0..e6a026225a 100644 --- a/packages/preset/agent-presets/package.json +++ b/packages/preset/agent-presets/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent-presets", "description": "Per-session agent composition from preset cordis.yml files for the DeepSeek Harness", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/preset/persona/package.json b/packages/preset/persona/package.json index ec81978b55..ac69830a75 100644 --- a/packages/preset/persona/package.json +++ b/packages/preset/persona/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-persona", "description": "Composition-authored deployment persona section for the DeepSeek Harness", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/runtime-diagnostics/invariants/package.json b/packages/runtime-diagnostics/invariants/package.json index 4666c078d5..6dcf6f5517 100644 --- a/packages/runtime-diagnostics/invariants/package.json +++ b/packages/runtime-diagnostics/invariants/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-invariants", "description": "Registry service for package-owned DeepSeek Harness runtime invariants", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/sandbox/sandbox-local/package.json b/packages/sandbox/sandbox-local/package.json index c9a5618507..02200c0e13 100644 --- a/packages/sandbox/sandbox-local/package.json +++ b/packages/sandbox/sandbox-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sandbox-local", "description": "Local process-sandbox backends for the DeepSeek Harness sandbox seam: bwrap, the npm-distributed landlock-run launcher, macOS Seatbelt, or the Windows ACL restricted-token runner — functionally probed, fail-closed", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/sandbox/sandbox-policy/package.json b/packages/sandbox/sandbox-policy/package.json index 7a21457c90..3f59b340c6 100644 --- a/packages/sandbox/sandbox-policy/package.json +++ b/packages/sandbox/sandbox-policy/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sandbox-policy", "description": "Per-call sandbox policy resolver and current model context: deployment fallbacks plus each session's mode and workspace root, shared by every enforcing capability family", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/sandbox/sandbox-windows-acl/package.json b/packages/sandbox/sandbox-windows-acl/package.json index 27ca514db8..5de9c52edf 100644 --- a/packages/sandbox/sandbox-windows-acl/package.json +++ b/packages/sandbox/sandbox-windows-acl/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sandbox-windows-acl", "description": "Windows ACL write-restriction sandbox backend (restricted-token spawn with capability-SID write allowlist) for the DeepSeek Harness sandbox seam", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/sandbox/sandbox/package.json b/packages/sandbox/sandbox/package.json index 02c4e0eee8..f817a85049 100644 --- a/packages/sandbox/sandbox/package.json +++ b/packages/sandbox/sandbox/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sandbox", "description": "Abstract process-sandbox seam (ctx.sandbox) for the DeepSeek Harness: same-world confinement vocabulary and the SandboxProvider contract", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/schedule/schedule/package.json b/packages/schedule/schedule/package.json index 78fac782ec..37fa4589c9 100644 --- a/packages/schedule/schedule/package.json +++ b/packages/schedule/schedule/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-schedule", "description": "Agent-scoped durable after, at, and fixed-rate reminders over the session event log", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/sdk/client/package.json b/packages/sdk/client/package.json index 5534f7028e..f3f9321ca3 100644 --- a/packages/sdk/client/package.json +++ b/packages/sdk/client/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sdk-client", "description": "TypeScript client SDK for driving a DeepSeek Harness runtime subprocess over stdio JSON-RPC: the DeepSeekHarness high-level turns API and the lower-level HarnessClient", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/sdk/protocol/package.json b/packages/sdk/protocol/package.json index 145a79d7a7..753fa4ac1e 100644 --- a/packages/sdk/protocol/package.json +++ b/packages/sdk/protocol/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sdk-protocol", "description": "Shared wire protocol for the DeepSeek Harness SDK runtime: the newline-delimited JSON-RPC stdio transport and the named request, result, and notification types spoken between the runtime server and SDK clients", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/sdk/server/package.json b/packages/sdk/server/package.json index 58920ce23b..aaf98612ea 100644 --- a/packages/sdk/server/package.json +++ b/packages/sdk/server/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sdk-jsonrpc-server", "description": "Stdio JSON-RPC server plugin for out-of-process DeepSeek Harness SDK clients", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/session-query/session-log-export/package.json b/packages/session-query/session-log-export/package.json index e107281710..91966f3773 100644 --- a/packages/session-query/session-log-export/package.json +++ b/packages/session-query/session-log-export/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-log-export", "description": "Web Session-log export command and shared download dialog", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/session-query/session-query-sqlite/package.json b/packages/session-query/session-query-sqlite/package.json index 560e1056cf..01d3178e31 100644 --- a/packages/session-query/session-query-sqlite/package.json +++ b/packages/session-query/session-query-sqlite/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-query-sqlite", "description": "Concrete ctx.sessionQuery backend with SQLite FTS5 search", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/session-query/session-query/package.json b/packages/session-query/session-query/package.json index 80e928341b..6141578ea4 100644 --- a/packages/session-query/session-query/package.json +++ b/packages/session-query/session-query/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-query", "description": "Combined session query service contract with concrete reads, traces, and filters", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/session-query/tool-session-query/package.json b/packages/session-query/tool-session-query/package.json index b54d4eae26..e3244c2e76 100644 --- a/packages/session-query/tool-session-query/package.json +++ b/packages/session-query/tool-session-query/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-session-query", "description": "Workspace-authorized model-facing session history search, trace, and event read tools", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-checkpoint-policy/package.json b/packages/session/session-checkpoint-policy/package.json index f59985e5b3..796e177b29 100644 --- a/packages/session/session-checkpoint-policy/package.json +++ b/packages/session/session-checkpoint-policy/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-checkpoint-policy", "description": "Semantic session durability checkpoints before model requests and tool side effects", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-format-catalog/package.json b/packages/session/session-format-catalog/package.json index b2f197c13c..56406e219b 100644 --- a/packages/session/session-format-catalog/package.json +++ b/packages/session/session-format-catalog/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-format-catalog", "description": "Build-static first-party Session format codec and migration catalog", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-format-v0-to-v1/package.json b/packages/session/session-format-v0-to-v1/package.json index 58e4b3f677..42f7f1c4a9 100644 --- a/packages/session/session-format-v0-to-v1/package.json +++ b/packages/session/session-format-v0-to-v1/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-format-v0-to-v1", "description": "Frozen released-v0 Session codec and identity migration to v1", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-format-v1-to-v2/package.json b/packages/session/session-format-v1-to-v2/package.json index b44f957bfd..11955848ea 100644 --- a/packages/session/session-format-v1-to-v2/package.json +++ b/packages/session/session-format-v1-to-v2/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-format-v1-to-v2", "description": "Frozen released-v1 Session codec and assistant-stream migration to v2", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-format-v2-to-v3/package.json b/packages/session/session-format-v2-to-v3/package.json index 99b1ca46d8..4c0c17815e 100644 --- a/packages/session/session-format-v2-to-v3/package.json +++ b/packages/session/session-format-v2-to-v3/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-format-v2-to-v3", "description": "Streaming system-prompt, canonical-envelope and PTC migration into V3", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-format/package.json b/packages/session/session-format/package.json index 069a8c2e95..cc55b09ce3 100644 --- a/packages/session/session-format/package.json +++ b/packages/session/session-format/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-format", "description": "Streaming adjacent Session format migration machinery", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-log-deepseek/package.json b/packages/session/session-log-deepseek/package.json index 9dfdf7e6dd..3c7f0e65fc 100644 --- a/packages/session/session-log-deepseek/package.json +++ b/packages/session/session-log-deepseek/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-log-deepseek", "description": "Incremental lossless session-log request extension for the official DeepSeek LLM API", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-persistence-jsonl/package.json b/packages/session/session-persistence-jsonl/package.json index 0233f1c4e5..11f50ba97a 100644 --- a/packages/session/session-persistence-jsonl/package.json +++ b/packages/session/session-persistence-jsonl/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-persistence-jsonl", "description": "JSONL durable session persistence backend for the DeepSeek Harness", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-persistence/package.json b/packages/session/session-persistence/package.json index 00c06bd87b..36f7009ebd 100644 --- a/packages/session/session-persistence/package.json +++ b/packages/session/session-persistence/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-persistence", "description": "Abstract durable session persistence seam (ctx.sessionPersistence) for the DeepSeek Harness", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-projection-cache/package.json b/packages/session/session-projection-cache/package.json index 4572668791..7d9cd2587e 100644 --- a/packages/session/session-projection-cache/package.json +++ b/packages/session/session-projection-cache/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-projection-cache", "description": "Persisted projection cache (ctx.sessionProjectionCache): durable per-session checkpoint records on the session_projcache storage domain (per-record layout), throttled write-behind, and the cached listing read", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-projection/package.json b/packages/session/session-projection/package.json index 0820666165..e36e2dc4a8 100644 --- a/packages/session/session-projection/package.json +++ b/packages/session/session-projection/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-projection", "description": "Session-projection seam: the merge-extensible projection type table, the provider contract, and the ctx.sessionProjections registry serving whole current values of log-derived per-session state", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-stats/package.json b/packages/session/session-stats/package.json index 507f87a96b..cfaf57382b 100644 --- a/packages/session/session-stats/package.json +++ b/packages/session/session-stats/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-stats", "description": "Whole-log conversation counts and wall times projection (sessionStats) for the DeepSeek Harness", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-telemetry-otel/package.json b/packages/session/session-telemetry-otel/package.json index 707e536ad4..51bb035943 100644 --- a/packages/session/session-telemetry-otel/package.json +++ b/packages/session/session-telemetry-otel/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-telemetry-otel", "description": "OpenTelemetry backend for the DeepSeek Harness telemetry seam: hands captured session records to the OTel JS SDK's log pipeline", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-telemetry/package.json b/packages/session/session-telemetry/package.json index a7e1b7c31a..7927c46409 100644 --- a/packages/session/session-telemetry/package.json +++ b/packages/session/session-telemetry/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-telemetry", "description": "SessionTelemetryBackend seam for the DeepSeek Harness: session-event capture, projection, redaction, and handoff to a reporting backend", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-title-all-prompts-llm/package.json b/packages/session/session-title-all-prompts-llm/package.json index 730c1d46de..c51f74eed9 100644 --- a/packages/session/session-title-all-prompts-llm/package.json +++ b/packages/session/session-title-all-prompts-llm/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-title-all-prompts-llm", "description": "All-user-messages LLM provider plugin for DeepSeek Harness session titles", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-title-first-prompt-llm/package.json b/packages/session/session-title-first-prompt-llm/package.json index 14343665f7..3a3717fcc7 100644 --- a/packages/session/session-title-first-prompt-llm/package.json +++ b/packages/session/session-title-first-prompt-llm/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-title-first-prompt-llm", "description": "First-message LLM provider plugin for DeepSeek Harness session titles", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-title-llm/package.json b/packages/session/session-title-llm/package.json index 2a13d395ca..89cd217004 100644 --- a/packages/session/session-title-llm/package.json +++ b/packages/session/session-title-llm/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-title-llm", "description": "Shared LLM generation policy for DeepSeek Harness session-title providers", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-title/package.json b/packages/session/session-title/package.json index 4f244531e9..79170df18d 100644 --- a/packages/session/session-title/package.json +++ b/packages/session/session-title/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-title", "description": "Log-backed session title service and provider registry for the DeepSeek Harness", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-turn-outline/package.json b/packages/session/session-turn-outline/package.json index 711e009430..892bd66c9b 100644 --- a/packages/session/session-turn-outline/package.json +++ b/packages/session/session-turn-outline/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-turn-outline", "description": "Whole-log turn outline projection (turnOutline) for the DeepSeek Harness", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/settings/settings-file/package.json b/packages/settings/settings-file/package.json index f0eb2ddb46..5a91ea8a02 100644 --- a/packages/settings/settings-file/package.json +++ b/packages/settings/settings-file/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-settings-file", "description": "File-backed settings provider (settings.yaml) for the DeepSeek Harness", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/settings/settings/package.json b/packages/settings/settings/package.json index c7a2fbec40..55343abb6c 100644 --- a/packages/settings/settings/package.json +++ b/packages/settings/settings/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-settings", "description": "Abstract user-settings seam (ctx.settings) for the DeepSeek Harness", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/shell/bash-local/package.json b/packages/shell/bash-local/package.json index 9359c2de1b..0f1b558a61 100644 --- a/packages/shell/bash-local/package.json +++ b/packages/shell/bash-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-bash-local", "description": "Local-subprocess implementation of the DeepSeek Harness bash executor seam", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/shell/bash-sandbox/package.json b/packages/shell/bash-sandbox/package.json index 7ea041a1a5..d7405f3cc4 100644 --- a/packages/shell/bash-sandbox/package.json +++ b/packages/shell/bash-sandbox/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-bash-sandbox", "description": "Sandbox-consuming implementation of the DeepSeek Harness bash executor seam (confines every command via ctx.sandbox, reports denial/enforcement result facts)", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/shell/pwsh-local/package.json b/packages/shell/pwsh-local/package.json index ebea606ae7..7b102e4c30 100644 --- a/packages/shell/pwsh-local/package.json +++ b/packages/shell/pwsh-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-pwsh-local", "description": "Local PowerShell implementation of the DeepSeek Harness bash executor seam", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/shell/pwsh-sandbox/package.json b/packages/shell/pwsh-sandbox/package.json index bc911347ea..6d5185ef9b 100644 --- a/packages/shell/pwsh-sandbox/package.json +++ b/packages/shell/pwsh-sandbox/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-pwsh-sandbox", "description": "Sandbox-consuming implementation of the DeepSeek Harness PowerShell executor seam (confines every command via ctx.sandbox, reports denial/enforcement result facts)", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/shell/shell-env/package.json b/packages/shell/shell-env/package.json index 3558307a0c..e0b4fb2368 100644 --- a/packages/shell/shell-env/package.json +++ b/packages/shell/shell-env/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-shell-env", "description": "Tool-independent managed DSH_* shell environment registry", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/shell/shell/package.json b/packages/shell/shell/package.json index 6fce0fb257..88503f4385 100644 --- a/packages/shell/shell/package.json +++ b/packages/shell/shell/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-shell", "description": "Abstract bash executor seam (ctx.shell) for the DeepSeek Harness", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/shell/tool-bash-persistent/package.json b/packages/shell/tool-bash-persistent/package.json index 7b817703b5..a8c99b3c5c 100644 --- a/packages/shell/tool-bash-persistent/package.json +++ b/packages/shell/tool-bash-persistent/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-bash-persistent", "description": "Model-facing owner-scoped persistent Bash tool backed by the Harness PTY service", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/shell/tool-bash/package.json b/packages/shell/tool-bash/package.json index 3ed1a01a9f..c8648afd57 100644 --- a/packages/shell/tool-bash/package.json +++ b/packages/shell/tool-bash/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-bash", "description": "Model-facing bash tool with optional generic background-job and sandbox-escalation support", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/shell/tool-pwsh-persistent/package.json b/packages/shell/tool-pwsh-persistent/package.json index 780948e7a0..c42a4bde52 100644 --- a/packages/shell/tool-pwsh-persistent/package.json +++ b/packages/shell/tool-pwsh-persistent/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-pwsh-persistent", "description": "Model-facing owner-scoped persistent PowerShell tool backed by the Harness PTY service", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/shell/tool-pwsh/package.json b/packages/shell/tool-pwsh/package.json index 7c003409c2..b01b2e3cd9 100644 --- a/packages/shell/tool-pwsh/package.json +++ b/packages/shell/tool-pwsh/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-pwsh", "description": "Model-facing pwsh tool over the bash executor seam", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/skill/skill-badge/package.json b/packages/skill/skill-badge/package.json index c5db5bbec3..1fd637dabb 100644 --- a/packages/skill/skill-badge/package.json +++ b/packages/skill/skill-badge/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-skill-badge", "description": "Bundled dsh badge skill provider for DeepSeek Harness", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/skill/skill-filesystem/package.json b/packages/skill/skill-filesystem/package.json index 09dc51f2dd..484c7ea0e1 100644 --- a/packages/skill/skill-filesystem/package.json +++ b/packages/skill/skill-filesystem/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-skill-filesystem", "description": "Local filesystem skill provider for the DeepSeek Harness", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/skill/skill/package.json b/packages/skill/skill/package.json index 8705f3481f..56154b531d 100644 --- a/packages/skill/skill/package.json +++ b/packages/skill/skill/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-skill", "description": "Agent skill provider registry for the DeepSeek Harness", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/skill/tool-skill/package.json b/packages/skill/tool-skill/package.json index 633473717e..120876880c 100644 --- a/packages/skill/tool-skill/package.json +++ b/packages/skill/tool-skill/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-skill", "description": "Model-facing skill loading tool for the DeepSeek Harness", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/spill/spill-local/package.json b/packages/spill/spill-local/package.json index 7cc5eda514..089535ff13 100644 --- a/packages/spill/spill-local/package.json +++ b/packages/spill/spill-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-spill-local", "description": "Local-filesystem implementation of the DeepSeek Harness spill storage seam (private session-scoped files)", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/spill/spill-policy/package.json b/packages/spill/spill-policy/package.json index 9990cdf288..d3a0ed5eb6 100644 --- a/packages/spill/spill-policy/package.json +++ b/packages/spill/spill-policy/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-spill-policy", "description": "Tool-result spill policy for the DeepSeek Harness — replaces oversized plain-text tool results with a retained preview plus a spill-file path (no service API)", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/spill/spill/package.json b/packages/spill/spill/package.json index eeedb76373..651531bf82 100644 --- a/packages/spill/spill/package.json +++ b/packages/spill/spill/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-spill", "description": "Abstract spill storage seam (ctx.spillStore) for the DeepSeek Harness — save oversized tool text and return a retrieval locator", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/storage/storage-domain/package.json b/packages/storage/storage-domain/package.json index ab2bc8c205..dbbd79571c 100644 --- a/packages/storage/storage-domain/package.json +++ b/packages/storage/storage-domain/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-storage-domain", "description": "Domain data form (ctx.storage.domain): schema-validated, event-emitting KV domains over storage backends for the DeepSeek Harness", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/storage/storage-json/package.json b/packages/storage/storage-json/package.json index f35b45d72f..4e79b26fc0 100644 --- a/packages/storage/storage-json/package.json +++ b/packages/storage/storage-json/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-storage-json", "description": "JSON file KV storage backend for the DeepSeek Harness storage hub", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/storage/storage-sqlite/package.json b/packages/storage/storage-sqlite/package.json index 00797f7c07..638ad2e283 100644 --- a/packages/storage/storage-sqlite/package.json +++ b/packages/storage/storage-sqlite/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-storage-sqlite", "description": "SQLite storage backend (kv facet) for the DeepSeek Harness storage hub", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/storage/storage/package.json b/packages/storage/storage/package.json index 471ac26288..c45cc74a11 100644 --- a/packages/storage/storage/package.json +++ b/packages/storage/storage/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-storage", "description": "Storage hub (ctx.storage): named backend registry plus mounted data-form facilities for the DeepSeek Harness", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-acp/package.json b/packages/subagent/subagent-acp/package.json index 4c6faca8b0..6e42076bc3 100644 --- a/packages/subagent/subagent-acp/package.json +++ b/packages/subagent/subagent-acp/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-acp", "description": "Out-of-process ACP subagent backend: drives a child agent in a spawned subprocess over the Agent Client Protocol", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-claude-code/package.json b/packages/subagent/subagent-claude-code/package.json index f4f1daea88..b4e328eff0 100644 --- a/packages/subagent/subagent-claude-code/package.json +++ b/packages/subagent/subagent-claude-code/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-claude-code", "description": "One-shot Claude Code subagent provider over the official Agent SDK", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-codex/package.json b/packages/subagent/subagent-codex/package.json index e25cfd69f8..79bda26eec 100644 --- a/packages/subagent/subagent-codex/package.json +++ b/packages/subagent/subagent-codex/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-codex", "description": "One-shot Codex subagent provider over the official app-server protocol", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-dsh-sdk/package.json b/packages/subagent/subagent-dsh-sdk/package.json index 43fbea39e5..505ee00c38 100644 --- a/packages/subagent/subagent-dsh-sdk/package.json +++ b/packages/subagent/subagent-dsh-sdk/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-dsh-sdk", "description": "Out-of-process SDK subagent backend: drives a child DeepSeek Harness runtime subprocess over stdio JSON-RPC through the TypeScript SDK client", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-fork-in-process/package.json b/packages/subagent/subagent-fork-in-process/package.json index 6c4c040b9b..2deb14834d 100644 --- a/packages/subagent/subagent-fork-in-process/package.json +++ b/packages/subagent/subagent-fork-in-process/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-fork-in-process", "description": "In-process fork subagent backend: runs a child agent seeded with a prefix of the parent's log", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-in-process-driver/package.json b/packages/subagent/subagent-in-process-driver/package.json index 51c6b2240b..ee7d0febc4 100644 --- a/packages/subagent/subagent-in-process-driver/package.json +++ b/packages/subagent/subagent-in-process-driver/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-in-process-driver", "description": "Shared in-process subagent run driver: drives a child agent on ctx.agents (used by the spawn and fork backends)", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-spawn-in-process/package.json b/packages/subagent/subagent-spawn-in-process/package.json index 7f0b7f2dba..73fd12c1fc 100644 --- a/packages/subagent/subagent-spawn-in-process/package.json +++ b/packages/subagent/subagent-spawn-in-process/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-spawn-in-process", "description": "In-process spawn subagent backend: runs a fresh child agent on ctx.agents", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent/package.json b/packages/subagent/subagent/package.json index 22e000973f..319d9ecbe5 100644 --- a/packages/subagent/subagent/package.json +++ b/packages/subagent/subagent/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent", "description": "Abstract subagent seam (ctx.subagents): named-provider registry for delegating to child agents", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/tool-subagent-control/package.json b/packages/subagent/tool-subagent-control/package.json index 71d256c372..667f86351b 100644 --- a/packages/subagent/tool-subagent-control/package.json +++ b/packages/subagent/tool-subagent-control/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-subagent-control", "description": "Globally named send_message, interrupt_agent, and list_agents tools over ctx.subagents continuations", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/tool-subagent/package.json b/packages/subagent/tool-subagent/package.json index 119d39ad1b..284c093f12 100644 --- a/packages/subagent/tool-subagent/package.json +++ b/packages/subagent/tool-subagent/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-subagent", "description": "Model-facing subagent delegation tool over the ctx.subagents seam", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/subprocess/subprocess-local/package.json b/packages/subprocess/subprocess-local/package.json index 5b19229c75..7465d787f9 100644 --- a/packages/subprocess/subprocess-local/package.json +++ b/packages/subprocess/subprocess-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subprocess-local", "description": "Local-subprocess implementation of the DeepSeek Harness subprocess seam", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/subprocess/subprocess/package.json b/packages/subprocess/subprocess/package.json index 627077a52a..2f20d3a6f9 100644 --- a/packages/subprocess/subprocess/package.json +++ b/packages/subprocess/subprocess/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subprocess", "description": "Subprocess seam (ctx.subprocess) for the DeepSeek Harness — managed process groups, bounded spill-backed output, and escalated kills behind one abstract service", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/subprocess/win32-process/package.json b/packages/subprocess/win32-process/package.json index a7b3a2fbb1..28fc6c2685 100644 --- a/packages/subprocess/win32-process/package.json +++ b/packages/subprocess/win32-process/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-win32-process", "description": "Shared low-level Win32 process, stdio, and Job Object primitives", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/terminal/terminal-bash/package.json b/packages/terminal/terminal-bash/package.json index 92f346a595..c433ad9375 100644 --- a/packages/terminal/terminal-bash/package.json +++ b/packages/terminal/terminal-bash/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-terminal-bash", "description": "Persistent shell PTY backend over the DeepSeek Harness subprocess terminal primitive", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/terminal/terminal/package.json b/packages/terminal/terminal/package.json index 4a65ed9e29..ec2fc7c719 100644 --- a/packages/terminal/terminal/package.json +++ b/packages/terminal/terminal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-terminal", "description": "Persistent PTY session seam for the DeepSeek Harness — owner-scoped ids, backend registry, interactive sends, reads, signals, and awaited cleanup", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/terminal/tool-terminal/package.json b/packages/terminal/tool-terminal/package.json index c0e1707fc6..bfcbeddef1 100644 --- a/packages/terminal/tool-terminal/package.json +++ b/packages/terminal/tool-terminal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-terminal", "description": "Six model-facing persistent PTY tools with owner isolation and generic background-job integration", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/test-support/agent-loop-testkit/package.json b/packages/test-support/agent-loop-testkit/package.json index 7bc3890fec..1e2eb18ee4 100644 --- a/packages/test-support/agent-loop-testkit/package.json +++ b/packages/test-support/agent-loop-testkit/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent-loop-testkit", "description": "Prerequisite mounting, production AgentLoop drivers, and Inbox stubs for tests", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/test-support/client-runtime/package.json b/packages/test-support/client-runtime/package.json index 88298cd72b..326a78083a 100644 --- a/packages/test-support/client-runtime/package.json +++ b/packages/test-support/client-runtime/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-test-runtime", "description": "jsdom slot test runtime: real Cordis Context + SlotRegistry + UI renderer with test-owned session/workspace doubles for feature specs", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/test-support/llm-mock-server/package.json b/packages/test-support/llm-mock-server/package.json index 09076a4f03..70538bbbad 100644 --- a/packages/test-support/llm-mock-server/package.json +++ b/packages/test-support/llm-mock-server/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-llm-mock-server", "description": "Scriptable OpenAI-compatible HTTP/SSE fault server for LLM recovery tests", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/test-support/llm-replay/package.json b/packages/test-support/llm-replay/package.json index f5281eb4f5..54e9d293b0 100644 --- a/packages/test-support/llm-replay/package.json +++ b/packages/test-support/llm-replay/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-llm-replay", "description": "Replay LLM plugin: short-circuits llm/stream with model chunks reconstructed from a recorded session JSONL (keyless snapshot tests)", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/test-support/loader-smoke/package.json b/packages/test-support/loader-smoke/package.json index f158d852c0..2d435c9d9e 100644 --- a/packages/test-support/loader-smoke/package.json +++ b/packages/test-support/loader-smoke/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-loader-smoke", "description": "Shared subprocess and direct-agent harness for keyless real-Loader example smoke tests", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/test-support/session-snapshot/package.json b/packages/test-support/session-snapshot/package.json index 8bf4945353..1b4576dccc 100644 --- a/packages/test-support/session-snapshot/package.json +++ b/packages/test-support/session-snapshot/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-snapshot", "description": "Session-log snapshot core with an ACP protocol adapter, expected-output normalization, and fixture invariants", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/todo/tool-todo/package.json b/packages/todo/tool-todo/package.json index d539bfd420..863345c42a 100644 --- a/packages/todo/tool-todo/package.json +++ b/packages/todo/tool-todo/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-todo", "description": "Model-facing todo_write tool over the DeepSeek Harness event-sourced session log", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/typert/generator/package.json b/packages/typert/generator/package.json index 8f4052432c..8ef779b90c 100644 --- a/packages/typert/generator/package.json +++ b/packages/typert/generator/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-typert-generator", "description": "TypeScript project analyzer and model-driven Typert artifact generator", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/typert/loader/package.json b/packages/typert/loader/package.json index 99873e3c8c..1e58aa5723 100644 --- a/packages/typert/loader/package.json +++ b/packages/typert/loader/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-typert-loader", "description": "Loader integration for generated Typert package contributions", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/typert/protocol/package.json b/packages/typert/protocol/package.json index 128a91015b..67b30c4377 100644 --- a/packages/typert/protocol/package.json +++ b/packages/typert/protocol/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-typert-protocol", "description": "Compiler-independent Remote metadata and Typert provider protocols", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/typert/registry/package.json b/packages/typert/registry/package.json index 57cc0269b7..2392b2f0c4 100644 --- a/packages/typert/registry/package.json +++ b/packages/typert/registry/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-typert-registry", "description": "Runtime registry for generated package reflection and Zod schemas", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/util/atomic-write/package.json b/packages/util/atomic-write/package.json index debc1821e2..c18e75ac26 100644 --- a/packages/util/atomic-write/package.json +++ b/packages/util/atomic-write/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-atomic-write", "description": "Zero-dependency atomic file replacement: exclusive-create random-suffix temp + rename carrying the caller-stated permissions (writeFileAtomic)", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/util/brand/package.json b/packages/util/brand/package.json index eb8f05ad2b..a7c8974297 100644 --- a/packages/util/brand/package.json +++ b/packages/util/brand/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-brand", "description": "Stateless branded primitive types for the DeepSeek Harness", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/util/chunked-list/package.json b/packages/util/chunked-list/package.json index 2a3fc316da..f27dee18f1 100644 --- a/packages/util/chunked-list/package.json +++ b/packages/util/chunked-list/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-chunked-list", "description": "Persistent append-only chunked lists with bounded copying and JSON checkpoint validation", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/util/crypto/package.json b/packages/util/crypto/package.json index 29c97db176..3e54890d57 100644 --- a/packages/util/crypto/package.json +++ b/packages/util/crypto/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-util-crypto", "description": "Zero-dependency browser-safe UUID and byte-encoding helpers", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/util/deque/package.json b/packages/util/deque/package.json index 9e770c6ebc..bdea147fb7 100644 --- a/packages/util/deque/package.json +++ b/packages/util/deque/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-deque", "description": "Zero-dependency circular deque with amortized constant-time end operations and bounded vacant storage", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/util/home-paths/package.json b/packages/util/home-paths/package.json index e2248ef6d4..34291f69ba 100644 --- a/packages/util/home-paths/package.json +++ b/packages/util/home-paths/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-home-paths", "description": "Shared filesystem path helpers for the DeepSeek Harness", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/util/http-proxy/package.json b/packages/util/http-proxy/package.json index 557ff05008..4091676849 100644 --- a/packages/util/http-proxy/package.json +++ b/packages/util/http-proxy/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-http-proxy", "description": "Process-wide outbound HTTP proxy policy for DeepSeek Harness: resolve it from the launch environment and install it as undici's global dispatcher", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/util/launch-environment/package.json b/packages/util/launch-environment/package.json index 05721e99de..69c88ef587 100644 --- a/packages/util/launch-environment/package.json +++ b/packages/util/launch-environment/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-launch-environment", "description": "Immutable DeepSeek Harness launch environment that records which layer supplied each value", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/util/native-command/package.json b/packages/util/native-command/package.json index ff25cef7b4..32b2562fd5 100644 --- a/packages/util/native-command/package.json +++ b/packages/util/native-command/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-native-command", "description": "Host-native command and path-opening utilities with shell-free execution, cancellation, desktop detection, and WSL handoff", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/util/output-retention/package.json b/packages/util/output-retention/package.json index 60f35e488c..ab3f8c5143 100644 --- a/packages/util/output-retention/package.json +++ b/packages/util/output-retention/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-output-retention", "description": "Zero-dependency bounded-retention primitive: ItemRetainer/TextRetainer + neutral notice helpers (what did we keep, what did we omit)", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/util/package-manifest/package.json b/packages/util/package-manifest/package.json index d5ac0e87bc..57e6beceda 100644 --- a/packages/util/package-manifest/package.json +++ b/packages/util/package-manifest/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-package-manifest", "description": "Shared type declarations for package.json.dsh configuration fields", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/util/time/package.json b/packages/util/time/package.json index 396b587e04..20b7468982 100644 --- a/packages/util/time/package.json +++ b/packages/util/time/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-util-time", "description": "Zero-dependency time vocabulary shared by wire boundaries: canonicalClientTimeZone (IANA zone validation and canonicalization only, no formatting)", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/util/timeout/package.json b/packages/util/timeout/package.json index 52601ba8e9..35500070eb 100644 --- a/packages/util/timeout/package.json +++ b/packages/util/timeout/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-timeout", "description": "Zero-dependency timeout/deadline primitive: clampTimeout, deadline, timeoutOf, TimeoutReason (timing + classification only, no termination)", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/util/values/package.json b/packages/util/values/package.json index 0caecde0da..8b08968d19 100644 --- a/packages/util/values/package.json +++ b/packages/util/values/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-util-values", "description": "Duplicate-install-safe value primitives for the DeepSeek Harness", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/util/workspace-path/package.json b/packages/util/workspace-path/package.json index 52831afa03..2d9f7d4cb7 100644 --- a/packages/util/workspace-path/package.json +++ b/packages/util/workspace-path/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-util-workspace-path", "description": "Browser-safe Workspace path and display helpers", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/web/tool-web/package.json b/packages/web/tool-web/package.json index 00029269e1..463248c787 100644 --- a/packages/web/tool-web/package.json +++ b/packages/web/tool-web/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-web", "description": "Model-facing web tools (web_search, web_fetch) over the DeepSeek Harness web capability seam (ctx.web)", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/web/web-fetch-http/package.json b/packages/web/web-fetch-http/package.json index 69deb08084..2c52a4b0f7 100644 --- a/packages/web/web-fetch-http/package.json +++ b/packages/web/web-fetch-http/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-fetch-http", "description": "Anonymous public HTTP(S) fetch provider for the DeepSeek Harness web capability seam (ctx.web)", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/web/web-search-deepseek/package.json b/packages/web/web-search-deepseek/package.json index 01ac736fcc..6e7ef73ca2 100644 --- a/packages/web/web-search-deepseek/package.json +++ b/packages/web/web-search-deepseek/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-search-deepseek", "description": "DeepSeek-backed search provider (native web_search via the Anthropic-compatible API) for the DeepSeek Harness web capability seam (ctx.web)", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/web/web-search-exa/package.json b/packages/web/web-search-exa/package.json index e50850670a..daefc8d2d3 100644 --- a/packages/web/web-search-exa/package.json +++ b/packages/web/web-search-exa/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-search-exa", "description": "Exa-backed search provider for the DeepSeek Harness web capability seam (ctx.web)", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/web/web-search-perplexity/package.json b/packages/web/web-search-perplexity/package.json index 8d58b9d76c..819966ac3a 100644 --- a/packages/web/web-search-perplexity/package.json +++ b/packages/web/web-search-perplexity/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-search-perplexity", "description": "Perplexity-backed search provider for the DeepSeek Harness web capability seam (ctx.web)", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/web/web/package.json b/packages/web/web/package.json index 7afab4d2bb..75b51cae79 100644 --- a/packages/web/web/package.json +++ b/packages/web/web/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web", "description": "Abstract web access capability seam (ctx.web) for the DeepSeek Harness — search/fetch provider registry, registration-order-independent selection, request/result vocabulary, and the WebError taxonomy", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/webhook/webhook-github/package.json b/packages/webhook/webhook-github/package.json index 8ad3788e28..81b8c9cdeb 100644 --- a/packages/webhook/webhook-github/package.json +++ b/packages/webhook/webhook-github/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-webhook-github", "description": "Signed GitHub HTTP webhook adapter for the DeepSeek Harness webhook runtime", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/webhook/webhook/package.json b/packages/webhook/webhook/package.json index 27baa443ce..ee8416fe68 100644 --- a/packages/webhook/webhook/package.json +++ b/packages/webhook/webhook/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-webhook", "description": "Fire-and-forget webhook rule runtime that creates Workspace-backed DeepSeek Harness Sessions", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/workflow/tool-ralph/package.json b/packages/workflow/tool-ralph/package.json index 23921d85c6..a1b6609049 100644 --- a/packages/workflow/tool-ralph/package.json +++ b/packages/workflow/tool-ralph/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-ralph", "description": "Model-facing fresh-agent Ralph loop over the workflow and subagent seams", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/workflow/tool-workflow/package.json b/packages/workflow/tool-workflow/package.json index 71f9830317..06cb66d839 100644 --- a/packages/workflow/tool-workflow/package.json +++ b/packages/workflow/tool-workflow/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-workflow", "description": "Model-facing workflow tool: run a JavaScript orchestration script over ctx.workflowEngine", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/workflow/workflow-worker-thread/package.json b/packages/workflow/workflow-worker-thread/package.json index 030baacd98..2a91e2d2dc 100644 --- a/packages/workflow/workflow-worker-thread/package.json +++ b/packages/workflow/workflow-worker-thread/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-workflow-worker-thread", "description": "worker-thread workflow engine: executes model-written orchestration scripts off the host event loop, bridging agent() calls back to ctx.subagents", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/workflow/workflow/package.json b/packages/workflow/workflow/package.json index c5f414941c..8e03f25a7c 100644 --- a/packages/workflow/workflow/package.json +++ b/packages/workflow/workflow/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-workflow", "description": "Workflow capability seam: ctx.workflowEngine service, run vocabulary, and workflow/* events", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/workspace/workspace/package.json b/packages/workspace/workspace/package.json index 4a30eea728..bf1ad56f88 100644 --- a/packages/workspace/workspace/package.json +++ b/packages/workspace/workspace/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-workspace", "description": "Workspace entity registry (ctx.workspaceRegistry): durable workspace records with validated session attachment over the domain data form for the DeepSeek Harness", - "version": "0.1.5-alpha.1", + "version": "0.1.5-alpha.2", "publishConfig": { "access": "public" }, From 8e8fb2fc6216c8acbadd83d20a99ff305c59775b Mon Sep 17 00:00:00 2001 From: j-xiang Date: Thu, 10 Sep 2026 00:21:32 +0800 Subject: [PATCH 066/111] docs(i18n): proofread bilingual README corpus --- apps/cli/README.i18n.yaml | 2 +- apps/cli/README.zh.md | 16 +++---- apps/cli/reference/README.i18n.yaml | 4 +- apps/cli/reference/README.md | 6 +-- apps/cli/reference/README.zh.md | 20 ++++---- apps/desktop/README.i18n.yaml | 2 +- apps/desktop/README.zh.md | 42 ++++++++-------- apps/web/tests/README.i18n.yaml | 4 +- apps/web/tests/README.md | 34 ++----------- apps/web/tests/README.zh.md | 26 ++-------- .../active-stream-reconnect/README.i18n.yaml | 2 +- .../active-stream-reconnect/README.zh.md | 4 +- .../agent-continuation/README.i18n.yaml | 2 +- benchmarks/agent-continuation/README.zh.md | 12 ++--- .../long-session-browser/README.i18n.yaml | 2 +- benchmarks/long-session-browser/README.zh.md | 8 ++-- docs/i18n/README.i18n.yaml | 2 +- docs/i18n/README.zh.md | 2 +- docs/subsystems/README.i18n.yaml | 2 +- docs/subsystems/README.zh.md | 26 +++++----- native/system/README.i18n.yaml | 2 +- native/system/README.zh.md | 10 ++-- .../packages/darwin-x64/README.i18n.yaml | 2 +- .../system/packages/darwin-x64/README.zh.md | 2 +- native/system/packages/entry/README.i18n.yaml | 2 +- native/system/packages/entry/README.zh.md | 4 +- .../packages/linux-arm64/README.i18n.yaml | 2 +- .../system/packages/linux-arm64/README.zh.md | 4 +- .../packages/linux-x64/README.i18n.yaml | 2 +- native/system/packages/linux-x64/README.zh.md | 4 +- packages/README.i18n.yaml | 2 +- packages/README.zh.md | 32 ++++++------- packages/acp/README.i18n.yaml | 2 +- packages/acp/README.zh.md | 8 ++-- packages/acp/acp/README.i18n.yaml | 4 +- packages/acp/acp/README.md | 6 +-- packages/acp/acp/README.zh.md | 22 ++++----- packages/api/README.i18n.yaml | 2 +- packages/api/README.zh.md | 12 ++--- packages/api/gateway/README.i18n.yaml | 2 +- packages/api/gateway/README.zh.md | 24 +++++----- packages/api/remotes/README.i18n.yaml | 4 +- packages/api/remotes/README.md | 4 +- packages/api/remotes/README.zh.md | 18 +++---- .../api/session-controller/README.i18n.yaml | 2 +- packages/api/session-controller/README.zh.md | 20 ++++---- .../api/settings-controller/README.i18n.yaml | 2 +- packages/api/settings-controller/README.zh.md | 12 ++--- .../api/workspace-controller/README.i18n.yaml | 2 +- .../api/workspace-controller/README.zh.md | 12 ++--- packages/api/workspace-files/README.i18n.yaml | 2 +- packages/api/workspace-files/README.zh.md | 8 ++-- packages/attachment/README.i18n.yaml | 4 +- packages/attachment/README.md | 2 +- packages/attachment/README.zh.md | 2 +- .../attachment-local/README.i18n.yaml | 4 +- .../attachment/attachment-local/README.md | 2 +- .../attachment/attachment-local/README.zh.md | 18 +++---- .../attachment/attachment/README.i18n.yaml | 4 +- packages/attachment/attachment/README.md | 4 +- packages/attachment/attachment/README.zh.md | 16 +++---- packages/boot/README.i18n.yaml | 2 +- packages/boot/README.zh.md | 2 +- packages/boot/app-boot/README.i18n.yaml | 2 +- packages/boot/app-boot/README.zh.md | 20 ++++---- packages/boot/cmdline/README.i18n.yaml | 2 +- packages/boot/cmdline/README.zh.md | 16 +++---- packages/bundle/README.i18n.yaml | 2 +- packages/bundle/README.zh.md | 14 +++--- packages/bundle/acp-app/README.i18n.yaml | 2 +- packages/bundle/acp-app/README.zh.md | 12 ++--- packages/bundle/base/README.i18n.yaml | 2 +- packages/bundle/base/README.zh.md | 14 +++--- packages/bundle/headless/README.i18n.yaml | 2 +- packages/bundle/headless/README.zh.md | 28 +++++------ packages/bundle/sdk-app/README.i18n.yaml | 2 +- packages/bundle/sdk-app/README.zh.md | 8 ++-- packages/bundle/sdk-minimal/README.i18n.yaml | 2 +- packages/bundle/sdk-minimal/README.zh.md | 16 +++---- packages/bundle/web-app/README.i18n.yaml | 2 +- packages/bundle/web-app/README.zh.md | 24 +++++----- packages/client/README.i18n.yaml | 2 +- packages/client/README.zh.md | 18 +++---- packages/client/connection/README.i18n.yaml | 2 +- packages/client/connection/README.zh.md | 14 +++--- packages/client/hmr/README.i18n.yaml | 4 +- packages/client/hmr/README.md | 4 +- packages/client/hmr/README.zh.md | 18 +++---- packages/client/locale/README.i18n.yaml | 2 +- packages/client/locale/README.zh.md | 34 ++++++------- packages/client/modules/README.i18n.yaml | 4 +- packages/client/modules/README.md | 6 +-- packages/client/modules/README.zh.md | 22 ++++----- packages/client/resources/README.i18n.yaml | 2 +- packages/client/resources/README.zh.md | 16 +++---- packages/client/store/README.i18n.yaml | 2 +- packages/client/store/README.zh.md | 12 ++--- .../client/ui-agent-preset/README.i18n.yaml | 2 +- packages/client/ui-agent-preset/README.zh.md | 20 ++++---- packages/client/ui-approval/README.i18n.yaml | 2 +- packages/client/ui-approval/README.zh.md | 2 +- .../client/ui-attachment/README.i18n.yaml | 4 +- packages/client/ui-attachment/README.md | 2 +- packages/client/ui-attachment/README.zh.md | 20 ++++---- .../client/ui-brand-official/README.i18n.yaml | 2 +- .../client/ui-brand-official/README.zh.md | 14 +++--- packages/client/ui-chat/README.i18n.yaml | 2 +- packages/client/ui-chat/README.zh.md | 20 ++++---- packages/client/ui-commands/README.i18n.yaml | 4 +- packages/client/ui-commands/README.md | 6 +-- packages/client/ui-commands/README.zh.md | 16 +++---- .../client/ui-conversation/README.i18n.yaml | 2 +- packages/client/ui-conversation/README.zh.md | 28 +++++------ .../client/ui-deliverables/README.i18n.yaml | 2 +- packages/client/ui-deliverables/README.zh.md | 26 +++++----- .../README.i18n.yaml | 2 +- .../ui-directory-picker-browse/README.zh.md | 4 +- .../README.i18n.yaml | 4 +- .../ui-directory-picker-native/README.md | 4 +- .../ui-directory-picker-native/README.zh.md | 24 +++++----- packages/client/ui-dockkit/README.i18n.yaml | 2 +- packages/client/ui-dockkit/README.zh.md | 20 ++++---- packages/client/ui-goal/README.i18n.yaml | 4 +- packages/client/ui-goal/README.md | 2 +- packages/client/ui-goal/README.zh.md | 16 +++---- .../client/ui-input-trigger/README.i18n.yaml | 2 +- packages/client/ui-input-trigger/README.zh.md | 18 +++---- packages/client/ui-jobs/README.i18n.yaml | 4 +- packages/client/ui-jobs/README.md | 4 +- packages/client/ui-jobs/README.zh.md | 22 ++++----- packages/client/ui-layout/README.i18n.yaml | 4 +- packages/client/ui-layout/README.md | 2 +- packages/client/ui-layout/README.zh.md | 18 +++---- .../ui-message-feedback/README.i18n.yaml | 2 +- .../client/ui-message-feedback/README.zh.md | 2 +- .../ui-model-selection/README.i18n.yaml | 4 +- packages/client/ui-model-selection/README.md | 4 +- .../client/ui-model-selection/README.zh.md | 18 +++---- .../client/ui-open-in-app/README.i18n.yaml | 2 +- packages/client/ui-open-in-app/README.zh.md | 12 ++--- .../ui-permission-presets/README.i18n.yaml | 4 +- .../client/ui-permission-presets/README.md | 6 +-- .../client/ui-permission-presets/README.zh.md | 18 +++---- packages/client/ui-plan/README.i18n.yaml | 2 +- packages/client/ui-plan/README.zh.md | 12 ++--- .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 4 +- packages/client/ui-primitives/README.zh.md | 26 +++++----- packages/client/ui-renderer/README.i18n.yaml | 2 +- packages/client/ui-renderer/README.zh.md | 10 ++-- packages/client/ui-schedule/README.i18n.yaml | 2 +- packages/client/ui-schedule/README.zh.md | 26 +++++----- packages/client/ui-session/README.i18n.yaml | 2 +- packages/client/ui-session/README.zh.md | 6 +-- .../ui-settings-general/README.i18n.yaml | 2 +- .../client/ui-settings-general/README.zh.md | 2 +- .../ui-settings-models/README.i18n.yaml | 2 +- .../client/ui-settings-models/README.zh.md | 2 +- .../README.i18n.yaml | 2 +- .../ui-settings-plugin-inventory/README.zh.md | 8 ++-- .../ui-settings-plugins/README.i18n.yaml | 2 +- .../client/ui-settings-plugins/README.zh.md | 10 ++-- .../README.i18n.yaml | 2 +- .../ui-sidebar-documentpreview/README.zh.md | 10 ++-- .../client/ui-sidebar-files/README.i18n.yaml | 2 +- packages/client/ui-sidebar-files/README.zh.md | 12 ++--- .../client/ui-sidebar-right/README.i18n.yaml | 2 +- packages/client/ui-sidebar-right/README.zh.md | 16 +++---- packages/client/ui-sidebar/README.i18n.yaml | 2 +- packages/client/ui-sidebar/README.zh.md | 10 ++-- packages/client/ui-skill/README.i18n.yaml | 4 +- packages/client/ui-skill/README.md | 2 +- packages/client/ui-skill/README.zh.md | 10 ++-- packages/client/ui-slots/README.i18n.yaml | 4 +- packages/client/ui-slots/README.md | 2 +- packages/client/ui-slots/README.zh.md | 14 +++--- packages/client/ui-subagent/README.i18n.yaml | 4 +- packages/client/ui-subagent/README.md | 4 +- packages/client/ui-subagent/README.zh.md | 4 +- packages/client/ui-theme/README.i18n.yaml | 2 +- packages/client/ui-theme/README.zh.md | 14 +++--- packages/client/ui-tool/README.i18n.yaml | 2 +- packages/client/ui-tool/README.zh.md | 16 +++---- .../client/ui-trajectory/README.i18n.yaml | 4 +- packages/client/ui-trajectory/README.md | 2 +- packages/client/ui-trajectory/README.zh.md | 14 +++--- .../client/ui-user-questions/README.i18n.yaml | 2 +- .../client/ui-user-questions/README.zh.md | 14 +++--- .../client/ui-workflow-run/README.i18n.yaml | 2 +- packages/client/ui-workflow-run/README.zh.md | 16 +++---- packages/client/ui-workspace/README.i18n.yaml | 4 +- packages/client/ui-workspace/README.md | 2 +- packages/client/ui-workspace/README.zh.md | 14 +++--- packages/client/web/README.i18n.yaml | 4 +- packages/client/web/README.md | 2 +- packages/client/web/README.zh.md | 12 ++--- packages/code-runtime/README.i18n.yaml | 4 +- packages/code-runtime/README.md | 2 +- packages/code-runtime/README.zh.md | 6 +-- .../README.i18n.yaml | 2 +- .../code-runtime-worker-thread/README.zh.md | 20 ++++---- .../code-runtime/README.i18n.yaml | 2 +- .../code-runtime/code-runtime/README.zh.md | 2 +- packages/compaction/README.i18n.yaml | 2 +- packages/compaction/README.zh.md | 4 +- .../command-compact/README.i18n.yaml | 2 +- .../compaction/command-compact/README.zh.md | 10 ++-- .../compaction-basic/README.i18n.yaml | 4 +- .../compaction/compaction-basic/README.md | 4 +- .../compaction/compaction-basic/README.zh.md | 14 +++--- .../README.i18n.yaml | 2 +- .../README.zh.md | 16 +++---- .../compaction/compaction/README.i18n.yaml | 4 +- packages/compaction/compaction/README.md | 8 ++-- packages/compaction/compaction/README.zh.md | 16 +++---- packages/context/README.i18n.yaml | 2 +- packages/context/README.zh.md | 16 +++---- .../agent-instructions/README.i18n.yaml | 4 +- packages/context/agent-instructions/README.md | 4 +- .../context/agent-instructions/README.zh.md | 16 +++---- .../file-reference-local/README.i18n.yaml | 2 +- .../context/file-reference-local/README.zh.md | 12 ++--- .../context/file-reference/README.i18n.yaml | 2 +- packages/context/file-reference/README.zh.md | 12 ++--- .../session-reference/README.i18n.yaml | 2 +- .../context/session-reference/README.zh.md | 26 +++++----- .../context/time-context/README.i18n.yaml | 4 +- packages/context/time-context/README.md | 4 +- packages/context/time-context/README.zh.md | 4 +- .../context/tmux-context/README.i18n.yaml | 4 +- packages/context/tmux-context/README.md | 6 +-- packages/context/tmux-context/README.zh.md | 16 +++---- packages/core/README.i18n.yaml | 2 +- packages/core/README.zh.md | 12 ++--- .../core/agent-default-model/README.i18n.yaml | 2 +- .../core/agent-default-model/README.zh.md | 12 ++--- packages/core/agent-loop/README.i18n.yaml | 4 +- packages/core/agent-loop/README.md | 4 +- packages/core/agent-loop/README.zh.md | 22 ++++----- .../agent-tool-presentation/README.i18n.yaml | 4 +- .../core/agent-tool-presentation/README.md | 2 +- .../core/agent-tool-presentation/README.zh.md | 8 ++-- packages/core/agent/README.i18n.yaml | 2 +- packages/core/agent/README.zh.md | 16 +++---- packages/core/scope/README.i18n.yaml | 2 +- packages/core/scope/README.zh.md | 10 ++-- packages/core/session/README.i18n.yaml | 4 +- packages/core/session/README.md | 4 +- packages/core/session/README.zh.md | 26 +++++----- packages/core/system-prompt/README.i18n.yaml | 2 +- packages/core/system-prompt/README.zh.md | 32 ++++++------- packages/core/tools/README.i18n.yaml | 4 +- packages/core/tools/README.md | 6 +-- packages/core/tools/README.zh.md | 6 +-- packages/credentials/README.i18n.yaml | 4 +- packages/credentials/README.md | 2 +- packages/credentials/README.zh.md | 6 +-- .../authorization/README.i18n.yaml | 2 +- .../credentials/authorization/README.zh.md | 14 +++--- .../credentials-local/README.i18n.yaml | 4 +- .../credentials/credentials-local/README.md | 4 +- .../credentials-local/README.zh.md | 16 +++---- .../credentials/credentials/README.i18n.yaml | 4 +- packages/credentials/credentials/README.md | 2 +- packages/credentials/credentials/README.zh.md | 16 +++---- packages/e2b/README.i18n.yaml | 2 +- packages/e2b/README.zh.md | 8 ++-- packages/e2b/e2b/README.i18n.yaml | 2 +- packages/e2b/e2b/README.zh.md | 16 +++---- packages/e2b/fs-e2b/README.i18n.yaml | 4 +- packages/e2b/fs-e2b/README.md | 2 +- packages/e2b/fs-e2b/README.zh.md | 16 +++---- packages/e2b/subprocess-e2b/README.i18n.yaml | 4 +- packages/e2b/subprocess-e2b/README.md | 2 +- packages/e2b/subprocess-e2b/README.zh.md | 10 ++-- packages/experimental/README.i18n.yaml | 2 +- packages/experimental/README.zh.md | 4 +- .../agent-team-profile/README.i18n.yaml | 2 +- .../agent-team-profile/README.zh.md | 12 ++--- .../agent-team-web-profile/README.i18n.yaml | 2 +- .../agent-team-web-profile/README.zh.md | 2 +- .../experimental/agent-team/README.i18n.yaml | 2 +- packages/experimental/agent-team/README.zh.md | 24 +++++----- .../client-ui-agent-team/README.i18n.yaml | 2 +- .../client-ui-agent-team/README.zh.md | 16 +++---- .../code-runtime-python/README.i18n.yaml | 2 +- .../code-runtime-python/README.zh.md | 11 +++-- .../experimental/inspector/README.i18n.yaml | 2 +- packages/experimental/inspector/README.zh.md | 38 +++++++-------- .../tool-agent-team/README.i18n.yaml | 2 +- .../experimental/tool-agent-team/README.zh.md | 22 ++++----- .../webworker-packer/README.i18n.yaml | 2 +- .../webworker-packer/README.zh.md | 22 ++++----- .../webworker-runtime/README.i18n.yaml | 2 +- .../webworker-runtime/README.zh.md | 10 ++-- packages/extensions/README.i18n.yaml | 4 +- packages/extensions/README.md | 2 +- packages/extensions/README.zh.md | 8 ++-- .../cordis-client-runner/README.i18n.yaml | 2 +- .../cordis-client-runner/README.zh.md | 4 +- .../cordis-host-runner/README.i18n.yaml | 2 +- .../cordis-host-runner/README.zh.md | 18 +++---- .../extensions/tool-cordis/README.i18n.yaml | 4 +- packages/extensions/tool-cordis/README.md | 2 +- packages/extensions/tool-cordis/README.zh.md | 22 ++++----- .../extensions/ui-cordis/README.i18n.yaml | 2 +- packages/extensions/ui-cordis/README.zh.md | 16 +++---- packages/feedback/README.i18n.yaml | 2 +- packages/feedback/README.zh.md | 8 ++-- .../command-feedback/README.i18n.yaml | 2 +- .../feedback/command-feedback/README.zh.md | 12 ++--- .../message-feedback/README.i18n.yaml | 4 +- packages/feedback/message-feedback/README.md | 4 +- .../feedback/message-feedback/README.zh.md | 20 ++++---- packages/fs/README.i18n.yaml | 2 +- packages/fs/README.zh.md | 4 +- packages/fs/fs-local/README.i18n.yaml | 4 +- packages/fs/fs-local/README.md | 4 +- packages/fs/fs-local/README.zh.md | 10 ++-- .../fs/fs-observation-policy/README.i18n.yaml | 4 +- packages/fs/fs-observation-policy/README.md | 6 +-- .../fs/fs-observation-policy/README.zh.md | 14 +++--- packages/fs/fs-sandbox/README.i18n.yaml | 4 +- packages/fs/fs-sandbox/README.md | 2 +- packages/fs/fs-sandbox/README.zh.md | 4 +- packages/fs/tool-fs-search/README.i18n.yaml | 2 +- packages/fs/tool-fs-search/README.zh.md | 8 ++-- packages/fs/tool-present/README.i18n.yaml | 2 +- packages/fs/tool-present/README.zh.md | 4 +- .../tool-str-replace-editor/README.i18n.yaml | 2 +- .../fs/tool-str-replace-editor/README.zh.md | 6 +-- packages/goal/README.i18n.yaml | 4 +- packages/goal/README.md | 4 +- packages/goal/README.zh.md | 14 +++--- packages/goal/command-goal/README.i18n.yaml | 2 +- packages/goal/command-goal/README.zh.md | 4 +- .../goal/goal-round-driver/README.i18n.yaml | 2 +- packages/goal/goal-round-driver/README.zh.md | 4 +- packages/goal/goal/README.i18n.yaml | 2 +- packages/goal/goal/README.zh.md | 14 +++--- packages/goal/tool-goal/README.i18n.yaml | 2 +- packages/goal/tool-goal/README.zh.md | 8 ++-- packages/guard/README.i18n.yaml | 4 +- packages/guard/README.md | 2 +- packages/guard/README.zh.md | 4 +- .../repeat-tool-reminder/README.i18n.yaml | 2 +- .../guard/repeat-tool-reminder/README.zh.md | 10 ++-- .../guard/timeout-policy/README.i18n.yaml | 4 +- packages/guard/timeout-policy/README.md | 2 +- packages/guard/timeout-policy/README.zh.md | 8 ++-- packages/hooks/README.i18n.yaml | 2 +- packages/hooks/README.zh.md | 6 +-- packages/hooks/hook-protocol/README.i18n.yaml | 2 +- packages/hooks/hook-protocol/README.zh.md | 10 ++-- .../hooks/hooks-claude-code/README.i18n.yaml | 4 +- packages/hooks/hooks-claude-code/README.md | 4 +- packages/hooks/hooks-claude-code/README.zh.md | 12 ++--- packages/hooks/hooks-codex/README.i18n.yaml | 2 +- packages/hooks/hooks-codex/README.zh.md | 4 +- packages/host/README.i18n.yaml | 4 +- packages/host/README.md | 2 +- packages/host/README.zh.md | 2 +- .../directory-picker-auto/README.i18n.yaml | 4 +- packages/host/directory-picker-auto/README.md | 2 +- .../host/directory-picker-auto/README.zh.md | 4 +- .../directory-picker-browse/README.i18n.yaml | 2 +- .../host/directory-picker-browse/README.zh.md | 10 ++-- .../directory-picker-native/README.i18n.yaml | 2 +- .../host/directory-picker-native/README.zh.md | 14 +++--- .../host/directory-picker/README.i18n.yaml | 4 +- packages/host/directory-picker/README.md | 2 +- packages/host/directory-picker/README.zh.md | 10 ++-- .../host/frontend-static/README.i18n.yaml | 2 +- packages/host/frontend-static/README.zh.md | 8 ++-- packages/host/open-in-app/README.i18n.yaml | 2 +- packages/host/open-in-app/README.zh.md | 12 ++--- .../host/plugin-inventory/README.i18n.yaml | 2 +- packages/host/plugin-inventory/README.zh.md | 14 +++--- packages/host/webserver/README.i18n.yaml | 2 +- packages/host/webserver/README.zh.md | 8 ++-- packages/identity/README.i18n.yaml | 2 +- packages/identity/README.zh.md | 4 +- .../anonymous-user-id/README.i18n.yaml | 4 +- packages/identity/anonymous-user-id/README.md | 6 +-- .../identity/anonymous-user-id/README.zh.md | 20 ++++---- packages/interaction/README.i18n.yaml | 4 +- packages/interaction/README.md | 2 +- packages/interaction/README.zh.md | 12 ++--- .../interaction/commands/README.i18n.yaml | 4 +- packages/interaction/commands/README.md | 2 +- packages/interaction/commands/README.zh.md | 20 ++++---- .../permission-presets/README.i18n.yaml | 2 +- .../permission-presets/README.zh.md | 10 ++-- .../tool-ask-user/README.i18n.yaml | 4 +- packages/interaction/tool-ask-user/README.md | 2 +- .../interaction/tool-ask-user/README.zh.md | 8 ++-- .../user-approval/README.i18n.yaml | 4 +- packages/interaction/user-approval/README.md | 2 +- .../interaction/user-approval/README.zh.md | 20 ++++---- .../user-questions/README.i18n.yaml | 4 +- packages/interaction/user-questions/README.md | 2 +- .../interaction/user-questions/README.zh.md | 4 +- packages/jobs/README.i18n.yaml | 2 +- packages/jobs/README.zh.md | 2 +- packages/jobs/jobs-local/README.i18n.yaml | 4 +- packages/jobs/jobs-local/README.md | 2 +- packages/jobs/jobs-local/README.zh.md | 6 +-- packages/jobs/jobs/README.i18n.yaml | 4 +- packages/jobs/jobs/README.md | 4 +- packages/jobs/jobs/README.zh.md | 14 +++--- packages/jobs/tool-jobs/README.i18n.yaml | 4 +- packages/jobs/tool-jobs/README.md | 4 +- packages/jobs/tool-jobs/README.zh.md | 16 +++---- packages/llm/README.i18n.yaml | 4 +- packages/llm/README.md | 2 +- packages/llm/README.zh.md | 8 ++-- .../README.i18n.yaml | 2 +- .../deepseek-llm-api-extensions/README.zh.md | 6 +-- packages/llm/llm-deepseek/README.i18n.yaml | 2 +- packages/llm/llm-deepseek/README.zh.md | 16 +++---- packages/llm/llm-pi-ai/README.i18n.yaml | 2 +- packages/llm/llm-pi-ai/README.zh.md | 18 +++---- packages/llm/llm-retry/README.i18n.yaml | 2 +- packages/llm/llm-retry/README.zh.md | 14 +++--- packages/llm/llm/README.i18n.yaml | 2 +- packages/llm/llm/README.zh.md | 16 +++---- .../README.i18n.yaml | 2 +- .../README.zh.md | 4 +- packages/llm/token-meter/README.i18n.yaml | 4 +- packages/llm/token-meter/README.md | 4 +- packages/llm/token-meter/README.zh.md | 16 +++---- packages/lsp/README.i18n.yaml | 2 +- packages/lsp/README.zh.md | 4 +- packages/lsp/lsp-stdio/README.i18n.yaml | 2 +- packages/lsp/lsp-stdio/README.zh.md | 6 +-- packages/lsp/lsp/README.i18n.yaml | 2 +- packages/lsp/lsp/README.zh.md | 6 +-- packages/lsp/tool-lsp/README.i18n.yaml | 2 +- packages/lsp/tool-lsp/README.zh.md | 10 ++-- packages/mcp/README.i18n.yaml | 2 +- packages/mcp/README.zh.md | 4 +- packages/mcp/mcp-client/README.i18n.yaml | 2 +- packages/mcp/mcp-client/README.zh.md | 22 ++++----- packages/plan/README.i18n.yaml | 2 +- packages/plan/README.zh.md | 8 ++-- packages/plan/plan-mode/README.i18n.yaml | 2 +- packages/plan/plan-mode/README.zh.md | 18 +++---- packages/preset/README.i18n.yaml | 2 +- packages/preset/README.zh.md | 6 +-- .../preset/agent-presets/README.i18n.yaml | 4 +- packages/preset/agent-presets/README.md | 2 +- packages/preset/agent-presets/README.zh.md | 12 ++--- packages/preset/persona/README.i18n.yaml | 2 +- packages/preset/persona/README.zh.md | 2 +- packages/runtime-diagnostics/README.i18n.yaml | 2 +- packages/runtime-diagnostics/README.zh.md | 6 +-- .../invariants/README.i18n.yaml | 2 +- .../invariants/README.zh.md | 24 +++++----- packages/sandbox/README.i18n.yaml | 2 +- packages/sandbox/README.zh.md | 8 ++-- .../sandbox/sandbox-local/README.i18n.yaml | 2 +- packages/sandbox/sandbox-local/README.zh.md | 8 ++-- .../sandbox/sandbox-policy/README.i18n.yaml | 2 +- packages/sandbox/sandbox-policy/README.zh.md | 16 +++---- .../sandbox-windows-acl/README.i18n.yaml | 2 +- .../sandbox/sandbox-windows-acl/README.zh.md | 6 +-- packages/sandbox/sandbox/README.i18n.yaml | 4 +- packages/sandbox/sandbox/README.md | 2 +- packages/sandbox/sandbox/README.zh.md | 16 +++---- packages/schedule/README.i18n.yaml | 2 +- packages/schedule/README.zh.md | 4 +- packages/schedule/schedule/README.i18n.yaml | 2 +- packages/schedule/schedule/README.zh.md | 14 +++--- packages/sdk/README.i18n.yaml | 2 +- packages/sdk/README.zh.md | 14 +++--- packages/sdk/client/README.i18n.yaml | 2 +- packages/sdk/client/README.zh.md | 14 +++--- packages/sdk/protocol/README.i18n.yaml | 2 +- packages/sdk/protocol/README.zh.md | 14 +++--- packages/sdk/server/README.i18n.yaml | 4 +- packages/sdk/server/README.md | 2 +- packages/sdk/server/README.zh.md | 16 +++---- packages/session-query/README.i18n.yaml | 2 +- packages/session-query/README.zh.md | 4 +- .../session-log-export/README.i18n.yaml | 2 +- .../session-log-export/README.zh.md | 24 +++++----- .../session-query-sqlite/README.i18n.yaml | 2 +- .../session-query-sqlite/README.zh.md | 8 ++-- .../session-query/README.i18n.yaml | 4 +- .../session-query/session-query/README.md | 2 +- .../session-query/session-query/README.zh.md | 16 +++---- .../tool-session-query/README.i18n.yaml | 2 +- .../tool-session-query/README.zh.md | 10 ++-- packages/session/README.i18n.yaml | 2 +- packages/session/README.zh.md | 8 ++-- .../README.i18n.yaml | 4 +- .../session-checkpoint-policy/README.md | 2 +- .../session-checkpoint-policy/README.zh.md | 6 +-- .../session-format-catalog/README.i18n.yaml | 2 +- .../session-format-catalog/README.zh.md | 8 ++-- .../session-format-v0-to-v1/README.i18n.yaml | 2 +- .../session-format-v0-to-v1/README.zh.md | 14 +++--- .../session-format-v2-to-v3/README.i18n.yaml | 2 +- .../session-format-v2-to-v3/README.zh.md | 16 +++---- .../session/session-format/README.i18n.yaml | 2 +- packages/session/session-format/README.zh.md | 14 +++--- .../session-log-deepseek/README.i18n.yaml | 2 +- .../session/session-log-deepseek/README.zh.md | 14 +++--- .../README.i18n.yaml | 2 +- .../session-persistence-jsonl/README.zh.md | 12 ++--- .../session-persistence/README.i18n.yaml | 2 +- .../session/session-persistence/README.zh.md | 30 ++++++------ .../session-projection-cache/README.i18n.yaml | 4 +- .../session-projection-cache/README.md | 4 +- .../session-projection-cache/README.zh.md | 16 +++---- .../session-projection/README.i18n.yaml | 2 +- .../session/session-projection/README.zh.md | 4 +- .../session/session-stats/README.i18n.yaml | 2 +- packages/session/session-stats/README.zh.md | 2 +- .../session-telemetry-otel/README.i18n.yaml | 2 +- .../session-telemetry-otel/README.zh.md | 6 +-- .../session-telemetry/README.i18n.yaml | 2 +- .../session/session-telemetry/README.zh.md | 10 ++-- .../README.i18n.yaml | 2 +- .../README.zh.md | 8 ++-- .../README.i18n.yaml | 2 +- .../README.zh.md | 8 ++-- .../session/session-title/README.i18n.yaml | 2 +- packages/session/session-title/README.zh.md | 8 ++-- .../session-turn-outline/README.i18n.yaml | 2 +- .../session/session-turn-outline/README.zh.md | 10 ++-- packages/settings/README.i18n.yaml | 4 +- packages/settings/README.md | 4 +- packages/settings/README.zh.md | 4 +- .../settings/settings-file/README.i18n.yaml | 2 +- packages/settings/settings-file/README.zh.md | 6 +-- packages/settings/settings/README.i18n.yaml | 2 +- packages/settings/settings/README.zh.md | 6 +-- packages/shell/README.i18n.yaml | 4 +- packages/shell/README.md | 4 +- packages/shell/README.zh.md | 6 +-- packages/shell/bash-local/README.i18n.yaml | 2 +- packages/shell/bash-local/README.zh.md | 16 +++---- packages/shell/bash-sandbox/README.i18n.yaml | 4 +- packages/shell/bash-sandbox/README.md | 2 +- packages/shell/bash-sandbox/README.zh.md | 14 +++--- packages/shell/pwsh-local/README.i18n.yaml | 2 +- packages/shell/pwsh-local/README.zh.md | 14 +++--- packages/shell/pwsh-sandbox/README.i18n.yaml | 4 +- packages/shell/pwsh-sandbox/README.md | 2 +- packages/shell/pwsh-sandbox/README.zh.md | 20 ++++---- packages/shell/shell-env/README.i18n.yaml | 2 +- packages/shell/shell-env/README.zh.md | 12 ++--- packages/shell/shell/README.i18n.yaml | 4 +- packages/shell/shell/README.md | 2 +- packages/shell/shell/README.zh.md | 12 ++--- .../tool-bash-persistent/README.i18n.yaml | 4 +- packages/shell/tool-bash-persistent/README.md | 6 +-- .../shell/tool-bash-persistent/README.zh.md | 12 ++--- packages/shell/tool-bash/README.i18n.yaml | 2 +- packages/shell/tool-bash/README.zh.md | 18 +++---- .../tool-pwsh-persistent/README.i18n.yaml | 2 +- .../shell/tool-pwsh-persistent/README.zh.md | 14 +++--- packages/shell/tool-pwsh/README.i18n.yaml | 2 +- packages/shell/tool-pwsh/README.zh.md | 16 +++---- packages/skill/README.i18n.yaml | 2 +- packages/skill/README.zh.md | 6 +-- packages/skill/skill-badge/README.i18n.yaml | 4 +- packages/skill/skill-badge/README.md | 4 +- packages/skill/skill-badge/README.zh.md | 10 ++-- .../skill/skill-filesystem/README.i18n.yaml | 2 +- packages/skill/skill-filesystem/README.zh.md | 12 ++--- packages/skill/skill/README.i18n.yaml | 4 +- packages/skill/skill/README.md | 2 +- packages/skill/skill/README.zh.md | 8 ++-- packages/skill/tool-skill/README.i18n.yaml | 4 +- packages/skill/tool-skill/README.md | 4 +- packages/skill/tool-skill/README.zh.md | 10 ++-- packages/spill/README.i18n.yaml | 2 +- packages/spill/README.zh.md | 4 +- packages/spill/spill-local/README.i18n.yaml | 2 +- packages/spill/spill-local/README.zh.md | 8 ++-- packages/spill/spill-policy/README.i18n.yaml | 2 +- packages/spill/spill-policy/README.zh.md | 2 +- packages/spill/spill/README.i18n.yaml | 2 +- packages/spill/spill/README.zh.md | 18 +++---- .../storage/storage-domain/README.i18n.yaml | 4 +- packages/storage/storage-domain/README.md | 6 +-- packages/storage/storage-domain/README.zh.md | 14 +++--- .../storage/storage-json/README.i18n.yaml | 2 +- packages/storage/storage-json/README.zh.md | 4 +- .../storage/storage-sqlite/README.i18n.yaml | 2 +- packages/storage/storage-sqlite/README.zh.md | 8 ++-- packages/storage/storage/README.i18n.yaml | 2 +- packages/storage/storage/README.zh.md | 6 +-- packages/subagent/README.i18n.yaml | 4 +- packages/subagent/README.md | 2 +- packages/subagent/README.zh.md | 6 +-- .../subagent/subagent-acp/README.i18n.yaml | 2 +- packages/subagent/subagent-acp/README.zh.md | 12 ++--- .../subagent-claude-code/README.i18n.yaml | 4 +- .../subagent/subagent-claude-code/README.md | 6 +-- .../subagent-claude-code/README.zh.md | 18 +++---- .../subagent/subagent-codex/README.i18n.yaml | 2 +- packages/subagent/subagent-codex/README.zh.md | 6 +-- .../subagent-dsh-sdk/README.i18n.yaml | 4 +- packages/subagent/subagent-dsh-sdk/README.md | 4 +- .../subagent/subagent-dsh-sdk/README.zh.md | 24 +++++----- .../subagent-fork-in-process/README.i18n.yaml | 2 +- .../subagent-fork-in-process/README.zh.md | 16 +++---- .../README.i18n.yaml | 4 +- .../subagent-in-process-driver/README.md | 2 +- .../subagent-in-process-driver/README.zh.md | 10 ++-- .../README.i18n.yaml | 2 +- .../subagent-spawn-in-process/README.zh.md | 14 +++--- packages/subagent/subagent/README.i18n.yaml | 2 +- packages/subagent/subagent/README.zh.md | 22 ++++----- .../tool-subagent-control/README.i18n.yaml | 2 +- .../tool-subagent-control/README.zh.md | 24 +++++----- .../subagent/tool-subagent/README.i18n.yaml | 2 +- packages/subagent/tool-subagent/README.zh.md | 2 +- packages/subprocess/README.i18n.yaml | 4 +- packages/subprocess/README.md | 2 +- packages/subprocess/README.zh.md | 2 +- .../subprocess-local/README.i18n.yaml | 2 +- .../subprocess/subprocess-local/README.zh.md | 16 +++---- .../subprocess/subprocess/README.i18n.yaml | 4 +- packages/subprocess/subprocess/README.md | 6 +-- packages/subprocess/subprocess/README.zh.md | 16 +++---- .../subprocess/win32-process/README.i18n.yaml | 2 +- .../subprocess/win32-process/README.zh.md | 42 ++++++++-------- packages/terminal/README.i18n.yaml | 2 +- packages/terminal/README.zh.md | 2 +- .../terminal/terminal-bash/README.i18n.yaml | 2 +- packages/terminal/terminal-bash/README.zh.md | 14 +++--- packages/terminal/terminal/README.i18n.yaml | 2 +- packages/terminal/terminal/README.zh.md | 10 ++-- .../terminal/tool-terminal/README.i18n.yaml | 2 +- packages/terminal/tool-terminal/README.zh.md | 10 ++-- packages/test-support/README.i18n.yaml | 4 +- packages/test-support/README.md | 2 +- packages/test-support/README.zh.md | 10 ++-- .../agent-loop-testkit/README.i18n.yaml | 2 +- .../agent-loop-testkit/README.zh.md | 8 ++-- .../client-runtime/README.i18n.yaml | 2 +- .../test-support/client-runtime/README.zh.md | 24 +++++----- .../llm-mock-server/README.i18n.yaml | 2 +- .../test-support/llm-mock-server/README.zh.md | 16 +++---- .../test-support/llm-replay/README.i18n.yaml | 2 +- packages/test-support/llm-replay/README.zh.md | 10 ++-- .../loader-smoke/README.i18n.yaml | 2 +- .../test-support/loader-smoke/README.zh.md | 18 +++---- .../session-snapshot/README.i18n.yaml | 2 +- .../session-snapshot/README.zh.md | 14 +++--- packages/todo/README.i18n.yaml | 2 +- packages/todo/README.zh.md | 2 +- packages/todo/tool-todo/README.i18n.yaml | 2 +- packages/todo/tool-todo/README.zh.md | 18 +++---- packages/typert/README.i18n.yaml | 4 +- packages/typert/README.md | 4 +- packages/typert/README.zh.md | 8 ++-- packages/typert/generator/README.i18n.yaml | 4 +- packages/typert/generator/README.md | 4 +- packages/typert/generator/README.zh.md | 16 +++---- packages/typert/loader/README.i18n.yaml | 4 +- packages/typert/loader/README.md | 2 +- packages/typert/loader/README.zh.md | 16 +++---- packages/typert/protocol/README.i18n.yaml | 2 +- packages/typert/protocol/README.zh.md | 6 +-- packages/util/README.i18n.yaml | 2 +- packages/util/README.zh.md | 6 +-- packages/util/atomic-write/README.i18n.yaml | 2 +- packages/util/atomic-write/README.zh.md | 14 +++--- packages/util/brand/README.i18n.yaml | 2 +- packages/util/brand/README.zh.md | 8 ++-- packages/util/chunked-list/README.i18n.yaml | 2 +- packages/util/chunked-list/README.zh.md | 10 ++-- packages/util/crypto/README.i18n.yaml | 2 +- packages/util/crypto/README.zh.md | 10 ++-- packages/util/deque/README.i18n.yaml | 2 +- packages/util/deque/README.zh.md | 6 +-- packages/util/home-paths/README.i18n.yaml | 4 +- packages/util/home-paths/README.md | 2 +- packages/util/home-paths/README.zh.md | 2 +- packages/util/http-proxy/README.i18n.yaml | 2 +- packages/util/http-proxy/README.zh.md | 20 ++++---- .../util/launch-environment/README.i18n.yaml | 2 +- packages/util/launch-environment/README.zh.md | 4 +- packages/util/native-command/README.i18n.yaml | 4 +- packages/util/native-command/README.md | 2 +- packages/util/native-command/README.zh.md | 4 +- .../util/output-retention/README.i18n.yaml | 2 +- packages/util/output-retention/README.zh.md | 2 +- packages/util/time/README.i18n.yaml | 2 +- packages/util/time/README.zh.md | 14 +++--- packages/util/timeout/README.i18n.yaml | 2 +- packages/util/timeout/README.zh.md | 8 ++-- packages/util/values/README.i18n.yaml | 2 +- packages/util/values/README.zh.md | 6 +-- packages/web/README.i18n.yaml | 2 +- packages/web/README.zh.md | 10 ++-- packages/web/tool-web/README.i18n.yaml | 2 +- packages/web/tool-web/README.zh.md | 6 +-- packages/web/web-fetch-http/README.i18n.yaml | 2 +- packages/web/web-fetch-http/README.zh.md | 8 ++-- .../web/web-search-deepseek/README.i18n.yaml | 4 +- packages/web/web-search-deepseek/README.md | 2 +- packages/web/web-search-deepseek/README.zh.md | 6 +-- packages/web/web-search-exa/README.i18n.yaml | 2 +- packages/web/web-search-exa/README.zh.md | 8 ++-- .../web-search-perplexity/README.i18n.yaml | 2 +- .../web/web-search-perplexity/README.zh.md | 6 +-- packages/web/web/README.i18n.yaml | 2 +- packages/web/web/README.zh.md | 6 +-- packages/webhook/README.i18n.yaml | 2 +- packages/webhook/README.zh.md | 12 ++--- .../webhook/webhook-github/README.i18n.yaml | 2 +- packages/webhook/webhook-github/README.zh.md | 28 +++++------ packages/webhook/webhook/README.i18n.yaml | 2 +- packages/webhook/webhook/README.zh.md | 48 +++++++++---------- packages/workflow/README.i18n.yaml | 2 +- packages/workflow/README.zh.md | 2 +- packages/workflow/tool-ralph/README.i18n.yaml | 4 +- packages/workflow/tool-ralph/README.md | 2 +- packages/workflow/tool-ralph/README.zh.md | 14 +++--- .../workflow/tool-workflow/README.i18n.yaml | 2 +- packages/workflow/tool-workflow/README.zh.md | 18 +++---- .../workflow-worker-thread/README.i18n.yaml | 2 +- .../workflow-worker-thread/README.zh.md | 14 +++--- packages/workflow/workflow/README.i18n.yaml | 4 +- packages/workflow/workflow/README.md | 4 +- packages/workflow/workflow/README.zh.md | 10 ++-- packages/workspace/README.i18n.yaml | 2 +- packages/workspace/README.zh.md | 6 +-- packages/workspace/workspace/README.i18n.yaml | 2 +- packages/workspace/workspace/README.zh.md | 12 ++--- python/README.i18n.yaml | 2 +- python/README.zh.md | 4 +- python/sdk-runtime/README.i18n.yaml | 2 +- python/sdk-runtime/README.zh.md | 16 +++---- python/sdk/README.i18n.yaml | 2 +- python/sdk/README.zh.md | 20 ++++---- python/sdk/examples/README.i18n.yaml | 2 +- python/sdk/examples/README.zh.md | 16 +++---- 744 files changed, 2592 insertions(+), 2631 deletions(-) diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index aa5c385a7e..cfded686cf 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/README.md README.md: 2e74ef68cf0b8487083a2e2af0f5175c78ec5212 -README.zh.md: 85a75f4c025e1bfe1461b9ffdaca533d308318ba +README.zh.md: 553f28f3af78b18658701449678de80b086dbb15 diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index 85a75f4c02..553f28f3af 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -`dsh` 是唯一受支持的 Node 应用启动器;profile 由多个插件组合包 patch 层按顺序叠加而成,其上再应用用户自己的覆盖配置。SDK 与 ACP 都是 profile,而不是独立的公开 bin。Python 运行时 wheel 会打包同一个命令;SDK 默认使用 `sdk`,极简示例选择 `sdk-minimal`。[`src/args.ts`](src/args.ts) 负责命令语法,[`src/bin.ts`](src/bin.ts) 只加载选中的运行器。无效命令、来自其他模式的选项、配置错误和启动失败都会以非零状态退出。 +`dsh` 是唯一受支持的 Node 应用启动器;profile 由多个插件组合包 patch 层按顺序叠加而成,其上再应用用户自己的覆盖配置。SDK 与 ACP(Agent Client Protocol)都是 profile,而不是独立的公开可执行命令。Python 运行时 wheel 包中也包含同一个命令;SDK 默认使用 `sdk`,极简示例选择 `sdk-minimal`。[`src/args.ts`](src/args.ts) 负责命令语法,[`src/bin.ts`](src/bin.ts) 只加载选中的运行器。无效命令、来自其他模式的选项、配置错误和启动失败都会以非零状态退出。 ## 入口模式 @@ -10,14 +10,14 @@ |---|---| | `dsh --profile ` | 启动位于 `$DSH_HOME/profiles/` 的指定 profile。 | | `dsh --profile --from-default-profile