Merge remote-tracking branch 'origin/master' into feat/plugin-owned-settings-surface

# Conflicts:
#	packages/client/ui-plugin-config/README.i18n.yaml
#	packages/client/ui-plugin-config/README.md
#	packages/client/ui-plugin-config/README.zh.md
#	packages/client/ui-plugin-config/src/client/PluginConfigSection.tsx
#	packages/client/ui-plugin-config/src/client/index.ts
#	packages/client/ui-plugin-config/tests/apply.client.spec.ts
#	packages/client/ui-plugin-config/tests/section.client.spec.tsx
This commit is contained in:
Yichen Jiang
2026-08-12 22:50:49 +08:00
124 changed files with 2593 additions and 424 deletions

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-11-plugin-settings-tabs.md
2026-08-11-plugin-settings-tabs.md: c46276de2d0ffee141377190ccacf0e7884ec994
2026-08-11-plugin-settings-tabs.zh.md: 00feb44f7b612e191c5b4c7dd3675ef34bf6e5ac

View File

@@ -0,0 +1,37 @@
# Agent Note: Feature-owned tabs in Plugins settings
Status: implemented
English | [中文](2026-08-11-plugin-settings-tabs.zh.md)
## Problem
Plugin configuration and the read-only Loader inventory each registered a top-level `settings.section`. They described the same Plugins domain but occupied two navigation rows, split search and configuration into unrelated pages, and gave the Settings shell no principled way to present them together. Combining their components directly would instead make one feature plugin import and own the other feature's data lifecycle.
## Decision
`@deepseek-ai/dsh-client-ui-plugin-config` owns the single `settings.section` contribution with id `plugins`. It renders the shared title and compact tab chrome, declares the root-scoped list slot `settings.plugins.tab`, and projects that ledger's id, order, and locale-following label into its tabs. The slot's canonical type lives in `ui-settings`, so a tab contributor depends on the Settings domain contract rather than on another feature plugin.
The section owner contributes a `configurable` tab that declares the existing nested `settings.plugin.item` list. Configuration cards keep their namespace bindings, draft state, validation, and writes unchanged. `@deepseek-ai/dsh-client-ui-plugins` contributes an `all` tab to `settings.plugins.tab`; its Host Loader observer, generated Remote namespace, DTO, and search semantics remain unchanged. Disabled inventory entries omit the redundant unmounted runtime state from summaries and details, while enabled entries continue to expose their Cordis phase.
The first ordered tab is selected by default. A tab mounts only when first selected and then remains mounted but hidden while the Plugins section stays mounted. This delays the inventory RPC until the user opens **Plugin list** and preserves drafts, search text, disclosure state, and the fetched snapshot while switching tabs. Closing Settings unmounts the section, so reopening it obtains a fresh inventory snapshot when that tab is selected again.
Both registrations use `ctx.slots.inject()`. If the section declarer unloads, the tab declaration and every contribution collapse with it; redeclaration lets each feature re-register without a static import or activation-order dependency.
## Alternatives considered
**Keep two Settings navigation rows and only rename them.** Rejected because the duplication is structural, not copy-related: both pages still represent the same Plugins domain and compete for navigation space.
**Import the inventory component into `ui-plugin-config`.** Rejected because the configuration plugin would then own another plugin's Remote dependency and lifecycle. It would also turn an optional browser contribution into a package-level dependency.
**Hard-code the two tab labels and components in the section owner.** Rejected because a third feature would require editing the owner, and HMR teardown could leave chrome for a contribution that no longer exists. The slot ledger already provides identity, ordering, localization, and cascade semantics.
**Move Plugins aggregation into `ui-settings-general`.** Rejected because the Settings shell owns generic navigation and modal chrome, not feature content. Adding Plugins-specific tabs there would make every future Plugins view a shell change.
## Consequences
Settings has one Plugins navigation row, ordered before Agent Presets, with **Plugin configuration** and **Plugin list** tabs. Agent Presets remains an independent section because it edits per-session agent compositions rather than the live Host Loader tree.
Feature ownership remains explicit: `ui-plugin-config` owns the Plugins page and editable cards, `ui-plugins` owns the read-only inventory view, and the Host/RPC path does not change. A new Plugins view can join by registering one `settings.plugins.tab` contribution.
The aggregation depends on the section owner being composed: without `ui-plugin-config`, `ui-plugins` waits for a tab declaration and renders nothing. That is an intentional composition dependency carried by the slot registry rather than a static package import.

