From a815a7f2a99f0e6f97c21c0ebcdde107847979b0 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 17 Sep 2026 17:58:29 +0800 Subject: [PATCH 1/7] feat(web): open action, badge and section slots on plugin detail pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Plugins page declares three list slots as children of its main entry: `plugins.detail.actions` renders at the head of a detail page before its switch and uninstall controls, `plugins.detail.badge` after the tags beside the title, and `plugins.detail.section` below the page's own content. Every entry receives a `subject` owner prop naming what the open page is about — a bundle, one row of a bundle, or an official item — so one entry serves any page and renders nothing elsewhere. A row's page still opens only for rows with a `plugins.row.config` entry. The `plugin-config` web lane drives the three slots through the live fixture on its bundle page and its row page, records a new bundle golden, and shows the fixture contributing nothing to an official page. --- ...ugin-detail-page-extension-slots.i18n.yaml | 6 + ...9-17-plugin-detail-page-extension-slots.md | 35 ++++ ...7-plugin-detail-page-extension-slots.zh.md | 35 ++++ .../expected/plugin-config/bundle.expected.md | 25 +++ .../expected/plugin-config/row.expected.md | 5 + .../plugins/fixture-live-client/client.js | 32 +++- apps/web/tests/plugin-config.e2e.ts | 33 +++- docs/subsystems/slots.i18n.yaml | 4 +- docs/subsystems/slots.md | 3 + docs/subsystems/slots.zh.md | 3 + .../client/ui-plugin-manager/README.i18n.yaml | 4 +- packages/client/ui-plugin-manager/README.md | 14 ++ .../client/ui-plugin-manager/README.zh.md | 14 ++ .../src/client/PluginManagerPage.tsx | 66 ++++++- .../ui-plugin-manager/src/client/index.ts | 7 +- .../src/client/slot-contract.ts | 80 +++++++-- .../tests/browser-plugin.client.spec.tsx | 3 + .../tests/components.client.spec.tsx | 92 +++++++++- .../src/client/slot-catalog.ts | 166 +++++++++++++++++- 19 files changed, 583 insertions(+), 44 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-09-17-plugin-detail-page-extension-slots.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-09-17-plugin-detail-page-extension-slots.md create mode 100644 .agents/notes/implemented/architecture/2026-09-17-plugin-detail-page-extension-slots.zh.md create mode 100644 apps/web/tests/expected/plugin-config/bundle.expected.md diff --git a/.agents/notes/implemented/architecture/2026-09-17-plugin-detail-page-extension-slots.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-17-plugin-detail-page-extension-slots.i18n.yaml new file mode 100644 index 0000000000..5bd9f6000c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-17-plugin-detail-page-extension-slots.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-17-plugin-detail-page-extension-slots.md +2026-09-17-plugin-detail-page-extension-slots.md: d4bbca0c4466fbb78a8c56cfc4e327100385368f +2026-09-17-plugin-detail-page-extension-slots.zh.md: f07efc3d3cadded04a7551867680cc058d532943 diff --git a/.agents/notes/implemented/architecture/2026-09-17-plugin-detail-page-extension-slots.md b/.agents/notes/implemented/architecture/2026-09-17-plugin-detail-page-extension-slots.md new file mode 100644 index 0000000000..d4bbca0c44 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-17-plugin-detail-page-extension-slots.md @@ -0,0 +1,35 @@ +# Agent Note: Plugin detail pages as an extension point + +Status: implemented + +English | [中文](2026-09-17-plugin-detail-page-extension-slots.zh.md) + +## Problem + +The Plugins page's detail pages — a bundle's page, a row's page, an official plugin's page — carried only what the page drew itself and the object's own configuration ([plugin configuration on the Plugins page](2026-09-16-plugin-configuration-on-the-plugins-page.md)). A plugin with something to say about a bundle it does not own — a diagnostics plugin's health check, a market plugin's update badge, a documentation plugin's README section — had no place on that bundle's page, and importing the page to extend it is forbidden by the client bundle purity rule. + +## Decision + +**The page declares three list slots keyed by region, not by object.** `plugins.detail.actions` renders at the head of a page before the page's own switch and uninstall; `plugins.detail.badge` beside the title after the version, beta, and problem tags; `plugins.detail.section` under the page's own content — after the rows on a bundle's page, after the configuration on a row's or an official plugin's page. Each entry is rendered with the page's `subject`: `{ kind: 'bundle', pkg }`, `{ kind: 'row', pkg, row }`, or `{ kind: 'item', id }`. An entry decides from the subject whether to render and returns null otherwise; the page orders entries by `order`. + +**The subject is a projection, not the store.** `PluginPackageRef` and `PluginRowRef` carry the name, version, installed and enabled facts, and the row list — the facts a contribution decides on — and nothing of the page's own state, so the page's store stays private and the contract stays small. + +**A row's page still exists only for a configured row.** A list entry cannot say in advance which rows it has something for, and a row page with nothing on it shows nothing worth opening, so the configure control keeps its rule: a row is opened when a `plugins.row.config` entry names it, and contributions for the row render on that page. + +## Alternatives considered + +**One slot per object and region (`plugins.bundle.section`, `plugins.row.section`, ...).** Rejected: nine slots for three regions, and a contribution that spans objects would register three times. The subject discriminator carries the same information in three slots. + +**Passing the page's `PackageView` and `PackageRow` as the subject.** Rejected: those types carry the Host's read-only reasons, errors, entry ids, and fiber phases, which are the page's business; exposing them would make store internals a public contract. + +**Every row gets a page so row contributions always have a home.** Deferred: a row without configuration has nothing a page would show beyond its module and state; the rule can widen when a contribution needs it. + +## Consequences + +- A contribution is a slot registration with `import type` of `ui-plugin-manager` for the declarations; the page never names a contributing plugin. +- The e2e fixture bundle (`@fixture/live-client`) contributes to its own bundle and row pages through the three slots; its browser half registers them with the rest, and they leave with it. +- The slot catalog and the slots subsystem hierarchy list the three slots under `main`. + +## Testing + +`packages/client/ui-plugin-manager/tests/components.client.spec.tsx` renders contributions on a bundle's page, a row's page, and an official plugin's page with the subject each is about, and pins the projection; `browser-plugin.client.spec.tsx` pins the declarations. `apps/web/tests/plugin-config.e2e.ts` switches the fixture bundle on and asserts its action, badge, and section on the bundle's page and the row's page, their absence on an official plugin's page, and holds the bundle page's golden. diff --git a/.agents/notes/implemented/architecture/2026-09-17-plugin-detail-page-extension-slots.zh.md b/.agents/notes/implemented/architecture/2026-09-17-plugin-detail-page-extension-slots.zh.md new file mode 100644 index 0000000000..f07efc3d3c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-17-plugin-detail-page-extension-slots.zh.md @@ -0,0 +1,35 @@ +# Agent Note:插件详情页作为扩展点 + +状态:已实现 + +[English](2026-09-17-plugin-detail-page-extension-slots.md) | 中文 + +## 问题 + +插件页的详情页——组合包页、行页、官方插件页——只承载页面自绘的内容和对象自己的配置([插件配置放在插件页](2026-09-16-plugin-configuration-on-the-plugins-page.zh.md))。对不属于自己的组合包有话要说的插件——诊断插件的健康检查、市场插件的更新标签、文档插件的 README 区块——在那个组合包的页面上无处安放,而通过 import 页面来扩展它被客户端包纯净性规则禁止。 + +## 决策 + +**页面按区域而不是按对象声明三个 list slot。** `plugins.detail.actions` 渲染在页头,位于页面自己的开关和卸载之前;`plugins.detail.badge` 在标题旁,位于版本、Beta 和异常标签之后;`plugins.detail.section` 在页面自身内容之下——组合包页在组件列表之后,行页与官方插件页在配置之后。每个条目都以页面的 `subject` 渲染:`{ kind: 'bundle', pkg }`、`{ kind: 'row', pkg, row }` 或 `{ kind: 'item', id }`。条目根据 subject 决定是否渲染,否则返回 null;页面按 `order` 排列条目。 + +**subject 是投影,不是 store。** `PluginPackageRef` 与 `PluginRowRef` 携带包名、版本、是否已安装、是否启用以及行列表——贡献者据以判断的事实——不带页面自己的任何状态,因此页面的 store 保持私有,契约保持精简。 + +**行页仍然只为已配置的行存在。** list 条目无法预先说明它对哪些行有话要说,而一个空空如也的行页没有值得打开的内容,所以配置控件保留原规则:有 `plugins.row.config` 条目点名这一行时才可打开,对该行的贡献渲染在那个页面上。 + +## 考虑过的替代方案 + +**按对象和区域各开一个 slot(`plugins.bundle.section`、`plugins.row.section` 等)。** 否决:三个区域要九个 slot,而跨对象的贡献要注册三次。subject 判别式在三个 slot 里承载同样的信息。 + +**把页面的 `PackageView` 与 `PackageRow` 直接作为 subject。** 否决:这些类型携带 Host 的只读原因、错误、条目 id 与 fiber 阶段,这些是页面自己的事;暴露它们会让 store 内部成为公共契约。 + +**每一行都有页面,让行级贡献总有落脚处。** 推迟:没有配置的行,页面除了模块名和状态之外无可展示;等有贡献需要时再放宽这条规则。 + +## 后果 + +- 一个贡献就是一次 slot 注册,只需 `import type` `ui-plugin-manager` 的声明;页面从不点名任何贡献插件。 +- e2e 夹具组合包(`@fixture/live-client`)通过三个 slot 向它自己的组合包页和行页贡献内容;它的浏览器半侧与其余注册一起完成,也一起离开。 +- slot 目录与 slots 子系统层级树在 `main` 下列出这三个 slot。 + +## 测试 + +`packages/client/ui-plugin-manager/tests/components.client.spec.tsx` 在组合包页、行页和官方插件页上渲染贡献并核对各自的 subject,钉住投影;`browser-plugin.client.spec.tsx` 钉住声明。`apps/web/tests/plugin-config.e2e.ts` 打开夹具组合包,断言组合包页与行页上的操作、标签和区块、官方插件页上没有它们,并保留组合包页的 golden。 diff --git a/apps/web/tests/expected/plugin-config/bundle.expected.md b/apps/web/tests/expected/plugin-config/bundle.expected.md new file mode 100644 index 0000000000..23985c8c66 --- /dev/null +++ b/apps/web/tests/expected/plugin-config/bundle.expected.md @@ -0,0 +1,25 @@ +- button "返回插件列表": + - img + - text: 插件列表 +- button "夹具操作" +- button "卸载 live-client": + - img + - text: 卸载 +- switch "启用 live-client" [checked] +- heading "live-client" [level=3] +- text: v0.0.1 夹具标签 +- paragraph: + - code: "@fixture/live-client" +- paragraph: 暂无描述。 +- heading "包含的组件" [level=4] +- text: 共 1 个 · 1 运行中 +- list: + - listitem: + - button "配置 fixture-live-client": + - text: fixture-live-client + - img + - text: "@fixture/live-client 运行中" + - switch "启用组件 fixture-live-client" [checked] +- region "夹具区块": + - heading "夹具区块" [level=4] + - paragraph: 来自夹具的区块内容 diff --git a/apps/web/tests/expected/plugin-config/row.expected.md b/apps/web/tests/expected/plugin-config/row.expected.md index a68510932f..839c9d630f 100644 --- a/apps/web/tests/expected/plugin-config/row.expected.md +++ b/apps/web/tests/expected/plugin-config/row.expected.md @@ -1,7 +1,9 @@ - button "返回 live-client": - img - text: live-client +- button "夹具操作" - heading "fixture-live-client" [level=3] +- text: 夹具标签 - paragraph: - code: "@fixture/live-client" - paragraph: 示例配置项 @@ -9,3 +11,6 @@ - text: 问候语 - textbox "问候语": 你好 - button "保存" +- region "夹具区块": + - heading "夹具区块" [level=4] + - paragraph: 来自夹具的区块内容 diff --git a/apps/web/tests/fixtures/plugins/fixture-live-client/client.js b/apps/web/tests/fixtures/plugins/fixture-live-client/client.js index d870a5e52a..e2dd74c8f4 100644 --- a/apps/web/tests/fixtures/plugins/fixture-live-client/client.js +++ b/apps/web/tests/fixtures/plugins/fixture-live-client/client.js @@ -13,9 +13,37 @@ window.__ModuleLoader__.load({ const counters = document.documentElement.dataset counters.liveMounts = String(Number(counters.liveMounts ?? 0) + 1) ctx.effect(() => ctx.locale.register('fixtureLive', { - zh: { active: '动态插件已启用', configSummary: '示例配置项', configForm: '动态插件配置', configField: '问候语', configSave: '保存' }, - en: { active: 'Live plugin enabled', configSummary: 'An example setting', configForm: 'Live plugin configuration', configField: 'Greeting', configSave: 'Save' }, + zh: { + active: '动态插件已启用', configSummary: '示例配置项', configForm: '动态插件配置', configField: '问候语', configSave: '保存', + action: '夹具操作', badge: '夹具标签', section: '夹具区块', sectionBody: '来自夹具的区块内容', + }, + en: { + active: 'Live plugin enabled', configSummary: 'An example setting', configForm: 'Live plugin configuration', configField: 'Greeting', configSave: 'Save', + action: 'Fixture action', badge: 'Fixture badge', section: 'Fixture section', sectionBody: 'Section content from the fixture', + }, })) + // Detail contributions for this bundle's own page and its row's page: an + // action at the head, a badge beside the title, and a section below. + // The subject says what the open page is about; another bundle's page + // gets nothing from these entries. + const mine = (subject) => (subject.kind === 'bundle' || subject.kind === 'row') && subject.pkg.name === '@fixture/live-client' + ctx.slots.inject('plugins.detail.actions', () => ctx.slots.register({ + name: 'plugins.detail.actions', id: 'fixture-live-client', locale: 'fixtureLive', + }, ({ t, subject }) => mine(subject) + ? React.createElement('button', { type: 'button', 'data-live-action': subject.kind }, t('action')) + : null)) + ctx.slots.inject('plugins.detail.badge', () => ctx.slots.register({ + name: 'plugins.detail.badge', id: 'fixture-live-client', locale: 'fixtureLive', + }, ({ t, subject }) => mine(subject) + ? React.createElement('span', { 'data-live-badge': subject.kind }, t('badge')) + : null)) + ctx.slots.inject('plugins.detail.section', () => ctx.slots.register({ + name: 'plugins.detail.section', id: 'fixture-live-client', locale: 'fixtureLive', + }, ({ t, subject }) => mine(subject) + ? React.createElement('section', { 'data-live-section': subject.kind, 'aria-label': t('section') }, + React.createElement('h4', null, t('section')), + React.createElement('p', null, t('sectionBody'))) + : null)) ctx.slots.inject('shell.overlay', () => ctx.slots.register({ name: 'shell.overlay', id: 'fixture-live-client', locale: 'fixtureLive', }, ({ t }) => React.createElement('div', { 'data-live-client': '' }, t('active')))) diff --git a/apps/web/tests/plugin-config.e2e.ts b/apps/web/tests/plugin-config.e2e.ts index db20eed409..7266b766a7 100644 --- a/apps/web/tests/plugin-config.e2e.ts +++ b/apps/web/tests/plugin-config.e2e.ts @@ -20,6 +20,7 @@ import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/plugin-config', import.meta.url)) const OFFICIAL_EXPECTED = join(SNAPSHOT_DIR, 'official.expected.md') const ROW_EXPECTED = join(SNAPSHOT_DIR, 'row.expected.md') +const BUNDLE_EXPECTED = join(SNAPSHOT_DIR, 'bundle.expected.md') const FIXTURE_PLUGINS = fileURLToPath(new URL('./fixtures/plugins', import.meta.url)) const MODE = webSnapshotMode() @@ -291,17 +292,37 @@ describe('web e2e: plugin configuration pages', () => { await row.waitFor({ timeout: 10_000 }) expect(await panel.getByRole('button', { name: '配置 fixture-live-client' }).count()).toBe(0) + // Off, the bundle's page carries no contribution either: the browser half + // that would make them is not loaded. + const bundlePage = panel.locator('[data-plugin-detail="@fixture/live-client"]') + expect(await bundlePage.locator('[data-live-action]').count()).toBe(0) + // Switched on, the Host recomposes and the browser half mounts without a - // reload; its registration puts the configure control on the row. + // reload; its registration puts the configure control on the row, and + // its detail contributions on the bundle's page: the action before the + // page's own switch, the badge beside the title, the section after the rows. await panel.getByRole('switch', { name: '启用 live-client' }).click() const configure = panel.getByRole('button', { name: '配置 fixture-live-client' }) await configure.waitFor({ timeout: 30_000 }) + await bundlePage.locator('[data-live-section="bundle"]').waitFor({ timeout: 10_000 }) + // The page's own view of the row follows the Host's change event, which + // can land after the browser half mounted; the golden holds the settled page. + await row.getByText('运行中', { exact: true }).waitFor({ timeout: 10_000 }) + expect(await bundlePage.getByRole('button', { name: '夹具操作' }).count()).toBe(1) + expect(await bundlePage.locator('[data-live-badge="bundle"]').textContent()).toBe('夹具标签') + expect(await bundlePage.getByRole('region', { name: '夹具区块' }).getByText('来自夹具的区块内容').count()).toBe(1) + const bundleSnapshot = await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd) + await compareOrRefreshGolden(BUNDLE_EXPECTED, bundleSnapshot, MODE) await configure.click() const rowPage = panel.locator('[data-plugin-row-detail="@fixture/live-client#fixture-live-client"]') await rowPage.waitFor({ timeout: 10_000 }) expect(await rowPage.getByRole('heading', { level: 3 }).textContent()).toBe('fixture-live-client') expect(await rowPage.getByText('示例配置项', { exact: true }).count()).toBe(1) + // The same entries render for the row's page, told it is about the row. + expect(await rowPage.locator('[data-live-action="row"]').count()).toBe(1) + expect(await rowPage.locator('[data-live-badge="row"]').count()).toBe(1) + expect(await rowPage.locator('[data-live-section="row"]').count()).toBe(1) const form = rowPage.getByRole('form', { name: '动态插件配置' }) await form.getByLabel('问候语').fill('你好') await form.getByRole('button', { name: '保存' }).click() @@ -310,12 +331,18 @@ describe('web e2e: plugin configuration pages', () => { const snapshot = await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd) await compareOrRefreshGolden(ROW_EXPECTED, snapshot, MODE) await rowPage.getByRole('button', { name: '返回 live-client' }).click() - await panel.locator('[data-plugin-detail="@fixture/live-client"]').waitFor({ timeout: 10_000 }) + await bundlePage.waitFor({ timeout: 10_000 }) + + // An official plugin's page is another subject; the fixture's entries render nothing for it. + await panel.getByRole('button', { name: '返回插件列表' }).click() + await openPage(panel, '终端') + const itemPage = panel.locator('[data-plugin-item-detail]') + expect(await itemPage.locator('[data-live-action], [data-live-badge], [data-live-section]').count()).toBe(0) expect(tripwire.pageErrors).toEqual([]) }, 90_000) it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { expect(tripwire.warnings).toEqual([]) - await assertFixtureInventory(SNAPSHOT_DIR, ['official.expected.md', 'row.expected.md', 'subagent.expected.md']) + await assertFixtureInventory(SNAPSHOT_DIR, ['bundle.expected.md', 'official.expected.md', 'row.expected.md', 'subagent.expected.md']) }) }) diff --git a/docs/subsystems/slots.i18n.yaml b/docs/subsystems/slots.i18n.yaml index d25c088405..4dff9db725 100644 --- a/docs/subsystems/slots.i18n.yaml +++ b/docs/subsystems/slots.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/slots.md -slots.md: 177bbc5b8f56b7276f271a2ddb7ac71ac8888363 -slots.zh.md: c746ac88704c2e22e5c50ef82a893fde9f091668 +slots.md: 31ccf545dc9d0e4a8df88dd9a6aa24541686fb2f +slots.zh.md: 734daa4919e9b0a9cba74d6c523011fd37ce26ab diff --git a/docs/subsystems/slots.md b/docs/subsystems/slots.md index 177bbc5b8f..31ccf545dc 100644 --- a/docs/subsystems/slots.md +++ b/docs/subsystems/slots.md @@ -132,6 +132,9 @@ root │ ├─ plugins.item │ ├─ plugins.bundle.config │ ├─ plugins.row.config +│ ├─ plugins.detail.actions +│ ├─ plugins.detail.badge +│ ├─ plugins.detail.section │ └─ main.conversation │ ├─ conversation.session │ │ └─ conversation.view diff --git a/docs/subsystems/slots.zh.md b/docs/subsystems/slots.zh.md index c746ac8870..734daa4919 100644 --- a/docs/subsystems/slots.zh.md +++ b/docs/subsystems/slots.zh.md @@ -132,6 +132,9 @@ root │ ├─ plugins.item │ ├─ plugins.bundle.config │ ├─ plugins.row.config +│ ├─ plugins.detail.actions +│ ├─ plugins.detail.badge +│ ├─ plugins.detail.section │ └─ main.conversation │ ├─ conversation.session │ │ └─ conversation.view diff --git a/packages/client/ui-plugin-manager/README.i18n.yaml b/packages/client/ui-plugin-manager/README.i18n.yaml index e50cdb00de..795636bb9f 100644 --- a/packages/client/ui-plugin-manager/README.i18n.yaml +++ b/packages/client/ui-plugin-manager/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-plugin-manager/README.md -README.md: e7128dd2146cc0553ab36fdef5c31470be7877f2 -README.zh.md: ff7706f1d9747f553bf91f45a76fa5f50472330d +README.md: 4b6c6752f8dc71f7c00324cf56d236631810f752 +README.zh.md: f7a9ebe9efba83e6b9f5fb46b6422c2e099ead04 diff --git a/packages/client/ui-plugin-manager/README.md b/packages/client/ui-plugin-manager/README.md index e7128dd214..4b6c6752f8 100644 --- a/packages/client/ui-plugin-manager/README.md +++ b/packages/client/ui-plugin-manager/README.md @@ -57,6 +57,20 @@ ctx.slots.inject('plugins.row.config', () => ctx.slots.register({ The bundle's patch must declare the row under that id, and the registration exists while the bundle is on, so a bundle that is off shows no configure control. +### Detail page extension points + +A plugin with something to say about a bundle, a row, or an official plugin it does not own contributes to that object's page through three list slots the page declares: `plugins.detail.actions` for a control at the head of the page, before the page's own switch and uninstall; `plugins.detail.badge` for a tag beside the title, after the version, beta, and problem tags; and `plugins.detail.section` for a section under the page's own content — after the rows on a bundle's page, after the configuration on a row's or an official plugin's page. Every entry is rendered with the page's `subject`: `{ kind: 'bundle', pkg }`, `{ kind: 'row', pkg, row }`, or `{ kind: 'item', id }`, where `pkg` and `row` carry the name, version, installed and enabled facts, and the row list a contribution decides on. An entry renders null for a subject it has nothing for and draws its own section chrome; the page orders entries by `order`. + +```tsx ignore-check +ctx.slots.inject('plugins.detail.section', () => ctx.slots.register({ + name: 'plugins.detail.section', + id: 'acme-health', + locale: 'acmeHealth', +}, ({ t, subject }) => subject.kind === 'bundle' ? : null)) +``` + +A row's page exists only while a `plugins.row.config` entry names the row, so a contribution meant for a row renders on the page that configuration opens. + ----- diff --git a/packages/client/ui-plugin-manager/README.zh.md b/packages/client/ui-plugin-manager/README.zh.md index ff7706f1d9..f7a9ebe9ef 100644 --- a/packages/client/ui-plugin-manager/README.zh.md +++ b/packages/client/ui-plugin-manager/README.zh.md @@ -57,6 +57,20 @@ ctx.slots.inject('plugins.row.config', () => ctx.slots.register({ 组合包的 patch 必须以该 id 声明这一行;注册只在组合包开启期间存在,因此关闭的组合包不显示配置控件。 +### 详情页扩展点 + +对某个不属于自己的组合包、行或官方插件有话要说的插件,通过本页声明的三个 list slot 向该对象的页面贡献内容:`plugins.detail.actions` 在页头放一个控件,位于页面自己的开关和卸载之前;`plugins.detail.badge` 在标题旁放一个标签,位于版本、Beta 和异常标签之后;`plugins.detail.section` 在页面自身内容之下放一个区块——组合包页在组件列表之后,行页和官方插件页在配置之后。每个条目都以页面的 `subject` 渲染:`{ kind: 'bundle', pkg }`、`{ kind: 'row', pkg, row }` 或 `{ kind: 'item', id }`,其中 `pkg` 与 `row` 携带包名、版本、是否已安装、是否启用以及行列表这些供贡献者判断的事实。条目对无话可说的 subject 返回 null,自绘区块外观;页面按 `order` 排列条目。 + +```tsx ignore-check +ctx.slots.inject('plugins.detail.section', () => ctx.slots.register({ + name: 'plugins.detail.section', + id: 'acme-health', + locale: 'acmeHealth', +}, ({ t, subject }) => subject.kind === 'bundle' ? : null)) +``` + +行页只在某个 `plugins.row.config` 条目点名这一行时存在,因此给行的贡献渲染在该配置打开的页面上。 + ----- diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx index a7cec19607..d118e672ab 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx @@ -26,14 +26,17 @@ import { type PluginManagerFace, } from './manager-store.ts' import { managementText, noticeText, packageText, type Translate } from './presentation.ts' -import type {} from './slot-contract.ts' +import type { PluginPackageRef, PluginRowRef, PluginsSubject } from './slot-contract.ts' import css from './PluginManagerPage.module.css' /** Full component props assembled by the main slot renderer. */ export type PluginManagerPageProps = PropsRuntime<'main'> & PropsLocale<'pluginManager'> - & PropsRenderSlots<'plugins.item' | 'plugins.bundle.config' | 'plugins.row.config'> + & PropsRenderSlots< + | 'plugins.item' | 'plugins.bundle.config' | 'plugins.row.config' + | 'plugins.detail.actions' | 'plugins.detail.badge' | 'plugins.detail.section' + > & InjectFace /** The page's slot renderer, narrowed to the configuration slots. */ @@ -343,24 +346,54 @@ function ItemCard({ item, t, onOpen, renderSlot }: { ) } -/** An official plugin's page: the crumb back to the cards, its icon, its title over its one-liner, and the form the entry renders. */ +/** One row as the detail slots see it. */ +function rowRef(row: PackageRow): PluginRowRef { + return { rowId: row.rowId, moduleName: row.moduleName, enabled: row.enabled } +} + +/** One bundle as the detail slots see it. */ +function packageRef(pkg: PackageView): PluginPackageRef { + return { + name: pkg.name, + ...pkg.version === undefined ? {} : { version: pkg.version }, + installed: pkg.installed, + enabled: pkg.enabled, + rows: pkg.rows.map(rowRef), + } +} + +/** + * An official plugin's page: the crumb back to the cards, its icon with the + * contributed actions, its title with the contributed badges over its + * one-liner, the form the entry renders, and the contributed sections. + */ function ItemDetail({ item, t, onBack, renderSlot }: { readonly item: OfficialItem readonly t: Translate readonly onBack: () => void readonly renderSlot: RenderConfig }): ReactNode { + const subject: PluginsSubject = { kind: 'item', id: item.id } return (
- + {renderSlot('plugins.detail.actions', { subject })}
} + />

{item.label}

+ {renderSlot('plugins.detail.badge', { subject })}

{renderSlot('plugins.item', { view: 'summary' }, { only: item.id })}

-
- {renderSlot('plugins.item', { view: 'page' }, { only: item.id })} +
+
+ {renderSlot('plugins.item', { view: 'page' }, { only: item.id })} +
+ {renderSlot('plugins.detail.section', { subject })}
) @@ -379,18 +412,29 @@ function RowDetail({ pkg, row, t, onBack, renderSlot }: { }): ReactNode { const { title } = packageText(pkg, t) const key = rowConfigKey(pkg.name, row.rowId) + const subject: PluginsSubject = { kind: 'row', pkg: packageRef(pkg), row: rowRef(row) } return (
- } /> + } + actions={
{renderSlot('plugins.detail.actions', { subject })}
} + />

{row.rowId}

+ {renderSlot('plugins.detail.badge', { subject })}

{row.moduleName}

{renderSlot('plugins.row.config', { view: 'summary' }, { entryKey: key })}

-
- {renderSlot('plugins.row.config', { view: 'page' }, { entryKey: key })} +
+
+ {renderSlot('plugins.row.config', { view: 'page' }, { entryKey: key })} +
+ {renderSlot('plugins.detail.section', { subject })}
) @@ -424,6 +468,7 @@ function PackageDetail({ }): ReactNode { const { title, description, beta } = packageText(pkg, t) const status = packageStatus(pkg) + const subject: PluginsSubject = { kind: 'bundle', pkg: packageRef(pkg) } return (
+ {renderSlot('plugins.detail.actions', { subject })} {pkg.installed ? (

{pkg.name}

{description ?? t('noDescription')}

@@ -477,6 +524,7 @@ function PackageDetail({ toggle={pkg.enabled ? { busy: row => busy || rowBusy(row), onSetEnabled: onSetRowEnabled } : undefined} configure={configure} /> + {renderSlot('plugins.detail.section', { subject })}
) diff --git a/packages/client/ui-plugin-manager/src/client/index.ts b/packages/client/ui-plugin-manager/src/client/index.ts index 1e2c9a60b1..2303b2609b 100644 --- a/packages/client/ui-plugin-manager/src/client/index.ts +++ b/packages/client/ui-plugin-manager/src/client/index.ts @@ -31,7 +31,9 @@ export type { PluginManagerPageProps } from './PluginManagerPage.tsx' export type { ConfigLedger, OfficialItem } from './config-ledger.ts' export type { PluginManagerFace } from './manager-store.ts' export type { PluginManagerLocaleKey } from './locales.ts' -export type { PluginConfigViewProps } from './slot-contract.ts' +export type { + PluginConfigViewProps, PluginDetailProps, PluginPackageRef, PluginRowRef, PluginsSubject, +} from './slot-contract.ts' declare module '@deepseek-ai/dsh-client-ui-slots' { interface LocaleNamespaceMap { @@ -89,6 +91,9 @@ export function apply(ctx: ClientContext): void { 'plugins.item': { kind: 'list', scope: 'root' }, 'plugins.bundle.config': { kind: 'keyed', scope: 'root' }, 'plugins.row.config': { kind: 'keyed', scope: 'root' }, + 'plugins.detail.actions': { kind: 'list', scope: 'root' }, + 'plugins.detail.badge': { kind: 'list', scope: 'root' }, + 'plugins.detail.section': { kind: 'list', scope: 'root' }, }, }, PluginManagerPage)) ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({ diff --git a/packages/client/ui-plugin-manager/src/client/slot-contract.ts b/packages/client/ui-plugin-manager/src/client/slot-contract.ts index 2fd0b41d40..3a371375f8 100644 --- a/packages/client/ui-plugin-manager/src/client/slot-contract.ts +++ b/packages/client/ui-plugin-manager/src/client/slot-contract.ts @@ -1,13 +1,19 @@ /** - * The slots the Plugins page declares for plugins that carry their own - * configuration: the official plugins listed beside the official bundles, one - * bundle's configuration on its page, and one row's configuration opened from - * that row. A registrant merges this contract with `import type` and registers - * through `ctx.slots`; it never imports this package at runtime. + * The slots the Plugins page declares for other plugins. Three carry a + * plugin's own configuration: the official plugins listed beside the official + * bundles, one bundle's configuration on its page, and one row's configuration + * opened from that row. Three more take contributions to any detail page — + * its head actions, the badges beside its title, and the sections under its + * own content — from a plugin that has something to say about bundles, rows, + * or official plugins it does not own. A registrant merges this contract with + * `import type` and registers through `ctx.slots`; it never imports this + * package at runtime. * - * Every entry is rendered in two views the owner asks for: `summary` for the - * one-liner the page places under the title, `page` for the form with its own - * save control. The page draws the title, the icon, and the crumb itself. + * A configuration entry is rendered in two views the owner asks for: + * `summary` for the one-liner the page places under the title, `page` for the + * form with its own save control. A detail contribution is rendered with the + * subject of the open page and decides from it whether to render at all. The + * page draws the title, the icon, and the crumb itself. */ import type {} from '@deepseek-ai/dsh-client-ui-slots' @@ -18,6 +24,42 @@ export interface PluginConfigViewProps { readonly view: 'summary' | 'page' } +/** One row of a bundle as a detail contribution sees it. */ +export interface PluginRowRef { + /** The row id as the bundle's patch declares it. */ + readonly rowId: string + /** The module the row names. */ + readonly moduleName: string + /** Whether the row's entry runs. */ + readonly enabled: boolean +} + +/** One bundle as a detail contribution sees it: the facts a contribution decides on, never the page's own state. */ +export interface PluginPackageRef { + /** The package name. */ + readonly name: string + /** The installed version, when the Host reports one. */ + readonly version?: string + /** Whether the profile's own dependencies hold the package; false for a bundle the installation supplies. */ + readonly installed: boolean + /** Whether the bundle is switched on. */ + readonly enabled: boolean + /** The rows the bundle declares. */ + readonly rows: readonly PluginRowRef[] +} + +/** What a detail page is about: a bundle, one row of a bundle, or an official plugin listed by its `plugins.item` id. */ +export type PluginsSubject = + | { readonly kind: 'bundle'; readonly pkg: PluginPackageRef } + | { readonly kind: 'row'; readonly pkg: PluginPackageRef; readonly row: PluginRowRef } + | { readonly kind: 'item'; readonly id: string } + +/** The owner props every detail contribution is rendered with. */ +export interface PluginDetailProps { + /** The subject of the open page; an entry renders null for a subject it has nothing for. */ + readonly subject: PluginsSubject +} + declare module '@deepseek-ai/dsh-client-ui-slots' { interface SlotMap { /** @@ -25,8 +67,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { * the official bundles: `label` is the card's title and `order` its place. * The page renders the entry as the card's one-liner (`view: 'summary'`) * and, once the card is opened, as the body of the plugin's own page - * (`view: 'page'`). OCCUPIED by the host-plane configuration pages - * `ui-settings-plugins` ships; a bundle's configuration belongs in + * (`view: 'page'`). OCCUPIED by the official settings pages, one companion + * package per host-plane namespace; a bundle's configuration belongs in * `plugins.bundle.config` or `plugins.row.config` instead. */ 'plugins.item': { kind: 'list'; scope: 'root'; owner: PluginConfigViewProps } @@ -43,5 +85,23 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { * the entry's page, headed by the row id and the entry's summary. */ 'plugins.row.config': { kind: 'keyed'; scope: 'root'; owner: PluginConfigViewProps } + /** + * Controls at the head of a detail page, before the page's own switch and + * uninstall, rendered with the page's subject. An entry renders null for a + * subject it has no control for. + */ + 'plugins.detail.actions': { kind: 'list'; scope: 'root'; owner: PluginDetailProps } + /** + * Tags beside a detail page's title, after the version, beta, and problem + * tags the page draws itself, rendered with the page's subject. + */ + 'plugins.detail.badge': { kind: 'list'; scope: 'root'; owner: PluginDetailProps } + /** + * Sections under a detail page's own content: after the rows on a bundle's + * page, after the configuration on a row's or an official plugin's page. + * An entry draws its own section chrome and renders null for a subject it + * has nothing for. + */ + 'plugins.detail.section': { kind: 'list'; scope: 'root'; owner: PluginDetailProps } } } diff --git a/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx b/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx index af7da41774..2c768e5d17 100644 --- a/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx @@ -81,6 +81,9 @@ describe('ui-plugin-manager browser plugin', () => { expect(b.slots.spec('plugins.item')).toMatchObject({ kind: 'list', scope: 'root' }) expect(b.slots.spec('plugins.bundle.config')).toMatchObject({ kind: 'keyed', scope: 'root' }) expect(b.slots.spec('plugins.row.config')).toMatchObject({ kind: 'keyed', scope: 'root' }) + for (const name of ['plugins.detail.actions', 'plugins.detail.badge', 'plugins.detail.section'] as const) { + expect(b.slots.spec(name)).toMatchObject({ kind: 'list', scope: 'root' }) + } const face = (entry.inject as unknown as () => PluginManagerFace)() expect(face.hooks.configLedger.getSnapshot()).toEqual({ items: [], bundles: new Set(), rows: new Set() }) // A Host change before the first render is not a reason to read. diff --git a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx index bca0c579da..6ec9cd78a0 100644 --- a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx @@ -10,6 +10,7 @@ import type { PluginManagerPageProps } from '../src/client/PluginManagerPage.tsx import type { ConfigLedger } from '../src/client/config-ledger.ts' import { rowKey, type InstallState, type PackageRow, type PackageView, type PluginManagerState } from '../src/client/manager-store.ts' import { en, zh, type PluginManagerLocaleKey } from '../src/client/locales.ts' +import type { PluginsSubject } from '../src/client/slot-contract.ts' afterEach(cleanup) @@ -52,8 +53,17 @@ const READY: PluginManagerState = { highlight: null, } -/** Configuration entries a test supplies: what each slot cell renders, by `:` and the view asked for. */ -type SlotBodies = Record ReactNode> +/** + * The owner props the page passes a slot: the view it asks a configuration + * entry for, or the subject a detail contribution is rendered with. + */ +interface SlotOwner { + readonly view?: 'summary' | 'page' + readonly subject?: PluginsSubject +} + +/** Slot entries a test supplies: what each slot cell renders, by `:` (a list slot's cell is empty) and the owner props. */ +type SlotBodies = Record ReactNode> const NO_CONFIG: ConfigLedger = { items: [], bundles: new Set(), rows: new Set() } @@ -85,8 +95,8 @@ function renderTab(state: Partial = {}, config: Partial - bodies[`${name}:${opts.only ?? opts.entryKey ?? ''}`]?.(owner.view) ?? null, + renderSlot: (name: string, owner: SlotOwner, opts: { only?: string; entryKey?: string } = {}) => + bodies[`${name}:${opts.only ?? opts.entryKey ?? ''}`]?.(owner) ?? null, } as unknown as PluginManagerPageProps const { rerender } = render() return { @@ -238,9 +248,9 @@ describe('PluginManagerPage', () => { describe('configuration pages', () => { const bodies: SlotBodies = { - 'plugins.item:bash': view => view === 'summary' ? 'Limits every command.' :
, - 'plugins.bundle.config:dsh-better-sidebar': view => view === 'page' ? : null, - 'plugins.row.config:dsh-better-sidebar#sidebar': view => view === 'summary' ? 'The sidebar row.' : , + 'plugins.item:bash': ({ view }) => view === 'summary' ? 'Limits every command.' : , + 'plugins.bundle.config:dsh-better-sidebar': ({ view }) => view === 'page' ? : null, + 'plugins.row.config:dsh-better-sidebar#sidebar': ({ view }) => view === 'summary' ? 'The sidebar row.' : , } it('lists an official plugin after the official bundles with its summary, and opens its page', () => { @@ -297,6 +307,74 @@ describe('PluginManagerPage', () => { }) }) + describe('detail contributions', () => { + const subjects: PluginsSubject[] = [] + const label = (owner: SlotOwner): string => { + const subject = owner.subject + if (subject === undefined) return 'none' + subjects.push(subject) + if (subject.kind === 'bundle') return `bundle ${subject.pkg.name}` + if (subject.kind === 'row') return `row ${subject.pkg.name}#${subject.row.rowId}` + return `item ${subject.id}` + } + const bodies: SlotBodies = { + 'plugins.item:bash': ({ view }) => view === 'summary' ? 'Limits every command.' : , + 'plugins.row.config:dsh-better-sidebar#sidebar': ({ view }) => view === 'summary' ? 'The sidebar row.' : , + 'plugins.detail.actions:': owner => , + 'plugins.detail.badge:': owner => {`badge ${label(owner)}`}, + 'plugins.detail.section:': owner =>
, + } + + it('renders the contributed actions, badges, and sections on each page, told what the page is about', () => { + renderTab( + { packages: [pkg({ rows: [row()] })] }, + { items: [{ id: 'bash', label: 'Shell' }], rows: new Set(['dsh-better-sidebar#sidebar']) }, + bodies, + ) + // The cards carry none of it. + expect(screen.queryByRole('button', { name: /^act / })).toBeNull() + + fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'better-sidebar') })) + const detail = document.querySelector('[data-plugin-detail]') as HTMLElement + const act = within(detail).getByRole('button', { name: 'act bundle dsh-better-sidebar' }) + expect(within(detail).getByText('badge bundle dsh-better-sidebar')).toBeTruthy() + const section = within(detail).getByRole('region', { name: 'section bundle dsh-better-sidebar' }) + // The contributed actions come before the page's own switch; the sections after the rows. + const toggle = within(detail).getByRole('switch', { name: en.enableToggle.replace('{name}', 'better-sidebar') }) + expect(act.compareDocumentPosition(toggle) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + const rows = detail.querySelector('[data-plugin-rows]') as HTMLElement + expect(rows.compareDocumentPosition(section) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + // A contribution sees the bundle's facts, not the page's own state. + expect(subjects.at(-1)).toEqual({ + kind: 'bundle', + pkg: { name: 'dsh-better-sidebar', version: '0.16.0', installed: true, enabled: true, rows: [{ rowId: 'sidebar', moduleName: 'dsh-better-sidebar', enabled: true }] }, + }) + + fireEvent.click(screen.getByRole('button', { name: en.configureRow.replace('{name}', 'sidebar') })) + const page = document.querySelector('[data-plugin-row-detail]') as HTMLElement + expect(within(page).getByRole('button', { name: 'act row dsh-better-sidebar#sidebar' })).toBeTruthy() + expect(within(page).getByText('badge row dsh-better-sidebar#sidebar')).toBeTruthy() + expect(within(page).getByRole('region', { name: 'section row dsh-better-sidebar#sidebar' })).toBeTruthy() + expect(subjects.at(-1)).toMatchObject({ kind: 'row', row: { rowId: 'sidebar', moduleName: 'dsh-better-sidebar', enabled: true } }) + fireEvent.click(within(page).getByRole('button', { name: en.backToPackage.replace('{name}', 'better-sidebar') })) + fireEvent.click(screen.getByRole('button', { name: en.backToList })) + + fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'Shell') })) + const item = document.querySelector('[data-plugin-item-detail="bash"]') as HTMLElement + expect(within(item).getByRole('button', { name: 'act item bash' })).toBeTruthy() + expect(within(item).getByText('badge item bash')).toBeTruthy() + expect(within(item).getByRole('region', { name: 'section item bash' })).toBeTruthy() + expect(within(item).getByRole('form', { name: 'bash form' })).toBeTruthy() + }) + + it('leaves the version out of a bundle the Host reports none for', () => { + const unversioned: PackageView = { name: 'dsh-better-sidebar', installed: true, optional: false, enabled: true, rows: [] } + renderTab({ packages: [unversioned] }, {}, bodies) + fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'better-sidebar') })) + expect(subjects.at(-1)).toEqual({ kind: 'bundle', pkg: { name: 'dsh-better-sidebar', installed: true, enabled: true, rows: [] } }) + }) + }) + it('preserves metadata for another scope with the same short name', () => { const name = '@acme/dsh-experimental-agent-team-profile' const { setLanguage } = renderTab({ packages: [pkg({ name, description: 'Third-party description.' })] }) 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 3c040fd03d..09ed715e1a 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -1575,14 +1575,164 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.bundle.config\', () => ctx.slots.register(\n { name: \'plugins.bundle.config\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:38', + source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:80', + }, + { + key: 'plugins.detail.actions', + kind: 'list', + scope: 'root', + summary: 'Controls at the head of a detail page, before the page\'s own switch and uninstall, rendered with the page\'s subject.', + doc: 'Controls at the head of a detail page, before the page\'s own switch and\nuninstall, rendered with the page\'s subject. An entry renders null for a\nsubject it has no control for.', + registerOptions: [ + { + name: 'id', + requirement: 'required', + type: 'string', + doc: 'Your cell key. Use an id of your own: a fresh id is added beside the shipped entries, while reusing a shipped id puts you in THAT cell and replaces it. Owners that filter by id address you by it.', + }, + { + name: 'order', + requirement: 'optional', + type: 'number', + doc: 'Position among the entries, ascending (default 0).', + }, + { + name: 'label', + requirement: 'optional', + type: 'string | (() => string)', + doc: 'Display text where the owner projects one (nav rows, tabs). A thunk is re-read on every projection, so localized text follows the active locale without re-registering.', + }, + ], + ownerProps: [ + '/** The owner props every detail contribution is rendered with. */\nexport interface PluginDetailProps {\n /** The subject of the open page; an entry renders null for a subject it has nothing for. */\n readonly subject: PluginsSubject\n}', + ], + ownerPropsReferences: [ + 'PluginsSubject', + ], + standardProps: [ + 'useResource: UseResource', + 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', + 'useSessions: UseSessions', + 'useSessionStatus: UseSessionStatus', + 'useSessionRetainInfo: UseSessionRetainInfo', + 'useWorkspaces: SnapshotSelectorHook', + ], + keyDomain: '', + hookContext: '', + slotInject: '', + declaredBy: 'an entry in \'main\' (client-ui-plugin-manager), so it exists while that entry is mounted', + occupants: [], + replaceRisk: 'none', + example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.detail.actions\', () => ctx.slots.register(\n { name: \'plugins.detail.actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', + source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:93', + }, + { + key: 'plugins.detail.badge', + kind: 'list', + scope: 'root', + summary: 'Tags beside a detail page\'s title, after the version, beta, and problem tags the page draws itself, rendered with the page\'s subject.', + doc: 'Tags beside a detail page\'s title, after the version, beta, and problem\ntags the page draws itself, rendered with the page\'s subject.', + registerOptions: [ + { + name: 'id', + requirement: 'required', + type: 'string', + doc: 'Your cell key. Use an id of your own: a fresh id is added beside the shipped entries, while reusing a shipped id puts you in THAT cell and replaces it. Owners that filter by id address you by it.', + }, + { + name: 'order', + requirement: 'optional', + type: 'number', + doc: 'Position among the entries, ascending (default 0).', + }, + { + name: 'label', + requirement: 'optional', + type: 'string | (() => string)', + doc: 'Display text where the owner projects one (nav rows, tabs). A thunk is re-read on every projection, so localized text follows the active locale without re-registering.', + }, + ], + ownerProps: [ + '/** The owner props every detail contribution is rendered with. */\nexport interface PluginDetailProps {\n /** The subject of the open page; an entry renders null for a subject it has nothing for. */\n readonly subject: PluginsSubject\n}', + ], + ownerPropsReferences: [ + 'PluginsSubject', + ], + standardProps: [ + 'useResource: UseResource', + 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', + 'useSessions: UseSessions', + 'useSessionStatus: UseSessionStatus', + 'useSessionRetainInfo: UseSessionRetainInfo', + 'useWorkspaces: SnapshotSelectorHook', + ], + keyDomain: '', + hookContext: '', + slotInject: '', + declaredBy: 'an entry in \'main\' (client-ui-plugin-manager), so it exists while that entry is mounted', + occupants: [], + replaceRisk: 'none', + example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.detail.badge\', () => ctx.slots.register(\n { name: \'plugins.detail.badge\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', + source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:98', + }, + { + key: 'plugins.detail.section', + kind: 'list', + scope: 'root', + summary: 'Sections under a detail page\'s own content: after the rows on a bundle\'s page, after the configuration on a row\'s or an official plugin\'s page.', + doc: 'Sections under a detail page\'s own content: after the rows on a bundle\'s\npage, after the configuration on a row\'s or an official plugin\'s page.\nAn entry draws its own section chrome and renders null for a subject it\nhas nothing for.', + registerOptions: [ + { + name: 'id', + requirement: 'required', + type: 'string', + doc: 'Your cell key. Use an id of your own: a fresh id is added beside the shipped entries, while reusing a shipped id puts you in THAT cell and replaces it. Owners that filter by id address you by it.', + }, + { + name: 'order', + requirement: 'optional', + type: 'number', + doc: 'Position among the entries, ascending (default 0).', + }, + { + name: 'label', + requirement: 'optional', + type: 'string | (() => string)', + doc: 'Display text where the owner projects one (nav rows, tabs). A thunk is re-read on every projection, so localized text follows the active locale without re-registering.', + }, + ], + ownerProps: [ + '/** The owner props every detail contribution is rendered with. */\nexport interface PluginDetailProps {\n /** The subject of the open page; an entry renders null for a subject it has nothing for. */\n readonly subject: PluginsSubject\n}', + ], + ownerPropsReferences: [ + 'PluginsSubject', + ], + standardProps: [ + 'useResource: UseResource', + 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', + 'useSessions: UseSessions', + 'useSessionStatus: UseSessionStatus', + 'useSessionRetainInfo: UseSessionRetainInfo', + 'useWorkspaces: SnapshotSelectorHook', + ], + keyDomain: '', + hookContext: '', + slotInject: '', + declaredBy: 'an entry in \'main\' (client-ui-plugin-manager), so it exists while that entry is mounted', + occupants: [], + replaceRisk: 'none', + example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.detail.section\', () => ctx.slots.register(\n { name: \'plugins.detail.section\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', + source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:105', }, { key: 'plugins.item', kind: 'list', scope: 'root', summary: 'One official plugin the Plugins page lists in its Official group after the official bundles: `label` is the card\'s title and `order` its place.', - doc: 'One official plugin the Plugins page lists in its Official group after\nthe official bundles: `label` is the card\'s title and `order` its place.\nThe page renders the entry as the card\'s one-liner (`view: \'summary\'`)\nand, once the card is opened, as the body of the plugin\'s own page\n(`view: \'page\'`). OCCUPIED by the host-plane configuration pages\n`ui-settings-plugins` ships; a bundle\'s configuration belongs in\n`plugins.bundle.config` or `plugins.row.config` instead.', + doc: 'One official plugin the Plugins page lists in its Official group after\nthe official bundles: `label` is the card\'s title and `order` its place.\nThe page renders the entry as the card\'s one-liner (`view: \'summary\'`)\nand, once the card is opened, as the body of the plugin\'s own page\n(`view: \'page\'`). OCCUPIED by the official settings pages, one companion\npackage per host-plane namespace; a bundle\'s configuration belongs in\n`plugins.bundle.config` or `plugins.row.config` instead.', registerOptions: [ { name: 'id', @@ -1621,14 +1771,14 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ slotInject: '', declaredBy: 'an entry in \'main\' (client-ui-plugin-manager), so it exists while that entry is mounted', occupants: [ - 'client-ui-settings-plugins BashCard id \'bash\'', - 'client-ui-settings-plugins AgentLoopCard id \'agent-loop\'', - 'client-ui-settings-plugins SubagentCard id \'subagent\'', - 'client-ui-settings-plugins WebSearchCard id \'web-search\'', + 'client-ui-settings-agent-loop AgentLoopCard id \'agent-loop\'', + 'client-ui-settings-shell ShellCard id \'shell\'', + 'client-ui-settings-subagent SubagentCard id \'subagent\'', + 'client-ui-settings-web-search WebSearchCard id \'web-search\'', ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.item\', () => ctx.slots.register(\n { name: \'plugins.item\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:32', + source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:74', }, { key: 'plugins.row.config', @@ -1664,7 +1814,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.row.config\', () => ctx.slots.register(\n { name: \'plugins.row.config\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:45', + source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:87', }, { key: 'rightbar', From 93e67201ddb1525e88c9bc9d274cb244f697fbc0 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 17 Sep 2026 17:59:12 +0800 Subject: [PATCH 2/7] refactor(web): move the official settings pages into companion packages Each official settings page now lives in a client package of its own: `ui-settings-shell`, `ui-settings-agent-loop`, `ui-settings-subagent` and `ui-settings-web-search` register into `plugins.item` while the Host serves their namespaces, through the new `settingsScope.whileServed` watch, each with its own dictionary and styles. The form model, the fields and the form move to `ui-primitives` as the shared settings kit, so a community package's browser half builds a page the same way. `ui-settings-plugins` keeps only the Built-in plugins section shell around the inventory tab. The companions hold Loader rows in `web-app` behind the section; the client catalog, config catalog and module graph regenerate; the settings-card cookbook walks through a companion package. --- ...onfiguration-on-the-plugins-page.i18n.yaml | 4 +- ...lugin-configuration-on-the-plugins-page.md | 4 +- ...in-configuration-on-the-plugins-page.zh.md | 4 +- ...ings-pages-as-companion-packages.i18n.yaml | 6 + ...17-settings-pages-as-companion-packages.md | 39 ++ ...settings-pages-as-companion-packages.zh.md | 39 ++ docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 4 + docs/config-catalog.zh.md | 4 + .../cookbook/adding-a-settings-card.i18n.yaml | 4 +- docs/cookbook/adding-a-settings-card.md | 73 +-- docs/cookbook/adding-a-settings-card.zh.md | 77 +-- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 8 + docs/module-graph.zh.md | 8 + packages/bundle/web-app/cordis.patch.yml | 15 + packages/bundle/web-app/package.json | 4 + .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 3 + packages/client/ui-primitives/README.zh.md | 3 + packages/client/ui-primitives/package.json | 1 + packages/client/ui-primitives/src/index.ts | 9 + .../settings-form/SettingsForm.module.css} | 2 +- .../src/settings-form/SettingsForm.tsx} | 49 +- .../src/settings-form}/fields.module.css | 2 +- .../src/settings-form}/fields.tsx | 21 +- .../src/settings-form/form-model.ts} | 122 ++-- .../tests/settings-fields.client.spec.tsx} | 30 +- .../tests/settings-form-model.client.spec.ts | 298 ++++++++++ .../tests/settings-form.client.spec.tsx | 82 +++ packages/client/ui-primitives/tsconfig.json | 5 + .../ui-settings-agent-loop/README.i18n.yaml | 6 + .../client/ui-settings-agent-loop/README.md | 78 +++ .../ui-settings-agent-loop/README.zh.md | 78 +++ .../ui-settings-agent-loop/package.json | 65 +++ .../src/client/AgentLoopCard.tsx | 27 +- .../src/client/agent-loop-card-controller.ts | 34 +- .../src/client/index.ts | 49 ++ .../src/client/locales.ts | 50 ++ .../src/css-modules.d.ts | 4 + .../ui-settings-agent-loop/src/index.ts | 10 + .../tests/apply.client.spec.ts | 95 +++ .../tests/card.client.spec.tsx | 66 +++ .../tests/controller.client.spec.ts | 41 ++ .../ui-settings-agent-loop/tsconfig.json | 42 ++ .../ui-settings-agent-loop/tsdown.config.ts | 3 + .../ui-settings-plugins/README.i18n.yaml | 4 +- packages/client/ui-settings-plugins/README.md | 57 +- .../client/ui-settings-plugins/README.zh.md | 65 +-- .../client/ui-settings-plugins/package.json | 10 +- .../src/client/bash-card-controller.ts | 64 -- .../ui-settings-plugins/src/client/index.ts | 131 +---- .../ui-settings-plugins/src/client/locales.ts | 141 +---- .../tests/apply.client.spec.ts | 210 +------ .../tests/section.client.spec.tsx | 551 +----------------- .../client/ui-settings-plugins/tsconfig.json | 12 - .../client/ui-settings-shell/README.i18n.yaml | 6 + packages/client/ui-settings-shell/README.md | 78 +++ .../client/ui-settings-shell/README.zh.md | 78 +++ .../client/ui-settings-shell/package.json | 65 +++ .../src/client/ShellCard.tsx} | 47 +- .../ui-settings-shell/src/client/index.ts | 50 ++ .../ui-settings-shell/src/client/locales.ts | 55 ++ .../src/client/shell-card-controller.ts | 66 +++ .../ui-settings-shell/src/css-modules.d.ts | 4 + .../client/ui-settings-shell/src/index.ts | 10 + .../tests/apply.client.spec.ts | 113 ++++ .../tests/card.client.spec.tsx | 125 ++++ .../tests/controller.client.spec.ts | 91 +++ .../client/ui-settings-shell/tsconfig.json | 42 ++ .../client/ui-settings-shell/tsdown.config.ts | 3 + .../ui-settings-subagent/README.i18n.yaml | 6 + .../client/ui-settings-subagent/README.md | 82 +++ .../client/ui-settings-subagent/README.zh.md | 82 +++ .../client/ui-settings-subagent/package.json | 65 +++ .../src/client/SubagentCard.module.css | 0 .../src/client/SubagentCard.tsx | 9 +- .../client/SubagentLimitsFields.module.css | 0 .../src/client/SubagentLimitsFields.tsx | 8 +- .../SubagentModelSelectionFields.module.css | 0 .../client/SubagentModelSelectionFields.tsx | 2 +- .../ui-settings-subagent/src/client/index.ts | 92 +++ .../src/client/locales.ts | 109 ++++ .../src/client/subagent-card-controller.ts | 4 +- .../client/subagent-limits-card-controller.ts | 24 +- ...ubagent-model-selection-card-controller.ts | 4 +- .../ui-settings-subagent/src/css-modules.d.ts | 4 + .../client/ui-settings-subagent/src/index.ts | 11 + .../tests/apply.client.spec.ts | 117 ++++ .../tests/card.client.spec.tsx | 292 ++++++++++ .../tests/controllers.client.spec.ts} | 525 ----------------- .../client/ui-settings-subagent/tsconfig.json | 42 ++ .../ui-settings-subagent/tsdown.config.ts | 3 + .../ui-settings-web-search/README.i18n.yaml | 6 + .../client/ui-settings-web-search/README.md | 79 +++ .../ui-settings-web-search/README.zh.md | 79 +++ .../ui-settings-web-search/package.json | 65 +++ .../src/client/WebSearchCard.tsx | 41 +- .../src/client/index.ts | 59 ++ .../src/client/locales.ts | 64 ++ .../src/client/web-search-card-controller.ts | 48 +- .../src/css-modules.d.ts | 4 + .../ui-settings-web-search/src/index.ts | 11 + .../tests/apply.client.spec.ts | 109 ++++ .../tests/card.client.spec.tsx | 101 ++++ .../tests/controller.client.spec.ts | 202 +++++++ .../ui-settings-web-search/tsconfig.json | 42 ++ .../ui-settings-web-search/tsdown.config.ts | 3 + .../ui-settings/src/client/settings-scope.ts | 31 + .../tests/while-served.client.spec.ts | 94 +++ pnpm-lock.yaml | 162 ++++- .../verify-package-readme-model-experience.ts | 4 + tsconfig.base.json | 4 + tsconfig.client.json | 4 + 114 files changed, 4340 insertions(+), 2011 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-09-17-settings-pages-as-companion-packages.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-09-17-settings-pages-as-companion-packages.md create mode 100644 .agents/notes/implemented/architecture/2026-09-17-settings-pages-as-companion-packages.zh.md rename packages/client/{ui-settings-plugins/src/client/PluginConfigForm.module.css => ui-primitives/src/settings-form/SettingsForm.module.css} (87%) rename packages/client/{ui-settings-plugins/src/client/PluginConfigForm.tsx => ui-primitives/src/settings-form/SettingsForm.tsx} (53%) rename packages/client/{ui-settings-plugins/src/client => ui-primitives/src/settings-form}/fields.module.css (96%) rename packages/client/{ui-settings-plugins/src/client => ui-primitives/src/settings-form}/fields.tsx (86%) rename packages/client/{ui-settings-plugins/src/client/card-form.ts => ui-primitives/src/settings-form/form-model.ts} (73%) rename packages/client/{ui-settings-plugins/tests/fields.client.spec.tsx => ui-primitives/tests/settings-fields.client.spec.tsx} (79%) create mode 100644 packages/client/ui-primitives/tests/settings-form-model.client.spec.ts create mode 100644 packages/client/ui-primitives/tests/settings-form.client.spec.tsx create mode 100644 packages/client/ui-settings-agent-loop/README.i18n.yaml create mode 100644 packages/client/ui-settings-agent-loop/README.md create mode 100644 packages/client/ui-settings-agent-loop/README.zh.md create mode 100644 packages/client/ui-settings-agent-loop/package.json rename packages/client/{ui-settings-plugins => ui-settings-agent-loop}/src/client/AgentLoopCard.tsx (63%) rename packages/client/{ui-settings-plugins => ui-settings-agent-loop}/src/client/agent-loop-card-controller.ts (52%) create mode 100644 packages/client/ui-settings-agent-loop/src/client/index.ts create mode 100644 packages/client/ui-settings-agent-loop/src/client/locales.ts create mode 100644 packages/client/ui-settings-agent-loop/src/css-modules.d.ts create mode 100644 packages/client/ui-settings-agent-loop/src/index.ts create mode 100644 packages/client/ui-settings-agent-loop/tests/apply.client.spec.ts create mode 100644 packages/client/ui-settings-agent-loop/tests/card.client.spec.tsx create mode 100644 packages/client/ui-settings-agent-loop/tests/controller.client.spec.ts create mode 100644 packages/client/ui-settings-agent-loop/tsconfig.json create mode 100644 packages/client/ui-settings-agent-loop/tsdown.config.ts delete mode 100644 packages/client/ui-settings-plugins/src/client/bash-card-controller.ts create mode 100644 packages/client/ui-settings-shell/README.i18n.yaml create mode 100644 packages/client/ui-settings-shell/README.md create mode 100644 packages/client/ui-settings-shell/README.zh.md create mode 100644 packages/client/ui-settings-shell/package.json rename packages/client/{ui-settings-plugins/src/client/BashCard.tsx => ui-settings-shell/src/client/ShellCard.tsx} (51%) create mode 100644 packages/client/ui-settings-shell/src/client/index.ts create mode 100644 packages/client/ui-settings-shell/src/client/locales.ts create mode 100644 packages/client/ui-settings-shell/src/client/shell-card-controller.ts create mode 100644 packages/client/ui-settings-shell/src/css-modules.d.ts create mode 100644 packages/client/ui-settings-shell/src/index.ts create mode 100644 packages/client/ui-settings-shell/tests/apply.client.spec.ts create mode 100644 packages/client/ui-settings-shell/tests/card.client.spec.tsx create mode 100644 packages/client/ui-settings-shell/tests/controller.client.spec.ts create mode 100644 packages/client/ui-settings-shell/tsconfig.json create mode 100644 packages/client/ui-settings-shell/tsdown.config.ts create mode 100644 packages/client/ui-settings-subagent/README.i18n.yaml create mode 100644 packages/client/ui-settings-subagent/README.md create mode 100644 packages/client/ui-settings-subagent/README.zh.md create mode 100644 packages/client/ui-settings-subagent/package.json rename packages/client/{ui-settings-plugins => ui-settings-subagent}/src/client/SubagentCard.module.css (100%) rename packages/client/{ui-settings-plugins => ui-settings-subagent}/src/client/SubagentCard.tsx (90%) rename packages/client/{ui-settings-plugins => ui-settings-subagent}/src/client/SubagentLimitsFields.module.css (100%) rename packages/client/{ui-settings-plugins => ui-settings-subagent}/src/client/SubagentLimitsFields.tsx (87%) rename packages/client/{ui-settings-plugins => ui-settings-subagent}/src/client/SubagentModelSelectionFields.module.css (100%) rename packages/client/{ui-settings-plugins => ui-settings-subagent}/src/client/SubagentModelSelectionFields.tsx (99%) create mode 100644 packages/client/ui-settings-subagent/src/client/index.ts create mode 100644 packages/client/ui-settings-subagent/src/client/locales.ts rename packages/client/{ui-settings-plugins => ui-settings-subagent}/src/client/subagent-card-controller.ts (96%) rename packages/client/{ui-settings-plugins => ui-settings-subagent}/src/client/subagent-limits-card-controller.ts (67%) rename packages/client/{ui-settings-plugins => ui-settings-subagent}/src/client/subagent-model-selection-card-controller.ts (98%) create mode 100644 packages/client/ui-settings-subagent/src/css-modules.d.ts create mode 100644 packages/client/ui-settings-subagent/src/index.ts create mode 100644 packages/client/ui-settings-subagent/tests/apply.client.spec.ts create mode 100644 packages/client/ui-settings-subagent/tests/card.client.spec.tsx rename packages/client/{ui-settings-plugins/tests/stores.client.spec.ts => ui-settings-subagent/tests/controllers.client.spec.ts} (57%) create mode 100644 packages/client/ui-settings-subagent/tsconfig.json create mode 100644 packages/client/ui-settings-subagent/tsdown.config.ts create mode 100644 packages/client/ui-settings-web-search/README.i18n.yaml create mode 100644 packages/client/ui-settings-web-search/README.md create mode 100644 packages/client/ui-settings-web-search/README.zh.md create mode 100644 packages/client/ui-settings-web-search/package.json rename packages/client/{ui-settings-plugins => ui-settings-web-search}/src/client/WebSearchCard.tsx (69%) create mode 100644 packages/client/ui-settings-web-search/src/client/index.ts create mode 100644 packages/client/ui-settings-web-search/src/client/locales.ts rename packages/client/{ui-settings-plugins => ui-settings-web-search}/src/client/web-search-card-controller.ts (83%) create mode 100644 packages/client/ui-settings-web-search/src/css-modules.d.ts create mode 100644 packages/client/ui-settings-web-search/src/index.ts create mode 100644 packages/client/ui-settings-web-search/tests/apply.client.spec.ts create mode 100644 packages/client/ui-settings-web-search/tests/card.client.spec.tsx create mode 100644 packages/client/ui-settings-web-search/tests/controller.client.spec.ts create mode 100644 packages/client/ui-settings-web-search/tsconfig.json create mode 100644 packages/client/ui-settings-web-search/tsdown.config.ts create mode 100644 packages/client/ui-settings/tests/while-served.client.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.i18n.yaml index d1c3e82cbb..737867c4a5 100644 --- a/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.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-16-plugin-configuration-on-the-plugins-page.md -2026-09-16-plugin-configuration-on-the-plugins-page.md: fb330bee44373d20c7aeea8cee521bf8def025d4 -2026-09-16-plugin-configuration-on-the-plugins-page.zh.md: 65c1f5a8bf4654b395ab4bfcf8015ae7c23a6acc +2026-09-16-plugin-configuration-on-the-plugins-page.md: d9736c6289ef2605053ce7c6ce28368998b73da0 +2026-09-16-plugin-configuration-on-the-plugins-page.zh.md: a3782c5629cf282cf9b1e1df78e5defc8fd4a997 diff --git a/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.md b/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.md index fb330bee44..d9736c6289 100644 --- a/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.md +++ b/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.md @@ -16,14 +16,14 @@ A plugin's settings lived in Settings, on the Plugins section's configuration ta **Only a save writes.** The form's discard control and its unsaved marker are gone: leaving a page drops the staged edits, which the form does on unmount. A save still writes through the client settings scope with the revision fence. -**The four host-plane pages register from `ui-settings-plugins` while the Host serves their namespaces.** The package keeps the Settings section as the **Built-in plugins** shell around the inventory tab and registers each page through `ctx.slots.inject` when the shared settings mirror shows the namespace, disposing it when the namespace goes, so a deployment that does not compose the owning plugin shows no trace of it. The `settings.plugin.item` slot is retired. +**The four host-plane pages register while the Host serves their namespaces.** Each page's package registers it through `ctx.slots.inject` when the shared settings mirror shows the namespace and disposes it when the namespace goes, so a deployment that does not compose the owning plugin shows no trace of it; since [settings pages as companion packages](2026-09-17-settings-pages-as-companion-packages.md) that is one companion package per page, and `ui-settings-plugins` keeps the Settings section as the **Built-in plugins** shell around the inventory tab. The `settings.plugin.item` slot is retired. **The Official group.** The optional bundles the installation ships open the group, tagged **Beta** where the feature is one (Agent Teams), with no official tag; the configuration pages follow. Auto review leaves `OPTIONAL_BUNDLES` and the CLI's dependencies: it is a published experimental package the install guide names as its example. ## Consequences - A bundle's browser half registers a form with one slot registration and its own dictionary; the bundle's patch must declare the row under the id in the key, and the registration exists while the row that carries the bundle's browser half is on: `dsh-client-modules` attaches that half to the row whose specifier is the bare package name, so a row-level page keyed to a subpath row disappears with the root row, not with its own. -- The four pages, their forms, and the settings write path are unchanged; `ui-settings-plugins` keeps its name for the section it still owns while its pages live on the Plugins page. +- The four pages, their forms, and the settings write path are unchanged; `ui-settings-plugins` keeps its name for the section it owns while the pages live on the Plugins page. - Settings lists the inventory only; the settings goldens that carried the Plugins nav entry and the configuration tab were re-recorded. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.zh.md b/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.zh.md index 65c1f5a8bf..a3782c5629 100644 --- a/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.zh.md @@ -16,14 +16,14 @@ **只有保存才写入。** 表单的放弃控件与未保存标记都去掉了:离开页面即丢弃暂存修改,表单在卸载时执行。保存仍经由客户端 settings scope,带 revision 栅栏。 -**四个宿主平面页面由 `ui-settings-plugins` 在 Host 服务其命名空间期间注册。** 该包把设置分区保留为清单标签页外面的**内置插件**外壳,在共享的 settings 镜像显示某个命名空间时通过 `ctx.slots.inject` 注册对应页面,命名空间消失时销毁,因此没有组装该插件的部署不会留下它的痕迹。`settings.plugin.item` slot 退役。 +**四个宿主平面页面在 Host 服务其命名空间期间注册。** 每个页面所在的包在共享的 settings 镜像显示某个命名空间时通过 `ctx.slots.inject` 注册对应页面,命名空间消失时销毁,因此没有组装该插件的部署不会留下它的痕迹;自[设置页作为伴生包](2026-09-17-settings-pages-as-companion-packages.zh.md)起,每个页面各是一个伴生包,`ui-settings-plugins` 把设置分区保留为清单标签页外面的**内置插件**外壳。`settings.plugin.item` slot 退役。 **官方分组。** 安装随附的可选组合包开启这个分组,属于 beta 功能的(Agent Teams)带 **Beta** 标签,不再有官方标签;配置页排在其后。Auto review 离开 `OPTIONAL_BUNDLES` 与 CLI 的依赖:它是一个已发布的实验包,安装引导拿它作示例。 ## 后果 - 组合包的浏览器半侧用一次 slot 注册加自己的词典就能提供表单;组合包的 patch 必须以键里的 id 声明这一行,注册只在承载组合包浏览器半侧的那一行开启期间存在:`dsh-client-modules` 把该半侧挂在说明符恰为包名的那一行上,因此键指向子路径行的行级页面随根行消失,而不随它自己的行。 -- 四个页面、它们的表单与 settings 写入路径不变;`ui-settings-plugins` 保留其名字,因为它仍拥有那个分区,尽管它的页面住在插件页上。 +- 四个页面、它们的表单与 settings 写入路径不变;`ui-settings-plugins` 保留其名字,因为它仍拥有那个分区,而页面住在插件页上。 - 设置只列出清单;带有插件导航项与配置标签页的设置类 golden 已重录。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/architecture/2026-09-17-settings-pages-as-companion-packages.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-17-settings-pages-as-companion-packages.i18n.yaml new file mode 100644 index 0000000000..0dd8337ca2 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-17-settings-pages-as-companion-packages.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-17-settings-pages-as-companion-packages.md +2026-09-17-settings-pages-as-companion-packages.md: dc604c010787ee35522b351b643fb2691540b4f9 +2026-09-17-settings-pages-as-companion-packages.zh.md: 066b17c9760f381089a6a48b1ec6606e600531d3 diff --git a/.agents/notes/implemented/architecture/2026-09-17-settings-pages-as-companion-packages.md b/.agents/notes/implemented/architecture/2026-09-17-settings-pages-as-companion-packages.md new file mode 100644 index 0000000000..dc604c0107 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-17-settings-pages-as-companion-packages.md @@ -0,0 +1,39 @@ +# Agent Note: Settings pages as companion packages + +Status: implemented + +English | [中文](2026-09-17-settings-pages-as-companion-packages.zh.md) + +## Problem + +The four official settings pages — shell, agent loop, Subagent, web search — lived in one client package, `ui-settings-plugins`, beside the Built-in plugins section. Every new built-in page grew that package, and a community bundle that wanted a settings page had no worked example of a page living with the plugin that owns the namespace. Moving each page into the Host package that registers its namespace was considered and does not fit: the shell namespace is registered by the executor families under the sandbox row, the loop package would gain a browser build for one number field, and the Subagent namespaces are registered from a subpath row, which carries no browser half. + +## Decision + +**A community plugin's settings page lives in its own package's browser half.** It registers into `plugins.bundle.config` or `plugins.row.config`, owns its copy and styles, and reads and writes through `ctx.settingsScope`. Its i18n is the client locale service, as the shipped community bundles already do. + +**A built-in namespace's page lives in a companion client package.** `ui-settings-shell`, `ui-settings-agent-loop`, `ui-settings-subagent`, and `ui-settings-web-search` are client-only packages with an empty Host `apply`, one bare-name row each in the web composition's roster, registering into `plugins.item` while the Host serves their namespace. Host packages stay free of browser code; the namespace they edit is spelled as a literal, never imported from the owner. + +**The form machinery moves to `ui-primitives`.** `SettingsFormModel` with `settingsNumberField` and `settingsTextField`, `SettingsValueField` and `SettingsSecretField`, and the `SettingsForm` frame are the kit every page renders with; the frame takes its copy as `labels` and the model takes a structural scope, so the baseline package depends on neither a plugin nor its dictionary. + +**`settingsScope.whileServed(namespaces, register)` is the registration rule.** A page exists exactly while the Host serves one of its namespaces: the service watches the shared describe mirror, runs the registration when a namespace appears, and disposes it when none is served, so four packages share one rule instead of four copies. + +**`ui-settings-plugins` keeps the Built-in plugins section only:** the navigation entry and the tab row feature-owned tabs register into. + +## Alternatives considered + +**A schema-driven generic form.** Rejected: a generic renderer covers scalar fields and stops at the first dynamic control — the Subagent page's live model catalogue, its cross-field rule, its one-mutation save — and every exception would become a role the schema is not for. Every page is code; a community plugin that ships no page has none. + +**Dual-face Host packages for the built-in pages.** Rejected for the reasons above; the companion keeps the option open for a package that can carry a half. + +**Copy for the form frame in one shared dictionary.** Rejected: it would couple every page to another plugin's dictionary keys; each page's dictionary carries the few frame strings. + +## Consequences + +- The Plugins page's Official group lists the same four pages in the same order, from four packages instead of one; their labels and forms are unchanged, so the page goldens hold. +- The cookbook [adding a settings page](../../../../docs/cookbook/adding-a-settings-card.md) shows the community path and names the companion template. +- The client module system's rule stands: a browser half rides the Loader row whose specifier is the bare package name, so a bundle split into subpath rows keeps every page it registers on its root row. + +## Testing + +Each companion's `tests/apply.client.spec.ts` registers its page while the Host serves the namespace, withdraws it when the Host stops, and collapses on teardown; its card and controller specs carry the page's behavior from the former package. `packages/client/ui-primitives/tests/settings-form-model.client.spec.ts`, `packages/client/ui-primitives/tests/settings-form.client.spec.tsx`, and `packages/client/ui-primitives/tests/settings-fields.client.spec.tsx` pin the kit; `packages/client/ui-settings/tests/while-served.client.spec.ts` pins the registration rule. `apps/web/tests/plugin-config.e2e.ts` exercises the shell and Subagent pages over the real wire. diff --git a/.agents/notes/implemented/architecture/2026-09-17-settings-pages-as-companion-packages.zh.md b/.agents/notes/implemented/architecture/2026-09-17-settings-pages-as-companion-packages.zh.md new file mode 100644 index 0000000000..066b17c976 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-17-settings-pages-as-companion-packages.zh.md @@ -0,0 +1,39 @@ +# Agent Note:设置页作为伴生包 + +状态:已实现 + +[English](2026-09-17-settings-pages-as-companion-packages.md) | 中文 + +## 问题 + +四个官方设置页——终端、Agent 循环、Subagent、网页搜索——都放在一个客户端包 `ui-settings-plugins` 里,与「内置插件」分区挨着。每新增一个内置页面都让这个包再长一截,而想要设置页的社区组合包也找不到"页面与拥有命名空间的插件放在一起"的现成样板。曾考虑把每个页面搬进注册其命名空间的宿主包,但行不通:shell 命名空间由执行器家族在 sandbox 行之下注册,循环包为了一个数字字段要长出浏览器构建,Subagent 的命名空间从子路径行注册,而子路径行不携带浏览器半侧。 + +## 决策 + +**社区插件的设置页放在自己包的浏览器半侧。** 它注册进 `plugins.bundle.config` 或 `plugins.row.config`,自己拥有文案和样式,通过 `ctx.settingsScope` 读写。它的 i18n 走客户端 locale 服务,已发布的社区组合包本来就是这么做的。 + +**内置命名空间的页面放在伴生客户端包里。** `ui-settings-shell`、`ui-settings-agent-loop`、`ui-settings-subagent`、`ui-settings-web-search` 是纯客户端包,宿主 `apply` 为空,各在 Web 组合花名册里占一条裸名行,在 Host 服务其命名空间期间注册进 `plugins.item`。宿主包不含浏览器代码;它们编辑的命名空间以字面量拼写,从不从 owner 导入。 + +**表单机制搬进 `ui-primitives`。** `SettingsFormModel` 及 `settingsNumberField`、`settingsTextField`,`SettingsValueField` 与 `SettingsSecretField`,以及 `SettingsForm` 框架,是每个页面共同渲染用的套件;框架以 `labels` 接收文案,模型接受结构化的 scope,因此基线包既不依赖任何插件也不依赖其字典。 + +**`settingsScope.whileServed(namespaces, register)` 是注册规则。** 页面恰好在 Host 服务其某个命名空间期间存在:服务监视共享的 describe 镜像,命名空间出现时执行注册,一个都不再服务时注销,四个包共用一条规则而不是四份副本。 + +**`ui-settings-plugins` 只保留「内置插件」分区:** 导航项和供功能插件注册标签页的标签行。 + +## 考虑过的替代方案 + +**由 schema 驱动的通用表单。** 否决:通用渲染器只覆盖标量字段,碰到第一个动态控件就止步——Subagent 页的实时模型目录、跨字段规则、一次 mutate 提交——而每个例外都会变成一个 schema 本不该承担的 role。每个页面都是代码;没有发页面的社区插件就没有页面。 + +**内置页面做成双面宿主包。** 因上述原因否决;伴生包给能带半侧的包留了这个选项。 + +**表单框架的文案放在一本共享字典里。** 否决:那会让每个页面耦合到另一个插件的字典键;每个页面的字典自带那几条框架文案。 + +## 后果 + +- 插件页官方分组列出同样四个页面、同样顺序,来自四个包而不是一个;标签与表单未变,页面 golden 保持不变。 +- 手册[添加设置页](../../../../docs/cookbook/adding-a-settings-card.zh.md)展示社区路径并点名伴生包模板。 +- 客户端模块系统的规则不变:浏览器半侧挂在说明符为裸包名的 Loader 行上,因此拆成子路径行的组合包,其注册的所有页面都留在根行上。 + +## 测试 + +每个伴生包的 `tests/apply.client.spec.ts` 在 Host 服务命名空间时注册页面、停止服务时注销、teardown 时收拢;其 card 与 controller 用例延续原包里的页面行为。`packages/client/ui-primitives/tests/settings-form-model.client.spec.ts`、`packages/client/ui-primitives/tests/settings-form.client.spec.tsx` 与 `packages/client/ui-primitives/tests/settings-fields.client.spec.tsx` 钉住套件;`packages/client/ui-settings/tests/while-served.client.spec.ts` 钉住注册规则。`apps/web/tests/plugin-config.e2e.ts` 走真实链路验证终端页与 Subagent 页。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 7da9acd525..b01bb4e0dd 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: 631ce6a34b120f44534a7935ff976662287fa879 -config-catalog.zh.md: 8d72786a79ceff85e35aff72fa6622af7373a3b4 +config-catalog.md: 6d96a3c041e5778c1fc144cf8c5008d7ceaf7cc1 +config-catalog.zh.md: ed4b009047f615217d78eb6e7f2050bbbfb8de8c diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 631ce6a34b..6d96a3c041 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -3806,11 +3806,15 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-client-ui-schedule` ([`packages/client/ui-schedule/src/index.ts`](../packages/client/ui-schedule/src/index.ts)) - `@deepseek-ai/dsh-client-ui-session` ([`packages/client/ui-session/src/index.ts`](../packages/client/ui-session/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings` ([`packages/client/ui-settings/src/index.ts`](../packages/client/ui-settings/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-settings-agent-loop` ([`packages/client/ui-settings-agent-loop/src/index.ts`](../packages/client/ui-settings-agent-loop/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings-general` ([`packages/client/ui-settings-general/src/index.ts`](../packages/client/ui-settings-general/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings-models` ([`packages/client/ui-settings-models/src/index.ts`](../packages/client/ui-settings-models/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings-plugin-inventory` ([`packages/client/ui-settings-plugin-inventory/src/index.ts`](../packages/client/ui-settings-plugin-inventory/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings-plugins` ([`packages/client/ui-settings-plugins/src/index.ts`](../packages/client/ui-settings-plugins/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-settings-shell` ([`packages/client/ui-settings-shell/src/index.ts`](../packages/client/ui-settings-shell/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-settings-subagent` ([`packages/client/ui-settings-subagent/src/index.ts`](../packages/client/ui-settings-subagent/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings-unarchive-sessions` ([`packages/client/ui-settings-unarchive-sessions/src/index.ts`](../packages/client/ui-settings-unarchive-sessions/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-settings-web-search` ([`packages/client/ui-settings-web-search/src/index.ts`](../packages/client/ui-settings-web-search/src/index.ts)) - `@deepseek-ai/dsh-client-ui-sidebar` ([`packages/client/ui-sidebar/src/index.ts`](../packages/client/ui-sidebar/src/index.ts)) - `@deepseek-ai/dsh-client-ui-sidebar-browser` ([`packages/client/ui-sidebar-browser/src/index.ts`](../packages/client/ui-sidebar-browser/src/index.ts)) - `@deepseek-ai/dsh-client-ui-sidebar-files` ([`packages/client/ui-sidebar-files/src/index.ts`](../packages/client/ui-sidebar-files/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 8d72786a79..ed4b009047 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -3808,11 +3808,15 @@ export interface Config { - `@deepseek-ai/dsh-client-ui-schedule`([`packages/client/ui-schedule/src/index.ts`](../packages/client/ui-schedule/src/index.ts)) - `@deepseek-ai/dsh-client-ui-session`([`packages/client/ui-session/src/index.ts`](../packages/client/ui-session/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings`([`packages/client/ui-settings/src/index.ts`](../packages/client/ui-settings/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-settings-agent-loop`([`packages/client/ui-settings-agent-loop/src/index.ts`](../packages/client/ui-settings-agent-loop/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings-general`([`packages/client/ui-settings-general/src/index.ts`](../packages/client/ui-settings-general/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings-models`([`packages/client/ui-settings-models/src/index.ts`](../packages/client/ui-settings-models/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings-plugin-inventory`([`packages/client/ui-settings-plugin-inventory/src/index.ts`](../packages/client/ui-settings-plugin-inventory/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings-plugins`([`packages/client/ui-settings-plugins/src/index.ts`](../packages/client/ui-settings-plugins/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-settings-shell`([`packages/client/ui-settings-shell/src/index.ts`](../packages/client/ui-settings-shell/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-settings-subagent`([`packages/client/ui-settings-subagent/src/index.ts`](../packages/client/ui-settings-subagent/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings-unarchive-sessions`([`packages/client/ui-settings-unarchive-sessions/src/index.ts`](../packages/client/ui-settings-unarchive-sessions/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-settings-web-search`([`packages/client/ui-settings-web-search/src/index.ts`](../packages/client/ui-settings-web-search/src/index.ts)) - `@deepseek-ai/dsh-client-ui-sidebar`([`packages/client/ui-sidebar/src/index.ts`](../packages/client/ui-sidebar/src/index.ts)) - `@deepseek-ai/dsh-client-ui-sidebar-browser`([`packages/client/ui-sidebar-browser/src/index.ts`](../packages/client/ui-sidebar-browser/src/index.ts)) - `@deepseek-ai/dsh-client-ui-sidebar-files`([`packages/client/ui-sidebar-files/src/index.ts`](../packages/client/ui-sidebar-files/src/index.ts)) diff --git a/docs/cookbook/adding-a-settings-card.i18n.yaml b/docs/cookbook/adding-a-settings-card.i18n.yaml index 52f7dc2c1c..d04ea95824 100644 --- a/docs/cookbook/adding-a-settings-card.i18n.yaml +++ b/docs/cookbook/adding-a-settings-card.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-settings-card.md -adding-a-settings-card.md: d1104299d319203789c9cd153f36b3f5e33edd10 -adding-a-settings-card.zh.md: 3ef245d76331015f4d69db52bfbd176dff3e4bcf +adding-a-settings-card.md: cc8daa90bcc3a757cd41dbcaecb0eab662d1dba8 +adding-a-settings-card.zh.md: a0fce4075b9e29630c772bc52e07cd00d89d5bab diff --git a/docs/cookbook/adding-a-settings-card.md b/docs/cookbook/adding-a-settings-card.md index d1104299d3..cc8daa90bc 100644 --- a/docs/cookbook/adding-a-settings-card.md +++ b/docs/cookbook/adding-a-settings-card.md @@ -1,10 +1,10 @@ -# Cookbook: adding a settings card +# Cookbook: adding a settings page English | [中文](adding-a-settings-card.zh.md) -How a plugin puts its own configuration on the web settings page. Nothing in this path needs a change inside this repository: the Host serves every registered settings namespace, and the **Plugins** section keys its cards on the namespace they edit, so a plugin that registers both halves is paired up automatically. +How a plugin puts its own configuration on the web client's Plugins page, and how it contributes to another plugin's page there. Nothing in this path needs a change inside this repository: the Host serves every registered settings namespace, and the Plugins page declares the slots a page or a contribution registers into. -The two halves live in one package — the Host half under `src/`, the browser half under `src/client/`, exported as `./client` and declared with `dsh.client`. [`packages/client/ui-theme`](../../packages/client/ui-theme) is a worked example of that packaging; the cards this section ships live in [`packages/client/ui-settings-plugins`](../../packages/client/ui-settings-plugins). +The two halves of a community bundle live in one package — the Host half under `src/`, the browser half under `src/client/`, exported as `./client` and declared with `dsh.client`. A built-in plugin whose package cannot carry a browser half ships its page as a companion client package instead; [`packages/client/ui-settings-shell`](../../packages/client/ui-settings-shell) is the template. ## 1. Register the namespace (Host half) @@ -43,43 +43,58 @@ export function apply(ctx: Context, config: Config) { } ``` -`role('secret')` on a field keeps its value off every response; the card writes such a field into an `update`/`mutate` payload, or addresses a credential reference through the `credentials` domain instead. `applies: 'restart'` tells a configuration surface the owner acts on a change only at the next start. +`role('secret')` on a field keeps its value off every response; the page writes such a field through the `credentials` domain instead. `applies: 'restart'` tells a configuration surface the owner acts on a change only at the next start. -## 2. Register the card (browser half) +## 2. Register the page (browser half) -The card registers into `settings.plugin.item` under its namespace and owns everything inside it — chrome, controls, and copy. It reads and writes through `ctx.settingsScope`, which fences each write with the revision it read: +The page registers into one of the Plugins page's configuration slots and owns everything inside it — controls, copy, and styles. A bundle's own configuration goes into `plugins.bundle.config`, keyed by the package name; the configuration of one row the bundle declares goes into `plugins.row.config`, keyed by `#`, which gives that row a configure control opening the row's page. The page asks every entry for two views: `summary` for the one-liner under the title, `page` for the form. + +The form reads and writes through `ctx.settingsScope`, which fences each write with the revision it read. The `ui-primitives` kit carries the staged-edit model and the controls, so a page declares its fields and renders them: ```ts ignore-check import type { Context as ClientContext } from '@deepseek-ai/cordis' -// Type-only: the keyed slot's declaration. Cross-plugin collaboration goes -// through cordis services; a value import fails the client bundle-purity gate. -import type {} from '@deepseek-ai/dsh-client-ui-settings-plugins/client' +// Type-only: the Plugins page's slot declarations and the settings scope. +// Cross-plugin collaboration goes through cordis services; a value import +// fails the client bundle-purity gate. +import type {} from '@deepseek-ai/dsh-client-ui-plugin-manager/client' +import type {} from '@deepseek-ai/dsh-client-ui-settings/client' +import { SettingsFormModel, settingsNumberField, settingsTextField } from '@deepseek-ai/dsh-client-ui-primitives' -export const inject = ['slots', 'locale', 'connection', 'remote', 'settingsScope'] +export const inject = ['slots', 'locale', 'settingsScope'] export function apply(ctx: ClientContext): void { - const card = new MyPluginCardController(ctx.settingsScope.bind({ namespace: 'my-plugin' })) - ctx.slots.inject('settings.plugin.item', () => ctx.slots.register({ - name: 'settings.plugin.item', - key: 'my-plugin', - locale: 'settings.myPlugin', - inject: () => card.inject(), - }, MyPluginCard), + ctx.effect(() => ctx.locale.register('myPlugin', { zh, en })) + const form = new SettingsFormModel( + ctx.settingsScope.bind({ namespace: 'my-plugin' }), + [settingsTextField('endpoint'), settingsNumberField('retries')], ) + const store = form.bind(() => ({ ...form.shell(), endpoint: form.field('endpoint'), retries: form.field('retries') })) + ctx.slots.inject('plugins.bundle.config', () => ctx.slots.register({ + name: 'plugins.bundle.config', + key: '@acme/dsh-my-plugin', + locale: 'myPlugin', + inject: () => ({ hooks: { myPluginForm: store }, ...form.actions() }), + }, MyPluginPage)) } ``` -The scope snapshot carries what a form needs: the resolved `value`, the composition `base`, and the raw `user` layer, whose key **presence** — not its value — is what marks a field overridden. `scope.set(field, value)` stores one field and `scope.unset(field)` clears it back to the composition layer. +`MyPluginPage` renders `t('summary')` for `view === 'summary'` and, for `page`, a `SettingsForm` around `SettingsValueField` controls, passing the frame its copy as `labels`. A field's presence in the raw user layer — not its value — is what marks it overridden; a reset clears that field so it re-inherits the composition layer. Only a save writes, and leaving the page drops every draft. -## 3. What the tab does with it +## 3. Contribute to another plugin's page -The **Plugin configuration** tab reads which namespaces the Host serves and dispatches one slot key per namespace. A card is rendered when the Host serves its key and skipped when it does not, so a deployment that never composed the Host half shows no trace of the card. A served namespace no card claims renders nothing — that is how the namespaces owned by other pages (`ui-theme`, `permission`, `llm-*`) stay off this tab. +A plugin with something to say about a bundle, a row, or an official plugin it does not own registers into `plugins.detail.actions` (a control at the head of the page), `plugins.detail.badge` (a tag beside the title), or `plugins.detail.section` (a section under the page's own content). Every entry is rendered with the page's `subject` — `{ kind: 'bundle', pkg }`, `{ kind: 'row', pkg, row }`, or `{ kind: 'item', id }` — and returns null for a subject it has nothing for: -Cards appear in the order they registered into the slot; a keyed entry declares no `order` of its own. +```ts ignore-check +ctx.slots.inject('plugins.detail.badge', () => ctx.slots.register({ + name: 'plugins.detail.badge', + id: 'acme-update', + locale: 'acmeUpdate', +}, ({ t, subject }) => subject.kind === 'bundle' && hasUpdate(subject.pkg) ? {t('update')} : null)) +``` -## Packaging +## 4. Where the browser half rides -The browser half is served to the page by the [client module system](../../packages/client/modules), which scans the enabled Loader entries for packages declaring `dsh.client` and serves each one's built `./client` export. So the plugin appears on the page as soon as a `cordis.yml` mounts it — no rebuild of the web application. +The browser half is served to the page by the [client module system](../../packages/client/modules), which scans the enabled Loader entries for packages declaring `dsh.client` and serves each one's built `./client` export — but it attaches a package's half to the Loader row whose specifier is the bare package name. A row mounted from a subpath export never carries a half, so a bundle that splits one package into several rows keeps its half on the root row, and every page it registers goes away when that row is switched off. A sub-plugin whose page must outlive the other rows ships as its own package. ```jsonc { @@ -87,16 +102,8 @@ The browser half is served to the page by the [client module system](../../packa ".": { "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, "./client": { "types": "./lib/types/client/index.d.ts", "default": "./lib/client.js" } }, - "dsh": { "client": { "platform": "web", "inject": ["@deepseek-ai/dsh-client-ui-settings-plugins"] } } + "dsh": { "client": { "platform": "web", "inject": ["@deepseek-ai/dsh-client-ui-settings"] } } } ``` -The bundle must be the loader's lazy-CJS factory artifact. Inside this repository `tsdown.config.ts` is three lines over the shared preset: - -```ts ignore-check -import { clientBundle } from '../tsdown.client.ts' - -export default clientBundle('@deepseek-ai/dsh-client-my-plugin', ['lib/types/index.js', 'lib/types/invariant.js']) -``` - -No published preset exposes this package, so a package outside this repository has to reproduce the same output format itself. The bundle-purity gate also rejects value imports across plugins, so a card cannot import this section's card chrome or its staged-form model — it renders its own, and owns its own staging and revision fencing. Both limits are recorded under [the section's known limitations](../../packages/client/ui-settings-plugins/README.md#known-limitations-and-deferred-work). +A companion package for a built-in namespace is the same browser half in a client-only package with an empty Host `apply`, listed in the web composition's plugin roster ([`packages/bundle/web-app/cordis.patch.yml`](../../packages/bundle/web-app/cordis.patch.yml)) and registering into `plugins.item` through `ctx.settingsScope.whileServed`, so the page exists exactly while the Host serves the namespace. diff --git a/docs/cookbook/adding-a-settings-card.zh.md b/docs/cookbook/adding-a-settings-card.zh.md index 3ef245d763..a0fce4075b 100644 --- a/docs/cookbook/adding-a-settings-card.zh.md +++ b/docs/cookbook/adding-a-settings-card.zh.md @@ -1,14 +1,14 @@ -# Cookbook: 新增设置卡片 +# 手册:添加设置页 [English](adding-a-settings-card.md) | 中文 -插件如何把自己的配置放上 Web 设置页。这条路径上没有任何一步需要改动本仓库:Host 服务每一个已注册的 settings 命名空间,而**插件配置**分区以卡片所编辑的命名空间为键,因此同时注册了两个半侧的插件会被自动配对。 +插件如何把自己的配置放到 Web 客户端的插件页上,以及如何向那里的其他插件页面贡献内容。这条路径不需要改动本仓库:Host 服务每一个已注册的设置命名空间,插件页声明了页面或贡献要注册进去的 slot。 -两个半侧住在同一个包里——Host 半侧在 `src/`,浏览器半侧在 `src/client/`,以 `./client` 导出并用 `dsh.client` 声明。[`packages/client/ui-theme`](../../packages/client/ui-theme) 是这种打包方式的现成例子;本分区自带的卡片在 [`packages/client/ui-settings-plugins`](../../packages/client/ui-settings-plugins)。 +社区组合包的两个半侧放在一个包里——宿主半侧在 `src/`,浏览器半侧在 `src/client/`,导出为 `./client` 并用 `dsh.client` 声明。包本身带不了浏览器半侧的内置插件,则把页面做成一个伴生客户端包;[`packages/client/ui-settings-shell`](../../packages/client/ui-settings-shell) 是模板。 -## 1. 注册命名空间(Host 半侧) +## 1. 注册命名空间(宿主半侧) -命名空间就是配对用的键,所以只挑一次,并在两个半侧都写出它。已经有 `cordis.yml` entry 的消费方应通过 `ctx.settings.installSection()` 注册——它把 entry 层叠在用户文档之下,并在没有挂载 settings provider 时照常工作: +命名空间是连接键,选定一次,两个半侧拼写一致。已有 `cordis.yml` 条目的消费者应通过 `ctx.settings.installSection()` 注册,它把条目铺在用户文档之下,并在没有挂载设置提供方时照常工作: ```ts import type { Context } from '@deepseek-ai/cordis' @@ -43,43 +43,58 @@ export function apply(ctx: Context, config: Config) { } ``` -字段上的 `role('secret')` 让它的值不出现在任何响应里;卡片把这类字段写进 `update`/`mutate` 载荷,或改为经 `credentials` 领域寻址一个凭据引用。`applies: 'restart'` 告诉配置表层:拥有方要到下次启动才会对变更生效。 +字段上的 `role('secret')` 让它的值不出现在任何响应里;页面改经 `credentials` 域写入这类字段。`applies: 'restart'` 告诉配置界面:所有者要到下次启动才响应更改。 -## 2. 注册卡片(浏览器半侧) +## 2. 注册页面(浏览器半侧) -卡片以自己的命名空间为键注册进 `settings.plugin.item`,并拥有其中的一切——外观、控件与文案。它通过 `ctx.settingsScope` 读写,后者用读取时的 revision 为每次写入设栅: +页面注册进插件页的某个配置 slot,并拥有其中的一切——控件、文案和样式。组合包自己的配置进 `plugins.bundle.config`,以包名为键;组合包声明的某一行的配置进 `plugins.row.config`,以 `<包名>#<行 id>` 为键,这会给该行一个打开行页的配置控件。页面向每个条目索取两种视图:`summary` 是标题下的一句话,`page` 是表单。 + +表单通过 `ctx.settingsScope` 读写,每次写入都以读取时的修订号作栅栏。`ui-primitives` 套件提供暂存编辑模型和控件,页面只需声明字段并渲染: ```ts ignore-check import type { Context as ClientContext } from '@deepseek-ai/cordis' -// Type-only: the keyed slot's declaration. Cross-plugin collaboration goes -// through cordis services; a value import fails the client bundle-purity gate. -import type {} from '@deepseek-ai/dsh-client-ui-settings-plugins/client' +// Type-only: the Plugins page's slot declarations and the settings scope. +// Cross-plugin collaboration goes through cordis services; a value import +// fails the client bundle-purity gate. +import type {} from '@deepseek-ai/dsh-client-ui-plugin-manager/client' +import type {} from '@deepseek-ai/dsh-client-ui-settings/client' +import { SettingsFormModel, settingsNumberField, settingsTextField } from '@deepseek-ai/dsh-client-ui-primitives' -export const inject = ['slots', 'locale', 'connection', 'remote', 'settingsScope'] +export const inject = ['slots', 'locale', 'settingsScope'] export function apply(ctx: ClientContext): void { - const card = new MyPluginCardController(ctx.settingsScope.bind({ namespace: 'my-plugin' })) - ctx.slots.inject('settings.plugin.item', () => ctx.slots.register({ - name: 'settings.plugin.item', - key: 'my-plugin', - locale: 'settings.myPlugin', - inject: () => card.inject(), - }, MyPluginCard), + ctx.effect(() => ctx.locale.register('myPlugin', { zh, en })) + const form = new SettingsFormModel( + ctx.settingsScope.bind({ namespace: 'my-plugin' }), + [settingsTextField('endpoint'), settingsNumberField('retries')], ) + const store = form.bind(() => ({ ...form.shell(), endpoint: form.field('endpoint'), retries: form.field('retries') })) + ctx.slots.inject('plugins.bundle.config', () => ctx.slots.register({ + name: 'plugins.bundle.config', + key: '@acme/dsh-my-plugin', + locale: 'myPlugin', + inject: () => ({ hooks: { myPluginForm: store }, ...form.actions() }), + }, MyPluginPage)) } ``` -scope 快照携带表单所需的一切:解析后的 `value`、组装层 `base`,以及原始的 `user` 层——字段是否被覆盖,取决于它在 `user` 层中是否**出现**,而非它的值。`scope.set(field, value)` 存一个字段,`scope.unset(field)` 把它清回组装层。 +`MyPluginPage` 在 `view === 'summary'` 时渲染 `t('summary')`,在 `page` 时渲染一个包着 `SettingsValueField` 控件的 `SettingsForm`,并以 `labels` 把文案交给框架。字段是否被覆盖看它在原始用户层里是否存在,而不看它的值;重置会清除该字段,让它重新继承组合层。只有保存才写入,离开页面即丢弃所有草稿。 -## 3. 标签页拿它做什么 +## 3. 向其他插件的页面贡献内容 -**插件配置**标签页读取 Host 服务了哪些命名空间,并为每个命名空间派发一个 slot 键。当 Host 服务了某卡片的键时它被渲染,否则被跳过,因此从未组装过 Host 半侧的部署不会留下这张卡片的任何痕迹。被服务却无人认领的命名空间什么都不渲染——归其他页面所有的那些命名空间(`ui-theme`、`permission`、`llm-*`)正是这样留在本标签页之外的。 +对某个不属于自己的组合包、行或官方插件有话要说的插件,注册进 `plugins.detail.actions`(页头的控件)、`plugins.detail.badge`(标题旁的标签)或 `plugins.detail.section`(页面自身内容之下的区块)。每个条目都以页面的 `subject` 渲染——`{ kind: 'bundle', pkg }`、`{ kind: 'row', pkg, row }` 或 `{ kind: 'item', id }`——对无话可说的 subject 返回 null: -卡片按其注册进该 slot 的顺序出现;keyed entry 不声明自己的 `order`。 +```ts ignore-check +ctx.slots.inject('plugins.detail.badge', () => ctx.slots.register({ + name: 'plugins.detail.badge', + id: 'acme-update', + locale: 'acmeUpdate', +}, ({ t, subject }) => subject.kind === 'bundle' && hasUpdate(subject.pkg) ? {t('update')} : null)) +``` -## 打包 +## 4. 浏览器半侧挂在哪里 -浏览器半侧由[客户端模块系统](../../packages/client/modules)提供给页面:它扫描已启用的 Loader entries 中声明了 `dsh.client` 的包,并提供每个包构建出的 `./client` 导出。因此只要 `cordis.yml` 挂载了该插件,它就会出现在页面上——无需重新构建 Web 应用。 +浏览器半侧由[客户端模块系统](../../packages/client/modules)送到页面:它扫描已启用的 Loader 条目,找出声明了 `dsh.client` 的包,送出每个包构建好的 `./client` 导出——但它只把一个包的半侧挂在说明符恰为裸包名的那一行上。从子路径导出挂载的行永远不带半侧,因此把一个包拆成多行的组合包,其半侧留在根行上,它注册的每个页面都随根行关闭而消失。需要在其他行关闭时仍保留页面的子插件,应作为独立的包发布。 ```jsonc { @@ -87,16 +102,8 @@ scope 快照携带表单所需的一切:解析后的 `value`、组装层 `base ".": { "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, "./client": { "types": "./lib/types/client/index.d.ts", "default": "./lib/client.js" } }, - "dsh": { "client": { "platform": "web", "inject": ["@deepseek-ai/dsh-client-ui-settings-plugins"] } } + "dsh": { "client": { "platform": "web", "inject": ["@deepseek-ai/dsh-client-ui-settings"] } } } ``` -bundle 必须是 loader 的 lazy-CJS factory 产物。在本仓库内,`tsdown.config.ts` 就是基于共享预设的三行: - -```ts ignore-check -import { clientBundle } from '../tsdown.client.ts' - -export default clientBundle('@deepseek-ai/dsh-client-my-plugin', ['lib/types/index.js', 'lib/types/invariant.js']) -``` - -没有已发布的预设暴露该包,因此本仓库之外的包得自行复刻同样的输出格式。bundle 纯净度门禁同时拒绝跨插件的值导入,所以卡片无法导入本分区的卡片外观或其暂存表单模型——它渲染自己的那一份,并自行拥有暂存与 revision 设栅。这两条限制都记在[本分区的已知限制](../../packages/client/ui-settings-plugins/README.zh.md#known-limitations-and-deferred-work)里。 +内置命名空间的伴生包就是同样的浏览器半侧放在一个纯客户端包里,宿主 `apply` 为空,列入 Web 组合的插件花名册([`packages/bundle/web-app/cordis.patch.yml`](../../packages/bundle/web-app/cordis.patch.yml)),并通过 `ctx.settingsScope.whileServed` 注册进 `plugins.item`,页面因此恰好在 Host 服务该命名空间期间存在。 diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index a71977cacf..30d8e47841 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.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/module-graph.md -module-graph.md: 8ec3673173c299fdd69134af1002ba157d6db870 -module-graph.zh.md: 1dd43514acff155c92c139dca7ad519e137e2a03 +module-graph.md: 227db2a1e1cbd312bfada48704c7b23d7cd0809b +module-graph.zh.md: 40a90450ce04fd0a759b8939bdb0d02cb5d20be9 diff --git a/docs/module-graph.md b/docs/module-graph.md index 8ec3673173..227db2a1e1 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -176,11 +176,15 @@ flowchart TD pkg_client_ui_schedule["client-ui-schedule"] pkg_client_ui_session["client-ui-session"] pkg_client_ui_settings["client-ui-settings"] + pkg_client_ui_settings_agent_loop["client-ui-settings-agent-loop"] pkg_client_ui_settings_general["client-ui-settings-general"] pkg_client_ui_settings_models["client-ui-settings-models"] pkg_client_ui_settings_plugin_inventory["client-ui-settings-plugin-inventory"] pkg_client_ui_settings_plugins["client-ui-settings-plugins"] + pkg_client_ui_settings_shell["client-ui-settings-shell"] + pkg_client_ui_settings_subagent["client-ui-settings-subagent"] pkg_client_ui_settings_unarchive_sessions["client-ui-settings-unarchive-sessions"] + pkg_client_ui_settings_web_search["client-ui-settings-web-search"] pkg_client_ui_sidebar["client-ui-sidebar"] pkg_client_ui_sidebar_browser["client-ui-sidebar-browser"] pkg_client_ui_sidebar_documentpreview["client-ui-sidebar-documentpreview"] @@ -1358,11 +1362,15 @@ flowchart TD | [`client-ui-schedule`](../packages/client/ui-schedule) | `client` | — | | [`client-ui-session`](../packages/client/ui-session) | `client` | — | | [`client-ui-settings`](../packages/client/ui-settings) | `client` | — | +| [`client-ui-settings-agent-loop`](../packages/client/ui-settings-agent-loop) | `client` | — | | [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | — | | [`client-ui-settings-models`](../packages/client/ui-settings-models) | `client` | — | | [`client-ui-settings-plugin-inventory`](../packages/client/ui-settings-plugin-inventory) | `client` | — | | [`client-ui-settings-plugins`](../packages/client/ui-settings-plugins) | `client` | — | +| [`client-ui-settings-shell`](../packages/client/ui-settings-shell) | `client` | — | +| [`client-ui-settings-subagent`](../packages/client/ui-settings-subagent) | `client` | — | | [`client-ui-settings-unarchive-sessions`](../packages/client/ui-settings-unarchive-sessions) | `client` | — | +| [`client-ui-settings-web-search`](../packages/client/ui-settings-web-search) | `client` | — | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | — | | [`client-ui-sidebar-browser`](../packages/client/ui-sidebar-browser) | `client` | — | | [`client-ui-sidebar-documentpreview`](../packages/client/ui-sidebar-documentpreview) | `client` | — | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 1dd43514ac..40a90450ce 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -178,11 +178,15 @@ flowchart TD pkg_client_ui_schedule["client-ui-schedule"] pkg_client_ui_session["client-ui-session"] pkg_client_ui_settings["client-ui-settings"] + pkg_client_ui_settings_agent_loop["client-ui-settings-agent-loop"] pkg_client_ui_settings_general["client-ui-settings-general"] pkg_client_ui_settings_models["client-ui-settings-models"] pkg_client_ui_settings_plugin_inventory["client-ui-settings-plugin-inventory"] pkg_client_ui_settings_plugins["client-ui-settings-plugins"] + pkg_client_ui_settings_shell["client-ui-settings-shell"] + pkg_client_ui_settings_subagent["client-ui-settings-subagent"] pkg_client_ui_settings_unarchive_sessions["client-ui-settings-unarchive-sessions"] + pkg_client_ui_settings_web_search["client-ui-settings-web-search"] pkg_client_ui_sidebar["client-ui-sidebar"] pkg_client_ui_sidebar_browser["client-ui-sidebar-browser"] pkg_client_ui_sidebar_documentpreview["client-ui-sidebar-documentpreview"] @@ -1360,11 +1364,15 @@ flowchart TD | [`client-ui-schedule`](../packages/client/ui-schedule) | `client` | — | | [`client-ui-session`](../packages/client/ui-session) | `client` | — | | [`client-ui-settings`](../packages/client/ui-settings) | `client` | — | +| [`client-ui-settings-agent-loop`](../packages/client/ui-settings-agent-loop) | `client` | — | | [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | — | | [`client-ui-settings-models`](../packages/client/ui-settings-models) | `client` | — | | [`client-ui-settings-plugin-inventory`](../packages/client/ui-settings-plugin-inventory) | `client` | — | | [`client-ui-settings-plugins`](../packages/client/ui-settings-plugins) | `client` | — | +| [`client-ui-settings-shell`](../packages/client/ui-settings-shell) | `client` | — | +| [`client-ui-settings-subagent`](../packages/client/ui-settings-subagent) | `client` | — | | [`client-ui-settings-unarchive-sessions`](../packages/client/ui-settings-unarchive-sessions) | `client` | — | +| [`client-ui-settings-web-search`](../packages/client/ui-settings-web-search) | `client` | — | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | — | | [`client-ui-sidebar-browser`](../packages/client/ui-sidebar-browser) | `client` | — | | [`client-ui-sidebar-documentpreview`](../packages/client/ui-sidebar-documentpreview) | `client` | — | diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 9fd0effbc5..8ade59ad22 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -364,6 +364,21 @@ - id: ui-settings-plugins name: '@deepseek-ai/dsh-client-ui-settings-plugins' + # The official settings pages: one companion package per host-plane + # namespace, each registering its page into the Plugins page while the + # Host serves the namespace. + - id: ui-settings-shell + name: '@deepseek-ai/dsh-client-ui-settings-shell' + + - id: ui-settings-agent-loop + name: '@deepseek-ai/dsh-client-ui-settings-agent-loop' + + - id: ui-settings-subagent + name: '@deepseek-ai/dsh-client-ui-settings-subagent' + + - id: ui-settings-web-search + name: '@deepseek-ai/dsh-client-ui-settings-web-search' + # Plan control: the composer plan seat over the plan projection + /plan channel. - id: ui-plan name: '@deepseek-ai/dsh-client-ui-plan' diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index aa70ffb352..782f91e2bc 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -83,6 +83,10 @@ "@deepseek-ai/dsh-client-ui-plugin-manager": "workspace:^", "@deepseek-ai/dsh-client-ui-settings-plugin-inventory": "workspace:^", "@deepseek-ai/dsh-client-ui-settings-plugins": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings-shell": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings-agent-loop": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings-subagent": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings-web-search": "workspace:^", "@deepseek-ai/dsh-client-ui-settings-unarchive-sessions": "workspace:^", "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", "@deepseek-ai/dsh-client-ui-sidebar-browser": "workspace:^", diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 706236dc2a..158c3790f9 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: d466de8e4a26a6476e373e19c45c72ae22197f1b -README.zh.md: eb6f4ce836eb8a219aad38f0941e666f3391ebc6 +README.md: 97be7103ad1aaddb7ed6647c7393d9d521d27a7c +README.zh.md: a0e6dc87e419bd12e775c28de92048df3b8ac01b diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index d466de8e4a..97be7103ad 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -52,6 +52,8 @@ Check this table before writing a control in a feature package. A plugin cannot | `Tooltip` | Hover text on a cloned anchor, placed right, bottom, or top. | | `HoverCard` | Hover preview the pointer can rest on and select from; optional copy button. | | `Toast` | Transient top-center banner held for the owner's `holdMs`. | +| `SettingsForm`, `SettingsValueField`, `SettingsSecretField` | The frame and the controls of a plugin's settings page: the frame takes its copy as `labels`, saves only on its button, and discards on unmount; a value field shows staged text with the overridden badge and reset; a secret field starts blank and reports only whether a value is configured. | +| `SettingsFormModel`, `settingsNumberField`, `settingsTextField` | The staged-edit model behind such a page over a settings scope: drafts are staged and written on save, a field is overridden by its presence in the user layer, and a save that did not land keeps its drafts. | | `JsonTree`, `JsonBlock` | Read-only JSON inspection. | | `MarkdownText`, `MarkdownDelegateProvider`, `CodeBlock` | Untrusted GFM with TeX math, owner-delegated HTTP(S) navigation, and highlighted code. `CodeBlock` accepts opt-in `lineNumbers`; copied source excludes the gutter, and `contentRef` exposes its stable source wrapper to an owner that uses it as a scrollport. Set `showHeader={false}` when the owner supplies its own language and copy toolbar. | | `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, `WebBlock` | The agent-output card matching each tool-result intent. | @@ -105,6 +107,7 @@ The package enforces one separation: presentational React atoms with zero Cordis | [`src/SearchBlock.tsx`](src/SearchBlock.tsx) / [`src/WebBlock.tsx`](src/WebBlock.tsx) | Search and web-retrieval cards | | [`src/icons/`](src/icons/) | `ic_ds_*` glyph components and brand marks | | [`src/useAnchoredPosition.ts`](src/useAnchoredPosition.ts) / [`src/useAnchoredMaxHeight.ts`](src/useAnchoredMaxHeight.ts) | Floating-panel and overlay geometry hooks | +| [`src/settings-form/`](src/settings-form/) | The settings page kit: the staged form model over a settings scope, the value and secret fields, and the form frame | ### Streaming markdown diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index eb6f4ce836..a0e6dc87e4 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -52,6 +52,8 @@ kind: "package-library" | `Tooltip` | 克隆锚点上的悬停文本,可置于右、下、上三个方向。 | | `HoverCard` | 指针可停留、可选中的悬停预览;可选带复制按钮。 | | `Toast` | 顶部居中的瞬时横幅,保持时长由所有者的 `holdMs` 决定。 | +| `SettingsForm`、`SettingsValueField`、`SettingsSecretField` | 插件设置页的框架与控件:框架以 `labels` 接收文案,只在按钮点击时保存,卸载即丢弃;值字段显示暂存文本以及已覆盖标签和重置;密文字段每次为空,只报告是否已配置。 | +| `SettingsFormModel`、`settingsNumberField`、`settingsTextField` | 这类页面背后基于设置 scope 的暂存编辑模型:草稿先暂存、保存时写入,字段是否被覆盖看用户层是否含有它,未落地的保存保留草稿。 | | `JsonTree`、`JsonBlock` | 只读 JSON 查看。 | | `MarkdownText`、`MarkdownDelegateProvider`、`CodeBlock` | 不可信 GFM 与 TeX 数学、owner 委托的 HTTP(S) 导航,以及高亮代码。`CodeBlock` 可通过 `lineNumbers` 开启行号;复制的源码不含行号栏,`contentRef` 则向需要把稳定源码包装节点用作滚动区的 owner 提供该节点。调用方提供自己的语言与复制工具栏时,设置 `showHeader={false}`。 | | `TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock`、`WebBlock` | 与各类工具结果意图对应的 agent 输出卡片。 | @@ -105,6 +107,7 @@ kind: "package-library" | [`src/SearchBlock.tsx`](src/SearchBlock.tsx) / [`src/WebBlock.tsx`](src/WebBlock.tsx) | 搜索与网页检索卡片 | | [`src/icons/`](src/icons/) | `ic_ds_*` 字形组件与品牌标记 | | [`src/useAnchoredPosition.ts`](src/useAnchoredPosition.ts) / [`src/useAnchoredMaxHeight.ts`](src/useAnchoredMaxHeight.ts) | 浮动面板与浮层几何钩子 | +| [`src/settings-form/`](src/settings-form/) | 设置页套件:基于设置 scope 的暂存表单模型、值字段与密文字段、表单框架 | ### 流式 Markdown diff --git a/packages/client/ui-primitives/package.json b/packages/client/ui-primitives/package.json index c10bf53c7d..6dad7d305d 100644 --- a/packages/client/ui-primitives/package.json +++ b/packages/client/ui-primitives/package.json @@ -23,6 +23,7 @@ }, "license": "MIT", "devDependencies": { + "@deepseek-ai/dsh-client-store": "workspace:^", "@types/react": "~18.3.1", "@types/react-dom": "~18.3.0", "@deepseek-ai/cordis": "workspace:^", diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index ff8bd4213a..4642ac6909 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -44,6 +44,15 @@ export type { TooltipSide } from './Tooltip.tsx' export { Toast } from './Toast.tsx' export { fileSizeText } from './file-size.ts' export { writeClipboard } from './clipboard.ts' +export { SettingsForm } from './settings-form/SettingsForm.tsx' +export type { SettingsFormLabels, SettingsFormProps } from './settings-form/SettingsForm.tsx' +export { SettingsSecretField, SettingsValueField } from './settings-form/fields.tsx' +export type { SettingsFieldProps } from './settings-form/fields.tsx' +export { SettingsFormModel, settingsNumberField, settingsTextField } from './settings-form/form-model.ts' +export type { + SettingsFieldSpec, SettingsFieldState, SettingsFieldWrite, SettingsFormActions, SettingsFormScope, + SettingsFormScopeSnapshot, SettingsFormShell, SettingsSecretSpec, +} from './settings-form/form-model.ts' export { relativeTime } from './relative-time.ts' export { rankByName } from './rank-by-name.ts' export { isDarwinDesktop } from './darwin-desktop.ts' diff --git a/packages/client/ui-settings-plugins/src/client/PluginConfigForm.module.css b/packages/client/ui-primitives/src/settings-form/SettingsForm.module.css similarity index 87% rename from packages/client/ui-settings-plugins/src/client/PluginConfigForm.module.css rename to packages/client/ui-primitives/src/settings-form/SettingsForm.module.css index cf1aade88f..01bca0b749 100644 --- a/packages/client/ui-settings-plugins/src/client/PluginConfigForm.module.css +++ b/packages/client/ui-primitives/src/settings-form/SettingsForm.module.css @@ -1,4 +1,4 @@ -/* Plugin configuration form: the controls the page shows under a plugin's title, and the save that writes them. */ +/* Settings form: the controls a page shows under a plugin's title, and the save that writes them. */ .form { display: flex; diff --git a/packages/client/ui-settings-plugins/src/client/PluginConfigForm.tsx b/packages/client/ui-primitives/src/settings-form/SettingsForm.tsx similarity index 53% rename from packages/client/ui-settings-plugins/src/client/PluginConfigForm.tsx rename to packages/client/ui-primitives/src/settings-form/SettingsForm.tsx index 0c3a4bf202..38cc6097f4 100644 --- a/packages/client/ui-settings-plugins/src/client/PluginConfigForm.tsx +++ b/packages/client/ui-primitives/src/settings-form/SettingsForm.tsx @@ -1,6 +1,6 @@ /** - * One plugin's configuration form as its page on the Plugins page shows it: - * the read-only notice when the deployment stores settings read-only, the + * One plugin's settings form as its page on the Plugins page shows it: the + * read-only notice when the deployment stores settings read-only, the * plugin's controls, and the save that writes every staged edit. The page * draws the plugin's title and one-liner itself. * @@ -11,16 +11,29 @@ */ import { useEffect, useRef, type ReactNode } from 'react' -import type { CardShell } from './card-form.ts' -import type { PluginsSettingsLocaleKey } from './locales.ts' -import css from './PluginConfigForm.module.css' +import type { SettingsFormShell } from './form-model.ts' +import css from './SettingsForm.module.css' -/** Form chrome shared by every plugin configuration page. */ -export interface PluginConfigFormProps { - /** Locale reader for this package's copy. */ - t: (key: PluginsSettingsLocaleKey) => string +/** The copy the form frame renders, from the owning plugin's dictionary. */ +export interface SettingsFormLabels { + /** Shown in place of the controls while the namespace is not served. */ + unavailable: string + /** Shown over the controls while the document is read-only. */ + readOnly: string + /** Shown beside the save after a save the Host did not accept. */ + saveFailed: string + /** The save control. */ + save: string + /** The save control while a save is crossing the wire. */ + saving: string +} + +/** Form chrome shared by every settings page. */ +export interface SettingsFormProps { + /** The frame's copy. */ + labels: SettingsFormLabels /** The form state: availability, writability, and what a save would do. */ - state: CardShell + state: SettingsFormShell /** Write every staged edit. */ onSave: () => void /** Drop every staged edit; the form calls it when it leaves the page. */ @@ -30,30 +43,30 @@ export interface PluginConfigFormProps { } /** - * Render one plugin's configuration form. - * @param props - the form state, its controls, and the save and discard actions. + * Render one plugin's settings form. + * @param props - the form's copy and state, its controls, and the save and discard actions. * @returns the form, or the unavailable line while the namespace is not served. */ -export function PluginConfigForm(props: PluginConfigFormProps) { - const { state } = props +export function SettingsForm(props: SettingsFormProps) { + const { state, labels } = props const discard = useRef(props.onDiscard) discard.current = props.onDiscard useEffect(() => () => { discard.current() }, []) - if (!state.available) return

{props.t('unavailable')}

+ if (!state.available) return

{labels.unavailable}

const blocked = !state.dirty || state.invalid || state.saving return (
- {!state.writable ?

{props.t('readOnly')}

: null} + {!state.writable ?

{labels.readOnly}

: null} {props.children}
- {state.failed ?

{props.t('saveFailed')}

: null} + {state.failed ?

{labels.saveFailed}

: null}
diff --git a/packages/client/ui-settings-plugins/src/client/fields.module.css b/packages/client/ui-primitives/src/settings-form/fields.module.css similarity index 96% rename from packages/client/ui-settings-plugins/src/client/fields.module.css rename to packages/client/ui-primitives/src/settings-form/fields.module.css index 4db120dc7c..6c637d95a6 100644 --- a/packages/client/ui-settings-plugins/src/client/fields.module.css +++ b/packages/client/ui-primitives/src/settings-form/fields.module.css @@ -1,4 +1,4 @@ -/* Plugin configuration fields: label, control, override badge, and hint. */ +/* Settings form fields: label, control, override badge, and hint. */ .field { display: flex; diff --git a/packages/client/ui-settings-plugins/src/client/fields.tsx b/packages/client/ui-primitives/src/settings-form/fields.tsx similarity index 86% rename from packages/client/ui-settings-plugins/src/client/fields.tsx rename to packages/client/ui-primitives/src/settings-form/fields.tsx index 3610c694bc..163c1b971a 100644 --- a/packages/client/ui-settings-plugins/src/client/fields.tsx +++ b/packages/client/ui-primitives/src/settings-form/fields.tsx @@ -1,17 +1,18 @@ /** - * Hand-written controls for the plugin configuration forms. Each renders one - * field's label, its staged text, whether saving would leave an override, and - * — when one stands — the reset that stages a clear back to the composition - * layer. Nothing here writes: a control reports what the user typed, and the - * card's save is the single point where a draft becomes a document mutation. + * The controls of a settings form. Each renders one field's label, its staged + * text, whether saving would leave an override, and — when one stands — the + * reset that stages a clear back to the composition layer. Nothing here + * writes: a control reports what the user typed, and the form's save is the + * single point where a draft becomes a document mutation. */ import { useState, type ReactNode } from 'react' -import { IconInfoOutline14, Tag } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconInfoOutline14 } from '../icons/index.tsx' +import { Tag } from '../Tag.tsx' import css from './fields.module.css' -/** What every field control needs regardless of its value type. */ -export interface FieldProps { +/** What every settings field control needs regardless of its value type. */ +export interface SettingsFieldProps { /** Stable id associating the label with its control. */ id: string /** Visible label. */ @@ -45,7 +46,7 @@ export interface FieldProps { * @param props - the field's copy, its staged text, and the edit actions. * @returns the labelled control. */ -export function ValueField(props: Omit & { +export function SettingsValueField(props: Omit & { /** Optional explanation shown below the input. */ hint?: string /** Rules disclosed by the information button beside the label. */ @@ -120,7 +121,7 @@ export function ValueField(props: Omit & { * @param props - the field's copy, its staged text, and the configured state. * @returns the labelled control. */ -export function SecretField(props: Pick & { +export function SettingsSecretField(props: Pick & { /** Whether the Host reports a configured credential for this reference. */ configured: boolean /** Copy describing the configured state. */ diff --git a/packages/client/ui-settings-plugins/src/client/card-form.ts b/packages/client/ui-primitives/src/settings-form/form-model.ts similarity index 73% rename from packages/client/ui-settings-plugins/src/client/card-form.ts rename to packages/client/ui-primitives/src/settings-form/form-model.ts index 365a2e8fd8..1d7af5daad 100644 --- a/packages/client/ui-settings-plugins/src/client/card-form.ts +++ b/packages/client/ui-primitives/src/settings-form/form-model.ts @@ -1,7 +1,7 @@ /** - * Shared form model behind every plugin card. + * The staged form model behind a plugin's settings page. * - * A card stages what the user types and writes it only when they save. Each + * A page stages what the user types and writes it only when they save. Each * settings write is a durable, revision-fenced document mutation, so a control * that committed as it settled turned one edit into a write the user never * asked for and could not preview; staged text makes what is on screen exactly @@ -14,15 +14,57 @@ */ import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' -import type { SettingsScope, SettingsScopeSnapshot } from '@deepseek-ai/dsh-client-ui-settings/client' -/** The write one field's staged text performs when the card is saved. */ -export type FieldWrite = +/** What the model reads of one settings namespace. */ +export interface SettingsFormScopeSnapshot { + /** `ready` while the Host serves the namespace to this client; the form renders nothing otherwise. */ + status: 'loading' | 'ready' | 'unavailable' + /** Last accepted schema-resolved section; undefined before the first acceptance. */ + value: T | undefined + /** Composition layer the value resolves over: what a field reverts to once cleared. */ + base: unknown + /** Raw user layer as stored; a field's PRESENCE here is what marks it overridden. */ + user: unknown + /** Whether the Host document accepts writes. */ + writable: boolean +} + +/** + * The namespace scope the model stages over: the reads and the two field + * writes of the scope `ui-settings` binds for a namespace, which a page passes + * as it is. + */ +export interface SettingsFormScope { + /** @returns the current sync snapshot. */ + getSnapshot(): SettingsFormScopeSnapshot + /** + * Observe snapshot replacements. + * @param listener - invoked after each snapshot change. + * @returns the disposer removing this listener. + */ + subscribe(listener: () => void): () => void + /** + * Queue one field write. + * @param field - scalar field inside the namespace section. + * @param value - JSON-shaped value selected by the user. + * @returns settlement after the write and any recovery read. + */ + set(field: string, value: unknown): Promise + /** + * Queue one field clear, so the field re-inherits the composition layer. + * @param field - scalar field inside the namespace section. + * @returns settlement after the clear and any recovery read. + */ + unset(field: string): Promise +} + +/** The write one field's staged text performs when the form is saved. */ +export type SettingsFieldWrite = | { kind: 'set'; value: unknown } | { kind: 'clear' } /** How one section field converts between its stored value and its draft text. */ -export interface CardFieldSpec { +export interface SettingsFieldSpec { /** Field name inside the namespace section. */ field: string /** Render a stored value as draft text; the empty string when the section carries none. */ @@ -31,7 +73,7 @@ export interface CardFieldSpec { * The write this draft text stages, or undefined when the text is not a * value this field accepts — which blocks the save rather than discarding it. */ - parse: (text: string) => FieldWrite | undefined + parse: (text: string) => SettingsFieldWrite | undefined } /** @@ -39,15 +81,15 @@ export interface CardFieldSpec { * literal never rides a response, so its draft has nothing to seed from: it is * blank until typed, and a blank draft writes nothing. */ -export interface CardSecretSpec { - /** Field name addressing this control inside the card's form. */ +export interface SettingsSecretSpec { + /** Field name addressing this control inside the form. */ field: string /** Write the staged text; resolves to whether the Host accepted it. */ write: (text: string) => Promise } -/** One field as a card's control renders it. */ -export interface CardFieldState { +/** One field as a form's control renders it. */ +export interface SettingsFieldState { /** Draft text the control renders. */ text: string /** @@ -60,9 +102,9 @@ export interface CardFieldState { invalid: boolean } -/** Form state every plugin card shares. */ -export interface CardShell { - /** False while the namespace is not served to this client; the card renders nothing. */ +/** Form state every settings page shares. */ +export interface SettingsFormShell { + /** False while the namespace is not served to this client; the form renders nothing. */ available: boolean /** Whether the Host document accepts writes. */ writable: boolean @@ -76,8 +118,8 @@ export interface CardShell { failed: boolean } -/** The write actions every plugin card's slot entry injects. */ -export interface CardActions { +/** The write actions a settings page's slot entry injects. */ +export interface SettingsFormActions { /** Stage draft text for one field. */ edit: (field: string, text: string) => void /** Stage a clear, so saving lets the field re-inherit the composition layer. */ @@ -113,7 +155,7 @@ interface PlannedWrite { * @param field - field name inside the namespace section. * @returns the field's conversion spec. */ -export function numberField(field: string): CardFieldSpec { +export function settingsNumberField(field: string): SettingsFieldSpec { return { field, // A section that carries no number for this field renders empty rather @@ -134,7 +176,7 @@ export function numberField(field: string): CardFieldSpec { * @param field - field name inside the namespace section. * @returns the field's conversion spec. */ -export function textField(field: string): CardFieldSpec { +export function settingsTextField(field: string): SettingsFieldSpec { return { field, format: value => typeof value === 'string' ? value : '', @@ -146,29 +188,29 @@ export function textField(field: string): CardFieldSpec { } /** - * Stages one card's edits over one settings namespace and writes them on save. + * Stages one page's edits over one settings namespace and writes them on save. * * The form publishes through a snapshot store because slot components read * through a snapshot selector, while both the scope and the local drafts * change underneath; every projection is rebuilt from the two together. */ -export class CardForm { - private readonly specs: Map - private readonly secretSpecs: Map +export class SettingsFormModel { + private readonly specs: Map + private readonly secretSpecs: Map private readonly staged = new Map() private readonly listeners = new Set<() => void>() private saving = false private failed = false /** - * @param scope - the bound settings scope for this card's namespace. - * @param specs - the section fields this card edits. - * @param secrets - the card's write-only controls, written outside the section. + * @param scope - the bound settings scope for the page's namespace. + * @param specs - the section fields the page edits. + * @param secrets - the page's write-only controls, written outside the section. */ constructor( - private readonly scope: SettingsScope, - specs: CardFieldSpec[], - secrets: CardSecretSpec[] = [], + private readonly scope: SettingsFormScope, + specs: SettingsFieldSpec[], + secrets: SettingsSecretSpec[] = [], ) { this.specs = new Map(specs.map(spec => [spec.field, spec])) this.secretSpecs = new Map(secrets.map(spec => [spec.field, spec])) @@ -177,8 +219,8 @@ export class CardForm { /** * Publish a projection of this form, rebuilt whenever the scope or a draft changes. - * @param project - build the card's state from the form's current reads. - * @returns the store the card's component reads through its bound selector. + * @param project - build the page's state from the form's current reads. + * @returns the store the page's component reads through its bound selector. */ bind(project: () => S): SnapshotStore { const store = createSnapshotStore(project()) @@ -187,10 +229,10 @@ export class CardForm { } /** - * Read the card-level state: what the Host serves, and what a save would do. - * @returns the form state every card shares. + * Read the form-level state: what the Host serves, and what a save would do. + * @returns the form state every page shares. */ - shell(): CardShell { + shell(): SettingsFormShell { const snapshot = this.scope.getSnapshot() const plan = this.plan() return { @@ -208,7 +250,7 @@ export class CardForm { * @param field - field name of a section field or of a write-only control. * @returns the draft text, whether a save would leave an override, and whether it is invalid. */ - field(field: string): CardFieldState { + field(field: string): SettingsFieldState { const staged = this.staged.get(field) if (this.secretSpecs.has(field)) { return { text: staged?.text ?? '', overridden: false, invalid: false } @@ -227,9 +269,9 @@ export class CardForm { /** * Build the edit, reset, save, and discard actions bound to this form. - * @returns the actions a card's slot entry injects. + * @returns the actions a page's slot entry injects. */ - actions(): CardActions { + actions(): SettingsFormActions { return { edit: (field, text) => { this.stage(field, { text, clear: false }) }, resetField: (field) => { @@ -316,15 +358,15 @@ export class CardForm { this.publish() } - private spec(field: string): CardFieldSpec { + private spec(field: string): SettingsFieldSpec { const spec = this.specs.get(field) - // Every call site names a field this card declared; a missing one is a + // Every call site names a field the page declared; a missing one is a // wiring mistake that must not degrade into a silently inert control. - if (spec === undefined) throw new Error(`plugin card has no field ${field}`) + if (spec === undefined) throw new Error(`settings form has no field ${field}`) return spec } - private snapshotOf(): SettingsScopeSnapshot { + private snapshotOf(): SettingsFormScopeSnapshot { return this.scope.getSnapshot() } diff --git a/packages/client/ui-settings-plugins/tests/fields.client.spec.tsx b/packages/client/ui-primitives/tests/settings-fields.client.spec.tsx similarity index 79% rename from packages/client/ui-settings-plugins/tests/fields.client.spec.tsx rename to packages/client/ui-primitives/tests/settings-fields.client.spec.tsx index d9a8d29ac2..a1de1f0b6e 100644 --- a/packages/client/ui-settings-plugins/tests/fields.client.spec.tsx +++ b/packages/client/ui-primitives/tests/settings-fields.client.spec.tsx @@ -2,7 +2,7 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' -import { SecretField, ValueField } from '../src/client/fields.tsx' +import { SettingsSecretField, SettingsValueField } from '../src/settings-form/fields.tsx' afterEach(cleanup) @@ -18,10 +18,10 @@ const frame = { invalid: false, } -describe('ValueField', () => { +describe('SettingsValueField', () => { it('stages every keystroke without writing', () => { const onEdit = vi.fn() - render() + render() fireEvent.change(screen.getByLabelText('Command timeout'), { target: { value: '9000' } }) @@ -29,20 +29,20 @@ describe('ValueField', () => { }) it('renders the staged text it is given rather than a draft of its own', () => { - const { rerender } = render() + const { rerender } = render() expect(screen.getByLabelText('Command timeout')).toHaveProperty('value', '60000') - rerender() + rerender() expect(screen.getByLabelText('Command timeout')).toHaveProperty('value', '9000') }) it('offers the reset only while an override would stand', () => { const onReset = vi.fn() - const { rerender } = render() + const { rerender } = render() expect(screen.queryByRole('button', { name: 'Reset to default' })).toBeNull() - rerender() + rerender() fireEvent.click(screen.getByRole('button', { name: 'Reset to default' })) expect(screen.getByText('Overridden')).toBeTruthy() @@ -50,7 +50,7 @@ describe('ValueField', () => { }) it('replaces the hint with the reason an invalid draft cannot be saved', () => { - render() + render() expect(screen.getByText('Enter a number.')).toBeTruthy() expect(screen.queryByText('How long one command may run.')).toBeNull() @@ -59,7 +59,7 @@ describe('ValueField', () => { it('hints a numeric keypad and renders a placeholder when asked', () => { render( - { }) it('disables the control and its reset while the document is read-only', () => { - render() + render() expect(screen.getByLabelText('Command timeout')).toHaveProperty('disabled', true) expect(screen.getByRole('button', { name: 'Reset to default' })).toHaveProperty('disabled', true) }) }) -describe('SecretField', () => { +describe('SettingsSecretField', () => { const secret = { id: 'key', label: 'API key', @@ -93,7 +93,7 @@ describe('SecretField', () => { it('stages the draft and never renders it', () => { const onEdit = vi.fn() render( - { it('reports the configured state the Host holds', () => { const { rerender } = render( - { expect(screen.getByText('No key is configured.')).toBeTruthy() rerender( - { it('disables the control when it is told to', () => { render( - { + scope: SettingsFormScope + set: ReturnType Promise>> + unset: ReturnType Promise>> + publish: (next: Partial>) => void +} + +function stubScope(): StubScope { + let snapshot: SettingsFormScopeSnapshot = { status: 'loading', value: undefined, base: undefined, user: undefined, writable: false } + const listeners = new Set<() => void>() + const set = vi.fn<(field: string, value: unknown) => Promise>(() => Promise.resolve()) + const unset = vi.fn<(field: string) => Promise>(() => Promise.resolve()) + return { + scope: { + getSnapshot: () => snapshot, + subscribe: (listener) => { + listeners.add(listener) + return () => { listeners.delete(listener) } + }, + set, + unset, + }, + set, + unset, + publish: (next) => { + snapshot = { ...snapshot, ...next } + for (const listener of [...listeners]) listener() + }, + } +} + +/** Make the stub behave like a Host that accepts every write. */ +function acceptWrites(host: StubScope): void { + const section = (): Record => ({ ...host.scope.getSnapshot().value as object }) + const layer = (): Record => ({ ...host.scope.getSnapshot().user as object }) + host.set.mockImplementation((field: string, value: unknown) => { + host.publish({ value: { ...section(), [field]: value } as T, user: { ...layer(), [field]: value } }) + return Promise.resolve() + }) + host.unset.mockImplementation((field: string) => { + const user = Object.fromEntries(Object.entries(layer()).filter(([key]) => key !== field)) + const base = host.scope.getSnapshot().base as Record | undefined + host.publish({ value: { ...section(), [field]: base?.[field] } as T, user }) + return Promise.resolve() + }) +} + +describe('SettingsFormModel', () => { + function form() { + const host = stubScope>() + const subject = new SettingsFormModel(host.scope, [settingsNumberField('timeoutMs'), settingsTextField('baseURL')]) + host.publish({ + status: 'ready', + writable: true, + value: { timeoutMs: 60_000, baseURL: 'https://search.test/v1' }, + base: { timeoutMs: 60_000, baseURL: 'https://search.test/v1' }, + user: {}, + }) + return { host, subject } + } + + it('shows the effective value and stays clean until something is staged', () => { + const { subject } = form() + + expect(subject.field('timeoutMs')).toEqual({ text: '60000', overridden: false, invalid: false }) + expect(subject.shell()).toMatchObject({ available: true, writable: true, dirty: false, invalid: false }) + }) + + it('marks a field the user layer carries as overridden', () => { + const { host, subject } = form() + + host.publish({ value: { timeoutMs: 60_000 }, user: { timeoutMs: 60_000 } }) + + // An override equal to the composition default is still an override. + expect(subject.field('timeoutMs').overridden).toBe(true) + }) + + it('writes nothing until the form is saved', async () => { + const { host, subject } = form() + acceptWrites(host) + + subject.actions().edit('timeoutMs', '9000') + + expect(subject.field('timeoutMs')).toEqual({ text: '9000', overridden: true, invalid: false }) + expect(subject.shell().dirty).toBe(true) + expect(host.set).not.toHaveBeenCalled() + + await subject.save() + + expect(host.set.mock.calls).toEqual([['timeoutMs', 9_000]]) + expect(subject.shell()).toMatchObject({ dirty: false, failed: false, saving: false }) + }) + + it('drops a draft that settles back on the value already shown', async () => { + const { host, subject } = form() + + subject.actions().edit('timeoutMs', '9000') + subject.actions().edit('timeoutMs', '60000') + + expect(subject.shell().dirty).toBe(false) + await subject.save() + + expect(host.set).not.toHaveBeenCalled() + }) + + it('refuses to save while a draft is not a value the field accepts', async () => { + const { host, subject } = form() + + subject.actions().edit('timeoutMs', 'soon') + + expect(subject.field('timeoutMs')).toEqual({ text: 'soon', overridden: false, invalid: true }) + expect(subject.shell()).toMatchObject({ dirty: true, invalid: true }) + + await subject.save() + + expect(host.set).not.toHaveBeenCalled() + expect(subject.field('timeoutMs').text).toBe('soon') + }) + + it('stages a reset that clears the field only once saved', async () => { + const { host, subject } = form() + acceptWrites(host) + host.publish({ value: { timeoutMs: 9_000 }, user: { timeoutMs: 9_000 } }) + + subject.actions().resetField('timeoutMs') + + // The badge previews the save: the field will no longer be overridden. + expect(subject.field('timeoutMs')).toEqual({ text: '60000', overridden: false, invalid: false }) + expect(host.unset).not.toHaveBeenCalled() + + await subject.save() + + expect(host.unset.mock.calls).toEqual([['timeoutMs']]) + expect(subject.shell()).toMatchObject({ dirty: false, failed: false }) + }) + + it('treats resetting an inherited field as no change at all', async () => { + const { host, subject } = form() + + subject.actions().resetField('timeoutMs') + + expect(subject.shell().dirty).toBe(false) + await subject.save() + + expect(host.unset).not.toHaveBeenCalled() + }) + + it('clears a number field by emptying it', async () => { + const { host, subject } = form() + acceptWrites(host) + host.publish({ user: { timeoutMs: 9_000 } }) + + subject.actions().edit('timeoutMs', '') + + expect(subject.field('timeoutMs')).toEqual({ text: '', overridden: false, invalid: false }) + await subject.save() + + expect(host.unset.mock.calls).toEqual([['timeoutMs']]) + }) + + it('clears a text field by emptying it', async () => { + const { host, subject } = form() + acceptWrites(host) + host.publish({ user: { baseURL: 'https://search.test/v1' } }) + + subject.actions().edit('baseURL', ' ') + await subject.save() + + expect(host.unset.mock.calls).toEqual([['baseURL']]) + }) + + it('writes the trimmed text of a text field', async () => { + const { host, subject } = form() + acceptWrites(host) + + subject.actions().edit('baseURL', ' https://other.test ') + await subject.save() + + expect(host.set.mock.calls).toEqual([['baseURL', 'https://other.test']]) + }) + + it('keeps the drafts a save did not land, and reports the failure', async () => { + const { host, subject } = form() + + subject.actions().edit('timeoutMs', '9000') + await subject.save() + + // The stub Host accepted the call without storing it, exactly as a + // validator that refuses the value does. + expect(host.set).toHaveBeenCalledWith('timeoutMs', 9_000) + expect(subject.shell()).toMatchObject({ dirty: true, failed: true, saving: false }) + expect(subject.field('timeoutMs').text).toBe('9000') + }) + + it('reports a reset the Host did not apply as a failure', async () => { + const { host, subject } = form() + host.publish({ user: { timeoutMs: 9_000 } }) + + subject.actions().resetField('timeoutMs') + await subject.save() + + expect(host.unset).toHaveBeenCalledWith('timeoutMs') + expect(subject.shell().failed).toBe(true) + }) + + it('clears the failure as soon as the user edits again', async () => { + const { subject } = form() + + subject.actions().edit('timeoutMs', '9000') + await subject.save() + expect(subject.shell().failed).toBe(true) + + subject.actions().edit('timeoutMs', '9001') + + expect(subject.shell().failed).toBe(false) + }) + + it('discards every staged edit', async () => { + const { host, subject } = form() + + subject.actions().edit('timeoutMs', '9000') + subject.actions().discard() + + expect(subject.field('timeoutMs').text).toBe('60000') + expect(subject.shell()).toMatchObject({ dirty: false, failed: false }) + + // A discard with nothing staged publishes nothing. + const before = subject.shell() + subject.actions().discard() + expect(subject.shell()).toEqual(before) + + await subject.save() + expect(host.set).not.toHaveBeenCalled() + }) + + it('refuses a second save while one is in flight', async () => { + const { host, subject } = form() + acceptWrites(host) + + subject.actions().edit('timeoutMs', '9000') + const first = subject.save() + expect(subject.shell().saving).toBe(true) + const second = subject.save() + await Promise.all([first, second]) + + expect(host.set).toHaveBeenCalledTimes(1) + }) + + it('publishes a projection whenever the scope or a draft changes', () => { + const { host, subject } = form() + const store = subject.bind(() => subject.field('timeoutMs').text) + expect(store.getSnapshot()).toBe('60000') + + host.publish({ value: { timeoutMs: 1_000 } }) + expect(store.getSnapshot()).toBe('1000') + + subject.actions().edit('timeoutMs', '2000') + expect(store.getSnapshot()).toBe('2000') + }) + + it('refuses to address a field the card never declared', () => { + const { subject } = form() + + expect(() => subject.field('nope')).toThrow('settings form has no field nope') + }) + + it('renders an absent section value as an empty draft', () => { + const host = stubScope>() + const subject = new SettingsFormModel(host.scope, [settingsNumberField('timeoutMs'), settingsTextField('baseURL')]) + + host.publish({ status: 'ready', writable: true, value: {}, base: {}, user: undefined }) + + expect(subject.field('timeoutMs').text).toBe('') + expect(subject.field('baseURL').text).toBe('') + expect(subject.shell().available).toBe(true) + }) + + it('stays unavailable while the namespace is not served', () => { + const host = stubScope>() + const subject = new SettingsFormModel(host.scope, [settingsNumberField('timeoutMs')]) + + host.publish({ status: 'unavailable' }) + + expect(subject.shell()).toMatchObject({ available: false, writable: false }) + }) +}) diff --git a/packages/client/ui-primitives/tests/settings-form.client.spec.tsx b/packages/client/ui-primitives/tests/settings-form.client.spec.tsx new file mode 100644 index 0000000000..087713d9a1 --- /dev/null +++ b/packages/client/ui-primitives/tests/settings-form.client.spec.tsx @@ -0,0 +1,82 @@ +// @vitest-environment jsdom +/** The settings form frame: what it says in each state, and that leaving it discards. */ + +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import { SettingsForm } from '../src/settings-form/SettingsForm.tsx' +import type { SettingsFormShell } from '../src/settings-form/form-model.ts' + +afterEach(cleanup) + +const labels = { + unavailable: 'Not loaded.', + readOnly: 'Read-only.', + saveFailed: 'Not accepted.', + save: 'Save', + saving: 'Saving…', +} + +const settled: SettingsFormShell = { available: true, writable: true, dirty: false, invalid: false, saving: false, failed: false } + +function renderForm(state: Partial = {}) { + const onSave = vi.fn() + const onDiscard = vi.fn() + render( + + + , + ) + return { onSave, onDiscard } +} + +it('says the namespace is not served in place of the controls', () => { + renderForm({ available: false }) + + expect(screen.getByRole('status').textContent).toBe(labels.unavailable) + expect(screen.queryByLabelText('Field')).toBeNull() +}) + +it('keeps the save inert until something is staged, then saves on click', () => { + const { onSave } = renderForm() + expect(screen.getByRole('button', { name: labels.save })).toHaveProperty('disabled', true) + cleanup() + + const dirty = renderForm({ dirty: true }) + fireEvent.click(screen.getByRole('button', { name: labels.save })) + expect(dirty.onSave).toHaveBeenCalledOnce() + expect(onSave).not.toHaveBeenCalled() +}) + +it('blocks the save while a draft is invalid or one is in flight, and reports a refused save', () => { + renderForm({ dirty: true, invalid: true }) + expect(screen.getByRole('button', { name: labels.save })).toHaveProperty('disabled', true) + cleanup() + + renderForm({ dirty: true, saving: true }) + expect(screen.getByRole('button', { name: labels.saving })).toHaveProperty('disabled', true) + cleanup() + + renderForm({ dirty: true, failed: true }) + expect(screen.getByRole('status').textContent).toBe(labels.saveFailed) +}) + +it('says the document is read-only over the controls', () => { + renderForm({ writable: false }) + + expect(screen.getByRole('status').textContent).toBe(labels.readOnly) + expect(screen.getByLabelText('Field')).toBeTruthy() +}) + +it('discards when it leaves the page, calling the latest discard it was given', () => { + const first = vi.fn() + const second = vi.fn() + const { rerender, unmount } = render( + , + ) + rerender() + + unmount() + + expect(first).not.toHaveBeenCalled() + expect(second).toHaveBeenCalledOnce() +}) diff --git a/packages/client/ui-primitives/tsconfig.json b/packages/client/ui-primitives/tsconfig.json index efd9866d5e..201a8a711e 100644 --- a/packages/client/ui-primitives/tsconfig.json +++ b/packages/client/ui-primitives/tsconfig.json @@ -9,5 +9,10 @@ ], "exclude": [ "**/*.legacy.*" + ], + "references": [ + { + "path": "../store" + } ] } diff --git a/packages/client/ui-settings-agent-loop/README.i18n.yaml b/packages/client/ui-settings-agent-loop/README.i18n.yaml new file mode 100644 index 0000000000..92ec6f1516 --- /dev/null +++ b/packages/client/ui-settings-agent-loop/README.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 packages/client/ui-settings-agent-loop/README.md +README.md: 725a28c2ec708c864702486a9ab8fc4b7f153dce +README.zh.md: 9792bf01dc510c4b842124b0d1999ac3739552f8 diff --git a/packages/client/ui-settings-agent-loop/README.md b/packages/client/ui-settings-agent-loop/README.md new file mode 100644 index 0000000000..725a28c2ec --- /dev/null +++ b/packages/client/ui-settings-agent-loop/README.md @@ -0,0 +1,78 @@ +--- +description: "The agent loop's settings page on the dsh web client's Plugins page: the parallel tool-call cap of the agent-loop namespace." +kind: "package-reference" +--- + +# @deepseek-ai/dsh-client-ui-settings-agent-loop + +English | [中文](README.zh.md) + +## Summary + +Open **Plugins** in the sidebar and select **Agent loop** in the Official group to set how many parallel-safe tool calls one step may run at once. The page stages what is typed and writes it only on save, marks a value the user overrode, and offers to reset it to the deployment's default. The page exists while the Host serves the `agent-loop` namespace. + +## Table of Contents + +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +The **Agent loop** card in the Official group opens the page. **Parallel tool calls** bounds the calls the loop runs at once within one step; the field renders the effective value, and once overridden carries an **Overridden** badge with **Reset to default** beside it. Nothing is written until **Save**; leaving the page drops the draft, an empty field saves as a reset, and text that is not a number blocks the save and says so under the field. + +----- + + +## Understand the implementation + +
+Implementation internals — click to expand + +The Host half is an empty `apply`, present only so the package holds a Loader row the client module system serves the browser half for. The browser half binds the `agent-loop` namespace through `ctx.settingsScope`, keeps the staged form in `AgentLoopCardController` over the shared `SettingsFormModel` of `ui-primitives`, and registers `AgentLoopCard` into the Plugins page's `plugins.item` slot through `ctx.settingsScope.whileServed`. The page's copy lives in this package's `settings.agentLoop` dictionary. + +
+ +----- + + +## Further Exploration + +- [ui-plugin-manager](../ui-plugin-manager/README.md) — the Plugins page and the `plugins.item` slot the page registers into. +- [ui-settings](../ui-settings/README.md) — the settings scope and the served-namespace watch the page rides. +- [ui-primitives](../ui-primitives/README.md) — the settings form model and fields the page renders. +- [agent-loop](../../core/agent-loop/README.md) — the loop that registers the `agent-loop` namespace. + +----- + + +## Model Experience + +None, as the package is a browser-side settings surface that registers no model surface. + +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + +## Known Limitations and Deferred Work + + + +- **One field of the namespace** — the Host section carries only the parallel cap; the composed `agents` array is boot composition, not a user setting, and has no field here. +- **Runtime invariant:** No companion is published. The page holds no owned relationship of its own: what it shows derives from the settings mirror, and what it writes the Host validates. + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
diff --git a/packages/client/ui-settings-agent-loop/README.zh.md b/packages/client/ui-settings-agent-loop/README.zh.md new file mode 100644 index 0000000000..9792bf01dc --- /dev/null +++ b/packages/client/ui-settings-agent-loop/README.zh.md @@ -0,0 +1,78 @@ +--- +description: "dsh Web 客户端插件页上的 Agent 循环设置页:agent-loop 命名空间的并行工具调用上限。" +kind: "package-reference" +--- + +# @deepseek-ai/dsh-client-ui-settings-agent-loop + +[English](README.md) | 中文 + +## 概述 + +在侧栏打开**插件**,在官方分组里选择 **Agent 循环**,即可设置一步之内最多同时运行多少个可并行的工具调用。页面暂存输入、只在保存时写入,标明用户覆盖过的值,并允许把它重置回部署默认值。页面只在 Host 服务 `agent-loop` 命名空间期间存在。 + +## 目录 + +- [使用本包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [进一步探索](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与延期工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用本包 + +官方分组里的 **Agent 循环**卡片打开这一页。**并行工具调用数**限制循环在一步之内同时运行的调用数;字段显示生效值,一旦被覆盖就带**已覆盖**标签,旁边是**恢复默认**。点击**保存**之前不会写入任何内容;离开页面即丢弃草稿,清空字段并保存等于重置,填了不是数字的文本则保存被阻止,并在字段下说明原因。 + +----- + + +## 理解实现 + +
+实现细节——点击展开 + +宿主半侧是一个空的 `apply`,只为让本包占一条 Loader 行,客户端模块系统据此送出浏览器半侧。浏览器半侧通过 `ctx.settingsScope` 绑定 `agent-loop` 命名空间,用 `ui-primitives` 的共享 `SettingsFormModel` 在 `AgentLoopCardController` 里维护暂存表单,并通过 `ctx.settingsScope.whileServed` 把 `AgentLoopCard` 注册进插件页的 `plugins.item` slot。页面文案在本包的 `settings.agentLoop` 字典里。 + +
+ +----- + + +## 进一步探索 + +- [ui-plugin-manager](../ui-plugin-manager/README.zh.md)——插件页以及本页注册进去的 `plugins.item` slot。 +- [ui-settings](../ui-settings/README.zh.md)——本页依赖的设置 scope 与"命名空间被服务期间"的监视。 +- [ui-primitives](../ui-primitives/README.zh.md)——本页渲染的设置表单模型与字段。 +- [agent-loop](../../core/agent-loop/README.zh.md)——注册 `agent-loop` 命名空间的循环。 + +----- + + +## 模型体验 + +无,本包是浏览器侧的设置界面,不注册任何模型面。 + +#### KV 缓存影响 + +无;本包既不组装也不发送提供方请求。 + +## 已知限制与延期工作 + + + +- **只编辑命名空间的一个字段**——Host 的这一节只承载并行上限;组合进来的 `agents` 数组属于启动组合而非用户设置,这里没有对应字段。 +- **运行时不变量:**不发布伴生。本页没有自己拥有的关系:它显示的内容派生自设置镜像,它写入的内容由 Host 校验。 + + +### 开发备注 + +
+维护者工作上下文——点击展开 + +无。 + +
diff --git a/packages/client/ui-settings-agent-loop/package.json b/packages/client/ui-settings-agent-loop/package.json new file mode 100644 index 0000000000..7a4f26ec1c --- /dev/null +++ b/packages/client/ui-settings-agent-loop/package.json @@ -0,0 +1,65 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-settings-agent-loop", + "description": "Settings page of the agent loop on the dsh web client's Plugins page: the parallel tool-call cap of the agent-loop namespace", + "version": "0.1.6-alpha.1", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/ui-settings-agent-loop" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-ui-settings", + "@deepseek-ai/dsh-client-ui-plugin-manager" + ], + "platform": "web" + } + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", + "@deepseek-ai/dsh-client-test-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-plugin-manager": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@types/react": "~18.3.1", + "react": "^18.2.0" + }, + "files": [ + "lib/index.js", + "lib/client.js", + "lib/types/**/*.d.ts" + ] +} diff --git a/packages/client/ui-settings-plugins/src/client/AgentLoopCard.tsx b/packages/client/ui-settings-agent-loop/src/client/AgentLoopCard.tsx similarity index 63% rename from packages/client/ui-settings-plugins/src/client/AgentLoopCard.tsx rename to packages/client/ui-settings-agent-loop/src/client/AgentLoopCard.tsx index 02c702cb9e..37eba283b1 100644 --- a/packages/client/ui-settings-plugins/src/client/AgentLoopCard.tsx +++ b/packages/client/ui-settings-agent-loop/src/client/AgentLoopCard.tsx @@ -1,37 +1,32 @@ -/** The agent loop's configuration page: how many tool calls one step may run at once. */ +/** The agent loop's settings page: how many tool calls one step may run at once. */ import type {} from '@deepseek-ai/dsh-client-ui-plugin-manager/client' +import { SettingsForm, SettingsValueField } from '@deepseek-ai/dsh-client-ui-primitives' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' -import { ValueField } from './fields.tsx' -import { PluginConfigForm } from './PluginConfigForm.tsx' +import { formLabels } from './locales.ts' import type { AgentLoopCardFace } from './agent-loop-card-controller.ts' /** Props the renderer binds for the agent-loop page. */ export type AgentLoopCardProps = PropsRuntime<'plugins.item'> - & PropsLocale<'settings.plugins'> + & PropsLocale<'settings.agentLoop'> & InjectFace /** - * Render the agent loop's one-liner or its configuration form, as the Plugins page asks. + * Render the agent loop's one-liner or its settings form, as the Plugins page asks. * @param props - the view asked for, locale copy, the form snapshot, and its actions. * @returns the one-liner, or the form. */ export function AgentLoopCard(props: AgentLoopCardProps) { const { t } = props const state = props.useAgentLoopCard(snapshot => snapshot) - if (props.view === 'summary') return t('agentLoopDescription') + if (props.view === 'summary') return t('description') return ( - - + { props.edit('maxParallelToolCalls', text) }} onReset={() => { props.resetField('maxParallelToolCalls') }} /> - + ) } diff --git a/packages/client/ui-settings-plugins/src/client/agent-loop-card-controller.ts b/packages/client/ui-settings-agent-loop/src/client/agent-loop-card-controller.ts similarity index 52% rename from packages/client/ui-settings-plugins/src/client/agent-loop-card-controller.ts rename to packages/client/ui-settings-agent-loop/src/client/agent-loop-card-controller.ts index d37d59b394..edc15e0b79 100644 --- a/packages/client/ui-settings-plugins/src/client/agent-loop-card-controller.ts +++ b/packages/client/ui-settings-agent-loop/src/client/agent-loop-card-controller.ts @@ -1,8 +1,10 @@ -/** The agent-loop card's staged form over the `agent-loop` settings namespace. */ +/** The agent-loop page's staged form over the `agent-loop` settings namespace. */ import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' -import type { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client' -import { CardForm, numberField, type CardActions, type CardFieldState, type CardShell } from './card-form.ts' +import { + SettingsFormModel, settingsNumberField, + type SettingsFieldState, type SettingsFormActions, type SettingsFormScope, type SettingsFormShell, +} from '@deepseek-ai/dsh-client-ui-primitives' /** * Namespace of the agent loop's user-owned settings. Spelled here rather than @@ -11,7 +13,7 @@ import { CardForm, numberField, type CardActions, type CardFieldState, type Card export const AGENT_LOOP_NS = 'agent-loop' /** - * The agent-loop fields this card edits. The Host section carries only this + * The agent-loop fields this page edits. The Host section carries only this * field — the composed `agents` array is deliberately not part of it. */ export interface AgentLoopSettings { @@ -19,28 +21,28 @@ export interface AgentLoopSettings { maxParallelToolCalls?: number } -/** What the agent-loop card renders. */ -export interface AgentLoopCardState extends CardShell { +/** What the agent-loop page renders. */ +export interface AgentLoopCardState extends SettingsFormShell { /** Parallel tool-call cap. */ - maxParallelToolCalls: CardFieldState + maxParallelToolCalls: SettingsFieldState } -/** The registration-side face the agent-loop card's slot entry injects. */ -export interface AgentLoopCardFace extends CardActions { +/** The registration-side face the agent-loop page's slot entry injects. */ +export interface AgentLoopCardFace extends SettingsFormActions { hooks: { - /** Card snapshot bound by the renderer as useAgentLoopCard. */ + /** Page snapshot bound by the renderer as useAgentLoopCard. */ agentLoopCard: SnapshotStore } } -/** Bridges the `agent-loop` scope onto the card's staged form. */ +/** Bridges the `agent-loop` scope onto the page's staged form. */ export class AgentLoopCardController { - private readonly form: CardForm + private readonly form: SettingsFormModel private readonly store: SnapshotStore /** @param scope - the bound settings scope for the `agent-loop` namespace. */ - constructor(scope: SettingsScope) { - this.form = new CardForm(scope, [numberField('maxParallelToolCalls')]) + constructor(scope: SettingsFormScope) { + this.form = new SettingsFormModel(scope, [settingsNumberField('maxParallelToolCalls')]) this.store = this.form.bind(() => this.projection()) } @@ -49,8 +51,8 @@ export class AgentLoopCardController { } /** - * Build the face the card's slot registration injects. - * @returns the card's snapshot and its form actions. + * Build the face the page's slot registration injects. + * @returns the page's snapshot and its form actions. */ inject(): AgentLoopCardFace { return { hooks: { agentLoopCard: this.store }, ...this.form.actions() } diff --git a/packages/client/ui-settings-agent-loop/src/client/index.ts b/packages/client/ui-settings-agent-loop/src/client/index.ts new file mode 100644 index 0000000000..5c3d4915ad --- /dev/null +++ b/packages/client/ui-settings-agent-loop/src/client/index.ts @@ -0,0 +1,49 @@ +/** + * The agent loop's settings page, browser half: the parallel tool-call cap + * over the `agent-loop` namespace the loop registers. The page registers into + * the Plugins page's `plugins.item` slot while the Host serves that namespace, + * so a deployment that exposes no agent-loop settings shows no trace of it. + */ + +// Type-only: pulls the locale plugin's Context merge (ctx.locale). +import type {} from '@deepseek-ai/dsh-client-locale/client' +// Type-only: the ctx.settingsScope Context merge. Cross-plugin collaboration +// goes through the service, never a value import (client bundle purity gate). +import type {} from '@deepseek-ai/dsh-client-ui-settings/client' +// Type-only: the Plugins page's SlotMap merge (the 'plugins.item' entry). +import type {} from '@deepseek-ai/dsh-client-ui-plugin-manager/client' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' +import { AgentLoopCard } from './AgentLoopCard.tsx' +import { AGENT_LOOP_NS, AgentLoopCardController } from './agent-loop-card-controller.ts' +import { en, zh, type AgentLoopSettingsLocaleKey } from './locales.ts' + +export type { AgentLoopCardProps } from './AgentLoopCard.tsx' +export type { AgentLoopCardFace, AgentLoopCardState, AgentLoopSettings } from './agent-loop-card-controller.ts' +export type { AgentLoopSettingsLocaleKey } from './locales.ts' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** Agent loop settings page copy. */ + 'settings.agentLoop': AgentLoopSettingsLocaleKey + } +} + +/** Dictionary namespace owned by this plugin. */ +export const NS = 'settings.agentLoop' + +/** Required services (cordis fiber inject). */ +export const inject = ['slots', 'locale', 'settingsScope'] + +/** + * Mount the agent loop's settings page while the Host serves its namespace. + * @param ctx - the browser plugin context. + */ +export function apply(ctx: ClientContext): void { + const t = ctx.locale.bind(NS) + ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-settings-agent-loop: dictionaries') + const card = new AgentLoopCardController(ctx.settingsScope.bind({ namespace: AGENT_LOOP_NS })) + ctx.effect(() => ctx.settingsScope.whileServed([AGENT_LOOP_NS], () => ctx.slots.inject('plugins.item', () => ctx.slots.register({ + name: 'plugins.item', id: 'agent-loop', order: 20, label: () => t('title'), locale: NS, inject: () => card.inject(), + }, AgentLoopCard))), 'ui-settings-agent-loop: page') +} diff --git a/packages/client/ui-settings-agent-loop/src/client/locales.ts b/packages/client/ui-settings-agent-loop/src/client/locales.ts new file mode 100644 index 0000000000..609abf64a0 --- /dev/null +++ b/packages/client/ui-settings-agent-loop/src/client/locales.ts @@ -0,0 +1,50 @@ +/** Locale bundles for the agent loop's settings page. */ + +import type { SettingsFormLabels } from '@deepseek-ai/dsh-client-ui-primitives' + +/** Locale keys the page renders. */ +export type AgentLoopSettingsLocaleKey = + | 'title' | 'description' | 'maxParallel' | 'maxParallelHint' + | 'overridden' | 'reset' | 'readOnly' | 'unavailable' + | 'save' | 'saving' | 'saveFailed' | 'invalidNumber' + +/** English copy. */ +export const en: Record = { + title: 'Agent loop', + description: 'How the agent dispatches tool calls.', + maxParallel: 'Parallel tool calls', + maxParallelHint: 'Upper bound on parallel-safe calls running at once within one step.', + overridden: 'Overridden', + reset: 'Reset to default', + readOnly: 'This deployment stores settings read-only.', + unavailable: 'This plugin is not loaded, so it cannot be configured right now.', + save: 'Save', + saving: 'Saving…', + saveFailed: 'The deployment did not accept these values; they were left for you to correct.', + invalidNumber: 'Enter a number, or leave blank to use the default.', +} + +/** Simplified Chinese copy. */ +export const zh: Record = { + title: 'Agent 循环', + description: 'Agent 如何派发工具调用。', + maxParallel: '并行工具调用数', + maxParallelHint: '同一步内最多同时运行多少个可并行的调用。', + overridden: '已覆盖', + reset: '恢复默认', + readOnly: '本部署的设置为只读。', + unavailable: '该插件当前未加载,暂时无法配置。', + save: '保存', + saving: '保存中…', + saveFailed: '本部署没有接受这些值,已保留供你修改。', + invalidNumber: '请填数字;留空表示使用默认值。', +} + +/** + * The form frame's copy, read from this page's dictionary. + * @param t - the page's locale reader. + * @returns the labels the shared settings form renders. + */ +export function formLabels(t: (key: AgentLoopSettingsLocaleKey) => string): SettingsFormLabels { + return { unavailable: t('unavailable'), readOnly: t('readOnly'), saveFailed: t('saveFailed'), save: t('save'), saving: t('saving') } +} diff --git a/packages/client/ui-settings-agent-loop/src/css-modules.d.ts b/packages/client/ui-settings-agent-loop/src/css-modules.d.ts new file mode 100644 index 0000000000..8811db1264 --- /dev/null +++ b/packages/client/ui-settings-agent-loop/src/css-modules.d.ts @@ -0,0 +1,4 @@ +declare module '*.module.css' { + const classes: Record + export default classes +} diff --git a/packages/client/ui-settings-agent-loop/src/index.ts b/packages/client/ui-settings-agent-loop/src/index.ts new file mode 100644 index 0000000000..ec90addf90 --- /dev/null +++ b/packages/client/ui-settings-agent-loop/src/index.ts @@ -0,0 +1,10 @@ +/** + * Agent loop settings page, node half. The empty apply exists so the plugin + * appears in the host cordis.yml / Loader; the browser half owns the page + * through exports["./client"], discovered from the package.json dsh.client + * declaration. The `agent-loop` namespace the page edits is registered by the + * agent loop, so this package registers no namespace of its own. + */ + +/** Host plugin body — no host-side behavior for this surface plugin. */ +export function apply(): void {} diff --git a/packages/client/ui-settings-agent-loop/tests/apply.client.spec.ts b/packages/client/ui-settings-agent-loop/tests/apply.client.spec.ts new file mode 100644 index 0000000000..b822607711 --- /dev/null +++ b/packages/client/ui-settings-agent-loop/tests/apply.client.spec.ts @@ -0,0 +1,95 @@ +/** What the browser half registers, when, and that it all leaves with the fiber. */ + +import { Context } from '@deepseek-ai/cordis' +import { describe, expect, it, vi } from 'vitest' +import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' +import { RemoteError, TestRemote } from '@deepseek-ai/dsh-client-test-runtime' +import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' +import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client' +import { apply, inject, NS } from '../src/client/index.ts' +import type { AgentLoopCardFace } from '../src/client/index.ts' +import { apply as hostApply } from '../src/index.ts' + +/** One Host view of a served namespace. */ +function view(ns: string, revision = 0) { + return { ns, schema: {}, value: {}, applies: 'live', secrets: [], revision } +} + +/** @param served - namespaces the Host describes; omitted answers a failed read. */ +async function bench(served?: string[]) { + const ctx = new Context() + await ctx.plugin(SlotRegistry).await() + const locale = new LocaleRuntime(ctx) + locale.setLocale('zh') + ctx.provide('locale', locale) + const describeSettings = vi.fn(() => Promise.resolve(served === undefined + ? { ok: false, error: new RemoteError('gateway/internal', 'no provider', {}) } + : { ok: true, value: { writable: true, hasDocument: true, namespaces: served.map(ns => view(ns)) } })) + const remote = new TestRemote(ctx, { settings: { describe: describeSettings } }) + await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() + return { ctx, slots: ctx.get('slots') as SlotRegistry, describeSettings, remote } +} + +/** The Plugins page's item slot, as its owner declares it. */ +function declareRoot(slots: SlotRegistry): () => void { + return slots.register({ + name: 'root', + children: { 'plugins.item': { kind: 'list', scope: 'root' } }, + } as never, () => null) +} + +describe('ui-settings-agent-loop apply', () => { + it('keeps the host Loader entry inert', () => { + expect(hostApply).not.toThrow() + }) + + it('declares the services it uses', () => { + expect(inject).toEqual(['slots', 'locale', 'settingsScope']) + }) + + it('registers the page while the Host serves the namespace, titled in the active locale', async () => { + const { ctx, slots } = await bench(['agent-loop', 'shell']) + declareRoot(slots) + + await ctx.plugin({ inject: [...inject], apply }).await() + + await vi.waitFor(() => { expect(slots.entries('plugins.item')).toHaveLength(1) }) + const entry = slots.entries('plugins.item')[0]! + expect(entry.options).toMatchObject({ id: 'agent-loop', order: 20 }) + expect(resolveSlotLabel(entry.options.label)).toBe('Agent 循环') + expect(entry.locale).toBe(NS) + const face = (entry.inject as unknown as () => AgentLoopCardFace)() + expect(Object.keys(face.hooks)).toEqual(['agentLoopCard']) + }) + + it('registers nothing while the namespace is not served, and withdraws the page when the Host stops serving it', async () => { + const { ctx, slots, describeSettings, remote } = await bench(['shell']) + declareRoot(slots) + await ctx.plugin({ inject: [...inject], apply }).await() + await vi.waitFor(() => { expect(describeSettings).toHaveBeenCalled() }) + expect(slots.entries('plugins.item')).toHaveLength(0) + + describeSettings.mockResolvedValue({ + ok: true, value: { writable: true, hasDocument: true, namespaces: [view('agent-loop', 1)] }, + }) + remote.emit('settings/document-updated', ['agent-loop', 1]) + await vi.waitFor(() => { expect(slots.entries('plugins.item')).toHaveLength(1) }) + + describeSettings.mockResolvedValue({ ok: true, value: { writable: true, hasDocument: true, namespaces: [] } }) + remote.emit('settings/document-updated', ['agent-loop', 2]) + await vi.waitFor(() => { expect(slots.entries('plugins.item')).toHaveLength(0) }) + }) + + it('collapses the page on teardown', async () => { + const { ctx, slots } = await bench(['agent-loop']) + declareRoot(slots) + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + await vi.waitFor(() => { expect(slots.entries('plugins.item')).toHaveLength(1) }) + + await fiber.dispose() + + expect(slots.entries('plugins.item')).toHaveLength(0) + }) +}) diff --git a/packages/client/ui-settings-agent-loop/tests/card.client.spec.tsx b/packages/client/ui-settings-agent-loop/tests/card.client.spec.tsx new file mode 100644 index 0000000000..be2fd9a09a --- /dev/null +++ b/packages/client/ui-settings-agent-loop/tests/card.client.spec.tsx @@ -0,0 +1,66 @@ +// @vitest-environment jsdom +/** The agent-loop page as the Plugins page renders it: its one-liner and its one field. */ + +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { SettingsFieldState, SettingsFormShell } from '@deepseek-ai/dsh-client-ui-primitives' +import { AgentLoopCard, type AgentLoopCardProps } from '../src/client/AgentLoopCard.tsx' +import type { AgentLoopCardState } from '../src/client/agent-loop-card-controller.ts' +import { en } from '../src/client/locales.ts' + +afterEach(cleanup) + +const t = (key: keyof typeof en) => en[key] + +const settled: SettingsFormShell = { available: true, writable: true, dirty: false, invalid: false, saving: false, failed: false } + +function field(text: string, rest: Partial = {}): SettingsFieldState { + return { text, overridden: false, invalid: false, ...rest } +} + +function renderCard(state: Partial = {}, view: 'summary' | 'page' = 'page') { + const store = createSnapshotStore({ ...settled, maxParallelToolCalls: field('10'), ...state }) + const actions = { edit: vi.fn(), resetField: vi.fn(), save: vi.fn(), discard: vi.fn() } + const props = { ...actions, view, t, useAgentLoopCard: bindSnapshotSelector(store) } as unknown as AgentLoopCardProps + render() + return actions +} + +describe('AgentLoopCard', () => { + it('renders its one-liner alone in the summary view', () => { + renderCard({}, 'summary') + + expect(document.body.textContent).toBe(en.description) + expect(screen.queryByLabelText(en.maxParallel)).toBeNull() + }) + + it('stages and saves the only field it owns', () => { + const actions = renderCard({ dirty: true }) + + fireEvent.change(screen.getByLabelText(en.maxParallel), { target: { value: '2' } }) + fireEvent.click(screen.getByRole('button', { name: en.save })) + + expect(actions.edit).toHaveBeenCalledWith('maxParallelToolCalls', '2') + expect(actions.save).toHaveBeenCalledOnce() + }) + + it('stages a reset for the field it owns', () => { + const actions = renderCard({ maxParallelToolCalls: field('2', { overridden: true }) }) + + fireEvent.click(screen.getByRole('button', { name: en.reset })) + + expect(actions.resetField).toHaveBeenCalledWith('maxParallelToolCalls') + }) + + it('says the plugin is not loaded while its namespace is unavailable, and disables the field while the document is read-only', () => { + renderCard({ available: false }) + expect(screen.getByRole('status').textContent).toBe(en.unavailable) + cleanup() + + renderCard({ writable: false }) + expect(screen.getByRole('status').textContent).toBe(en.readOnly) + expect(screen.getByLabelText(en.maxParallel)).toHaveProperty('disabled', true) + }) +}) diff --git a/packages/client/ui-settings-agent-loop/tests/controller.client.spec.ts b/packages/client/ui-settings-agent-loop/tests/controller.client.spec.ts new file mode 100644 index 0000000000..76eaa5df27 --- /dev/null +++ b/packages/client/ui-settings-agent-loop/tests/controller.client.spec.ts @@ -0,0 +1,41 @@ +/** The agent-loop page's form over a scripted settings scope. */ + +import { describe, expect, it, vi } from 'vitest' +import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' +import { AgentLoopCardController, type AgentLoopSettings } from '../src/client/agent-loop-card-controller.ts' + +describe('AgentLoopCardController', () => { + it('saves the only field it owns', async () => { + const host = stubSettingsScope() + host.set.mockImplementation((field: string, value: unknown) => { + host.publish({ value: { [field]: value }, user: { [field]: value } }) + }) + const controller = new AgentLoopCardController(host.scope) + host.publish({ + status: 'ready', + writable: true, + value: { maxParallelToolCalls: 10 }, + base: { maxParallelToolCalls: 10 }, + user: {}, + }) + const face = controller.inject() + + face.edit('maxParallelToolCalls', '4') + face.save() + await vi.waitFor(() => { expect(host.set).toHaveBeenCalledWith('maxParallelToolCalls', 4) }) + + expect(face.hooks.agentLoopCard.getSnapshot()).toMatchObject({ + dirty: false, + maxParallelToolCalls: { text: '4', overridden: true }, + }) + }) + + it('reports a read-only document so the page can disable its controls', () => { + const host = stubSettingsScope() + const controller = new AgentLoopCardController(host.scope) + + host.publish({ status: 'ready', writable: false, value: { maxParallelToolCalls: 10 } }) + + expect(controller.inject().hooks.agentLoopCard.getSnapshot().writable).toBe(false) + }) +}) diff --git a/packages/client/ui-settings-agent-loop/tsconfig.json b/packages/client/ui-settings-agent-loop/tsconfig.json new file mode 100644 index 0000000000..a43ca3698f --- /dev/null +++ b/packages/client/ui-settings-agent-loop/tsconfig.json @@ -0,0 +1,42 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../locale" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../store" + }, + { + "path": "../../test-support/client-runtime" + }, + { + "path": "../ui-primitives" + }, + { + "path": "../ui-renderer" + }, + { + "path": "../ui-plugin-manager" + }, + { + "path": "../ui-settings" + }, + { + "path": "../../api/remotes/tsconfig.client.json" + }, + { + "path": "../ui-slots" + } + ] +} diff --git a/packages/client/ui-settings-agent-loop/tsdown.config.ts b/packages/client/ui-settings-agent-loop/tsdown.config.ts new file mode 100644 index 0000000000..7ae5a8134a --- /dev/null +++ b/packages/client/ui-settings-agent-loop/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-ui-settings-agent-loop', ['lib/types/index.js']) diff --git a/packages/client/ui-settings-plugins/README.i18n.yaml b/packages/client/ui-settings-plugins/README.i18n.yaml index 22a2db2519..043c8a60bb 100644 --- a/packages/client/ui-settings-plugins/README.i18n.yaml +++ b/packages/client/ui-settings-plugins/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-settings-plugins/README.md -README.md: 7d09cfb4f7d77cb8a121e043d94b62cd4ccd823f -README.zh.md: 649c5c51219856e8d8544db0d4d291343db55c64 +README.md: b4b3d37bd9da0fa6d1e3dcefd4d26394d4dde161 +README.zh.md: d22a9ec3f142bd7c11b27b1cb08e0a8a56ce6ead diff --git a/packages/client/ui-settings-plugins/README.md b/packages/client/ui-settings-plugins/README.md index 7d09cfb4f7..b4b3d37bd9 100644 --- a/packages/client/ui-settings-plugins/README.md +++ b/packages/client/ui-settings-plugins/README.md @@ -1,5 +1,5 @@ --- -description: "Built-in plugins settings section for the dsh web client, and the official plugin configuration pages that register into the Plugins page." +description: "Built-in plugins settings section for the dsh web client: the Settings navigation entry and the tab chrome that feature-owned tabs register into." kind: "package-reference" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Use the **Built-in plugins** settings section to inspect the plugins this deployment ships, and the **Official** group of the sidebar's Plugins page to configure the host-plane plugins that expose settings. Each configuration page shows which values the user overrode, lets them reset those to deployment defaults, keeps edits local until save, and drops them when the page is left. If the configuration changed after the page loaded, the save is rejected instead of overwriting the newer values. +Use the **Built-in plugins** settings section to inspect the plugins this deployment ships. The section is a shell: it owns the navigation entry and the tab row, and every tab in it is registered by another plugin — the read-only inventory ships one. Configuring a built-in plugin happens on the sidebar's Plugins page, where each official plugin's own companion package registers its page. ## Table of Contents @@ -25,25 +25,9 @@ Use the **Built-in plugins** settings section to inspect the plugins this deploy ## Use this package -Open **Built-in plugins** in Settings for the read-only inventory; [ui-settings-plugin-inventory](../ui-settings-plugin-inventory/README.md) contributes it as the section's one tab, shown as the page itself. To configure a host-plane plugin, select **Plugins** in the sidebar: the Official group lists one card per plugin this deployment composes, in this order — the shell executor (`shell`), the agent loop's tool-call parallelism (`agent-loop`), Subagent delegation limits and model selection (`subagent` and `subagent-model-selection`), and the DeepSeek search provider (`web-search-deepseek`) — and a card opens the plugin's page with its form. +Open **Built-in plugins** in Settings. [ui-settings-plugin-inventory](../ui-settings-plugin-inventory/README.md) contributes the inventory as the section's one tab, shown as the page itself; a second registered tab turns the row into tabs. A deployment whose composition contributes no tab shows the section's empty line. -### What appears here - -Each page registers into the Plugins page's `plugins.item` slot while the Host serves its settings namespace, so a deployment that does not compose the owning plugin shows no trace of it, and a namespace the Host starts or stops serving adds or withdraws its page on the next settings-document commit or reconnect. The card's one-liner and the page's form are one entry rendered in the two views the Plugins page asks for. - -### Editing and saving - -A page stages what the user types and writes it only when they save. Each control renders staged text, so what is on screen is exactly what a save would store. Leaving the page drops the drafts; there is no discard control. A failed save keeps the page as it is, reports the failure, and retains the drafts for correction. A reset stages the composed default rather than writing immediately, and a draft the field does not accept blocks the save instead of being dropped. The Host is the only authority on whether a value was accepted. - -The **Subagent** card groups delegation limits and model selection on one page with one save button. **Maximum recursion depth** and **Subagent parallelism limit** appear side by side, stacking on narrow screens. Information buttons reveal a two-row depth example and the shared count rule; validation errors remain visible below the input. Depth retains explicit tool overrides. Capacity counts live continuable descendants across all recursion levels, including waiting children and excluding the root, one-shot runs, and external providers. Saving applies to later delegation attempts; lowering capacity does not stop existing children. - -The model selection section stages its permission switch and exact model checkboxes together. Enabling requires at least one selected adapter route. Saving submits `enabled` and `allowedModels` in one mutation fenced by the revision where that draft began; a newer Host revision marks the draft failed instead of restoring a revoked route. Disabling retains the selected routes for later reuse. Available models are grouped by provider, while saved routes absent from the current catalog appear last and remain removable. Adapter names and model descriptions remain live directory metadata and are not stored, and the card refreshes them after adapter changes, settings commits, and reconnects. - -Saving the Subagent card validates both sections and writes their drafts through their existing namespaces. These writes are independent: if one fails, the card stays open with that draft retained, and retry writes only the remaining draft. The page appears when either namespace is served and shows only the available sections. - -### Secret-role fields - -A key control starts blank, reports only whether one is configured, and writes through the credentials domain rather than the settings section; a blank draft writes nothing and keeps the stored key. +To contribute a tab, register into `settings.plugins.tab` with an `id`, an `order`, and a localized `label`; the section renders the entries in order and mounts a tab on its first selection. Feature copy stays in the registering plugin's dictionary. ----- @@ -53,15 +37,7 @@ A key control starts blank, reports only whether one is configured, and writes t
Implementation internals — click to expand -The package is one registration rule and one write path: each page is registered while its namespace is served, and saves go through the client settings scope. - -### The registration rule - -The section declares `settings.plugins.tab`, a root list slot whose labels become ordered tabs; a lone contribution renders as the page itself, and a tab stays mounted after its first selection so search and the inventory snapshot survive switching. The configuration pages are `plugins.item` registrations, one per namespace, made through `ctx.slots.inject` when the shared settings mirror shows the Host serves the namespace and disposed when it stops; registration order is the page order, not the Host's description order, which follows plugin activation and can change between boots. A page owns its controls and copy; the Plugins page draws its title, icon, and crumb. - -### The write path - -Saving writes staged fields through the client settings scope, which fences each write or ordered mutation with the namespace revision the draft read, so a form that has drifted from the document is refused rather than overwriting a concurrent change. A field's presence in the raw user layer — not its value — is what marks it overridden; a reset clears that field so it re-inherits the composition layer. Secret-role fields never ride a response; the page re-reads on the forwarded `credentials/reference-updated` event for the reference it watches. +The section declares `settings.plugins.tab`, a root list slot whose labels become ordered tabs; a lone contribution renders as the page itself, and a tab stays mounted after its first selection so search and the inventory snapshot survive switching. The section's `inject` projects the slot's ledger into ordered rows whose labels follow the active locale, cached until the ledger version or the locale revision moves. The Host half is an empty `apply`, present only so the package holds a Loader row the client module system serves the browser half for.
@@ -70,14 +46,10 @@ Saving writes staged fields through the client settings scope, which fences each ## Further Exploration -These pages cover the Plugins page, the settings base, the inventory tab, and the durable seams behind the forms. - -- [ui-plugin-manager](../ui-plugin-manager/README.md) — the sidebar page whose `plugins.item`, `plugins.bundle.config`, and `plugins.row.config` slots host configuration pages. -- [ui-settings](../ui-settings/README.md) — the domain base declaring `settings.section` and the settings scope. -- [ui-settings-plugin-inventory](../ui-settings-plugin-inventory/README.md) — the read-only inventory the section shows. -- [settings](../../settings/README.md) — the durable user-settings seam and its file provider. -- [credentials](../../credentials/README.md) — the credential-reference seam secret fields write through. -- [ui-settings-general](../ui-settings-general/README.md) — the settings shell hosting the section. +- [ui-settings-plugin-inventory](../ui-settings-plugin-inventory/README.md) — the read-only inventory tab. +- [ui-settings](../ui-settings/README.md) — the domain base declaring `settings.section`. +- [ui-plugin-manager](../ui-plugin-manager/README.md) — the Plugins page where official plugins are configured. +- [ui-settings-shell](../ui-settings-shell/README.md), [ui-settings-agent-loop](../ui-settings-agent-loop/README.md), [ui-settings-subagent](../ui-settings-subagent/README.md), [ui-settings-web-search](../ui-settings-web-search/README.md) — the official configuration pages, one companion package each. ----- @@ -94,13 +66,8 @@ None; this package neither assembles nor sends a provider request. - -These limits define which plugins get a page and how fresh the group is; they are current package constraints. - -- **Only host-plane plugins have a page** — a plugin an agent preset mounts carries its configuration inline in that preset's `agent.cordis.yml` and cannot register a settings namespace at all, so this package registers nothing for it. Editing those values remains the preset editor's job. -- **A page still needs a browser bundle** — the browser half must be a `dsh.client` package built in the client module system's lazy-CJS factory format, and the `clientBundle` preset that emits it lives in `../../../packages/client/tsdown.client.ts` rather than a published package, so a plugin outside this repository has to reproduce that build itself. -- **The served namespaces re-read on two signals only** — the wire announces settings-document commits and connection resets, not registrations, so a namespace whose owner registers after the mirror's read joins the Official group on the next document commit or reconnect. -- **The shell page follows the composed executor** — the POSIX and PowerShell executor families share the `shell` namespace because a host composes exactly one of them, so the served schema differs by platform (PowerShell adds `pwshPath`) even though the page edits the same two fields on both. +- **The section has no tab of its own** — it renders its empty line until a feature plugin registers one; the shell cannot fill the section alone. +- **Runtime invariant:** No companion is published. The section owns no relationship beyond the slot ledger it projects. ### Dev Note @@ -111,5 +78,3 @@ These limits define which plugins get a page and how fresh the group is; they ar None. - -**Runtime invariant:** No companion is published. This is a browser-side settings surface whose node half owns no event stream or mutable runtime data; the layering and write refusals are Host contracts covered by the owning plugins and the api-proxy. diff --git a/packages/client/ui-settings-plugins/README.zh.md b/packages/client/ui-settings-plugins/README.zh.md index 649c5c5121..d22a9ec3f1 100644 --- a/packages/client/ui-settings-plugins/README.zh.md +++ b/packages/client/ui-settings-plugins/README.zh.md @@ -1,5 +1,5 @@ --- -description: "dsh Web 客户端的「内置插件」设置分区,以及注册进插件页的官方插件配置页。" +description: "dsh Web 客户端的「内置插件」设置分区:设置导航项与供功能插件注册标签页的标签行。" kind: "package-reference" --- @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -使用**内置插件**设置分区查看本部署随附的插件;使用侧栏插件页的**官方**分组配置开放了设置的宿主平面插件。每个配置页标明用户覆盖过哪些值,允许把它们重置为部署默认值,在本地保留修改直到保存,离开页面即丢弃。如果配置在页面加载后发生变化,保存会被拒绝,而不会覆盖较新的值。 +使用**内置插件**设置分区查看本部署随附的插件。该分区只是一个壳:它拥有导航项和标签行,里面的每个标签页都由其他插件注册——只读清单注册了一个。配置内置插件在侧栏的插件页上进行,每个官方插件自己的伴生包把页面注册到那里。 ## 目录 @@ -25,25 +25,9 @@ kind: "package-reference" ## 使用本包 -打开设置中的**内置插件**查看只读的插件列表;它由 [ui-settings-plugin-inventory](../ui-settings-plugin-inventory/README.zh.md) 作为分区唯一的标签页贡献,直接显示为页面本身。要配置宿主平面插件,在侧栏选择**插件**:官方分组为本部署组装的每个插件列出一张卡片,顺序依次为 shell 执行器(`shell`)、agent loop 的工具调用并行度(`agent-loop`)、Subagent 委派限制和模型选择(`subagent` 与 `subagent-model-selection`)以及 DeepSeek 搜索提供方(`web-search-deepseek`),点开卡片就是该插件带表单的页面。 +在设置里打开**内置插件**。[ui-settings-plugin-inventory](../ui-settings-plugin-inventory/README.zh.md) 把清单作为分区唯一的标签页贡献进来,直接显示为页面本身;注册第二个标签页后这一行就变成标签行。组合里没有任何标签页贡献的部署会显示分区的空提示。 -### 这里会出现什么 - -每个页面在 Host 服务其 settings 命名空间期间注册进插件页的 `plugins.item` slot,因此没有组装该插件的部署不会留下它的任何痕迹;Host 开始或停止服务某个命名空间时,其页面会在下一次 settings 文档提交或重连时加入或撤下。卡片上的一句话简介与页面上的表单是同一个条目按插件页索取的两种视图渲染出来的。 - -### 编辑与保存 - -页面暂存用户输入,只有用户保存时才写入。每个控件渲染的都是暂存文本,因此屏幕上所见即保存后所存。离开页面即丢弃草稿,没有放弃控件。保存失败时页面保持原样、报告失败并保留草稿供用户修改。重置暂存的是组装默认值而非立即写入;字段不接受的草稿会阻塞保存,而不是被丢弃。值是否被接受,唯一的裁判是 Host。 - -**Subagent** 卡片将委派限制与模型选择放在同一个页面中,共用一个保存按钮。**最大递归深度**和 **Subagent 并行数量上限**并排呈现,窄屏时上下排列。信息按钮按需展开两行深度示例表和共享计数规则;校验错误仍直接显示在输入框下。深度保留工具显式覆盖。总数统计所有递归层级中存活的可续接 Subagent,包含等待中的 Subagent,不计入主 Agent、一次性运行和外部提供方。保存后用于后续委派;调低上限不会停止已有 Subagent。 - -模型选择分区会同时暂存其权限开关与精确模型复选框。启用时必须至少选择一条适配器路由。保存会在一次 mutation 中提交 `enabled` 与 `allowedModels`,并以草稿开始时的 revision 设栅;Host revision 更新后,草稿会标记为失败,而不会恢复已撤销的路由。关闭时会保留已选路由供以后重新使用。可用模型按提供方分组;当前目录中缺失的已存路由排在末尾,且仍可移除。适配器名称与模型描述仍属于实时目录元数据,不会存储;适配器变化、设置提交和重连后,卡片会刷新这些元数据。 - -保存 Subagent 卡片时会校验两个分区,并通过各自原有命名空间写入草稿。这些写入相互独立:若其中一项失败,页面保持打开并保留该草稿,重试仅写入剩余草稿。只要部署提供任一命名空间,页面就会出现,且仅显示可用分区。 - -### secret 角色字段 - -密钥控件初始为空、只报告是否已配置,并经由 credentials 领域而非 settings 分节写入;空草稿不写入任何东西,保留已存密钥。 +要贡献一个标签页,带 `id`、`order` 和本地化的 `label` 注册进 `settings.plugins.tab`;分区按序渲染条目,标签页在首次被选中时挂载。功能文案留在注册方自己的字典里。 ----- @@ -53,15 +37,7 @@ kind: "package-reference"
实现细节——点击展开 -本包是一条注册规则加一条写入路径:每个页面在其命名空间被服务期间注册;保存经由客户端 settings scope。 - -### 注册规则 - -本分区声明根级列表 slot `settings.plugins.tab`,其标签会成为有序标签页;只有一个贡献时它直接显示为页面本身,某个标签页首次被选择后会保持挂载,因此搜索与清单快照在切换时不会丢失。配置页是 `plugins.item` 注册项,每个命名空间一个:共享的 settings 镜像表明 Host 服务该命名空间时通过 `ctx.slots.inject` 注册,停止服务时销毁;注册顺序就是页面顺序,而不是 Host 的描述顺序——后者跟随插件激活,可能在两次启动之间变化。页面拥有自己的控件与文案;插件页负责画标题、图标与面包屑。 - -### 写入路径 - -保存时,暂存字段通过客户端 settings scope 写入;每次单字段写入或有序 mutation 都以草稿读取时的命名空间 revision 设栅,因此已与文档脱节的表单会被拒绝,而不是覆盖并发变更。字段是否被覆盖,取决于它是否出现在原始用户层中,而非取决于它的值;重置会清除该字段,使其重新继承组装层。secret 角色的字段绝不搭乘响应;页面会在转发来的 `credentials/reference-updated` 事件到来时重读它所关注的引用。 +分区声明 `settings.plugins.tab`,一个根级 list slot,其标签成为有序的标签页;只有一个贡献时直接渲染为页面本身,标签页在首次被选中后保持挂载,搜索词和清单快照因此在切换间不丢失。分区的 `inject` 把 slot 账本投影成按序排列、标签随当前语言的行,在账本版本或语言修订变化前保持缓存。宿主半侧是一个空的 `apply`,只为让本包占一条 Loader 行,客户端模块系统据此送出浏览器半侧。
@@ -70,46 +46,35 @@ kind: "package-reference" ## 进一步探索 -以下页面覆盖插件页、设置底座、清单标签页与表单背后的持久化 seam。 - -- [ui-plugin-manager](../ui-plugin-manager/README.zh.md)——侧栏插件页,其 `plugins.item`、`plugins.bundle.config` 与 `plugins.row.config` slot 承载配置页。 -- [ui-settings](../ui-settings/README.zh.md)——声明 `settings.section` 与 settings scope 的领域底座。 -- [ui-settings-plugin-inventory](../ui-settings-plugin-inventory/README.zh.md)——分区显示的只读插件列表。 -- [settings](../../settings/README.zh.md)——持久化用户设置 seam 及其文件提供方。 -- [credentials](../../credentials/README.zh.md)——secret 字段写入所经的凭据引用 seam。 -- [ui-settings-general](../ui-settings-general/README.zh.md)——承载本分区的设置外壳。 +- [ui-settings-plugin-inventory](../ui-settings-plugin-inventory/README.zh.md)——只读清单标签页。 +- [ui-settings](../ui-settings/README.zh.md)——声明 `settings.section` 的领域基座。 +- [ui-plugin-manager](../ui-plugin-manager/README.zh.md)——配置官方插件的插件页。 +- [ui-settings-shell](../ui-settings-shell/README.zh.md)、[ui-settings-agent-loop](../ui-settings-agent-loop/README.zh.md)、[ui-settings-subagent](../ui-settings-subagent/README.zh.md)、[ui-settings-web-search](../ui-settings-web-search/README.zh.md)——官方配置页,每个一个伴生包。 ----- ## 模型体验 -无。该包是浏览器端设置界面,不注册任何面向模型的接口。 +无,本包是浏览器侧的设置界面,不注册任何模型面。 -#### KV Cache 影响 +#### KV 缓存影响 -无;该包既不组装也不发送提供方请求。 +无;本包既不组装也不发送提供方请求。 ## 已知限制与延期工作 - -这些限制定义哪些插件会有页面、分组有多新鲜;它们是当前包约束。 - -- **只有宿主平面的插件有页面**:由 agent preset 挂载的插件把配置内联在该 preset 的 `agent.cordis.yml` 中,且根本无法注册 settings 命名空间,因此本包不会为它注册任何东西。编辑那些值仍是 preset 编辑器的职责。 -- **页面仍然需要一份浏览器 bundle**:浏览器半侧必须是按客户端模块系统的 lazy-CJS factory 格式构建的 `dsh.client` 包,而产出它的 `clientBundle` 预设位于 `../../../packages/client/tsdown.client.ts`,并非已发布的包,因此本仓库之外的插件得自行复刻该构建。 -- **被服务的命名空间只在两种信号上重读**:协议通告的是 settings 文档提交与连接重置,而非注册行为,因此在镜像读取之后才被其拥有方注册的命名空间,要等下一次文档提交或重连才会加入官方分组。 -- **shell 页面跟随被组装的执行器**:POSIX 与 PowerShell 两个执行器家族共用 `shell` 命名空间,因为一个宿主只组装其中之一,所以被服务的 schema 随平台不同(PowerShell 多出 `pwshPath`),尽管页面在两者下编辑的都是同样两个字段。 +- **分区没有自己的标签页**——在功能插件注册标签页之前它只显示空提示;壳自己填不满分区。 +- **运行时不变量:**不发布伴生。分区除了投影 slot 账本之外不拥有任何关系。 ### 开发备注
-维护者的工作上下文——点击展开 +维护者工作上下文——点击展开 无。
- -**运行时不变式:** 不发布伴生入口。这是浏览器端设置界面,node half 不持有事件流或可变运行时数据;分层与写入拒绝是 Host 约定,由相应插件和 api-proxy 覆盖。 diff --git a/packages/client/ui-settings-plugins/package.json b/packages/client/ui-settings-plugins/package.json index 0639c80393..e1346f298b 100644 --- a/packages/client/ui-settings-plugins/package.json +++ b/packages/client/ui-settings-plugins/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-client-ui-settings-plugins", - "description": "Plugins settings section with feature-owned tabs and configurable host-plane plugin cards", + "description": "Built-in plugins settings section for the dsh web client: the Settings navigation entry and the tab chrome feature-owned tabs register into", "version": "0.1.6-alpha.1", "publishConfig": { "access": "public" @@ -29,8 +29,7 @@ "client": { "inject": [ "@deepseek-ai/dsh-client-locale", - "@deepseek-ai/dsh-client-ui-settings", - "@deepseek-ai/dsh-api-remotes" + "@deepseek-ai/dsh-client-ui-settings" ], "platform": "web" } @@ -45,17 +44,12 @@ }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", - "@deepseek-ai/dsh-client-ui-plugin-manager": "workspace:^", - "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@types/react": "~18.3.1", - "clsx": "^2.0.0", "react": "^18.2.0" }, "files": [ diff --git a/packages/client/ui-settings-plugins/src/client/bash-card-controller.ts b/packages/client/ui-settings-plugins/src/client/bash-card-controller.ts deleted file mode 100644 index 3ad8c6fefe..0000000000 --- a/packages/client/ui-settings-plugins/src/client/bash-card-controller.ts +++ /dev/null @@ -1,64 +0,0 @@ -/** The shell card's staged form over the `bash` settings namespace. */ - -import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' -import type { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client' -import { CardForm, numberField, type CardActions, type CardFieldState, type CardShell } from './card-form.ts' - -/** - * Namespace of the shell capability. Spelled here rather than imported: a - * client package must not depend on a Host package, and the executor families - * that own it spell the same value. - */ -export const SHELL_NS = 'shell' - -/** The shell fields this card edits — a subset of the served schema by design. */ -export interface BashSettings { - /** Foreground command timeout in milliseconds. */ - timeoutMs?: number - /** Per-stream in-memory output cap in bytes. */ - maxOutputBytes?: number -} - -/** What the shell card renders. */ -export interface BashCardState extends CardShell { - /** Command timeout in milliseconds. */ - timeoutMs: CardFieldState - /** Per-stream output cap in bytes. */ - maxOutputBytes: CardFieldState -} - -/** The registration-side face the shell card's slot entry injects. */ -export interface BashCardFace extends CardActions { - hooks: { - /** Card snapshot bound by the renderer as useBashCard. */ - bashCard: SnapshotStore - } -} - -/** Bridges the `bash` scope onto the shell card's staged form. */ -export class BashCardController { - private readonly form: CardForm - private readonly store: SnapshotStore - - /** @param scope - the bound settings scope for the `bash` namespace. */ - constructor(scope: SettingsScope) { - this.form = new CardForm(scope, [numberField('timeoutMs'), numberField('maxOutputBytes')]) - this.store = this.form.bind(() => this.projection()) - } - - private projection(): BashCardState { - return { - ...this.form.shell(), - timeoutMs: this.form.field('timeoutMs'), - maxOutputBytes: this.form.field('maxOutputBytes'), - } - } - - /** - * Build the face the card's slot registration injects. - * @returns the card's snapshot and its form actions. - */ - inject(): BashCardFace { - return { hooks: { bashCard: this.store }, ...this.form.actions() } - } -} diff --git a/packages/client/ui-settings-plugins/src/client/index.ts b/packages/client/ui-settings-plugins/src/client/index.ts index 7eccded6d6..1a4f2feff0 100644 --- a/packages/client/ui-settings-plugins/src/client/index.ts +++ b/packages/client/ui-settings-plugins/src/client/index.ts @@ -1,152 +1,41 @@ /** - * Built-in plugins settings surface and the official plugin configuration - * pages, browser half. The Settings section is the shell around the + * Built-in plugins settings section, browser half: the shell around the * feature-owned tabs registered into `settings.plugins.tab` (the read-only - * inventory ships one); the configuration pages this package ships register - * into the Plugins page's `plugins.item` slot, for the host-plane namespaces - * the deployment exposes, and appear in the page's Official group. Each form - * binds its namespace through the client settings scope, which keeps the - * pages unaware of one another and of the section. + * inventory ships one). The configuration pages of the host-plane plugins + * live in their own companion packages, which register into the Plugins + * page; this section owns the Settings navigation entry and the tab chrome + * only. */ // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' -// Type-only: the settings shell's SlotMap merge (the 'settings.section' entry) -// and the ctx.settingsScope Context merge. Cross-plugin collaboration goes -// through the service, never a value import (client bundle purity gate). +// Type-only: the settings shell's SlotMap merge (the 'settings.section' +// entry). Cross-plugin collaboration goes through slots, never a value import +// (client bundle purity gate). import type {} from '@deepseek-ai/dsh-client-ui-settings/client' -// Type-only: the Plugins page's SlotMap merge (the 'plugins.item' entry). -import type {} from '@deepseek-ai/dsh-client-ui-plugin-manager/client' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import type { Context as ClientContext } from '@deepseek-ai/cordis' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' -// Type-only: the ctx.remote Context merge and the forwarded-event key face. -import type {} from '@deepseek-ai/dsh-api-remotes/client' -import { AgentLoopCard } from './AgentLoopCard.tsx' -import { BashCard } from './BashCard.tsx' import { PluginsSettingsSection } from './PluginsSettingsSection.tsx' import type { PluginsSettingsSectionInjected, PluginsSettingsTabEntry } from './PluginsSettingsSection.tsx' -import { SubagentCard } from './SubagentCard.tsx' -import { subagentCardFace } from './subagent-card-controller.ts' -import { SubagentLimitsCardController } from './subagent-limits-card-controller.ts' -import { WebSearchCard } from './WebSearchCard.tsx' -import { AGENT_LOOP_NS, AgentLoopCardController } from './agent-loop-card-controller.ts' -import { SHELL_NS, BashCardController } from './bash-card-controller.ts' -import { - SUBAGENT_MODEL_SELECTION_NS, SubagentModelSelectionCardController, -} from './subagent-model-selection-card-controller.ts' -import { WEB_SEARCH_NS, WebSearchCardController } from './web-search-card-controller.ts' import { en, zh } from './locales.ts' export type { PluginsSettingsSectionInjected, PluginsSettingsSectionProps } from './PluginsSettingsSection.tsx' -export type { PluginConfigFormProps } from './PluginConfigForm.tsx' -export type { FieldProps } from './fields.tsx' -export type { - CardActions, CardFieldSpec, CardFieldState, CardSecretSpec, CardShell, -} from './card-form.ts' -export type { AgentLoopCardFace, AgentLoopCardState } from './agent-loop-card-controller.ts' -export type { BashCardFace, BashCardState } from './bash-card-controller.ts' -export type { WebSearchCardFace, WebSearchCardState } from './web-search-card-controller.ts' /** Dictionary namespace owned by this plugin. */ const NS = 'settings.plugins' /** Required services (cordis fiber inject). */ -export const inject = [ - 'slots', 'locale', 'remote', 'remote.credentials', 'remote.session', 'settingsScope', -] +export const inject = ['slots', 'locale'] /** - * Mount the built-in plugins section and the configuration pages this package ships. + * Mount the built-in plugins section. * @param ctx - the browser plugin context. */ export function apply(ctx: ClientContext): void { const t = ctx.locale.bind(NS) ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-settings-plugins: section dictionaries') - const bash = new BashCardController(ctx.settingsScope.bind({ namespace: SHELL_NS })) - const agentLoop = new AgentLoopCardController(ctx.settingsScope.bind({ namespace: AGENT_LOOP_NS })) - const webSearch = new WebSearchCardController( - ctx.settingsScope.bind({ namespace: WEB_SEARCH_NS }), ctx) - const subagentLimits = new SubagentLimitsCardController(ctx.settingsScope.bind({ namespace: 'subagent' })) - const subagentModelSelection = new SubagentModelSelectionCardController( - ctx.settingsScope.bind({ namespace: SUBAGENT_MODEL_SELECTION_NS }), - ctx, - ) - const subagentLimitsFace = subagentLimits.inject() - const subagentModelsFace = subagentModelSelection.inject() - - // The credential a page reports is not part of any settings section, so its - // scope publishes nothing when one is written. This is the only signal that - // a key written on another surface reached the Host. - ctx.effect( - () => ctx.remote.$on('credentials/reference-updated', (ref) => { webSearch.refreshCredential(ref) }), - 'ui-settings-plugins: credential invalidations', - ) - ctx.effect( - () => ctx.remote.$on('llm/adapters-updated', () => { subagentModelSelection.refreshCatalog() }), - 'ui-settings-plugins: subagent adapter invalidations', - ) - ctx.effect( - () => ctx.remote.$on('settings/document-updated', () => { subagentModelSelection.refreshCatalog() }), - 'ui-settings-plugins: subagent settings invalidations', - ) - ctx.effect( - () => ctx.on('connection/reset', () => { subagentModelSelection.resetConnection() }), - 'ui-settings-plugins: subagent connection generation', - ) - ctx.effect(() => () => { subagentModelSelection.dispose() }, 'ui-settings-plugins: subagent preference') - - // Configuration pages register while the Host serves their namespaces. - // A deployment without those plugins shows no trace of them. Card registration order is the page order, not the - // Host's description order, which follows plugin activation and can change - // between boots. - const pages: ReadonlyArray () => void]> = [ - [[SHELL_NS], () => ctx.slots.inject('plugins.item', () => ctx.slots.register({ - name: 'plugins.item', id: 'bash', order: 10, label: () => t('bashTitle'), locale: NS, inject: () => bash.inject(), - }, BashCard))], - [[AGENT_LOOP_NS], () => ctx.slots.inject('plugins.item', () => ctx.slots.register({ - name: 'plugins.item', id: 'agent-loop', order: 20, label: () => t('agentLoopTitle'), locale: NS, inject: () => agentLoop.inject(), - }, AgentLoopCard))], - [['subagent', SUBAGENT_MODEL_SELECTION_NS], () => ctx.slots.inject('plugins.item', () => ctx.slots.register({ - name: 'plugins.item', - id: 'subagent', - order: 30, - label: () => t('subagentTitle'), - locale: NS, - inject: () => subagentCardFace(subagentLimitsFace, subagentModelsFace), - }, SubagentCard))], - [[WEB_SEARCH_NS], () => ctx.slots.inject('plugins.item', () => ctx.slots.register({ - name: 'plugins.item', id: 'web-search', order: 40, label: () => t('webSearchTitle'), locale: NS, inject: () => webSearch.inject(), - }, WebSearchCard))], - ] - // The shared SettingsScope mirror updates after document commits and reconnects. - const describeFace = ctx.settingsScope.describe() - ctx.effect(() => { - const registered = new Map void>() - const sync = (): void => { - const served = new Set(describeFace.getSnapshot().view?.namespaces.map(view => view.ns) ?? []) - for (const [namespaces, register] of pages) { - const namespace = namespaces[0] - const available = namespaces.some(namespace => served.has(namespace)) - const off = registered.get(namespace) - if (available && off === undefined) registered.set(namespace, register()) - else if (!available && off !== undefined) { - off() - registered.delete(namespace) - } - } - } - const unsubscribe = describeFace.subscribe(sync) - void describeFace.ensure() - sync() - return () => { - unsubscribe() - for (const off of registered.values()) off() - registered.clear() - } - }, 'ui-settings-plugins: configuration pages') - let tabsVersion = -1 let tabsRevision = -1 let tabs: readonly PluginsSettingsTabEntry[] = [] diff --git a/packages/client/ui-settings-plugins/src/client/locales.ts b/packages/client/ui-settings-plugins/src/client/locales.ts index e45dbb5cd0..0e82c56625 100644 --- a/packages/client/ui-settings-plugins/src/client/locales.ts +++ b/packages/client/ui-settings-plugins/src/client/locales.ts @@ -1,30 +1,7 @@ -/** Locale bundles for the built-in plugins settings section and the plugin configuration pages. */ +/** Locale bundles for the built-in plugins settings section. */ -/** Locale keys these surfaces render. */ -export type PluginsSettingsLocaleKey = - | 'nav' | 'title' | 'intro' | 'tabs' | 'empty' - | 'overridden' | 'reset' | 'readOnly' | 'unavailable' - | 'save' | 'saving' | 'saveFailed' | 'invalidNumber' - | 'bashTitle' | 'bashDescription' | 'bashTimeoutMs' | 'bashTimeoutMsHint' - | 'bashMaxOutputBytes' | 'bashMaxOutputBytesHint' - | 'agentLoopTitle' | 'agentLoopDescription' | 'agentLoopMaxParallel' | 'agentLoopMaxParallelHint' - | 'webSearchTitle' | 'webSearchDescription' - | 'webSearchApiKey' | 'webSearchApiKeyHint' | 'webSearchApiKeySet' | 'webSearchApiKeyUnset' - | 'webSearchBaseUrl' | 'webSearchBaseUrlHint' | 'webSearchMaxUses' | 'webSearchMaxUsesHint' - | 'subagentTitle' | 'subagentDescription' | 'subagentLimitsTitle' - | 'subagentMaxDepth' - | 'subagentDepthHelpLabel' | 'subagentDepthHelp' - | 'subagentDepthZero' | 'subagentDepthOne' | 'subagentDepthOverride' - | 'subagentMaxActive' - | 'subagentCapacityHelpLabel' | 'subagentCapacityHelp' - | 'subagentDepthInvalid' - | 'subagentCapacityInvalid' - | 'subagentModelSelectionTitle' - | 'subagentModelSelectionToggle' | 'subagentModelSelectionChoose' | 'subagentModelSelectionAllowed' - | 'subagentModelSelectionLoading' | 'subagentModelSelectionLoadFailed' | 'subagentModelSelectionRetry' - | 'subagentModelSelectionPartial' | 'subagentModelSelectionUnavailable' - | 'subagentModelSelectionUnavailableGroup' | 'subagentModelSelectionEmpty' - | 'subagentModelSelectionRequired' | 'subagentModelSelectionConflict' | 'subagentModelSelectionOff' +/** Locale keys the section renders. */ +export type PluginsSettingsLocaleKey = 'nav' | 'title' | 'intro' | 'tabs' | 'empty' /** English copy. */ export const en: Record = { @@ -33,62 +10,6 @@ export const en: Record = { intro: 'Inspect the plugins this deployment ships.', tabs: 'Plugin views', empty: 'This deployment exposes no plugin views.', - overridden: 'Overridden', - reset: 'Reset to default', - readOnly: 'This deployment stores settings read-only.', - unavailable: 'This plugin is not loaded, so it cannot be configured right now.', - save: 'Save', - saving: 'Saving…', - saveFailed: 'The deployment did not accept these values; they were left for you to correct.', - invalidNumber: 'Enter a number, or leave blank to use the default.', - bashTitle: 'Shell', - bashDescription: 'Limits every command the agent runs.', - bashTimeoutMs: 'Command timeout (ms)', - bashTimeoutMsHint: 'How long one command may run before it is terminated.', - bashMaxOutputBytes: 'Output cap per stream (bytes)', - bashMaxOutputBytesHint: 'Output beyond this spills to a temporary file rather than being lost.', - agentLoopTitle: 'Agent loop', - agentLoopDescription: 'How the agent dispatches tool calls.', - agentLoopMaxParallel: 'Parallel tool calls', - agentLoopMaxParallelHint: 'Upper bound on parallel-safe calls running at once within one step.', - webSearchTitle: 'Web search', - webSearchDescription: 'The DeepSeek search provider.', - webSearchApiKey: 'API key', - webSearchApiKeyHint: 'Stored outside the settings file. Leave blank to keep the current key.', - webSearchApiKeySet: 'A key is configured.', - webSearchApiKeyUnset: 'No key is configured; search is unavailable until one is.', - webSearchBaseUrl: 'Endpoint', - webSearchBaseUrlHint: 'Leave blank to use the provider default.', - webSearchMaxUses: 'Max searches per request', - webSearchMaxUsesHint: 'How many times one request may search before it must answer.', - subagentTitle: 'Subagent', - subagentDescription: 'Set Subagent recursion depth, count, and models.', - subagentLimitsTitle: 'Limits', - subagentMaxDepth: 'Maximum recursion depth', - subagentDepthHelpLabel: 'About maximum recursion depth', - subagentDepthHelp: 'Limits how many levels of Subagents an Agent can create.', - subagentDepthZero: 'Disable Subagents', - subagentDepthOne: 'Only the main Agent can create Subagents', - subagentDepthOverride: 'If a tool defines its own maximum recursion depth, that setting takes precedence.', - subagentMaxActive: 'Subagent parallelism limit', - subagentCapacityHelpLabel: 'About the Subagent parallelism limit', - subagentCapacityHelp: 'Total live Subagents under the same main Agent, across all recursion levels. The main Agent is excluded. New start requests are rejected when the limit is reached.', - subagentDepthInvalid: 'Enter a whole number of 0 or more.', - subagentCapacityInvalid: 'Enter a whole number of 1 or more.', - subagentModelSelectionTitle: 'Model selection', - subagentModelSelectionToggle: 'Allow agents to choose models for Subagents', - subagentModelSelectionChoose: 'When enabled, agents can choose a provider, model, and reasoning effort for each Subagent from the authorized models below. Applies only to new sessions.', - subagentModelSelectionAllowed: 'Models agents may choose', - subagentModelSelectionLoading: 'Loading models…', - subagentModelSelectionLoadFailed: 'Models could not be loaded.', - subagentModelSelectionRetry: 'Retry', - subagentModelSelectionPartial: 'Some model providers could not be loaded; saved choices remain removable.', - subagentModelSelectionUnavailable: 'Currently unavailable', - subagentModelSelectionUnavailableGroup: 'Saved but currently unavailable', - subagentModelSelectionEmpty: 'No model provider currently advertises a model.', - subagentModelSelectionRequired: 'Select at least one model before saving.', - subagentModelSelectionConflict: 'Settings changed elsewhere. Discard your draft and try again.', - subagentModelSelectionOff: 'Subagents use configured defaults or inherit the parent agent\'s model. Saved model choices are retained.', } /** Simplified Chinese copy. */ @@ -98,60 +19,4 @@ export const zh: Record = { intro: '查看内置部署的插件列表', tabs: '插件视图', empty: '本部署没有开放任何插件视图。', - overridden: '已覆盖', - reset: '恢复默认', - readOnly: '本部署的设置为只读。', - unavailable: '该插件当前未加载,暂时无法配置。', - save: '保存', - saving: '保存中…', - saveFailed: '本部署没有接受这些值,已保留供你修改。', - invalidNumber: '请填数字;留空表示使用默认值。', - bashTitle: '终端', - bashDescription: '限制 agent 运行的每一条命令。', - bashTimeoutMs: '命令超时(毫秒)', - bashTimeoutMsHint: '单条命令允许运行多久,超时即终止。', - bashMaxOutputBytes: '单流输出上限(字节)', - bashMaxOutputBytesHint: '超出部分会转存到临时文件,而不是被丢弃。', - agentLoopTitle: 'Agent 循环', - agentLoopDescription: 'Agent 如何派发工具调用。', - agentLoopMaxParallel: '并行工具调用数', - agentLoopMaxParallelHint: '同一步内最多同时运行多少个可并行的调用。', - webSearchTitle: '网页搜索', - webSearchDescription: 'DeepSeek 搜索提供方。', - webSearchApiKey: 'API Key', - webSearchApiKeyHint: '不写入设置文件。留空表示保持当前密钥。', - webSearchApiKeySet: '已配置密钥。', - webSearchApiKeyUnset: '未配置密钥;配置之前搜索不可用。', - webSearchBaseUrl: '接口地址', - webSearchBaseUrlHint: '留空则使用提供方默认地址。', - webSearchMaxUses: '单次请求最多搜索次数', - webSearchMaxUsesHint: '一次请求在必须作答前最多可以搜索多少次。', - subagentTitle: 'Subagent', - subagentDescription: '设置 Subagent 的递归层级、数量和模型。', - subagentLimitsTitle: '运行限制', - subagentMaxDepth: '最大递归深度', - subagentDepthHelpLabel: '最大递归深度说明', - subagentDepthHelp: '限制 Agent 创建 Subagent 的递归层级。', - subagentDepthZero: '禁用 Subagent', - subagentDepthOne: '仅允许主 Agent 创建 Subagent', - subagentDepthOverride: '如果某个工具单独设置了最大递归深度,以该工具的设置为准。', - subagentMaxActive: 'Subagent 并行数量上限', - subagentCapacityHelpLabel: 'Subagent 并行数量上限说明', - subagentCapacityHelp: '同一主 Agent 下,所有递归层级同时存活的 Subagent 总数,主 Agent 不计入。达到上限时,新的启动请求会被拒绝。', - subagentDepthInvalid: '请输入不小于 0 的整数。', - subagentCapacityInvalid: '请输入不小于 1 的整数。', - subagentModelSelectionTitle: '模型选择', - subagentModelSelectionToggle: '允许 Agent 为 Subagent 选择模型', - subagentModelSelectionChoose: '开启后,Agent 可以从下方授权模型中,为每个 Subagent 选择提供方、模型和推理强度。仅影响新会话。', - subagentModelSelectionAllowed: 'Agent 可选择的模型', - subagentModelSelectionLoading: '正在加载模型…', - subagentModelSelectionLoadFailed: '无法加载模型。', - subagentModelSelectionRetry: '重试', - subagentModelSelectionPartial: '部分模型提供方暂时无法加载;已保存的选择仍可移除。', - subagentModelSelectionUnavailable: '当前不可用', - subagentModelSelectionUnavailableGroup: '已保存但当前不可用', - subagentModelSelectionEmpty: '当前没有模型提供方公布模型。', - subagentModelSelectionRequired: '保存前请至少选择一个模型。', - subagentModelSelectionConflict: '设置已在其他位置更新。请放弃修改后重试。', - subagentModelSelectionOff: '关闭后,Subagent 使用配置的默认模型或继承父 Agent 的模型;已选模型会保留。', } diff --git a/packages/client/ui-settings-plugins/tests/apply.client.spec.ts b/packages/client/ui-settings-plugins/tests/apply.client.spec.ts index 8b24c35968..7581245ddb 100644 --- a/packages/client/ui-settings-plugins/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-plugins/tests/apply.client.spec.ts @@ -1,68 +1,32 @@ /** What the browser half registers, and that it all leaves with the fiber. */ import { Context } from '@deepseek-ai/cordis' -import { describe, expect, it, onTestFinished, vi } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' -import { RemoteError, TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' -import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client' import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings-plugins/client' import type { PluginsSettingsSectionInjected } from '@deepseek-ai/dsh-client-ui-settings-plugins/client' -import { SubagentModelSelectionCardController } from '../src/client/subagent-model-selection-card-controller.ts' import { apply as hostApply } from '../src/index.ts' // These specs assert the shipped Chinese copy. The lane has no jsdom `window`, // so browser-language detection never runs and a fresh LocaleRuntime opens on // FALLBACK_LOCALE (en); bench stages zh explicitly on the locale instead. -/** - * @param served - namespaces the Host describes; omitted answers a failed read, - * which is what most of these specs want (no card has anything to render). - */ -async function bench(served?: string[]) { +async function bench() { const ctx = new Context() await ctx.plugin(SlotRegistry).await() const locale = new LocaleRuntime(ctx) locale.setLocale('zh') ctx.provide('locale', locale) - const describeCredentials = vi.fn(() => Promise.resolve({ - ok: false, error: new RemoteError('gateway/internal', 'no provider', {}), - })) - const models = vi.fn(() => Promise.resolve({ - ok: true as const, value: { groups: [], failures: [] }, - })) - const describeSettings = vi.fn(() => Promise.resolve(served === undefined - ? { ok: false, error: new RemoteError('gateway/internal', 'no provider', {}) } - : { - ok: true, - value: { - writable: true, - hasDocument: true, - namespaces: served.map(ns => ({ - ns, schema: {}, value: {}, applies: 'live', secrets: [], revision: 0, - })), - }, - })) - const remote = new TestRemote(ctx, { - credentials: { describe: describeCredentials, set: vi.fn() }, - session: { modelCatalog: models }, - settings: { describe: describeSettings }, - }) - await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() - return { - ctx, slots: ctx.get('slots') as SlotRegistry, describeCredentials, describeSettings, models, remote, - } + return { ctx, slots: ctx.get('slots') as SlotRegistry } } -/** The Settings shell's section slot and the Plugins page's item slot, as the two owners declare them. */ +/** The Settings shell's section slot, as its owner declares it. */ function declareRoot(slots: SlotRegistry): () => void { return slots.register({ name: 'root', - children: { - 'settings.section': { kind: 'list', scope: 'root' }, - 'plugins.item': { kind: 'list', scope: 'root' }, - }, + children: { 'settings.section': { kind: 'list', scope: 'root' } }, } as never, () => null) } @@ -72,9 +36,7 @@ describe('ui-settings-plugins apply', () => { }) it('declares the services it uses', () => { - expect(inject).toEqual([ - 'slots', 'locale', 'remote', 'remote.credentials', 'remote.session', 'settingsScope', - ]) + expect(inject).toEqual(['slots', 'locale']) }) it('registers one Built-in plugins section and declares its tab slot, contributing no tab of its own', async () => { @@ -91,8 +53,8 @@ describe('ui-settings-plugins apply', () => { expect(slots.entries('settings.plugins.tab')).toHaveLength(0) }) - it('injects a live tab projection and one business face per configuration page', async () => { - const { ctx, slots } = await bench(['shell', 'agent-loop', 'subagent', 'subagent-model-selection', 'web-search-deepseek']) + it('injects a live tab projection ordered by the contributions', async () => { + const { ctx, slots } = await bench() declareRoot(slots) await ctx.plugin({ inject: [...inject], apply }).await() @@ -102,8 +64,7 @@ describe('ui-settings-plugins apply', () => { expect(initialTabs).toEqual([]) expect(sectionFace.hooks.tabs.getSnapshot()).toBe(initialTabs) - const listener = vi.fn() - const unsubscribe = sectionFace.hooks.tabs.subscribe(listener) + const unsubscribe = sectionFace.hooks.tabs.subscribe(vi.fn()) slots.register({ name: 'settings.plugins.tab', id: 'plain' } as never, () => null) slots.register({ name: 'settings.plugins.tab', id: 'first', order: -1 } as never, () => null) // Tabs follow their contribution's order, whatever order they registered in. @@ -112,151 +73,6 @@ describe('ui-settings-plugins apply', () => { { id: 'plain', order: 0, label: '' }, ]) unsubscribe() - - await vi.waitFor(() => { expect(slots.entries('plugins.item')).toHaveLength(4) }) - for (const entry of slots.entries('plugins.item')) { - const face = (entry as { inject?: () => unknown }).inject?.() as { hooks: Record } - expect(Object.keys(face.hooks)).toHaveLength(entry.options.id === 'subagent' ? 2 : 1) - } - }) - - it('registers one configuration page per served namespace, in its own order, titled in the active locale', async () => { - const { ctx, slots } = await bench(['web-search-deepseek', 'shell', 'agent-loop', 'subagent-model-selection']) - declareRoot(slots) - - await ctx.plugin({ inject: [...inject], apply }).await() - - await vi.waitFor(() => { expect(slots.entries('plugins.item')).toHaveLength(4) }) - const entries = slots.entries('plugins.item') - expect(entries.map(entry => entry.options.id)).toEqual(['bash', 'agent-loop', 'subagent', 'web-search']) - expect(entries.map(entry => resolveSlotLabel(entry.options.label))).toEqual(['终端', 'Agent 循环', 'Subagent', '网页搜索']) - expect(entries.every(entry => entry.locale === 'settings.plugins')).toBe(true) - }) - - it.each([ - ['subagent'], - ['subagent-model-selection'], - ['subagent', 'subagent-model-selection'], - ])('keeps one Subagent page while either namespace is served: %j', async (...served) => { - const { ctx, slots, describeSettings, remote } = await bench(served) - onTestFinished(() => ctx.fiber.dispose()) - declareRoot(slots) - await ctx.plugin({ inject: [...inject], apply }).await() - await vi.waitFor(() => { - expect(slots.entries('plugins.item').map(entry => entry.options.id)).toEqual(['subagent']) - }) - const entry = slots.entries('plugins.item')[0] - for (const namespaces of [['subagent-model-selection'], ['subagent'], []]) { - describeSettings.mockResolvedValue({ - ok: true, - value: { - writable: true, hasDocument: true, - namespaces: namespaces.map(ns => ({ ns, schema: {}, value: {}, applies: 'live', secrets: [], revision: 1 })), - }, - }) - remote.emit('settings/document-updated', ['subagent', 1]) - await vi.waitFor(() => { - expect(ctx.settingsScope.describe().getSnapshot().view?.namespaces.map(view => view.ns)).toEqual(namespaces) - expect(slots.entries('plugins.item')).toEqual(namespaces.length > 0 ? [entry] : []) - }) - } - }) - - it('registers the pages of the served namespaces only, and withdraws one the Host stops serving', async () => { - // ui-theme is served but belongs to another surface, and a deployment - // composing no PowerShell/POSIX executor serves no `bash` at all. - const { ctx, slots, describeSettings, remote } = await bench(['agent-loop', 'ui-theme', 'web-search-deepseek']) - declareRoot(slots) - await ctx.plugin({ inject: [...inject], apply }).await() - - await vi.waitFor(() => { - expect(slots.entries('plugins.item').map(entry => entry.options.id)).toEqual(['agent-loop', 'web-search']) - }) - - describeSettings.mockResolvedValue({ - ok: true, - value: { - writable: true, - hasDocument: true, - namespaces: [{ ns: 'agent-loop', schema: {}, value: {}, applies: 'live', secrets: [], revision: 1 }], - }, - }) - remote.emit('settings/document-updated', ['web-search-deepseek', 1]) - - await vi.waitFor(() => { - expect(slots.entries('plugins.item').map(entry => entry.options.id)).toEqual(['agent-loop']) - }) - }) - - it('re-reads the served namespaces when the Host commits a settings document', async () => { - // Which namespaces the Host serves is a registration fact the wire never - // announces on its own, so the tab rides the invalidation that can - // accompany a changed composition. - const { ctx, slots, describeSettings, remote } = await bench(['bash']) - declareRoot(slots) - await ctx.plugin({ inject: [...inject], apply }).await() - await vi.waitFor(() => { expect(describeSettings).toHaveBeenCalled() }) - describeSettings.mockClear() - - remote.emit('settings/document-updated', ['bash', 1]) - - await vi.waitFor(() => { expect(describeSettings).toHaveBeenCalled() }) - }) - - it('re-reads the served namespaces after a reconnect', async () => { - const { ctx, slots, describeSettings } = await bench(['bash']) - declareRoot(slots) - await ctx.plugin({ inject: [...inject], apply }).await() - await vi.waitFor(() => { expect(describeSettings).toHaveBeenCalled() }) - describeSettings.mockClear() - - ctx.emit('connection/reset') - - await vi.waitFor(() => { expect(describeSettings).toHaveBeenCalled() }) - }) - - it('re-reads the credential when the Host reports the watched reference changed', async () => { - const { ctx, slots, describeCredentials, remote } = await bench() - declareRoot(slots) - await ctx.plugin({ inject: [...inject], apply }).await() - await vi.waitFor(() => { expect(describeCredentials).toHaveBeenCalled() }) - describeCredentials.mockClear() - - // A key written on another surface changes no settings section, so this - // event is the only thing that reaches the card. - remote.emit('credentials/reference-updated', ['DEEPSEEK_API_KEY']) - - await vi.waitFor(() => { expect(describeCredentials).toHaveBeenCalledTimes(1) }) - }) - - it('refreshes the subagent catalog after model inputs change or the connection resets', async () => { - const refresh = vi.spyOn(SubagentModelSelectionCardController.prototype, 'refreshCatalog') - const reset = vi.spyOn(SubagentModelSelectionCardController.prototype, 'resetConnection') - const { ctx, slots, remote } = await bench(['subagent-model-selection']) - declareRoot(slots) - await ctx.plugin({ inject: [...inject], apply }).await() - refresh.mockClear() - reset.mockClear() - - remote.emit('llm/adapters-updated', []) - expect(refresh).toHaveBeenCalledTimes(1) - remote.emit('settings/document-updated', ['llm-deepseek', 1]) - expect(refresh).toHaveBeenCalledTimes(2) - ctx.emit('connection/reset') - expect(reset).toHaveBeenCalledTimes(1) - }) - - it('ignores a credential change for a reference no card watches', async () => { - const { ctx, slots, describeCredentials, remote } = await bench() - declareRoot(slots) - await ctx.plugin({ inject: [...inject], apply }).await() - await vi.waitFor(() => { expect(describeCredentials).toHaveBeenCalled() }) - describeCredentials.mockClear() - - remote.emit('credentials/reference-updated', ['SOME_OTHER_KEY']) - await Promise.resolve() - - expect(describeCredentials).not.toHaveBeenCalled() }) it('registers into a declaration that arrives after apply', async () => { @@ -268,17 +84,15 @@ describe('ui-settings-plugins apply', () => { await vi.waitFor(() => { expect(slots.entries('settings.section')).toHaveLength(1) }) }) - it('collapses every contribution on teardown', async () => { - const { ctx, slots } = await bench(['shell', 'agent-loop']) + it('collapses the section on teardown', async () => { + const { ctx, slots } = await bench() declareRoot(slots) const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() - await vi.waitFor(() => { expect(slots.entries('plugins.item')).toHaveLength(2) }) + expect(slots.entries('settings.section')).toHaveLength(1) await fiber.dispose() expect(slots.entries('settings.section')).toHaveLength(0) - expect(slots.spec('settings.plugins.tab')).toBeUndefined() - expect(slots.entries('plugins.item')).toHaveLength(0) }) }) diff --git a/packages/client/ui-settings-plugins/tests/section.client.spec.tsx b/packages/client/ui-settings-plugins/tests/section.client.spec.tsx index 069e19f0bb..0907213a17 100644 --- a/packages/client/ui-settings-plugins/tests/section.client.spec.tsx +++ b/packages/client/ui-settings-plugins/tests/section.client.spec.tsx @@ -1,47 +1,16 @@ // @vitest-environment jsdom +/** The Built-in plugins section: its heading, its tab row, and which tab is mounted. */ -import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' -import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' -import { SubagentCard, type SubagentCardProps } from '../src/client/SubagentCard.tsx' -import type { SubagentLimitsCardState } from '../src/client/subagent-limits-card-controller.ts' -import { AgentLoopCard } from '../src/client/AgentLoopCard.tsx' -import type { AgentLoopCardProps } from '../src/client/AgentLoopCard.tsx' -import { BashCard } from '../src/client/BashCard.tsx' -import type { BashCardProps } from '../src/client/BashCard.tsx' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' import { PluginsSettingsSection } from '../src/client/PluginsSettingsSection.tsx' import type { PluginsSettingsSectionProps, PluginsSettingsTabEntry } from '../src/client/PluginsSettingsSection.tsx' -import { WebSearchCard } from '../src/client/WebSearchCard.tsx' -import type { WebSearchCardProps } from '../src/client/WebSearchCard.tsx' -import type { AgentLoopCardState } from '../src/client/agent-loop-card-controller.ts' -import type { BashCardState } from '../src/client/bash-card-controller.ts' -import type { CardFieldState, CardShell } from '../src/client/card-form.ts' -import type { WebSearchCardState } from '../src/client/web-search-card-controller.ts' -import type { SubagentModelSelectionCardState } from '../src/client/subagent-model-selection-card-controller.ts' import { en } from '../src/client/locales.ts' afterEach(cleanup) const t = (key: keyof typeof en) => en[key] -const settled: CardShell = { - available: true, - writable: true, - dirty: false, - invalid: false, - saving: false, - failed: false, -} - -function field(text: string, rest: Partial = {}): CardFieldState { - return { text, overridden: false, invalid: false, ...rest } -} - -function cardActions() { - return { edit: vi.fn(), resetField: vi.fn(), save: vi.fn(), discard: vi.fn() } -} - function renderSection(rows: readonly PluginsSettingsTabEntry[]) { const props = { t, @@ -53,70 +22,6 @@ function renderSection(rows: readonly PluginsSettingsTabEntry[]) { render() } -/** The Plugins page asks a configuration entry for its one-liner or its form; every page renders as `page` unless a test says otherwise. */ -type ConfigView = 'summary' | 'page' - -function renderBashCard(state: Partial = {}, view: ConfigView = 'page') { - const store = createSnapshotStore({ - ...settled, - timeoutMs: field('60000'), - maxOutputBytes: field('64000'), - ...state, - }) - const actions = cardActions() - const props = { ...actions, view, t, useBashCard: bindSnapshotSelector(store) } as unknown as BashCardProps - render() - return { actions, store } -} - -function renderBash(state: Partial = {}) { - return renderBashCard(state).actions -} - -function renderSubagent( - limitState: Partial = {}, - modelState: Partial = {}, - view: ConfigView = 'page', -) { - const limits = createSnapshotStore({ - ...settled, - maxDepth: field('3'), - maxActiveSubagents: field('8'), - ...limitState, - }) - const models = createSnapshotStore({ - ...settled, - enabled: false, - candidates: [], - catalogStatus: 'idle', - catalogPartial: false, - conflicted: false, - ...modelState, - }) - const actions = { - editLimit: vi.fn(), - resetLimit: vi.fn(), - toggleEnabled: vi.fn(), - toggleModel: vi.fn(), - retryCatalog: vi.fn(), - save: vi.fn(), - discard: vi.fn(), - } - const props = { - ...actions, - view, - t, - useSubagentLimitsCard: bindSnapshotSelector(limits), - useSubagentModelSelectionCard: bindSnapshotSelector(models), - } as unknown as SubagentCardProps - render() - return { actions, limits, models } -} - -function renderSubagentModelSelection(state: Partial = {}, view: ConfigView = 'page') { - return renderSubagent({ available: false }, state, view).actions -} - describe('PluginsSettingsSection', () => { it('says so when no plugin contributed a tab', () => { renderSection([]) @@ -186,453 +91,3 @@ describe('PluginsSettingsSection', () => { expect(configurable.getAttribute('aria-selected')).toBe('true') }) }) - -describe('BashCard', () => { - it('renders its one-liner alone in the summary view', () => { - renderBashCard({}, 'summary') - - expect(document.body.textContent).toBe(en.bashDescription) - expect(screen.queryByLabelText(en.bashTimeoutMs)).toBeNull() - }) - - it('says the plugin is not loaded in place of its fields while its namespace is unavailable', () => { - renderBash({ available: false }) - - expect(screen.getByRole('status').textContent).toBe(en.unavailable) - expect(screen.queryByLabelText(en.bashTimeoutMs)).toBeNull() - }) - - it('shows its fields at once on its page, without a title of its own', () => { - renderBash() - - expect(screen.getByLabelText(en.bashTimeoutMs)).toBeTruthy() - expect(screen.getByLabelText(en.bashMaxOutputBytes)).toBeTruthy() - expect(screen.queryByText(en.bashTitle)).toBeNull() - }) - - it('stages an edit instead of writing it', () => { - const actions = renderBash() - - fireEvent.change(screen.getByLabelText(en.bashTimeoutMs), { target: { value: '9000' } }) - - expect(actions.edit).toHaveBeenCalledWith('timeoutMs', '9000') - expect(actions.save).not.toHaveBeenCalled() - }) - - it('offers the reset for an overridden field only', () => { - const actions = renderBash({ timeoutMs: field('9000', { overridden: true }) }) - - // One badge and one reset: the output cap is still inherited. - expect(screen.getAllByText(en.overridden)).toHaveLength(1) - fireEvent.click(screen.getByRole('button', { name: en.reset })) - - expect(actions.resetField).toHaveBeenCalledWith('timeoutMs') - }) - - it('addresses each of its two fields separately', () => { - const actions = renderBash({ maxOutputBytes: field('64000', { overridden: true }) }) - - fireEvent.change(screen.getByLabelText(en.bashMaxOutputBytes), { target: { value: '1024' } }) - fireEvent.click(screen.getByRole('button', { name: en.reset })) - - expect(actions.edit).toHaveBeenCalledWith('maxOutputBytes', '1024') - expect(actions.resetField).toHaveBeenCalledWith('maxOutputBytes') - }) - - it('keeps the save inert until something is staged, and offers no discard', () => { - renderBash() - - expect(screen.getByRole('button', { name: en.save })).toHaveProperty('disabled', true) - expect(screen.getAllByRole('button')).toHaveLength(1) - }) - - it('writes the staged edits when saved', () => { - const actions = renderBash({ dirty: true, timeoutMs: field('9000', { overridden: true }) }) - - fireEvent.click(screen.getByRole('button', { name: en.save })) - - expect(actions.save).toHaveBeenCalledOnce() - expect(actions.discard).not.toHaveBeenCalled() - }) - - it('drops the staged edits when it leaves the page', () => { - const actions = renderBash({ dirty: true }) - expect(actions.discard).not.toHaveBeenCalled() - - cleanup() - - expect(actions.discard).toHaveBeenCalledOnce() - }) - - it('blocks the save while a draft is invalid, and says why', () => { - renderBash({ dirty: true, invalid: true, timeoutMs: field('soon', { invalid: true }) }) - - expect(screen.getByRole('button', { name: en.save })).toHaveProperty('disabled', true) - expect(screen.getByText(en.invalidNumber)).toBeTruthy() - }) - - it('reports a save in flight and refuses another', () => { - renderBash({ dirty: true, saving: true }) - - expect(screen.getByRole('button', { name: en.saving })).toHaveProperty('disabled', true) - }) - - it('reports a save the deployment did not accept and keeps the fields for correction', () => { - renderBash({ dirty: true, failed: true }) - - expect(screen.getByText(en.saveFailed)).toBeTruthy() - expect(screen.getByLabelText(en.bashTimeoutMs)).toBeTruthy() - }) - - it('says the document is read-only and disables its controls', () => { - renderBash({ writable: false }) - - expect(screen.getByRole('status')).toHaveProperty('textContent', en.readOnly) - expect(screen.getByLabelText(en.bashTimeoutMs)).toHaveProperty('disabled', true) - }) -}) - -describe('Subagent model selection fields', () => { - it('renders the default-off preference in its staged plugin card', () => { - const actions = renderSubagentModelSelection() - - const control = screen.getByRole('switch', { name: en.subagentModelSelectionToggle }) - expect(control.getAttribute('aria-checked')).toBe('false') - fireEvent.click(control) - - expect(actions.toggleEnabled).toHaveBeenCalledOnce() - }) - - it('groups available adapter candidates by provider', () => { - const actions = renderSubagentModelSelection({ - enabled: true, - candidates: [ - { - key: 'alpha\0fast', - provider: 'alpha', - model: 'fast', - providerName: 'Alpha API', - modelName: 'Fast', - available: true, - selected: true, - }, - { - key: 'alpha\0deep', - provider: 'alpha', - model: 'deep', - providerName: 'Alpha API', - modelName: 'Deep', - available: true, - selected: false, - }, - ], - catalogStatus: 'ready', - }) - - expect(screen.getByRole('switch').getAttribute('aria-checked')).toBe('true') - expect(screen.getByText('Alpha API', { exact: true })).toBeTruthy() - fireEvent.click(screen.getByRole('checkbox', { name: /Fast/ })) - fireEvent.click(screen.getByRole('checkbox', { name: /Deep/ })) - expect(actions.toggleModel).toHaveBeenCalledWith('alpha\0fast') - expect(actions.toggleModel).toHaveBeenCalledWith('alpha\0deep') - }) - - it('renders directory progress, failures, unavailable routes, and validation', () => { - renderSubagentModelSelection({ enabled: true, catalogStatus: 'loading', invalid: true }) - expect(screen.getByText(en.subagentModelSelectionLoading)).toBeTruthy() - expect(screen.getByText(en.subagentModelSelectionRequired)).toBeTruthy() - - cleanup() - const errorActions = renderSubagentModelSelection({ enabled: true, catalogStatus: 'error' }) - fireEvent.click(screen.getByRole('button', { name: en.subagentModelSelectionRetry })) - expect(errorActions.retryCatalog).toHaveBeenCalledOnce() - - cleanup() - renderSubagentModelSelection({ - enabled: true, - catalogStatus: 'ready', - catalogPartial: true, - candidates: [{ - key: 'legacy\0old', - provider: 'legacy', - model: 'old', - providerName: 'legacy', - modelName: 'old', - available: false, - selected: true, - }], - }) - expect(screen.getByText(en.subagentModelSelectionPartial)).toBeTruthy() - expect(screen.getByText(en.subagentModelSelectionUnavailable)).toBeTruthy() - expect(screen.getByText(en.subagentModelSelectionUnavailableGroup)).toBeTruthy() - - cleanup() - renderSubagentModelSelection({ enabled: true, catalogStatus: 'ready' }) - expect(screen.getByText(en.subagentModelSelectionEmpty)).toBeTruthy() - }) - - it('distinguishes a stale draft from a rejected save', () => { - renderSubagentModelSelection({ dirty: true, conflicted: true }) - - expect(screen.getByText(en.subagentModelSelectionConflict)).toBeTruthy() - expect(screen.queryByText(en.saveFailed)).toBeNull() - }) - - it('renders its one-liner in the summary view, says so when unavailable, and disables writes when read-only', () => { - renderSubagentModelSelection({}, 'summary') - expect(document.body.textContent).toBe(en.subagentDescription) - - cleanup() - renderSubagentModelSelection({ available: false }) - expect(screen.getByRole('status').textContent).toBe(en.unavailable) - - cleanup() - const actions = renderSubagentModelSelection({ writable: false }) - const control = screen.getByRole('switch') as HTMLButtonElement - expect(control.disabled).toBe(true) - fireEvent.click(control) - expect(actions.toggleEnabled).not.toHaveBeenCalled() - }) -}) - -describe('AgentLoopCard', () => { - it('renders its one-liner alone in the summary view', () => { - const store = createSnapshotStore({ ...settled, maxParallelToolCalls: field('10') }) - const props = { ...cardActions(), view: 'summary', t, useAgentLoopCard: bindSnapshotSelector(store) } as unknown as AgentLoopCardProps - render() - - expect(document.body.textContent).toBe(en.agentLoopDescription) - expect(screen.queryByLabelText(en.agentLoopMaxParallel)).toBeNull() - }) - - it('stages and saves the only field it owns', () => { - const store = createSnapshotStore({ - ...settled, - dirty: true, - maxParallelToolCalls: field('10'), - }) - const actions = cardActions() - const props = { - ...actions, - view: 'page', - t, - useAgentLoopCard: bindSnapshotSelector(store), - } as unknown as AgentLoopCardProps - render() - - fireEvent.change(screen.getByLabelText(en.agentLoopMaxParallel), { target: { value: '2' } }) - fireEvent.click(screen.getByRole('button', { name: en.save })) - - expect(actions.edit).toHaveBeenCalledWith('maxParallelToolCalls', '2') - expect(actions.save).toHaveBeenCalledOnce() - }) - - it('stages a reset for the field it owns', () => { - const store = createSnapshotStore({ - ...settled, - maxParallelToolCalls: field('2', { overridden: true }), - }) - const actions = cardActions() - const props = { - ...actions, - view: 'page', - t, - useAgentLoopCard: bindSnapshotSelector(store), - } as unknown as AgentLoopCardProps - render() - - fireEvent.click(screen.getByRole('button', { name: en.reset })) - - expect(actions.resetField).toHaveBeenCalledWith('maxParallelToolCalls') - }) -}) - -describe('WebSearchCard', () => { - function renderWebSearch(state: Partial = {}) { - const store = createSnapshotStore({ - ...settled, - baseURL: field(''), - maxUses: field('5'), - apiKey: field(''), - apiKeyConfigured: false, - apiKeyWritable: true, - ...state, - }) - const actions = cardActions() - const props = { ...actions, view: 'page', t, useWebSearchCard: bindSnapshotSelector(store) } as unknown as WebSearchCardProps - render() - return actions - } - - it('renders its one-liner alone in the summary view', () => { - const store = createSnapshotStore({ - ...settled, baseURL: field(''), maxUses: field('5'), apiKey: field(''), apiKeyConfigured: false, apiKeyWritable: true, - }) - const props = { ...cardActions(), view: 'summary', t, useWebSearchCard: bindSnapshotSelector(store) } as unknown as WebSearchCardProps - render() - - expect(document.body.textContent).toBe(en.webSearchDescription) - expect(screen.queryByLabelText(en.webSearchApiKey)).toBeNull() - }) - - it('reports whether a key is configured without ever showing one', () => { - renderWebSearch({ apiKeyConfigured: true }) - - expect(screen.getByText(en.webSearchApiKeySet)).toBeTruthy() - expect(screen.getByLabelText(en.webSearchApiKey)).toHaveProperty('type', 'password') - }) - - it('keeps the key control usable while the settings document is read-only', () => { - const actions = renderWebSearch({ writable: false }) - - const key = screen.getByLabelText(en.webSearchApiKey) - expect(key).toHaveProperty('disabled', false) - expect(screen.getByLabelText(en.webSearchBaseUrl)).toHaveProperty('disabled', true) - - fireEvent.change(key, { target: { value: 'ds-secret' } }) - - expect(actions.edit).toHaveBeenCalledWith('apiKey', 'ds-secret') - }) - - it('disables the key control when the reference itself is not writable', () => { - // A key coming from the process environment: the settings document is - // writable, the credential is not. - renderWebSearch({ apiKeyConfigured: true, apiKeyWritable: false }) - - expect(screen.getByLabelText(en.webSearchApiKey)).toHaveProperty('disabled', true) - expect(screen.getByLabelText(en.webSearchBaseUrl)).toHaveProperty('disabled', false) - }) - - it('stages the endpoint, the search budget, and their resets', () => { - const actions = renderWebSearch({ - baseURL: field('https://search.test/v1', { overridden: true }), - maxUses: field('3', { overridden: true }), - }) - - fireEvent.change(screen.getByLabelText(en.webSearchBaseUrl), { target: { value: 'https://other.test' } }) - fireEvent.change(screen.getByLabelText(en.webSearchMaxUses), { target: { value: '4' } }) - const resets = screen.getAllByRole('button', { name: en.reset }) - expect(resets).toHaveLength(2) - for (const reset of resets) fireEvent.click(reset) - - expect(actions.edit.mock.calls).toEqual([ - ['baseURL', 'https://other.test'], - ['maxUses', '4'], - ]) - expect(actions.resetField.mock.calls).toEqual([['baseURL'], ['maxUses']]) - }) -}) - - -describe('SubagentCard', () => { - it('discards both drafts when leaving the page', () => { - const { actions } = renderSubagent({ dirty: true }, { dirty: true }) - cleanup() - expect(actions.discard).toHaveBeenCalledOnce() - }) - - it('renders limits without model selection when only limits are served', () => { - renderSubagent({}, { available: false }) - expect(screen.getByLabelText(en.subagentMaxDepth)).toBeTruthy() - expect(screen.queryByRole('switch')).toBeNull() - }) - - it('shows both sections on one page with one save footer', () => { - renderSubagent({ dirty: true }) - - expect(screen.getByRole('heading', { name: en.subagentLimitsTitle })).toBeTruthy() - expect(screen.getByRole('heading', { name: en.subagentModelSelectionTitle })).toBeTruthy() - expect(screen.getAllByRole('button', { name: en.save })).toHaveLength(1) - }) - - it('reveals field rules on demand without changing staged values', () => { - renderSubagent({ dirty: true, maxDepth: field('2') }) - const depthHelp = screen.getByRole('button', { name: en.subagentDepthHelpLabel }) - const capacityHelp = screen.getByRole('button', { name: en.subagentCapacityHelpLabel }) - expect(depthHelp.getAttribute('aria-expanded')).toBe('false') - expect(screen.queryByText(en.subagentDepthHelp)).toBeNull() - expect(screen.queryByText(en.subagentCapacityHelp)).toBeNull() - - fireEvent.click(depthHelp) - expect(depthHelp.getAttribute('aria-expanded')).toBe('true') - expect(screen.getByRole('region', { name: en.subagentDepthHelpLabel })).toBeTruthy() - expect(screen.getByText(en.subagentDepthHelp)).toBeTruthy() - expect(screen.getByRole('table', { name: en.subagentDepthHelpLabel })).toBeTruthy() - expect(screen.getByRole('row', { name: `0 ${en.subagentDepthZero}` })).toBeTruthy() - expect(screen.getByRole('row', { name: `1 ${en.subagentDepthOne}` })).toBeTruthy() - expect(screen.getByText(en.subagentDepthOverride)).toBeTruthy() - fireEvent.click(capacityHelp) - expect(screen.getByText(en.subagentCapacityHelp)).toBeTruthy() - fireEvent.click(depthHelp) - expect(screen.queryByText(en.subagentDepthHelp)).toBeNull() - expect(screen.getByLabelText(en.subagentMaxDepth)).toHaveProperty('value', '2') - expect(screen.getByRole('button', { name: en.save })).toHaveProperty('disabled', false) - }) - - it('keeps validation visible when the rules are collapsed and links it to the input', () => { - renderSubagent({ dirty: true, invalid: true, maxDepth: field('1.5', { invalid: true }) }) - const depth = screen.getByLabelText(en.subagentMaxDepth) - const messageId = depth.getAttribute('aria-describedby')! - expect(document.getElementById(messageId)?.textContent).toBe(en.subagentDepthInvalid) - expect(screen.queryByRole('region', { name: en.subagentDepthHelpLabel })).toBeNull() - }) - - it('edits and resets limits through the shared card', () => { - const { actions, limits } = renderSubagent({ - dirty: true, - maxDepth: field('3', { overridden: true }), - maxActiveSubagents: field('8', { overridden: true }), - }) - fireEvent.change(screen.getByLabelText(en.subagentMaxDepth), { target: { value: '2' } }) - fireEvent.change(screen.getByLabelText(en.subagentMaxActive), { target: { value: '12' } }) - expect(actions.editLimit.mock.calls).toEqual([['maxDepth', '2'], ['maxActiveSubagents', '12']]) - for (const button of screen.getAllByRole('button', { name: en.reset })) fireEvent.click(button) - expect(actions.resetLimit.mock.calls).toEqual([['maxDepth'], ['maxActiveSubagents']]) - act(() => { limits.set({ ...limits.getSnapshot(), writable: false }) }) - expect(screen.getByLabelText(en.subagentMaxActive)).toHaveProperty('disabled', true) - }) - - it('blocks saving both sections when a model selection is invalid or conflicted', () => { - const { models } = renderSubagent({ dirty: true }, { dirty: true, invalid: true }) - expect(screen.getByRole('button', { name: en.save })).toHaveProperty('disabled', true) - act(() => { models.set({ ...models.getSnapshot(), invalid: false, conflicted: true }) }) - expect(screen.getByRole('button', { name: en.save })).toHaveProperty('disabled', true) - }) - - it('locks both sections while either is saving and stays open after both settle', () => { - const { limits, models } = renderSubagent({ dirty: true }, { dirty: true }) - act(() => { - limits.set({ ...limits.getSnapshot(), saving: true }) - models.set({ ...models.getSnapshot(), saving: true }) - }) - expect(screen.getByLabelText(en.subagentMaxDepth)).toHaveProperty('disabled', true) - expect(screen.getByRole('switch')).toHaveProperty('disabled', true) - expect(screen.getByRole('button', { name: en.saving })).toHaveProperty('disabled', true) - act(() => { limits.set({ ...limits.getSnapshot(), saving: false, dirty: false }) }) - expect(screen.getByRole('switch')).toHaveProperty('disabled', true) - act(() => { models.set({ ...models.getSnapshot(), saving: false, dirty: false }) }) - expect(screen.getByRole('switch')).toHaveProperty('disabled', false) - expect(screen.getByRole('button', { name: en.save })).toHaveProperty('disabled', true) - }) - - it('keeps a rejected section open after the other section saves', () => { - const { limits, models } = renderSubagent({ dirty: true }, { dirty: true }) - act(() => { - limits.set({ ...limits.getSnapshot(), saving: true }) - models.set({ ...models.getSnapshot(), saving: true }) - }) - act(() => { - limits.set({ ...limits.getSnapshot(), saving: false, dirty: false }) - models.set({ ...models.getSnapshot(), saving: false, failed: true }) - }) - expect(screen.getByRole('switch')).toBeTruthy() - expect(screen.getByText(en.saveFailed)).toBeTruthy() - expect(screen.getByRole('button', { name: en.save })).toHaveProperty('disabled', false) - }) - - it('renders model-only deployments without limit controls', () => { - renderSubagent({ available: false }) - expect(screen.getByRole('switch')).toBeTruthy() - expect(screen.queryByLabelText(en.subagentMaxDepth)).toBeNull() - }) -}) diff --git a/packages/client/ui-settings-plugins/tsconfig.json b/packages/client/ui-settings-plugins/tsconfig.json index a43ca3698f..b30ef5452e 100644 --- a/packages/client/ui-settings-plugins/tsconfig.json +++ b/packages/client/ui-settings-plugins/tsconfig.json @@ -14,27 +14,15 @@ { "path": "../../../vendor/cordis" }, - { - "path": "../store" - }, { "path": "../../test-support/client-runtime" }, - { - "path": "../ui-primitives" - }, { "path": "../ui-renderer" }, - { - "path": "../ui-plugin-manager" - }, { "path": "../ui-settings" }, - { - "path": "../../api/remotes/tsconfig.client.json" - }, { "path": "../ui-slots" } diff --git a/packages/client/ui-settings-shell/README.i18n.yaml b/packages/client/ui-settings-shell/README.i18n.yaml new file mode 100644 index 0000000000..deca0c7f4a --- /dev/null +++ b/packages/client/ui-settings-shell/README.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 packages/client/ui-settings-shell/README.md +README.md: f77cc0e5f49f426c6801f3832a65bc8214080775 +README.zh.md: 81b924fab3481a835fdc3df9771b169fb3b6c30a diff --git a/packages/client/ui-settings-shell/README.md b/packages/client/ui-settings-shell/README.md new file mode 100644 index 0000000000..f77cc0e5f4 --- /dev/null +++ b/packages/client/ui-settings-shell/README.md @@ -0,0 +1,78 @@ +--- +description: "The shell executor's settings page on the dsh web client's Plugins page: the command timeout and the per-stream output cap of the shell namespace." +kind: "package-reference" +--- + +# @deepseek-ai/dsh-client-ui-settings-shell + +English | [中文](README.zh.md) + +## Summary + +Open **Plugins** in the sidebar and select **Shell** in the Official group to set how long one command may run and how much of each output stream stays in memory. The page stages what is typed and writes it only on save, marks the values the user overrode, and offers to reset each back to the deployment's default. The page exists while the Host serves the `shell` namespace, so a deployment without a local shell executor shows no trace of it. + +## Table of Contents + +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +The **Shell** card in the Official group opens the page. **Command timeout (ms)** ends a command that runs longer; **Output cap per stream (bytes)** spills output beyond it to a temporary file. Both render the effective value — the user's override over the composed default — and a field the user overrode carries an **Overridden** badge with **Reset to default** beside it. Nothing is written until **Save**; leaving the page drops the drafts, an empty field saves as a reset, and text that is not a number blocks the save and says so under the field. + +----- + + +## Understand the implementation + +
+Implementation internals — click to expand + +The Host half is an empty `apply`, present only so the package holds a Loader row the client module system serves the browser half for. The browser half binds the `shell` namespace through `ctx.settingsScope`, keeps the staged form in `ShellCardController` over the shared `SettingsFormModel` of `ui-primitives`, and registers `ShellCard` into the Plugins page's `plugins.item` slot through `ctx.settingsScope.whileServed`, which registers while the Host serves the namespace and withdraws when it stops. The page's copy lives in this package's `settings.shell` dictionary; the `SettingsForm` frame takes its copy as props. + +
+ +----- + + +## Further Exploration + +- [ui-plugin-manager](../ui-plugin-manager/README.md) — the Plugins page and the `plugins.item` slot the page registers into. +- [ui-settings](../ui-settings/README.md) — the settings scope and the served-namespace watch the page rides. +- [ui-primitives](../ui-primitives/README.md) — the settings form model and fields the page renders. +- [bash-local](../../shell/bash-local/README.md) — the executor that registers the `shell` namespace. + +----- + + +## Model Experience + +None, as the package is a browser-side settings surface that registers no model surface. + +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + +## Known Limitations and Deferred Work + + + +- **The page follows the composed executor** — the POSIX and PowerShell executor families share the `shell` namespace because a host composes exactly one of them, so the served schema differs by platform (PowerShell adds `pwshPath`) while the page edits the same two fields on both. +- **Runtime invariant:** No companion is published. The page holds no owned relationship of its own: what it shows derives from the settings mirror, and what it writes the Host validates. + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
diff --git a/packages/client/ui-settings-shell/README.zh.md b/packages/client/ui-settings-shell/README.zh.md new file mode 100644 index 0000000000..81b924fab3 --- /dev/null +++ b/packages/client/ui-settings-shell/README.zh.md @@ -0,0 +1,78 @@ +--- +description: "dsh Web 客户端插件页上的终端执行器设置页:shell 命名空间的命令超时与单流输出上限。" +kind: "package-reference" +--- + +# @deepseek-ai/dsh-client-ui-settings-shell + +[English](README.md) | 中文 + +## 概述 + +在侧栏打开**插件**,在官方分组里选择**终端**,即可设置单条命令允许运行多久、每条输出流在内存里保留多少。页面暂存输入、只在保存时写入,标明用户覆盖过的值,并允许把每个值重置回部署默认值。页面只在 Host 服务 `shell` 命名空间期间存在,没有本地终端执行器的部署看不到它。 + +## 目录 + +- [使用本包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [进一步探索](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与延期工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用本包 + +官方分组里的**终端**卡片打开这一页。**命令超时(毫秒)**终止运行更久的命令;**单流输出上限(字节)**把超出的输出转存到临时文件。两个字段都显示生效值,即用户覆盖值叠在组合默认值之上;用户覆盖过的字段带**已覆盖**标签,旁边是**恢复默认**。点击**保存**之前不会写入任何内容;离开页面即丢弃草稿,清空字段并保存等于重置,填了不是数字的文本则保存被阻止,并在字段下说明原因。 + +----- + + +## 理解实现 + +
+实现细节——点击展开 + +宿主半侧是一个空的 `apply`,只为让本包占一条 Loader 行,客户端模块系统据此送出浏览器半侧。浏览器半侧通过 `ctx.settingsScope` 绑定 `shell` 命名空间,用 `ui-primitives` 的共享 `SettingsFormModel` 在 `ShellCardController` 里维护暂存表单,并通过 `ctx.settingsScope.whileServed` 把 `ShellCard` 注册进插件页的 `plugins.item` slot:Host 服务该命名空间时注册,停止服务时注销。页面文案在本包的 `settings.shell` 字典里;`SettingsForm` 框架的文案以 props 传入。 + +
+ +----- + + +## 进一步探索 + +- [ui-plugin-manager](../ui-plugin-manager/README.zh.md)——插件页以及本页注册进去的 `plugins.item` slot。 +- [ui-settings](../ui-settings/README.zh.md)——本页依赖的设置 scope 与"命名空间被服务期间"的监视。 +- [ui-primitives](../ui-primitives/README.zh.md)——本页渲染的设置表单模型与字段。 +- [bash-local](../../shell/bash-local/README.zh.md)——注册 `shell` 命名空间的执行器。 + +----- + + +## 模型体验 + +无,本包是浏览器侧的设置界面,不注册任何模型面。 + +#### KV 缓存影响 + +无;本包既不组装也不发送提供方请求。 + +## 已知限制与延期工作 + + + +- **页面跟随组合进来的执行器**——POSIX 与 PowerShell 两个执行器家族共用 `shell` 命名空间,因为一台主机只组合其中一个,所以服务出来的 schema 因平台而异(PowerShell 多一个 `pwshPath`),而页面在两个平台上编辑的都是同样两个字段。 +- **运行时不变量:**不发布伴生。本页没有自己拥有的关系:它显示的内容派生自设置镜像,它写入的内容由 Host 校验。 + + +### 开发备注 + +
+维护者工作上下文——点击展开 + +无。 + +
diff --git a/packages/client/ui-settings-shell/package.json b/packages/client/ui-settings-shell/package.json new file mode 100644 index 0000000000..ed5afb29c1 --- /dev/null +++ b/packages/client/ui-settings-shell/package.json @@ -0,0 +1,65 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-settings-shell", + "description": "Settings page of the shell executor on the dsh web client's Plugins page: the command timeout and the per-stream output cap of the shell namespace", + "version": "0.1.6-alpha.1", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/ui-settings-shell" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-ui-settings", + "@deepseek-ai/dsh-client-ui-plugin-manager" + ], + "platform": "web" + } + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", + "@deepseek-ai/dsh-client-test-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-plugin-manager": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@types/react": "~18.3.1", + "react": "^18.2.0" + }, + "files": [ + "lib/index.js", + "lib/client.js", + "lib/types/**/*.d.ts" + ] +} diff --git a/packages/client/ui-settings-plugins/src/client/BashCard.tsx b/packages/client/ui-settings-shell/src/client/ShellCard.tsx similarity index 51% rename from packages/client/ui-settings-plugins/src/client/BashCard.tsx rename to packages/client/ui-settings-shell/src/client/ShellCard.tsx index 96f5791be8..16b8b3fe34 100644 --- a/packages/client/ui-settings-plugins/src/client/BashCard.tsx +++ b/packages/client/ui-settings-shell/src/client/ShellCard.tsx @@ -1,38 +1,33 @@ -/** The shell plugin's configuration page: the limits every command the agent runs is bound by. */ +/** The shell executor's settings page: the limits every command the agent runs is bound by. */ import type {} from '@deepseek-ai/dsh-client-ui-plugin-manager/client' +import { SettingsForm, SettingsValueField } from '@deepseek-ai/dsh-client-ui-primitives' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' -import { ValueField } from './fields.tsx' -import { PluginConfigForm } from './PluginConfigForm.tsx' -import type { BashCardFace } from './bash-card-controller.ts' +import { formLabels } from './locales.ts' +import type { ShellCardFace } from './shell-card-controller.ts' /** Props the renderer binds for the shell page. */ -export type BashCardProps = +export type ShellCardProps = PropsRuntime<'plugins.item'> - & PropsLocale<'settings.plugins'> - & InjectFace + & PropsLocale<'settings.shell'> + & InjectFace /** - * Render the shell plugin's one-liner or its configuration form, as the Plugins page asks. + * Render the shell executor's one-liner or its settings form, as the Plugins page asks. * @param props - the view asked for, locale copy, the form snapshot, and its actions. * @returns the one-liner, or the form. */ -export function BashCard(props: BashCardProps) { +export function ShellCard(props: ShellCardProps) { const { t } = props - const state = props.useBashCard(snapshot => snapshot) - if (props.view === 'summary') return t('bashDescription') + const state = props.useShellCard(snapshot => snapshot) + if (props.view === 'summary') return t('description') const disabled = !state.writable return ( - - + { props.edit('timeoutMs', text) }} onReset={() => { props.resetField('timeoutMs') }} /> - { props.edit('maxOutputBytes', text) }} onReset={() => { props.resetField('maxOutputBytes') }} /> - + ) } diff --git a/packages/client/ui-settings-shell/src/client/index.ts b/packages/client/ui-settings-shell/src/client/index.ts new file mode 100644 index 0000000000..d9f975fb2b --- /dev/null +++ b/packages/client/ui-settings-shell/src/client/index.ts @@ -0,0 +1,50 @@ +/** + * The shell executor's settings page, browser half: the command timeout and + * the per-stream output cap over the `shell` namespace the executor + * registers. The page registers into the Plugins page's `plugins.item` slot + * while the Host serves that namespace, so a deployment without a local shell + * executor shows no trace of it. + */ + +// Type-only: pulls the locale plugin's Context merge (ctx.locale). +import type {} from '@deepseek-ai/dsh-client-locale/client' +// Type-only: the ctx.settingsScope Context merge. Cross-plugin collaboration +// goes through the service, never a value import (client bundle purity gate). +import type {} from '@deepseek-ai/dsh-client-ui-settings/client' +// Type-only: the Plugins page's SlotMap merge (the 'plugins.item' entry). +import type {} from '@deepseek-ai/dsh-client-ui-plugin-manager/client' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' +import { ShellCard } from './ShellCard.tsx' +import { SHELL_NS, ShellCardController } from './shell-card-controller.ts' +import { en, zh, type ShellSettingsLocaleKey } from './locales.ts' + +export type { ShellCardProps } from './ShellCard.tsx' +export type { ShellCardFace, ShellCardState, ShellSettings } from './shell-card-controller.ts' +export type { ShellSettingsLocaleKey } from './locales.ts' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** Shell settings page copy. */ + 'settings.shell': ShellSettingsLocaleKey + } +} + +/** Dictionary namespace owned by this plugin. */ +export const NS = 'settings.shell' + +/** Required services (cordis fiber inject). */ +export const inject = ['slots', 'locale', 'settingsScope'] + +/** + * Mount the shell settings page while the Host serves its namespace. + * @param ctx - the browser plugin context. + */ +export function apply(ctx: ClientContext): void { + const t = ctx.locale.bind(NS) + ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-settings-shell: dictionaries') + const card = new ShellCardController(ctx.settingsScope.bind({ namespace: SHELL_NS })) + ctx.effect(() => ctx.settingsScope.whileServed([SHELL_NS], () => ctx.slots.inject('plugins.item', () => ctx.slots.register({ + name: 'plugins.item', id: 'shell', order: 10, label: () => t('title'), locale: NS, inject: () => card.inject(), + }, ShellCard))), 'ui-settings-shell: page') +} diff --git a/packages/client/ui-settings-shell/src/client/locales.ts b/packages/client/ui-settings-shell/src/client/locales.ts new file mode 100644 index 0000000000..b456046ca3 --- /dev/null +++ b/packages/client/ui-settings-shell/src/client/locales.ts @@ -0,0 +1,55 @@ +/** Locale bundles for the shell executor's settings page. */ + +import type { SettingsFormLabels } from '@deepseek-ai/dsh-client-ui-primitives' + +/** Locale keys the page renders. */ +export type ShellSettingsLocaleKey = + | 'title' | 'description' + | 'timeoutMs' | 'timeoutMsHint' | 'maxOutputBytes' | 'maxOutputBytesHint' + | 'overridden' | 'reset' | 'readOnly' | 'unavailable' + | 'save' | 'saving' | 'saveFailed' | 'invalidNumber' + +/** English copy. */ +export const en: Record = { + title: 'Shell', + description: 'Limits every command the agent runs.', + timeoutMs: 'Command timeout (ms)', + timeoutMsHint: 'How long one command may run before it is terminated.', + maxOutputBytes: 'Output cap per stream (bytes)', + maxOutputBytesHint: 'Output beyond this spills to a temporary file rather than being lost.', + overridden: 'Overridden', + reset: 'Reset to default', + readOnly: 'This deployment stores settings read-only.', + unavailable: 'This plugin is not loaded, so it cannot be configured right now.', + save: 'Save', + saving: 'Saving…', + saveFailed: 'The deployment did not accept these values; they were left for you to correct.', + invalidNumber: 'Enter a number, or leave blank to use the default.', +} + +/** Simplified Chinese copy. */ +export const zh: Record = { + title: '终端', + description: '限制 agent 运行的每一条命令。', + timeoutMs: '命令超时(毫秒)', + timeoutMsHint: '单条命令允许运行多久,超时即终止。', + maxOutputBytes: '单流输出上限(字节)', + maxOutputBytesHint: '超出部分会转存到临时文件,而不是被丢弃。', + overridden: '已覆盖', + reset: '恢复默认', + readOnly: '本部署的设置为只读。', + unavailable: '该插件当前未加载,暂时无法配置。', + save: '保存', + saving: '保存中…', + saveFailed: '本部署没有接受这些值,已保留供你修改。', + invalidNumber: '请填数字;留空表示使用默认值。', +} + +/** + * The form frame's copy, read from this page's dictionary. + * @param t - the page's locale reader. + * @returns the labels the shared settings form renders. + */ +export function formLabels(t: (key: ShellSettingsLocaleKey) => string): SettingsFormLabels { + return { unavailable: t('unavailable'), readOnly: t('readOnly'), saveFailed: t('saveFailed'), save: t('save'), saving: t('saving') } +} diff --git a/packages/client/ui-settings-shell/src/client/shell-card-controller.ts b/packages/client/ui-settings-shell/src/client/shell-card-controller.ts new file mode 100644 index 0000000000..f5648931b6 --- /dev/null +++ b/packages/client/ui-settings-shell/src/client/shell-card-controller.ts @@ -0,0 +1,66 @@ +/** The shell page's staged form over the `shell` settings namespace. */ + +import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' +import { + SettingsFormModel, settingsNumberField, + type SettingsFieldState, type SettingsFormActions, type SettingsFormScope, type SettingsFormShell, +} from '@deepseek-ai/dsh-client-ui-primitives' + +/** + * Namespace of the shell capability. Spelled here rather than imported: a + * client package must not depend on a Host package, and the executor families + * that own it spell the same value. + */ +export const SHELL_NS = 'shell' + +/** The shell fields this page edits — a subset of the served schema by design. */ +export interface ShellSettings { + /** Foreground command timeout in milliseconds. */ + timeoutMs?: number + /** Per-stream in-memory output cap in bytes. */ + maxOutputBytes?: number +} + +/** What the shell page renders. */ +export interface ShellCardState extends SettingsFormShell { + /** Command timeout in milliseconds. */ + timeoutMs: SettingsFieldState + /** Per-stream output cap in bytes. */ + maxOutputBytes: SettingsFieldState +} + +/** The registration-side face the shell page's slot entry injects. */ +export interface ShellCardFace extends SettingsFormActions { + hooks: { + /** Page snapshot bound by the renderer as useShellCard. */ + shellCard: SnapshotStore + } +} + +/** Bridges the `shell` scope onto the page's staged form. */ +export class ShellCardController { + private readonly form: SettingsFormModel + private readonly store: SnapshotStore + + /** @param scope - the bound settings scope for the `shell` namespace. */ + constructor(scope: SettingsFormScope) { + this.form = new SettingsFormModel(scope, [settingsNumberField('timeoutMs'), settingsNumberField('maxOutputBytes')]) + this.store = this.form.bind(() => this.projection()) + } + + private projection(): ShellCardState { + return { + ...this.form.shell(), + timeoutMs: this.form.field('timeoutMs'), + maxOutputBytes: this.form.field('maxOutputBytes'), + } + } + + /** + * Build the face the page's slot registration injects. + * @returns the page's snapshot and its form actions. + */ + inject(): ShellCardFace { + return { hooks: { shellCard: this.store }, ...this.form.actions() } + } +} diff --git a/packages/client/ui-settings-shell/src/css-modules.d.ts b/packages/client/ui-settings-shell/src/css-modules.d.ts new file mode 100644 index 0000000000..8811db1264 --- /dev/null +++ b/packages/client/ui-settings-shell/src/css-modules.d.ts @@ -0,0 +1,4 @@ +declare module '*.module.css' { + const classes: Record + export default classes +} diff --git a/packages/client/ui-settings-shell/src/index.ts b/packages/client/ui-settings-shell/src/index.ts new file mode 100644 index 0000000000..f8d853e9be --- /dev/null +++ b/packages/client/ui-settings-shell/src/index.ts @@ -0,0 +1,10 @@ +/** + * Shell settings page, node half. The empty apply exists so the plugin appears + * in the host cordis.yml / Loader; the browser half owns the page through + * exports["./client"], discovered from the package.json dsh.client + * declaration. The `shell` namespace the page edits is registered by the + * shell executor, so this package registers no namespace of its own. + */ + +/** Host plugin body — no host-side behavior for this surface plugin. */ +export function apply(): void {} diff --git a/packages/client/ui-settings-shell/tests/apply.client.spec.ts b/packages/client/ui-settings-shell/tests/apply.client.spec.ts new file mode 100644 index 0000000000..3bc0b93161 --- /dev/null +++ b/packages/client/ui-settings-shell/tests/apply.client.spec.ts @@ -0,0 +1,113 @@ +/** What the browser half registers, when, and that it all leaves with the fiber. */ + +import { Context } from '@deepseek-ai/cordis' +import { describe, expect, it, vi } from 'vitest' +import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' +import { RemoteError, TestRemote } from '@deepseek-ai/dsh-client-test-runtime' +import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' +import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client' +import { apply, inject, NS } from '../src/client/index.ts' +import type { ShellCardFace } from '../src/client/index.ts' +import { apply as hostApply } from '../src/index.ts' + +// These specs assert the shipped Chinese copy. The lane has no jsdom `window`, +// so browser-language detection never runs and a fresh LocaleRuntime opens on +// FALLBACK_LOCALE (en); bench stages zh explicitly on the locale instead. + +/** One Host view of a served namespace. */ +function view(ns: string, revision = 0) { + return { ns, schema: {}, value: {}, applies: 'live', secrets: [], revision } +} + +/** + * @param served - namespaces the Host describes; omitted answers a failed read, + * which leaves the page nothing to register for. + */ +async function bench(served?: string[]) { + const ctx = new Context() + await ctx.plugin(SlotRegistry).await() + const locale = new LocaleRuntime(ctx) + locale.setLocale('zh') + ctx.provide('locale', locale) + const describeSettings = vi.fn(() => Promise.resolve(served === undefined + ? { ok: false, error: new RemoteError('gateway/internal', 'no provider', {}) } + : { ok: true, value: { writable: true, hasDocument: true, namespaces: served.map(ns => view(ns)) } })) + const remote = new TestRemote(ctx, { settings: { describe: describeSettings } }) + await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() + return { ctx, slots: ctx.get('slots') as SlotRegistry, describeSettings, remote } +} + +/** The Plugins page's item slot, as its owner declares it. */ +function declareRoot(slots: SlotRegistry): () => void { + return slots.register({ + name: 'root', + children: { 'plugins.item': { kind: 'list', scope: 'root' } }, + } as never, () => null) +} + +describe('ui-settings-shell apply', () => { + it('keeps the host Loader entry inert', () => { + expect(hostApply).not.toThrow() + }) + + it('declares the services it uses', () => { + expect(inject).toEqual(['slots', 'locale', 'settingsScope']) + }) + + it('registers the shell page while the Host serves the namespace, titled in the active locale', async () => { + const { ctx, slots } = await bench(['shell', 'ui-theme']) + declareRoot(slots) + + await ctx.plugin({ inject: [...inject], apply }).await() + + await vi.waitFor(() => { expect(slots.entries('plugins.item')).toHaveLength(1) }) + const entry = slots.entries('plugins.item')[0]! + expect(entry.options).toMatchObject({ id: 'shell', order: 10 }) + expect(resolveSlotLabel(entry.options.label)).toBe('终端') + expect(entry.locale).toBe(NS) + const face = (entry.inject as unknown as () => ShellCardFace)() + expect(Object.keys(face.hooks)).toEqual(['shellCard']) + expect(face.hooks.shellCard.getSnapshot()).toMatchObject({ available: false, dirty: false }) + }) + + it('registers nothing while the namespace is not served, and follows the Host when that changes', async () => { + // A deployment composing no POSIX or PowerShell executor serves no `shell` at all. + const { ctx, slots, describeSettings, remote } = await bench(['agent-loop']) + declareRoot(slots) + await ctx.plugin({ inject: [...inject], apply }).await() + await vi.waitFor(() => { expect(describeSettings).toHaveBeenCalled() }) + expect(slots.entries('plugins.item')).toHaveLength(0) + + describeSettings.mockResolvedValue({ + ok: true, value: { writable: true, hasDocument: true, namespaces: [view('shell', 1)] }, + }) + remote.emit('settings/document-updated', ['shell', 1]) + await vi.waitFor(() => { expect(slots.entries('plugins.item')).toHaveLength(1) }) + + describeSettings.mockResolvedValue({ ok: true, value: { writable: true, hasDocument: true, namespaces: [] } }) + remote.emit('settings/document-updated', ['shell', 2]) + await vi.waitFor(() => { expect(slots.entries('plugins.item')).toHaveLength(0) }) + }) + + it('registers into a declaration that arrives after apply', async () => { + const { ctx, slots } = await bench(['shell']) + await ctx.plugin({ inject: [...inject], apply }).await() + + declareRoot(slots) + + await vi.waitFor(() => { expect(slots.entries('plugins.item')).toHaveLength(1) }) + }) + + it('collapses the page on teardown', async () => { + const { ctx, slots } = await bench(['shell']) + declareRoot(slots) + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + await vi.waitFor(() => { expect(slots.entries('plugins.item')).toHaveLength(1) }) + + await fiber.dispose() + + expect(slots.entries('plugins.item')).toHaveLength(0) + }) +}) diff --git a/packages/client/ui-settings-shell/tests/card.client.spec.tsx b/packages/client/ui-settings-shell/tests/card.client.spec.tsx new file mode 100644 index 0000000000..d789c28165 --- /dev/null +++ b/packages/client/ui-settings-shell/tests/card.client.spec.tsx @@ -0,0 +1,125 @@ +// @vitest-environment jsdom +/** The shell page as the Plugins page renders it: its one-liner, its two fields, and what a save does. */ + +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { SettingsFieldState, SettingsFormShell } from '@deepseek-ai/dsh-client-ui-primitives' +import { ShellCard, type ShellCardProps } from '../src/client/ShellCard.tsx' +import type { ShellCardState } from '../src/client/shell-card-controller.ts' +import { en } from '../src/client/locales.ts' + +afterEach(cleanup) + +const t = (key: keyof typeof en) => en[key] + +const settled: SettingsFormShell = { available: true, writable: true, dirty: false, invalid: false, saving: false, failed: false } + +function field(text: string, rest: Partial = {}): SettingsFieldState { + return { text, overridden: false, invalid: false, ...rest } +} + +function renderCard(state: Partial = {}, view: 'summary' | 'page' = 'page') { + const store = createSnapshotStore({ + ...settled, timeoutMs: field('60000'), maxOutputBytes: field('64000'), ...state, + }) + const actions = { edit: vi.fn(), resetField: vi.fn(), save: vi.fn(), discard: vi.fn() } + const props = { ...actions, view, t, useShellCard: bindSnapshotSelector(store) } as unknown as ShellCardProps + render() + return actions +} + +describe('ShellCard', () => { + it('renders its one-liner alone in the summary view', () => { + renderCard({}, 'summary') + + expect(document.body.textContent).toBe(en.description) + expect(screen.queryByLabelText(en.timeoutMs)).toBeNull() + }) + + it('says the plugin is not loaded in place of its fields while its namespace is unavailable', () => { + renderCard({ available: false }) + + expect(screen.getByRole('status').textContent).toBe(en.unavailable) + expect(screen.queryByLabelText(en.timeoutMs)).toBeNull() + }) + + it('shows its fields at once on its page, without a title of its own', () => { + renderCard() + + expect(screen.getByLabelText(en.timeoutMs)).toBeTruthy() + expect(screen.getByLabelText(en.maxOutputBytes)).toBeTruthy() + expect(screen.queryByText(en.title)).toBeNull() + }) + + it('stages an edit instead of writing it', () => { + const actions = renderCard() + + fireEvent.change(screen.getByLabelText(en.timeoutMs), { target: { value: '9000' } }) + + expect(actions.edit).toHaveBeenCalledWith('timeoutMs', '9000') + expect(actions.save).not.toHaveBeenCalled() + }) + + it('offers the reset for an overridden field only, and addresses each field separately', () => { + const actions = renderCard({ maxOutputBytes: field('64000', { overridden: true }) }) + + // One badge and one reset: the timeout is still inherited. + expect(screen.getAllByText(en.overridden)).toHaveLength(1) + fireEvent.change(screen.getByLabelText(en.maxOutputBytes), { target: { value: '1024' } }) + fireEvent.click(screen.getByRole('button', { name: en.reset })) + + expect(actions.edit).toHaveBeenCalledWith('maxOutputBytes', '1024') + expect(actions.resetField).toHaveBeenCalledWith('maxOutputBytes') + cleanup() + + const timeout = renderCard({ timeoutMs: field('9000', { overridden: true }) }) + fireEvent.click(screen.getByRole('button', { name: en.reset })) + expect(timeout.resetField).toHaveBeenCalledWith('timeoutMs') + }) + + it('keeps the save inert until something is staged, offers no discard, and writes the staged edits when saved', () => { + renderCard() + expect(screen.getByRole('button', { name: en.save })).toHaveProperty('disabled', true) + expect(screen.getAllByRole('button')).toHaveLength(1) + cleanup() + + const actions = renderCard({ dirty: true, timeoutMs: field('9000', { overridden: true }) }) + fireEvent.click(screen.getByRole('button', { name: en.save })) + + expect(actions.save).toHaveBeenCalledOnce() + expect(actions.discard).not.toHaveBeenCalled() + }) + + it('drops the staged edits when it leaves the page', () => { + const actions = renderCard({ dirty: true }) + expect(actions.discard).not.toHaveBeenCalled() + + cleanup() + + expect(actions.discard).toHaveBeenCalledOnce() + }) + + it('blocks the save while a draft is invalid and says why, reports a save in flight, and a save the deployment refused', () => { + renderCard({ dirty: true, invalid: true, timeoutMs: field('soon', { invalid: true }) }) + expect(screen.getByRole('button', { name: en.save })).toHaveProperty('disabled', true) + expect(screen.getByText(en.invalidNumber)).toBeTruthy() + cleanup() + + renderCard({ dirty: true, saving: true }) + expect(screen.getByRole('button', { name: en.saving })).toHaveProperty('disabled', true) + cleanup() + + renderCard({ dirty: true, failed: true }) + expect(screen.getByText(en.saveFailed)).toBeTruthy() + expect(screen.getByLabelText(en.timeoutMs)).toBeTruthy() + }) + + it('says the document is read-only and disables its controls', () => { + renderCard({ writable: false }) + + expect(screen.getByRole('status')).toHaveProperty('textContent', en.readOnly) + expect(screen.getByLabelText(en.timeoutMs)).toHaveProperty('disabled', true) + }) +}) diff --git a/packages/client/ui-settings-shell/tests/controller.client.spec.ts b/packages/client/ui-settings-shell/tests/controller.client.spec.ts new file mode 100644 index 0000000000..d5958f79a5 --- /dev/null +++ b/packages/client/ui-settings-shell/tests/controller.client.spec.ts @@ -0,0 +1,91 @@ +/** The shell page's form over a scripted settings scope: what it projects and what a save writes. */ + +import { describe, expect, it, vi } from 'vitest' +import { stubSettingsScope, type StubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' +import { ShellCardController, type ShellSettings } from '../src/client/shell-card-controller.ts' + +/** Make the stub behave like a Host that accepts every write. */ +function acceptWrites(host: StubSettingsScope): void { + const section = (): Record => ({ ...host.scope.getSnapshot().value as object }) + const layer = (): Record => ({ ...host.scope.getSnapshot().user as object }) + host.set.mockImplementation((field: string, value: unknown) => { + host.publish({ value: { ...section(), [field]: value } as T, user: { ...layer(), [field]: value } }) + }) + host.unset.mockImplementation((field: string) => { + const user = Object.fromEntries(Object.entries(layer()).filter(([key]) => key !== field)) + const base = host.scope.getSnapshot().base as Record | undefined + host.publish({ value: { ...section(), [field]: base?.[field] } as T, user }) + }) +} + +describe('ShellCardController', () => { + it('projects both fields and saves them in one write pass', async () => { + const host = stubSettingsScope() + acceptWrites(host) + const controller = new ShellCardController(host.scope) + host.publish({ + status: 'ready', + writable: true, + value: { timeoutMs: 5_000, maxOutputBytes: 64_000 }, + base: { timeoutMs: 60_000, maxOutputBytes: 64_000 }, + user: { timeoutMs: 5_000 }, + }) + const face = controller.inject() + + expect(face.hooks.shellCard.getSnapshot()).toMatchObject({ + available: true, + writable: true, + dirty: false, + timeoutMs: { text: '5000', overridden: true }, + maxOutputBytes: { text: '64000', overridden: false }, + }) + + face.edit('timeoutMs', '9000') + face.edit('maxOutputBytes', '1024') + expect(face.hooks.shellCard.getSnapshot().dirty).toBe(true) + + face.save() + await vi.waitFor(() => { expect(host.set).toHaveBeenCalledTimes(2) }) + + expect(host.set.mock.calls).toEqual([['timeoutMs', 9_000], ['maxOutputBytes', 1_024]]) + expect(face.hooks.shellCard.getSnapshot().dirty).toBe(false) + }) + + it('stages a reset and applies it on save', async () => { + const host = stubSettingsScope() + acceptWrites(host) + const controller = new ShellCardController(host.scope) + host.publish({ + status: 'ready', + writable: true, + value: { timeoutMs: 5_000 }, + base: { timeoutMs: 60_000 }, + user: { timeoutMs: 5_000 }, + }) + const face = controller.inject() + + face.resetField('timeoutMs') + expect(face.hooks.shellCard.getSnapshot().timeoutMs.text).toBe('60000') + + face.save() + await vi.waitFor(() => { expect(host.unset).toHaveBeenCalledWith('timeoutMs') }) + + expect(face.hooks.shellCard.getSnapshot()).toMatchObject({ + dirty: false, + timeoutMs: { text: '60000', overridden: false }, + }) + }) + + it('discards staged edits without writing', () => { + const host = stubSettingsScope() + const controller = new ShellCardController(host.scope) + host.publish({ status: 'ready', writable: true, value: { timeoutMs: 5_000 }, user: {} }) + const face = controller.inject() + + face.edit('timeoutMs', '9000') + face.discard() + + expect(face.hooks.shellCard.getSnapshot().timeoutMs.text).toBe('5000') + expect(host.set).not.toHaveBeenCalled() + }) +}) diff --git a/packages/client/ui-settings-shell/tsconfig.json b/packages/client/ui-settings-shell/tsconfig.json new file mode 100644 index 0000000000..a43ca3698f --- /dev/null +++ b/packages/client/ui-settings-shell/tsconfig.json @@ -0,0 +1,42 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../locale" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../store" + }, + { + "path": "../../test-support/client-runtime" + }, + { + "path": "../ui-primitives" + }, + { + "path": "../ui-renderer" + }, + { + "path": "../ui-plugin-manager" + }, + { + "path": "../ui-settings" + }, + { + "path": "../../api/remotes/tsconfig.client.json" + }, + { + "path": "../ui-slots" + } + ] +} diff --git a/packages/client/ui-settings-shell/tsdown.config.ts b/packages/client/ui-settings-shell/tsdown.config.ts new file mode 100644 index 0000000000..4e48f8dff3 --- /dev/null +++ b/packages/client/ui-settings-shell/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-ui-settings-shell', ['lib/types/index.js']) diff --git a/packages/client/ui-settings-subagent/README.i18n.yaml b/packages/client/ui-settings-subagent/README.i18n.yaml new file mode 100644 index 0000000000..9d1c8c77fe --- /dev/null +++ b/packages/client/ui-settings-subagent/README.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 packages/client/ui-settings-subagent/README.md +README.md: cb65a54c35e3d03b532a34682a1d9aa016721b59 +README.zh.md: 53e9918760bb3bde5b83820f31056f806f0720f5 diff --git a/packages/client/ui-settings-subagent/README.md b/packages/client/ui-settings-subagent/README.md new file mode 100644 index 0000000000..cb65a54c35 --- /dev/null +++ b/packages/client/ui-settings-subagent/README.md @@ -0,0 +1,82 @@ +--- +description: "The Subagent settings page on the dsh web client's Plugins page: delegation depth and capacity over the subagent namespace, and the models agents may choose over subagent-model-selection, on one page with one save." +kind: "package-reference" +--- + +# @deepseek-ai/dsh-client-ui-settings-subagent + +English | [中文](README.zh.md) + +## Summary + +Open **Plugins** in the sidebar and select **Subagent** in the Official group to set how deep and how wide delegation may go, and which models agents may choose for their subagents. The page groups the two Host namespaces, `subagent` and `subagent-model-selection`, under one save; it exists while the Host serves either and shows the sections it serves. + +## Table of Contents + +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +**Limits** holds **Maximum recursion depth** and **Subagent parallelism limit** side by side, stacking on narrow screens; the information button beside each label discloses its rule — a two-row depth example, and the count that capacity bounds — and a draft that is not a whole number in range blocks the save and says so under the field. Depth yields to a tool's own maximum; capacity counts every live subagent under one main agent across all levels, the main agent excluded. + +**Model selection** stages its permission switch and the exact adapter routes together. Enabling requires at least one selected route; disabling keeps the selected routes for later. Routes the Host stored but no adapter advertises stay listed under **Saved but currently unavailable** and remain removable; a provider that failed to load is reported without hiding the others, and a failed load offers **Retry**. + +One **Save** writes both sections through their own namespaces, each fenced by the revision its draft read. The writes are independent: a section the Host refuses keeps its draft with the failure reported while the other lands, and a model draft a newer Host revision superseded is reported as a conflict to discard rather than overwriting the newer routes. Leaving the page drops every draft. + +----- + + +## Understand the implementation + +
+Implementation internals — click to expand + +The Host half is an empty `apply`, present only so the package holds a Loader row the client module system serves the browser half for. The browser half binds each namespace through `ctx.settingsScope`: `SubagentLimitsCardController` stages the limits over the shared `SettingsFormModel` of `ui-primitives`, with a field spec that admits safe whole numbers at or above each field's floor; `SubagentModelSelectionCardController` keeps its own draft because its two fields save as one revision-fenced `mutate`, joins the stored routes with `remote.session.modelCatalog()`, and re-reads the catalogue on `llm/adapters-updated` and `settings/document-updated` while a connection reset drops its draft. `subagentCardFace` composes the two into the one face `SubagentCard` renders inside the shared `SettingsForm`, whose save validates both and writes the dirty ones. The page registers into the Plugins page's `plugins.item` slot through `ctx.settingsScope.whileServed` over both namespaces. + +
+ +----- + + +## Further Exploration + +- [ui-plugin-manager](../ui-plugin-manager/README.md) — the Plugins page and the `plugins.item` slot the page registers into. +- [ui-settings](../ui-settings/README.md) — the settings scope and the served-namespace watch the page rides. +- [ui-primitives](../ui-primitives/README.md) — the settings form model and fields the limits section renders. +- [tool-subagent](../../subagent/tool-subagent/README.md) — the delegation tools that register both namespaces. + +----- + + +## Model Experience + +None, as the package is a browser-side settings surface that registers no model surface. + +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + +## Known Limitations and Deferred Work + + + +- **The catalogue re-reads on three signals only** — adapter changes, document commits, and reconnects; a provider that starts advertising without any of them shows up on the next of those or on **Retry**. +- **Runtime invariant:** No companion is published. The page holds no owned relationship of its own: what it shows derives from the settings mirror and the model catalogue, and what it writes the Host validates. + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
diff --git a/packages/client/ui-settings-subagent/README.zh.md b/packages/client/ui-settings-subagent/README.zh.md new file mode 100644 index 0000000000..53e9918760 --- /dev/null +++ b/packages/client/ui-settings-subagent/README.zh.md @@ -0,0 +1,82 @@ +--- +description: "dsh Web 客户端插件页上的 Subagent 设置页:subagent 命名空间的委派深度与并行容量,以及 subagent-model-selection 里 Agent 可选的模型,同一页一次保存。" +kind: "package-reference" +--- + +# @deepseek-ai/dsh-client-ui-settings-subagent + +[English](README.md) | 中文 + +## 概述 + +在侧栏打开**插件**,在官方分组里选择 **Subagent**,即可设置委派可以多深、多宽,以及 Agent 可以为 Subagent 选择哪些模型。页面把 Host 的两个命名空间 `subagent` 与 `subagent-model-selection` 收在一次保存之下;Host 服务其中任一命名空间时页面就存在,并只显示被服务的部分。 + +## 目录 + +- [使用本包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [进一步探索](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与延期工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用本包 + +**运行限制**把**最大递归深度**和 **Subagent 并行数量上限**并排放置,窄屏时上下堆叠;每个标签旁的信息按钮展开它的规则——深度是一张两行示例表,容量是计数口径——填了不在范围内的整数会阻止保存并在字段下说明。深度让位于工具自己的上限;容量统计同一主 Agent 下所有层级存活的 Subagent,主 Agent 不计入。 + +**模型选择**把权限开关和精确的适配器路由一起暂存。开启时至少要选一条路由;关闭会保留已选路由以备后用。Host 已存储但没有适配器公布的路由留在**已保存但当前不可用**分组里并且仍可移除;加载失败的提供方会被报告而不隐藏其他提供方,加载失败时提供**重试**。 + +一次**保存**通过各自的命名空间写入两个部分,每次写入都以其草稿读取时的修订号作栅栏。两次写入相互独立:Host 拒绝的部分保留草稿并报告失败,另一部分照常落地;被更新的 Host 修订号超越的模型草稿会以冲突形式报告并要求放弃,而不是覆盖较新的路由。离开页面即丢弃所有草稿。 + +----- + + +## 理解实现 + +
+实现细节——点击展开 + +宿主半侧是一个空的 `apply`,只为让本包占一条 Loader 行,客户端模块系统据此送出浏览器半侧。浏览器半侧通过 `ctx.settingsScope` 分别绑定两个命名空间:`SubagentLimitsCardController` 用 `ui-primitives` 的共享 `SettingsFormModel` 暂存运行限制,字段规格只接受不低于各自下限的安全整数;`SubagentModelSelectionCardController` 自己维护草稿,因为它的两个字段要作为一次带修订栅栏的 `mutate` 保存,它把已存储的路由与 `remote.session.modelCatalog()` 合并,在 `llm/adapters-updated` 与 `settings/document-updated` 时重读目录,连接重置时丢弃草稿。`subagentCardFace` 把两者合成 `SubagentCard` 在共享 `SettingsForm` 里渲染的一个 face,保存时校验两部分并写入有改动的那些。页面通过 `ctx.settingsScope.whileServed` 监视两个命名空间,注册进插件页的 `plugins.item` slot。 + +
+ +----- + + +## 进一步探索 + +- [ui-plugin-manager](../ui-plugin-manager/README.zh.md)——插件页以及本页注册进去的 `plugins.item` slot。 +- [ui-settings](../ui-settings/README.zh.md)——本页依赖的设置 scope 与"命名空间被服务期间"的监视。 +- [ui-primitives](../ui-primitives/README.zh.md)——运行限制部分渲染的设置表单模型与字段。 +- [tool-subagent](../../subagent/tool-subagent/README.zh.md)——注册这两个命名空间的委派工具。 + +----- + + +## 模型体验 + +无,本包是浏览器侧的设置界面,不注册任何模型面。 + +#### KV 缓存影响 + +无;本包既不组装也不发送提供方请求。 + +## 已知限制与延期工作 + + + +- **目录只在三种信号下重读**——适配器变化、文档提交和重连;在这三者之外开始公布模型的提供方,要等下一次信号或点击**重试**才会出现。 +- **运行时不变量:**不发布伴生。本页没有自己拥有的关系:它显示的内容派生自设置镜像与模型目录,它写入的内容由 Host 校验。 + + +### 开发备注 + +
+维护者工作上下文——点击展开 + +无。 + +
diff --git a/packages/client/ui-settings-subagent/package.json b/packages/client/ui-settings-subagent/package.json new file mode 100644 index 0000000000..49ccf5295f --- /dev/null +++ b/packages/client/ui-settings-subagent/package.json @@ -0,0 +1,65 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-settings-subagent", + "description": "Settings page of Subagent delegation on the dsh web client's Plugins page: recursion depth, parallel capacity, and the models agents may choose for subagents", + "version": "0.1.6-alpha.1", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/ui-settings-subagent" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-ui-settings", + "@deepseek-ai/dsh-client-ui-plugin-manager" + ], + "platform": "web" + } + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", + "@deepseek-ai/dsh-client-test-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-plugin-manager": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@types/react": "~18.3.1", + "react": "^18.2.0" + }, + "files": [ + "lib/index.js", + "lib/client.js", + "lib/types/**/*.d.ts" + ] +} diff --git a/packages/client/ui-settings-plugins/src/client/SubagentCard.module.css b/packages/client/ui-settings-subagent/src/client/SubagentCard.module.css similarity index 100% rename from packages/client/ui-settings-plugins/src/client/SubagentCard.module.css rename to packages/client/ui-settings-subagent/src/client/SubagentCard.module.css diff --git a/packages/client/ui-settings-plugins/src/client/SubagentCard.tsx b/packages/client/ui-settings-subagent/src/client/SubagentCard.tsx similarity index 90% rename from packages/client/ui-settings-plugins/src/client/SubagentCard.tsx rename to packages/client/ui-settings-subagent/src/client/SubagentCard.tsx index 685fa89285..42d2991c16 100644 --- a/packages/client/ui-settings-plugins/src/client/SubagentCard.tsx +++ b/packages/client/ui-settings-subagent/src/client/SubagentCard.tsx @@ -3,7 +3,8 @@ import { useId } from 'react' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type {} from '@deepseek-ai/dsh-client-ui-plugin-manager/client' -import { PluginConfigForm } from './PluginConfigForm.tsx' +import { SettingsForm } from '@deepseek-ai/dsh-client-ui-primitives' +import { formLabels } from './locales.ts' import { SubagentLimitsFields } from './SubagentLimitsFields.tsx' import { SubagentModelSelectionFields } from './SubagentModelSelectionFields.tsx' import { subagentCardShell, type SubagentCardFace } from './subagent-card-controller.ts' @@ -11,7 +12,7 @@ import css from './SubagentCard.module.css' /** Framework-derived props for the shared Subagent settings card. */ export type SubagentCardProps = PropsRuntime<'plugins.item'> - & PropsLocale<'settings.plugins'> & InjectFace + & PropsLocale<'settings.subagent'> & InjectFace /** * Render the available Subagent settings with one configuration page and save footer. @@ -26,7 +27,7 @@ export function SubagentCard(props: SubagentCardProps) { if (props.view === 'summary') return t('subagentDescription') const state = subagentCardShell(limits, models) return ( - {limits.available ? ( @@ -46,6 +47,6 @@ export function SubagentCard(props: SubagentCardProps) {
) : null} - + ) } diff --git a/packages/client/ui-settings-plugins/src/client/SubagentLimitsFields.module.css b/packages/client/ui-settings-subagent/src/client/SubagentLimitsFields.module.css similarity index 100% rename from packages/client/ui-settings-plugins/src/client/SubagentLimitsFields.module.css rename to packages/client/ui-settings-subagent/src/client/SubagentLimitsFields.module.css diff --git a/packages/client/ui-settings-plugins/src/client/SubagentLimitsFields.tsx b/packages/client/ui-settings-subagent/src/client/SubagentLimitsFields.tsx similarity index 87% rename from packages/client/ui-settings-plugins/src/client/SubagentLimitsFields.tsx rename to packages/client/ui-settings-subagent/src/client/SubagentLimitsFields.tsx index e1b732e1fc..2560850325 100644 --- a/packages/client/ui-settings-plugins/src/client/SubagentLimitsFields.tsx +++ b/packages/client/ui-settings-subagent/src/client/SubagentLimitsFields.tsx @@ -1,12 +1,12 @@ /** Delegation-limit fields inside the shared Subagent settings card. */ import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' -import { ValueField } from './fields.tsx' +import { SettingsValueField } from '@deepseek-ai/dsh-client-ui-primitives' import type { SubagentLimitsCardFace, SubagentLimitsCardState } from './subagent-limits-card-controller.ts' import css from './SubagentLimitsFields.module.css' /** Plain state and edit callbacks supplied by the owning card. */ -export type SubagentLimitsFieldsProps = PropsLocale<'settings.plugins'> +export type SubagentLimitsFieldsProps = PropsLocale<'settings.subagent'> & Pick & { state: SubagentLimitsCardState } @@ -21,7 +21,7 @@ export function SubagentLimitsFields(props: SubagentLimitsFieldsProps) { <>
-

{t('subagentDepthHelp')}

@@ -45,7 +45,7 @@ export function SubagentLimitsFields(props: SubagentLimitsFieldsProps) { onEdit={(text) => { props.edit('maxDepth', text) }} onReset={() => { props.resetField('maxDepth') }} />
- {t('subagentCapacityHelp')}

}} overriddenLabel={t('overridden')} resetLabel={t('reset')} invalidLabel={t('subagentCapacityInvalid')} numeric disabled={!state.writable || state.saving} {...state.maxActiveSubagents} diff --git a/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionFields.module.css b/packages/client/ui-settings-subagent/src/client/SubagentModelSelectionFields.module.css similarity index 100% rename from packages/client/ui-settings-plugins/src/client/SubagentModelSelectionFields.module.css rename to packages/client/ui-settings-subagent/src/client/SubagentModelSelectionFields.module.css diff --git a/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionFields.tsx b/packages/client/ui-settings-subagent/src/client/SubagentModelSelectionFields.tsx similarity index 99% rename from packages/client/ui-settings-plugins/src/client/SubagentModelSelectionFields.tsx rename to packages/client/ui-settings-subagent/src/client/SubagentModelSelectionFields.tsx index 6aa6fe2dd4..39d733f688 100644 --- a/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionFields.tsx +++ b/packages/client/ui-settings-subagent/src/client/SubagentModelSelectionFields.tsx @@ -10,7 +10,7 @@ import type { import css from './SubagentModelSelectionFields.module.css' /** Plain model state and callbacks supplied by the owning Subagent card. */ -export type SubagentModelSelectionFieldsProps = PropsLocale<'settings.plugins'> +export type SubagentModelSelectionFieldsProps = PropsLocale<'settings.subagent'> & Pick & { state: SubagentModelSelectionCardState } diff --git a/packages/client/ui-settings-subagent/src/client/index.ts b/packages/client/ui-settings-subagent/src/client/index.ts new file mode 100644 index 0000000000..cdaab36259 --- /dev/null +++ b/packages/client/ui-settings-subagent/src/client/index.ts @@ -0,0 +1,92 @@ +/** + * The Subagent settings page, browser half: the delegation limits over the + * `subagent` namespace and the models agents may choose over the + * `subagent-model-selection` namespace, on one page with one save. The page + * registers into the Plugins page's `plugins.item` slot while the Host serves + * either namespace and shows the sections it serves. + */ + +// Type-only: pulls the locale plugin's Context merge (ctx.locale). +import type {} from '@deepseek-ai/dsh-client-locale/client' +// Type-only: the ctx.settingsScope Context merge. Cross-plugin collaboration +// goes through the service, never a value import (client bundle purity gate). +import type {} from '@deepseek-ai/dsh-client-ui-settings/client' +// Type-only: the Plugins page's SlotMap merge (the 'plugins.item' entry). +import type {} from '@deepseek-ai/dsh-client-ui-plugin-manager/client' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +// Type-only: the ctx.remote Context merge and the forwarded-event key face. +import type {} from '@deepseek-ai/dsh-api-remotes/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' +import { SubagentCard } from './SubagentCard.tsx' +import { subagentCardFace } from './subagent-card-controller.ts' +import { SubagentLimitsCardController } from './subagent-limits-card-controller.ts' +import { + SUBAGENT_MODEL_SELECTION_NS, SubagentModelSelectionCardController, +} from './subagent-model-selection-card-controller.ts' +import { en, zh, type SubagentSettingsLocaleKey } from './locales.ts' + +export type { SubagentCardProps } from './SubagentCard.tsx' +export type { SubagentCardFace } from './subagent-card-controller.ts' +export type { SubagentLimitsCardFace, SubagentLimitsCardState, SubagentLimitsSettings } from './subagent-limits-card-controller.ts' +export type { + SubagentModelSelectionCardFace, SubagentModelSelectionCardState, SubagentModelSelectionSettings, +} from './subagent-model-selection-card-controller.ts' +export type { SubagentSettingsLocaleKey } from './locales.ts' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** Subagent settings page copy. */ + 'settings.subagent': SubagentSettingsLocaleKey + } +} + +/** Dictionary namespace owned by this plugin. */ +export const NS = 'settings.subagent' + +/** + * Namespace of the delegation limits. Spelled here rather than imported: a + * client package must not depend on a Host package. + */ +export const SUBAGENT_NS = 'subagent' + +/** Required services (cordis fiber inject). */ +export const inject = ['slots', 'locale', 'remote', 'remote.session', 'settingsScope'] + +/** + * Mount the Subagent settings page while the Host serves either of its namespaces. + * @param ctx - the browser plugin context. + */ +export function apply(ctx: ClientContext): void { + const t = ctx.locale.bind(NS) + ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-settings-subagent: dictionaries') + const limits = new SubagentLimitsCardController(ctx.settingsScope.bind({ namespace: SUBAGENT_NS })) + const models = new SubagentModelSelectionCardController( + ctx.settingsScope.bind({ namespace: SUBAGENT_MODEL_SELECTION_NS }), + ctx, + ) + const limitsFace = limits.inject() + const modelsFace = models.inject() + // The model catalogue is not part of any settings section: adapters come and + // go, and a document commit elsewhere can change which routes are stored. + ctx.effect( + () => ctx.remote.$on('llm/adapters-updated', () => { models.refreshCatalog() }), + 'ui-settings-subagent: adapter invalidations', + ) + ctx.effect( + () => ctx.remote.$on('settings/document-updated', () => { models.refreshCatalog() }), + 'ui-settings-subagent: settings invalidations', + ) + ctx.effect( + () => ctx.on('connection/reset', () => { models.resetConnection() }), + 'ui-settings-subagent: connection generation', + ) + ctx.effect(() => () => { models.dispose() }, 'ui-settings-subagent: model preference') + ctx.effect(() => ctx.settingsScope.whileServed([SUBAGENT_NS, SUBAGENT_MODEL_SELECTION_NS], () => ctx.slots.inject('plugins.item', () => ctx.slots.register({ + name: 'plugins.item', + id: 'subagent', + order: 30, + label: () => t('subagentTitle'), + locale: NS, + inject: () => subagentCardFace(limitsFace, modelsFace), + }, SubagentCard))), 'ui-settings-subagent: page') +} diff --git a/packages/client/ui-settings-subagent/src/client/locales.ts b/packages/client/ui-settings-subagent/src/client/locales.ts new file mode 100644 index 0000000000..5b05cd74f1 --- /dev/null +++ b/packages/client/ui-settings-subagent/src/client/locales.ts @@ -0,0 +1,109 @@ +/** Locale bundles for the Subagent settings page. */ + +import type { SettingsFormLabels } from '@deepseek-ai/dsh-client-ui-primitives' + +/** Locale keys the page renders. */ +export type SubagentSettingsLocaleKey = + | 'overridden' | 'reset' | 'readOnly' | 'unavailable' + | 'save' | 'saving' | 'saveFailed' + | 'subagentTitle' | 'subagentDescription' | 'subagentLimitsTitle' + | 'subagentMaxDepth' + | 'subagentDepthHelpLabel' | 'subagentDepthHelp' + | 'subagentDepthZero' | 'subagentDepthOne' | 'subagentDepthOverride' + | 'subagentMaxActive' + | 'subagentCapacityHelpLabel' | 'subagentCapacityHelp' + | 'subagentDepthInvalid' + | 'subagentCapacityInvalid' + | 'subagentModelSelectionTitle' + | 'subagentModelSelectionToggle' | 'subagentModelSelectionChoose' | 'subagentModelSelectionAllowed' + | 'subagentModelSelectionLoading' | 'subagentModelSelectionLoadFailed' | 'subagentModelSelectionRetry' + | 'subagentModelSelectionPartial' | 'subagentModelSelectionUnavailable' + | 'subagentModelSelectionUnavailableGroup' | 'subagentModelSelectionEmpty' + | 'subagentModelSelectionRequired' | 'subagentModelSelectionConflict' | 'subagentModelSelectionOff' + +/** English copy. */ +export const en: Record = { + overridden: 'Overridden', + reset: 'Reset to default', + readOnly: 'This deployment stores settings read-only.', + unavailable: 'This plugin is not loaded, so it cannot be configured right now.', + save: 'Save', + saving: 'Saving…', + saveFailed: 'The deployment did not accept these values; they were left for you to correct.', + subagentTitle: 'Subagent', + subagentDescription: 'Set Subagent recursion depth, count, and models.', + subagentLimitsTitle: 'Limits', + subagentMaxDepth: 'Maximum recursion depth', + subagentDepthHelpLabel: 'About maximum recursion depth', + subagentDepthHelp: 'Limits how many levels of Subagents an Agent can create.', + subagentDepthZero: 'Disable Subagents', + subagentDepthOne: 'Only the main Agent can create Subagents', + subagentDepthOverride: 'If a tool defines its own maximum recursion depth, that setting takes precedence.', + subagentMaxActive: 'Subagent parallelism limit', + subagentCapacityHelpLabel: 'About the Subagent parallelism limit', + subagentCapacityHelp: 'Total live Subagents under the same main Agent, across all recursion levels. The main Agent is excluded. New start requests are rejected when the limit is reached.', + subagentDepthInvalid: 'Enter a whole number of 0 or more.', + subagentCapacityInvalid: 'Enter a whole number of 1 or more.', + subagentModelSelectionTitle: 'Model selection', + subagentModelSelectionToggle: 'Allow agents to choose models for Subagents', + subagentModelSelectionChoose: 'When enabled, agents can choose a provider, model, and reasoning effort for each Subagent from the authorized models below. Applies only to new sessions.', + subagentModelSelectionAllowed: 'Models agents may choose', + subagentModelSelectionLoading: 'Loading models…', + subagentModelSelectionLoadFailed: 'Models could not be loaded.', + subagentModelSelectionRetry: 'Retry', + subagentModelSelectionPartial: 'Some model providers could not be loaded; saved choices remain removable.', + subagentModelSelectionUnavailable: 'Currently unavailable', + subagentModelSelectionUnavailableGroup: 'Saved but currently unavailable', + subagentModelSelectionEmpty: 'No model provider currently advertises a model.', + subagentModelSelectionRequired: 'Select at least one model before saving.', + subagentModelSelectionConflict: 'Settings changed elsewhere. Discard your draft and try again.', + subagentModelSelectionOff: 'Subagents use configured defaults or inherit the parent agent\'s model. Saved model choices are retained.', +} + +/** Simplified Chinese copy. */ +export const zh: Record = { + overridden: '已覆盖', + reset: '恢复默认', + readOnly: '本部署的设置为只读。', + unavailable: '该插件当前未加载,暂时无法配置。', + save: '保存', + saving: '保存中…', + saveFailed: '本部署没有接受这些值,已保留供你修改。', + subagentTitle: 'Subagent', + subagentDescription: '设置 Subagent 的递归层级、数量和模型。', + subagentLimitsTitle: '运行限制', + subagentMaxDepth: '最大递归深度', + subagentDepthHelpLabel: '最大递归深度说明', + subagentDepthHelp: '限制 Agent 创建 Subagent 的递归层级。', + subagentDepthZero: '禁用 Subagent', + subagentDepthOne: '仅允许主 Agent 创建 Subagent', + subagentDepthOverride: '如果某个工具单独设置了最大递归深度,以该工具的设置为准。', + subagentMaxActive: 'Subagent 并行数量上限', + subagentCapacityHelpLabel: 'Subagent 并行数量上限说明', + subagentCapacityHelp: '同一主 Agent 下,所有递归层级同时存活的 Subagent 总数,主 Agent 不计入。达到上限时,新的启动请求会被拒绝。', + subagentDepthInvalid: '请输入不小于 0 的整数。', + subagentCapacityInvalid: '请输入不小于 1 的整数。', + subagentModelSelectionTitle: '模型选择', + subagentModelSelectionToggle: '允许 Agent 为 Subagent 选择模型', + subagentModelSelectionChoose: '开启后,Agent 可以从下方授权模型中,为每个 Subagent 选择提供方、模型和推理强度。仅影响新会话。', + subagentModelSelectionAllowed: 'Agent 可选择的模型', + subagentModelSelectionLoading: '正在加载模型…', + subagentModelSelectionLoadFailed: '无法加载模型。', + subagentModelSelectionRetry: '重试', + subagentModelSelectionPartial: '部分模型提供方暂时无法加载;已保存的选择仍可移除。', + subagentModelSelectionUnavailable: '当前不可用', + subagentModelSelectionUnavailableGroup: '已保存但当前不可用', + subagentModelSelectionEmpty: '当前没有模型提供方公布模型。', + subagentModelSelectionRequired: '保存前请至少选择一个模型。', + subagentModelSelectionConflict: '设置已在其他位置更新。请放弃修改后重试。', + subagentModelSelectionOff: '关闭后,Subagent 使用配置的默认模型或继承父 Agent 的模型;已选模型会保留。', +} + +/** + * The form frame's copy, read from this page's dictionary. + * @param t - the page's locale reader. + * @returns the labels the shared settings form renders. + */ +export function formLabels(t: (key: SubagentSettingsLocaleKey) => string): SettingsFormLabels { + return { unavailable: t('unavailable'), readOnly: t('readOnly'), saveFailed: t('saveFailed'), save: t('save'), saving: t('saving') } +} diff --git a/packages/client/ui-settings-plugins/src/client/subagent-card-controller.ts b/packages/client/ui-settings-subagent/src/client/subagent-card-controller.ts similarity index 96% rename from packages/client/ui-settings-plugins/src/client/subagent-card-controller.ts rename to packages/client/ui-settings-subagent/src/client/subagent-card-controller.ts index ad217e1225..5e46ab2e87 100644 --- a/packages/client/ui-settings-plugins/src/client/subagent-card-controller.ts +++ b/packages/client/ui-settings-subagent/src/client/subagent-card-controller.ts @@ -1,6 +1,6 @@ /** Shared presentation and actions for the two Host-owned Subagent settings sections. */ -import type { CardShell } from './card-form.ts' +import type { SettingsFormShell } from '@deepseek-ai/dsh-client-ui-primitives' import type { SubagentLimitsCardFace, SubagentLimitsCardState } from './subagent-limits-card-controller.ts' import type { SubagentModelSelectionCardFace, SubagentModelSelectionCardState, @@ -29,7 +29,7 @@ export interface SubagentCardFace { export function subagentCardShell( limits: SubagentLimitsCardState, models: SubagentModelSelectionCardState, -): CardShell { +): SettingsFormShell { const sections = [limits, models].filter(section => section.available) return { available: sections.length > 0, diff --git a/packages/client/ui-settings-plugins/src/client/subagent-limits-card-controller.ts b/packages/client/ui-settings-subagent/src/client/subagent-limits-card-controller.ts similarity index 67% rename from packages/client/ui-settings-plugins/src/client/subagent-limits-card-controller.ts rename to packages/client/ui-settings-subagent/src/client/subagent-limits-card-controller.ts index cfb566961f..15b5292b86 100644 --- a/packages/client/ui-settings-plugins/src/client/subagent-limits-card-controller.ts +++ b/packages/client/ui-settings-subagent/src/client/subagent-limits-card-controller.ts @@ -1,8 +1,10 @@ /** Staged delegation limits backed by the Host's subagent settings section. */ import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' -import type { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client' -import { CardForm, numberField, type CardActions, type CardFieldSpec, type CardFieldState, type CardShell } from './card-form.ts' +import { + SettingsFormModel, settingsNumberField, + type SettingsFieldSpec, type SettingsFieldState, type SettingsFormActions, type SettingsFormScope, type SettingsFormShell, +} from '@deepseek-ai/dsh-client-ui-primitives' /** Host-owned delegation defaults and live capacity. */ export interface SubagentLimitsSettings { @@ -11,20 +13,20 @@ export interface SubagentLimitsSettings { } /** Effective values and drafts presented by the limits card. */ -export interface SubagentLimitsCardState extends CardShell { - maxDepth: CardFieldState - maxActiveSubagents: CardFieldState +export interface SubagentLimitsCardState extends SettingsFormShell { + maxDepth: SettingsFieldState + maxActiveSubagents: SettingsFieldState } /** Actions and observable state bound by the slot renderer. */ -export interface SubagentLimitsCardFace extends CardActions { +export interface SubagentLimitsCardFace extends SettingsFormActions { hooks: { subagentLimitsCard: SnapshotStore } } -function limitField(field: keyof SubagentLimitsSettings, minimum: number): CardFieldSpec { - const numeric = numberField(field) +function limitField(field: keyof SubagentLimitsSettings, minimum: number): SettingsFieldSpec { + const numeric = settingsNumberField(field) return { ...numeric, parse: (text) => { @@ -38,12 +40,12 @@ function limitField(field: keyof SubagentLimitsSettings, minimum: number): CardF /** Bind two independently resettable limits to one staged settings form. */ export class SubagentLimitsCardController { - private readonly form: CardForm + private readonly form: SettingsFormModel private readonly store: SnapshotStore /** @param scope - The Host's `subagent` settings section. */ - constructor(scope: SettingsScope) { - this.form = new CardForm(scope, [limitField('maxDepth', 0), limitField('maxActiveSubagents', 1)]) + constructor(scope: SettingsFormScope) { + this.form = new SettingsFormModel(scope, [limitField('maxDepth', 0), limitField('maxActiveSubagents', 1)]) this.store = this.form.bind(() => ({ ...this.form.shell(), maxDepth: this.form.field('maxDepth'), diff --git a/packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts b/packages/client/ui-settings-subagent/src/client/subagent-model-selection-card-controller.ts similarity index 98% rename from packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts rename to packages/client/ui-settings-subagent/src/client/subagent-model-selection-card-controller.ts index 96e5b8c494..75c5d86299 100644 --- a/packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts +++ b/packages/client/ui-settings-subagent/src/client/subagent-model-selection-card-controller.ts @@ -4,7 +4,7 @@ import type { Context as ClientContext } from '@deepseek-ai/cordis' import type { ModelProviderGroup } from '@deepseek-ai/dsh-api-remotes/client' import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client' -import type { CardShell } from './card-form.ts' +import type { SettingsFormShell } from '@deepseek-ai/dsh-client-ui-primitives' /** Namespace of the Host-owned subagent model-selection preference. */ export const SUBAGENT_MODEL_SELECTION_NS = 'subagent-model-selection' @@ -38,7 +38,7 @@ export interface SubagentModelCandidate extends AllowedSubagentModel { } /** State rendered by the staged allowlist card. */ -export interface SubagentModelSelectionCardState extends CardShell { +export interface SubagentModelSelectionCardState extends SettingsFormShell { /** Whether the draft enables model-facing child route selection. */ enabled: boolean /** Live catalog joined with stored routes. */ diff --git a/packages/client/ui-settings-subagent/src/css-modules.d.ts b/packages/client/ui-settings-subagent/src/css-modules.d.ts new file mode 100644 index 0000000000..8811db1264 --- /dev/null +++ b/packages/client/ui-settings-subagent/src/css-modules.d.ts @@ -0,0 +1,4 @@ +declare module '*.module.css' { + const classes: Record + export default classes +} diff --git a/packages/client/ui-settings-subagent/src/index.ts b/packages/client/ui-settings-subagent/src/index.ts new file mode 100644 index 0000000000..4360878c3d --- /dev/null +++ b/packages/client/ui-settings-subagent/src/index.ts @@ -0,0 +1,11 @@ +/** + * Subagent settings page, node half. The empty apply exists so the plugin + * appears in the host cordis.yml / Loader; the browser half owns the page + * through exports["./client"], discovered from the package.json dsh.client + * declaration. The `subagent` and `subagent-model-selection` namespaces the + * page edits are registered by the delegation plugins, so this package + * registers no namespace of its own. + */ + +/** Host plugin body — no host-side behavior for this surface plugin. */ +export function apply(): void {} diff --git a/packages/client/ui-settings-subagent/tests/apply.client.spec.ts b/packages/client/ui-settings-subagent/tests/apply.client.spec.ts new file mode 100644 index 0000000000..edfe36b27e --- /dev/null +++ b/packages/client/ui-settings-subagent/tests/apply.client.spec.ts @@ -0,0 +1,117 @@ +/** What the browser half registers, when, what refreshes the model catalogue, and that it all leaves with the fiber. */ + +import { Context } from '@deepseek-ai/cordis' +import { describe, expect, it, vi } from 'vitest' +import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' +import { RemoteError, TestRemote } from '@deepseek-ai/dsh-client-test-runtime' +import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' +import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client' +import { apply, inject, NS } from '../src/client/index.ts' +import type { SubagentCardFace } from '../src/client/index.ts' +import { SubagentModelSelectionCardController } from '../src/client/subagent-model-selection-card-controller.ts' +import { apply as hostApply } from '../src/index.ts' + +/** One Host view of a served namespace. */ +function view(ns: string, revision = 0) { + return { ns, schema: {}, value: {}, applies: 'live', secrets: [], revision } +} + +/** @param served - namespaces the Host describes; omitted answers a failed read. */ +async function bench(served?: string[]) { + const ctx = new Context() + await ctx.plugin(SlotRegistry).await() + const locale = new LocaleRuntime(ctx) + locale.setLocale('zh') + ctx.provide('locale', locale) + const models = vi.fn(() => Promise.resolve({ ok: true as const, value: { groups: [], failures: [] } })) + const describeSettings = vi.fn(() => Promise.resolve(served === undefined + ? { ok: false, error: new RemoteError('gateway/internal', 'no provider', {}) } + : { ok: true, value: { writable: true, hasDocument: true, namespaces: served.map(ns => view(ns)) } })) + const remote = new TestRemote(ctx, { + session: { modelCatalog: models }, + settings: { describe: describeSettings }, + }) + await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() + return { ctx, slots: ctx.get('slots') as SlotRegistry, describeSettings, models, remote } +} + +/** The Plugins page's item slot, as its owner declares it. */ +function declareRoot(slots: SlotRegistry): () => void { + return slots.register({ + name: 'root', + children: { 'plugins.item': { kind: 'list', scope: 'root' } }, + } as never, () => null) +} + +describe('ui-settings-subagent apply', () => { + it('keeps the host Loader entry inert', () => { + expect(hostApply).not.toThrow() + }) + + it('declares the services it uses', () => { + expect(inject).toEqual(['slots', 'locale', 'remote', 'remote.session', 'settingsScope']) + }) + + it('registers one page for both namespaces while the Host serves either, titled in the active locale', async () => { + const { ctx, slots } = await bench(['subagent', 'subagent-model-selection']) + declareRoot(slots) + + await ctx.plugin({ inject: [...inject], apply }).await() + + await vi.waitFor(() => { expect(slots.entries('plugins.item')).toHaveLength(1) }) + const entry = slots.entries('plugins.item')[0]! + expect(entry.options).toMatchObject({ id: 'subagent', order: 30 }) + expect(resolveSlotLabel(entry.options.label)).toBe('Subagent') + expect(entry.locale).toBe(NS) + const face = (entry.inject as unknown as () => SubagentCardFace)() + expect(Object.keys(face.hooks).sort()).toEqual(['subagentLimitsCard', 'subagentModelSelectionCard']) + }) + + it('registers the page when only the model namespace is served, and nothing when neither is', async () => { + const { ctx, slots, describeSettings } = await bench(['subagent-model-selection']) + declareRoot(slots) + await ctx.plugin({ inject: [...inject], apply }).await() + await vi.waitFor(() => { expect(slots.entries('plugins.item')).toHaveLength(1) }) + await ctx.plugin({ inject: [...inject], apply }).dispose() + expect(slots.entries('plugins.item')).toHaveLength(1) + + const other = await bench(['shell']) + declareRoot(other.slots) + await other.ctx.plugin({ inject: [...inject], apply }).await() + await vi.waitFor(() => { expect(other.describeSettings).toHaveBeenCalled() }) + expect(other.slots.entries('plugins.item')).toHaveLength(0) + expect(describeSettings).toHaveBeenCalled() + }) + + it('refreshes the model catalogue after model inputs change or the connection resets', async () => { + const refresh = vi.spyOn(SubagentModelSelectionCardController.prototype, 'refreshCatalog') + const reset = vi.spyOn(SubagentModelSelectionCardController.prototype, 'resetConnection') + const { ctx, slots, remote } = await bench(['subagent-model-selection']) + declareRoot(slots) + await ctx.plugin({ inject: [...inject], apply }).await() + refresh.mockClear() + reset.mockClear() + + remote.emit('llm/adapters-updated', []) + expect(refresh).toHaveBeenCalledTimes(1) + remote.emit('settings/document-updated', ['llm-deepseek', 1]) + expect(refresh).toHaveBeenCalledTimes(2) + ctx.emit('connection/reset') + expect(reset).toHaveBeenCalledTimes(1) + refresh.mockRestore() + reset.mockRestore() + }) + + it('collapses the page on teardown', async () => { + const { ctx, slots } = await bench(['subagent']) + declareRoot(slots) + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + await vi.waitFor(() => { expect(slots.entries('plugins.item')).toHaveLength(1) }) + + await fiber.dispose() + + expect(slots.entries('plugins.item')).toHaveLength(0) + }) +}) diff --git a/packages/client/ui-settings-subagent/tests/card.client.spec.tsx b/packages/client/ui-settings-subagent/tests/card.client.spec.tsx new file mode 100644 index 0000000000..d0efaf23f8 --- /dev/null +++ b/packages/client/ui-settings-subagent/tests/card.client.spec.tsx @@ -0,0 +1,292 @@ +// @vitest-environment jsdom +/** The Subagent page as the Plugins page renders it: both sections on one page with one save. */ + +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import { SubagentCard, type SubagentCardProps } from '../src/client/SubagentCard.tsx' +import type { SubagentLimitsCardState } from '../src/client/subagent-limits-card-controller.ts' +import type { SettingsFieldState, SettingsFormShell } from '@deepseek-ai/dsh-client-ui-primitives' +import type { SubagentModelSelectionCardState } from '../src/client/subagent-model-selection-card-controller.ts' +import { en } from '../src/client/locales.ts' + +afterEach(cleanup) + +const t = (key: keyof typeof en) => en[key] + +const settled: SettingsFormShell = { + available: true, + writable: true, + dirty: false, + invalid: false, + saving: false, + failed: false, +} + +function field(text: string, rest: Partial = {}): SettingsFieldState { + return { text, overridden: false, invalid: false, ...rest } +} + +/** The Plugins page asks a configuration entry for its one-liner or its form; every page renders as `page` unless a test says otherwise. */ +type ConfigView = 'summary' | 'page' + +function renderSubagent( + limitState: Partial = {}, + modelState: Partial = {}, + view: ConfigView = 'page', +) { + const limits = createSnapshotStore({ + ...settled, + maxDepth: field('3'), + maxActiveSubagents: field('8'), + ...limitState, + }) + const models = createSnapshotStore({ + ...settled, + enabled: false, + candidates: [], + catalogStatus: 'idle', + catalogPartial: false, + conflicted: false, + ...modelState, + }) + const actions = { + editLimit: vi.fn(), + resetLimit: vi.fn(), + toggleEnabled: vi.fn(), + toggleModel: vi.fn(), + retryCatalog: vi.fn(), + save: vi.fn(), + discard: vi.fn(), + } + const props = { + ...actions, + view, + t, + useSubagentLimitsCard: bindSnapshotSelector(limits), + useSubagentModelSelectionCard: bindSnapshotSelector(models), + } as unknown as SubagentCardProps + render() + return { actions, limits, models } +} + +function renderSubagentModelSelection(state: Partial = {}, view: ConfigView = 'page') { + return renderSubagent({ available: false }, state, view).actions +} + +describe('Subagent model selection fields', () => { + it('renders the default-off preference in its staged plugin card', () => { + const actions = renderSubagentModelSelection() + + const control = screen.getByRole('switch', { name: en.subagentModelSelectionToggle }) + expect(control.getAttribute('aria-checked')).toBe('false') + fireEvent.click(control) + + expect(actions.toggleEnabled).toHaveBeenCalledOnce() + }) + + it('groups available adapter candidates by provider', () => { + const actions = renderSubagentModelSelection({ + enabled: true, + candidates: [ + { + key: 'alpha\0fast', + provider: 'alpha', + model: 'fast', + providerName: 'Alpha API', + modelName: 'Fast', + available: true, + selected: true, + }, + { + key: 'alpha\0deep', + provider: 'alpha', + model: 'deep', + providerName: 'Alpha API', + modelName: 'Deep', + available: true, + selected: false, + }, + ], + catalogStatus: 'ready', + }) + + expect(screen.getByRole('switch').getAttribute('aria-checked')).toBe('true') + expect(screen.getByText('Alpha API', { exact: true })).toBeTruthy() + fireEvent.click(screen.getByRole('checkbox', { name: /Fast/ })) + fireEvent.click(screen.getByRole('checkbox', { name: /Deep/ })) + expect(actions.toggleModel).toHaveBeenCalledWith('alpha\0fast') + expect(actions.toggleModel).toHaveBeenCalledWith('alpha\0deep') + }) + + it('renders directory progress, failures, unavailable routes, and validation', () => { + renderSubagentModelSelection({ enabled: true, catalogStatus: 'loading', invalid: true }) + expect(screen.getByText(en.subagentModelSelectionLoading)).toBeTruthy() + expect(screen.getByText(en.subagentModelSelectionRequired)).toBeTruthy() + + cleanup() + const errorActions = renderSubagentModelSelection({ enabled: true, catalogStatus: 'error' }) + fireEvent.click(screen.getByRole('button', { name: en.subagentModelSelectionRetry })) + expect(errorActions.retryCatalog).toHaveBeenCalledOnce() + + cleanup() + renderSubagentModelSelection({ + enabled: true, + catalogStatus: 'ready', + catalogPartial: true, + candidates: [{ + key: 'legacy\0old', + provider: 'legacy', + model: 'old', + providerName: 'legacy', + modelName: 'old', + available: false, + selected: true, + }], + }) + expect(screen.getByText(en.subagentModelSelectionPartial)).toBeTruthy() + expect(screen.getByText(en.subagentModelSelectionUnavailable)).toBeTruthy() + expect(screen.getByText(en.subagentModelSelectionUnavailableGroup)).toBeTruthy() + + cleanup() + renderSubagentModelSelection({ enabled: true, catalogStatus: 'ready' }) + expect(screen.getByText(en.subagentModelSelectionEmpty)).toBeTruthy() + }) + + it('distinguishes a stale draft from a rejected save', () => { + renderSubagentModelSelection({ dirty: true, conflicted: true }) + + expect(screen.getByText(en.subagentModelSelectionConflict)).toBeTruthy() + expect(screen.queryByText(en.saveFailed)).toBeNull() + }) + + it('renders its one-liner in the summary view, says so when unavailable, and disables writes when read-only', () => { + renderSubagentModelSelection({}, 'summary') + expect(document.body.textContent).toBe(en.subagentDescription) + + cleanup() + renderSubagentModelSelection({ available: false }) + expect(screen.getByRole('status').textContent).toBe(en.unavailable) + + cleanup() + const actions = renderSubagentModelSelection({ writable: false }) + const control = screen.getByRole('switch') as HTMLButtonElement + expect(control.disabled).toBe(true) + fireEvent.click(control) + expect(actions.toggleEnabled).not.toHaveBeenCalled() + }) +}) + +describe('SubagentCard', () => { + it('discards both drafts when leaving the page', () => { + const { actions } = renderSubagent({ dirty: true }, { dirty: true }) + cleanup() + expect(actions.discard).toHaveBeenCalledOnce() + }) + + it('renders limits without model selection when only limits are served', () => { + renderSubagent({}, { available: false }) + expect(screen.getByLabelText(en.subagentMaxDepth)).toBeTruthy() + expect(screen.queryByRole('switch')).toBeNull() + }) + + it('shows both sections on one page with one save footer', () => { + renderSubagent({ dirty: true }) + + expect(screen.getByRole('heading', { name: en.subagentLimitsTitle })).toBeTruthy() + expect(screen.getByRole('heading', { name: en.subagentModelSelectionTitle })).toBeTruthy() + expect(screen.getAllByRole('button', { name: en.save })).toHaveLength(1) + }) + + it('reveals field rules on demand without changing staged values', () => { + renderSubagent({ dirty: true, maxDepth: field('2') }) + const depthHelp = screen.getByRole('button', { name: en.subagentDepthHelpLabel }) + const capacityHelp = screen.getByRole('button', { name: en.subagentCapacityHelpLabel }) + expect(depthHelp.getAttribute('aria-expanded')).toBe('false') + expect(screen.queryByText(en.subagentDepthHelp)).toBeNull() + expect(screen.queryByText(en.subagentCapacityHelp)).toBeNull() + + fireEvent.click(depthHelp) + expect(depthHelp.getAttribute('aria-expanded')).toBe('true') + expect(screen.getByRole('region', { name: en.subagentDepthHelpLabel })).toBeTruthy() + expect(screen.getByText(en.subagentDepthHelp)).toBeTruthy() + expect(screen.getByRole('table', { name: en.subagentDepthHelpLabel })).toBeTruthy() + expect(screen.getByRole('row', { name: `0 ${en.subagentDepthZero}` })).toBeTruthy() + expect(screen.getByRole('row', { name: `1 ${en.subagentDepthOne}` })).toBeTruthy() + expect(screen.getByText(en.subagentDepthOverride)).toBeTruthy() + fireEvent.click(capacityHelp) + expect(screen.getByText(en.subagentCapacityHelp)).toBeTruthy() + fireEvent.click(depthHelp) + expect(screen.queryByText(en.subagentDepthHelp)).toBeNull() + expect(screen.getByLabelText(en.subagentMaxDepth)).toHaveProperty('value', '2') + expect(screen.getByRole('button', { name: en.save })).toHaveProperty('disabled', false) + }) + + it('keeps validation visible when the rules are collapsed and links it to the input', () => { + renderSubagent({ dirty: true, invalid: true, maxDepth: field('1.5', { invalid: true }) }) + const depth = screen.getByLabelText(en.subagentMaxDepth) + const messageId = depth.getAttribute('aria-describedby')! + expect(document.getElementById(messageId)?.textContent).toBe(en.subagentDepthInvalid) + expect(screen.queryByRole('region', { name: en.subagentDepthHelpLabel })).toBeNull() + }) + + it('edits and resets limits through the shared card', () => { + const { actions, limits } = renderSubagent({ + dirty: true, + maxDepth: field('3', { overridden: true }), + maxActiveSubagents: field('8', { overridden: true }), + }) + fireEvent.change(screen.getByLabelText(en.subagentMaxDepth), { target: { value: '2' } }) + fireEvent.change(screen.getByLabelText(en.subagentMaxActive), { target: { value: '12' } }) + expect(actions.editLimit.mock.calls).toEqual([['maxDepth', '2'], ['maxActiveSubagents', '12']]) + for (const button of screen.getAllByRole('button', { name: en.reset })) fireEvent.click(button) + expect(actions.resetLimit.mock.calls).toEqual([['maxDepth'], ['maxActiveSubagents']]) + act(() => { limits.set({ ...limits.getSnapshot(), writable: false }) }) + expect(screen.getByLabelText(en.subagentMaxActive)).toHaveProperty('disabled', true) + }) + + it('blocks saving both sections when a model selection is invalid or conflicted', () => { + const { models } = renderSubagent({ dirty: true }, { dirty: true, invalid: true }) + expect(screen.getByRole('button', { name: en.save })).toHaveProperty('disabled', true) + act(() => { models.set({ ...models.getSnapshot(), invalid: false, conflicted: true }) }) + expect(screen.getByRole('button', { name: en.save })).toHaveProperty('disabled', true) + }) + + it('locks both sections while either is saving and stays open after both settle', () => { + const { limits, models } = renderSubagent({ dirty: true }, { dirty: true }) + act(() => { + limits.set({ ...limits.getSnapshot(), saving: true }) + models.set({ ...models.getSnapshot(), saving: true }) + }) + expect(screen.getByLabelText(en.subagentMaxDepth)).toHaveProperty('disabled', true) + expect(screen.getByRole('switch')).toHaveProperty('disabled', true) + expect(screen.getByRole('button', { name: en.saving })).toHaveProperty('disabled', true) + act(() => { limits.set({ ...limits.getSnapshot(), saving: false, dirty: false }) }) + expect(screen.getByRole('switch')).toHaveProperty('disabled', true) + act(() => { models.set({ ...models.getSnapshot(), saving: false, dirty: false }) }) + expect(screen.getByRole('switch')).toHaveProperty('disabled', false) + expect(screen.getByRole('button', { name: en.save })).toHaveProperty('disabled', true) + }) + + it('keeps a rejected section open after the other section saves', () => { + const { limits, models } = renderSubagent({ dirty: true }, { dirty: true }) + act(() => { + limits.set({ ...limits.getSnapshot(), saving: true }) + models.set({ ...models.getSnapshot(), saving: true }) + }) + act(() => { + limits.set({ ...limits.getSnapshot(), saving: false, dirty: false }) + models.set({ ...models.getSnapshot(), saving: false, failed: true }) + }) + expect(screen.getByRole('switch')).toBeTruthy() + expect(screen.getByText(en.saveFailed)).toBeTruthy() + expect(screen.getByRole('button', { name: en.save })).toHaveProperty('disabled', false) + }) + + it('renders model-only deployments without limit controls', () => { + renderSubagent({ available: false }) + expect(screen.getByRole('switch')).toBeTruthy() + expect(screen.queryByLabelText(en.subagentMaxDepth)).toBeNull() + }) +}) diff --git a/packages/client/ui-settings-plugins/tests/stores.client.spec.ts b/packages/client/ui-settings-subagent/tests/controllers.client.spec.ts similarity index 57% rename from packages/client/ui-settings-plugins/tests/stores.client.spec.ts rename to packages/client/ui-settings-subagent/tests/controllers.client.spec.ts index 80da2dbb56..5607ccfb2e 100644 --- a/packages/client/ui-settings-plugins/tests/stores.client.spec.ts +++ b/packages/client/ui-settings-subagent/tests/controllers.client.spec.ts @@ -6,17 +6,13 @@ import { describe, expect, it, vi } from 'vitest' import type { SettingsPathOpView } from '@deepseek-ai/dsh-api-remotes/client' import { RemoteError, stubSettingsScope, type StubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' -import { CardForm, numberField, textField } from '../src/client/card-form.ts' import { SubagentLimitsCardController, type SubagentLimitsSettings } from '../src/client/subagent-limits-card-controller.ts' import { subagentCardFace, subagentCardShell } from '../src/client/subagent-card-controller.ts' -import { AgentLoopCardController, type AgentLoopSettings } from '../src/client/agent-loop-card-controller.ts' -import { BashCardController, type BashSettings } from '../src/client/bash-card-controller.ts' import { SubagentModelSelectionCardController, subagentModelCandidates, type SubagentModelSelectionSettings, } from '../src/client/subagent-model-selection-card-controller.ts' -import { WebSearchCardController, type WebSearchSettings } from '../src/client/web-search-card-controller.ts' /** Make the stub behave like a Host that accepts every write. */ function acceptWrites(host: StubSettingsScope): void { @@ -49,15 +45,6 @@ function ctxWith(namespaces: object) { return { remote: namespaces } as never } -function credentialsApi(configured: boolean) { - const describe = vi.fn(() => Promise.resolve({ - ok: true as const, - value: { DEEPSEEK_API_KEY: { configured, writable: true } }, - })) - const set = vi.fn(() => Promise.resolve({ ok: true as const, value: undefined })) - return { ctx: ctxWith({ credentials: { describe, set } }), describe, set } -} - function modelsApi(options: { groups?: readonly { id: string @@ -85,352 +72,6 @@ function deferred() { return { promise, resolve, reject } } -describe('CardForm', () => { - function form() { - const host = stubSettingsScope>() - const subject = new CardForm(host.scope, [numberField('timeoutMs'), textField('baseURL')]) - host.publish({ - status: 'ready', - writable: true, - value: { timeoutMs: 60_000, baseURL: 'https://search.test/v1' }, - base: { timeoutMs: 60_000, baseURL: 'https://search.test/v1' }, - user: {}, - }) - return { host, subject } - } - - it('shows the effective value and stays clean until something is staged', () => { - const { subject } = form() - - expect(subject.field('timeoutMs')).toEqual({ text: '60000', overridden: false, invalid: false }) - expect(subject.shell()).toMatchObject({ available: true, writable: true, dirty: false, invalid: false }) - }) - - it('marks a field the user layer carries as overridden', () => { - const { host, subject } = form() - - host.publish({ value: { timeoutMs: 60_000 }, user: { timeoutMs: 60_000 } }) - - // An override equal to the composition default is still an override. - expect(subject.field('timeoutMs').overridden).toBe(true) - }) - - it('writes nothing until the form is saved', async () => { - const { host, subject } = form() - acceptWrites(host) - - subject.actions().edit('timeoutMs', '9000') - - expect(subject.field('timeoutMs')).toEqual({ text: '9000', overridden: true, invalid: false }) - expect(subject.shell().dirty).toBe(true) - expect(host.set).not.toHaveBeenCalled() - - await subject.save() - - expect(host.set.mock.calls).toEqual([['timeoutMs', 9_000]]) - expect(subject.shell()).toMatchObject({ dirty: false, failed: false, saving: false }) - }) - - it('drops a draft that settles back on the value already shown', async () => { - const { host, subject } = form() - - subject.actions().edit('timeoutMs', '9000') - subject.actions().edit('timeoutMs', '60000') - - expect(subject.shell().dirty).toBe(false) - await subject.save() - - expect(host.set).not.toHaveBeenCalled() - }) - - it('refuses to save while a draft is not a value the field accepts', async () => { - const { host, subject } = form() - - subject.actions().edit('timeoutMs', 'soon') - - expect(subject.field('timeoutMs')).toEqual({ text: 'soon', overridden: false, invalid: true }) - expect(subject.shell()).toMatchObject({ dirty: true, invalid: true }) - - await subject.save() - - expect(host.set).not.toHaveBeenCalled() - expect(subject.field('timeoutMs').text).toBe('soon') - }) - - it('stages a reset that clears the field only once saved', async () => { - const { host, subject } = form() - acceptWrites(host) - host.publish({ value: { timeoutMs: 9_000 }, user: { timeoutMs: 9_000 } }) - - subject.actions().resetField('timeoutMs') - - // The badge previews the save: the field will no longer be overridden. - expect(subject.field('timeoutMs')).toEqual({ text: '60000', overridden: false, invalid: false }) - expect(host.unset).not.toHaveBeenCalled() - - await subject.save() - - expect(host.unset.mock.calls).toEqual([['timeoutMs']]) - expect(subject.shell()).toMatchObject({ dirty: false, failed: false }) - }) - - it('treats resetting an inherited field as no change at all', async () => { - const { host, subject } = form() - - subject.actions().resetField('timeoutMs') - - expect(subject.shell().dirty).toBe(false) - await subject.save() - - expect(host.unset).not.toHaveBeenCalled() - }) - - it('clears a number field by emptying it', async () => { - const { host, subject } = form() - acceptWrites(host) - host.publish({ user: { timeoutMs: 9_000 } }) - - subject.actions().edit('timeoutMs', '') - - expect(subject.field('timeoutMs')).toEqual({ text: '', overridden: false, invalid: false }) - await subject.save() - - expect(host.unset.mock.calls).toEqual([['timeoutMs']]) - }) - - it('clears a text field by emptying it', async () => { - const { host, subject } = form() - acceptWrites(host) - host.publish({ user: { baseURL: 'https://search.test/v1' } }) - - subject.actions().edit('baseURL', ' ') - await subject.save() - - expect(host.unset.mock.calls).toEqual([['baseURL']]) - }) - - it('writes the trimmed text of a text field', async () => { - const { host, subject } = form() - acceptWrites(host) - - subject.actions().edit('baseURL', ' https://other.test ') - await subject.save() - - expect(host.set.mock.calls).toEqual([['baseURL', 'https://other.test']]) - }) - - it('keeps the drafts a save did not land, and reports the failure', async () => { - const { host, subject } = form() - - subject.actions().edit('timeoutMs', '9000') - await subject.save() - - // The stub Host accepted the call without storing it, exactly as a - // validator that refuses the value does. - expect(host.set).toHaveBeenCalledWith('timeoutMs', 9_000) - expect(subject.shell()).toMatchObject({ dirty: true, failed: true, saving: false }) - expect(subject.field('timeoutMs').text).toBe('9000') - }) - - it('reports a reset the Host did not apply as a failure', async () => { - const { host, subject } = form() - host.publish({ user: { timeoutMs: 9_000 } }) - - subject.actions().resetField('timeoutMs') - await subject.save() - - expect(host.unset).toHaveBeenCalledWith('timeoutMs') - expect(subject.shell().failed).toBe(true) - }) - - it('clears the failure as soon as the user edits again', async () => { - const { subject } = form() - - subject.actions().edit('timeoutMs', '9000') - await subject.save() - expect(subject.shell().failed).toBe(true) - - subject.actions().edit('timeoutMs', '9001') - - expect(subject.shell().failed).toBe(false) - }) - - it('discards every staged edit', async () => { - const { host, subject } = form() - - subject.actions().edit('timeoutMs', '9000') - subject.actions().discard() - - expect(subject.field('timeoutMs').text).toBe('60000') - expect(subject.shell()).toMatchObject({ dirty: false, failed: false }) - - // A discard with nothing staged publishes nothing. - const before = subject.shell() - subject.actions().discard() - expect(subject.shell()).toEqual(before) - - await subject.save() - expect(host.set).not.toHaveBeenCalled() - }) - - it('refuses a second save while one is in flight', async () => { - const { host, subject } = form() - acceptWrites(host) - - subject.actions().edit('timeoutMs', '9000') - const first = subject.save() - expect(subject.shell().saving).toBe(true) - const second = subject.save() - await Promise.all([first, second]) - - expect(host.set).toHaveBeenCalledTimes(1) - }) - - it('publishes a projection whenever the scope or a draft changes', () => { - const { host, subject } = form() - const store = subject.bind(() => subject.field('timeoutMs').text) - expect(store.getSnapshot()).toBe('60000') - - host.publish({ value: { timeoutMs: 1_000 } }) - expect(store.getSnapshot()).toBe('1000') - - subject.actions().edit('timeoutMs', '2000') - expect(store.getSnapshot()).toBe('2000') - }) - - it('refuses to address a field the card never declared', () => { - const { subject } = form() - - expect(() => subject.field('nope')).toThrow('plugin card has no field nope') - }) - - it('renders an absent section value as an empty draft', () => { - const host = stubSettingsScope>() - const subject = new CardForm(host.scope, [numberField('timeoutMs'), textField('baseURL')]) - - host.publish({ status: 'ready', writable: true, value: {}, base: {}, user: undefined }) - - expect(subject.field('timeoutMs').text).toBe('') - expect(subject.field('baseURL').text).toBe('') - expect(subject.shell().available).toBe(true) - }) - - it('stays unavailable while the namespace is not served', () => { - const host = stubSettingsScope>() - const subject = new CardForm(host.scope, [numberField('timeoutMs')]) - - host.publish({ status: 'unavailable' }) - - expect(subject.shell()).toMatchObject({ available: false, writable: false }) - }) -}) - -describe('BashCardController', () => { - it('projects both fields and saves them in one write pass', async () => { - const host = stubSettingsScope() - acceptWrites(host) - const controller = new BashCardController(host.scope) - host.publish({ - status: 'ready', - writable: true, - value: { timeoutMs: 5_000, maxOutputBytes: 64_000 }, - base: { timeoutMs: 60_000, maxOutputBytes: 64_000 }, - user: { timeoutMs: 5_000 }, - }) - const face = controller.inject() - - expect(face.hooks.bashCard.getSnapshot()).toMatchObject({ - available: true, - writable: true, - dirty: false, - timeoutMs: { text: '5000', overridden: true }, - maxOutputBytes: { text: '64000', overridden: false }, - }) - - face.edit('timeoutMs', '9000') - face.edit('maxOutputBytes', '1024') - expect(face.hooks.bashCard.getSnapshot().dirty).toBe(true) - - face.save() - await vi.waitFor(() => { expect(host.set).toHaveBeenCalledTimes(2) }) - - expect(host.set.mock.calls).toEqual([['timeoutMs', 9_000], ['maxOutputBytes', 1_024]]) - expect(face.hooks.bashCard.getSnapshot().dirty).toBe(false) - }) - - it('stages a reset and applies it on save', async () => { - const host = stubSettingsScope() - acceptWrites(host) - const controller = new BashCardController(host.scope) - host.publish({ - status: 'ready', - writable: true, - value: { timeoutMs: 5_000 }, - base: { timeoutMs: 60_000 }, - user: { timeoutMs: 5_000 }, - }) - const face = controller.inject() - - face.resetField('timeoutMs') - expect(face.hooks.bashCard.getSnapshot().timeoutMs.text).toBe('60000') - - face.save() - await vi.waitFor(() => { expect(host.unset).toHaveBeenCalledWith('timeoutMs') }) - - expect(face.hooks.bashCard.getSnapshot()).toMatchObject({ - dirty: false, - timeoutMs: { text: '60000', overridden: false }, - }) - }) - - it('discards staged edits without writing', () => { - const host = stubSettingsScope() - const controller = new BashCardController(host.scope) - host.publish({ status: 'ready', writable: true, value: { timeoutMs: 5_000 }, user: {} }) - const face = controller.inject() - - face.edit('timeoutMs', '9000') - face.discard() - - expect(face.hooks.bashCard.getSnapshot().timeoutMs.text).toBe('5000') - expect(host.set).not.toHaveBeenCalled() - }) -}) - -describe('AgentLoopCardController', () => { - it('saves the only field it owns', async () => { - const host = stubSettingsScope() - acceptWrites(host) - const controller = new AgentLoopCardController(host.scope) - host.publish({ - status: 'ready', - writable: true, - value: { maxParallelToolCalls: 10 }, - base: { maxParallelToolCalls: 10 }, - user: {}, - }) - const face = controller.inject() - - face.edit('maxParallelToolCalls', '4') - face.save() - await vi.waitFor(() => { expect(host.set).toHaveBeenCalledWith('maxParallelToolCalls', 4) }) - - expect(face.hooks.agentLoopCard.getSnapshot()).toMatchObject({ - dirty: false, - maxParallelToolCalls: { text: '4', overridden: true }, - }) - }) - - it('reports a read-only document so the card can disable its controls', () => { - const host = stubSettingsScope() - const controller = new AgentLoopCardController(host.scope) - - host.publish({ status: 'ready', writable: false, value: { maxParallelToolCalls: 10 } }) - - expect(controller.inject().hooks.agentLoopCard.getSnapshot().writable).toBe(false) - }) -}) - describe('SubagentModelSelectionCardController', () => { it('joins stored routes with the live catalog without dropping unavailable choices', () => { const candidates = subagentModelCandidates( @@ -851,172 +492,6 @@ describe('SubagentModelSelectionCardController', () => { }) }) -describe('WebSearchCardController', () => { - it('reads the credential state for the reference the tab names', async () => { - const host = stubSettingsScope() - const credentials = credentialsApi(true) - const controller = new WebSearchCardController(host.scope, credentials.ctx) - const state = () => controller.inject().hooks.webSearchCard.getSnapshot() - await vi.waitFor(() => { expect(credentials.describe).toHaveBeenCalled() }) - - host.publish({ status: 'ready', writable: true, value: { baseURL: 'https://search.test/v1' }, user: {} }) - await vi.waitFor(() => { expect(state().apiKeyConfigured).toBe(true) }) - - expect(state()).toMatchObject({ - baseURL: { text: 'https://search.test/v1', overridden: false }, - apiKey: { text: '', overridden: false }, - }) - }) - - it('writes the staged key through the credentials domain, never the settings section', async () => { - const host = stubSettingsScope() - const credentials = credentialsApi(false) - const controller = new WebSearchCardController(host.scope, credentials.ctx) - host.publish({ status: 'ready', writable: true, value: {}, user: {} }) - const face = controller.inject() - - face.edit('apiKey', ' ds-secret ') - expect(face.hooks.webSearchCard.getSnapshot().dirty).toBe(true) - expect(credentials.set).not.toHaveBeenCalled() - - credentials.describe.mockImplementation(() => Promise.resolve({ - ok: true as const, - value: { DEEPSEEK_API_KEY: { configured: true, writable: true } }, - })) - face.save() - await vi.waitFor(() => { expect(credentials.set).toHaveBeenCalled() }) - - expect(credentials.set).toHaveBeenCalledWith('DEEPSEEK_API_KEY', 'ds-secret') - expect(host.set).not.toHaveBeenCalled() - await vi.waitFor(() => { - expect(face.hooks.webSearchCard.getSnapshot()).toMatchObject({ dirty: false, apiKeyConfigured: true }) - }) - }) - - it('keeps the stored key when the draft is left blank', () => { - const host = stubSettingsScope() - const credentials = credentialsApi(true) - const controller = new WebSearchCardController(host.scope, credentials.ctx) - host.publish({ status: 'ready', writable: true, value: {}, user: {} }) - const face = controller.inject() - - face.edit('apiKey', ' ') - - expect(face.hooks.webSearchCard.getSnapshot().dirty).toBe(false) - face.save() - - expect(credentials.set).not.toHaveBeenCalled() - }) - - it('re-reads when the Host reports the watched reference changed', async () => { - const host = stubSettingsScope() - const credentials = credentialsApi(false) - const controller = new WebSearchCardController(host.scope, credentials.ctx) - host.publish({ status: 'ready', writable: true, value: {}, user: {} }) - await vi.waitFor(() => { expect(credentials.describe).toHaveBeenCalled() }) - credentials.describe.mockClear() - - // Another reference is not this card's business. - controller.refreshCredential('OTHER_KEY') - expect(credentials.describe).not.toHaveBeenCalled() - - // A key written on another surface reaches this card only through this signal. - credentials.describe.mockImplementation(() => Promise.resolve({ - ok: true as const, - value: { DEEPSEEK_API_KEY: { configured: true, writable: true } }, - })) - controller.refreshCredential('DEEPSEEK_API_KEY') - - await vi.waitFor(() => { - expect(controller.inject().hooks.webSearchCard.getSnapshot().apiKeyConfigured).toBe(true) - }) - }) - - it('addresses the reference the tab declares rather than the default', async () => { - const host = stubSettingsScope() - const credentials = credentialsApi(false) - const controller = new WebSearchCardController(host.scope, credentials.ctx) - host.publish({ status: 'ready', writable: true, value: { apiKeyEnv: 'SEARCH_KEY' }, user: {} }) - const face = controller.inject() - - face.edit('apiKey', 'ds-secret') - face.save() - await vi.waitFor(() => { expect(credentials.set).toHaveBeenCalled() }) - - expect(credentials.set).toHaveBeenCalledWith('SEARCH_KEY', 'ds-secret') - }) - - it('reports a key the Host did not store as a failed save', async () => { - const host = stubSettingsScope() - const credentials = credentialsApi(false) - const controller = new WebSearchCardController(host.scope, credentials.ctx) - host.publish({ status: 'ready', writable: true, value: {}, user: {} }) - const face = controller.inject() - - face.edit('apiKey', 'ds-secret') - face.save() - - await vi.waitFor(() => { - expect(face.hooks.webSearchCard.getSnapshot()).toMatchObject({ failed: true, dirty: true }) - }) - }) - - it('keeps the card usable when the credential read is refused', async () => { - const host = stubSettingsScope() - const refusal = () => Promise.resolve({ - ok: false as const, - error: new RemoteError('credential/rejected', 'offline', { ref: 'DEEPSEEK_API_KEY' }), - }) - const describe = vi.fn(refusal) - const set = vi.fn(refusal) - const controller = new WebSearchCardController(host.scope, ctxWith({ credentials: { describe, set } })) - const face = controller.inject() - await vi.waitFor(() => { expect(describe).toHaveBeenCalled() }) - - host.publish({ status: 'ready', writable: true, value: { baseURL: 'https://search.test/v1' }, user: {} }) - face.edit('apiKey', 'ds-secret') - face.save() - await vi.waitFor(() => { expect(set).toHaveBeenCalled() }) - - expect(face.hooks.webSearchCard.getSnapshot()).toMatchObject({ - available: true, - apiKeyConfigured: false, - baseURL: { text: 'https://search.test/v1' }, - }) - }) - - it('ignores a credential read the Host refused', async () => { - const host = stubSettingsScope() - const describe = vi.fn(() => Promise.resolve({ - ok: false as const, - error: new RemoteError('gateway/internal', 'no credential provider', {}), - })) - const controller = new WebSearchCardController(host.scope, ctxWith({ - credentials: { describe, set: vi.fn() }, - })) - await vi.waitFor(() => { expect(describe).toHaveBeenCalled() }) - - expect(controller.inject().hooks.webSearchCard.getSnapshot().apiKeyConfigured).toBe(false) - }) - - it('saves the endpoint and the search budget together', async () => { - const host = stubSettingsScope() - acceptWrites(host) - const credentials = credentialsApi(true) - const controller = new WebSearchCardController(host.scope, credentials.ctx) - host.publish({ status: 'ready', writable: true, value: {}, base: {}, user: {} }) - const face = controller.inject() - - face.edit('baseURL', 'https://other.test') - face.edit('maxUses', '3') - face.save() - await vi.waitFor(() => { expect(host.set).toHaveBeenCalledTimes(2) }) - - expect(host.set.mock.calls).toEqual([['baseURL', 'https://other.test'], ['maxUses', 3]]) - expect(credentials.set).not.toHaveBeenCalled() - }) -}) - describe('SubagentLimitsCardController', () => { it('validates staged limits, saves them, and restores composed defaults', async () => { const host = stubSettingsScope() diff --git a/packages/client/ui-settings-subagent/tsconfig.json b/packages/client/ui-settings-subagent/tsconfig.json new file mode 100644 index 0000000000..a43ca3698f --- /dev/null +++ b/packages/client/ui-settings-subagent/tsconfig.json @@ -0,0 +1,42 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../locale" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../store" + }, + { + "path": "../../test-support/client-runtime" + }, + { + "path": "../ui-primitives" + }, + { + "path": "../ui-renderer" + }, + { + "path": "../ui-plugin-manager" + }, + { + "path": "../ui-settings" + }, + { + "path": "../../api/remotes/tsconfig.client.json" + }, + { + "path": "../ui-slots" + } + ] +} diff --git a/packages/client/ui-settings-subagent/tsdown.config.ts b/packages/client/ui-settings-subagent/tsdown.config.ts new file mode 100644 index 0000000000..f97175aba2 --- /dev/null +++ b/packages/client/ui-settings-subagent/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-ui-settings-subagent', ['lib/types/index.js']) diff --git a/packages/client/ui-settings-web-search/README.i18n.yaml b/packages/client/ui-settings-web-search/README.i18n.yaml new file mode 100644 index 0000000000..051d811f82 --- /dev/null +++ b/packages/client/ui-settings-web-search/README.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 packages/client/ui-settings-web-search/README.md +README.md: d0e272cde281f07c0ff0dc278bba4a4f44ecec38 +README.zh.md: f3dbefa0a5a6107d9e1afac56dc9b8369048a584 diff --git a/packages/client/ui-settings-web-search/README.md b/packages/client/ui-settings-web-search/README.md new file mode 100644 index 0000000000..d0e272cde2 --- /dev/null +++ b/packages/client/ui-settings-web-search/README.md @@ -0,0 +1,79 @@ +--- +description: "The DeepSeek web-search provider's settings page on the dsh web client's Plugins page: its API key, endpoint, and per-request search budget." +kind: "package-reference" +--- + +# @deepseek-ai/dsh-client-ui-settings-web-search + +English | [中文](README.zh.md) + +## Summary + +Open **Plugins** in the sidebar and select **Web search** in the Official group to set the provider's key, endpoint, and how many times one request may search. The page stages what is typed and writes it only on save; the key is written through the credentials domain rather than the settings document, so its literal never rides a response. The page exists while the Host serves the `web-search-deepseek` namespace. + +## Table of Contents + +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +The **Web search** card in the Official group opens the page. **API key** starts blank on every load and reports only whether a key is configured; a blank draft keeps the stored key, and the control is disabled when the credential cannot be written from here, such as a key the process environment supplies. **Endpoint** and **Max searches per request** render the effective value, carry an **Overridden** badge with **Reset to default** once overridden, and save as a reset when emptied. Nothing is written until **Save**; leaving the page drops the drafts. + +----- + + +## Understand the implementation + +
+Implementation internals — click to expand + +The Host half is an empty `apply`, present only so the package holds a Loader row the client module system serves the browser half for. The browser half binds the `web-search-deepseek` namespace through `ctx.settingsScope` and keeps the staged form in `WebSearchCardController` over the shared `SettingsFormModel` of `ui-primitives`, with the key as the form's one secret control: its write goes to `remote.credentials.set` under the reference the section's `apiKeyEnv` names (`DEEPSEEK_API_KEY` when it names none), and success is read back from `remote.credentials.describe`. The controller re-reads the credential when the scope changes and when the Host reports `credentials/reference-updated` for the watched reference, since a key written on the Models page changes no settings section. The page registers `WebSearchCard` into the Plugins page's `plugins.item` slot through `ctx.settingsScope.whileServed`. + +
+ +----- + + +## Further Exploration + +- [ui-plugin-manager](../ui-plugin-manager/README.md) — the Plugins page and the `plugins.item` slot the page registers into. +- [ui-settings](../ui-settings/README.md) — the settings scope and the served-namespace watch the page rides. +- [ui-primitives](../ui-primitives/README.md) — the settings form model and fields the page renders. +- [credentials](../../credentials/README.md) — the credential-reference seam the key writes through. +- [web-search-deepseek](../../web/web-search-deepseek/README.md) — the provider that registers the namespace. + +----- + + +## Model Experience + +None, as the package is a browser-side settings surface that registers no model surface. + +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + +## Known Limitations and Deferred Work + + + +- **Three fields of the namespace** — the provider's model, API version, and token budget stay at their composed values; the page edits the key, the endpoint, and the search budget only. +- **Runtime invariant:** No companion is published. The page holds no owned relationship of its own: what it shows derives from the settings mirror and the credentials domain, and what it writes the Host validates. + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
diff --git a/packages/client/ui-settings-web-search/README.zh.md b/packages/client/ui-settings-web-search/README.zh.md new file mode 100644 index 0000000000..f3dbefa0a5 --- /dev/null +++ b/packages/client/ui-settings-web-search/README.zh.md @@ -0,0 +1,79 @@ +--- +description: "dsh Web 客户端插件页上的 DeepSeek 网页搜索提供方设置页:API Key、接口地址与单次请求的搜索次数上限。" +kind: "package-reference" +--- + +# @deepseek-ai/dsh-client-ui-settings-web-search + +[English](README.md) | 中文 + +## 概述 + +在侧栏打开**插件**,在官方分组里选择**网页搜索**,即可设置提供方的密钥、接口地址,以及一次请求最多可以搜索几次。页面暂存输入、只在保存时写入;密钥通过凭据域写入而不进设置文件,其明文从不出现在任何响应里。页面只在 Host 服务 `web-search-deepseek` 命名空间期间存在。 + +## 目录 + +- [使用本包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [进一步探索](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与延期工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用本包 + +官方分组里的**网页搜索**卡片打开这一页。**API Key** 每次加载都是空的,只报告是否已配置密钥;留空保存等于保留现有密钥,而当凭据不能从这里写入时(例如由进程环境变量提供的密钥)该控件会被禁用。**接口地址**和**单次请求最多搜索次数**显示生效值,一旦被覆盖就带**已覆盖**标签和**恢复默认**,清空后保存等于重置。点击**保存**之前不会写入任何内容;离开页面即丢弃草稿。 + +----- + + +## 理解实现 + +
+实现细节——点击展开 + +宿主半侧是一个空的 `apply`,只为让本包占一条 Loader 行,客户端模块系统据此送出浏览器半侧。浏览器半侧通过 `ctx.settingsScope` 绑定 `web-search-deepseek` 命名空间,用 `ui-primitives` 的共享 `SettingsFormModel` 在 `WebSearchCardController` 里维护暂存表单,密钥是表单里唯一的密文控件:写入走 `remote.credentials.set`,引用名取自本节的 `apiKeyEnv`(未指定时为 `DEEPSEEK_API_KEY`),是否成功由 `remote.credentials.describe` 回读判定。scope 变化时、以及 Host 对所监视引用发出 `credentials/reference-updated` 时,控制器都会重读凭据,因为在模型页写入的密钥不会改变任何设置节。页面通过 `ctx.settingsScope.whileServed` 把 `WebSearchCard` 注册进插件页的 `plugins.item` slot。 + +
+ +----- + + +## 进一步探索 + +- [ui-plugin-manager](../ui-plugin-manager/README.zh.md)——插件页以及本页注册进去的 `plugins.item` slot。 +- [ui-settings](../ui-settings/README.zh.md)——本页依赖的设置 scope 与"命名空间被服务期间"的监视。 +- [ui-primitives](../ui-primitives/README.zh.md)——本页渲染的设置表单模型与字段。 +- [credentials](../../credentials/README.zh.md)——密钥写入所经的凭据引用 seam。 +- [web-search-deepseek](../../web/web-search-deepseek/README.zh.md)——注册该命名空间的提供方。 + +----- + + +## 模型体验 + +无,本包是浏览器侧的设置界面,不注册任何模型面。 + +#### KV 缓存影响 + +无;本包既不组装也不发送提供方请求。 + +## 已知限制与延期工作 + + + +- **只编辑命名空间的三个字段**——提供方的模型、API 版本和 token 预算保持组合值不变;本页只编辑密钥、接口地址和搜索次数。 +- **运行时不变量:**不发布伴生。本页没有自己拥有的关系:它显示的内容派生自设置镜像与凭据域,它写入的内容由 Host 校验。 + + +### 开发备注 + +
+维护者工作上下文——点击展开 + +无。 + +
diff --git a/packages/client/ui-settings-web-search/package.json b/packages/client/ui-settings-web-search/package.json new file mode 100644 index 0000000000..4959b285bc --- /dev/null +++ b/packages/client/ui-settings-web-search/package.json @@ -0,0 +1,65 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-settings-web-search", + "description": "Settings page of the DeepSeek web-search provider on the dsh web client's Plugins page: its API key, endpoint, and per-request search budget", + "version": "0.1.6-alpha.1", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/ui-settings-web-search" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-ui-settings", + "@deepseek-ai/dsh-client-ui-plugin-manager" + ], + "platform": "web" + } + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", + "@deepseek-ai/dsh-client-test-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-plugin-manager": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@types/react": "~18.3.1", + "react": "^18.2.0" + }, + "files": [ + "lib/index.js", + "lib/client.js", + "lib/types/**/*.d.ts" + ] +} diff --git a/packages/client/ui-settings-plugins/src/client/WebSearchCard.tsx b/packages/client/ui-settings-web-search/src/client/WebSearchCard.tsx similarity index 69% rename from packages/client/ui-settings-plugins/src/client/WebSearchCard.tsx rename to packages/client/ui-settings-web-search/src/client/WebSearchCard.tsx index 42fedcf685..1d56b46bb1 100644 --- a/packages/client/ui-settings-plugins/src/client/WebSearchCard.tsx +++ b/packages/client/ui-settings-web-search/src/client/WebSearchCard.tsx @@ -1,42 +1,37 @@ /** - * The web-search provider's configuration page: its endpoint, its per-request + * The web-search provider's settings page: its endpoint, its per-request * search budget, and the key — which is written through the credentials * domain, never into the settings section, so the literal never rides a response. */ import type {} from '@deepseek-ai/dsh-client-ui-plugin-manager/client' +import { SettingsForm, SettingsSecretField, SettingsValueField } from '@deepseek-ai/dsh-client-ui-primitives' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' -import { SecretField, ValueField } from './fields.tsx' -import { PluginConfigForm } from './PluginConfigForm.tsx' +import { formLabels } from './locales.ts' import type { WebSearchCardFace } from './web-search-card-controller.ts' /** Props the renderer binds for the web-search page. */ export type WebSearchCardProps = PropsRuntime<'plugins.item'> - & PropsLocale<'settings.plugins'> + & PropsLocale<'settings.webSearch'> & InjectFace /** - * Render the web-search provider's one-liner or its configuration form, as the Plugins page asks. + * Render the web-search provider's one-liner or its settings form, as the Plugins page asks. * @param props - the view asked for, locale copy, the form snapshot, and its actions. * @returns the one-liner, or the form. */ export function WebSearchCard(props: WebSearchCardProps) { const { t } = props const state = props.useWebSearchCard(snapshot => snapshot) - if (props.view === 'summary') return t('webSearchDescription') + if (props.view === 'summary') return t('description') const disabled = !state.writable return ( - - + { props.edit('apiKey', text) }} /> - { props.edit('baseURL', text) }} onReset={() => { props.resetField('baseURL') }} /> - { props.edit('maxUses', text) }} onReset={() => { props.resetField('maxUses') }} /> - + ) } diff --git a/packages/client/ui-settings-web-search/src/client/index.ts b/packages/client/ui-settings-web-search/src/client/index.ts new file mode 100644 index 0000000000..f0d90eab0f --- /dev/null +++ b/packages/client/ui-settings-web-search/src/client/index.ts @@ -0,0 +1,59 @@ +/** + * The web-search provider's settings page, browser half: the key, the + * endpoint, and the per-request search budget over the `web-search-deepseek` + * namespace the provider registers. The page registers into the Plugins + * page's `plugins.item` slot while the Host serves that namespace, so a + * deployment without the provider shows no trace of it. + */ + +// Type-only: pulls the locale plugin's Context merge (ctx.locale). +import type {} from '@deepseek-ai/dsh-client-locale/client' +// Type-only: the ctx.settingsScope Context merge. Cross-plugin collaboration +// goes through the service, never a value import (client bundle purity gate). +import type {} from '@deepseek-ai/dsh-client-ui-settings/client' +// Type-only: the Plugins page's SlotMap merge (the 'plugins.item' entry). +import type {} from '@deepseek-ai/dsh-client-ui-plugin-manager/client' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +// Type-only: the ctx.remote Context merge and the forwarded-event key face. +import type {} from '@deepseek-ai/dsh-api-remotes/client' +import type { Context as ClientContext } from '@deepseek-ai/cordis' +import { WebSearchCard } from './WebSearchCard.tsx' +import { WEB_SEARCH_NS, WebSearchCardController } from './web-search-card-controller.ts' +import { en, zh, type WebSearchSettingsLocaleKey } from './locales.ts' + +export type { WebSearchCardProps } from './WebSearchCard.tsx' +export type { WebSearchCardFace, WebSearchCardState, WebSearchSettings } from './web-search-card-controller.ts' +export type { WebSearchSettingsLocaleKey } from './locales.ts' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** Web-search settings page copy. */ + 'settings.webSearch': WebSearchSettingsLocaleKey + } +} + +/** Dictionary namespace owned by this plugin. */ +export const NS = 'settings.webSearch' + +/** Required services (cordis fiber inject). */ +export const inject = ['slots', 'locale', 'remote', 'remote.credentials', 'settingsScope'] + +/** + * Mount the web-search settings page while the Host serves its namespace. + * @param ctx - the browser plugin context. + */ +export function apply(ctx: ClientContext): void { + const t = ctx.locale.bind(NS) + ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-settings-web-search: dictionaries') + const card = new WebSearchCardController(ctx.settingsScope.bind({ namespace: WEB_SEARCH_NS }), ctx) + // The credential the page reports is not part of any settings section, so + // its scope publishes nothing when one is written. This is the only signal + // that a key written on another surface reached the Host. + ctx.effect( + () => ctx.remote.$on('credentials/reference-updated', (ref) => { card.refreshCredential(ref) }), + 'ui-settings-web-search: credential invalidations', + ) + ctx.effect(() => ctx.settingsScope.whileServed([WEB_SEARCH_NS], () => ctx.slots.inject('plugins.item', () => ctx.slots.register({ + name: 'plugins.item', id: 'web-search', order: 40, label: () => t('title'), locale: NS, inject: () => card.inject(), + }, WebSearchCard))), 'ui-settings-web-search: page') +} diff --git a/packages/client/ui-settings-web-search/src/client/locales.ts b/packages/client/ui-settings-web-search/src/client/locales.ts new file mode 100644 index 0000000000..4df1f4bb6e --- /dev/null +++ b/packages/client/ui-settings-web-search/src/client/locales.ts @@ -0,0 +1,64 @@ +/** Locale bundles for the web-search provider's settings page. */ + +import type { SettingsFormLabels } from '@deepseek-ai/dsh-client-ui-primitives' + +/** Locale keys the page renders. */ +export type WebSearchSettingsLocaleKey = + | 'title' | 'description' + | 'apiKey' | 'apiKeyHint' | 'apiKeySet' | 'apiKeyUnset' + | 'baseUrl' | 'baseUrlHint' | 'maxUses' | 'maxUsesHint' + | 'overridden' | 'reset' | 'readOnly' | 'unavailable' + | 'save' | 'saving' | 'saveFailed' | 'invalidNumber' + +/** English copy. */ +export const en: Record = { + title: 'Web search', + description: 'The DeepSeek search provider.', + apiKey: 'API key', + apiKeyHint: 'Stored outside the settings file. Leave blank to keep the current key.', + apiKeySet: 'A key is configured.', + apiKeyUnset: 'No key is configured; search is unavailable until one is.', + baseUrl: 'Endpoint', + baseUrlHint: 'Leave blank to use the provider default.', + maxUses: 'Max searches per request', + maxUsesHint: 'How many times one request may search before it must answer.', + overridden: 'Overridden', + reset: 'Reset to default', + readOnly: 'This deployment stores settings read-only.', + unavailable: 'This plugin is not loaded, so it cannot be configured right now.', + save: 'Save', + saving: 'Saving…', + saveFailed: 'The deployment did not accept these values; they were left for you to correct.', + invalidNumber: 'Enter a number, or leave blank to use the default.', +} + +/** Simplified Chinese copy. */ +export const zh: Record = { + title: '网页搜索', + description: 'DeepSeek 搜索提供方。', + apiKey: 'API Key', + apiKeyHint: '不写入设置文件。留空表示保持当前密钥。', + apiKeySet: '已配置密钥。', + apiKeyUnset: '未配置密钥;配置之前搜索不可用。', + baseUrl: '接口地址', + baseUrlHint: '留空则使用提供方默认地址。', + maxUses: '单次请求最多搜索次数', + maxUsesHint: '一次请求在必须作答前最多可以搜索多少次。', + overridden: '已覆盖', + reset: '恢复默认', + readOnly: '本部署的设置为只读。', + unavailable: '该插件当前未加载,暂时无法配置。', + save: '保存', + saving: '保存中…', + saveFailed: '本部署没有接受这些值,已保留供你修改。', + invalidNumber: '请填数字;留空表示使用默认值。', +} + +/** + * The form frame's copy, read from this page's dictionary. + * @param t - the page's locale reader. + * @returns the labels the shared settings form renders. + */ +export function formLabels(t: (key: WebSearchSettingsLocaleKey) => string): SettingsFormLabels { + return { unavailable: t('unavailable'), readOnly: t('readOnly'), saveFailed: t('saveFailed'), save: t('save'), saving: t('saving') } +} diff --git a/packages/client/ui-settings-plugins/src/client/web-search-card-controller.ts b/packages/client/ui-settings-web-search/src/client/web-search-card-controller.ts similarity index 83% rename from packages/client/ui-settings-plugins/src/client/web-search-card-controller.ts rename to packages/client/ui-settings-web-search/src/client/web-search-card-controller.ts index 35990763bb..51aaf3c29e 100644 --- a/packages/client/ui-settings-plugins/src/client/web-search-card-controller.ts +++ b/packages/client/ui-settings-web-search/src/client/web-search-card-controller.ts @@ -1,12 +1,12 @@ /** - * The web-search card's staged form over the `web-search-deepseek` settings + * The web-search page's staged form over the `web-search-deepseek` settings * namespace. * * The key is the one control that does not live in the section: its literal - * never rides a response, so the card learns only whether one is configured + * never rides a response, so the page learns only whether one is configured * and writes it through the credentials domain, addressed by the reference the * section names. It is still staged with the rest of the form, so one save - * covers everything the card shows. + * covers everything the page shows. */ import type { Context as ClientContext } from '@deepseek-ai/cordis' @@ -15,9 +15,9 @@ import type {} from '@deepseek-ai/dsh-api-remotes/client' import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { SettingsScope, SettingsScopeSnapshot } from '@deepseek-ai/dsh-client-ui-settings/client' import { - CardForm, numberField, textField, - type CardActions, type CardFieldState, type CardShell, -} from './card-form.ts' + SettingsFormModel, settingsNumberField, settingsTextField, + type SettingsFieldState, type SettingsFormActions, type SettingsFormShell, +} from '@deepseek-ai/dsh-client-ui-primitives' /** * Namespace of the DeepSeek search provider. Spelled here rather than @@ -31,7 +31,7 @@ const DEFAULT_API_KEY_REF = 'DEEPSEEK_API_KEY' /** Form field the credential control stages under. */ const API_KEY_FIELD = 'apiKey' -/** The search-provider fields this card edits. */ +/** The search-provider fields this page edits. */ export interface WebSearchSettings { /** Credential reference naming the environment key. */ apiKeyEnv?: string @@ -51,46 +51,46 @@ interface CredentialState { writable: boolean } -/** What the web-search card renders. */ -export interface WebSearchCardState extends CardShell { +/** What the web-search page renders. */ +export interface WebSearchCardState extends SettingsFormShell { /** Provider endpoint. */ - baseURL: CardFieldState + baseURL: SettingsFieldState /** Searches allowed per request. */ - maxUses: CardFieldState + maxUses: SettingsFieldState /** The staged credential, which starts blank on every load. */ - apiKey: CardFieldState + apiKey: SettingsFieldState /** Whether the Host reports a credential configured for the referenced key. */ apiKeyConfigured: boolean /** Whether the credentials domain accepts a write for it; false disables the control. */ apiKeyWritable: boolean } -/** The registration-side face the web-search card's slot entry injects. */ -export interface WebSearchCardFace extends CardActions { +/** The registration-side face the web-search page's slot entry injects. */ +export interface WebSearchCardFace extends SettingsFormActions { hooks: { - /** Card snapshot bound by the renderer as useWebSearchCard. */ + /** Page snapshot bound by the renderer as useWebSearchCard. */ webSearchCard: SnapshotStore } } -/** Bridges the `web-search-deepseek` scope and the credentials domain onto the card. */ +/** Bridges the `web-search-deepseek` scope and the credentials domain onto the page. */ export class WebSearchCardController { - private readonly form: CardForm + private readonly form: SettingsFormModel private readonly store: SnapshotStore private credential: CredentialState = { ref: '', configured: false, writable: true } /** * @param scope - the bound settings scope for the `web-search-deepseek` namespace. - * @param ctx - the card plugin's context, whose `remote.credentials` namespace + * @param ctx - the page plugin's context, whose `remote.credentials` namespace * answers for the credential the section references. */ constructor( private readonly scope: SettingsScope, private readonly ctx: ClientContext, ) { - this.form = new CardForm( + this.form = new SettingsFormModel( scope, - [textField('baseURL'), numberField('maxUses')], + [settingsTextField('baseURL'), settingsNumberField('maxUses')], [{ field: API_KEY_FIELD, write: text => this.writeKey(text) }], ) this.store = this.form.bind(() => this.projection()) @@ -132,7 +132,7 @@ export class WebSearchCardController { ref, configured: view?.configured ?? false, // An unknown reference is treated as writable: the control stays usable - // and the Host is what refuses, rather than the card guessing a refusal. + // and the Host is what refuses, rather than the page guessing a refusal. writable: view?.writable ?? true, } if (next.configured === this.credential.configured && next.writable === this.credential.writable) return @@ -141,7 +141,7 @@ export class WebSearchCardController { } /** - * Re-read after the Host reports a change to the reference this card watches. + * Re-read after the Host reports a change to the reference this page watches. * * A key can be written from somewhere else — the Models page addresses the * same reference — and the settings section does not change when it is, so @@ -154,8 +154,8 @@ export class WebSearchCardController { } /** - * Build the face the card's slot registration injects. - * @returns the card's snapshot and its form actions. + * Build the face the page's slot registration injects. + * @returns the page's snapshot and its form actions. */ inject(): WebSearchCardFace { return { hooks: { webSearchCard: this.store }, ...this.form.actions() } diff --git a/packages/client/ui-settings-web-search/src/css-modules.d.ts b/packages/client/ui-settings-web-search/src/css-modules.d.ts new file mode 100644 index 0000000000..8811db1264 --- /dev/null +++ b/packages/client/ui-settings-web-search/src/css-modules.d.ts @@ -0,0 +1,4 @@ +declare module '*.module.css' { + const classes: Record + export default classes +} diff --git a/packages/client/ui-settings-web-search/src/index.ts b/packages/client/ui-settings-web-search/src/index.ts new file mode 100644 index 0000000000..d3b9035057 --- /dev/null +++ b/packages/client/ui-settings-web-search/src/index.ts @@ -0,0 +1,11 @@ +/** + * Web-search settings page, node half. The empty apply exists so the plugin + * appears in the host cordis.yml / Loader; the browser half owns the page + * through exports["./client"], discovered from the package.json dsh.client + * declaration. The `web-search-deepseek` namespace the page edits is + * registered by the search provider, so this package registers no namespace + * of its own. + */ + +/** Host plugin body — no host-side behavior for this surface plugin. */ +export function apply(): void {} diff --git a/packages/client/ui-settings-web-search/tests/apply.client.spec.ts b/packages/client/ui-settings-web-search/tests/apply.client.spec.ts new file mode 100644 index 0000000000..bdff8383fe --- /dev/null +++ b/packages/client/ui-settings-web-search/tests/apply.client.spec.ts @@ -0,0 +1,109 @@ +/** What the browser half registers, when, which credential it watches, and that it all leaves with the fiber. */ + +import { Context } from '@deepseek-ai/cordis' +import { describe, expect, it, vi } from 'vitest' +import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' +import { RemoteError, TestRemote } from '@deepseek-ai/dsh-client-test-runtime' +import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' +import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client' +import { apply, inject, NS } from '../src/client/index.ts' +import type { WebSearchCardFace } from '../src/client/index.ts' +import { apply as hostApply } from '../src/index.ts' + +/** One Host view of a served namespace. */ +function view(ns: string, revision = 0) { + return { ns, schema: {}, value: {}, applies: 'live', secrets: [], revision } +} + +/** @param served - namespaces the Host describes; omitted answers a failed read. */ +async function bench(served?: string[]) { + const ctx = new Context() + await ctx.plugin(SlotRegistry).await() + const locale = new LocaleRuntime(ctx) + locale.setLocale('zh') + ctx.provide('locale', locale) + const describeCredentials = vi.fn(() => Promise.resolve({ + ok: false, error: new RemoteError('gateway/internal', 'no provider', {}), + })) + const describeSettings = vi.fn(() => Promise.resolve(served === undefined + ? { ok: false, error: new RemoteError('gateway/internal', 'no provider', {}) } + : { ok: true, value: { writable: true, hasDocument: true, namespaces: served.map(ns => view(ns)) } })) + const remote = new TestRemote(ctx, { + credentials: { describe: describeCredentials, set: vi.fn() }, + settings: { describe: describeSettings }, + }) + await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() + return { ctx, slots: ctx.get('slots') as SlotRegistry, describeCredentials, describeSettings, remote } +} + +/** The Plugins page's item slot, as its owner declares it. */ +function declareRoot(slots: SlotRegistry): () => void { + return slots.register({ + name: 'root', + children: { 'plugins.item': { kind: 'list', scope: 'root' } }, + } as never, () => null) +} + +describe('ui-settings-web-search apply', () => { + it('keeps the host Loader entry inert', () => { + expect(hostApply).not.toThrow() + }) + + it('declares the services it uses', () => { + expect(inject).toEqual(['slots', 'locale', 'remote', 'remote.credentials', 'settingsScope']) + }) + + it('registers the page while the Host serves the namespace, titled in the active locale', async () => { + const { ctx, slots } = await bench(['web-search-deepseek']) + declareRoot(slots) + + await ctx.plugin({ inject: [...inject], apply }).await() + + await vi.waitFor(() => { expect(slots.entries('plugins.item')).toHaveLength(1) }) + const entry = slots.entries('plugins.item')[0]! + expect(entry.options).toMatchObject({ id: 'web-search', order: 40 }) + expect(resolveSlotLabel(entry.options.label)).toBe('网页搜索') + expect(entry.locale).toBe(NS) + const face = (entry.inject as unknown as () => WebSearchCardFace)() + expect(Object.keys(face.hooks)).toEqual(['webSearchCard']) + }) + + it('registers nothing while the namespace is not served', async () => { + const { ctx, slots, describeSettings } = await bench(['shell']) + declareRoot(slots) + await ctx.plugin({ inject: [...inject], apply }).await() + await vi.waitFor(() => { expect(describeSettings).toHaveBeenCalled() }) + + expect(slots.entries('plugins.item')).toHaveLength(0) + }) + + it('re-reads the credential when the Host reports the watched reference changed, and ignores another', async () => { + const { ctx, slots, describeCredentials, remote } = await bench() + declareRoot(slots) + await ctx.plugin({ inject: [...inject], apply }).await() + await vi.waitFor(() => { expect(describeCredentials).toHaveBeenCalled() }) + describeCredentials.mockClear() + + remote.emit('credentials/reference-updated', ['SOME_OTHER_KEY']) + await Promise.resolve() + expect(describeCredentials).not.toHaveBeenCalled() + + // A key written on another surface changes no settings section, so this + // event is the only thing that reaches the page. + remote.emit('credentials/reference-updated', ['DEEPSEEK_API_KEY']) + await vi.waitFor(() => { expect(describeCredentials).toHaveBeenCalledTimes(1) }) + }) + + it('collapses the page on teardown', async () => { + const { ctx, slots } = await bench(['web-search-deepseek']) + declareRoot(slots) + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + await vi.waitFor(() => { expect(slots.entries('plugins.item')).toHaveLength(1) }) + + await fiber.dispose() + + expect(slots.entries('plugins.item')).toHaveLength(0) + }) +}) diff --git a/packages/client/ui-settings-web-search/tests/card.client.spec.tsx b/packages/client/ui-settings-web-search/tests/card.client.spec.tsx new file mode 100644 index 0000000000..7f0c480cf6 --- /dev/null +++ b/packages/client/ui-settings-web-search/tests/card.client.spec.tsx @@ -0,0 +1,101 @@ +// @vitest-environment jsdom +/** The web-search page as the Plugins page renders it: its key control, its two fields, and their resets. */ + +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { SettingsFieldState, SettingsFormShell } from '@deepseek-ai/dsh-client-ui-primitives' +import { WebSearchCard, type WebSearchCardProps } from '../src/client/WebSearchCard.tsx' +import type { WebSearchCardState } from '../src/client/web-search-card-controller.ts' +import { en } from '../src/client/locales.ts' + +afterEach(cleanup) + +const t = (key: keyof typeof en) => en[key] + +const settled: SettingsFormShell = { available: true, writable: true, dirty: false, invalid: false, saving: false, failed: false } + +function field(text: string, rest: Partial = {}): SettingsFieldState { + return { text, overridden: false, invalid: false, ...rest } +} + +function cardActions() { + return { edit: vi.fn(), resetField: vi.fn(), save: vi.fn(), discard: vi.fn() } +} + +describe('WebSearchCard', () => { + function renderWebSearch(state: Partial = {}) { + const store = createSnapshotStore({ + ...settled, + baseURL: field(''), + maxUses: field('5'), + apiKey: field(''), + apiKeyConfigured: false, + apiKeyWritable: true, + ...state, + }) + const actions = cardActions() + const props = { ...actions, view: 'page', t, useWebSearchCard: bindSnapshotSelector(store) } as unknown as WebSearchCardProps + render() + return actions + } + + it('renders its one-liner alone in the summary view', () => { + const store = createSnapshotStore({ + ...settled, baseURL: field(''), maxUses: field('5'), apiKey: field(''), apiKeyConfigured: false, apiKeyWritable: true, + }) + const props = { ...cardActions(), view: 'summary', t, useWebSearchCard: bindSnapshotSelector(store) } as unknown as WebSearchCardProps + render() + + expect(document.body.textContent).toBe(en.description) + expect(screen.queryByLabelText(en.apiKey)).toBeNull() + }) + + it('reports whether a key is configured without ever showing one', () => { + renderWebSearch({ apiKeyConfigured: true }) + + expect(screen.getByText(en.apiKeySet)).toBeTruthy() + expect(screen.getByLabelText(en.apiKey)).toHaveProperty('type', 'password') + }) + + it('keeps the key control usable while the settings document is read-only', () => { + const actions = renderWebSearch({ writable: false }) + + const key = screen.getByLabelText(en.apiKey) + expect(key).toHaveProperty('disabled', false) + expect(screen.getByLabelText(en.baseUrl)).toHaveProperty('disabled', true) + + fireEvent.change(key, { target: { value: 'ds-secret' } }) + + expect(actions.edit).toHaveBeenCalledWith('apiKey', 'ds-secret') + }) + + it('disables the key control when the reference itself is not writable', () => { + // A key coming from the process environment: the settings document is + // writable, the credential is not. + renderWebSearch({ apiKeyConfigured: true, apiKeyWritable: false }) + + expect(screen.getByLabelText(en.apiKey)).toHaveProperty('disabled', true) + expect(screen.getByLabelText(en.baseUrl)).toHaveProperty('disabled', false) + }) + + it('stages the endpoint, the search budget, and their resets', () => { + const actions = renderWebSearch({ + baseURL: field('https://search.test/v1', { overridden: true }), + maxUses: field('3', { overridden: true }), + }) + + fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://other.test' } }) + fireEvent.change(screen.getByLabelText(en.maxUses), { target: { value: '4' } }) + const resets = screen.getAllByRole('button', { name: en.reset }) + expect(resets).toHaveLength(2) + for (const reset of resets) fireEvent.click(reset) + + expect(actions.edit.mock.calls).toEqual([ + ['baseURL', 'https://other.test'], + ['maxUses', '4'], + ]) + expect(actions.resetField.mock.calls).toEqual([['baseURL'], ['maxUses']]) + }) +}) diff --git a/packages/client/ui-settings-web-search/tests/controller.client.spec.ts b/packages/client/ui-settings-web-search/tests/controller.client.spec.ts new file mode 100644 index 0000000000..8385f2b39d --- /dev/null +++ b/packages/client/ui-settings-web-search/tests/controller.client.spec.ts @@ -0,0 +1,202 @@ +/** + * The web-search page's form over a scripted settings scope and credentials + * domain: what it projects, where the key goes, and what a save writes. + */ + +import { describe, expect, it, vi } from 'vitest' +import { RemoteError, stubSettingsScope, type StubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' +import { WebSearchCardController, type WebSearchSettings } from '../src/client/web-search-card-controller.ts' + +/** Make the stub behave like a Host that accepts every write. */ +function acceptWrites(host: StubSettingsScope): void { + const section = (): Record => ({ ...host.scope.getSnapshot().value as object }) + const layer = (): Record => ({ ...host.scope.getSnapshot().user as object }) + host.set.mockImplementation((field: string, value: unknown) => { + host.publish({ value: { ...section(), [field]: value } as T, user: { ...layer(), [field]: value } }) + }) + host.unset.mockImplementation((field: string) => { + const user = Object.fromEntries(Object.entries(layer()).filter(([key]) => key !== field)) + const base = host.scope.getSnapshot().base as Record | undefined + host.publish({ value: { ...section(), [field]: base?.[field] } as T, user }) + }) +} + +/** The page plugin's context, scripted down to the namespaces the page reaches. */ +function ctxWith(namespaces: object) { + return { remote: namespaces } as never +} + +function credentialsApi(configured: boolean) { + const describe = vi.fn(() => Promise.resolve({ + ok: true as const, + value: { DEEPSEEK_API_KEY: { configured, writable: true } }, + })) + const set = vi.fn(() => Promise.resolve({ ok: true as const, value: undefined })) + return { ctx: ctxWith({ credentials: { describe, set } }), describe, set } +} + +describe('WebSearchCardController', () => { + it('reads the credential state for the reference the tab names', async () => { + const host = stubSettingsScope() + const credentials = credentialsApi(true) + const controller = new WebSearchCardController(host.scope, credentials.ctx) + const state = () => controller.inject().hooks.webSearchCard.getSnapshot() + await vi.waitFor(() => { expect(credentials.describe).toHaveBeenCalled() }) + + host.publish({ status: 'ready', writable: true, value: { baseURL: 'https://search.test/v1' }, user: {} }) + await vi.waitFor(() => { expect(state().apiKeyConfigured).toBe(true) }) + + expect(state()).toMatchObject({ + baseURL: { text: 'https://search.test/v1', overridden: false }, + apiKey: { text: '', overridden: false }, + }) + }) + + it('writes the staged key through the credentials domain, never the settings section', async () => { + const host = stubSettingsScope() + const credentials = credentialsApi(false) + const controller = new WebSearchCardController(host.scope, credentials.ctx) + host.publish({ status: 'ready', writable: true, value: {}, user: {} }) + const face = controller.inject() + + face.edit('apiKey', ' ds-secret ') + expect(face.hooks.webSearchCard.getSnapshot().dirty).toBe(true) + expect(credentials.set).not.toHaveBeenCalled() + + credentials.describe.mockImplementation(() => Promise.resolve({ + ok: true as const, + value: { DEEPSEEK_API_KEY: { configured: true, writable: true } }, + })) + face.save() + await vi.waitFor(() => { expect(credentials.set).toHaveBeenCalled() }) + + expect(credentials.set).toHaveBeenCalledWith('DEEPSEEK_API_KEY', 'ds-secret') + expect(host.set).not.toHaveBeenCalled() + await vi.waitFor(() => { + expect(face.hooks.webSearchCard.getSnapshot()).toMatchObject({ dirty: false, apiKeyConfigured: true }) + }) + }) + + it('keeps the stored key when the draft is left blank', () => { + const host = stubSettingsScope() + const credentials = credentialsApi(true) + const controller = new WebSearchCardController(host.scope, credentials.ctx) + host.publish({ status: 'ready', writable: true, value: {}, user: {} }) + const face = controller.inject() + + face.edit('apiKey', ' ') + + expect(face.hooks.webSearchCard.getSnapshot().dirty).toBe(false) + face.save() + + expect(credentials.set).not.toHaveBeenCalled() + }) + + it('re-reads when the Host reports the watched reference changed', async () => { + const host = stubSettingsScope() + const credentials = credentialsApi(false) + const controller = new WebSearchCardController(host.scope, credentials.ctx) + host.publish({ status: 'ready', writable: true, value: {}, user: {} }) + await vi.waitFor(() => { expect(credentials.describe).toHaveBeenCalled() }) + credentials.describe.mockClear() + + // Another reference is not this card's business. + controller.refreshCredential('OTHER_KEY') + expect(credentials.describe).not.toHaveBeenCalled() + + // A key written on another surface reaches this card only through this signal. + credentials.describe.mockImplementation(() => Promise.resolve({ + ok: true as const, + value: { DEEPSEEK_API_KEY: { configured: true, writable: true } }, + })) + controller.refreshCredential('DEEPSEEK_API_KEY') + + await vi.waitFor(() => { + expect(controller.inject().hooks.webSearchCard.getSnapshot().apiKeyConfigured).toBe(true) + }) + }) + + it('addresses the reference the tab declares rather than the default', async () => { + const host = stubSettingsScope() + const credentials = credentialsApi(false) + const controller = new WebSearchCardController(host.scope, credentials.ctx) + host.publish({ status: 'ready', writable: true, value: { apiKeyEnv: 'SEARCH_KEY' }, user: {} }) + const face = controller.inject() + + face.edit('apiKey', 'ds-secret') + face.save() + await vi.waitFor(() => { expect(credentials.set).toHaveBeenCalled() }) + + expect(credentials.set).toHaveBeenCalledWith('SEARCH_KEY', 'ds-secret') + }) + + it('reports a key the Host did not store as a failed save', async () => { + const host = stubSettingsScope() + const credentials = credentialsApi(false) + const controller = new WebSearchCardController(host.scope, credentials.ctx) + host.publish({ status: 'ready', writable: true, value: {}, user: {} }) + const face = controller.inject() + + face.edit('apiKey', 'ds-secret') + face.save() + + await vi.waitFor(() => { + expect(face.hooks.webSearchCard.getSnapshot()).toMatchObject({ failed: true, dirty: true }) + }) + }) + + it('keeps the card usable when the credential read is refused', async () => { + const host = stubSettingsScope() + const refusal = () => Promise.resolve({ + ok: false as const, + error: new RemoteError('credential/rejected', 'offline', { ref: 'DEEPSEEK_API_KEY' }), + }) + const describe = vi.fn(refusal) + const set = vi.fn(refusal) + const controller = new WebSearchCardController(host.scope, ctxWith({ credentials: { describe, set } })) + const face = controller.inject() + await vi.waitFor(() => { expect(describe).toHaveBeenCalled() }) + + host.publish({ status: 'ready', writable: true, value: { baseURL: 'https://search.test/v1' }, user: {} }) + face.edit('apiKey', 'ds-secret') + face.save() + await vi.waitFor(() => { expect(set).toHaveBeenCalled() }) + + expect(face.hooks.webSearchCard.getSnapshot()).toMatchObject({ + available: true, + apiKeyConfigured: false, + baseURL: { text: 'https://search.test/v1' }, + }) + }) + + it('ignores a credential read the Host refused', async () => { + const host = stubSettingsScope() + const describe = vi.fn(() => Promise.resolve({ + ok: false as const, + error: new RemoteError('gateway/internal', 'no credential provider', {}), + })) + const controller = new WebSearchCardController(host.scope, ctxWith({ + credentials: { describe, set: vi.fn() }, + })) + await vi.waitFor(() => { expect(describe).toHaveBeenCalled() }) + + expect(controller.inject().hooks.webSearchCard.getSnapshot().apiKeyConfigured).toBe(false) + }) + + it('saves the endpoint and the search budget together', async () => { + const host = stubSettingsScope() + acceptWrites(host) + const credentials = credentialsApi(true) + const controller = new WebSearchCardController(host.scope, credentials.ctx) + host.publish({ status: 'ready', writable: true, value: {}, base: {}, user: {} }) + const face = controller.inject() + + face.edit('baseURL', 'https://other.test') + face.edit('maxUses', '3') + face.save() + await vi.waitFor(() => { expect(host.set).toHaveBeenCalledTimes(2) }) + + expect(host.set.mock.calls).toEqual([['baseURL', 'https://other.test'], ['maxUses', 3]]) + expect(credentials.set).not.toHaveBeenCalled() + }) +}) diff --git a/packages/client/ui-settings-web-search/tsconfig.json b/packages/client/ui-settings-web-search/tsconfig.json new file mode 100644 index 0000000000..a43ca3698f --- /dev/null +++ b/packages/client/ui-settings-web-search/tsconfig.json @@ -0,0 +1,42 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../locale" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../store" + }, + { + "path": "../../test-support/client-runtime" + }, + { + "path": "../ui-primitives" + }, + { + "path": "../ui-renderer" + }, + { + "path": "../ui-plugin-manager" + }, + { + "path": "../ui-settings" + }, + { + "path": "../../api/remotes/tsconfig.client.json" + }, + { + "path": "../ui-slots" + } + ] +} diff --git a/packages/client/ui-settings-web-search/tsdown.config.ts b/packages/client/ui-settings-web-search/tsdown.config.ts new file mode 100644 index 0000000000..112d2e7f96 --- /dev/null +++ b/packages/client/ui-settings-web-search/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-ui-settings-web-search', ['lib/types/index.js']) diff --git a/packages/client/ui-settings/src/client/settings-scope.ts b/packages/client/ui-settings/src/client/settings-scope.ts index e795ef4afc..2dec36af4e 100644 --- a/packages/client/ui-settings/src/client/settings-scope.ts +++ b/packages/client/ui-settings/src/client/settings-scope.ts @@ -296,4 +296,35 @@ export class SettingsScopeBinder extends Service { }, `ui-settings: ${spec.namespace} settings scope`) return controller } + + /** + * Keep a registration alive while the Host serves any of some namespaces: + * `register` runs once one of them is in the describe mirror, and its + * disposer runs when none is or when the returned disposer runs. A plugin + * whose page edits a namespace another plugin owns registers the page + * through this, so a deployment that never composed the owner shows no + * trace of the page. + * @param namespaces - the settings namespaces the registration follows. + * @param register - registers the contribution; returns its disposer. + * @returns the disposer ending the watch and any live registration. + */ + whileServed(namespaces: readonly string[], register: () => () => void): () => void { + let off: (() => void) | undefined + const sync = (): void => { + const served = this.mirror.getSnapshot().view?.namespaces.some(view => namespaces.includes(view.ns)) ?? false + if (served && off === undefined) off = register() + else if (!served && off !== undefined) { + off() + off = undefined + } + } + const unsubscribe = this.mirror.subscribe(sync) + void this.mirror.ensure() + sync() + return () => { + unsubscribe() + off?.() + off = undefined + } + } } diff --git a/packages/client/ui-settings/tests/while-served.client.spec.ts b/packages/client/ui-settings/tests/while-served.client.spec.ts new file mode 100644 index 0000000000..4311314d38 --- /dev/null +++ b/packages/client/ui-settings/tests/while-served.client.spec.ts @@ -0,0 +1,94 @@ +/** The served-namespace watch: a registration that lives exactly while the Host serves a namespace. */ + +import { Context } from '@deepseek-ai/cordis' +import { describe, expect, it, vi } from 'vitest' +import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' +import { SettingsSchemaService } from '../src/client/schema.ts' +import { SettingsScopeBinder } from '../src/client/settings-scope.ts' +import { SettingsDescribeMirror } from '../src/client/settings-mirror.ts' + +/** What the Host describes: the named namespaces, each an empty section. */ +function described(namespaces: readonly string[], revision = 0) { + return { + ok: true as const, + value: { + writable: true, + hasDocument: true, + namespaces: namespaces.map(ns => ({ ns, schema: {}, value: {}, applies: 'live', secrets: [], revision })), + }, + } +} + +/** A binder over a scripted Host, and the plugin context that will watch through it. */ +async function bench(namespaces: readonly string[]) { + const describeCall = vi.fn().mockResolvedValue(described(namespaces)) + const ctx = new Context() + new TestRemote(ctx, { settings: { describe: describeCall } }) + const mirror = new SettingsDescribeMirror({ remote: { settings: { describe: describeCall } } } as never) + await ctx.plugin(SettingsScopeBinder, { mirror, schema: new SettingsSchemaService(ctx), persistence: 'host' }).await() + return { ctx, mirror, describeCall } +} + +describe('SettingsScopeBinder.whileServed', () => { + it('registers once any watched namespace is served, and withdraws when none is', async () => { + const { ctx, mirror, describeCall } = await bench(['agent-loop']) + const dispose = vi.fn() + const register = vi.fn(() => dispose) + let off!: () => void + const fiber = ctx.plugin({ + inject: ['settingsScope'], + apply: (plugin: Context) => { + off = plugin.settingsScope.whileServed(['shell', 'subagent'], register) + }, + }) + await fiber.await() + await mirror.ensure() + expect(register).not.toHaveBeenCalled() + + describeCall.mockResolvedValue(described(['agent-loop', 'subagent'], 1)) + await mirror.load() + expect(register).toHaveBeenCalledOnce() + // Still served: a further read changes nothing. + await mirror.load() + expect(register).toHaveBeenCalledOnce() + expect(dispose).not.toHaveBeenCalled() + + describeCall.mockResolvedValue(described(['agent-loop'], 2)) + await mirror.load() + expect(dispose).toHaveBeenCalledOnce() + + describeCall.mockResolvedValue(described(['shell'], 3)) + await mirror.load() + expect(register).toHaveBeenCalledTimes(2) + + off() + expect(dispose).toHaveBeenCalledTimes(2) + // Ended: the watch no longer follows the Host. + describeCall.mockResolvedValue(described([], 4)) + await mirror.load() + describeCall.mockResolvedValue(described(['shell'], 5)) + await mirror.load() + expect(register).toHaveBeenCalledTimes(2) + expect(dispose).toHaveBeenCalledTimes(2) + await fiber.dispose() + }) + + it('registers at once for a namespace the mirror already holds, and ending the watch with nothing live is harmless', async () => { + const { ctx, mirror } = await bench(['shell']) + await mirror.ensure() + const dispose = vi.fn() + let off!: () => void + await ctx.plugin({ + inject: ['settingsScope'], + apply: (plugin: Context) => { + off = plugin.settingsScope.whileServed(['shell'], () => dispose) + }, + }).await() + + expect(dispose).not.toHaveBeenCalled() + off() + expect(dispose).toHaveBeenCalledOnce() + off() + expect(dispose).toHaveBeenCalledOnce() + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 99e2ff0442..8a9475984e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2169,6 +2169,9 @@ importers: '@deepseek-ai/dsh-client-ui-settings': specifier: workspace:^ version: link:../../client/ui-settings + '@deepseek-ai/dsh-client-ui-settings-agent-loop': + specifier: workspace:^ + version: link:../../client/ui-settings-agent-loop '@deepseek-ai/dsh-client-ui-settings-general': specifier: workspace:^ version: link:../../client/ui-settings-general @@ -2181,9 +2184,18 @@ importers: '@deepseek-ai/dsh-client-ui-settings-plugins': specifier: workspace:^ version: link:../../client/ui-settings-plugins + '@deepseek-ai/dsh-client-ui-settings-shell': + specifier: workspace:^ + version: link:../../client/ui-settings-shell + '@deepseek-ai/dsh-client-ui-settings-subagent': + specifier: workspace:^ + version: link:../../client/ui-settings-subagent '@deepseek-ai/dsh-client-ui-settings-unarchive-sessions': specifier: workspace:^ version: link:../../client/ui-settings-unarchive-sessions + '@deepseek-ai/dsh-client-ui-settings-web-search': + specifier: workspace:^ + version: link:../../client/ui-settings-web-search '@deepseek-ai/dsh-client-ui-sidebar': specifier: workspace:^ version: link:../../client/ui-sidebar @@ -3746,6 +3758,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-client-store': + specifier: workspace:^ + version: link:../store '@shikijs/langs': specifier: ^4.3.1 version: 4.3.1 @@ -4014,6 +4029,45 @@ importers: specifier: ^18.2.0 version: 18.3.1 + packages/client/ui-settings-agent-loop: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-remotes': + specifier: workspace:^ + version: link:../../api/remotes + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-store': + specifier: workspace:^ + version: link:../store + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../../test-support/client-runtime + '@deepseek-ai/dsh-client-ui-plugin-manager': + specifier: workspace:^ + version: link:../ui-plugin-manager + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer + '@deepseek-ai/dsh-client-ui-settings': + specifier: workspace:^ + version: link:../ui-settings + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + react: + specifier: ^18.2.0 + version: 18.3.1 + packages/client/ui-settings-general: dependencies: '@deepseek-ai/schemastery': @@ -4166,6 +4220,72 @@ importers: version: 18.3.1(react@18.3.1) packages/client/ui-settings-plugins: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../../test-support/client-runtime + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer + '@deepseek-ai/dsh-client-ui-settings': + specifier: workspace:^ + version: link:../ui-settings + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + react: + specifier: ^18.2.0 + version: 18.3.1 + + packages/client/ui-settings-shell: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-remotes': + specifier: workspace:^ + version: link:../../api/remotes + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-store': + specifier: workspace:^ + version: link:../store + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../../test-support/client-runtime + '@deepseek-ai/dsh-client-ui-plugin-manager': + specifier: workspace:^ + version: link:../ui-plugin-manager + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer + '@deepseek-ai/dsh-client-ui-settings': + specifier: workspace:^ + version: link:../ui-settings + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + react: + specifier: ^18.2.0 + version: 18.3.1 + + packages/client/ui-settings-subagent: devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -4200,9 +4320,6 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 - clsx: - specifier: ^2.0.0 - version: 2.1.1 react: specifier: ^18.2.0 version: 18.3.1 @@ -4258,6 +4375,45 @@ importers: specifier: ^18.2.0 version: 18.3.1(react@18.3.1) + packages/client/ui-settings-web-search: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-remotes': + specifier: workspace:^ + version: link:../../api/remotes + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-store': + specifier: workspace:^ + version: link:../store + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../../test-support/client-runtime + '@deepseek-ai/dsh-client-ui-plugin-manager': + specifier: workspace:^ + version: link:../ui-plugin-manager + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer + '@deepseek-ai/dsh-client-ui-settings': + specifier: workspace:^ + version: link:../ui-settings + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + react: + specifier: ^18.2.0 + version: 18.3.1 + packages/client/ui-sidebar: devDependencies: '@deepseek-ai/cordis': diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index a3c030bc0b..ed2fec5409 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -116,6 +116,10 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/extensions/ui-cordis': { kind: 'indirect', reason: 'The definition card drives the host dynamic run/stop verbs that the model\'s cordis_run/cordis_stop tools also reach; the runner owns any model-visible effect.' }, 'packages/client/ui-permission-presets': { kind: 'indirect', reason: 'The picker submits the host /permission command; the knob events it appends own the model-visible effect through the sandbox/approval consumers.' }, 'packages/client/ui-settings-plugins': { kind: 'none', reason: 'Browser-side settings surface; registers no model surface.' }, + 'packages/client/ui-settings-shell': { kind: 'none', reason: 'Browser-side settings surface; registers no model surface.' }, + 'packages/client/ui-settings-agent-loop': { kind: 'none', reason: 'Browser-side settings surface; registers no model surface.' }, + 'packages/client/ui-settings-subagent': { kind: 'none', reason: 'Browser-side settings surface; registers no model surface.' }, + 'packages/client/ui-settings-web-search': { kind: 'none', reason: 'Browser-side settings surface; registers no model surface.' }, 'packages/client/ui-plan': { kind: 'indirect', reason: 'The chip dispatches /plan off; dsh-plan-mode owns the model-visible policy, exit tool, and logged state.' }, 'packages/client/ui-user-questions': { kind: 'indirect', reason: 'The package mounts dsh-tool-ask-user; that tool owns the model-visible schema and answer rendering.' }, 'packages/client/ui-trajectory': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index 6ec529b39d..9ea5d8d206 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -242,6 +242,10 @@ "@deepseek-ai/dsh-client-ui-subagent": ["./packages/client/ui-subagent/src"], "@deepseek-ai/dsh-experimental-client-ui-agent-team": ["./packages/experimental/client-ui-agent-team/src"], "@deepseek-ai/dsh-client-ui-settings-plugins": ["./packages/client/ui-settings-plugins/src"], + "@deepseek-ai/dsh-client-ui-settings-shell": ["./packages/client/ui-settings-shell/src"], + "@deepseek-ai/dsh-client-ui-settings-agent-loop": ["./packages/client/ui-settings-agent-loop/src"], + "@deepseek-ai/dsh-client-ui-settings-subagent": ["./packages/client/ui-settings-subagent/src"], + "@deepseek-ai/dsh-client-ui-settings-web-search": ["./packages/client/ui-settings-web-search/src"], "@deepseek-ai/dsh-client-ui-jobs": ["./packages/client/ui-jobs/src"], "@deepseek-ai/dsh-client-ui-schedule": ["./packages/client/ui-schedule/src"], "@deepseek-ai/dsh-client-ui-schedule/client": ["./packages/client/ui-schedule/src/client/index.ts"], diff --git a/tsconfig.client.json b/tsconfig.client.json index b830d85b2f..7b0982ba51 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -104,6 +104,10 @@ { "path": "./packages/client/ui-permission-presets" }, { "path": "./packages/client/ui-plan" }, { "path": "./packages/client/ui-settings-plugins" }, + { "path": "./packages/client/ui-settings-shell" }, + { "path": "./packages/client/ui-settings-agent-loop" }, + { "path": "./packages/client/ui-settings-subagent" }, + { "path": "./packages/client/ui-settings-web-search" }, { "path": "./packages/client/ui-user-questions" }, { "path": "./packages/client/ui-trajectory" }, { "path": "./packages/session-query/session-log-export/tsconfig.client.json" }, From caa0c99be17c3a0c981d25a8fb04441e83e2b232 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 17 Sep 2026 20:13:26 +0800 Subject: [PATCH 3/7] docs(web): follow up the review on the companion move and detail slots The maintained docs now name the four companion packages where they still credited `ui-settings-plugins`: the web-app row comment, the Plugins page README, the ui-settings README, and the client group README, which gains a row per companion. The ui-settings README states the `whileServed` lifetime and disposer ownership beside `bind`, and the JSDoc says the caller owns the disposer. The settings-page cookbook keeps the one home of the lazy-CJS build limitation for packages outside this repository, its JSX example is a tsx fence, and the architecture index and website label follow the page's title. The Agent Note records the shell page's `plugins.item` id, the two companions that use `ctx.remote` list api-remotes in `dsh.client.inject`, and the moved form model test speaks of the form. --- ...-09-17-settings-pages-as-companion-packages.i18n.yaml | 4 ++-- .../2026-09-17-settings-pages-as-companion-packages.md | 1 + ...2026-09-17-settings-pages-as-companion-packages.zh.md | 1 + docs/architecture.i18n.yaml | 4 ++-- docs/architecture.md | 2 +- docs/architecture.zh.md | 2 +- docs/cookbook/adding-a-settings-card.i18n.yaml | 4 ++-- docs/cookbook/adding-a-settings-card.md | 4 +++- docs/cookbook/adding-a-settings-card.zh.md | 4 +++- packages/bundle/web-app/cordis.patch.yml | 5 +++-- packages/client/README.i18n.yaml | 4 ++-- packages/client/README.md | 6 +++++- packages/client/README.zh.md | 6 +++++- packages/client/ui-plugin-manager/README.i18n.yaml | 4 ++-- packages/client/ui-plugin-manager/README.md | 4 ++-- packages/client/ui-plugin-manager/README.zh.md | 4 ++-- .../tests/settings-form-model.client.spec.ts | 2 +- packages/client/ui-settings-subagent/package.json | 3 ++- packages/client/ui-settings-web-search/package.json | 3 ++- packages/client/ui-settings/README.i18n.yaml | 4 ++-- packages/client/ui-settings/README.md | 9 +++++++-- packages/client/ui-settings/README.zh.md | 9 +++++++-- packages/client/ui-settings/src/client/settings-scope.ts | 4 +++- website/docs.ts | 2 +- 24 files changed, 62 insertions(+), 33 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-09-17-settings-pages-as-companion-packages.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-17-settings-pages-as-companion-packages.i18n.yaml index 0dd8337ca2..665be1daa4 100644 --- a/.agents/notes/implemented/architecture/2026-09-17-settings-pages-as-companion-packages.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-17-settings-pages-as-companion-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-09-17-settings-pages-as-companion-packages.md -2026-09-17-settings-pages-as-companion-packages.md: dc604c010787ee35522b351b643fb2691540b4f9 -2026-09-17-settings-pages-as-companion-packages.zh.md: 066b17c9760f381089a6a48b1ec6606e600531d3 +2026-09-17-settings-pages-as-companion-packages.md: de2f2897938fcc158c49bed772f399b4d9328d19 +2026-09-17-settings-pages-as-companion-packages.zh.md: 27d00faa156f0f48c6dc3995930be9ca629a732d diff --git a/.agents/notes/implemented/architecture/2026-09-17-settings-pages-as-companion-packages.md b/.agents/notes/implemented/architecture/2026-09-17-settings-pages-as-companion-packages.md index dc604c0107..de2f289793 100644 --- a/.agents/notes/implemented/architecture/2026-09-17-settings-pages-as-companion-packages.md +++ b/.agents/notes/implemented/architecture/2026-09-17-settings-pages-as-companion-packages.md @@ -33,6 +33,7 @@ The four official settings pages — shell, agent loop, Subagent, web search — - The Plugins page's Official group lists the same four pages in the same order, from four packages instead of one; their labels and forms are unchanged, so the page goldens hold. - The cookbook [adding a settings page](../../../../docs/cookbook/adding-a-settings-card.md) shows the community path and names the companion template. - The client module system's rule stands: a browser half rides the Loader row whose specifier is the bare package name, so a bundle split into subpath rows keeps every page it registers on its root row. +- The shell page's `plugins.item` id is `shell`, where the old package registered `bash`: `subject.id` on an official page is now a discriminator `plugins.detail.*` contributions key on, so the id names the capability the page edits rather than one executor family. ## Testing diff --git a/.agents/notes/implemented/architecture/2026-09-17-settings-pages-as-companion-packages.zh.md b/.agents/notes/implemented/architecture/2026-09-17-settings-pages-as-companion-packages.zh.md index 066b17c976..27d00faa15 100644 --- a/.agents/notes/implemented/architecture/2026-09-17-settings-pages-as-companion-packages.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-17-settings-pages-as-companion-packages.zh.md @@ -33,6 +33,7 @@ - 插件页官方分组列出同样四个页面、同样顺序,来自四个包而不是一个;标签与表单未变,页面 golden 保持不变。 - 手册[添加设置页](../../../../docs/cookbook/adding-a-settings-card.zh.md)展示社区路径并点名伴生包模板。 - 客户端模块系统的规则不变:浏览器半侧挂在说明符为裸包名的 Loader 行上,因此拆成子路径行的组合包,其注册的所有页面都留在根行上。 +- 终端页在 `plugins.item` 里的 id 是 `shell`,旧包注册的是 `bash`:官方页面上的 `subject.id` 现在是 `plugins.detail.*` 贡献可以据以判断的标识,所以 id 以页面编辑的能力命名,而不是某一个执行器家族。 ## 测试 diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index ead9c04e54..63e1078eaa 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: 37aaf83e37fb5cb7e3df4bd6ed034ccd42103919 -architecture.zh.md: 084fa76a045a9ae744228e8e43900f174b26ae9e +architecture.md: ebb28a79fb17ff1364b59dddfaeb5b6eb723bd1f +architecture.zh.md: 0a19671c6bb36df689585e8f5654ce721834bd49 diff --git a/docs/architecture.md b/docs/architecture.md index 37aaf83e37..ebb28a79fb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -161,4 +161,4 @@ New behavior attaches to a documented extension point. Changing the loop itself | Store sessions in a new backend | implement `SessionPersistence` (`create`/`open`/`stat`/`list`/`export`) over the shared handle scaffolding | | Scope a registration to one agent | use that agent's `agent.ctx` | -The [extension cookbook](cookbook/extension-cookbook.md) maps features to capabilities and indexes the step-by-step guides for [packages](cookbook/adding-a-package.md), [tools](cookbook/adding-a-tool.md), [LLM adapters](cookbook/adding-an-llm-adapter.md), and [settings cards](cookbook/adding-a-settings-card.md). The [Conversation subsystem](subsystems/conversation.md) owns Chat-node assembly. +The [extension cookbook](cookbook/extension-cookbook.md) maps features to capabilities and indexes the step-by-step guides for [packages](cookbook/adding-a-package.md), [tools](cookbook/adding-a-tool.md), [LLM adapters](cookbook/adding-an-llm-adapter.md), and [settings pages](cookbook/adding-a-settings-card.md). The [Conversation subsystem](subsystems/conversation.md) owns Chat-node assembly. diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 084fa76a04..0a19671c6b 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -165,4 +165,4 @@ seam 正是替换一个提供方就能改变整个产品的原因。文件系统 | 在新后端存储会话 | 基于共享的句柄脚手架实现 `SessionPersistence`(`create`/`open`/`stat`/`list`/`export`) | | 将注册项限定到单个 agent | 使用该 agent 的 `agent.ctx` | -[扩展实操手册](cookbook/extension-cookbook.zh.md)将功能映射到能力,并索引[包](cookbook/adding-a-package.zh.md)、[工具](cookbook/adding-a-tool.zh.md)、[LLM(大语言模型)适配器](cookbook/adding-an-llm-adapter.zh.md)和[设置卡片](cookbook/adding-a-settings-card.zh.md)的分步指南。[Conversation 子系统](subsystems/conversation.zh.md)负责 Chat node 组装。 +[扩展实操手册](cookbook/extension-cookbook.zh.md)将功能映射到能力,并索引[包](cookbook/adding-a-package.zh.md)、[工具](cookbook/adding-a-tool.zh.md)、[LLM(大语言模型)适配器](cookbook/adding-an-llm-adapter.zh.md)和[设置页](cookbook/adding-a-settings-card.zh.md)的分步指南。[Conversation 子系统](subsystems/conversation.zh.md)负责 Chat node 组装。 diff --git a/docs/cookbook/adding-a-settings-card.i18n.yaml b/docs/cookbook/adding-a-settings-card.i18n.yaml index d04ea95824..1c493f63fe 100644 --- a/docs/cookbook/adding-a-settings-card.i18n.yaml +++ b/docs/cookbook/adding-a-settings-card.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-settings-card.md -adding-a-settings-card.md: cc8daa90bcc3a757cd41dbcaecb0eab662d1dba8 -adding-a-settings-card.zh.md: a0fce4075b9e29630c772bc52e07cd00d89d5bab +adding-a-settings-card.md: 72d7eaeb85d0d4f6981e814a8c335428c561b6bd +adding-a-settings-card.zh.md: d1254078085ba1de541ea70db566bcfb1bd50d1d diff --git a/docs/cookbook/adding-a-settings-card.md b/docs/cookbook/adding-a-settings-card.md index cc8daa90bc..72d7eaeb85 100644 --- a/docs/cookbook/adding-a-settings-card.md +++ b/docs/cookbook/adding-a-settings-card.md @@ -84,7 +84,7 @@ export function apply(ctx: ClientContext): void { A plugin with something to say about a bundle, a row, or an official plugin it does not own registers into `plugins.detail.actions` (a control at the head of the page), `plugins.detail.badge` (a tag beside the title), or `plugins.detail.section` (a section under the page's own content). Every entry is rendered with the page's `subject` — `{ kind: 'bundle', pkg }`, `{ kind: 'row', pkg, row }`, or `{ kind: 'item', id }` — and returns null for a subject it has nothing for: -```ts ignore-check +```tsx ignore-check ctx.slots.inject('plugins.detail.badge', () => ctx.slots.register({ name: 'plugins.detail.badge', id: 'acme-update', @@ -96,6 +96,8 @@ ctx.slots.inject('plugins.detail.badge', () => ctx.slots.register({ The browser half is served to the page by the [client module system](../../packages/client/modules), which scans the enabled Loader entries for packages declaring `dsh.client` and serves each one's built `./client` export — but it attaches a package's half to the Loader row whose specifier is the bare package name. A row mounted from a subpath export never carries a half, so a bundle that splits one package into several rows keeps its half on the root row, and every page it registers goes away when that row is switched off. A sub-plugin whose page must outlive the other rows ships as its own package. +The built `./client` file must be in the client module system's lazy-CJS factory format: one script that registers the package name and a `factory(require)` with the page's module loader, described in the [client module system's README](../../packages/client/modules/README.md). The `clientBundle` tsdown preset that emits it lives in `packages/client/tsdown.client.ts` rather than in a published package, so a package outside this repository reproduces that build itself. + ```jsonc { "exports": { diff --git a/docs/cookbook/adding-a-settings-card.zh.md b/docs/cookbook/adding-a-settings-card.zh.md index a0fce4075b..d125407808 100644 --- a/docs/cookbook/adding-a-settings-card.zh.md +++ b/docs/cookbook/adding-a-settings-card.zh.md @@ -84,7 +84,7 @@ export function apply(ctx: ClientContext): void { 对某个不属于自己的组合包、行或官方插件有话要说的插件,注册进 `plugins.detail.actions`(页头的控件)、`plugins.detail.badge`(标题旁的标签)或 `plugins.detail.section`(页面自身内容之下的区块)。每个条目都以页面的 `subject` 渲染——`{ kind: 'bundle', pkg }`、`{ kind: 'row', pkg, row }` 或 `{ kind: 'item', id }`——对无话可说的 subject 返回 null: -```ts ignore-check +```tsx ignore-check ctx.slots.inject('plugins.detail.badge', () => ctx.slots.register({ name: 'plugins.detail.badge', id: 'acme-update', @@ -96,6 +96,8 @@ ctx.slots.inject('plugins.detail.badge', () => ctx.slots.register({ 浏览器半侧由[客户端模块系统](../../packages/client/modules)送到页面:它扫描已启用的 Loader 条目,找出声明了 `dsh.client` 的包,送出每个包构建好的 `./client` 导出——但它只把一个包的半侧挂在说明符恰为裸包名的那一行上。从子路径导出挂载的行永远不带半侧,因此把一个包拆成多行的组合包,其半侧留在根行上,它注册的每个页面都随根行关闭而消失。需要在其他行关闭时仍保留页面的子插件,应作为独立的包发布。 +构建出的 `./client` 文件必须是客户端模块系统的 lazy-CJS factory 格式:一段脚本,向页面的模块加载器登记包名和一个 `factory(require)`,见[客户端模块系统的 README](../../packages/client/modules/README.zh.md)。生成它的 `clientBundle` tsdown 预设位于 `packages/client/tsdown.client.ts`,而不在任何已发布的包里,因此仓库之外的包要自己复刻这一步构建。 + ```jsonc { "exports": { diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 8ade59ad22..8966c3d3e0 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -359,8 +359,9 @@ - id: ui-agent-preset name: '@deepseek-ai/dsh-client-ui-agent-preset' - # Plugin configuration: the host-plane sections a user owns, as expandable - # cards. A namespace this deployment does not expose renders nothing. + # The Built-in plugins settings section: the navigation entry and the tab + # row the inventory tab registers into. The official configuration pages + # are the companion rows below. - id: ui-settings-plugins name: '@deepseek-ai/dsh-client-ui-settings-plugins' diff --git a/packages/client/README.i18n.yaml b/packages/client/README.i18n.yaml index 2a01a6de69..89f30f6340 100644 --- a/packages/client/README.i18n.yaml +++ b/packages/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/client/README.md -README.md: 3a1a54f801090456a1038d0e3d59fe5223374f3c -README.zh.md: 231449ab574224a3e4fc5436c57e9ece5b0183b5 +README.md: 7b6c88ebbbfdf97f1ec191a2e8133c488ecb422d +README.zh.md: 11846c189afd85817d33e8266bb105b5f5418314 diff --git a/packages/client/README.md b/packages/client/README.md index 3a1a54f801..7b6c88ebbb 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -67,12 +67,16 @@ The kernel packages boot and serve the page; the UI feature packages present it. | [`ui-model-selection/`](ui-model-selection/README.md) | Provides model selection in conversation surfaces | — | | [`ui-permission-presets/`](ui-permission-presets/README.md) | Configures default permissions and switches the current session's access | — | | [`ui-plan/`](ui-plan/README.md) | Presents active plan-mode status and its exit control | — | -| [`ui-settings-plugins/`](ui-settings-plugins/README.md) | Owns the Plugins settings section, its tab extension point, and configurable host-plane plugin cards | — | +| [`ui-settings-plugins/`](ui-settings-plugins/README.md) | Owns the Built-in plugins settings section shell and its tab extension point | — | | [`ui-user-questions/`](ui-user-questions/README.md) | Presents interactive questions requested by the agent | — | | [`ui-agent-preset/`](ui-agent-preset/README.md) | Selects a session's agent preset and authors preset compositions | — | | [`ui-settings/`](ui-settings/README.md) | Hosts the settings interface and its extension areas | — | | [`ui-settings-general/`](ui-settings-general/README.md) | Provides the general settings section | — | | [`ui-settings-models/`](ui-settings-models/README.md) | Provides model-provider configuration and DeepSeek onboarding | — | +| [`ui-settings-shell/`](ui-settings-shell/README.md) | Provides the shell settings page on the Plugins page | — | +| [`ui-settings-agent-loop/`](ui-settings-agent-loop/README.md) | Provides the agent-loop settings page on the Plugins page | — | +| [`ui-settings-subagent/`](ui-settings-subagent/README.md) | Provides the Subagent settings page on the Plugins page | — | +| [`ui-settings-web-search/`](ui-settings-web-search/README.md) | Provides the web-search settings page on the Plugins page | — | | [`ui-plugin-manager/`](ui-plugin-manager/README.md) | Contributes the sidebar Plugins panel: install, enable, disable, retry, and compose installed packages | — | | [`ui-settings-plugin-inventory/`](ui-settings-plugin-inventory/README.md) | Contributes the read-only Host Loader inventory tab to Plugins settings | — | | [`ui-deliverables/`](ui-deliverables/README.md) | Produces the changed-files card with its comparison tabs, delivery cards, and clickable final-response file references | — | diff --git a/packages/client/README.zh.md b/packages/client/README.zh.md index 231449ab57..11846c189a 100644 --- a/packages/client/README.zh.md +++ b/packages/client/README.zh.md @@ -67,12 +67,16 @@ kind: "package-group" | [`ui-model-selection/`](ui-model-selection/README.zh.md) | 在对话界面中提供模型选择 | — | | [`ui-permission-presets/`](ui-permission-presets/README.zh.md) | 配置默认权限并切换当前会话的访问模式 | — | | [`ui-plan/`](ui-plan/README.zh.md) | 展示生效中的 plan mode 状态及其退出控件 | — | -| [`ui-settings-plugins/`](ui-settings-plugins/README.zh.md) | 负责「插件」设置分区、其标签页扩展点与可配置的宿主平面插件卡片 | — | +| [`ui-settings-plugins/`](ui-settings-plugins/README.zh.md) | 负责「内置插件」设置分区壳及其标签页扩展点 | — | | [`ui-user-questions/`](ui-user-questions/README.zh.md) | 展示 agent 请求的交互式问题 | — | | [`ui-agent-preset/`](ui-agent-preset/README.zh.md) | 选择会话的 agent 预设并编写预设组合 | — | | [`ui-settings/`](ui-settings/README.zh.md) | 承载设置界面及其扩展区域 | — | | [`ui-settings-general/`](ui-settings-general/README.zh.md) | 提供常规设置分区 | — | | [`ui-settings-models/`](ui-settings-models/README.zh.md) | 提供模型提供方配置与 DeepSeek 引导 | — | +| [`ui-settings-shell/`](ui-settings-shell/README.zh.md) | 在插件页提供终端设置页 | — | +| [`ui-settings-agent-loop/`](ui-settings-agent-loop/README.zh.md) | 在插件页提供 Agent 循环设置页 | — | +| [`ui-settings-subagent/`](ui-settings-subagent/README.zh.md) | 在插件页提供 Subagent 设置页 | — | +| [`ui-settings-web-search/`](ui-settings-web-search/README.zh.md) | 在插件页提供网页搜索设置页 | — | | [`ui-plugin-manager/`](ui-plugin-manager/README.zh.md) | 贡献侧栏的“插件”面板:安装、启用、停用、重试与组合已安装的包 | — | | [`ui-settings-plugin-inventory/`](ui-settings-plugin-inventory/README.zh.md) | 向「插件」设置贡献只读的 Host Loader 清单标签页 | — | | [`ui-deliverables/`](ui-deliverables/README.zh.md) | 生成改动文件卡片及其对比 tab、交付文件卡片与可点击的最终响应文件引用 | — | diff --git a/packages/client/ui-plugin-manager/README.i18n.yaml b/packages/client/ui-plugin-manager/README.i18n.yaml index 795636bb9f..7c58898be2 100644 --- a/packages/client/ui-plugin-manager/README.i18n.yaml +++ b/packages/client/ui-plugin-manager/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-plugin-manager/README.md -README.md: 4b6c6752f8dc71f7c00324cf56d236631810f752 -README.zh.md: f7a9ebe9efba83e6b9f5fb46b6422c2e099ead04 +README.md: a31960cab79b693f3d0f3b01a26d484b96ec404f +README.zh.md: 86f06fbeaaf0935f4d4cbdc639e95e6c75173dd6 diff --git a/packages/client/ui-plugin-manager/README.md b/packages/client/ui-plugin-manager/README.md index 4b6c6752f8..a31960cab7 100644 --- a/packages/client/ui-plugin-manager/README.md +++ b/packages/client/ui-plugin-manager/README.md @@ -45,7 +45,7 @@ A row's switch on the bundle's page calls `pluginManager.setPluginEnabled`, whic ### Configuration pages -A plugin that carries its own configuration renders it on this page rather than in Settings, through three slots the page declares: `plugins.item` (list) for an official plugin, listed in the Official group by its `label`; `plugins.bundle.config` (keyed by the bundle's package name) for a bundle's own configuration, shown on the bundle's page between its description and its rows; and `plugins.row.config` (keyed by `#`) for one row's configuration, which gives that row a **Configure** control opening the row's page. The page asks every entry for two views through its owner props: `view: 'summary'` for the one-liner under the title, `view: 'page'` for the form with its own save control. Only a save writes: the page draws the title, icon, and crumb, and the entry's form drops its staged edits when the page is left. The four host-plane pages the installation ships — the shell executor, the agent loop, subagent model selection, and the DeepSeek search provider — come from [ui-settings-plugins](../ui-settings-plugins/README.md), registered while the Host serves their namespaces. A bundle's browser half registers the same way: +A plugin that carries its own configuration renders it on this page rather than in Settings, through three slots the page declares: `plugins.item` (list) for an official plugin, listed in the Official group by its `label`; `plugins.bundle.config` (keyed by the bundle's package name) for a bundle's own configuration, shown on the bundle's page between its description and its rows; and `plugins.row.config` (keyed by `#`) for one row's configuration, which gives that row a **Configure** control opening the row's page. The page asks every entry for two views through its owner props: `view: 'summary'` for the one-liner under the title, `view: 'page'` for the form with its own save control. Only a save writes: the page draws the title, icon, and crumb, and the entry's form drops its staged edits when the page is left. The four host-plane pages the installation ships — the shell executor, the agent loop, Subagent, and the DeepSeek search provider — come from one companion package each, [ui-settings-shell](../ui-settings-shell/README.md), [ui-settings-agent-loop](../ui-settings-agent-loop/README.md), [ui-settings-subagent](../ui-settings-subagent/README.md), and [ui-settings-web-search](../ui-settings-web-search/README.md), registered while the Host serves their namespaces. A bundle's browser half registers the same way: ```tsx ignore-check ctx.slots.inject('plugins.row.config', () => ctx.slots.register({ @@ -105,7 +105,7 @@ These pages cover the sidebar, the Remote calls, and the Host-side manager. - [ui-sidebar](../ui-sidebar/README.md) — the panel list the Plugins entry registers into; [ui-layout](../ui-layout/README.md) — the main slot the page occupies. - [api-remotes](../../api/remotes/README.md) — the Remote BFF surface behind `pluginManager.*` and `pluginInventory.*`. - [plugin-manager](../../boot/plugin-manager/README.md) — the Host-side manager this page drives. -- [ui-settings-plugins](../ui-settings-plugins/README.md) — the official configuration pages that register into this page's slots. +- [ui-settings-shell](../ui-settings-shell/README.md), [ui-settings-agent-loop](../ui-settings-agent-loop/README.md), [ui-settings-subagent](../ui-settings-subagent/README.md), [ui-settings-web-search](../ui-settings-web-search/README.md) — the official configuration pages that register into this page's `plugins.item` slot. ----- diff --git a/packages/client/ui-plugin-manager/README.zh.md b/packages/client/ui-plugin-manager/README.zh.md index f7a9ebe9ef..86f06fbeaa 100644 --- a/packages/client/ui-plugin-manager/README.zh.md +++ b/packages/client/ui-plugin-manager/README.zh.md @@ -45,7 +45,7 @@ Agent Teams、Agent Teams Web UI 和 Auto Authorization Review 三个包使用 ### 配置页 -自带配置的插件把配置渲染在本页而不是设置里,通过本页声明的三个 slot:`plugins.item`(list)用于官方插件,按其 `label` 列在官方分组里;`plugins.bundle.config`(以组合包的包名为键)用于组合包自己的配置,显示在组合包页面的描述与行之间;`plugins.row.config`(以 `<包名>#<行 id>` 为键)用于某一行的配置,这一行由此多出一个**配置**控件,打开该行自己的页面。页面通过 owner props 向每个条目索取两种视图:`view: 'summary'` 是标题下的一句话简介,`view: 'page'` 是带自己保存控件的表单。只有保存才写入:页面负责画标题、图标与面包屑,条目的表单在离开页面时丢弃暂存的修改。安装随附的四个宿主平面配置页——shell 执行器、agent loop、subagent 模型选择、DeepSeek 搜索提供方——来自 [ui-settings-plugins](../ui-settings-plugins/README.zh.md),在 Host 服务其命名空间期间注册。组合包的浏览器半侧用同样的方式注册: +自带配置的插件把配置渲染在本页而不是设置里,通过本页声明的三个 slot:`plugins.item`(list)用于官方插件,按其 `label` 列在官方分组里;`plugins.bundle.config`(以组合包的包名为键)用于组合包自己的配置,显示在组合包页面的描述与行之间;`plugins.row.config`(以 `<包名>#<行 id>` 为键)用于某一行的配置,这一行由此多出一个**配置**控件,打开该行自己的页面。页面通过 owner props 向每个条目索取两种视图:`view: 'summary'` 是标题下的一句话简介,`view: 'page'` 是带自己保存控件的表单。只有保存才写入:页面负责画标题、图标与面包屑,条目的表单在离开页面时丢弃暂存的修改。安装随附的四个宿主平面配置页——shell 执行器、agent loop、Subagent、DeepSeek 搜索提供方——各来自一个伴生包:[ui-settings-shell](../ui-settings-shell/README.zh.md)、[ui-settings-agent-loop](../ui-settings-agent-loop/README.zh.md)、[ui-settings-subagent](../ui-settings-subagent/README.zh.md) 与 [ui-settings-web-search](../ui-settings-web-search/README.zh.md),在 Host 服务其命名空间期间注册。组合包的浏览器半侧用同样的方式注册: ```tsx ignore-check ctx.slots.inject('plugins.row.config', () => ctx.slots.register({ @@ -105,7 +105,7 @@ ctx.slots.inject('plugins.detail.section', () => ctx.slots.register({ - [ui-sidebar](../ui-sidebar/README.zh.md)——插件入口注册进的面板列表;[ui-layout](../ui-layout/README.zh.md)——页面占用的主 slot。 - [api-remotes](../../api/remotes/README.zh.md)——`pluginManager.*` 与 `pluginInventory.*` 背后的 Remote BFF 面。 - [plugin-manager](../../boot/plugin-manager/README.zh.md)——本页驱动的 Host 侧管理器。 -- [ui-settings-plugins](../ui-settings-plugins/README.zh.md)——注册进本页 slot 的官方配置页。 +- [ui-settings-shell](../ui-settings-shell/README.zh.md)、[ui-settings-agent-loop](../ui-settings-agent-loop/README.zh.md)、[ui-settings-subagent](../ui-settings-subagent/README.zh.md)、[ui-settings-web-search](../ui-settings-web-search/README.zh.md)——注册进本页 `plugins.item` slot 的官方配置页。 ----- diff --git a/packages/client/ui-primitives/tests/settings-form-model.client.spec.ts b/packages/client/ui-primitives/tests/settings-form-model.client.spec.ts index 66fb0b5ad8..a2a0ef86ca 100644 --- a/packages/client/ui-primitives/tests/settings-form-model.client.spec.ts +++ b/packages/client/ui-primitives/tests/settings-form-model.client.spec.ts @@ -270,7 +270,7 @@ describe('SettingsFormModel', () => { expect(store.getSnapshot()).toBe('2000') }) - it('refuses to address a field the card never declared', () => { + it('refuses to address a field the form never declared', () => { const { subject } = form() expect(() => subject.field('nope')).toThrow('settings form has no field nope') diff --git a/packages/client/ui-settings-subagent/package.json b/packages/client/ui-settings-subagent/package.json index 49ccf5295f..1f5858183d 100644 --- a/packages/client/ui-settings-subagent/package.json +++ b/packages/client/ui-settings-subagent/package.json @@ -30,7 +30,8 @@ "inject": [ "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-settings", - "@deepseek-ai/dsh-client-ui-plugin-manager" + "@deepseek-ai/dsh-client-ui-plugin-manager", + "@deepseek-ai/dsh-api-remotes" ], "platform": "web" } diff --git a/packages/client/ui-settings-web-search/package.json b/packages/client/ui-settings-web-search/package.json index 4959b285bc..becf2a405a 100644 --- a/packages/client/ui-settings-web-search/package.json +++ b/packages/client/ui-settings-web-search/package.json @@ -30,7 +30,8 @@ "inject": [ "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-settings", - "@deepseek-ai/dsh-client-ui-plugin-manager" + "@deepseek-ai/dsh-client-ui-plugin-manager", + "@deepseek-ai/dsh-api-remotes" ], "platform": "web" } diff --git a/packages/client/ui-settings/README.i18n.yaml b/packages/client/ui-settings/README.i18n.yaml index f325c7e238..861b4012de 100644 --- a/packages/client/ui-settings/README.i18n.yaml +++ b/packages/client/ui-settings/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-settings/README.md -README.md: 760b4a4567481b561ce319926149815119018177 -README.zh.md: f8e2128d734b650b7cd7a10c180f90ea01b77ab5 +README.md: d19de7bea23b1d752b977aea06d868e029fc3331 +README.zh.md: 5e30e94f7436dc93c3d6f06a58ed771aa314bff7 diff --git a/packages/client/ui-settings/README.md b/packages/client/ui-settings/README.md index 760b4a4567..d19de7bea2 100644 --- a/packages/client/ui-settings/README.md +++ b/packages/client/ui-settings/README.md @@ -31,6 +31,10 @@ Feature plugins use this package to store and edit their preferences without re- A feature calls `ctx.settingsScope.bind(spec)` with a per-namespace spec and gets a scope derived from the shared document mirror. The scope snapshot carries the resolved section, composition `base`, raw `user`, revision, writability, and host/memory mode; a field is overridden when it is present in `user`, even when its value equals `base`, and `unset` clears that override. Writes go through the scope: `set` and `unset` submit one operation, while `mutate` submits several ordered operations atomically. Each write is fenced by the namespace revision as `expectedRevision`, so a concurrent write from another surface is refused instead of silently overwritten. A staged editor can supply the revision where its draft began as a fixed fence; otherwise the scope uses the latest queued or mirrored revision. +### Following served namespaces + +A page that edits a namespace another plugin owns registers through `ctx.settingsScope.whileServed(namespaces, register)`: `register` runs once any listed namespace is in the shared mirror and returns the registration's disposer, which runs when none of them is served any more or when the disposer `whileServed` returns runs. The caller owns that returned disposer and wraps it in `ctx.effect`; unlike `bind`, the service registers nothing on the caller's context. A deployment that never composed the owner therefore shows no trace of the page, and a namespace the Host stops serving withdraws it. The four official pages on the Plugins page ride it, one companion package each. + ### Filling the settings slots A settings surface registers into the slot types this package declares. The shell (`sidebar.settings` occupant, navigation, chrome) lives in ui-settings-general; feature pages register `settings.section` contributions; the Plugins section hosts `settings.plugins.tab` pages; onboarding steps register `settings.onboarding`. Cross-namespace surfaces (schema introspection, the served-namespace directory, `hasDocument`) read the same mirror through `ctx.settingsScope.describe()`. @@ -55,7 +59,7 @@ The plugin injects `remote` with its `settings` namespace, resolves Host persist ### Scope derivation -`ctx.settingsScope.bind(spec)` returns a per-namespace scope derived from the mirror on the caller's context: the scope's disposer belongs to the calling fiber, binding adds no wire read, and a row's activation never blocks on the settings transport. Writes stay per-scope: `set` and `unset` are single-operation forms of `mutate`, which copies and queues several ordered field operations behind one namespace revision as `expectedRevision`. A committed mutation folds its answer in, a rejected or failed latest mutation triggers one recovery read, and a superseded one leaves recovery to its successor. The cold-boot read count is pinned by `../../../apps/web/tests/startup-rpc-budget.e2e.ts`; a new direct `settings.describe` caller in client code is a regression against it. +`ctx.settingsScope.bind(spec)` returns a per-namespace scope derived from the mirror on the caller's context: the scope's disposer belongs to the calling fiber, binding adds no wire read, and a row's activation never blocks on the settings transport. Writes stay per-scope: `set` and `unset` are single-operation forms of `mutate`, which copies and queues several ordered field operations behind one namespace revision as `expectedRevision`. A committed mutation folds its answer in, a rejected or failed latest mutation triggers one recovery read, and a superseded one leaves recovery to its successor. The cold-boot read count is pinned by `../../../apps/web/tests/startup-rpc-budget.e2e.ts`; a new direct `settings.describe` caller in client code is a regression against it. `whileServed` subscribes to the same mirror and re-evaluates its served set on every snapshot, so a registration toggles with the describe view and needs no wire read beyond the mirror's `ensure`. ### Schema service @@ -71,7 +75,8 @@ The plugin injects `remote` with its `settings` namespace, resolves Host persist These pages cover the settings surface family and the durable seam behind it. - [ui-settings-general](../ui-settings-general/README.md) — the settings shell: trigger chrome, navigation, General section, onboarding projection. -- [ui-settings-plugins](../ui-settings-plugins/README.md) — the Plugins section and its configurable host-plane cards. +- [ui-settings-plugins](../ui-settings-plugins/README.md) — the Built-in plugins section shell around the inventory tab. +- [ui-settings-shell](../ui-settings-shell/README.md), [ui-settings-agent-loop](../ui-settings-agent-loop/README.md), [ui-settings-subagent](../ui-settings-subagent/README.md), [ui-settings-web-search](../ui-settings-web-search/README.md) — the official configuration pages on the Plugins page, each following its namespaces through `whileServed`. - [ui-settings-models](../ui-settings-models/README.md) — the Models page and DeepSeek onboarding over this base. - [settings](../../settings/README.md) — the durable user-settings seam and its file provider. - [ui-sidebar](../ui-sidebar/README.md) — the sidebar shell whose bottom seat hosts the settings trigger. diff --git a/packages/client/ui-settings/README.zh.md b/packages/client/ui-settings/README.zh.md index f8e2128d73..5e30e94f74 100644 --- a/packages/client/ui-settings/README.zh.md +++ b/packages/client/ui-settings/README.zh.md @@ -31,6 +31,10 @@ kind: "package-reference" 功能调用 `ctx.settingsScope.bind(spec)` 并传入按命名空间的 spec,得到一个由共享文档镜像派生的 scope。scope 快照携带解析后的分区、组合 `base`、原始 `user`、revision、可写性以及 host/内存模式;字段只要出现在 `user` 中即视为覆盖,即使其值与 `base` 相等,`unset` 会清除该覆盖。写入经 scope 进行:`set` 与 `unset` 提交一个操作,`mutate` 则原子提交多个有序操作。每次写入都以命名空间 revision 作为 `expectedRevision` 围栏,因此来自另一界面的并发写入会被拒绝,而不是被静默覆盖。暂存编辑器可以把开始草拟时读取的 revision 作为固定围栏传入;否则 scope 使用最新排队或镜像 revision。 +### 跟随被服务的命名空间 + +编辑另一个插件所拥有命名空间的页面通过 `ctx.settingsScope.whileServed(namespaces, register)` 注册:只要所列命名空间中的任一个进入共享镜像,`register` 就运行一次并返回该注册的 disposer;当它们全都不再被服务,或 `whileServed` 返回的 disposer 被调用时,这个 disposer 就运行。调用方持有返回的 disposer 并把它包进 `ctx.effect`;与 `bind` 不同,服务不会在调用方的 context 上注册任何东西。因此从未组合过所有者的部署看不到该页面的任何痕迹,Host 停止服务的命名空间会撤下它。插件页上的四个官方页面各自通过一个伴生包走这条路径。 + ### 填充设置 slot 设置界面会注册进本包声明的 slot 类型。外壳(`sidebar.settings` 占位方、导航、界面框架)位于 ui-settings-general;功能页面注册 `settings.section` 贡献;「插件」分区承载 `settings.plugins.tab` 页面;首次使用引导步骤注册 `settings.onboarding`。跨命名空间的表面(schema 内省、已服务命名空间目录、`hasDocument`)通过 `ctx.settingsScope.describe()` 读同一面镜像。 @@ -55,7 +59,7 @@ kind: "package-reference" ### Scope 派生 -`ctx.settingsScope.bind(spec)` 在调用方的 context 上返回一个由镜像派生的按命名空间 scope:scope 的 disposer 归调用方 fiber 所有,绑定不新增任何线路读取,某一行的激活绝不会阻塞在设置传输层上。写入仍归各 scope:`set` 与 `unset` 是 `mutate` 的单操作形式,后者会复制操作列表,并把多个有序字段操作排在同一个作为 `expectedRevision` 的命名空间 revision 之后。提交成功的 mutation 把应答折回镜像,被拒绝或失败的最新 mutation 触发一次恢复读取,被取代的 mutation 把恢复留给后继者。冷启动读取次数由 `../../../apps/web/tests/startup-rpc-budget.e2e.ts` 钉住;客户端代码中新增直连 `settings.describe` 调用即是对它的回归。 +`ctx.settingsScope.bind(spec)` 在调用方的 context 上返回一个由镜像派生的按命名空间 scope:scope 的 disposer 归调用方 fiber 所有,绑定不新增任何线路读取,某一行的激活绝不会阻塞在设置传输层上。写入仍归各 scope:`set` 与 `unset` 是 `mutate` 的单操作形式,后者会复制操作列表,并把多个有序字段操作排在同一个作为 `expectedRevision` 的命名空间 revision 之后。提交成功的 mutation 把应答折回镜像,被拒绝或失败的最新 mutation 触发一次恢复读取,被取代的 mutation 把恢复留给后继者。冷启动读取次数由 `../../../apps/web/tests/startup-rpc-budget.e2e.ts` 钉住;客户端代码中新增直连 `settings.describe` 调用即是对它的回归。`whileServed` 订阅同一面镜像,在每次快照变化时重新判断被服务集合,因此注册随 describe 视图开关,除镜像的 `ensure` 外不需要任何线路读取。 ### Schema 服务 @@ -71,7 +75,8 @@ kind: "package-reference" 以下页面覆盖设置界面家族及其背后的持久化 seam。 - [ui-settings-general](../ui-settings-general/README.zh.md)——设置外壳:触发控件、导航、「通用」分区、引导投影。 -- [ui-settings-plugins](../ui-settings-plugins/README.zh.md)——「插件」分区及其可配置宿主平面卡片。 +- [ui-settings-plugins](../ui-settings-plugins/README.zh.md)——围绕清单标签页的「内置插件」分区壳。 +- [ui-settings-shell](../ui-settings-shell/README.zh.md)、[ui-settings-agent-loop](../ui-settings-agent-loop/README.zh.md)、[ui-settings-subagent](../ui-settings-subagent/README.zh.md)、[ui-settings-web-search](../ui-settings-web-search/README.zh.md)——插件页上的官方配置页,各自通过 `whileServed` 跟随其命名空间。 - [ui-settings-models](../ui-settings-models/README.zh.md)——建立在本底座之上的 Models 页面与 DeepSeek 引导。 - [settings](../../settings/README.zh.md)——持久化用户设置 seam 及其文件提供方。 - [ui-sidebar](../ui-sidebar/README.zh.md)——底部席位承载设置触发控件的侧边栏外壳。 diff --git a/packages/client/ui-settings/src/client/settings-scope.ts b/packages/client/ui-settings/src/client/settings-scope.ts index 2dec36af4e..f9fa3af55d 100644 --- a/packages/client/ui-settings/src/client/settings-scope.ts +++ b/packages/client/ui-settings/src/client/settings-scope.ts @@ -303,7 +303,9 @@ export class SettingsScopeBinder extends Service { * disposer runs when none is or when the returned disposer runs. A plugin * whose page edits a namespace another plugin owns registers the page * through this, so a deployment that never composed the owner shows no - * trace of the page. + * trace of the page. The caller owns the returned disposer and wraps it in + * `ctx.effect`; unlike {@link bind}, nothing is registered on the caller's + * context here. * @param namespaces - the settings namespaces the registration follows. * @param register - registers the contribution; returns its disposer. * @returns the disposer ending the watch and any live registration. diff --git a/website/docs.ts b/website/docs.ts index e366926c9c..5279241051 100644 --- a/website/docs.ts +++ b/website/docs.ts @@ -440,7 +440,7 @@ const reference = [ ['adding-a-package.md', '新增 Package', 'Adding a package'], ['adding-a-tool.md', '新增 Tool', 'Adding a tool'], ['adding-an-llm-adapter.md', '新增 LLM Adapter', 'Adding an LLM adapter'], - ['adding-a-settings-card.md', '新增设置卡片', 'Adding a settings card'], + ['adding-a-settings-card.md', '新增设置页', 'Adding a settings page'], ['extension-cookbook.md', '扩展模式', 'Extension patterns'], ] as const).map(([file, rootLabel, enLabel], order): PairedPage => ({ source: `docs/cookbook/${file}`, From 9f3ddc1a19cae3be60371923aba86c9cc687b99f Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 19 Sep 2026 10:41:31 +0800 Subject: [PATCH 4/7] test(web): re-record the bundle golden after the detail-page chrome change The crumb, uninstall, and configure controls no longer render an icon child in the accessibility tree; the bundle page golden this branch added follows the row and official goldens master re-recorded. --- .../tests/expected/plugin-config/bundle.expected.md | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/apps/web/tests/expected/plugin-config/bundle.expected.md b/apps/web/tests/expected/plugin-config/bundle.expected.md index 23985c8c66..50cb737738 100644 --- a/apps/web/tests/expected/plugin-config/bundle.expected.md +++ b/apps/web/tests/expected/plugin-config/bundle.expected.md @@ -1,10 +1,6 @@ -- button "返回插件列表": - - img - - text: 插件列表 +- button "返回插件列表": 插件列表 - button "夹具操作" -- button "卸载 live-client": - - img - - text: 卸载 +- button "卸载 live-client": 卸载 - switch "启用 live-client" [checked] - heading "live-client" [level=3] - text: v0.0.1 夹具标签 @@ -15,9 +11,7 @@ - text: 共 1 个 · 1 运行中 - list: - listitem: - - button "配置 fixture-live-client": - - text: fixture-live-client - - img + - button "配置 fixture-live-client": fixture-live-client - text: "@fixture/live-client 运行中" - switch "启用组件 fixture-live-client" [checked] - region "夹具区块": From 3bcac0b87872d2380b74fde9cd85f9dfa5d6858e Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 19 Sep 2026 10:46:51 +0800 Subject: [PATCH 5/7] test(web): expect three startup plugin batches in the keyless smoke The application phase's map-form combo URL passes the 3 KiB limit once the four settings companions join the composition, so the module system partitions that phase into two combos beside the bootstrap one, as the loading model allows. The smoke pins the three immutable batches and still checks every combo's script, map, and cache header. --- apps/web/tests/smoke-real.e2e.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/apps/web/tests/smoke-real.e2e.ts b/apps/web/tests/smoke-real.e2e.ts index 7d78803cc7..77acfed00a 100644 --- a/apps/web/tests/smoke-real.e2e.ts +++ b/apps/web/tests/smoke-real.e2e.ts @@ -309,7 +309,7 @@ const notReady = UI_PLUGIN_DIRS.filter((dir) => { if (notReady.length > 0) console.warn(`[smoke-real] skipped — client bundles not ready: ${notReady.join(', ')}`) describe('dsh web keyless CLI smoke', () => { - it('serves a usable app from two immutable plugin batches', async () => { + it('serves a usable app from three immutable plugin batches', async () => { requireDist() const sessionsDir = mkdtempSync(join(tmpdir(), 'dsh-web-keyless-')) const tsxLoader = pathToFileURL(createRequire(join(REPO_ROOT, 'package.json')).resolve('tsx')).href @@ -356,10 +356,13 @@ describe('dsh web keyless CLI smoke', () => { await page.goto(readyUrl) await page.getByRole('button', { name: 'New session', exact: true }).first().waitFor({ timeout: 30_000 }) const batchPaths = [...new Set(pluginScripts)].sort() - expect(batchPaths).toHaveLength(2) - expect(batchPaths).toContainEqual(expect.stringMatching( - /^\/plugins\/\?\?.+\/client\.js,.+\/client\.js&rev=[a-f\d]{12}$/, - )) + // The bootstrap phase is the modules package alone; the application phase + // spans two combos because its map-form URL is over the 3 KiB combo limit + // since the four settings companions joined the composition. + expect(batchPaths).toHaveLength(3) + expect(batchPaths.filter(path => ( + /^\/plugins\/\?\?.+\/client\.js,.+\/client\.js&rev=[a-f\d]{12}$/.test(path) + ))).toHaveLength(2) expect(batchPaths).toContainEqual(expect.stringMatching( /^\/plugins\/\?\?@deepseek-ai\/dsh-client-modules\/client\.js&rev=[a-f\d]{12}$/, )) @@ -367,6 +370,7 @@ describe('dsh web keyless CLI smoke', () => { expect([...cacheHeaders.values()]).toEqual([ 'public, max-age=31536000, immutable', 'public, max-age=31536000, immutable', + 'public, max-age=31536000, immutable', ]) for (const path of batchPaths) { const [scriptResponse, mapResponse] = await Promise.all([ From 873140cff3ed54c58d3925bddb772c44279b069a Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 21 Sep 2026 17:13:17 +0800 Subject: [PATCH 6/7] test(web): follow master's slot fixture and cast rules after the merge The Plugins page spec's configuration bodies take the view positionally as master's fixture does, the companion and section specs read a slot entry's injected face through a Pick of the members they assert instead of a double assertion, and the no-unknown-casts baseline drops the entries retired with the moved settings tests. --- .../tests/components.client.spec.tsx | 6 +++--- .../tests/apply.client.spec.ts | 2 +- .../tests/card.client.spec.tsx | 2 +- .../ui-settings-plugins/tests/apply.client.spec.ts | 2 +- .../tests/section.client.spec.tsx | 2 +- .../ui-settings-shell/tests/apply.client.spec.ts | 2 +- .../ui-settings-shell/tests/card.client.spec.tsx | 2 +- .../ui-settings-subagent/tests/apply.client.spec.ts | 2 +- .../ui-settings-subagent/tests/card.client.spec.tsx | 2 +- .../tests/apply.client.spec.ts | 2 +- .../tests/card.client.spec.tsx | 4 ++-- scripts/no-unknown-casts.baseline.json | 12 ------------ 12 files changed, 14 insertions(+), 26 deletions(-) diff --git a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx index 35594c89e0..a98b0bc3d3 100644 --- a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx @@ -541,9 +541,9 @@ describe('PluginManagerPage', () => { describe('configuration pages', () => { const bodies: SlotBodies = { - 'plugins.item:bash': ({ view }) => view === 'summary' ? 'Limits every command.' : , - 'plugins.bundle.config:dsh-better-sidebar': ({ view }) => view === 'page' ? : null, - 'plugins.row.config:dsh-better-sidebar#sidebar': ({ view }) => view === 'summary' ? 'The sidebar row.' : , + 'plugins.item:bash': view => view === 'summary' ? 'Limits every command.' : , + 'plugins.bundle.config:dsh-better-sidebar': view => view === 'page' ? : null, + 'plugins.row.config:dsh-better-sidebar#sidebar': view => view === 'summary' ? 'The sidebar row.' : , } it('lists an official plugin after the official bundles with its summary, and opens its page', () => { diff --git a/packages/client/ui-settings-agent-loop/tests/apply.client.spec.ts b/packages/client/ui-settings-agent-loop/tests/apply.client.spec.ts index b822607711..744d62f3d4 100644 --- a/packages/client/ui-settings-agent-loop/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-agent-loop/tests/apply.client.spec.ts @@ -59,7 +59,7 @@ describe('ui-settings-agent-loop apply', () => { expect(entry.options).toMatchObject({ id: 'agent-loop', order: 20 }) expect(resolveSlotLabel(entry.options.label)).toBe('Agent 循环') expect(entry.locale).toBe(NS) - const face = (entry.inject as unknown as () => AgentLoopCardFace)() + const face = (entry.inject as () => Pick)() expect(Object.keys(face.hooks)).toEqual(['agentLoopCard']) }) diff --git a/packages/client/ui-settings-agent-loop/tests/card.client.spec.tsx b/packages/client/ui-settings-agent-loop/tests/card.client.spec.tsx index be2fd9a09a..e3287413d1 100644 --- a/packages/client/ui-settings-agent-loop/tests/card.client.spec.tsx +++ b/packages/client/ui-settings-agent-loop/tests/card.client.spec.tsx @@ -23,7 +23,7 @@ function field(text: string, rest: Partial = {}): SettingsFi function renderCard(state: Partial = {}, view: 'summary' | 'page' = 'page') { const store = createSnapshotStore({ ...settled, maxParallelToolCalls: field('10'), ...state }) const actions = { edit: vi.fn(), resetField: vi.fn(), save: vi.fn(), discard: vi.fn() } - const props = { ...actions, view, t, useAgentLoopCard: bindSnapshotSelector(store) } as unknown as AgentLoopCardProps + const props = { ...actions, view, t, useAgentLoopCard: bindSnapshotSelector(store) } as AgentLoopCardProps render() return actions } diff --git a/packages/client/ui-settings-plugins/tests/apply.client.spec.ts b/packages/client/ui-settings-plugins/tests/apply.client.spec.ts index 7581245ddb..63ba773884 100644 --- a/packages/client/ui-settings-plugins/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-plugins/tests/apply.client.spec.ts @@ -59,7 +59,7 @@ describe('ui-settings-plugins apply', () => { await ctx.plugin({ inject: [...inject], apply }).await() const section = slots.entries('settings.section')[0]! - const sectionFace = (section.inject as unknown as () => PluginsSettingsSectionInjected)() + const sectionFace = (section.inject as () => Pick)() const initialTabs = sectionFace.hooks.tabs.getSnapshot() expect(initialTabs).toEqual([]) expect(sectionFace.hooks.tabs.getSnapshot()).toBe(initialTabs) diff --git a/packages/client/ui-settings-plugins/tests/section.client.spec.tsx b/packages/client/ui-settings-plugins/tests/section.client.spec.tsx index 0907213a17..7b4f0001ef 100644 --- a/packages/client/ui-settings-plugins/tests/section.client.spec.tsx +++ b/packages/client/ui-settings-plugins/tests/section.client.spec.tsx @@ -18,7 +18,7 @@ function renderSection(rows: readonly PluginsSettingsTabEntry[]) { renderSlot: (_name: string, _owner: unknown, options: { only?: string }) => ( {options.only} ), - } as unknown as PluginsSettingsSectionProps + } as PluginsSettingsSectionProps render() } diff --git a/packages/client/ui-settings-shell/tests/apply.client.spec.ts b/packages/client/ui-settings-shell/tests/apply.client.spec.ts index 3bc0b93161..935336964a 100644 --- a/packages/client/ui-settings-shell/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-shell/tests/apply.client.spec.ts @@ -66,7 +66,7 @@ describe('ui-settings-shell apply', () => { expect(entry.options).toMatchObject({ id: 'shell', order: 10 }) expect(resolveSlotLabel(entry.options.label)).toBe('终端') expect(entry.locale).toBe(NS) - const face = (entry.inject as unknown as () => ShellCardFace)() + const face = (entry.inject as () => Pick)() expect(Object.keys(face.hooks)).toEqual(['shellCard']) expect(face.hooks.shellCard.getSnapshot()).toMatchObject({ available: false, dirty: false }) }) diff --git a/packages/client/ui-settings-shell/tests/card.client.spec.tsx b/packages/client/ui-settings-shell/tests/card.client.spec.tsx index d789c28165..0570e65e2b 100644 --- a/packages/client/ui-settings-shell/tests/card.client.spec.tsx +++ b/packages/client/ui-settings-shell/tests/card.client.spec.tsx @@ -25,7 +25,7 @@ function renderCard(state: Partial = {}, view: 'summary' | 'page ...settled, timeoutMs: field('60000'), maxOutputBytes: field('64000'), ...state, }) const actions = { edit: vi.fn(), resetField: vi.fn(), save: vi.fn(), discard: vi.fn() } - const props = { ...actions, view, t, useShellCard: bindSnapshotSelector(store) } as unknown as ShellCardProps + const props = { ...actions, view, t, useShellCard: bindSnapshotSelector(store) } as ShellCardProps render() return actions } diff --git a/packages/client/ui-settings-subagent/tests/apply.client.spec.ts b/packages/client/ui-settings-subagent/tests/apply.client.spec.ts index edfe36b27e..d4d5b836b5 100644 --- a/packages/client/ui-settings-subagent/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-subagent/tests/apply.client.spec.ts @@ -64,7 +64,7 @@ describe('ui-settings-subagent apply', () => { expect(entry.options).toMatchObject({ id: 'subagent', order: 30 }) expect(resolveSlotLabel(entry.options.label)).toBe('Subagent') expect(entry.locale).toBe(NS) - const face = (entry.inject as unknown as () => SubagentCardFace)() + const face = (entry.inject as () => Pick)() expect(Object.keys(face.hooks).sort()).toEqual(['subagentLimitsCard', 'subagentModelSelectionCard']) }) diff --git a/packages/client/ui-settings-subagent/tests/card.client.spec.tsx b/packages/client/ui-settings-subagent/tests/card.client.spec.tsx index d0efaf23f8..6804ea9e91 100644 --- a/packages/client/ui-settings-subagent/tests/card.client.spec.tsx +++ b/packages/client/ui-settings-subagent/tests/card.client.spec.tsx @@ -66,7 +66,7 @@ function renderSubagent( t, useSubagentLimitsCard: bindSnapshotSelector(limits), useSubagentModelSelectionCard: bindSnapshotSelector(models), - } as unknown as SubagentCardProps + } as SubagentCardProps render() return { actions, limits, models } } diff --git a/packages/client/ui-settings-web-search/tests/apply.client.spec.ts b/packages/client/ui-settings-web-search/tests/apply.client.spec.ts index bdff8383fe..169ba04dfd 100644 --- a/packages/client/ui-settings-web-search/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-web-search/tests/apply.client.spec.ts @@ -65,7 +65,7 @@ describe('ui-settings-web-search apply', () => { expect(entry.options).toMatchObject({ id: 'web-search', order: 40 }) expect(resolveSlotLabel(entry.options.label)).toBe('网页搜索') expect(entry.locale).toBe(NS) - const face = (entry.inject as unknown as () => WebSearchCardFace)() + const face = (entry.inject as () => Pick)() expect(Object.keys(face.hooks)).toEqual(['webSearchCard']) }) diff --git a/packages/client/ui-settings-web-search/tests/card.client.spec.tsx b/packages/client/ui-settings-web-search/tests/card.client.spec.tsx index 7f0c480cf6..a1b36c73a8 100644 --- a/packages/client/ui-settings-web-search/tests/card.client.spec.tsx +++ b/packages/client/ui-settings-web-search/tests/card.client.spec.tsx @@ -36,7 +36,7 @@ describe('WebSearchCard', () => { ...state, }) const actions = cardActions() - const props = { ...actions, view: 'page', t, useWebSearchCard: bindSnapshotSelector(store) } as unknown as WebSearchCardProps + const props = { ...actions, view: 'page', t, useWebSearchCard: bindSnapshotSelector(store) } as WebSearchCardProps render() return actions } @@ -45,7 +45,7 @@ describe('WebSearchCard', () => { const store = createSnapshotStore({ ...settled, baseURL: field(''), maxUses: field('5'), apiKey: field(''), apiKeyConfigured: false, apiKeyWritable: true, }) - const props = { ...cardActions(), view: 'summary', t, useWebSearchCard: bindSnapshotSelector(store) } as unknown as WebSearchCardProps + const props = { ...cardActions(), view: 'summary', t, useWebSearchCard: bindSnapshotSelector(store) } as WebSearchCardProps render() expect(document.body.textContent).toBe(en.description) diff --git a/scripts/no-unknown-casts.baseline.json b/scripts/no-unknown-casts.baseline.json index b274bbe4fe..11163ebaab 100644 --- a/scripts/no-unknown-casts.baseline.json +++ b/scripts/no-unknown-casts.baseline.json @@ -688,18 +688,6 @@ "79c53da50aa08004e201cebca842267ae2650a543b435a7906960f6492c2398b": 1, "a3c1509d552d0102e118f3071a234002a5c83c30ca6923f8a87e9e3733ce0dcf": 1 }, - "packages/client/ui-settings-plugins/tests/apply.client.spec.ts": { - "3d455f54ba188122f29085e9d462bebbca24c517057052f346229efd175e6e3c": 1 - }, - "packages/client/ui-settings-plugins/tests/section.client.spec.tsx": { - "0d8472724f1363e2891409f0a3a4add1cff1ca68b49e3c553457ad69deb9743c": 2, - "203a19d9e599f039376a58265a2e59a5350752ca21ed2502095b3bc73cd45c6a": 1, - "878e5d009eb310a1e5574351c795dfa691449b162d027f7639220075957a5b18": 1, - "9f2da941345134a519a3be6744a11f5e6a1fcaa70ab3ac6ca40f8ddec52e172f": 1, - "ab5c523f935fc83dfa5acb5919ea34b578739a4703cf30de5382aaba06250af7": 1, - "fc819a8f00497253d2231099c726434b4ed85f9677af7d287111e52c09f005a7": 1, - "fd37ca50cf574f86f8640694f4e2bc7466e97e9d54869402a08cceab69dee52c": 1 - }, "packages/client/ui-settings/tests/schema.client.spec.ts": { "0584be4cde4cd33049ef7c02e9d1562a30ec48ef2176f2f22b328b5be2903407": 1 }, From 5882efd7cee555fa78eb1e6d8510ebb447282def Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 21 Sep 2026 17:13:41 +0800 Subject: [PATCH 7/7] test(web): re-record the bundle golden for plugin-owned identity The bundle page now titles the fixture by its package name, prints the row id as code under the row's title, and drops the missing-description line, as master's row and official goldens already record. --- .../expected/plugin-config/bundle.expected.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/web/tests/expected/plugin-config/bundle.expected.md b/apps/web/tests/expected/plugin-config/bundle.expected.md index 50cb737738..ff95faa173 100644 --- a/apps/web/tests/expected/plugin-config/bundle.expected.md +++ b/apps/web/tests/expected/plugin-config/bundle.expected.md @@ -1,19 +1,19 @@ - button "返回插件列表": 插件列表 - button "夹具操作" -- button "卸载 live-client": 卸载 -- switch "启用 live-client" [checked] -- heading "live-client" [level=3] +- button "卸载 @fixture/live-client": 卸载 +- switch "启用 @fixture/live-client" [checked] +- heading "@fixture/live-client" [level=3] - text: v0.0.1 夹具标签 - paragraph: - code: "@fixture/live-client" -- paragraph: 暂无描述。 - heading "包含的组件" [level=4] - text: 共 1 个 · 1 运行中 - list: - listitem: - - button "配置 fixture-live-client": fixture-live-client - - text: "@fixture/live-client 运行中" - - switch "启用组件 fixture-live-client" [checked] + - button "配置 @fixture/live-client": "@fixture/live-client" + - code: fixture-live-client + - text: 运行中 + - switch "启用组件 @fixture/live-client" [checked] - region "夹具区块": - heading "夹具区块" [level=4] - paragraph: 来自夹具的区块内容