diff --git a/.agents/notes/implemented/architecture/2026-08-11-plugin-settings-tabs.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-11-plugin-settings-tabs.i18n.yaml new file mode 100644 index 0000000000..980999fc1a --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-11-plugin-settings-tabs.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-08-11-plugin-settings-tabs.md +2026-08-11-plugin-settings-tabs.md: c46276de2d0ffee141377190ccacf0e7884ec994 +2026-08-11-plugin-settings-tabs.zh.md: 00feb44f7b612e191c5b4c7dd3675ef34bf6e5ac diff --git a/.agents/notes/implemented/architecture/2026-08-11-plugin-settings-tabs.md b/.agents/notes/implemented/architecture/2026-08-11-plugin-settings-tabs.md new file mode 100644 index 0000000000..c46276de2d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-11-plugin-settings-tabs.md @@ -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. diff --git a/.agents/notes/implemented/architecture/2026-08-11-plugin-settings-tabs.zh.md b/.agents/notes/implemented/architecture/2026-08-11-plugin-settings-tabs.zh.md new file mode 100644 index 0000000000..00feb44f7b --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-11-plugin-settings-tabs.zh.md @@ -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。 diff --git a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.i18n.yaml b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.i18n.yaml index 492b10cf8a..fabb554407 100644 --- a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md -2026-08-10-telemetry-default-off.md: 8163079eb5f8d6170e329c164141364681030793 -2026-08-10-telemetry-default-off.zh.md: c8e16f84248bde5bdc2c1d4bdb814f0d80fcf77e +2026-08-10-telemetry-default-off.md: c3e5d9e0b65449f91044f16e7649f4a5ff2f5b61 +2026-08-10-telemetry-default-off.zh.md: 8e2544eb7dee8b9bd6b8a4c81a28a8a03d3404d4 diff --git a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md index 8163079eb5..c3e5d9e0b6 100644 --- a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md +++ b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md @@ -12,7 +12,7 @@ DeepSeek Harness has two outbound telemetry feeds. During internal testing, the Both feeds use `DSH_TELEMETRY_MODE` as their positive consent setting. Unset and empty values resolve to `DISABLED`. `@deepseek-ai/dsh-session-telemetry-otel` also resolves an omitted `mode` to `DISABLED`, which constructs no OTel provider, processor, or exporter and leaves feedback in the local session log. The shared dsh base keeps the backend row mounted so disabled feedback can still explain that nothing was shared. A deployment opts into Session Log sharing through `FULL` or `FEEDBACK_ONLY`; only `FULL` also permits dsh-sdk launcher reporting. Any non-empty `DSH_TELEMETRY_DISABLED` remains an authoritative pre-load hard opt-out. The [default-mount decision](2026-07-31-web-telemetry-default-mount.md) continues to own the endpoint, batching cadence, and exit-drain settings. -The dsh-sdk launcher reads the same variable without parsing `cordis.yml` or booting Cordis. `FULL` permits reporting; `FEEDBACK_ONLY`, `DISABLED`, unset, and empty values deny it. Consent is frozen from the launching environment before the command runs, because `dsh-sdk start` loads a project `.env` and project code can mutate `process.env`: resolving afterwards would let a project grant reporting of its own configuration, which the [configuration source ownership decision](../architecture/2026-08-04-configuration-source-ownership.md) denies for the whole `DSH_*` namespace. An unsupported mode denies rather than throwing at that boundary, since telemetry may never change a command's result. Telemetry consent is owned here; no SDK project configuration or toolchain may opt in on the launching environment's behalf. +The dsh-sdk launcher reads the same variable without parsing `cordis.yml` or booting Cordis. `FULL` permits reporting; `FEEDBACK_ONLY`, `DISABLED`, unset, and empty values deny it. Consent is frozen from the launching environment before the command runs, because `dsh-sdk start` loads a project `.env` and project code can mutate `process.env`: resolving afterwards would let a project grant reporting of its own configuration, which the [configuration source ownership decision](../architecture/2026-08-04-configuration-source-ownership.md) denies for the whole `DSH_*` namespace. An unsupported mode denies rather than throwing at that boundary, since telemetry may never change a command's result. This rule superseded the default-on launcher consent before the launcher and its proposal were deleted by the [SDK project toolchain removal](../simplification/2026-08-11-remove-sdk-project-toolchain.md). The versioned Web welcome notice states that Session Log upload is off by default, names `DSH_TELEMETRY_MODE=FEEDBACK_ONLY` and `DSH_TELEMETRY_MODE=FULL` as the two opt-in choices, and discloses that `FULL` also enables dsh-sdk command telemetry. Its version changes with that material privacy statement so every profile acknowledges the current copy. diff --git a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.zh.md b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.zh.md index c8e16f8424..8e2544eb7d 100644 --- a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.zh.md +++ b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.zh.md @@ -12,7 +12,7 @@ DeepSeek Harness 有两路出站遥测数据流。在内测阶段,共享基础 两路数据流都使用 `DSH_TELEMETRY_MODE` 作为正向授权配置。未设置和空值都解析为 `DISABLED`。`@deepseek-ai/dsh-session-telemetry-otel` 也将省略的 `mode` 解析为 `DISABLED`;该模式不构造 OTel 提供方、处理器或导出器,并将反馈留在本地会话日志中。dsh 共享基础配置继续挂载后端配置行,使禁用模式仍可在记录反馈时说明没有共享任何内容。部署方通过 `FULL` 或 `FEEDBACK_ONLY` 显式启用 Session Log 共享;只有 `FULL` 还允许 dsh-sdk 启动器上报。任何非空 `DSH_TELEMETRY_DISABLED` 仍是具有最高优先级的加载前硬性退出开关。[默认挂载决策](2026-07-31-web-telemetry-default-mount.md)继续负责 endpoint、批处理节奏和退出排空设置。 -dsh-sdk 启动器读取同一变量,不解析 `cordis.yml`,也不启动 Cordis。`FULL` 允许上报;`FEEDBACK_ONLY`、`DISABLED`、未设置和空值都会拒绝。授权在命令执行前从启动环境冻结:`dsh-sdk start` 会加载项目 `.env`,项目代码也能修改 `process.env`,若在执行后解析,项目便能自行授权上报其自身配置,而[配置来源所有权决策](../architecture/2026-08-04-configuration-source-ownership.md)对整个 `DSH_*` 命名空间禁止这种行为。在该边界上,不受支持的模式按拒绝处理而非抛出,因为遥测不得改变命令结果。遥测授权由本说明持有;SDK 项目配置或工具链不得代替启动环境显式启用遥测。 +dsh-sdk 启动器读取同一变量,不解析 `cordis.yml`,也不启动 Cordis。`FULL` 允许上报;`FEEDBACK_ONLY`、`DISABLED`、未设置和空值都会拒绝。授权在命令执行前从启动环境冻结:`dsh-sdk start` 会加载项目 `.env`,项目代码也能修改 `process.env`,若在执行后解析,项目便能自行授权上报其自身配置,而[配置来源所有权决策](../architecture/2026-08-04-configuration-source-ownership.md)对整个 `DSH_*` 命名空间禁止这种行为。在该边界上,不受支持的模式按拒绝处理而非抛出,因为遥测不得改变命令结果。此规则在启动器及其提案被[SDK 项目工具链移除决策](../simplification/2026-08-11-remove-sdk-project-toolchain.md)删除之前,仅取代了启动器默认允许上报的规则。 带版本的 Web 欢迎通知说明会话日志上传默认关闭,将 `DSH_TELEMETRY_MODE=FEEDBACK_ONLY` 和 `DSH_TELEMETRY_MODE=FULL` 列为两种显式启用选项,并披露 `FULL` 同时会启用 dsh-sdk 命令遥测。其版本随这项重要的隐私声明一同变更,使每个 profile 都确认当前文案。 diff --git a/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.i18n.yaml b/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.i18n.yaml index a27cb812e9..bea499a69a 100644 --- a/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.md -2026-08-10-web-plugin-configuration.md: 7375f496c7af1a695243444fe56aca7262d3dedd -2026-08-10-web-plugin-configuration.zh.md: 59d65db39bcc2306983f2a26dcf252164d7a6f37 +2026-08-10-web-plugin-configuration.md: 29c69695141d4fd00f5b323232522c5298ce4e9b +2026-08-10-web-plugin-configuration.zh.md: d7df9c332fc3cbca20ec75cb543600601bbac141 diff --git a/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.md b/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.md index 7375f496c7..29c6969514 100644 --- a/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.md +++ b/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.md @@ -12,7 +12,7 @@ The seam that made the Models page possible was already general: any plugin may ## Decision -Three host-plane plugins register their own settings namespace, and one browser-side section renders whatever the deployment exposes. +Three host-plane plugins register their own settings namespace, and one browser-side Plugins section aggregates feature-owned tabs. Its configurable tab renders whatever editable settings the deployment exposes. **Layering, unchanged.** A section resolves as schema defaults → the plugin's composition entry → the user layer. Each plugin passes its `cordis.yml` entry as the `base` and reads its config through a source thunk, so a stored change reaches the next use and a detaching settings provider leaves the composition entry running. Constraints the schema cannot express — positive and finite, the timer bound on `graceMs`, the parallel cap being a positive integer — become the section validator, so a bad value is refused at the write instead of at the next command. @@ -24,7 +24,7 @@ Three host-plane plugins register their own settings namespace, and one browser- **Exposure stays a Host allowlist.** The three namespaces join `WEB_SETTINGS_NAMESPACES`; registration alone still never crosses the transport, and a namespace absent from that list answers `settings-not-exposed` exactly as an unregistered one does. -**The section knows no namespace.** `dsh-client-ui-plugin-config` declares a `settings.plugin.item` slot and renders the cards registered into it, so a plugin that ships a browser half owns its card and its controls. Each card binds its namespace through the client settings scope, which gained the two things a form needs: the raw `user` layer, whose key PRESENCE is what marks a field overridden, and `unset`, which clears one field back to the composition layer. A card renders nothing while its namespace is unavailable, so a deployment that does not compose the owning plugin shows no trace of it. +**The configurable tab knows no namespace.** `dsh-client-ui-plugin-config` owns the Plugins section, contributes its `configurable` page through `settings.plugins.tab`, and declares a nested `settings.plugin.item` slot there. It renders the cards registered into that nested slot, so a plugin that ships a browser half owns its card and its controls. Each card binds its namespace through the client settings scope, which gained the two things a form needs: the raw `user` layer, whose key PRESENCE is what marks a field overridden, and `unset`, which clears one field back to the composition layer. A card renders nothing while its namespace is unavailable, so a deployment that does not compose the owning plugin shows no trace of it. **A card stages its edits and writes them on save.** Controls hold no draft of their own: the card's form owns the staged text, every control renders it, and only **Save** turns it into document mutations. A settings write is durable and revision-fenced, so a control that committed as it settled spent a revision on a value the user had not decided to store and could not preview; the reset stages the composed default the same way. Because the Host's validators own the constraints no schema can express, the form reads the section back after writing and reports a save that did not land instead of predicting the outcome, keeping those drafts for the user to correct. The credential control is staged with the rest even though it writes through the credentials domain, so one save covers everything the card shows. diff --git a/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.zh.md b/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.zh.md index 59d65db39b..d7df9c332f 100644 --- a/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.zh.md +++ b/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -三个宿主平面插件各自注册 settings 命名空间,一个浏览器侧分区渲染该部署所暴露的一切。 +三个宿主平面插件各自注册 settings 命名空间,一个浏览器侧“插件”分区聚合由各功能持有的标签页。它的“可配置”标签页渲染该部署所暴露的一切可编辑设置。 **分层不变。** 一个分节按 schema 默认值 → 插件的组装条目 → 用户层解析。每个插件把自己的 `cordis.yml` 条目作为 `base` 传入,并通过 source thunk 读取配置,因此存储的变更会作用于下一次使用,而脱离的 settings 提供方会让组装条目继续运行。schema 无法表达的约束——正有限、`graceMs` 的定时器上界、并行上限必须是正整数——成为分节的校验器,因此错误的值在写入时被拒绝,而不是到下一条命令时才失败。 @@ -24,7 +24,7 @@ Status: implemented **暴露仍是 Host 的白名单。** 这三个命名空间加入 `WEB_SETTINGS_NAMESPACES`;仅有注册依然不会跨越传输边界,而不在该名单中的命名空间会与未注册的命名空间得到完全相同的 `settings-not-exposed`。 -**该分区不认识任何命名空间。** `dsh-client-ui-plugin-config` 声明 `settings.plugin.item` slot 并渲染注册进来的卡片,因此带浏览器半侧的插件拥有自己的卡片与控件。每张卡片通过客户端 settings scope 绑定其命名空间,而该 scope 补上了表单所需的两样东西:原始 `user` 层——键的**存在**才标记字段被覆盖——以及把单个字段清回组装层的 `unset`。命名空间不可用时卡片什么都不渲染,因此未组装该插件的部署不会显示它的任何痕迹。 +**“可配置”标签页不认识任何命名空间。** `dsh-client-ui-plugin-config` 拥有“插件”分区,通过 `settings.plugins.tab` 贡献自己的 `configurable` 页面,并在其中声明嵌套的 `settings.plugin.item` slot。它渲染注册进这个嵌套 slot 的卡片,因此带浏览器半侧的插件拥有自己的卡片与控件。每张卡片通过客户端 settings scope 绑定其命名空间,而该 scope 补上了表单所需的两样东西:原始 `user` 层——键的**存在**才标记字段被覆盖——以及把单个字段清回组装层的 `unset`。命名空间不可用时卡片什么都不渲染,因此未组装该插件的部署不会显示它的任何痕迹。 **卡片暂存修改,保存时才写入。** 控件不持有自己的草稿:暂存文本归卡片的表单所有,所有控件渲染的都是它,只有**保存**才把它变成文档变更。settings 写入是持久且带 revision 栅栏的,因此「失焦即提交」的控件会为用户尚未决定存储、也无从预览的值花掉一个 revision;重置同样只是暂存组装默认值。schema 表达不了的约束归 Host 的校验器所有,所以表单在写入后回读分节、报告没有落盘的保存,而不是自行预测结果,并保留这些草稿供用户修改。密钥控件虽然经由 credentials 领域写入,也和其余字段一起暂存,因此一次保存覆盖卡片上的全部内容。 diff --git a/apps/web/tests/plugin-config.e2e.ts b/apps/web/tests/plugin-config.e2e.ts index 15956877f4..3c97f4b414 100644 --- a/apps/web/tests/plugin-config.e2e.ts +++ b/apps/web/tests/plugin-config.e2e.ts @@ -1,5 +1,5 @@ -// Web e2e scenario: the Plugins settings section — the cards a deployment's -// exposed host-plane namespaces produce, one field edited through the real +// Web e2e scenario: the configurable tab in Plugins settings — the cards a +// deployment's exposed host-plane namespaces produce, one field edited through the real // wire down to `$DSH_HOME/settings.yaml`, and the override badge and reset // that layering produces. Zero model calls: everything is client state plus // the settings document on a blank frame, so there is no fixture and a stray @@ -56,9 +56,12 @@ describe('web e2e: plugin configuration section', () => { await page.getByRole('button', { name: '设置', exact: true }).click() const dialog = page.getByRole('dialog', { name: '设置' }) await dialog.waitFor({ timeout: 10_000 }) - await dialog.getByRole('button', { name: '插件配置', exact: true }).click() + await dialog.getByRole('button', { name: '插件', exact: true }).click() await expect - .poll(() => dialog.getByRole('button', { name: '插件配置', exact: true }).getAttribute('aria-current'), { timeout: 5_000 }) + .poll(() => dialog.getByRole('button', { name: '插件', exact: true }).getAttribute('aria-current'), { timeout: 5_000 }) + .toBe('true') + await expect + .poll(() => dialog.getByRole('tab', { name: '插件配置', exact: true }).getAttribute('aria-selected'), { timeout: 5_000 }) .toBe('true') return dialog } diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index 5d057a7bed..d84f314bf5 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -99,6 +99,7 @@ describe('web e2e: settings modal and General preferences', () => { // an unrelated plugin does not rewrite this surface's golden. await dialog.getByRole('button', { name: '插件', exact: true }).click() await dialog.getByRole('heading', { name: '插件', exact: true }).waitFor({ timeout: 10_000 }) + await dialog.getByRole('tab', { name: '插件列表', exact: true }).click() const pluginRow = dialog.locator(PLUGIN_ROW_SELECTOR) await pluginRow.waitFor({ timeout: 10_000 }) const expectedPluginCount = [...scaffold.ctx.loader.entries()] @@ -109,6 +110,7 @@ describe('web e2e: settings modal and General preferences', () => { expect(await dialog.locator('[data-plugin-count]').getAttribute('data-plugin-count')) .toBe(String(expectedPluginCount)) expect(await dialog.getByRole('button', { name: '插件', exact: true }).getAttribute('aria-current')).toBe('true') + expect(await dialog.getByRole('tab', { name: '插件列表', exact: true }).getAttribute('aria-selected')).toBe('true') expect(await dialog.getByRole('button', { name: '模型' }).getAttribute('aria-current')).toBeNull() const pluginsSnapshot = await captureStableAria( page, diff --git a/apps/web/tests/snapshots/agent-preset-authoring/created.expected.md b/apps/web/tests/snapshots/agent-preset-authoring/created.expected.md index 40b9497831..c29b1cf478 100644 --- a/apps/web/tests/snapshots/agent-preset-authoring/created.expected.md +++ b/apps/web/tests/snapshots/agent-preset-authoring/created.expected.md @@ -13,9 +13,6 @@ - button "Agent 预设": - img - text: Agent 预设 - - button "插件配置": - - img - - text: 插件配置 - button "打开配置文件" - button "关闭": - img diff --git a/apps/web/tests/snapshots/agent-preset-authoring/damaged.expected.md b/apps/web/tests/snapshots/agent-preset-authoring/damaged.expected.md index c3e9035098..15d4cc8f94 100644 --- a/apps/web/tests/snapshots/agent-preset-authoring/damaged.expected.md +++ b/apps/web/tests/snapshots/agent-preset-authoring/damaged.expected.md @@ -13,9 +13,6 @@ - button "Agent 预设": - img - text: Agent 预设 - - button "插件配置": - - img - - text: 插件配置 - button "打开配置文件" - button "关闭": - img diff --git a/apps/web/tests/snapshots/agent-preset-authoring/section.expected.md b/apps/web/tests/snapshots/agent-preset-authoring/section.expected.md index e411e8ea28..312958187b 100644 --- a/apps/web/tests/snapshots/agent-preset-authoring/section.expected.md +++ b/apps/web/tests/snapshots/agent-preset-authoring/section.expected.md @@ -13,9 +13,6 @@ - button "Agent 预设": - img - text: Agent 预设 - - button "插件配置": - - img - - text: 插件配置 - button "打开配置文件" - button "关闭": - img diff --git a/apps/web/tests/snapshots/models-settings/configured.expected.md b/apps/web/tests/snapshots/models-settings/configured.expected.md index 66cc7c88b6..3c3be0922c 100644 --- a/apps/web/tests/snapshots/models-settings/configured.expected.md +++ b/apps/web/tests/snapshots/models-settings/configured.expected.md @@ -13,9 +13,6 @@ - button "Agent 预设": - img - text: Agent 预设 - - button "插件配置": - - img - - text: 插件配置 - button "打开配置文件" - button "关闭": - img diff --git a/apps/web/tests/snapshots/models-settings/declared-edit.expected.md b/apps/web/tests/snapshots/models-settings/declared-edit.expected.md index 86f8b77fe8..1d538bcfe4 100644 --- a/apps/web/tests/snapshots/models-settings/declared-edit.expected.md +++ b/apps/web/tests/snapshots/models-settings/declared-edit.expected.md @@ -13,9 +13,6 @@ - button "Agent 预设": - img - text: Agent 预设 - - button "插件配置": - - img - - text: 插件配置 - button "打开配置文件" - button "关闭": - img diff --git a/apps/web/tests/snapshots/models-settings/declared.expected.md b/apps/web/tests/snapshots/models-settings/declared.expected.md index b9e5dca61f..df48328fd3 100644 --- a/apps/web/tests/snapshots/models-settings/declared.expected.md +++ b/apps/web/tests/snapshots/models-settings/declared.expected.md @@ -13,9 +13,6 @@ - button "Agent 预设": - img - text: Agent 预设 - - button "插件配置": - - img - - text: 插件配置 - button "打开配置文件" - button "关闭": - img diff --git a/apps/web/tests/snapshots/models-settings/empty.expected.md b/apps/web/tests/snapshots/models-settings/empty.expected.md index 03e87a7a51..cea14113ac 100644 --- a/apps/web/tests/snapshots/models-settings/empty.expected.md +++ b/apps/web/tests/snapshots/models-settings/empty.expected.md @@ -13,9 +13,6 @@ - button "Agent 预设": - img - text: Agent 预设 - - button "插件配置": - - img - - text: 插件配置 - button "打开配置文件" - button "关闭": - img diff --git a/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md b/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md index 562b54d837..5ebf0b1456 100644 --- a/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md +++ b/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md @@ -13,9 +13,6 @@ - button "Agent 预设": - img - text: Agent 预设 - - button "插件配置": - - img - - text: 插件配置 - button "打开配置文件" - button "关闭": - img diff --git a/apps/web/tests/snapshots/onboarding-usable-provider/dismissed.expected.md b/apps/web/tests/snapshots/onboarding-usable-provider/dismissed.expected.md index b3e1141abc..84c7358b54 100644 --- a/apps/web/tests/snapshots/onboarding-usable-provider/dismissed.expected.md +++ b/apps/web/tests/snapshots/onboarding-usable-provider/dismissed.expected.md @@ -13,9 +13,6 @@ - button "Agent 预设": - img - text: Agent 预设 - - button "插件配置": - - img - - text: 插件配置 - button "打开配置文件" - button "关闭": - img diff --git a/apps/web/tests/snapshots/plugin-config/section.expected.md b/apps/web/tests/snapshots/plugin-config/section.expected.md index 18cdef13d5..54ac42b4a7 100644 --- a/apps/web/tests/snapshots/plugin-config/section.expected.md +++ b/apps/web/tests/snapshots/plugin-config/section.expected.md @@ -13,25 +13,26 @@ - button "Agent 预设": - img - text: Agent 预设 - - button "插件配置": - - img - - text: 插件配置 - button "打开配置文件" - button "关闭": - img - text: 关闭 - - heading "插件配置" [level=2] - - paragraph: 配置本部署已安装的插件。 - - list: - - listitem: - - 'button "展开设置: 终端"': - - text: 终端 限制 agent 运行的每一条命令。 - - img - - listitem: - - 'button "展开设置: Agent 循环"': - - text: Agent 循环 Agent 如何派发工具调用。 - - img - - listitem: - - 'button "展开设置: 网页搜索"': - - text: 网页搜索 DeepSeek 搜索提供方。 - - img + - heading "插件" [level=2] + - paragraph: 配置和查看本部署已安装的插件。 + - tablist "插件视图": + - tab "插件配置" [selected] + - tab "插件列表" + - tabpanel "插件配置": + - list: + - listitem: + - 'button "展开设置: 终端"': + - text: 终端 限制 agent 运行的每一条命令。 + - img + - listitem: + - 'button "展开设置: Agent 循环"': + - text: Agent 循环 Agent 如何派发工具调用。 + - img + - listitem: + - 'button "展开设置: 网页搜索"': + - text: 网页搜索 DeepSeek 搜索提供方。 + - img diff --git a/apps/web/tests/snapshots/settings-chrome/dialog.expected.md b/apps/web/tests/snapshots/settings-chrome/dialog.expected.md index 2a7c767bf8..89cff5df3f 100644 --- a/apps/web/tests/snapshots/settings-chrome/dialog.expected.md +++ b/apps/web/tests/snapshots/settings-chrome/dialog.expected.md @@ -13,9 +13,6 @@ - button "Agent 预设": - img - text: Agent 预设 - - button "插件配置": - - img - - text: 插件配置 - button "打开配置文件" - button "关闭": - img diff --git a/packages/client/README.i18n.yaml b/packages/client/README.i18n.yaml index 11b0aa9478..fe9c9e40a6 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: 236531281c17ef982982e97caad99491584bd0b5 -README.zh.md: 73bc3e31c90a4f12c4c5f11e9fd0552601dcc7a6 +README.md: b9452d1f763be5be6953cb7973da8a2c909ed979 +README.zh.md: 0dfb6e6b6d619f111e36d5bdf57d127ac1ca9ef5 diff --git a/packages/client/README.md b/packages/client/README.md index 236531281c..b9452d1f76 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -35,13 +35,13 @@ The browser side of the dsh web GUI: shell boot, browser-host communication, sha | [`ui-model/`](ui-model/README.md) | Provides model selection in conversation surfaces. | | [`ui-permission/`](ui-permission/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-plugin-config/`](ui-plugin-config/README.md) | The Plugins settings section: host-plane plugin configuration as expandable cards. | +| [`ui-plugin-config/`](ui-plugin-config/README.md) | Owns the Plugins settings section, its tab extension point, and configurable host-plane plugin cards. | | [`ui-question/`](ui-question/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-models/`](ui-models/README.md) | Provides model-provider configuration and DeepSeek onboarding. | -| [`ui-plugins/`](ui-plugins/README.md) | Shows the current Host Loader entries in a read-only Settings section. | +| [`ui-plugins/`](ui-plugins/README.md) | Contributes the read-only Host Loader inventory tab to Plugins settings. | Each child reference owns its contract and detailed behavior. The [slot system standard](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md) and [web client architecture note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md) own the cross-package composition and loading decisions. diff --git a/packages/client/README.zh.md b/packages/client/README.zh.md index 73bc3e31c9..0dfb6e6b6d 100644 --- a/packages/client/README.zh.md +++ b/packages/client/README.zh.md @@ -35,13 +35,13 @@ dsh web GUI 的浏览器侧:shell 启动、浏览器与宿主通信、共享 U | [`ui-model/`](ui-model/README.md) | 在对话界面中提供模型选择。 | | [`ui-permission/`](ui-permission/README.md) | 配置默认权限并切换当前会话的访问模式。 | | [`ui-plan/`](ui-plan/README.md) | 展示生效中的 plan mode 状态及其退出控件。 | -| [`ui-plugin-config/`](ui-plugin-config/README.md) | 插件设置分区:把宿主平面的插件配置呈现为可展开卡片。 | +| [`ui-plugin-config/`](ui-plugin-config/README.md) | 拥有“插件”设置分区、它的标签页扩展点,以及可配置的宿主平面插件卡片。 | | [`ui-question/`](ui-question/README.md) | 展示 agent 请求的交互式问题。 | | [`ui-agent-preset/`](ui-agent-preset/README.md) | 选择会话的 agent 预设,并编写预设组合。 | | [`ui-settings/`](ui-settings/README.md) | 承载设置界面及其扩展区域。 | | [`ui-settings-general/`](ui-settings-general/README.md) | 提供常规设置分区。 | | [`ui-models/`](ui-models/README.md) | 提供模型提供方配置与 DeepSeek 配置引导。 | -| [`ui-plugins/`](ui-plugins/README.md) | 在只读设置分区中展示当前 Host Loader 条目。 | +| [`ui-plugins/`](ui-plugins/README.md) | 向“插件”设置贡献只读的 Host Loader 清单标签页。 | 每个子文档负责自身的约定和详细行为。[slot 系统标准](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md)与 [Web 客户端架构 Agent Note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md)负责跨包组合与加载决策。 diff --git a/packages/client/ui-plugin-config/README.i18n.yaml b/packages/client/ui-plugin-config/README.i18n.yaml index d112523b42..9b3486623f 100644 --- a/packages/client/ui-plugin-config/README.i18n.yaml +++ b/packages/client/ui-plugin-config/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-config/README.md -README.md: 7e530d70f6573d619378e43b0245345b45d6db18 -README.zh.md: fd4f980fcf71c00c2357017fb40c76a9ca7a72cc +README.md: 1bf8dd60b966ee0c0e28ae931556182c5c2b2e87 +README.zh.md: bd87e06202b2b083a6e9e089eb91681deea9d678 diff --git a/packages/client/ui-plugin-config/README.md b/packages/client/ui-plugin-config/README.md index 7e530d70f6..1bf8dd60b9 100644 --- a/packages/client/ui-plugin-config/README.md +++ b/packages/client/ui-plugin-config/README.md @@ -2,17 +2,17 @@ English | [中文](README.zh.md) -The **Plugins** settings section: one expandable card per Host plugin whose configuration a user owns. A card shows the plugin's name and what it governs; expanding it in place reveals hand-written controls bound to that plugin's settings namespace, each field marking whether the user overrode it and offering a reset back to the value the deployment composed. +The **Plugins** settings section and its **Plugin configuration** tab. The section owns the heading and compact tab chrome; feature plugins contribute pages through `settings.plugins.tab`. This package's own tab shows one expandable card per Host plugin whose configuration a user owns. A card shows the plugin's name and what it governs; expanding it in place reveals hand-written controls bound to that plugin's settings namespace, each field marking whether the user overrode it and offering a reset back to the value the deployment composed. ## What appears here -A card renders only when its namespace is both registered by a live Host plugin and served to the browser. A deployment that does not compose the owning plugin — or serves the namespace to no client — renders nothing for it rather than an empty or disabled card, so the section reflects what this deployment actually runs. +A card renders only when its namespace is both registered by a live Host plugin and served to the browser. A deployment that does not compose the owning plugin — or serves the namespace to no client — renders nothing for it rather than an empty or disabled card, so the configurable tab reflects what this deployment actually runs. The first batch covers the shell executor (`bash`), the agent loop's tool-call parallelism (`agent-loop`), and the DeepSeek search provider (`web-search-deepseek`). ## Extension point -The section declares `settings.plugin.item`, a root list slot. A plugin that ships a browser half registers its own card into that slot and owns its controls; this package neither enumerates namespaces nor renders a form it was not given. Ordering follows the slot's `order`. +The section declares `settings.plugins.tab`, a root list slot whose labels become ordered tabs. It keeps a tab mounted after its first selection, so local drafts and read-only snapshots survive tab switches. The package registers its own `configurable` contribution, which declares the nested `settings.plugin.item` list slot. A plugin that ships a browser half registers its own card into that nested slot and owns its controls; this package neither enumerates namespaces nor renders a form it was not given. Both levels follow the contribution's `order`. ## Writes diff --git a/packages/client/ui-plugin-config/README.zh.md b/packages/client/ui-plugin-config/README.zh.md index fd4f980fcf..bd87e06202 100644 --- a/packages/client/ui-plugin-config/README.zh.md +++ b/packages/client/ui-plugin-config/README.zh.md @@ -2,17 +2,17 @@ [English](README.md) | 中文 -**插件**设置分区:每个配置由用户拥有的 Host 插件占一张可展开卡片。卡片展示插件名称及其管辖范围;就地展开后是绑定到该插件 settings 命名空间的手写控件,每个字段标注用户是否覆盖过它,并提供重置回部署组装值的入口。 +**插件**设置分区及其**插件配置**标签页。该分区拥有标题与紧凑的标签栏;功能插件通过 `settings.plugins.tab` 贡献页面。本包自己的标签页为每个配置由用户拥有的 Host 插件展示一张可展开卡片。卡片展示插件名称及其管辖范围;就地展开后是绑定到该插件 settings 命名空间的手写控件,每个字段标注用户是否覆盖过它,并提供重置回部署组装值的入口。 ## 这里会出现什么 -只有当某个命名空间既被存活的 Host 插件注册、又被服务给浏览器时,它的卡片才会渲染。未组装该插件的部署——或未向任何客户端服务该命名空间的部署——不会渲染空卡片或禁用卡片,而是什么都不渲染,因此这一分区反映的是该部署实际运行的东西。 +只有当某个命名空间既被存活的 Host 插件注册、又被服务给浏览器时,它的卡片才会渲染。未组装该插件的部署——或未向任何客户端服务该命名空间的部署——不会渲染空卡片或禁用卡片,而是什么都不渲染,因此“插件配置”标签页反映的是该部署实际运行的东西。 第一批覆盖 shell 执行器(`bash`)、agent 循环的工具调用并行度(`agent-loop`)以及 DeepSeek 搜索提供方(`web-search-deepseek`)。 ## 扩展点 -本分区声明了根级列表 slot `settings.plugin.item`。带浏览器半侧的插件把自己的卡片注册进该 slot 并拥有其控件;本包既不枚举命名空间,也不渲染未被交给它的表单。排序遵循 slot 的 `order`。 +本分区声明根级列表 slot `settings.plugins.tab`,其标签会成为有序标签页。某个标签页首次被选择后会保持挂载,因此本地草稿与只读快照在切换标签页时不会丢失。本包注册自己的 `configurable` 贡献,由它声明嵌套的 `settings.plugin.item` 列表 slot。带浏览器半侧的插件把自己的卡片注册进这个嵌套 slot 并拥有其控件;本包既不枚举命名空间,也不渲染未被交给它的表单。两层排序都遵循贡献的 `order`。 ## 写入 diff --git a/packages/client/ui-plugin-config/package.json b/packages/client/ui-plugin-config/package.json index dd4d808789..9637702da7 100644 --- a/packages/client/ui-plugin-config/package.json +++ b/packages/client/ui-plugin-config/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-client-ui-plugin-config", - "description": "Plugin configuration section: host-plane plugin settings as expandable cards", + "description": "Plugins settings section with feature-owned tabs and configurable host-plane plugin cards", "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" diff --git a/packages/client/ui-plugin-config/src/client/ConfigurablePluginsTab.tsx b/packages/client/ui-plugin-config/src/client/ConfigurablePluginsTab.tsx new file mode 100644 index 0000000000..1531e2ad0a --- /dev/null +++ b/packages/client/ui-plugin-config/src/client/ConfigurablePluginsTab.tsx @@ -0,0 +1,25 @@ +/** Configurable Host plugins contributed to the shared Plugins section. */ + +import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type {} from './slot-contract.ts' +import css from './PluginConfigSection.module.css' + +/** Registration-side business face for the configurable tab. */ +export interface ConfigurablePluginsTabInjected { + /** How many cards the slot ledger held when the tab registration mounted. */ + cardCount: number +} + +/** Props the renderer binds for the configurable tab. */ +export type ConfigurablePluginsTabProps = + PropsRuntime<'settings.plugins.tab'> + & PropsLocale<'settings.pluginConfig'> + & PropsRenderSlots<'settings.plugin.item'> + & InjectFace + +/** Render cards registered by plugins that expose editable settings. */ +export function ConfigurablePluginsTab({ t, renderSlot, cardCount }: ConfigurablePluginsTabProps) { + return cardCount === 0 + ?