View File

@@ -0,0 +1,37 @@
# Agent Note: “插件”设置中的功能自有标签页
Status: implemented
[English](2026-08-11-plugin-settings-tabs.md) | 中文
## 问题
插件配置与只读 Loader 清单各自注册了一个顶层 `settings.section`。两者描述同一个“插件”领域,却占据两行导航,把搜索与配置拆成互不相关的页面,也没有给 Settings 外壳一个有原则的聚合方式。若直接合并两者的组件,则会让一个功能插件 import 并拥有另一个功能的数据生命周期。
## 决策
`@deepseek-ai/dsh-client-ui-plugin-config` 拥有唯一一个 id 为 `plugins` 的 `settings.section` 贡献。它渲染共享标题和紧凑标签栏,声明根级列表 slot `settings.plugins.tab`,并把该记录中的 id、order 与跟随语言的 label 投影成标签页。该 slot 的规范类型位于 `ui-settings`,因此标签页贡献方依赖设置领域约定,而不是依赖另一个功能插件。
分区拥有方贡献 `configurable` 标签页,由它声明既有的嵌套 `settings.plugin.item` 列表。配置卡片原有的命名空间绑定、草稿状态、校验与写入均保持不变。`@deepseek-ai/dsh-client-ui-plugins` 向 `settings.plugins.tab` 贡献 `all` 标签页;它的 Host Loader 观察器、生成的 Remote 命名空间、DTO 与搜索语义保持不变。已停用的清单条目会在摘要和详情中省略重复的“未挂载”运行状态,已启用条目仍显示其 Cordis 阶段。
默认选择顺序中的第一个标签页。某个标签页只有首次被选择时才挂载,之后在“插件”分区保持挂载期间只隐藏而不卸载。这样会把清单 RPC 延迟到用户打开**插件列表**时,并在切换标签页时保留草稿、搜索文本、折叠状态和已读取的快照。关闭 Settings 会卸载该分区,因此再次打开后,重新选择该标签页时会取得新的清单快照。
两项注册都使用 `ctx.slots.inject()`。分区声明方卸载时,标签 slot 及其全部贡献随之折叠;重新声明后,每项功能都能重新注册,无需静态 import,也不依赖激活顺序。
## 备选方案
**保留两行 Settings 导航,只改名称。** 否决,因为重复是结构问题,而非文案问题:两个页面仍然代表同一个“插件”领域,并继续争夺导航空间。
**把清单组件 import 进 `ui-plugin-config`。** 否决,因为配置插件会因此拥有另一个插件的 Remote 依赖与生命周期,也会把可选的浏览器贡献变成包级依赖。
**在分区拥有方硬编码两个标签页的名称和组件。** 否决,因为第三项功能需要修改拥有方,HMR teardown 也可能留下已不存在贡献的界面框架。slot 记录已经提供标识、顺序、本地化与级联语义。
**把“插件”聚合移入 `ui-settings-general`。** 否决,因为 Settings 外壳拥有通用导航与模态界面框架,而不拥有功能内容。把“插件”专属标签页放在那里,会让今后每一种“插件”视图都需要修改外壳。
## 影响
Settings 只有一行“插件”导航,排在“Agent 预设”之前,包含**插件配置**与**插件列表**两个标签页。“Agent 预设”仍是独立分区,因为它编辑每个会话的 agent 组装,而非实时 Host Loader 树。
功能所有权保持明确:`ui-plugin-config` 拥有“插件”页面与可编辑卡片,`ui-plugins` 拥有只读清单视图,Host/RPC 路径不变。新的“插件”视图只需注册一个 `settings.plugins.tab` 贡献即可加入。
该聚合依赖分区拥有方被组装:没有 `ui-plugin-config` 时,`ui-plugins` 会等待标签 slot 的声明且不渲染任何内容。这是通过 slot 注册表承载的有意组合依赖,而不是静态包 import。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-12-plugin-owned-settings-surface.md
2026-08-12-plugin-owned-settings-surface.md: 2cc78986906e83132f4401fc80c0cd601b71827c
2026-08-12-plugin-owned-settings-surface.zh.md: 8f076e2690f5eaf0129ff209883b57fe44ffedb6
2026-08-12-plugin-owned-settings-surface.md: 3137cfe81ef3cb78a940f085c559ab4a7b62cce3
2026-08-12-plugin-owned-settings-surface.zh.md: 8dd5e5ccebf1cfb80b55a615f6049dd391943bd7