{t('empty')}

+ : +} diff --git a/packages/client/ui-plugin-config/src/client/PluginConfigSection.module.css b/packages/client/ui-plugin-config/src/client/PluginConfigSection.module.css index 45c9a78f00..5760403c4a 100644 --- a/packages/client/ui-plugin-config/src/client/PluginConfigSection.module.css +++ b/packages/client/ui-plugin-config/src/client/PluginConfigSection.module.css @@ -1,10 +1,10 @@ -/* Plugin configuration section: heading, intro, and the card list. */ +/* Plugins section: compact tabs plus the configurable plugin card list. */ .section { display: flex; flex-direction: column; gap: 12px; - max-width: 720px; + max-width: 760px; color: var(--dsw-alias-label-primary); } @@ -20,6 +20,55 @@ color: var(--dsw-alias-label-tertiary); } +.tabs { + display: flex; + align-items: flex-end; + gap: 22px; + border-bottom: 1px solid var(--dsw-alias-border-l2); + margin-top: 2px; +} + +.tab { + position: relative; + border: 0; + padding: 7px 1px 9px; + background: transparent; + color: var(--dsw-alias-label-tertiary); + font: inherit; + font-size: 13px; + line-height: 20px; + cursor: pointer; +} + +.tab:hover, +.tab[data-active='true'] { + color: var(--dsw-alias-label-primary); +} + +.tab[data-active='true']::after, +.tab:focus-visible::after { + position: absolute; + right: 0; + bottom: -1px; + left: 0; + height: 2px; + border-radius: 2px 2px 0 0; + background: var(--dsw-alias-label-primary); + content: ''; +} + +.tab:focus-visible { + outline: 2px solid var(--dsw-alias-state-business-primary); + outline-offset: 2px; + border-radius: 2px; + color: var(--dsw-alias-label-primary); +} + +.panel { + min-width: 0; + padding-top: 2px; +} + .cards { list-style: none; margin: 0; diff --git a/packages/client/ui-plugin-config/src/client/PluginConfigSection.tsx b/packages/client/ui-plugin-config/src/client/PluginConfigSection.tsx index 68de45eff3..592e495488 100644 --- a/packages/client/ui-plugin-config/src/client/PluginConfigSection.tsx +++ b/packages/client/ui-plugin-config/src/client/PluginConfigSection.tsx @@ -1,49 +1,123 @@ -/** - * Plugin configuration section: the shell around the per-plugin cards. It - * enumerates nothing itself — cards arrive through the `settings.plugin.item` - * slot it declares, so a plugin that ships a browser half owns its own card - * and this section never learns what a namespace means. - */ +/** Plugins settings section: localized tabs around feature-owned pages. */ -import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' -import type {} from './slot-contract.ts' +import { useEffect, useId, useRef, useState } from 'react' +import type { + HostObservable, InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, +} from '@deepseek-ai/dsh-client-ui-slots' import type { PluginConfigKey } from './locales.ts' import css from './PluginConfigSection.module.css' +/** One tab projected from a `settings.plugins.tab` contribution. */ +export interface PluginSettingsTabRow { + id: string + order: number + label: string +} + /** Registration-side business face for the section. */ export interface PluginConfigSectionInjected { - /** How many cards the slot ledger currently holds; zero renders the empty line. */ - cardCount: number + hooks: { + /** Ordered, locale-aware projection of the Plugins tab ledger. */ + tabs: HostObservable + } } /** Props the renderer binds for the section. */ export type PluginConfigSectionProps = PropsRuntime<'settings.section'> & PropsLocale<'settings.pluginConfig'> - & PropsRenderSlots<'settings.plugin.item'> + & PropsRenderSlots<'settings.plugins.tab'> & InjectFace -/** - * Render the plugin configuration section. - * @param props - runtime slot rendering, locale copy, and the card count. - * @returns the section. - */ -export function PluginConfigSection(props: PluginConfigSectionProps) { - const { t, renderSlot, cardCount } = props +/** Render one Plugins page whose contents arrive from feature-owned tabs. */ +export function PluginConfigSection({ t, renderSlot, useTabs }: PluginConfigSectionProps) { + const tabsId = useId() + const tabRefs = useRef>([]) + const rows = useTabs(value => value) + const [activeId, setActiveId] = useState() + const [visitedIds, setVisitedIds] = useState>(() => new Set()) + const active = rows.find(row => row.id === activeId)?.id ?? rows[0]?.id + + // A tab mounts only when first selected, then stays mounted while hidden so + // local drafts, disclosure state, search, and the inventory snapshot survive + // switching between the two views. + useEffect(() => { + if (active === undefined) return + setVisitedIds((previous) => { + if (previous.has(active)) return previous + return new Set([...previous, active]) + }) + }, [active]) + return (

{t('title')}

{t('intro')}

- {cardCount === 0 - ?

{t('empty')}

- :
    {renderSlot('settings.plugin.item', {})}
} + {rows.length === 0 ?

{t('empty')}

: ( + <> +
+ {rows.map((row, index) => { + const selected = row.id === active + return ( + + ) + })} +
+ {rows + .filter(row => row.id === active || visitedIds.has(row.id)) + .map((row) => { + const selected = row.id === active + return ( + + ) + })} + + )}
) } declare module '@deepseek-ai/dsh-client-ui-slots' { interface LocaleNamespaceMap { - /** Plugin configuration section and card copy. */ + /** Plugins section, configurable-tab, and card copy. */ 'settings.pluginConfig': PluginConfigKey } } diff --git a/packages/client/ui-plugin-config/src/client/index.ts b/packages/client/ui-plugin-config/src/client/index.ts index f3425634ef..a83bff8c11 100644 --- a/packages/client/ui-plugin-config/src/client/index.ts +++ b/packages/client/ui-plugin-config/src/client/index.ts @@ -1,13 +1,12 @@ /** - * Plugin configuration surface, browser half — one settings section holding - * an expandable card per Host plugin whose configuration a user owns. + * Plugins settings surface, browser half — one section whose feature-owned + * tabs include configurable Host plugin cards and read-only inventory. * - * The section owns no knowledge of any namespace: it declares the - * `settings.plugin.item` slot and renders whatever cards were registered into - * it, so a plugin that ships a browser half contributes its own card and its - * own controls. The three cards this package registers are the host-plane - * sections the deployment already exposes; each binds its namespace through - * the client settings scope, which keeps them unaware of one another. + * The section declares `settings.plugins.tab`; its own `configurable` tab then + * declares `settings.plugin.item` and renders whatever cards were registered + * into it. The three cards this package ships are the host-plane sections the + * deployment already exposes; each binds its namespace through the client + * settings scope, which keeps them unaware of one another and of other tabs. */ import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' @@ -18,11 +17,15 @@ import type {} from '@deepseek-ai/dsh-client-locale/client' // through the service, never a value import (client bundle purity gate). import type {} from '@deepseek-ai/dsh-client-ui-settings/client' import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +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 { ConfigurablePluginsTab } from './ConfigurablePluginsTab.tsx' +import type { ConfigurablePluginsTabInjected } from './ConfigurablePluginsTab.tsx' import { PluginConfigSection } from './PluginConfigSection.tsx' +import type { PluginConfigSectionInjected, PluginSettingsTabRow } from './PluginConfigSection.tsx' import { WebSearchCard } from './WebSearchCard.tsx' import { AGENT_LOOP_NS, AgentLoopCardController } from './agent-loop-store.ts' import { BASH_NS, BashCardController } from './bash-store.ts' @@ -30,6 +33,7 @@ import { WEB_SEARCH_NS, WebSearchCardController } from './web-search-store.ts' import { en, zh } from './locales.ts' export type { PluginConfigSectionInjected, PluginConfigSectionProps } from './PluginConfigSection.tsx' +export type { ConfigurablePluginsTabInjected, ConfigurablePluginsTabProps } from './ConfigurablePluginsTab.tsx' export type { PluginCardProps } from './PluginCard.tsx' export type { SettingsPluginItemOwnerProps } from './slot-contract.ts' export type { FieldProps } from './fields.tsx' @@ -67,23 +71,67 @@ export function apply(ctx: ClientContext): void { 'ui-plugin-config: credential invalidations', ) - // The section renders the empty line rather than an empty list when no plugin - // contributed a card. The count is read once: the renderer caches a root - // entry's inject face per registration, so this reports what was registered - // when the section mounted, not what is visible now. Both gaps are bounded by - // this deployment always registering the three cards below — a card that - // arrives later would not raise the count, and a namespace this deployment - // does not expose leaves its card rendering nothing inside a non-empty list. + let tabsVersion = -1 + let tabsRevision = -1 + let tabs: readonly PluginSettingsTabRow[] = [] + const sectionInjected = (): PluginConfigSectionInjected => ({ + hooks: { + tabs: { + getSnapshot: () => { + const version = ctx.slots.getVersion('settings.plugins.tab') + const revision = ctx.locale.getSnapshot().revision + if (version !== tabsVersion || revision !== tabsRevision) { + tabsVersion = version + tabsRevision = revision + tabs = ctx.slots.entries('settings.plugins.tab') + .map(entry => ({ + /* v8 ignore next -- list-slot registration requires id */ + id: entry.options.id ?? '', + order: entry.options.order ?? 0, + label: resolveSlotLabel(entry.options.label) ?? '', + })) + .sort((a, b) => a.order - b.order) + } + return tabs + }, + subscribe: (listener) => { + const offLedger = ctx.slots.subscribe('settings.plugins.tab', listener) + const offLocale = ctx.locale.subscribe(listener) + return () => { + offLedger() + offLocale() + } + }, + }, + }, + }) + + // This package owns the one Plugins navigation entry and the tab chrome; + // feature plugins contribute pages without competing for Settings nav rows. ctx.slots.inject('settings.section', () => ctx.slots.register({ name: 'settings.section', id: 'plugins', - order: 30, + order: 15, label: () => t('nav'), locale: NS, - inject: () => ({ cardCount: ctx.slots.entries('settings.plugin.item').length }), - children: { 'settings.plugin.item': { kind: 'list', scope: 'root' } }, + inject: sectionInjected, + children: { 'settings.plugins.tab': { kind: 'list', scope: 'root' } }, }, PluginConfigSection)) + // The existing configuration page is one ordinary tab. It keeps ownership + // of the card slot and the three shipped card contributions below. + ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({ + name: 'settings.plugins.tab', + id: 'configurable', + order: 0, + label: () => t('configurableTab'), + locale: NS, + inject: (): ConfigurablePluginsTabInjected => ({ + cardCount: ctx.slots.entries('settings.plugin.item').length, + }), + children: { 'settings.plugin.item': { kind: 'list', scope: 'root' } }, + }, ConfigurablePluginsTab)) + ctx.slots.inject('settings.plugin.item', function* () { yield ctx.slots.register({ name: 'settings.plugin.item', diff --git a/packages/client/ui-plugin-config/src/client/locales.ts b/packages/client/ui-plugin-config/src/client/locales.ts index 18fc943f7b..763b9986c4 100644 --- a/packages/client/ui-plugin-config/src/client/locales.ts +++ b/packages/client/ui-plugin-config/src/client/locales.ts @@ -2,7 +2,7 @@ /** Locale keys these surfaces render. */ export type PluginConfigKey = - | 'nav' | 'title' | 'intro' | 'empty' + | 'nav' | 'title' | 'intro' | 'tabs' | 'configurableTab' | 'empty' | 'overridden' | 'reset' | 'readOnly' | 'expand' | 'collapse' | 'save' | 'saving' | 'discard' | 'unsaved' | 'saveFailed' | 'invalidNumber' | 'bashTitle' | 'bashDescription' | 'bashTimeoutMs' | 'bashTimeoutMsHint' @@ -14,9 +14,11 @@ export type PluginConfigKey = /** English copy. */ export const en: Record = { - nav: 'Plugin config', - title: 'Plugin configuration', - intro: 'Configure the plugins this deployment installed.', + nav: 'Plugins', + title: 'Plugins', + intro: 'Configure and inspect the plugins installed in this deployment.', + tabs: 'Plugin views', + configurableTab: 'Plugin configuration', empty: 'This deployment exposes no plugin settings.', overridden: 'Overridden', reset: 'Reset to default', @@ -53,9 +55,11 @@ export const en: Record = { /** Simplified Chinese copy. */ export const zh: Record = { - nav: '插件配置', - title: '插件配置', - intro: '配置本部署已安装的插件。', + nav: '插件', + title: '插件', + intro: '配置和查看本部署已安装的插件。', + tabs: '插件视图', + configurableTab: '插件配置', empty: '本部署没有开放任何插件设置。', overridden: '已覆盖', reset: '恢复默认', diff --git a/packages/client/ui-plugin-config/src/index.ts b/packages/client/ui-plugin-config/src/index.ts index 0bb50fba35..ce0f6e034d 100644 --- a/packages/client/ui-plugin-config/src/index.ts +++ b/packages/client/ui-plugin-config/src/index.ts @@ -1,7 +1,7 @@ /** - * Plugin configuration surface, node half. The empty apply exists so the - * plugin appears in the host cordis.yml / Loader; the browser half ships the - * settings section through exports["./client"], discovered from the + * Plugins settings surface, node half. The empty apply exists so the plugin + * appears in the host cordis.yml / Loader; the browser half owns the section + * and its configurable tab through exports["./client"], discovered from the * package.json dsh.client declaration. Every section this page edits is owned * by the Host plugin that registered it, so this package registers no * namespace of its own. diff --git a/packages/client/ui-plugin-config/tests/apply.client.spec.ts b/packages/client/ui-plugin-config/tests/apply.client.spec.ts index a880446e8c..b0d7053e83 100644 --- a/packages/client/ui-plugin-config/tests/apply.client.spec.ts +++ b/packages/client/ui-plugin-config/tests/apply.client.spec.ts @@ -8,6 +8,9 @@ import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' import { SettingsScopeService } from '@deepseek-ai/dsh-client-ui-settings/client' import { apply, inject } from '@deepseek-ai/dsh-client-ui-plugin-config/client' +import type { + ConfigurablePluginsTabInjected, PluginConfigSectionInjected, +} from '@deepseek-ai/dsh-client-ui-plugin-config/client' // The service reads its initial locale from the browser; these specs assert // the shipped Chinese copy, so they state the browser they assume. @@ -46,16 +49,20 @@ describe('ui-plugin-config apply', () => { expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'settingsScope']) }) - it('registers the section and declares the per-plugin card slot', async () => { + it('registers one Plugins section and declares the tab and card slots', async () => { const { ctx, slots } = await bench() declareRoot(slots) await ctx.plugin({ inject: [...inject], apply }).await() const section = slots.entries('settings.section')[0]! - expect(section.options).toMatchObject({ id: 'plugins', order: 30 }) + expect(section.options).toMatchObject({ id: 'plugins', order: 15 }) // The nav label is a locale-following thunk; owners resolve it at read time. - expect(resolveSlotLabel(section.options.label)).toBe('插件配置') + expect(resolveSlotLabel(section.options.label)).toBe('插件') + expect(slots.spec('settings.plugins.tab')).toMatchObject({ kind: 'list', scope: 'root' }) + const tab = slots.entries('settings.plugins.tab')[0]! + expect(tab.options).toMatchObject({ id: 'configurable', order: 0 }) + expect(resolveSlotLabel(tab.options.label)).toBe('插件配置') expect(slots.spec('settings.plugin.item')).toMatchObject({ kind: 'list', scope: 'root' }) }) @@ -69,13 +76,30 @@ describe('ui-plugin-config apply', () => { .toEqual(['bash', 'agent-loop', 'web-search']) }) - it('injects a live card count and one business face per card', async () => { + it('injects a live tab projection, a card count, and one business face per card', async () => { const { ctx, slots } = await bench() declareRoot(slots) await ctx.plugin({ inject: [...inject], apply }).await() const section = slots.entries('settings.section')[0]! - expect((section as { inject?: () => unknown }).inject?.()).toEqual({ cardCount: 3 }) + const sectionFace = (section.inject as unknown as () => PluginConfigSectionInjected)() + const initialTabs = sectionFace.hooks.tabs.getSnapshot() + expect(initialTabs).toEqual([ + { id: 'configurable', order: 0, label: '插件配置' }, + ]) + expect(sectionFace.hooks.tabs.getSnapshot()).toBe(initialTabs) + + const listener = vi.fn() + const unsubscribe = sectionFace.hooks.tabs.subscribe(listener) + slots.register({ name: 'settings.plugins.tab', id: 'plain' } as never, () => null) + expect(sectionFace.hooks.tabs.getSnapshot()).toEqual([ + { id: 'configurable', order: 0, label: '插件配置' }, + { id: 'plain', order: 0, label: '' }, + ]) + unsubscribe() + + const tab = slots.entries('settings.plugins.tab')[0]! + expect((tab.inject as unknown as () => ConfigurablePluginsTabInjected)()).toEqual({ cardCount: 3 }) for (const entry of slots.entries('settings.plugin.item')) { const face = (entry as { inject?: () => unknown }).inject?.() as { hooks: Record } // Each card injects exactly one snapshot store plus its own actions. @@ -129,6 +153,7 @@ describe('ui-plugin-config apply', () => { await fiber.dispose() expect(slots.entries('settings.section')).toHaveLength(0) + expect(slots.spec('settings.plugins.tab')).toBeUndefined() expect(slots.spec('settings.plugin.item')).toBeUndefined() }) }) diff --git a/packages/client/ui-plugin-config/tests/section.client.spec.tsx b/packages/client/ui-plugin-config/tests/section.client.spec.tsx index 3945092587..1191fc3aea 100644 --- a/packages/client/ui-plugin-config/tests/section.client.spec.tsx +++ b/packages/client/ui-plugin-config/tests/section.client.spec.tsx @@ -13,8 +13,10 @@ 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 { ConfigurablePluginsTab } from '../src/client/ConfigurablePluginsTab.tsx' +import type { ConfigurablePluginsTabProps } from '../src/client/ConfigurablePluginsTab.tsx' import { PluginConfigSection } from '../src/client/PluginConfigSection.tsx' -import type { PluginConfigSectionProps } from '../src/client/PluginConfigSection.tsx' +import type { PluginConfigSectionProps, PluginSettingsTabRow } from '../src/client/PluginConfigSection.tsx' import { WebSearchCard } from '../src/client/WebSearchCard.tsx' import type { WebSearchCardProps } from '../src/client/WebSearchCard.tsx' import type { AgentLoopCardState } from '../src/client/agent-loop-store.ts' @@ -46,13 +48,24 @@ function cardActions() { return { edit: vi.fn(), resetField: vi.fn(), save: vi.fn(), discard: vi.fn() } } -function renderSection(cardCount: number, cards = 'cards') { +function renderSection(rows: readonly PluginSettingsTabRow[]) { + const props = { + t, + useTabs: (selector: (value: readonly PluginSettingsTabRow[]) => unknown) => selector(rows), + renderSlot: (_name: string, _owner: unknown, options: { only?: string }) => ( + {options.only} + ), + } as unknown as PluginConfigSectionProps + render() +} + +function renderConfigurable(cardCount: number, cards = 'cards') { const props = { t, cardCount, renderSlot: () =>
  • {cards}
  • , - } as unknown as PluginConfigSectionProps - render() + } as unknown as ConfigurablePluginsTabProps + render() } function renderBash(state: Partial = {}) { @@ -69,26 +82,89 @@ function renderBash(state: Partial = {}) { } describe('PluginConfigSection', () => { + it('says so when no plugin contributed a tab', () => { + renderSection([]) + + expect(screen.getByText(en.empty)).toBeTruthy() + expect(screen.queryByRole('tab')).toBeNull() + }) + + it('defaults to the first ordered tab and mounts another only after selection', () => { + renderSection([ + { id: 'configurable', order: 0, label: en.configurableTab }, + { id: 'all', order: 10, label: 'Plugin list' }, + ]) + + const configurable = screen.getByRole('tab', { name: en.configurableTab }) + const all = screen.getByRole('tab', { name: 'Plugin list' }) + expect(configurable.getAttribute('aria-selected')).toBe('true') + expect(screen.getByText('configurable')).toBeTruthy() + expect(screen.queryByText('all')).toBeNull() + + fireEvent.click(all) + expect(all.getAttribute('aria-selected')).toBe('true') + expect(screen.getByText('all')).toBeTruthy() + expect(screen.getByText('configurable').closest('[role="tabpanel"]')).toHaveProperty('hidden', true) + + fireEvent.click(configurable) + expect(configurable.getAttribute('aria-selected')).toBe('true') + expect(screen.getByText('all').closest('[role="tabpanel"]')).toHaveProperty('hidden', true) + }) + + it('leads with its own heading and intro', () => { + renderSection([{ id: 'configurable', order: 0, label: en.configurableTab }]) + + expect(screen.getByRole('heading', { name: en.title })).toBeTruthy() + expect(screen.getByText(en.intro)).toBeTruthy() + }) + + it('moves focus and selection with standard horizontal tab keys', () => { + renderSection([ + { id: 'configurable', order: 0, label: en.configurableTab }, + { id: 'all', order: 10, label: 'Plugin list' }, + { id: 'diagnostics', order: 20, label: 'Diagnostics' }, + ]) + + const configurable = screen.getByRole('tab', { name: en.configurableTab }) + const all = screen.getByRole('tab', { name: 'Plugin list' }) + const diagnostics = screen.getByRole('tab', { name: 'Diagnostics' }) + expect(configurable.getAttribute('tabindex')).toBe('0') + expect(all.getAttribute('tabindex')).toBe('-1') + + configurable.focus() + fireEvent.keyDown(configurable, { key: 'ArrowRight' }) + expect(document.activeElement).toBe(all) + expect(all.getAttribute('aria-selected')).toBe('true') + + fireEvent.keyDown(all, { key: 'End' }) + expect(document.activeElement).toBe(diagnostics) + fireEvent.keyDown(diagnostics, { key: 'ArrowRight' }) + expect(document.activeElement).toBe(configurable) + fireEvent.keyDown(configurable, { key: 'ArrowLeft' }) + expect(document.activeElement).toBe(diagnostics) + fireEvent.keyDown(diagnostics, { key: 'Home' }) + expect(document.activeElement).toBe(configurable) + + fireEvent.keyDown(configurable, { key: 'Escape' }) + expect(document.activeElement).toBe(configurable) + expect(configurable.getAttribute('aria-selected')).toBe('true') + }) +}) + +describe('ConfigurablePluginsTab', () => { it('says so when no plugin contributed a card', () => { - renderSection(0) + renderConfigurable(0) expect(screen.getByText(en.empty)).toBeTruthy() expect(screen.queryByText('cards')).toBeNull() }) it('renders the card list once a plugin contributed one', () => { - renderSection(1) + renderConfigurable(1) expect(screen.getByText('cards')).toBeTruthy() expect(screen.queryByText(en.empty)).toBeNull() }) - - it('leads with its own heading and intro', () => { - renderSection(1) - - expect(screen.getByRole('heading', { name: en.title })).toBeTruthy() - expect(screen.getByText(en.intro)).toBeTruthy() - }) }) describe('BashCard', () => { diff --git a/packages/client/ui-plugins/README.i18n.yaml b/packages/client/ui-plugins/README.i18n.yaml index 62085c3d6b..04e1faa8c5 100644 --- a/packages/client/ui-plugins/README.i18n.yaml +++ b/packages/client/ui-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-plugins/README.md -README.md: bb487d5e2cbd34406d83867997ede4d70b190d70 -README.zh.md: 48a11911509ea260aa9727d55c0b4df6efbfb1c9 +README.md: a663665cc2d7ce26a2724da9aecc25f51515327a +README.zh.md: 852ecbe6546f76a76fc6d9d3a8bb891539b0844f diff --git a/packages/client/ui-plugins/README.md b/packages/client/ui-plugins/README.md index bb487d5e2c..a663665cc2 100644 --- a/packages/client/ui-plugins/README.md +++ b/packages/client/ui-plugins/README.md @@ -2,9 +2,9 @@ English | [中文](README.zh.md) -Read-only Plugins section for Web Settings. The browser plugin registers one localized `settings.section` contribution with id `plugin-inventory`, after Models, and lets the Settings shell supply its ordinary fallback icon. It performs no Remote read during plugin activation; mounting the section lazily calls `ctx.remote.pluginInventory.list()` through [`api-remotes`](../../api/remotes/README.md). +Read-only **Plugin list** tab for Web Settings. The browser plugin registers one localized `settings.plugins.tab` contribution with id `all`; the Plugins section owns the navigation entry and tab chrome. It performs no Remote read during plugin activation. Selecting the tab for the first time mounts it and lazily calls `ctx.remote.pluginInventory.list()` through [`api-remotes`](../../api/remotes/README.md). -The page renders a searchable two-column catalog of compact disclosure cards. Each collapsed card uses the local Loader id as its title, a colored root-Fiber status dot, and a small effective-enablement tag. Expanding one card reveals its Loader-tree entry value without a redundant field label, followed by the effective configuration and Cordis status. Loading, empty, no-match, and generic failure states stay local to the mounted component, and a failed read can be retried without exposing transport details. The registration uses `ctx.slots.inject()`, so it follows late Settings declaration, redeclaration, locale changes, and teardown without owning another global store. +The tab renders a searchable two-column catalog of compact disclosure cards. Each collapsed card uses the short module name as its title and a small effective-enablement tag; enabled entries also show a colored root-fiber status dot. Expanding one card reveals its Loader-tree entry id without a redundant field label, followed by the effective configuration and, for enabled entries, Cordis status. Disabled entries omit the redundant unmounted runtime state. The entry id remains the React key, disclosure identity, detail value, and an additional search target; it is never classified by string shape. Loading, empty, no-match, and generic failure states stay local to the mounted component, and a failed read can be retried without exposing transport details. The registration uses `ctx.slots.inject()`, so it follows late tab declaration, redeclaration, locale changes, and teardown without importing the section owner. ## Model Experience @@ -16,5 +16,5 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **One snapshot per mount or retry** — the page does not subscribe to Loader changes or automatically refetch after reconnect; reopening the section obtains a new snapshot. +- **One snapshot per Settings mount or retry** — the tab does not subscribe to Loader changes or automatically refetch after reconnect; switching tabs preserves the current snapshot, while reopening Settings obtains a new one. - **Read-only Loader view** — local search does not add provenance, current-browser activation diagnosis, grouping by source, or plugin mutation controls. diff --git a/packages/client/ui-plugins/README.zh.md b/packages/client/ui-plugins/README.zh.md index 48a1191150..852ecbe654 100644 --- a/packages/client/ui-plugins/README.zh.md +++ b/packages/client/ui-plugins/README.zh.md @@ -2,9 +2,9 @@ [English](README.md) | 中文 -Web 设置中的只读“插件”分区。浏览器插件在“模型”之后注册一个 id 为 `plugin-inventory` 的本地化 `settings.section` 贡献,并由 Settings shell 提供常规的回退图标。插件激活期间不会读取 Remote;挂载该分区时,组件才通过 [`api-remotes`](../../api/remotes/README.md) 懒调用 `ctx.remote.pluginInventory.list()`。 +Web 设置中的只读**插件列表**标签页。浏览器插件注册一个 id 为 `all` 的本地化 `settings.plugins.tab` 贡献;“插件”分区拥有导航入口与标签栏。插件激活期间不会读取 Remote;首次选择该标签页时才挂载组件,并通过 [`api-remotes`](../../api/remotes/README.md) 懒调用 `ctx.remote.pluginInventory.list()`。 -页面以可搜索的双列紧凑折叠卡片展示清单。每张收起的卡片使用 Loader 本地 id 作为标题,以彩色圆点表示根 Fiber 状态,以小标签表示有效启停状态。展开卡片后会直接展示 Loader 树条目值,不附加重复的字段标题,并列出有效配置状态与 Cordis 状态。加载、空结果、无匹配结果与通用失败状态只属于已挂载组件;读取失败后可以重试,且不会暴露传输细节。注册使用 `ctx.slots.inject()`,因此能跟随 Settings 的延迟声明、重新声明、本地化变化与 teardown,而不拥有另一份全局 store。 +该标签页以可搜索的双列紧凑折叠卡片展示清单。每张收起的卡片使用模块短名称作为标题,以小标签表示有效启停状态;已启用的条目还会以彩色圆点表示根 fiber 状态。展开卡片后会直接展示 Loader 树条目 id,不附加重复的字段标题,并列出有效配置状态;已启用的条目还会列出 Cordis 状态,已停用的条目则省略重复的“未挂载”运行状态。条目 id 仍作为 React key、展开标识、详情值与额外的搜索目标;代码不按字符串形状对它分类。加载、空结果、无匹配结果与通用失败状态只属于已挂载组件;读取失败后可以重试,且不会暴露传输细节。注册使用 `ctx.slots.inject()`,因此能跟随标签 slot 的延迟声明、重新声明、本地化变化与 teardown,而无需 import 分区拥有方。 ## 模型体验 @@ -16,5 +16,5 @@ Web 设置中的只读“插件”分区。浏览器插件在“模型”之后 ## 已知限制与暂缓事项 -- **每次挂载或重试只读取一份快照** —— 页面不订阅 Loader 变化,也不会在重连后自动重新读取;重新打开分区会取得新快照。 +- **每次 Settings 挂载或重试只读取一份快照** —— 标签页不订阅 Loader 变化,也不会在重连后自动重新读取;切换标签页会保留当前快照,重新打开 Settings 则会取得新快照。 - **只读 Loader 视图** —— 本地搜索不会额外引入来源、按来源分组、当前浏览器激活诊断或插件修改控件。 diff --git a/packages/client/ui-plugins/package.json b/packages/client/ui-plugins/package.json index 07fb9d162c..965a1ef415 100644 --- a/packages/client/ui-plugins/package.json +++ b/packages/client/ui-plugins/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-client-ui-plugins", - "description": "Read-only Cordis Loader plugin inventory in Web settings", + "description": "Read-only Cordis Loader inventory tab in Web Plugins settings", "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" diff --git a/packages/client/ui-plugins/src/client/PluginSettingsSection.module.css b/packages/client/ui-plugins/src/client/PluginSettingsSection.module.css index 9429b60bb5..de10a3bd36 100644 --- a/packages/client/ui-plugins/src/client/PluginSettingsSection.module.css +++ b/packages/client/ui-plugins/src/client/PluginSettingsSection.module.css @@ -7,19 +7,12 @@ color: var(--dsw-alias-label-primary); } -.heading h2, .catalogHeading h3, .status, .failure p { margin: 0; } -.heading h2 { - font-size: 16px; - line-height: 24px; - font-weight: 600; -} - .status, .failure { font-size: 13px; diff --git a/packages/client/ui-plugins/src/client/PluginSettingsSection.tsx b/packages/client/ui-plugins/src/client/PluginSettingsSection.tsx index 87d6486000..4febea4814 100644 --- a/packages/client/ui-plugins/src/client/PluginSettingsSection.tsx +++ b/packages/client/ui-plugins/src/client/PluginSettingsSection.tsx @@ -19,7 +19,7 @@ type PluginFiberPhase = PluginInventoryEntry['fiberPhase'] /** Full component props assembled by the Settings slot renderer. */ export type PluginSettingsSectionProps = - PropsRuntime<'settings.section'> + PropsRuntime<'settings.plugins.tab'> & PropsLocale<'settings.plugins'> & InjectFace @@ -62,7 +62,7 @@ function matches(entry: PluginInventoryEntry, normalizedQuery: string): boolean /** Render the read-only current Loader inventory. */ export function PluginSettingsSection({ list, t }: PluginSettingsSectionProps): ReactNode { - const titleId = useId() + const catalogId = useId() const [request, setRequest] = useState(0) const [query, setQuery] = useState('') const [expanded, setExpanded] = useState(null) @@ -97,10 +97,7 @@ export function PluginSettingsSection({ list, t }: PluginSettingsSectionProps): } return ( -
    -
    -

    {t('title')}

    -
    +
    {state.status === 'loading' ?

    {t('loading')}

    : null} {state.status === 'error' ? (
    @@ -134,8 +131,9 @@ export function PluginSettingsSection({ list, t }: PluginSettingsSectionProps): {filteredEntries.map((entry) => { const status = phaseLabel(entry.fiberPhase, t) const title = moduleShortName(entry.moduleName) + const configuration = t(entry.enabled ? 'enabledTag' : 'disabledTag') const open = expanded === entry.entryId - const detailId = `${titleId}-details-${encodeURIComponent(entry.entryId)}` + const detailId = `${catalogId}-details-${encodeURIComponent(entry.entryId)}` return (
  • { setExpanded(current => current === entry.entryId ? null : entry.entryId) }} > {title} - + {entry.enabled ? ( + + ) : null} - {t(entry.enabled ? 'enabledTag' : 'disabledTag')} + {configuration} @@ -174,12 +174,14 @@ export function PluginSettingsSection({ list, t }: PluginSettingsSectionProps):
    {t('configuration')}
    -
    {t(entry.enabled ? 'enabledTag' : 'disabledTag')}
    -
    -
    -
    {t('cordis')}
    -
    {status}
    +
    {configuration}
    + {entry.enabled ? ( +
    +
    {t('cordis')}
    +
    {status}
    +
    + ) : null}
  • ) : null} @@ -190,6 +192,6 @@ export function PluginSettingsSection({ list, t }: PluginSettingsSectionProps): ) : null}
    ) : null} -
    + ) } diff --git a/packages/client/ui-plugins/src/client/index.ts b/packages/client/ui-plugins/src/client/index.ts index ccf12ab989..f2261086b7 100644 --- a/packages/client/ui-plugins/src/client/index.ts +++ b/packages/client/ui-plugins/src/client/index.ts @@ -22,7 +22,7 @@ export const NS = 'settings.plugins' /** Services required by the Settings registration and generated Remote face. */ export const inject = ['slots', 'locale', 'remote', 'remote.pluginInventory'] -/** Register the lazy plugin inventory page below Models in Settings. */ +/** Contribute the lazy inventory tab to the Plugins settings section. */ export function apply(ctx: ClientContext): void { ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-plugins: dictionaries') @@ -36,11 +36,11 @@ export function apply(ctx: ClientContext): void { } const injected = (): PluginSettingsSectionInjected => ({ list }) - ctx.slots.inject('settings.section', () => ctx.slots.register({ - name: 'settings.section', - id: 'plugin-inventory', - order: 15, - label: () => t('nav'), + ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({ + name: 'settings.plugins.tab', + id: 'all', + order: 10, + label: () => t('tab'), locale: NS, inject: injected, }, PluginSettingsSection)) diff --git a/packages/client/ui-plugins/src/client/locales.ts b/packages/client/ui-plugins/src/client/locales.ts index c505296f38..a95d7b95cb 100644 --- a/packages/client/ui-plugins/src/client/locales.ts +++ b/packages/client/ui-plugins/src/client/locales.ts @@ -2,8 +2,7 @@ /** Simplified Chinese dictionary and key source of truth. */ export const zh = { - nav: '插件', - title: '插件', + tab: '插件列表', loading: '正在读取插件…', error: '暂时无法读取插件。', retry: '重试', @@ -28,8 +27,7 @@ export type PluginsKey = keyof typeof zh /** English dictionary checked against the Chinese key set. */ export const en = { - nav: 'Plugins', - title: 'Plugins', + tab: 'Plugin list', loading: 'Reading plugins…', error: 'Plugins are temporarily unavailable.', retry: 'Retry', diff --git a/packages/client/ui-plugins/src/index.ts b/packages/client/ui-plugins/src/index.ts index 489544a421..473386a9ec 100644 --- a/packages/client/ui-plugins/src/index.ts +++ b/packages/client/ui-plugins/src/index.ts @@ -1,4 +1,4 @@ -/** Host loader entry for the browser implementation exported from `./client`. */ +/** Host loader entry for the inventory-tab browser implementation exported from `./client`. */ -/** Host plugin body — no host-side behavior for the plugin settings section. */ +/** Host plugin body — no host-side behavior for the plugin inventory tab. */ export function apply(): void {} diff --git a/packages/client/ui-plugins/tests/browser-plugin.client.spec.tsx b/packages/client/ui-plugins/tests/browser-plugin.client.spec.tsx index d9d8a43cd8..e0feb50e69 100644 --- a/packages/client/ui-plugins/tests/browser-plugin.client.spec.tsx +++ b/packages/client/ui-plugins/tests/browser-plugin.client.spec.tsx @@ -38,7 +38,7 @@ async function bench() { function declare(slots: SlotsService): () => void { return slots.register({ name: 'root', - children: { 'settings.section': { kind: 'list', scope: 'root' } }, + children: { 'settings.plugins.tab': { kind: 'list', scope: 'root' } }, } as never, () => null) } @@ -47,16 +47,16 @@ describe('ui-plugins browser plugin', () => { expect(inject).toEqual(['slots', 'locale', 'remote', 'remote.pluginInventory']) }) - it('registers a localized section without reading the Remote eagerly', async () => { + it('registers a localized tab without reading the Remote eagerly', async () => { const b = await bench() declare(b.slots) await b.ctx.plugin({ inject: [...inject], apply }).await() - const entry = b.slots.entries('settings.section')[0]! + const entry = b.slots.entries('settings.plugins.tab')[0]! expect(entry.component).toBe(PluginSettingsSection) - expect(entry.options).toMatchObject({ id: 'plugin-inventory', order: 15 }) + expect(entry.options).toMatchObject({ id: 'all', order: 10 }) expect(entry.locale).toBe(NS) - expect(resolveSlotLabel(entry.options.label)).toBe('插件') + expect(resolveSlotLabel(entry.options.label)).toBe('插件列表') expect(b.list).not.toHaveBeenCalled() const injected = (entry.inject as unknown as () => PluginSettingsSectionInjected)() @@ -71,22 +71,22 @@ describe('ui-plugins browser plugin', () => { const b = await bench() const fiber = b.ctx.plugin({ inject: [...inject], apply }) await fiber.await() - expect(b.slots.entries('settings.section')).toHaveLength(0) + expect(b.slots.entries('settings.plugins.tab')).toHaveLength(0) const stop = declare(b.slots) - await vi.waitFor(() => { expect(b.slots.entries('settings.section')).toHaveLength(1) }) + await vi.waitFor(() => { expect(b.slots.entries('settings.plugins.tab')).toHaveLength(1) }) b.locale.setLocale('en') - expect(resolveSlotLabel(b.slots.entries('settings.section')[0]!.options.label)).toBe('Plugins') + expect(resolveSlotLabel(b.slots.entries('settings.plugins.tab')[0]!.options.label)).toBe('Plugin list') stop() - expect(b.slots.entries('settings.section')).toHaveLength(0) + expect(b.slots.entries('settings.plugins.tab')).toHaveLength(0) declare(b.slots) await vi.waitFor(() => { - expect(b.slots.entries('settings.section')[0]?.component).toBe(PluginSettingsSection) + expect(b.slots.entries('settings.plugins.tab')[0]?.component).toBe(PluginSettingsSection) }) await fiber.dispose() - expect(b.slots.entries('settings.section')).toHaveLength(0) + expect(b.slots.entries('settings.plugins.tab')).toHaveLength(0) expect(() => b.locale.register(NS, 'zh', {})).not.toThrow() await b.ctx.fiber.dispose() }) diff --git a/packages/client/ui-plugins/tests/components.client.spec.tsx b/packages/client/ui-plugins/tests/components.client.spec.tsx index 9da8a79b0d..7989837065 100644 --- a/packages/client/ui-plugins/tests/components.client.spec.tsx +++ b/packages/client/ui-plugins/tests/components.client.spec.tsx @@ -12,16 +12,12 @@ afterEach(cleanup) type Snapshot = Awaited> const t = ((key: PluginsKey): string => en[key]) as PluginSettingsSectionProps['t'] -const unusedHook = (() => { throw new Error('unused by plugin inventory') }) as never function props(list: PluginSettingsSectionInjected['list']): PluginSettingsSectionProps { return { - close: vi.fn(), - useSessions: unusedHook, - useWorkspaces: unusedHook, t, list, - } + } as PluginSettingsSectionProps } const SNAPSHOT = { @@ -31,12 +27,13 @@ const SNAPSHOT = { { entryId: 'loading', moduleName: '@fixture/loading-name', enabled: true, fiberPhase: 'loading' }, { entryId: 'failed', moduleName: '@fixture/failed-name', enabled: true, fiberPhase: 'failed' }, { entryId: 'unloading', moduleName: '@fixture/unloading-name', enabled: true, fiberPhase: 'unloading' }, + { entryId: 'unobserved', moduleName: '@fixture/unobserved-name', enabled: true, fiberPhase: null }, { entryId: 'disabled-entry', moduleName: '@deepseek-ai/dsh-host-directory-picker-native', enabled: false, fiberPhase: null }, ], } as unknown as Snapshot describe('PluginSettingsSection', () => { - it('renders searchable two-column-card semantics with dots and tags', async () => { + it('renders runtime status only for enabled plugins', async () => { const deferred = Promise.withResolvers() const list = vi.fn(() => deferred.promise) const view = render() @@ -46,9 +43,9 @@ describe('PluginSettingsSection', () => { expect(list).toHaveBeenCalledOnce() expect(screen.getByRole('searchbox', { name: en.search })).toBeTruthy() expect(screen.getByRole('heading', { name: en.catalog })).toBeTruthy() - expect(view.container.querySelector('[data-plugin-count]')?.textContent).toBe('6') - expect(screen.getAllByRole('listitem')).toHaveLength(6) - expect(screen.getAllByText(en.enabledTag)).toHaveLength(5) + expect(view.container.querySelector('[data-plugin-count]')?.textContent).toBe('7') + expect(screen.getAllByRole('listitem')).toHaveLength(7) + expect(screen.getAllByText(en.enabledTag)).toHaveLength(6) expect(screen.getByText(en.disabledTag)).toBeTruthy() for (const value of [ 'Mounted', @@ -75,8 +72,10 @@ describe('PluginSettingsSection', () => { target: { value: 'disabled-entry' }, }) expect(view.container.querySelector('[data-loader-entry]')).toBeNull() - fireEvent.click(screen.getByRole('button', { name: 'directory-picker-native, Not mounted, Disabled' })) + fireEvent.click(screen.getByRole('button', { name: 'directory-picker-native, Disabled' })) expect(screen.getAllByText(en.disabledTag)).toHaveLength(2) + expect(screen.queryByText(en.cordis)).toBeNull() + expect(screen.queryByText(en.unobserved)).toBeNull() }) it('filters by module name or Loader entry id', async () => { diff --git a/packages/client/ui-settings/README.i18n.yaml b/packages/client/ui-settings/README.i18n.yaml index 392f2b52a1..ec7c333250 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: 8bf085fd02e3c76148674065bb4f5708e9a6e8d8 -README.zh.md: f7b6f18809c64be6830ea23c3968e9af70b70c41 +README.md: 950585c4957cd59fe3a38dc37cdd4084f7c5541c +README.zh.md: dce8dbf5c8e0939142fed8a3df84c47acd7c8a1e diff --git a/packages/client/ui-settings/README.md b/packages/client/ui-settings/README.md index 8bf085fd02..950585c495 100644 --- a/packages/client/ui-settings/README.md +++ b/packages/client/ui-settings/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The settings domain's base layer, with two roles and no presentation of its own. It provides `ctx.settingsScope`, the Host transport every preference row binds its durable namespace section through, and it declares the settings slot types registrants fill: `settings.trigger` / `settings.header` / `settings.close` (chrome content), `settings.action` (ordered content-header actions), `settings.section` (one page per feature), and `settings.onboarding` (ordered feature-owned pages). It depends on no `ui-*` presentation package, so any feature that owns a preference can reach it; the settings SHELL — the `sidebar.settings` occupant, its navigation, and the chrome — lives in ui-settings-general, because a shell dependency on ui-sidebar would close a reference graph cycle through ui-layout and ui-theme. The shell's own contract types live beside the shell for the same reason. +The settings domain's base layer, with two roles and no presentation of its own. It provides `ctx.settingsScope`, the Host transport every preference row binds its durable namespace section through, and it declares the settings slot types registrants fill: `settings.trigger` / `settings.header` / `settings.close` (chrome content), `settings.action` (ordered content-header actions), `settings.section` (one page per feature), `settings.plugins.tab` (feature-owned pages inside the Plugins section), and `settings.onboarding` (ordered feature-owned pages). It depends on no `ui-*` presentation package, so any feature that owns a preference can reach it; the settings SHELL — the `sidebar.settings` occupant, its navigation, and the chrome — lives in ui-settings-general, because a shell dependency on ui-sidebar would close a reference graph cycle through ui-layout and ui-theme. The shell's own contract types live beside the shell for the same reason. The plugin injects nothing and waits for nothing: `ctx.settingsScope.bind(spec)` resolves the wire face through the CALLER's context at call time, so the bound scope's disposer belongs to the calling fiber, and the caller injects `connection` for the transport and `remote` for the invalidation. Listeners exist before the first background read starts, so a row's activation never blocks on the settings transport. A bound scope reloads on the forwarded `settings/document-updated` event for its own namespace and on `connection/reset`. Writes carry one field path and the last known namespace revision as `expectedRevision`; a rejected or failed write re-reads unless a newer write already superseded it, and a stale read never publishes over a newer one. Without a `decode` in the spec, a section that is not a plain object, fails its rehydrated schema, or carries a schema envelope this client cannot rehydrate publishes no value at all, so a row renders its own absent state instead of a half-decoded one. diff --git a/packages/client/ui-settings/README.zh.md b/packages/client/ui-settings/README.zh.md index f7b6f18809..dce8dbf5c8 100644 --- a/packages/client/ui-settings/README.zh.md +++ b/packages/client/ui-settings/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -设置领域的底座,承担两项职责,本身不含任何呈现内容。它提供 `ctx.settingsScope`——每个偏好设置行绑定自己那份持久化命名空间分区所用的宿主传输层;并声明由注册方填充的设置 slot 类型:`settings.trigger`/`settings.header`/`settings.close`(界面框架内容)、`settings.action`(内容标题栏中的有序操作)、`settings.section`(每项功能一页)和 `settings.onboarding`(由各功能持有的有序页面)。它不依赖任何 `ui-*` 呈现包,因此任何持有偏好设置的功能都能够到它;设置**外壳**——`sidebar.settings` 占位方、它的导航与界面框架——位于 ui-settings-general,因为外壳一旦依赖 ui-sidebar,就会经 ui-layout 与 ui-theme 闭合出一条引用图环路。外壳自身的契约类型出于同一原因与外壳放在一起。 +设置领域的底座,承担两项职责,本身不含任何呈现内容。它提供 `ctx.settingsScope`——每个偏好设置行绑定自己那份持久化命名空间分区所用的宿主传输层;并声明由注册方填充的设置 slot 类型:`settings.trigger`/`settings.header`/`settings.close`(界面框架内容)、`settings.action`(内容标题栏中的有序操作)、`settings.section`(每项功能一页)、`settings.plugins.tab`(“插件”分区内由各功能持有的页面)和 `settings.onboarding`(由各功能持有的有序页面)。它不依赖任何 `ui-*` 呈现包,因此任何持有偏好设置的功能都能够到它;设置**外壳**——`sidebar.settings` 占位方、它的导航与界面框架——位于 ui-settings-general,因为外壳一旦依赖 ui-sidebar,就会经 ui-layout 与 ui-theme 闭合出一条引用图环路。外壳自身的契约类型出于同一原因与外壳放在一起。 该插件不注入任何服务、也不等待任何服务:`ctx.settingsScope.bind(spec)` 在调用时经**调用方**的 context 解析线路面,因此绑定所得 scope 的 disposer 归调用方 fiber 所有,而由调用方注入 `connection` 取得传输层、注入 `remote` 取得失效通知。监听器在首次后台读取启动之前就已存在,因此某一行的激活绝不会阻塞在设置传输层上。已绑定的 scope 会在收到属于自己命名空间的转发 `settings/document-updated` 事件时、以及在 `connection/reset` 时重新读取。写入携带单一字段路径以及最近已知的命名空间 revision 作为 `expectedRevision`;被拒绝或失败的写入会重新读取,除非已有更新的写入取代了它,而过期的读取绝不会覆盖发布更新的结果。若 spec 未提供 `decode`,则分区不是普通对象、未通过其重建后的 schema 校验、或携带本客户端无法重建的 schema 信封时,一律不发布任何值,于是行渲染自己的缺失状态,而不是一份半解码的值。 diff --git a/packages/client/ui-settings/src/client/contract/slots.ts b/packages/client/ui-settings/src/client/contract/slots.ts index e648fb2244..79ec682959 100644 --- a/packages/client/ui-settings/src/client/contract/slots.ts +++ b/packages/client/ui-settings/src/client/contract/slots.ts @@ -51,6 +51,15 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { * item registrant; the shell neither declares nor renders it.) */ 'settings.section': { kind: 'list'; scope: 'root'; owner: SettingsSectionOwnerProps } + /** + * One page inside the Plugins settings section. The section owner renders + * localized entry labels as tabs and mounts each contribution inside its + * corresponding tab panel. Options: `id` (tab key), `order` (tab order), + * and `label` (registrant-localized tab text). Declared at runtime by the + * feature that owns the Plugins section; the type lives here so inventory + * and configuration plugins collaborate without depending on one another. + */ + 'settings.plugins.tab': { kind: 'list'; scope: 'root'; owner: SettingsPluginsTabOwnerProps } /** * Root-scoped onboarding steps contributed by settings features. The * shell mounts one ordered step at a time; the active registrant either @@ -83,6 +92,12 @@ export interface SettingsGeneralItemOwnerProps { children?: never } +/** Owner share of a Plugins tab (the section supplies nothing). */ +export interface SettingsPluginsTabOwnerProps { + /** Marker field: tab owner props are intentionally empty. */ + children?: never +} + /** Owner share of the trigger content seat: the sidebar column state. */ export interface SettingsTriggerOwnerProps { /** Whether the sidebar renders wide content (false = 56px rail, icon only). */ diff --git a/packages/client/ui-settings/src/client/index.ts b/packages/client/ui-settings/src/client/index.ts index 8a6fbf25c9..2a670a7396 100644 --- a/packages/client/ui-settings/src/client/index.ts +++ b/packages/client/ui-settings/src/client/index.ts @@ -13,7 +13,7 @@ import { SettingsScopeService } from './settings-scope.ts' export type { SettingsGeneralItemOwnerProps, SettingsHeaderOwnerProps, SettingsOnboardingOwnerProps, - SettingsSectionOwnerProps, SettingsTriggerOwnerProps, + SettingsPluginsTabOwnerProps, SettingsSectionOwnerProps, SettingsTriggerOwnerProps, } from './contract/slots.ts' export { SettingsScopeController, SettingsScopeService } from './settings-scope.ts' diff --git a/packages/host/plugin-inventory/tests/inventory.spec.ts b/packages/host/plugin-inventory/tests/inventory.spec.ts index e979d34306..6de2943197 100644 --- a/packages/host/plugin-inventory/tests/inventory.spec.ts +++ b/packages/host/plugin-inventory/tests/inventory.spec.ts @@ -52,28 +52,28 @@ describe('PluginInventoryService', () => { }) await ctx.loader.create({ name: 'cordis:active', group: true }) - expect(inventory.list()).toEqual({ - entries: [ - { - entryId: activeId, - moduleName: 'cordis:active', - enabled: true, - fiberPhase: 'active', - }, - { - entryId: pendingId, - moduleName: 'cordis:pending', - enabled: true, - fiberPhase: 'pending', - }, - { - entryId: disabledId, - moduleName: 'cordis:not-installed', - enabled: false, - fiberPhase: null, - }, - ], - }) + const snapshot = inventory.list() + expect(snapshot.entries).toHaveLength(3) + expect(snapshot.entries).toEqual(expect.arrayContaining([ + { + entryId: activeId, + moduleName: 'cordis:active', + enabled: true, + fiberPhase: 'active', + }, + { + entryId: pendingId, + moduleName: 'cordis:pending', + enabled: true, + fiberPhase: 'pending', + }, + { + entryId: disabledId, + moduleName: 'cordis:not-installed', + enabled: false, + fiberPhase: null, + }, + ])) await ctx.loader.update(activeId, { disabled: true }) expect(inventory.list().entries.find(entry => entry.entryId === activeId)).toEqual({