View File

@@ -20,13 +20,13 @@ Together the two meant a user-authored plugin was configurable only by hand-edit
**The settings seam is untouched.** Which client may read a namespace, and which page renders it, are facts about consumers; a Service Definition that carried either would let one Consumer dictate its contract. `SettingsRegisterOptions` gains nothing.
**`settings.plugin.item` is keyed on the settings namespace.** The slot moved from `list` to `keyed`, the key being the namespace the card edits, following the `tool.call.toolview` precedent where each tool plugin registers its renderer under the tool name. A card declares `key`, not `id`/`order`.
**`settings.plugin.item` is keyed on the settings namespace.** The slot moved from `list` to `keyed`, the key being the namespace the card edits, following the `tool.call.toolview` precedent where each tool plugin registers its renderer under the tool name. A card declares `key`, not `id`/`order`. The slot is declared by the Plugins section's `configurable` tab, which owns the card list.
**The section drives dispatch from the served namespaces.** It reads `settings.describe` once, subscribes to the settings-document invalidation and to connection resets, and dispatches one key per served namespace. What renders is the intersection of two ledgers — namespaces a live Host plugin registered, and cards registered under those keys — computed in the section's controller from the slot ledger (`ctx.slots.entries`, `ctx.slots.subscribe`) and the wire answer.
**The tab drives dispatch from the served namespaces.** It reads `settings.describe` once, subscribes to the settings-document invalidation and to connection resets, and dispatches one key per served namespace. What renders is the intersection of two ledgers — namespaces a live Host plugin registered, and cards registered under those keys — computed in the tab's controller from the slot ledger (`ctx.slots.entries`, `ctx.slots.subscribe`) and the wire answer.
Keying makes absence the signal, and that is what removes the bookkeeping the previous shape needed. A namespace another surface owns (`ui-theme`, `permission`, `llm-*`, `agent-presets`) has no card under its key, so it renders nothing without declaring anything anywhere. A card whose namespace this deployment does not serve is never dispatched, which also fixes the old empty-state defect: the section counted registered cards, including ones rendering nothing, so a deployment exposing none showed an empty list instead of its empty line.
Keying makes absence the signal, and that is what removes the bookkeeping the previous shape needed. A namespace another surface owns (`ui-theme`, `permission`, `llm-*`, `agent-presets`) has no card under its key, so it renders nothing without declaring anything anywhere. A card whose namespace this deployment does not serve is never dispatched, which also fixes the old empty-state defect: the tab counted registered cards, including ones rendering nothing, so a deployment exposing none showed an empty list instead of its empty line.
**Nothing renders a form it was not given.** The section supplies no fallback card. A plugin's browser half owns its card completely — chrome, controls, and copy — which is what the slot's `fallback` option would have replaced with a schema-reverse-rendered form.
**Nothing renders a form it was not given.** The tab supplies no fallback card. A plugin's browser half owns its card completely — chrome, controls, and copy — which is what the slot's `fallback` option would have replaced with a schema-reverse-rendered form.
## What the allowlist protected

View File

@@ -20,13 +20,13 @@ Status: implemented
**settings seam 不动。** 哪个客户端可以读某个命名空间、哪个页面渲染它,都是关于 Consumer 的事实;Service Definition 只要携带其中之一,就等于让一个 Consumer 决定它的契约。`SettingsRegisterOptions` 一个字段都没加。
**`settings.plugin.item` 以 settings 命名空间为键。** 该 slot 从 `list` 改为 `keyed`,键就是卡片所编辑的命名空间,沿用 `tool.call.toolview` 的先例——每个工具插件把自己的渲染器注册在工具名这个键上。卡片声明 `key`,不再声明 `id`/`order`。
**`settings.plugin.item` 以 settings 命名空间为键。** 该 slot 从 `list` 改为 `keyed`,键就是卡片所编辑的命名空间,沿用 `tool.call.toolview` 的先例——每个工具插件把自己的渲染器注册在工具名这个键上。卡片声明 `key`,不再声明 `id`/`order`。该 slot 由「插件」分区的 `configurable` 标签页声明,卡片列表归它所有。
**分区以被服务的命名空间驱动派发。** 它读取一次 `settings.describe`,订阅 settings 文档失效通知与连接重置,并为每个被服务的命名空间派发一个键。渲染出来的是两份账本的交集——存活 Host 插件注册的命名空间,以及注册在这些键上的卡片——由分区的 controller 从 slot 账本(`ctx.slots.entries`、`ctx.slots.subscribe`)与协议答复算出。
**标签页以被服务的命名空间驱动派发。** 它读取一次 `settings.describe`,订阅 settings 文档失效通知与连接重置,并为每个被服务的命名空间派发一个键。渲染出来的是两份账本的交集——存活 Host 插件注册的命名空间,以及注册在这些键上的卡片——由标签页的 controller 从 slot 账本(`ctx.slots.entries`、`ctx.slots.subscribe`)与协议答复算出。
以命名空间为键,让「缺席」本身成为信号,而这正是它消掉旧形态所需簿记的原因。归别的界面所有的命名空间(`ui-theme`、`permission`、`llm-*`、`agent-presets`)在其键上没有卡片,于是什么都不渲染,且无需在任何地方声明任何东西。命名空间未被本部署服务的卡片根本不会被派发,这同时修掉了旧的空态缺陷:分区数的是已注册卡片,其中包含那些什么都不渲染的,因此一个都不暴露的部署看到的是空列表,而不是它那行空态文案。
以命名空间为键,让「缺席」本身成为信号,而这正是它消掉旧形态所需簿记的原因。归别的界面所有的命名空间(`ui-theme`、`permission`、`llm-*`、`agent-presets`)在其键上没有卡片,于是什么都不渲染,且无需在任何地方声明任何东西。命名空间未被本部署服务的卡片根本不会被派发,这同时修掉了旧的空态缺陷:标签页数的是已注册卡片,其中包含那些什么都不渲染的,因此一个都不暴露的部署看到的是空列表,而不是它那行空态文案。
**不渲染任何未被交给它的表单。** 分区不提供兜底卡片。插件的浏览器半侧完整拥有自己的卡片——外观、控件与文案——而这正是 slot 的 `fallback` 选项会用一份 schema 反向渲染的表单取代掉的东西。
**不渲染任何未被交给它的表单。** 标签页不提供兜底卡片。插件的浏览器半侧完整拥有自己的卡片——外观、控件与文案——而这正是 slot 的 `fallback` 选项会用一份 schema 反向渲染的表单取代掉的东西。
## 白名单实际护住了什么