From 7cdf36dfd057dbfc51238069390b6972eb88953c Mon Sep 17 00:00:00 2001 From: Turtle Date: Sat, 25 Jul 2026 14:18:16 +0800 Subject: [PATCH 01/32] docs: require appropriate PR labels --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index 8007f32b90..98b23b88ae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -110,6 +110,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, - **A tool's ACP render intent is part of its design**, decided up front (`generic`/`terminal`/`diff`, `locations`); presentation methods are pure functions of `args` ([cookbook](docs/cookbook/adding-a-tool.md)). - **Plan unit, e2e, and snapshot coverage** for new seams, lifecycle shapes, and transcript surfaces; missing snapshot-harness support is part of the implementation, not deferred follow-up. - **Keep PRs coherent and merge with merge commits.** Split an independently meaningful feature or design decision into a separate or stacked PR when combining it obscures ownership, intent, or verification. Never squash/rebase or rewrite pushed branches; put a review fix on its introducing PR, then merge down the stack ([guide](docs/cookbook/responding-to-pr-review-on-a-stack.md)). +- **Label PRs appropriately.** Apply labels required by each PR's changes, including labels that trigger optional CI workflows. - TODO markers: `FIXME`/`TODO`/`XXX` by urgency ([semantics](docs/development.md)). - Files end with exactly one trailing newline; `git diff --cached --check` (pre-commit) gates it. From fd33a039a2d4c58afc50f870bf7e3e37b9d9c2e9 Mon Sep 17 00:00:00 2001 From: Turtle Date: Sat, 25 Jul 2026 14:25:44 +0800 Subject: [PATCH 02/32] docs: fit PR label rule within budget --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 95364b5c56..f2d25c71d6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -111,7 +111,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, - **A tool's UI render intent is part of its design**, decided up front (`generic`/`terminal`/`diff`, `locations`); presentation methods are pure functions of `args` ([cookbook](docs/cookbook/adding-a-tool.md)). - **Plan unit, e2e, and snapshot coverage** for new seams, lifecycle shapes, and transcript surfaces; missing snapshot-harness support is part of the implementation, not deferred follow-up. - **Keep PRs coherent and merge with merge commits.** Split an independently meaningful feature or design decision into a separate or stacked PR when combining it obscures ownership, intent, or verification. Never squash/rebase or rewrite pushed branches; put a review fix on its introducing PR, then merge down the stack ([guide](docs/cookbook/responding-to-pr-review-on-a-stack.md)). -- **Label PRs appropriately.** Apply labels required by each PR's changes, including labels that trigger optional CI workflows. +- **Label PRs appropriately.** Apply labels required by each PR's changes, including optional CI-trigger labels. - TODO markers: `FIXME`/`TODO`/`XXX` by urgency ([semantics](docs/development.md)). - Files end with exactly one trailing newline; `git diff --cached --check` (pre-commit) gates it. From dc5045dc0ae32b0ec15d945b9978109efd75e5b7 Mon Sep 17 00:00:00 2001 From: Turtle Date: Sat, 25 Jul 2026 14:30:52 +0800 Subject: [PATCH 03/32] docs: keep PR label guidance general --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index f2d25c71d6..8c4cc143f8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -111,7 +111,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, - **A tool's UI render intent is part of its design**, decided up front (`generic`/`terminal`/`diff`, `locations`); presentation methods are pure functions of `args` ([cookbook](docs/cookbook/adding-a-tool.md)). - **Plan unit, e2e, and snapshot coverage** for new seams, lifecycle shapes, and transcript surfaces; missing snapshot-harness support is part of the implementation, not deferred follow-up. - **Keep PRs coherent and merge with merge commits.** Split an independently meaningful feature or design decision into a separate or stacked PR when combining it obscures ownership, intent, or verification. Never squash/rebase or rewrite pushed branches; put a review fix on its introducing PR, then merge down the stack ([guide](docs/cookbook/responding-to-pr-review-on-a-stack.md)). -- **Label PRs appropriately.** Apply labels required by each PR's changes, including optional CI-trigger labels. +- **Label PRs appropriately.** Apply labels required by each PR's changes. - TODO markers: `FIXME`/`TODO`/`XXX` by urgency ([semantics](docs/development.md)). - Files end with exactly one trailing newline; `git diff --cached --check` (pre-commit) gates it. From 5209656086944adcbbf637468b89bb99799c9633 Mon Sep 17 00:00:00 2001 From: Turtle Date: Sat, 25 Jul 2026 17:43:26 +0800 Subject: [PATCH 04/32] docs: clarify PR label selection --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 8c4cc143f8..6ec6383d6a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -111,7 +111,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, - **A tool's UI render intent is part of its design**, decided up front (`generic`/`terminal`/`diff`, `locations`); presentation methods are pure functions of `args` ([cookbook](docs/cookbook/adding-a-tool.md)). - **Plan unit, e2e, and snapshot coverage** for new seams, lifecycle shapes, and transcript surfaces; missing snapshot-harness support is part of the implementation, not deferred follow-up. - **Keep PRs coherent and merge with merge commits.** Split an independently meaningful feature or design decision into a separate or stacked PR when combining it obscures ownership, intent, or verification. Never squash/rebase or rewrite pushed branches; put a review fix on its introducing PR, then merge down the stack ([guide](docs/cookbook/responding-to-pr-review-on-a-stack.md)). -- **Label PRs appropriately.** Apply labels required by each PR's changes. +- Use matching existing PR labels (`documentation`, `web`, `tui`, `core`); never create one implicitly. - TODO markers: `FIXME`/`TODO`/`XXX` by urgency ([semantics](docs/development.md)). - Files end with exactly one trailing newline; `git diff --cached --check` (pre-commit) gates it. From fd8d93da12903b8c9d8455cab0972ff272f36bc0 Mon Sep 17 00:00:00 2001 From: Turtle Date: Sat, 25 Jul 2026 18:02:36 +0800 Subject: [PATCH 05/32] docs: prohibit creating PR labels --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 6ec6383d6a..eae3007c86 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -111,7 +111,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, - **A tool's UI render intent is part of its design**, decided up front (`generic`/`terminal`/`diff`, `locations`); presentation methods are pure functions of `args` ([cookbook](docs/cookbook/adding-a-tool.md)). - **Plan unit, e2e, and snapshot coverage** for new seams, lifecycle shapes, and transcript surfaces; missing snapshot-harness support is part of the implementation, not deferred follow-up. - **Keep PRs coherent and merge with merge commits.** Split an independently meaningful feature or design decision into a separate or stacked PR when combining it obscures ownership, intent, or verification. Never squash/rebase or rewrite pushed branches; put a review fix on its introducing PR, then merge down the stack ([guide](docs/cookbook/responding-to-pr-review-on-a-stack.md)). -- Use matching existing PR labels (`documentation`, `web`, `tui`, `core`); never create one implicitly. +- Pick matching existing GitHub labels such as `documentation`, `web`, `tui`, or `core`; never create new labels. - TODO markers: `FIXME`/`TODO`/`XXX` by urgency ([semantics](docs/development.md)). - Files end with exactly one trailing newline; `git diff --cached --check` (pre-commit) gates it. From 6e721b9fdd5524b31fc399bf7b72f0cba0f32cba Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 26 Jul 2026 00:16:05 +0800 Subject: [PATCH 06/32] feat(gui): settings panel with locale and theme preferences Add the browser Settings surface as slot-composed plugins over new preference services: - Rename dsh-client-i18n to dsh-client-locale (locale is the domain name); LocaleService adds getLocale()/setLocale(id), immutable snapshots, a locale/change event, and dsh.locale persistence. - ThemeService owns the light/dark/system preference (default system), resolves system via prefers-color-scheme, publishes theme/change snapshots, persists dsh.theme, and no longer touches the DOM; ui-layout's ThemePresenter applies resolved snapshots (body[data-ds-dark-theme] + alias tokens) and cleans up on dispose. - ui-sidebar drops the phase-1 settings dropdown/modal; the foot renders the new sidebar.settings slot with the column state. - New ui-settings shell occupies sidebar.settings: foot trigger row and the centered 1080x700 panel (figma 501:29947) with 24% mask, close button / mask click / Escape all closing, and a 188px nav projected from the settings.section list slot it declares. Nav labels are registrant-localized; sections re-register on locale change, so the ledger version is the shell's only subscription. - ui-settings-general registers the General section: Permission and Tool Call skeletons, live Language (locale menu) and Appearance (Light/Dark/System cubes following the persisted preference); its slot store mirrors both service snapshots via apply-side listeners. - ui-settings-models registers the Models nav entry with an empty content column. - Portaled menus pin z-index above modal overlays (a menu anchored inside the settings dialog rendered underneath it and was unclickable). - theme/data/list-pen icons in ui-primitives; settings copy ships as zh/en dictionaries; fixture manifests gain the settings rows. --- ...6-07-25-client-settings-locale-theme.zh.md | 99 +++++++++ apps/cli/cordis.yml | 13 +- apps/cli/package.json | 5 +- apps/cli/tsconfig.json | 11 +- apps/web/tests/session-title.snapshot.ts | 5 +- apps/web/tests/smoke-real.e2e.ts | 2 +- apps/web/tests/workspace-flow.snapshot.ts | 5 +- packages/client/i18n/README.md | 16 -- packages/client/i18n/src/client/index.ts | 108 --------- packages/client/i18n/tests/i18n.spec.ts | 53 ----- packages/client/i18n/tests/invariant.spec.ts | 30 --- packages/client/i18n/tsdown.config.ts | 3 - packages/client/locale/README.md | 16 ++ packages/client/{i18n => locale}/package.json | 13 +- packages/client/locale/src/client/index.ts | 195 +++++++++++++++++ packages/client/{i18n => locale}/src/index.ts | 2 +- .../client/{i18n => locale}/src/invariant.ts | 8 +- .../client/{i18n => locale}/src/locales/en.ts | 0 .../client/{i18n => locale}/src/locales/zh.ts | 0 .../client/locale/tests/invariant.spec.ts | 30 +++ packages/client/locale/tests/locale.spec.ts | 90 ++++++++ .../client/{i18n => locale}/tsconfig.json | 3 - packages/client/locale/tsdown.config.ts | 3 + packages/client/tsdown.client.ts | 2 +- packages/client/ui-conversation/package.json | 2 +- .../tests/apply-inject.spec.tsx | 2 +- .../ui-conversation/tests/chat-apply.spec.tsx | 2 +- .../tests/chat-toolview-slot.spec.tsx | 4 +- packages/client/ui-conversation/tsconfig.json | 2 +- packages/client/ui-layout/README.md | 2 +- packages/client/ui-layout/package.json | 5 +- packages/client/ui-layout/src/client/index.ts | 19 +- .../ui-layout/src/client/theme-presenter.ts | 43 ++++ packages/client/ui-layout/tests/apply.spec.ts | 21 +- .../ui-layout/tests/theme-presenter.spec.ts | 56 +++++ packages/client/ui-layout/tsconfig.json | 3 + .../client/ui-primitives/src/Menu.module.css | 4 +- .../client/ui-primitives/src/icons/index.tsx | 87 ++++++++ .../client/ui-primitives/tests/icons.spec.tsx | 4 +- packages/client/ui-settings-general/README.md | 15 ++ .../client/ui-settings-general/package.json | 70 ++++++ .../src/client/GeneralSection.module.css | 131 +++++++++++ .../src/client/GeneralSection.tsx | 118 ++++++++++ .../src/client/contract.ts | 66 ++++++ .../ui-settings-general/src/client/index.ts | 111 ++++++++++ .../ui-settings-general/src/client/locales.ts | 42 ++++ .../ui-settings-general/src/client/store.ts | 43 ++++ .../ui-settings-general/src/css-modules.d.ts | 6 + .../client/ui-settings-general/src/index.ts | 4 + .../ui-settings-general/src/invariant.ts | 32 +++ .../client/ui-settings-general/tsconfig.json | 36 +++ .../ui-settings-general/tsdown.config.ts | 3 + packages/client/ui-settings-models/README.md | 15 ++ .../client/ui-settings-models/package.json | 63 ++++++ .../src/client/ModelsSection.tsx | 13 ++ .../ui-settings-models/src/client/index.ts | 63 ++++++ .../ui-settings-models/src/css-modules.d.ts | 6 + .../client/ui-settings-models/src/index.ts | 4 + .../ui-settings-models/src/invariant.ts | 31 +++ .../client/ui-settings-models/tsconfig.json | 30 +++ .../ui-settings-models/tsdown.config.ts | 3 + packages/client/ui-settings/README.md | 15 ++ packages/client/ui-settings/package.json | 68 ++++++ .../src/client/SettingsRoot.module.css | 192 ++++++++++++++++ .../ui-settings/src/client/SettingsRoot.tsx | 125 +++++++++++ .../ui-settings/src/client/contract/slots.ts | 62 ++++++ .../client/ui-settings/src/client/index.ts | 66 ++++++ .../client/ui-settings/src/css-modules.d.ts | 6 + packages/client/ui-settings/src/index.ts | 4 + packages/client/ui-settings/src/invariant.ts | 32 +++ packages/client/ui-settings/tsconfig.json | 33 +++ packages/client/ui-settings/tsdown.config.ts | 3 + packages/client/ui-sidebar/README.md | 4 +- .../src/client/SidebarRoot.module.css | 46 +--- .../ui-sidebar/src/client/SidebarRoot.tsx | 9 +- .../ui-sidebar/src/client/contract/slots.ts | 22 +- .../client/ui-sidebar/src/client/index.ts | 11 +- .../ui-sidebar/tests/sidebar-root.spec.tsx | 16 +- packages/client/ui-theme/README.md | 5 +- packages/client/ui-theme/package.json | 8 +- packages/client/ui-theme/src/client/index.ts | 207 ++++++++++++++---- packages/client/ui-theme/src/invariant.ts | 7 +- packages/client/ui-theme/tests/theme.spec.ts | 109 +++++---- packages/client/web/src/boot.tsx | 2 +- pnpm-lock.yaml | 120 +++++++++- .../verify-package-readme-model-experience.ts | 2 +- tsconfig.base.json | 5 +- tsconfig.client.json | 5 +- 88 files changed, 2653 insertions(+), 404 deletions(-) create mode 100644 .agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.zh.md delete mode 100644 packages/client/i18n/README.md delete mode 100644 packages/client/i18n/src/client/index.ts delete mode 100644 packages/client/i18n/tests/i18n.spec.ts delete mode 100644 packages/client/i18n/tests/invariant.spec.ts delete mode 100644 packages/client/i18n/tsdown.config.ts create mode 100644 packages/client/locale/README.md rename packages/client/{i18n => locale}/package.json (78%) create mode 100644 packages/client/locale/src/client/index.ts rename packages/client/{i18n => locale}/src/index.ts (61%) rename packages/client/{i18n => locale}/src/invariant.ts (86%) rename packages/client/{i18n => locale}/src/locales/en.ts (100%) rename packages/client/{i18n => locale}/src/locales/zh.ts (100%) create mode 100644 packages/client/locale/tests/invariant.spec.ts create mode 100644 packages/client/locale/tests/locale.spec.ts rename packages/client/{i18n => locale}/tsconfig.json (87%) create mode 100644 packages/client/locale/tsdown.config.ts create mode 100644 packages/client/ui-layout/src/client/theme-presenter.ts create mode 100644 packages/client/ui-layout/tests/theme-presenter.spec.ts create mode 100644 packages/client/ui-settings-general/README.md create mode 100644 packages/client/ui-settings-general/package.json create mode 100644 packages/client/ui-settings-general/src/client/GeneralSection.module.css create mode 100644 packages/client/ui-settings-general/src/client/GeneralSection.tsx create mode 100644 packages/client/ui-settings-general/src/client/contract.ts create mode 100644 packages/client/ui-settings-general/src/client/index.ts create mode 100644 packages/client/ui-settings-general/src/client/locales.ts create mode 100644 packages/client/ui-settings-general/src/client/store.ts create mode 100644 packages/client/ui-settings-general/src/css-modules.d.ts create mode 100644 packages/client/ui-settings-general/src/index.ts create mode 100644 packages/client/ui-settings-general/src/invariant.ts create mode 100644 packages/client/ui-settings-general/tsconfig.json create mode 100644 packages/client/ui-settings-general/tsdown.config.ts create mode 100644 packages/client/ui-settings-models/README.md create mode 100644 packages/client/ui-settings-models/package.json create mode 100644 packages/client/ui-settings-models/src/client/ModelsSection.tsx create mode 100644 packages/client/ui-settings-models/src/client/index.ts create mode 100644 packages/client/ui-settings-models/src/css-modules.d.ts create mode 100644 packages/client/ui-settings-models/src/index.ts create mode 100644 packages/client/ui-settings-models/src/invariant.ts create mode 100644 packages/client/ui-settings-models/tsconfig.json create mode 100644 packages/client/ui-settings-models/tsdown.config.ts create mode 100644 packages/client/ui-settings/README.md create mode 100644 packages/client/ui-settings/package.json create mode 100644 packages/client/ui-settings/src/client/SettingsRoot.module.css create mode 100644 packages/client/ui-settings/src/client/SettingsRoot.tsx create mode 100644 packages/client/ui-settings/src/client/contract/slots.ts create mode 100644 packages/client/ui-settings/src/client/index.ts create mode 100644 packages/client/ui-settings/src/css-modules.d.ts create mode 100644 packages/client/ui-settings/src/index.ts create mode 100644 packages/client/ui-settings/src/invariant.ts create mode 100644 packages/client/ui-settings/tsconfig.json create mode 100644 packages/client/ui-settings/tsdown.config.ts diff --git a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.zh.md b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.zh.md new file mode 100644 index 0000000000..d3c61794b9 --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.zh.md @@ -0,0 +1,99 @@ +# Agent Note: Client Settings、Locale 与 Theme 分层 + +Status: proposed + +## Problem + +浏览器端已有的 Settings 直接写在 Sidebar 内,语言和主题也由组件本地状态直接改 DOM。这使 Settings 无法由独立插件扩展,偏好状态没有稳定的跨插件服务契约,主题 registry 同时承担状态与呈现职责。 + +## Proposal + +Sidebar 声明 `sidebar.settings` 单坑位,`ui-settings` 占用它并声明 `settings.section` list 坑位。每个 section 由独立插件贡献;Settings 壳只从 slot ledger 读取 entry metadata 生成导航,通过 `only` 渲染当前 section。 + +Settings 入口是 sidebar Foot 的 Settings 行,点击直接打开 1080×700 居中浮层(黑 24% 遮罩);close 按钮、点击遮罩、ESC 均关闭。无任何中间菜单形态。 + +`@deepseek-ai/dsh-client-locale` 提供 `ctx.locale`,`ui-theme` 提供 `ctx.theme`。两个 service 都以 getter 读取、setter 写入并用 typed Cordis change event 发布 immutable snapshot;service 自己持久化偏好(只存 id,坏值回退默认)。 + +General 的 apply 层订阅 `locale/change` 和 `theme/change`,把 snapshot 投影到该 section 声明的 Zustand store。React 组件只读 `useStore`、写注入的 setter callback,不读取 ctx 或 service。 + +Theme 偏好三态:`light`、`dark`、`system`,默认 `system`(无持久化偏好或坏值时)。system 的解析属主题领域:ThemeService 持有 `prefers-color-scheme` matchMedia 监听(环境感知,非 DOM 呈现),偏好为 system 且系统配色变化时重发 snapshot;snapshot 同时携带 `preference` 与解析后的 `active` 定义。 + +Theme service 不操作 DOM。`ui-layout` 初始读取 Theme getter,随后订阅 `theme/change`,由 Layout 持有的 presenter 按 `active` 更新 `body[data-ds-dark-theme]` 和主题 token;presenter 不感知 system,只消费已解析结果。 + +### 首期 section 范围 + +| section | 插件 | 首期内容 | +|---|---|---| +| General | `ui-settings-general` | Language(Selector 下拉)与 Appearance(Light/Dark/System 三 cube)真实可切;Permission、Tool Call 仅视觉骨架,无写操作 | +| Models | `ui-settings-models` | 仅导航项,内容区为空 | +| Plugin | 不建包 | 首期不做,导航不出现该项(无目标的外链入口不上屏;后续插件注册 section 即自动出现) | + +首期只翻译 Settings 浮层内文案(General 各行 + 导航);其他页面文案不动。 + +### Slot topology + +```text +root +└─ sidebar + └─ sidebar.settings single/root + └─ ui-settings + └─ settings.section list/root + ├─ general ui-settings-general + └─ models ui-settings-models +``` + +section contribution 使用 declaration-aware deferral,不依赖 client manifest 的 apply 顺序。 + +### Service contracts + +```ts +type ThemePreference = 'light' | 'dark' | 'system' + +interface ThemeSnapshot { + preference: ThemePreference + active: ThemeDefinition // system 已解析为具体 light/dark 定义 + themes: readonly ThemeDefinition[] + revision: number +} + +interface LocaleSnapshot { + active: 'zh' | 'en' + locales: readonly LocaleDefinition[] + revision: number +} + +interface Events { + /** @param snapshot - Current locale registry snapshot. @mode emit */ + 'locale/change'(snapshot: LocaleSnapshot): void + /** @param snapshot - Current theme registry snapshot. @mode emit */ + 'theme/change'(snapshot: ThemeSnapshot): void +} +``` + +Locale 内置中文和 English;`setLocale`/`setTheme` 是唯一写入口,未知 id 失败。 + +## Alternatives considered + +**由 app shell 统一订阅偏好并重渲染 root slot tree。** 语言和主题变化只需要更新实际消费者;全树刷新放大影响面,也把业务偏好接入 shell。 + +**Theme service 直接修改 DOM。** registry service 因此依赖呈现环境,生命周期与全局样式所有权不清;Layout 已经拥有页面根呈现边界。 + +**system 由 Layout presenter 解析。** presenter 需自带 matchMedia 订阅并在 themes 列表里挑选具体定义,呈现层被迫理解偏好语义;解析放服务侧则所有消费者拿到一致的已解析 snapshot。 + +**Settings import 并枚举各 section。** 新增页面必须修改壳插件,破坏「每个功能由自己的插件占坑」的组合模型。 + +**把 Locale/Theme snapshot 直接注入 React。** inject 结果按 entry identity 缓存,易变值会陈旧;为每个 service 自造 React hook 也绕开 slot store 的统一绑定。 + +## Acceptance criteria + +- Settings 壳只依赖 slot ledger,不依赖任一 section 实现。 +- Locale 与 Theme 的写入只走 setter,持续同步只走 change event。 +- General store 初始化走 getter,后续由两个 event 更新并局部重渲染。 +- Layout 独立应用 Theme snapshot,Theme service 不访问 DOM;presenter 不出现 system 分支。 +- 中文/English 与 Light/Dark/System 能切换并刷新后恢复;偏好为 system 时系统配色变化即时生效。 +- Models 只有导航项与空内容区;Permission、Tool Call 骨架无写操作。 +- 浮层经 close 按钮、遮罩点击、ESC 均可关闭。 + +## Risks + +slot 声明与 contribution 的 apply 顺序不固定,所有新 section 必须保留 declaration-aware registration 和幂等防护。service event 可能早于 section 首次渲染,General store 的 init 与 controller attach 都必须从 getter 对齐当前 snapshot。Layout 卸载时必须清理自己设置的全局属性,ThemeService dispose 时必须移除 matchMedia 监听,避免 HMR 后残留。 diff --git a/apps/cli/cordis.yml b/apps/cli/cordis.yml index b89df77c46..8412a313b4 100644 --- a/apps/cli/cordis.yml +++ b/apps/cli/cordis.yml @@ -221,8 +221,8 @@ - id: ui-theme name: '@deepseek-ai/dsh-client-ui-theme' -- id: i18n - name: '@deepseek-ai/dsh-client-i18n' +- id: locale + name: '@deepseek-ai/dsh-client-locale' - id: ui-layout name: '@deepseek-ai/dsh-client-ui-layout' @@ -230,6 +230,15 @@ - id: ui-sidebar name: '@deepseek-ai/dsh-client-ui-sidebar' +- id: ui-settings + name: '@deepseek-ai/dsh-client-ui-settings' + +- id: ui-settings-general + name: '@deepseek-ai/dsh-client-ui-settings-general' + +- id: ui-settings-models + name: '@deepseek-ai/dsh-client-ui-settings-models' + - id: ui-conversation name: '@deepseek-ai/dsh-client-ui-conversation' diff --git a/apps/cli/package.json b/apps/cli/package.json index e1c07f90b5..74a6e417e1 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -23,13 +23,16 @@ "@deepseek-ai/dsh-bash-local": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-hmr": "workspace:^", - "@deepseek-ai/dsh-client-i18n": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-modules": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-layout": "workspace:^", "@deepseek-ai/dsh-client-ui-question": "workspace:^", "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings-general": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings-models": "workspace:^", "@deepseek-ai/dsh-client-ui-theme": "workspace:^", "@deepseek-ai/dsh-client-ui-trajectory": "workspace:^", "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index 4db4861b93..7614ae2e31 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -42,7 +42,16 @@ "path": "../../packages/client/ui-theme" }, { - "path": "../../packages/client/i18n" + "path": "../../packages/client/ui-settings" + }, + { + "path": "../../packages/client/ui-settings-general" + }, + { + "path": "../../packages/client/ui-settings-models" + }, + { + "path": "../../packages/client/locale" }, { "path": "../../packages/client/ui-layout" diff --git a/apps/web/tests/session-title.snapshot.ts b/apps/web/tests/session-title.snapshot.ts index c1616bb724..b8ee3408a3 100644 --- a/apps/web/tests/session-title.snapshot.ts +++ b/apps/web/tests/session-title.snapshot.ts @@ -10,9 +10,12 @@ const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [ { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true }, { id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true }, { id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true }, - { id: '@deepseek-ai/dsh-client-i18n', dir: 'i18n', url: '/plugins/i18n.js', rev: 'fx', inject: [], immediately: true }, + { id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true }, { id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] }, { id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, + { id: '@deepseek-ai/dsh-client-ui-settings', dir: 'ui-settings', url: '/plugins/ui-settings.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-sidebar', '@deepseek-ai/dsh-client-locale'] }, + { id: '@deepseek-ai/dsh-client-ui-settings-general', dir: 'ui-settings-general', url: '/plugins/ui-settings-general.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-settings'] }, + { id: '@deepseek-ai/dsh-client-ui-settings-models', dir: 'ui-settings-models', url: '/plugins/ui-settings-models.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-settings'] }, { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, { id: '@deepseek-ai/dsh-client-ui-workspace', dir: 'ui-workspace', url: '/plugins/ui-workspace.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-conversation', '@deepseek-ai/dsh-client-ui-sidebar'] }, { id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] }, diff --git a/apps/web/tests/smoke-real.e2e.ts b/apps/web/tests/smoke-real.e2e.ts index a3d511df16..70d1d81523 100644 --- a/apps/web/tests/smoke-real.e2e.ts +++ b/apps/web/tests/smoke-real.e2e.ts @@ -147,7 +147,7 @@ async function detailsTrack(page: Page): Promise { // Readiness gate: `dsh web` serves ALL nine manifest plugins; until every UI // plugin's client bundle exists and exports apply, the loader fail-louds and // the frame never appears. -const UI_PLUGIN_DIRS = ['connection', 'runtime', 'ui-theme', 'i18n', 'ui-layout', 'ui-sidebar', 'ui-conversation', 'ui-question', 'ui-trajectory'] +const UI_PLUGIN_DIRS = ['connection', 'runtime', 'ui-theme', 'locale', 'ui-layout', 'ui-sidebar', 'ui-conversation', 'ui-question', 'ui-trajectory'] const ROUND_DONE_MARKER = 'WEB_ROUND_DONE' const notReady = UI_PLUGIN_DIRS.filter((dir) => { const bundle = join(REPO_ROOT, 'packages/client', dir, 'lib/client.js') diff --git a/apps/web/tests/workspace-flow.snapshot.ts b/apps/web/tests/workspace-flow.snapshot.ts index 78ac843a64..e232d8e123 100644 --- a/apps/web/tests/workspace-flow.snapshot.ts +++ b/apps/web/tests/workspace-flow.snapshot.ts @@ -10,9 +10,12 @@ const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [ { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true }, { id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true }, { id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true }, - { id: '@deepseek-ai/dsh-client-i18n', dir: 'i18n', url: '/plugins/i18n.js', rev: 'fx', inject: [], immediately: true }, + { id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true }, { id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] }, { id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, + { id: '@deepseek-ai/dsh-client-ui-settings', dir: 'ui-settings', url: '/plugins/ui-settings.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-sidebar', '@deepseek-ai/dsh-client-locale'] }, + { id: '@deepseek-ai/dsh-client-ui-settings-general', dir: 'ui-settings-general', url: '/plugins/ui-settings-general.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-settings'] }, + { id: '@deepseek-ai/dsh-client-ui-settings-models', dir: 'ui-settings-models', url: '/plugins/ui-settings-models.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-settings'] }, { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, { id: '@deepseek-ai/dsh-client-ui-workspace', diff --git a/packages/client/i18n/README.md b/packages/client/i18n/README.md deleted file mode 100644 index db6be0fe2a..0000000000 --- a/packages/client/i18n/README.md +++ /dev/null @@ -1,16 +0,0 @@ -# @deepseek-ai/dsh-client-i18n - -i18n plugin: I18nService (ns×locale dictionaries, bind(ns)→t with a stable function identity, locale store). Contract: api-contracts v3 §8. - -## Model Experience - -None, as the i18n registry serves browser UI copy; nothing here reaches a model request. - -#### KV Cache effect - -None; this package neither assembles nor sends a provider request. - -## Known Limitations and Deferred Work - -- **zh/en ship as empty structures** — the existing UI copy is inline Chinese; extraction into dictionaries is deferred repo-wide work, so `bind(ns)` consumers today mostly receive key-echo fallbacks. -- **Locale switching re-renders the whole tree** — accepted as a low-frequency operation; no per-namespace subscription granularity. diff --git a/packages/client/i18n/src/client/index.ts b/packages/client/i18n/src/client/index.ts deleted file mode 100644 index 9dea9c4bd4..0000000000 --- a/packages/client/i18n/src/client/index.ts +++ /dev/null @@ -1,108 +0,0 @@ -/** - * Browser-side locale registry. Bound translation functions retain stable - * identity for injected consumers. - */ -import type { Context } from 'cordis' -// Snapshot stores are framework-neutral; React consumers bind hooks at their -// rendering boundary. -import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' -import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' -import { en } from '../locales/en.ts' -import { zh } from '../locales/zh.ts' - -/** Translate a key with optional params. */ -export type Translate = (key: string, params?: Record) => string - -/** Locale dictionary: flat key to template string ({name} placeholders). */ -export type LocaleDict = Record - -declare module 'cordis' { - interface Context { - i18n: I18nService - } -} - -/** Fallback locale consulted after the active locale misses. */ -export const FALLBACK_LOCALE = 'zh' - -/** Shared namespace for shell-level texts. */ -export const COMMON_NS = 'common' - -/** - * Dictionary registry plus locale switch. Lookup chain per key: active locale - * -> zh fallback -> the key itself (missing text stays visible, fail loud in - * the UI rather than blank). - */ -export class I18nService { - private dicts = new Map>() - private bound = new Map() - private localeStore = createSnapshotStore(FALLBACK_LOCALE) - - /** - * Register a dictionary for a namespace and locale. Duplicate (ns, locale) - * throws (single occupant; a namespace's texts have one owner). - * @param ns - namespace. - * @param locale - locale tag (zh/en to start). - * @param dict - dictionary. - * @returns disposer (idempotent). - */ - register(ns: string, locale: string, dict: LocaleDict): () => void { - let locales = this.dicts.get(ns) - if (!locales) { - locales = new Map() - this.dicts.set(ns, locales) - } - if (locales.has(locale)) throw new Error(`i18n namespace "${ns}" already has locale "${locale}"`) - locales.set(locale, dict) - return () => { - const owner = this.dicts.get(ns) - if (owner?.get(locale) === dict) owner.delete(locale) - } - } - - /** - * Bind a namespace to a translate function. The returned reference is - * stable per namespace (repeat binds return the same function), so it can - * ride inject surfaces without breaking memoization. - * @param ns - namespace. - * @returns the translate function (reads the locale store at call time). - */ - bind(ns: string): Translate { - let t = this.bound.get(ns) - if (!t) { - t = (key, params) => this.translate(ns, key, params) - this.bound.set(ns, t) - return t - } - return t - } - - /** Active locale store (switching re-renders the tree; low frequency). */ - get locale(): SnapshotStore { - return this.localeStore - } - - private translate(ns: string, key: string, params?: Record): string { - const locales = this.dicts.get(ns) - const template = locales?.get(this.localeStore.getSnapshot())?.[key] - ?? locales?.get(FALLBACK_LOCALE)?.[key] - ?? key - if (!params) return template - return template.replace(/\{(\w+)\}/g, (match, name: string) => - name in params ? String(params[name]) : match) - } -} - -/** Required services (none; the loader passes the export surface as an object plugin). */ -export const inject: string[] = [] - -/** - * Client plugin body: provide the i18n service with base dictionaries. - * @param ctx - client cordis context. - */ -export function apply(ctx: Context): void { - const i18n = new I18nService() - i18n.register(COMMON_NS, 'zh', zh) - i18n.register(COMMON_NS, 'en', en) - ctx.provide('i18n', i18n) -} diff --git a/packages/client/i18n/tests/i18n.spec.ts b/packages/client/i18n/tests/i18n.spec.ts deleted file mode 100644 index 12fef2c716..0000000000 --- a/packages/client/i18n/tests/i18n.spec.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { I18nService } from '@deepseek-ai/dsh-client-i18n/client' - -describe('I18nService', () => { - it('translates from the active locale with zh fallback then key passthrough', () => { - const i18n = new I18nService() - i18n.register('ns', 'zh', { hello: '你好', onlyZh: '仅中文' }) - i18n.register('ns', 'en', { hello: 'Hello' }) - const t = i18n.bind('ns') - expect(i18n.locale.getSnapshot()).toBe('zh') - expect(t('hello')).toBe('你好') - i18n.locale.set('en') - expect(t('hello')).toBe('Hello') - expect(t('onlyZh')).toBe('仅中文') - expect(t('missing.key')).toBe('missing.key') - }) - - it('interpolates {name} params and leaves unknown placeholders intact', () => { - const i18n = new I18nService() - i18n.register('ns', 'zh', { greet: '你好,{name}!第 {n} 次', partial: '{known} 与 {unknown}' }) - const t = i18n.bind('ns') - expect(t('greet', { name: '世界', n: 2 })).toBe('你好,世界!第 2 次') - expect(t('partial', { known: 'A' })).toBe('A 与 {unknown}') - expect(t('greet')).toBe('你好,{name}!第 {n} 次') - }) - - it('bind returns a stable reference per namespace', () => { - const i18n = new I18nService() - expect(i18n.bind('a')).toBe(i18n.bind('a')) - expect(i18n.bind('a')).not.toBe(i18n.bind('b')) - }) - - it('duplicate (ns, locale) throws; disposer unregisters and is idempotent', () => { - const i18n = new I18nService() - const dispose = i18n.register('ns', 'zh', { k: 'v1' }) - expect(() => i18n.register('ns', 'zh', { k: 'v2' })).toThrow('already has locale') - dispose() - dispose() - const t = i18n.bind('ns') - expect(t('k')).toBe('k') - i18n.register('ns', 'zh', { k: 'v2' }) - expect(t('k')).toBe('v2') - }) - - it('locale store is subscribable (snapshot store contract)', () => { - const i18n = new I18nService() - let notified = 0 - i18n.locale.subscribe(() => { notified += 1 }) - i18n.locale.set('en') - expect(i18n.locale.getSnapshot()).toBe('en') - expect(notified).toBe(1) - }) -}) diff --git a/packages/client/i18n/tests/invariant.spec.ts b/packages/client/i18n/tests/invariant.spec.ts deleted file mode 100644 index b992e50d52..0000000000 --- a/packages/client/i18n/tests/invariant.spec.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' -import { apply as nodeApply } from '@deepseek-ai/dsh-client-i18n' -import { apply as clientApply, COMMON_NS, I18nService, inject } from '@deepseek-ai/dsh-client-i18n/client' -import * as I18nInvariant from '@deepseek-ai/dsh-client-i18n/invariant' -import InvariantService from '@deepseek-ai/dsh-invariants' - -describe('invariant companion', () => { - it('registers under the package name with an empty installer', async () => { - const ctx = new Context() - await ctx.plugin(InvariantService, { enabled: true }) - await expect(ctx.plugin(I18nInvariant).await()).resolves.toBeDefined() - }) - - it('node-half apply is a no-op host placeholder', () => { - nodeApply() - expect(true).toBe(true) // reaching here without throw is the contract - }) - - it('client apply provides ctx.i18n seeded with the zh/en common namespace', async () => { - expect(inject).toEqual([]) - const ctx = new Context() - await ctx.plugin({ inject, apply: clientApply }).await() - const i18n = ctx.get('i18n') - expect(i18n).toBeInstanceOf(I18nService) - // Seeded dictionaries occupy the (ns, locale) seats even while empty. - expect(() => (i18n as I18nService).register(COMMON_NS, 'zh', {})).toThrow('already has locale') - expect(() => (i18n as I18nService).register(COMMON_NS, 'en', {})).toThrow('already has locale') - }) -}) diff --git a/packages/client/i18n/tsdown.config.ts b/packages/client/i18n/tsdown.config.ts deleted file mode 100644 index 1c0802be49..0000000000 --- a/packages/client/i18n/tsdown.config.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { clientBundle } from '../tsdown.client.ts' - -export default clientBundle('@deepseek-ai/dsh-client-i18n', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/client/locale/README.md b/packages/client/locale/README.md new file mode 100644 index 0000000000..078967c2cc --- /dev/null +++ b/packages/client/locale/README.md @@ -0,0 +1,16 @@ +# @deepseek-ai/dsh-client-locale + +Locale plugin: LocaleService — the browser locale preference (`zh`/`en`, persisted under `dsh.locale`, getter/setter with `locale/change` snapshots) plus the ns×locale dictionary registry (`bind(ns)`→t with a stable function identity; lookup chain active → zh → key). + +## Model Experience + +None, as the locale registry serves browser UI copy; nothing here reaches a model request. + +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + +## Known Limitations and Deferred Work + +- **Only the Settings surface is translated** — other pages keep inline copy; repo-wide extraction into dictionaries is deferred. +- **Locale switching re-renders subscribed consumers only** — sections not wired to `locale/change` keep their rendered text until remount. diff --git a/packages/client/i18n/package.json b/packages/client/locale/package.json similarity index 78% rename from packages/client/i18n/package.json rename to packages/client/locale/package.json index 3e617174bf..340efe042e 100644 --- a/packages/client/i18n/package.json +++ b/packages/client/locale/package.json @@ -1,6 +1,6 @@ { - "name": "@deepseek-ai/dsh-client-i18n", - "description": "i18n plugin: I18nService (ns x locale dictionaries, bind(ns) -> t, locale store); zh/en skeleton", + "name": "@deepseek-ai/dsh-client-locale", + "description": "Locale plugin: LocaleService (zh/en preference with getter/setter/change event + persistence; ns x locale dictionaries, bind(ns) -> t)", "version": "0.0.1", "private": true, "type": "module", @@ -28,9 +28,6 @@ "immediately": true }, "license": "BSD-3-Clause", - "dependencies": { - "@deepseek-ai/dsh-client-runtime": "workspace:^" - }, "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", "cordis": "^4.0.0-rc.7" @@ -46,5 +43,9 @@ "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" - ] + ], + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + } } diff --git a/packages/client/locale/src/client/index.ts b/packages/client/locale/src/client/index.ts new file mode 100644 index 0000000000..e01d69ea25 --- /dev/null +++ b/packages/client/locale/src/client/index.ts @@ -0,0 +1,195 @@ +/** + * Browser-side locale registry. Bound translation functions retain stable + * identity for injected consumers. + */ +import type { Context } from 'cordis' +import { en } from '../locales/en.ts' +import { zh } from '../locales/zh.ts' + +/** Translate a key with optional params. */ +export type Translate = (key: string, params?: Record) => string + +/** Locale dictionary: flat key to template string ({name} placeholders). */ +export type LocaleDict = Record + +/** Locale identifier: the two shipped locales. */ +export type LocaleId = 'zh' | 'en' + +/** One selectable locale: id plus its self-described display name. */ +export interface LocaleDefinition { + /** Locale id (persisted; the setLocale argument). */ + id: LocaleId + /** Display name in its own language (中文 / English). */ + label: string +} + +/** Immutable locale state published on every change. */ +export interface LocaleSnapshot { + /** Active locale id. */ + active: LocaleId + /** Selectable locales in display order. */ + locales: readonly LocaleDefinition[] + /** Monotonic change counter (registry or active changes). */ + revision: number +} + +declare module 'cordis' { + interface Context { + locale: LocaleService + } + interface Events { + /** + * Locale state changed (active locale switched or registry updated). + * @param snapshot - Current immutable locale snapshot. + * @mode emit + */ + 'locale/change'(snapshot: LocaleSnapshot): void + } +} + +/** Fallback locale consulted after the active locale misses (also the default). */ +export const FALLBACK_LOCALE: LocaleId = 'zh' + +/** Shared namespace for shell-level texts. */ +export const COMMON_NS = 'common' + +/** localStorage key holding the persisted locale id. */ +export const STORAGE_KEY = 'dsh.locale' + +/** The two shipped locales. */ +const LOCALES: readonly LocaleDefinition[] = Object.freeze([ + { id: 'zh', label: '中文' }, + { id: 'en', label: 'English' }, +]) + +/** + * Dictionary registry plus locale preference. Lookup chain per key: active + * locale -> zh fallback -> the key itself (missing text stays visible, fail + * loud in the UI rather than blank). Reads go through {@link getLocale}; + * writes only through {@link setLocale}; continuous sync only through the + * `locale/change` event. + */ +export class LocaleService { + private dicts = new Map>() + private bound = new Map() + private snapshot: LocaleSnapshot + private readonly ctx: Context + + /** + * @param ctx - owning context (change events are emitted on it). + */ + constructor(ctx: Context) { + this.ctx = ctx + this.snapshot = Object.freeze({ active: restorePreference(), locales: LOCALES, revision: 0 }) + } + + /** + * Read the current immutable locale snapshot. + * @returns the current snapshot (stable reference until the next change). + */ + getLocale(): LocaleSnapshot { + return this.snapshot + } + + /** + * Switch the active locale — the only preference write entry. Persists the + * id and emits `locale/change`. + * @param id - a registered locale id; unknown ids throw. + */ + setLocale(id: string): void { + const match = this.snapshot.locales.find(l => l.id === id) + if (match === undefined) throw new Error(`locale "${id}" is not registered`) + if (this.snapshot.active === match.id) return + this.snapshot = Object.freeze({ + active: match.id, + locales: this.snapshot.locales, + revision: this.snapshot.revision + 1, + }) + persistPreference(match.id) + this.ctx.emit('locale/change', this.snapshot) + } + + /** + * Register a dictionary for a namespace and locale. Duplicate (ns, locale) + * throws (single occupant; a namespace's texts have one owner). + * @param ns - namespace. + * @param locale - locale tag (zh/en to start). + * @param dict - dictionary. + * @returns disposer (idempotent). + */ + register(ns: string, locale: string, dict: LocaleDict): () => void { + let locales = this.dicts.get(ns) + if (!locales) { + locales = new Map() + this.dicts.set(ns, locales) + } + if (locales.has(locale)) throw new Error(`locale namespace "${ns}" already has locale "${locale}"`) + locales.set(locale, dict) + return () => { + const owner = this.dicts.get(ns) + if (owner?.get(locale) === dict) owner.delete(locale) + } + } + + /** + * Bind a namespace to a translate function. The returned reference is + * stable per namespace (repeat binds return the same function), so it can + * ride inject surfaces without breaking memoization. + * @param ns - namespace. + * @returns the translate function (reads the active locale at call time). + */ + bind(ns: string): Translate { + let t = this.bound.get(ns) + if (!t) { + t = (key, params) => this.translate(ns, key, params) + this.bound.set(ns, t) + return t + } + return t + } + + private translate(ns: string, key: string, params?: Record): string { + const locales = this.dicts.get(ns) + const template = locales?.get(this.snapshot.active)?.[key] + ?? locales?.get(FALLBACK_LOCALE)?.[key] + ?? key + if (!params) return template + return template.replace(/\{(\w+)\}/g, (match, name: string) => + name in params ? String(params[name]) : match) + } +} + +/** Read the persisted locale id; unknown or unreadable values fall back to zh. */ +function restorePreference(): LocaleId { + try { + const stored = globalThis.localStorage?.getItem(STORAGE_KEY) + if (stored === 'zh' || stored === 'en') return stored + } catch { + // Storage access can throw (privacy mode); the default below covers it. + } + return FALLBACK_LOCALE +} + +/** Persist the locale id; storage failures are non-fatal (preference resets next boot). */ +function persistPreference(id: LocaleId): void { + try { + globalThis.localStorage?.setItem(STORAGE_KEY, id) + } catch { + // Storage access can throw (privacy mode / quota); the preference simply + // does not survive the session. + } +} + +/** Required services (none; the loader passes the export surface as an object plugin). */ +export const inject: string[] = [] + +/** + * Client plugin body: provide the locale service with base dictionaries. + * @param ctx - client cordis context. + */ +export function apply(ctx: Context): void { + const locale = new LocaleService(ctx) + locale.register(COMMON_NS, 'zh', zh) + locale.register(COMMON_NS, 'en', en) + ctx.provide('locale', locale) +} diff --git a/packages/client/i18n/src/index.ts b/packages/client/locale/src/index.ts similarity index 61% rename from packages/client/i18n/src/index.ts rename to packages/client/locale/src/index.ts index e759f1edc1..c220373932 100644 --- a/packages/client/i18n/src/index.ts +++ b/packages/client/locale/src/index.ts @@ -1,4 +1,4 @@ /** Host loader entry for the browser implementation exported from `./client`. */ -/** Host plugin body — no host-side behavior for the i18n plugin. */ +/** Host plugin body — no host-side behavior for the locale plugin. */ export function apply(): void {} diff --git a/packages/client/i18n/src/invariant.ts b/packages/client/locale/src/invariant.ts similarity index 86% rename from packages/client/i18n/src/invariant.ts rename to packages/client/locale/src/invariant.ts index b2c196e93b..96c94018f0 100644 --- a/packages/client/i18n/src/invariant.ts +++ b/packages/client/locale/src/invariant.ts @@ -1,16 +1,16 @@ /** - * Package-owned invariant companion for `@deepseek-ai/dsh-client-i18n`. - * @module @deepseek-ai/dsh-client-i18n/invariant + * Package-owned invariant companion for `@deepseek-ai/dsh-client-locale`. + * @module @deepseek-ai/dsh-client-locale/invariant */ /* jscpd:ignore-start */ import type { Context } from 'cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' -const PACKAGE_NAME = '@deepseek-ai/dsh-client-i18n' +const PACKAGE_NAME = '@deepseek-ai/dsh-client-locale' /** Cordis companion plugin name. */ -export const name = 'client-i18n-invariant' +export const name = 'client-locale-invariant' /** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] diff --git a/packages/client/i18n/src/locales/en.ts b/packages/client/locale/src/locales/en.ts similarity index 100% rename from packages/client/i18n/src/locales/en.ts rename to packages/client/locale/src/locales/en.ts diff --git a/packages/client/i18n/src/locales/zh.ts b/packages/client/locale/src/locales/zh.ts similarity index 100% rename from packages/client/i18n/src/locales/zh.ts rename to packages/client/locale/src/locales/zh.ts diff --git a/packages/client/locale/tests/invariant.spec.ts b/packages/client/locale/tests/invariant.spec.ts new file mode 100644 index 0000000000..e9f4fdee36 --- /dev/null +++ b/packages/client/locale/tests/invariant.spec.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { apply as nodeApply } from '@deepseek-ai/dsh-client-locale' +import { apply as clientApply, COMMON_NS, LocaleService, inject } from '@deepseek-ai/dsh-client-locale/client' +import * as LocaleInvariant from '@deepseek-ai/dsh-client-locale/invariant' +import InvariantService from '@deepseek-ai/dsh-invariants' + +describe('invariant companion', () => { + it('registers under the package name with an empty installer', async () => { + const ctx = new Context() + await ctx.plugin(InvariantService, { enabled: true }) + await expect(ctx.plugin(LocaleInvariant).await()).resolves.toBeDefined() + }) + + it('node-half apply is a no-op host placeholder', () => { + nodeApply() + expect(true).toBe(true) // reaching here without throw is the contract + }) + + it('client apply provides ctx.locale seeded with the zh/en common namespace', async () => { + expect(inject).toEqual([]) + const ctx = new Context() + await ctx.plugin({ inject, apply: clientApply }).await() + const locale = ctx.get('locale') + expect(locale).toBeInstanceOf(LocaleService) + // Seeded dictionaries occupy the (ns, locale) seats even while empty. + expect(() => (locale as LocaleService).register(COMMON_NS, 'zh', {})).toThrow('already has locale') + expect(() => (locale as LocaleService).register(COMMON_NS, 'en', {})).toThrow('already has locale') + }) +}) diff --git a/packages/client/locale/tests/locale.spec.ts b/packages/client/locale/tests/locale.spec.ts new file mode 100644 index 0000000000..750c2452e8 --- /dev/null +++ b/packages/client/locale/tests/locale.spec.ts @@ -0,0 +1,90 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import type { LocaleSnapshot } from '@deepseek-ai/dsh-client-locale/client' +import { LocaleService, STORAGE_KEY } from '@deepseek-ai/dsh-client-locale/client' + +const make = (): { ctx: Context; svc: LocaleService; events: LocaleSnapshot[] } => { + const ctx = new Context() + const events: LocaleSnapshot[] = [] + ctx.on('locale/change', (snapshot) => { events.push(snapshot) }) + return { ctx, svc: new LocaleService(ctx), events } +} + +describe('LocaleService', () => { + beforeEach(() => { + localStorage.clear() + }) + + it('translates through the active-locale -> zh -> key chain', () => { + const { svc } = make() + svc.register('ns', 'zh', { hello: '你好', onlyZh: '仅中文' }) + svc.register('ns', 'en', { hello: 'Hello' }) + const t = svc.bind('ns') + expect(svc.getLocale().active).toBe('zh') + expect(t('hello')).toBe('你好') + svc.setLocale('en') + expect(t('hello')).toBe('Hello') + expect(t('onlyZh')).toBe('仅中文') + expect(t('missing.key')).toBe('missing.key') + }) + + it('interpolates {name} params and leaves unknown placeholders intact', () => { + const { svc } = make() + svc.register('ns', 'zh', { greet: '你好,{name}!第 {n} 次', partial: '{known} 与 {unknown}' }) + const t = svc.bind('ns') + expect(t('greet', { name: '世界', n: 2 })).toBe('你好,世界!第 2 次') + expect(t('partial', { known: 'A' })).toBe('A 与 {unknown}') + }) + + it('bind returns a stable per-namespace function identity', () => { + const { svc } = make() + expect(svc.bind('a')).toBe(svc.bind('a')) + expect(svc.bind('a')).not.toBe(svc.bind('b')) + }) + + it('rejects duplicate (ns, locale) and disposer only removes its own dict', () => { + const { svc } = make() + const dispose = svc.register('ns', 'zh', { k: 'v1' }) + expect(() => svc.register('ns', 'zh', { k: 'v2' })).toThrow('already has locale') + dispose() + const t = svc.bind('ns') + expect(t('k')).toBe('k') + svc.register('ns', 'zh', { k: 'v2' }) + expect(t('k')).toBe('v2') + dispose() + expect(t('k')).toBe('v2') + }) + + it('setLocale persists, republishes an immutable snapshot, and no-ops on same value', () => { + const { svc, events } = make() + svc.setLocale('en') + expect(svc.getLocale().active).toBe('en') + expect(localStorage.getItem(STORAGE_KEY)).toBe('en') + expect(events).toHaveLength(1) + expect(events[0]).toBe(svc.getLocale()) + expect(events[0]!.revision).toBe(1) + svc.setLocale('en') + expect(events).toHaveLength(1) + }) + + it('throws on unknown locale ids', () => { + const { svc } = make() + expect(() => { svc.setLocale('fr') }).toThrow('not registered') + }) + + it('restores a persisted locale and falls back to zh on garbage', () => { + localStorage.setItem(STORAGE_KEY, 'en') + expect(make().svc.getLocale().active).toBe('en') + localStorage.setItem(STORAGE_KEY, 'fr') + expect(make().svc.getLocale().active).toBe('zh') + }) + + it('exposes the two shipped locales with self-described labels', () => { + const { svc } = make() + expect(svc.getLocale().locales).toEqual([ + { id: 'zh', label: '中文' }, + { id: 'en', label: 'English' }, + ]) + }) +}) diff --git a/packages/client/i18n/tsconfig.json b/packages/client/locale/tsconfig.json similarity index 87% rename from packages/client/i18n/tsconfig.json rename to packages/client/locale/tsconfig.json index 63eb9779ca..51f9171643 100644 --- a/packages/client/i18n/tsconfig.json +++ b/packages/client/locale/tsconfig.json @@ -11,9 +11,6 @@ { "path": "../../../vendor/cordis" }, - { - "path": "../runtime" - }, { "path": "../../support/invariants" } diff --git a/packages/client/locale/tsdown.config.ts b/packages/client/locale/tsdown.config.ts new file mode 100644 index 0000000000..2141970e5d --- /dev/null +++ b/packages/client/locale/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-locale', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/client/tsdown.client.ts b/packages/client/tsdown.client.ts index 56bdd29320..9b93feae8b 100644 --- a/packages/client/tsdown.client.ts +++ b/packages/client/tsdown.client.ts @@ -33,7 +33,7 @@ export const INLINE_SAFE = /^@deepseek-ai\/dsh-(host-apiproxy|session|llm|tools| * Documented TEMPORARY exemption, not a platform module (hence not in * platform.ts): the snapshot-store engine (createSnapshotStore/defineStore/ * shallowEqual) lives in runtime pending its promotion-time rehoming, and - * five importers (i18n, ui-layout, ui-conversation ×3) ride this single + * five importers (locale, ui-layout, ui-conversation ×3) ride this single * exemption. At runtime the lazy CJS table answers the require natively: * runtime is an immediately-tier row, its factory is registered before any * dependent bundle materializes. TODO(webload/store-rehome): remove with the diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index d7306a6810..95fde30b5a 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -24,7 +24,7 @@ }, "dshClient": { "inject": [ - "@deepseek-ai/dsh-client-i18n", + "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-ui-layout" ], diff --git a/packages/client/ui-conversation/tests/apply-inject.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.spec.tsx index 21fe70499e..b040e847e0 100644 --- a/packages/client/ui-conversation/tests/apply-inject.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.spec.tsx @@ -99,7 +99,7 @@ async function bench() { ctx.provide('workspaces', workspacesFake) const layoutFake = { openDetails: vi.fn(), closeDetails: vi.fn() } ctx.provide('layout', layoutFake) - ctx.provide('i18n', { bind: () => (key: string) => key }) + ctx.provide('locale', { bind: () => (key: string) => key }) // The AppFrame role: the three conversation-package slots must be declared // by a live entry before apply can contribute into them (the stand-in diff --git a/packages/client/ui-conversation/tests/chat-apply.spec.tsx b/packages/client/ui-conversation/tests/chat-apply.spec.tsx index dcfe2d7de7..31b251843a 100644 --- a/packages/client/ui-conversation/tests/chat-apply.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-apply.spec.tsx @@ -48,7 +48,7 @@ async function bench() { sendSession: vi.fn(), }) ctx.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) - ctx.provide('i18n', { bind: () => (key: string) => key }) + ctx.provide('locale', { bind: () => (key: string) => key }) // Declared by ui-layout's root entry in production; a stand-in root // occupant declares them here so the contributions land (it consumes diff --git a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx index bfae743cff..959ee871d8 100644 --- a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx @@ -93,7 +93,7 @@ async function bench(nodes: ToolResultNode[]) { sendSession: vi.fn(), }) ctx.provide('layout', layout) - ctx.provide('i18n', { bind: () => (key: string) => key }) + ctx.provide('locale', { bind: () => (key: string) => key }) slots.install(createSlotRenderer()) slots.register({ @@ -212,7 +212,7 @@ describe('registrant load-order seam', () => { sendSession: vi.fn(), }) ctx.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) - ctx.provide('i18n', { bind: () => (key: string) => key }) + ctx.provide('locale', { bind: () => (key: string) => key }) slots.register({ name: 'root', children: { diff --git a/packages/client/ui-conversation/tsconfig.json b/packages/client/ui-conversation/tsconfig.json index 7755337093..902c1c7f9f 100644 --- a/packages/client/ui-conversation/tsconfig.json +++ b/packages/client/ui-conversation/tsconfig.json @@ -27,7 +27,7 @@ "path": "../ui-layout" }, { - "path": "../i18n" + "path": "../locale" }, { "path": "../../support/invariants" diff --git a/packages/client/ui-layout/README.md b/packages/client/ui-layout/README.md index 4a9f5e9bd5..a8c3bc0bd6 100644 --- a/packages/client/ui-layout/README.md +++ b/packages/client/ui-layout/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-client-ui-layout -Shell plugin: three-column AppFrame (drag handles and concession chain) plus the `ctx.layout` panel-geometry service; it registers into the runtime-owned `root` slot and declares `sidebar`, `conversation`, `details`, and `conversation.empty`. The sidebar is fixed-width (only details shrinks, then auto-closes); a closed sidebar retains a 56px control rail while details closes to zero width. +Shell plugin: three-column AppFrame (drag handles and concession chain) plus the `ctx.layout` panel-geometry service; it registers into the runtime-owned `root` slot and declares `sidebar`, `conversation`, `details`, and `conversation.empty`. The sidebar is fixed-width (only details shrinks, then auto-closes); a closed sidebar retains a 56px control rail while details closes to zero width. The package also seats the theme presenter: it consumes resolved `ctx.theme` snapshots and projects them onto `document.body` (`data-ds-dark-theme` from the active color scheme plus the theme's alias tokens as inline variables). AppFrame reads the runtime Session projection: `baselinesReady` selects loading, a page-local `SessionListState.intent` selects the empty composer, and a connected Session renders through `SessionProvider`. The conversation and empty-state owner shares are empty; each registrant obtains business data from standard hooks and actions from its own inject face. The sidebar owner share contains only `collapsed` and `width`; navigation actions belong to sidebar's own injected service face. diff --git a/packages/client/ui-layout/package.json b/packages/client/ui-layout/package.json index fd3e691375..17459ee5d2 100644 --- a/packages/client/ui-layout/package.json +++ b/packages/client/ui-layout/package.json @@ -24,7 +24,8 @@ }, "dshClient": { "inject": [ - "@deepseek-ai/dsh-client-runtime" + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-ui-theme" ], "platform": "web" }, @@ -36,6 +37,7 @@ "peerDependencies": { "@deepseek-ai/dsh-client-runtime": "^0.0.1", "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", + "@deepseek-ai/dsh-client-ui-theme": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "cordis": "^4.0.0-rc.7", "react": "^18.2.0" @@ -43,6 +45,7 @@ "devDependencies": { "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-client-ui-theme": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", "cordis": "^4.0.0-rc.7", diff --git a/packages/client/ui-layout/src/client/index.ts b/packages/client/ui-layout/src/client/index.ts index 6ecb6f6b1d..39fee1fb52 100644 --- a/packages/client/ui-layout/src/client/index.ts +++ b/packages/client/ui-layout/src/client/index.ts @@ -4,13 +4,16 @@ * four child slots (declaration = exclusive render authority), seats the * layout store (panel geometry), and wires the panel-action service face. * ctx.layout is the cross-plugin panel-action seam; navigation state lives - * with the runtime sessions service. + * with the runtime sessions service. A second effect seats the theme + * presenter, which projects ctx.theme snapshots onto document.body. */ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import type {} from '@deepseek-ai/dsh-client-ui-theme/client' import type { PanelActions } from './service.ts' import { AppFrame } from './AppFrame.tsx' import { createLayoutStore } from './stores.ts' import { LayoutService } from './service.ts' +import { ThemePresenter } from './theme-presenter.ts' // Contract surface only (export-convergence rule: cross-package consumers // keep a symbol exported; test-only/package-internal symbols live off /src). @@ -62,7 +65,7 @@ export interface DetailsOwnerProps {} export interface EmptyOwnerProps { children?: never } /** Required services (cordis fiber inject — the loader passes the whole export surface as an object plugin). */ -export const inject = ['slots'] +export const inject = ['slots', 'theme'] /** * Client plugin body: provide ctx.layout, then one register() call — AppFrame @@ -98,4 +101,16 @@ export function apply(ctx: ClientContext): void { void disposeService() } }, 'ui-layout: service + root registration') + + // Theme presentation: pure DOM writes from resolved snapshots — initial + // state through the getter once, then event-driven only; no React path. + ctx.effect(() => { + const presenter = new ThemePresenter() + presenter.apply(ctx.theme.getTheme()) + const off = ctx.on('theme/change', snapshot => { presenter.apply(snapshot) }) + return () => { + off() + presenter.dispose() + } + }, 'ui-layout: theme presenter') } diff --git a/packages/client/ui-layout/src/client/theme-presenter.ts b/packages/client/ui-layout/src/client/theme-presenter.ts new file mode 100644 index 0000000000..958f2fd93e --- /dev/null +++ b/packages/client/ui-layout/src/client/theme-presenter.ts @@ -0,0 +1,43 @@ +/** + * Global theme DOM applier: projects the resolved ThemeSnapshot onto + * document.body — the `data-ds-dark-theme` palette switch plus the active + * theme's alias-token overrides as inline CSS variables. Pure DOM writes, no + * React involvement; the presenter only ever retracts what it wrote itself, + * so foreign body attributes and inline styles survive apply/dispose. + */ +import type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client' + +/** Body attribute selecting the dark base palette in the token stylesheets. */ +export const DARK_ATTRIBUTE = 'data-ds-dark-theme' + +/** Applies theme snapshots to document.body; one instance per plugin fiber. */ +export class ThemePresenter { + /** Token names this presenter wrote in the last apply (its retraction set). */ + private appliedTokens: string[] = [] + + /** + * Project a snapshot onto the body: switch the palette attribute from + * `active.colorScheme` (never the id — `system` is resolved upstream) and + * replace the previously applied token variables with `active.tokens`. + * @param snapshot - resolved theme snapshot from ctx.theme. + */ + apply(snapshot: ThemeSnapshot): void { + const body = document.body + if (snapshot.active.colorScheme === 'dark') body.setAttribute(DARK_ATTRIBUTE, '') + else body.removeAttribute(DARK_ATTRIBUTE) + for (const name of this.appliedTokens) body.style.removeProperty(name) + this.appliedTokens = [] + for (const [name, value] of Object.entries(snapshot.active.tokens)) { + body.style.setProperty(name, value) + this.appliedTokens.push(name) + } + } + + /** Retract everything this presenter wrote: the palette attribute and all applied token variables. */ + dispose(): void { + const body = document.body + body.removeAttribute(DARK_ATTRIBUTE) + for (const name of this.appliedTokens) body.style.removeProperty(name) + this.appliedTokens = [] + } +} diff --git a/packages/client/ui-layout/tests/apply.spec.ts b/packages/client/ui-layout/tests/apply.spec.ts index bed0987f62..29ad4b3058 100644 --- a/packages/client/ui-layout/tests/apply.spec.ts +++ b/packages/client/ui-layout/tests/apply.spec.ts @@ -9,6 +9,7 @@ import { Context } from 'cordis' import { describe, expect, it, vi } from 'vitest' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' +import { apply as themeApply, inject as themeInject, ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client' import { apply, inject, LayoutService } from '@deepseek-ai/dsh-client-ui-layout/client' import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-layout' import * as invariant from '@deepseek-ai/dsh-client-ui-layout/invariant' @@ -16,13 +17,14 @@ import * as invariant from '@deepseek-ai/dsh-client-ui-layout/invariant' async function bench() { const ctx = new Context() const slotsFiber = ctx.plugin(SlotsService) + await ctx.plugin({ inject: themeInject, apply: themeApply }).await() await slotsFiber.await() return { ctx, slots: ctx.get('slots') as SlotsService } } describe('ui-layout client apply', () => { it('declares its service dependencies', () => { - expect(inject).toEqual(['slots']) + expect(inject).toEqual(['slots', 'theme']) }) it('provides ctx.layout and registers AppFrame into root with the four child declarations', async () => { @@ -53,6 +55,23 @@ describe('ui-layout client apply', () => { expect(actions.toggleSidebar).toHaveBeenCalledOnce() }) + it('theme presenter applies the initial snapshot, follows theme/change, and unwinds on dispose', async () => { + const { ctx } = await bench() + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + // Initial getter application: jsdom has no matchMedia, system resolves light. + expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(false) + const theme = ctx.get('theme') as ThemeService + theme.setTheme('dark') + expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(true) + await fiber.dispose() + expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(false) + // Listener is off: further theme changes no longer reach the body. + theme.setTheme('light') + theme.setTheme('dark') + expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(false) + }) + it('teardown unwinds the service, the root registration, and the child declarations', async () => { const { ctx, slots } = await bench() const fiber = ctx.plugin({ inject: [...inject], apply }) diff --git a/packages/client/ui-layout/tests/theme-presenter.spec.ts b/packages/client/ui-layout/tests/theme-presenter.spec.ts new file mode 100644 index 0000000000..ced83a379e --- /dev/null +++ b/packages/client/ui-layout/tests/theme-presenter.spec.ts @@ -0,0 +1,56 @@ +// @vitest-environment jsdom +// ThemePresenter behavior account: the palette attribute follows +// active.colorScheme only, token variables replace the previous apply's set, +// and dispose retracts everything the presenter wrote. + +import { beforeEach, describe, expect, it } from 'vitest' +import type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client' +import { DARK_ATTRIBUTE, ThemePresenter } from '@deepseek-ai/dsh-client-ui-layout/src/client/theme-presenter.ts' + +function snapshot(colorScheme: 'light' | 'dark', tokens: Record = {}): ThemeSnapshot { + // The presenter must key off colorScheme, not the id — keep them distinct. + const active = { id: `${colorScheme}-test`, colorScheme, tokens } + return { preference: colorScheme, active, themes: [active], revision: 1 } +} + +beforeEach(() => { + document.body.removeAttribute(DARK_ATTRIBUTE) + document.body.removeAttribute('style') +}) + +describe('ThemePresenter', () => { + it('light scheme leaves the dark attribute absent', () => { + const presenter = new ThemePresenter() + presenter.apply(snapshot('light')) + expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(false) + }) + + it('dark scheme sets the attribute; switching back to light removes it', () => { + const presenter = new ThemePresenter() + presenter.apply(snapshot('dark')) + expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(true) + presenter.apply(snapshot('light')) + expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(false) + }) + + it('applies tokens as inline variables and clears the previous set on theme change', () => { + const presenter = new ThemePresenter() + presenter.apply(snapshot('dark', { '--dsw-alias-bg': '#111', '--dsw-alias-fg': '#eee' })) + expect(document.body.style.getPropertyValue('--dsw-alias-bg')).toBe('#111') + expect(document.body.style.getPropertyValue('--dsw-alias-fg')).toBe('#eee') + presenter.apply(snapshot('light', { '--dsw-alias-bg': '#fff' })) + expect(document.body.style.getPropertyValue('--dsw-alias-bg')).toBe('#fff') + // The old theme's extra variable is gone, not merged. + expect(document.body.style.getPropertyValue('--dsw-alias-fg')).toBe('') + }) + + it('dispose removes the attribute and every applied variable, sparing foreign inline styles', () => { + document.body.style.setProperty('--foreign', 'kept') + const presenter = new ThemePresenter() + presenter.apply(snapshot('dark', { '--dsw-alias-bg': '#111' })) + presenter.dispose() + expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(false) + expect(document.body.style.getPropertyValue('--dsw-alias-bg')).toBe('') + expect(document.body.style.getPropertyValue('--foreign')).toBe('kept') + }) +}) diff --git a/packages/client/ui-layout/tsconfig.json b/packages/client/ui-layout/tsconfig.json index c82482294b..4401e8e4db 100644 --- a/packages/client/ui-layout/tsconfig.json +++ b/packages/client/ui-layout/tsconfig.json @@ -14,6 +14,9 @@ { "path": "../ui-slots" }, + { + "path": "../ui-theme" + }, { "path": "../ui-primitives" }, diff --git a/packages/client/ui-primitives/src/Menu.module.css b/packages/client/ui-primitives/src/Menu.module.css index b55abe094d..3cabe258bc 100644 --- a/packages/client/ui-primitives/src/Menu.module.css +++ b/packages/client/ui-primitives/src/Menu.module.css @@ -30,11 +30,13 @@ /* Portal mode: fixed in the viewport, coordinates supplied inline from the * anchor rect (side/align resolved in JS, the in-place offset rules above - * don't apply). */ + * don't apply). Portaled lists must layer above modal overlays (z 1000) — + * an anchor inside a dialog still expects its menu on top. */ .portal { position: fixed; top: auto; left: auto; + z-index: 1100; } /* Open above the anchor (empty-state workspace chip: figma 122:9481). */ diff --git a/packages/client/ui-primitives/src/icons/index.tsx b/packages/client/ui-primitives/src/icons/index.tsx index 80bb3848dd..4c2083bae1 100644 --- a/packages/client/ui-primitives/src/icons/index.tsx +++ b/packages/client/ui-primitives/src/icons/index.tsx @@ -583,3 +583,90 @@ export const IconTreeCorner8x10 = ({ size = 10, className }: IconProps) => ( ) + +/** ic_ds_light_outline_16 */ +export const IconLightOutline16 = ({ size = 16, className }: IconProps) => ( + + + + + + + + + + + +) + +/** ic_ds_dark_outline_16 */ +export const IconDarkOutline16 = ({ size = 16, className }: IconProps) => ( + + + +) + +/** ic_ds_followsystem_outline_16 */ +export const IconFollowsystemOutline16 = ({ size = 16, className }: IconProps) => ( + + + + +) + +/** ic_ds_data_outline_16 */ +export const IconDataOutline16 = ({ size = 16, className }: IconProps) => ( + + + + +) + +/** ic_ds_List_Pen_outline_16 */ +export const IconListPenOutline16 = ({ size = 16, className }: IconProps) => ( + + + + + + + + +) diff --git a/packages/client/ui-primitives/tests/icons.spec.tsx b/packages/client/ui-primitives/tests/icons.spec.tsx index 74bf5a9678..281124b8d5 100644 --- a/packages/client/ui-primitives/tests/icons.spec.tsx +++ b/packages/client/ui-primitives/tests/icons.spec.tsx @@ -14,8 +14,8 @@ const icons = Object.fromEntries( const iconNames = Object.keys(icons) describe('ic_ds_ icon set', () => { - it('exports the full P-I set (43 deepsuite + 7 figma extracts)', () => { - expect(iconNames.length).toBe(50) + it('exports the full P-I set (43 deepsuite + 12 figma extracts)', () => { + expect(iconNames.length).toBe(55) }) it.each(iconNames)('%s renders an svg with currentColor fills and no hardcoded palette', name => { diff --git a/packages/client/ui-settings-general/README.md b/packages/client/ui-settings-general/README.md new file mode 100644 index 0000000000..435dd8c1b4 --- /dev/null +++ b/packages/client/ui-settings-general/README.md @@ -0,0 +1,15 @@ +# @deepseek-ai/dsh-client-ui-settings-general + +General settings section plugin: registers the `general` entry into `settings.section`. Language (中文/English) and Appearance (Light/Dark/System) are live preferences wired to `ctx.locale` / `ctx.theme`; Permission and Tool Call rows are visual skeletons with no write surface. + +## Model Experience + +None, as the section renders browser preference UI; nothing here reaches a model request. + +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + +## Known Limitations and Deferred Work + +- **Permission and Tool Call are display skeletons** — the backing host services and RPC methods do not exist yet; the controls are disabled and write nothing. diff --git a/packages/client/ui-settings-general/package.json b/packages/client/ui-settings-general/package.json new file mode 100644 index 0000000000..9b3d83e472 --- /dev/null +++ b/packages/client/ui-settings-general/package.json @@ -0,0 +1,70 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-settings-general", + "description": "General settings section plugin: Language and Appearance preferences (live), Permission and Tool Call skeleton rows", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dshClient": { + "inject": [ + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-ui-settings", + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-ui-theme" + ], + "platform": "web" + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "BSD-3-Clause", + "dependencies": { + "clsx": "^2.0.0" + }, + "peerDependencies": { + "@deepseek-ai/dsh-client-runtime": "^0.0.1", + "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", + "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.7", + "react": "^18.2.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-client-ui-theme": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@types/react": "~18.3.1", + "cordis": "^4.0.0-rc.7", + "react": "^18.2.0" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/client.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ] +} diff --git a/packages/client/ui-settings-general/src/client/GeneralSection.module.css b/packages/client/ui-settings-general/src/client/GeneralSection.module.css new file mode 100644 index 0000000000..dffafc419d --- /dev/null +++ b/packages/client/ui-settings-general/src/client/GeneralSection.module.css @@ -0,0 +1,131 @@ +/* General section rows (figma 501:29983 'Options'): four groups, 16px + * vertical padding each, hairline separator under all but the last. The + * shell's content column owns the outer horizontal padding. */ + +.section { + display: flex; + flex-direction: column; + width: 100%; +} + +/* Title + trailing control row (figma 'Setting-Cell': gap 8, pad 16/0). */ +.row { + display: flex; + align-items: center; + gap: 8px; + padding: 16px 0; + border-bottom: 1px solid var(--dsw-alias-border-l2); +} + +/* Title + full-width body group (figma 'Frame 2117131229': column, gap 8). */ +.group { + display: flex; + flex-direction: column; + gap: 8px; + padding: 16px 0; + border-bottom: 1px solid var(--dsw-alias-border-l2); +} + +.last { + border-bottom: none; +} + +/* Leading text column (figma 'Frame 2036083120': gap 4, pad-right 48). */ +.rowText { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; + padding-right: 48px; +} + +.title { + font-size: 14px; + font-weight: 400; + line-height: 22px; + color: var(--dsw-alias-label-primary); +} + +.desc { + font-size: 12px; + font-weight: 400; + line-height: 18px; + color: var(--dsw-alias-label-tertiary); +} + +/* Selector pill (figma 'Selector': h36 r18, fill #F5F6F7, pad 0/14, gap 12). */ +.selector { + display: inline-flex; + align-items: center; + gap: 12px; + height: 36px; + padding: 0 14px; + border: none; + border-radius: 18px; + background: var(--dsw-alias-bg-module-platform); + font: inherit; + font-size: 14px; + line-height: 22px; + color: var(--dsw-alias-label-primary); + cursor: pointer; +} + +.selector:disabled { + cursor: default; +} + +.chevron { + flex: none; +} + +/* Cube rows share an 8px gap; cubes stretch to equal height. */ +.cubeRow { + display: flex; + align-items: stretch; + gap: 8px; +} + +/* Tool Call mode cube (figma '.Selector Cube' 418w r16; horizontal inset = + * outer pad 4 + inner .Menu_cell pad 10, vertical = inner pad 8). */ +.modeCube { + box-sizing: border-box; + width: 418px; + display: flex; + flex-direction: column; + justify-content: center; + gap: 2px; + padding: 8px 14px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 16px; + background: transparent; + text-align: left; +} + +/* Appearance cube (figma '.Selector Cube' 276x82 r16, pad 20/32, centered + * icon-over-label column, gap 4). */ +.themeCube { + box-sizing: border-box; + width: 276px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + padding: 20px 32px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 16px; + background: transparent; + font: inherit; + font-size: 14px; + line-height: 22px; + color: var(--dsw-alias-label-primary); + cursor: pointer; +} + +/* Selected cube: #F5F6F7 fill + #ADB2B8 border (static token — the bluish-400 + * step has no alias-layer name). */ +.selected { + background: var(--dsw-alias-bg-module-platform); + border-color: var(--dsw-static-neutral-bluish-400); +} diff --git a/packages/client/ui-settings-general/src/client/GeneralSection.tsx b/packages/client/ui-settings-general/src/client/GeneralSection.tsx new file mode 100644 index 0000000000..d2947c0435 --- /dev/null +++ b/packages/client/ui-settings-general/src/client/GeneralSection.tsx @@ -0,0 +1,118 @@ +/** + * General settings section: Permission and Tool Call skeleton rows (visual + * only, no interaction), live Language and Appearance preference rows wired + * through the injected setLocale/setTheme callbacks and the snapshot-mirror + * store. Figma: Settings > Content > Options (501:29983). + */ +import { useState } from 'react' +import clsx from 'clsx' +import { + IconChevronDownOutline14, IconDarkOutline16, IconFollowsystemOutline16, IconLightOutline16, + Menu, +} from '@deepseek-ai/dsh-client-ui-primitives' +import type { GeneralSectionComponentProps, ThemePreferenceId } from './contract.ts' +import css from './GeneralSection.module.css' + +/** Appearance cube order and icons (figma 501:30015-30017: Light, Dark, System). */ +const THEME_CUBES: readonly { id: ThemePreferenceId; labelKey: string; Icon: typeof IconLightOutline16 }[] = [ + { id: 'light', labelKey: 'appearance.light', Icon: IconLightOutline16 }, + { id: 'dark', labelKey: 'appearance.dark', Icon: IconDarkOutline16 }, + { id: 'system', labelKey: 'appearance.system', Icon: IconFollowsystemOutline16 }, +] + +/** + * Render the General section content column. + * @param props - composed slot props (contract.ts). + * @returns the section element tree. + */ +export function GeneralSection(props: GeneralSectionComponentProps) { + const { t, setLocale, setTheme, useStore } = props + const localeActive = useStore(s => s.localeActive) + const localeOptions = useStore(s => s.localeOptions) + const themePreference = useStore(s => s.themePreference) + const [languageOpen, setLanguageOpen] = useState(false) + + const activeLocaleLabel = localeOptions.find(l => l.id === localeActive)?.label ?? localeActive + + return ( +
+ {/* Permission (skeleton): disabled selector pill. */} +
+
+
{t('permission.title')}
+
{t('permission.desc')}
+
+ +
+ + {/* Tool Call (skeleton): schema cube pinned selected, code cube unselected. */} +
+
{t('toolcall.title')}
+
+
+
{t('toolcall.schema.title')}
+
{t('toolcall.schema.desc')}
+
+
+
{t('toolcall.code.title')}
+
{t('toolcall.code.desc')}
+
+
+
+ + {/* Language: selector pill opens the locale menu. */} +
+
+
{t('language.title')}
+
+ { setLanguageOpen(false) }} + items={localeOptions.map(l => ({ id: l.id, label: l.label }))} + selectedId={localeActive} + onSelect={(id) => { + setLocale(id) + setLanguageOpen(false) + }} + align="end" + portal + anchor={( + + )} + /> +
+ + {/* Appearance: three preference cubes; selection follows the persisted + * preference, never the resolved active theme. */} +
+
{t('appearance.title')}
+
+ {THEME_CUBES.map(({ id, labelKey, Icon }) => ( + + ))} +
+
+
+ ) +} diff --git a/packages/client/ui-settings-general/src/client/contract.ts b/packages/client/ui-settings-general/src/client/contract.ts new file mode 100644 index 0000000000..912d925615 --- /dev/null +++ b/packages/client/ui-settings-general/src/client/contract.ts @@ -0,0 +1,66 @@ +/** + * General section component contract: the slot-store state shape, the + * injected business face, and the composed props type. The component imports + * only from here; service snapshot shapes are mirrored as plain rows so the + * presentation layer stays decoupled from the locale/theme packages. + */ +import type { PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' +// Type-only: pulls the shell's SlotMap merge (the 'settings.section' entry). +import type {} from '@deepseek-ai/dsh-client-ui-settings/client' +import type { createGeneralSettingsStore } from './store.ts' + +/** One selectable locale row projected into the store (id + self-described label). */ +export interface LocaleOptionRow { + /** Locale id (the setLocale argument). */ + id: string + /** Display name in its own language (中文 / English). */ + label: string +} + +/** Theme preference union mirrored from the theme service snapshot. */ +export type ThemePreferenceId = 'light' | 'dark' | 'system' + +/** + * Store state: mirrors of the locale/theme service snapshots, written only by + * the plugin's apply-world change listeners (components have no write path — + * preference writes go through the injected callbacks to the services, and + * the resulting change events flow back into this mirror). + */ +export interface GeneralSettingsState { + /** Active locale id. */ + localeActive: string + /** Selectable locales in display order. */ + localeOptions: LocaleOptionRow[] + /** Locale service revision (re-renders translated copy on dictionary/locale changes); -1 until first sync. */ + localeRevision: number + /** Persisted theme preference (selection state reads this, never the resolved active theme). */ + themePreference: ThemePreferenceId + /** Theme service revision; -1 until first sync. */ + themeRevision: number +} + +/** + * Registrant-private injected share of the General section (assembled in + * apply): the namespace-bound translate function (stable identity — re-render + * on locale change comes from the store revision, not from `t`) and the two + * preference write callbacks. + */ +export interface GeneralSectionInjected { + /** Translate a `settings.general` dictionary key to the active-locale text. */ + t: (key: string) => string + /** Switch the active locale (a registered locale id). */ + setLocale: (id: string) => void + /** Switch the theme preference. */ + setTheme: (id: ThemePreferenceId) => void +} + +/** Store handle type for the props share (type-only; the factory stays internal to apply and tests). */ +export type GeneralSettingsStoreHandle = ReturnType + +/** + * Full component props of the General section: the section owner share + * (empty marker) plus the store share and the injected face. No child slots + * are declared; menu open state is component-local viewing state. + */ +export type GeneralSectionComponentProps = + PropsRuntime<'settings.section'> & PropsStore & GeneralSectionInjected diff --git a/packages/client/ui-settings-general/src/client/index.ts b/packages/client/ui-settings-general/src/client/index.ts new file mode 100644 index 0000000000..0e59ce2116 --- /dev/null +++ b/packages/client/ui-settings-general/src/client/index.ts @@ -0,0 +1,111 @@ +/** + * General settings section plugin, browser half. Registers the `general` + * entry into the shell-declared `settings.section` list slot; Language and + * Appearance are live preferences projected from ctx.locale / ctx.theme + * through this entry's slot store. Export discipline: packages/client/AGENTS.md. + */ +import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots' +// Type-only: pulls the shell's SlotMap merge (the 'settings.section' entry). +import type {} from '@deepseek-ai/dsh-client-ui-settings/client' +// Type-only: the locale/theme Context+Events merges and snapshot shapes. +import type { LocaleSnapshot } from '@deepseek-ai/dsh-client-locale/client' +import type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client' +import type { GeneralSectionInjected } from './contract.ts' +import { createGeneralSettingsStore } from './store.ts' +import { en, zh } from './locales.ts' +import { GeneralSection } from './GeneralSection.tsx' + +export type { + GeneralSectionComponentProps, GeneralSectionInjected, GeneralSettingsState, + GeneralSettingsStoreHandle, LocaleOptionRow, ThemePreferenceId, +} from './contract.ts' + +/** Dictionary namespace owned by this section (also the nav-label reference prefix). */ +const NS = 'settings.general' + +/** + * Required services (cordis fiber inject). The target slot is declared by + * ui-settings' apply, whose activation order relative to this one is NOT + * constrained; registration goes through declaration-aware deferral. + */ +export const inject = ['slots', 'locale', 'theme'] + +/** + * Register the `settings.general` dictionaries and the General section entry + * once the `settings.section` declaration is on the ledger. The slot store + * mirrors the locale/theme snapshots: change listeners attach here in apply, + * write through the bound actions captured at inject time, and the inject + * factory re-syncs from the getters so no event is lost between registration + * and first render (the store's revision guard drops stale duplicates). + * @param ctx - client root context. + */ +export function apply(ctx: ClientContext): void { + ctx.effect(() => { + const disposeZh = ctx.locale.register(NS, 'zh', zh) + const disposeEn = ctx.locale.register(NS, 'en', en) + return () => { + disposeZh() + disposeEn() + } + }, 'ui-settings-general: dictionaries') + + const store = createGeneralSettingsStore() + let bound: BoundActions | undefined + + const syncLocale = (snapshot: LocaleSnapshot): void => { + bound?.syncLocale( + snapshot.active, + snapshot.locales.map(l => ({ id: l.id, label: l.label })), + snapshot.revision, + ) + } + const syncTheme = (snapshot: ThemeSnapshot): void => { + bound?.syncTheme(snapshot.preference, snapshot.revision) + } + ctx.on('locale/change', syncLocale) + ctx.on('theme/change', syncTheme) + + const injected = (actions: BoundActions): GeneralSectionInjected => { + bound = actions + syncLocale(ctx.locale.getLocale()) + syncTheme(ctx.theme.getTheme()) + return { + t: ctx.locale.bind(NS), + setLocale: (id) => { ctx.locale.setLocale(id) }, + setTheme: (id) => { ctx.theme.setTheme(id) }, + } + } + + ctx.effect(() => { + let dispose: (() => void) | undefined + const register = (): void => { + dispose = ctx.slots.register({ + name: 'settings.section', + id: 'general', + order: 0, + label: ctx.locale.bind(NS)('nav'), + store, + inject: injected, + }, GeneralSection) + } + const tryRegister = (): void => { + if (ctx.slots.spec('settings.section') === undefined || dispose !== undefined) return + register() + } + // Nav labels are registrant-localized: re-register on locale change so + // the ledger carries fresh text (the version bump re-renders the shell). + const offLocale = ctx.on('locale/change', () => { + if (dispose === undefined) return + dispose() + register() + }) + const unsubscribe = ctx.slots.subscribe('settings.section', () => { tryRegister() }) + tryRegister() + return () => { + offLocale() + unsubscribe() + dispose?.() + } + }, 'ui-settings-general: section registration') +} diff --git a/packages/client/ui-settings-general/src/client/locales.ts b/packages/client/ui-settings-general/src/client/locales.ts new file mode 100644 index 0000000000..ff10ce5c37 --- /dev/null +++ b/packages/client/ui-settings-general/src/client/locales.ts @@ -0,0 +1,42 @@ +/** + * `settings.general` namespace dictionaries. Skeleton-row technical copy + * (Read only / Schema mode / Code mode and their descriptions) is shared + * verbatim across locales per the Figma design. + */ +import type { LocaleDict } from '@deepseek-ai/dsh-client-locale/client' + +const SHARED = { + 'permission.value': 'Read only', + 'toolcall.schema.title': 'Schema mode', + 'toolcall.schema.desc': 'Traditional function calling — invoke tools one at a time', + 'toolcall.code.title': 'Code mode', + 'toolcall.code.desc': 'Chain multiple tools with code — multi-step orchestration', +} satisfies LocaleDict + +/** Simplified Chinese dictionary. */ +export const zh: LocaleDict = { + ...SHARED, + 'nav': '通用设置', + 'permission.title': '权限', + 'permission.desc': '选择默认权限模式', + 'toolcall.title': '工具调用', + 'language.title': '语言', + 'appearance.title': '外观', + 'appearance.light': '浅色', + 'appearance.dark': '深色', + 'appearance.system': '跟随系统', +} + +/** English dictionary. */ +export const en: LocaleDict = { + ...SHARED, + 'nav': 'General', + 'permission.title': 'Permission', + 'permission.desc': 'Choose default permission mode', + 'toolcall.title': 'Tool Call', + 'language.title': 'Language', + 'appearance.title': 'Appearance', + 'appearance.light': 'Light', + 'appearance.dark': 'Dark', + 'appearance.system': 'System', +} diff --git a/packages/client/ui-settings-general/src/client/store.ts b/packages/client/ui-settings-general/src/client/store.ts new file mode 100644 index 0000000000..121e3f52a9 --- /dev/null +++ b/packages/client/ui-settings-general/src/client/store.ts @@ -0,0 +1,43 @@ +/** + * General section slot store: locale/theme snapshot mirrors. The plugin + * creates the handle at apply time (identity follows the fiber) and its + * change listeners are the only writers; components read via props.useStore. + */ +import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-runtime/client' +import type { GeneralSettingsState, LocaleOptionRow, ThemePreferenceId } from './contract.ts' + +/** Declared action shape used to give the exported factory a stable return type. */ +type GeneralSettingsActions = { + syncLocale: (draft: GeneralSettingsState, active: string, options: LocaleOptionRow[], revision: number) => void + syncTheme: (draft: GeneralSettingsState, preference: ThemePreferenceId, revision: number) => void +} + +/** + * Declares the General section state and write surface. Revisions start at -1 + * so the apply-time initial sync (revision 0) always lands as a change. + * @returns the store handle. + */ +export function createGeneralSettingsStore(): EngineStoreHandle { + return defineStore({ + init: (): GeneralSettingsState => ({ + localeActive: '', + localeOptions: [], + localeRevision: -1, + themePreference: 'system', + themeRevision: -1, + }), + actions: { + syncLocale: (d, active: string, options: LocaleOptionRow[], revision: number) => { + if (revision <= d.localeRevision) return + d.localeActive = active + d.localeOptions = options + d.localeRevision = revision + }, + syncTheme: (d, preference: ThemePreferenceId, revision: number) => { + if (revision <= d.themeRevision) return + d.themePreference = preference + d.themeRevision = revision + }, + }, + }) +} diff --git a/packages/client/ui-settings-general/src/css-modules.d.ts b/packages/client/ui-settings-general/src/css-modules.d.ts new file mode 100644 index 0000000000..bc5e482353 --- /dev/null +++ b/packages/client/ui-settings-general/src/css-modules.d.ts @@ -0,0 +1,6 @@ +declare module '*.module.css' { + const classes: Record + export default classes +} + +declare module '*.css' diff --git a/packages/client/ui-settings-general/src/index.ts b/packages/client/ui-settings-general/src/index.ts new file mode 100644 index 0000000000..94b9bdf674 --- /dev/null +++ b/packages/client/ui-settings-general/src/index.ts @@ -0,0 +1,4 @@ +/** Host loader entry for the browser implementation exported from `./client`. */ + +/** Host plugin body — no host-side behavior for the general settings plugin. */ +export function apply(): void {} diff --git a/packages/client/ui-settings-general/src/invariant.ts b/packages/client/ui-settings-general/src/invariant.ts new file mode 100644 index 0000000000..a40cc3cc00 --- /dev/null +++ b/packages/client/ui-settings-general/src/invariant.ts @@ -0,0 +1,32 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-settings-general`. + * @module @deepseek-ai/dsh-client-ui-settings-general/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-settings-general' + +/** Cordis companion plugin name. */ +export const name = 'client-ui-settings-general-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: a section plugin projecting two service change events + * into its own slot store — it emits no cordis events of its own and owns no + * cross-plugin mutable relation; snapshot/store agreement is asserted by this + * package's behavior specs. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/client/ui-settings-general/tsconfig.json b/packages/client/ui-settings-general/tsconfig.json new file mode 100644 index 0000000000..9b2cc5b838 --- /dev/null +++ b/packages/client/ui-settings-general/tsconfig.json @@ -0,0 +1,36 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../ui-slots" + }, + { + "path": "../ui-primitives" + }, + { + "path": "../runtime" + }, + { + "path": "../ui-settings" + }, + { + "path": "../locale" + }, + { + "path": "../ui-theme" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/client/ui-settings-general/tsdown.config.ts b/packages/client/ui-settings-general/tsdown.config.ts new file mode 100644 index 0000000000..bf67c4f10f --- /dev/null +++ b/packages/client/ui-settings-general/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-ui-settings-general', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/client/ui-settings-models/README.md b/packages/client/ui-settings-models/README.md new file mode 100644 index 0000000000..c96e2843e4 --- /dev/null +++ b/packages/client/ui-settings-models/README.md @@ -0,0 +1,15 @@ +# @deepseek-ai/dsh-client-ui-settings-models + +Models settings section plugin: registers the `models` nav entry into `settings.section` with an intentionally empty content column — model management lands in a later phase. + +## Model Experience + +None, as the section renders an empty browser UI column; nothing here reaches a model request. + +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + +## Known Limitations and Deferred Work + +- **Content column is empty by design** — provider list, editing form, and activation flow are deferred until the model-management service exists. diff --git a/packages/client/ui-settings-models/package.json b/packages/client/ui-settings-models/package.json new file mode 100644 index 0000000000..da8fa8c18d --- /dev/null +++ b/packages/client/ui-settings-models/package.json @@ -0,0 +1,63 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-settings-models", + "description": "Models settings section plugin: nav entry with an empty content column (model management lands later)", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dshClient": { + "inject": [ + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-ui-settings", + "@deepseek-ai/dsh-client-locale" + ], + "platform": "web" + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-client-runtime": "^0.0.1", + "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.7", + "react": "^18.2.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@types/react": "~18.3.1", + "cordis": "^4.0.0-rc.7", + "react": "^18.2.0" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/client.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ] +} diff --git a/packages/client/ui-settings-models/src/client/ModelsSection.tsx b/packages/client/ui-settings-models/src/client/ModelsSection.tsx new file mode 100644 index 0000000000..ee33b916cb --- /dev/null +++ b/packages/client/ui-settings-models/src/client/ModelsSection.tsx @@ -0,0 +1,13 @@ +/** + * Models settings section: an intentionally empty content column — the nav + * entry exists so the section slot composition is visible; model management + * lands in a later phase. + */ + +/** + * Render the (empty) Models section content column. + * @returns null — no content this phase. + */ +export function ModelsSection() { + return null +} diff --git a/packages/client/ui-settings-models/src/client/index.ts b/packages/client/ui-settings-models/src/client/index.ts new file mode 100644 index 0000000000..8ad2ebb750 --- /dev/null +++ b/packages/client/ui-settings-models/src/client/index.ts @@ -0,0 +1,63 @@ +/** + * Models settings section plugin, browser half. Registers the `models` nav + * entry into the shell-declared `settings.section` list slot; the content + * column is intentionally empty until model management lands. Export + * discipline: packages/client/AGENTS.md. + */ +import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +// Type-only: pulls the shell's SlotMap merge (the 'settings.section' entry). +import type {} from '@deepseek-ai/dsh-client-ui-settings/client' +// Type-only: pulls the locale plugin's Context merge (ctx.locale). +import type {} from '@deepseek-ai/dsh-client-locale/client' +import { ModelsSection } from './ModelsSection.tsx' + +/** + * Required services (cordis fiber inject). The target slot is declared by + * ui-settings' apply, whose activation order relative to this one is NOT + * constrained; registration goes through declaration-aware deferral. + */ +export const inject = ['slots', 'locale'] + +/** + * Register the Models section once the `settings.section` declaration is on + * the ledger. + * @param ctx - client root context. + */ +export function apply(ctx: ClientContext): void { + ctx.effect(() => { + const disposers = [ + ctx.locale.register('settings.models', 'zh', { nav: '模型' }), + ctx.locale.register('settings.models', 'en', { nav: 'Models' }), + ] + return () => { for (const dispose of disposers) dispose() } + }, 'ui-settings-models: nav copy dictionaries') + ctx.effect(() => { + let dispose: (() => void) | undefined + const register = (): void => { + dispose = ctx.slots.register({ + name: 'settings.section', + id: 'models', + order: 10, + label: ctx.locale.bind('settings.models')('nav'), + }, ModelsSection) + } + const tryRegister = (): void => { + if (ctx.slots.spec('settings.section') === undefined || dispose !== undefined) return + register() + } + // Nav labels are registrant-localized: re-register on locale change so + // the ledger carries fresh text (the version bump re-renders the shell). + const offLocale = ctx.on('locale/change', () => { + if (dispose === undefined) return + dispose() + register() + }) + const unsubscribe = ctx.slots.subscribe('settings.section', () => { tryRegister() }) + tryRegister() + return () => { + offLocale() + unsubscribe() + dispose?.() + } + }, 'ui-settings-models: section registration') +} diff --git a/packages/client/ui-settings-models/src/css-modules.d.ts b/packages/client/ui-settings-models/src/css-modules.d.ts new file mode 100644 index 0000000000..bc5e482353 --- /dev/null +++ b/packages/client/ui-settings-models/src/css-modules.d.ts @@ -0,0 +1,6 @@ +declare module '*.module.css' { + const classes: Record + export default classes +} + +declare module '*.css' diff --git a/packages/client/ui-settings-models/src/index.ts b/packages/client/ui-settings-models/src/index.ts new file mode 100644 index 0000000000..da3060fe59 --- /dev/null +++ b/packages/client/ui-settings-models/src/index.ts @@ -0,0 +1,4 @@ +/** Host loader entry for the browser implementation exported from `./client`. */ + +/** Host plugin body — no host-side behavior for the models settings plugin. */ +export function apply(): void {} diff --git a/packages/client/ui-settings-models/src/invariant.ts b/packages/client/ui-settings-models/src/invariant.ts new file mode 100644 index 0000000000..9ffdc668d7 --- /dev/null +++ b/packages/client/ui-settings-models/src/invariant.ts @@ -0,0 +1,31 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-settings-models`. + * @module @deepseek-ai/dsh-client-ui-settings-models/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-settings-models' + +/** Cordis companion plugin name. */ +export const name = 'client-ui-settings-models-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: a nav-entry-only section plugin rendering a fixed + * empty content column — it emits no cordis events and owns no cross-plugin + * mutable relation. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/client/ui-settings-models/tsconfig.json b/packages/client/ui-settings-models/tsconfig.json new file mode 100644 index 0000000000..dde94c20af --- /dev/null +++ b/packages/client/ui-settings-models/tsconfig.json @@ -0,0 +1,30 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../ui-slots" + }, + { + "path": "../runtime" + }, + { + "path": "../ui-settings" + }, + { + "path": "../locale" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/client/ui-settings-models/tsdown.config.ts b/packages/client/ui-settings-models/tsdown.config.ts new file mode 100644 index 0000000000..7a2688a097 --- /dev/null +++ b/packages/client/ui-settings-models/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-ui-settings-models', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/client/ui-settings/README.md b/packages/client/ui-settings/README.md new file mode 100644 index 0000000000..64250c7917 --- /dev/null +++ b/packages/client/ui-settings/README.md @@ -0,0 +1,15 @@ +# @deepseek-ai/dsh-client-ui-settings + +Settings shell plugin: the sidebar trigger row and the modal settings panel occupying `sidebar.settings`; declares the `settings.section` list slot that section plugins contribute pages into. The shell projects the section ledger into navigation and renders only the active section (`only` filtering). + +## Model Experience + +None, as the settings shell serves browser UI composition; nothing here reaches a model request. + +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + +## Known Limitations and Deferred Work + +- **Panel is browser-preference scope only** — host-side settings (permission mode, tool-call mode) render as skeletons in the General section; no RPC surface exists yet. diff --git a/packages/client/ui-settings/package.json b/packages/client/ui-settings/package.json new file mode 100644 index 0000000000..8f71d90c8b --- /dev/null +++ b/packages/client/ui-settings/package.json @@ -0,0 +1,68 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-settings", + "description": "Settings shell plugin: sidebar trigger + modal panel occupying sidebar.settings; declares the settings.section list slot", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dshClient": { + "inject": [ + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-ui-sidebar", + "@deepseek-ai/dsh-client-locale" + ], + "platform": "web" + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "BSD-3-Clause", + "dependencies": { + "clsx": "^2.0.0" + }, + "peerDependencies": { + "@deepseek-ai/dsh-client-runtime": "^0.0.1", + "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", + "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.7", + "react": "^18.2.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@types/react": "~18.3.1", + "cordis": "^4.0.0-rc.7", + "react": "^18.2.0" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/client.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ] +} diff --git a/packages/client/ui-settings/src/client/SettingsRoot.module.css b/packages/client/ui-settings/src/client/SettingsRoot.module.css new file mode 100644 index 0000000000..bf557c0a04 --- /dev/null +++ b/packages/client/ui-settings/src/client/SettingsRoot.module.css @@ -0,0 +1,192 @@ +/* Settings shell (figma 501:29904 mask context / 501:29947 panel): sidebar + foot trigger row + centered 1080x700 modal panel. The trigger reproduces + the former sidebar foot geometry (49px wide row / 36px rail circle); the + panel is a two-column layout — 188px nav rail + content column with a + 54px header and the 24px-padded options area. */ + +/* Trigger row (former sidebar foot, figma 133:7668): 49px hover pill. */ +.trigger { + flex: none; + display: flex; + align-items: center; + gap: 8px; + width: 100%; + height: 49px; + margin: 8px 0 0; + padding: 0 2px 0 6px; + border: none; + border-radius: 12px; + background: transparent; + cursor: pointer; + overflow: hidden; + color: var(--dsw-alias-label-primary); + font-family: inherit; + font-size: 14px; +} + +.trigger:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + +/* Rail trigger: the same 36x36 circle box as the other rail controls. */ +.trigger.rail { + width: 36px; + height: 36px; + margin: 18px 0 10px; + justify-content: center; + gap: 0; + padding: 0; + border-radius: 50%; +} + +.triggerLabel { + overflow: hidden; + white-space: nowrap; +} + +/* Full-viewport layer (figma Mask 501:29946 #000@24%, no blur). */ +.overlay { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; +} + +.mask { + position: absolute; + inset: 0; + background: var(--dsw-alias-bg-mask-1); +} + +/* Panel (figma Settings 501:29947): 1080x700, r24, white, lv3 shadow + (figma effects match --dsw-shadow-lv3 exactly). */ +.panel { + position: relative; + z-index: 1; + display: flex; + width: 1080px; + height: 700px; + max-width: calc(100vw - 48px); + max-height: calc(100vh - 48px); + border-radius: 24px; + overflow: hidden; + background: var(--dsw-alias-bg-layer-1); + box-shadow: var(--dsw-shadow-lv3); +} + +/* Nav rail (figma .Setting-nav 501:29958): 188 wide, pad (12,22,12,0), + gap 18, no own fill — the panel white shows through. */ +.nav { + flex: none; + display: flex; + flex-direction: column; + gap: 18px; + width: 188px; + padding: 22px 12px 0; + box-sizing: border-box; +} + +/* Title row (figma 501:29959): 16/500 lh24, 12px side padding. */ +.navTitle { + padding: 0 12px; + font-size: 16px; + line-height: 24px; + font-weight: 500; + color: var(--dsw-alias-label-primary); +} + +/* Cell stack (figma 501:29961): gap 4. */ +.navList { + display: flex; + flex-direction: column; + gap: 4px; +} + +/* Nav cell (figma .Setting-nav-cell 501:29962): 164x40, r12, pad + (12,9,16,9), gap 8; label 14/400 lh22; selected fill #EBEEF2. */ +.navCell { + display: flex; + align-items: center; + gap: 8px; + height: 40px; + padding: 9px 16px 9px 12px; + box-sizing: border-box; + border: none; + border-radius: 12px; + background: transparent; + cursor: pointer; + font-family: inherit; + font-size: 14px; + line-height: 22px; + font-weight: 400; + color: var(--dsw-alias-label-primary); + text-align: left; +} + +.navCell:hover { + background: var(--dsw-specific-sidebar-nav-item-hover); +} + +.navCell.active { + background: var(--dsw-specific-sidebar-nav-item-active); +} + +.navIcon { + flex: none; +} + +.navLabel { + flex: 1; + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +/* Content column (figma Content 501:29980): header + options. */ +.content { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; +} + +/* Header (figma .Header 501:29981): h54, pad (10,20,14,8), close right. */ +.header { + flex: none; + display: flex; + align-items: flex-start; + justify-content: flex-end; + height: 54px; + padding: 20px 14px 8px 10px; + box-sizing: border-box; +} + +/* Close button (figma .Icon_container 501:29982): 28x28, r28, 14px glyph. */ +.close { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border: none; + border-radius: 28px; + background: transparent; + cursor: pointer; + color: var(--dsw-alias-label-primary); +} + +.close:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + +/* Options area (figma Options 501:29983): pad (24,0,24,8), scrolls. */ +.options { + flex: 1; + min-height: 0; + padding: 0 24px 8px; + overflow-y: auto; +} diff --git a/packages/client/ui-settings/src/client/SettingsRoot.tsx b/packages/client/ui-settings/src/client/SettingsRoot.tsx new file mode 100644 index 0000000000..3a27acdf6c --- /dev/null +++ b/packages/client/ui-settings/src/client/SettingsRoot.tsx @@ -0,0 +1,125 @@ +/** + * Settings shell root: the sidebar-foot trigger row plus the centered modal + * panel (figma 501:29947, 1080x700) with the section nav rail. Modal open + * state and the active section id are component-local viewing state; the + * section ledger arrives through the injected face (nav labels are + * registrant-localized — the shell owns no locale/theme subscription). + */ +import { useCallback, useEffect, useRef, useState } from 'react' +import clsx from 'clsx' +import { + IconCloseOutline16, IconDataOutline16, IconSettingsOutline14, IconSettingsOutline16, +} from '@deepseek-ai/dsh-client-ui-primitives' +import type { SettingsRootComponentProps } from './contract/slots.ts' +import css from './SettingsRoot.module.css' + +/** Nav glyph by section id; unknown ids fall back to the settings gear. */ +function navIcon(id: string) { + if (id === 'models') return + return +} + +type PanelProps = { + translate: SettingsRootComponentProps['translate'] + rows: ReturnType + renderSlot: SettingsRootComponentProps['renderSlot'] + onClose: () => void +} + +/** + * The modal layer: full-viewport mask + centered panel. Close paths: the + * header button, a mask click, and document-level Escape (mounted only while + * open, so the listener lifetime is the panel's). + */ +function SettingsPanel({ translate, rows, renderSlot, onClose }: PanelProps) { + // Local selection; entries can unmount underneath it, so the render-time + // projection falls back to the first row when the id is gone. + const [activeId, setActiveId] = useState(undefined) + const active = rows.find((r) => r.id === activeId)?.id ?? rows[0]?.id + + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose() + } + document.addEventListener('keydown', onKeyDown) + return () => { document.removeEventListener('keydown', onKeyDown) } + }, [onClose]) + + // Baseline focus management: entering the dialog lands on the close button. + const closeButton = useRef(null) + useEffect(() => { closeButton.current?.focus() }, []) + + return ( +
+ + ) +} + +/** + * Render the settings trigger and panel. + * @param props - composed slot props (contract/slots.ts). + * @returns the settings shell element tree. + */ +export function SettingsRoot(props: SettingsRootComponentProps) { + const { wide, translate, subscribeSections, sectionsVersion, sections, renderSlot } = props + const [open, setOpen] = useState(false) + const close = useCallback(() => { setOpen(false) }, []) + + // The ledger tick is the shell's only subscription: sections re-register + // with freshly localized labels on locale change, so the version bump also + // re-renders the shell's own translate()-read chrome copy. + // State = ledger version: same-version notifications dedupe to no render. + const [, setSectionsRev] = useState(() => sectionsVersion()) + useEffect( + () => subscribeSections(() => { setSectionsRev(sectionsVersion()) }), + [subscribeSections, sectionsVersion], + ) + const rows = sections() + + return ( + <> + + {open && } + + ) +} diff --git a/packages/client/ui-settings/src/client/contract/slots.ts b/packages/client/ui-settings/src/client/contract/slots.ts new file mode 100644 index 0000000000..10e4e0ecbe --- /dev/null +++ b/packages/client/ui-settings/src/client/contract/slots.ts @@ -0,0 +1,62 @@ +/** + * Settings shell slot contract: the shell occupies the sidebar-owned + * `sidebar.settings` hole and declares the `settings.section` list slot that + * section plugins (General, Models, …) contribute pages into. + */ +import type { PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +// Type-only: pulls ui-sidebar's SlotMap merge (the 'sidebar.settings' entry) +// into every program that sees this contract. +import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface SlotMap { + /** + * One settings page per list entry. Registrant options carry the nav + * identity: `id` (section key, drives `only` filtering), `order` (nav + * position), `label` (registrant-localized display text — the registrant + * re-registers with fresh text on locale change, so the shell never + * subscribes locale/theme state; the ledger bump doubles as the shell's + * re-render trigger). Sections render inside the panel content column. + */ + 'settings.section': { kind: 'list'; scope: 'root'; owner: SettingsSectionOwnerProps } + } +} + +/** + * Owner share of a settings section entry. The shell owns modal visibility + * and navigation; sections receive nothing but the render site (their data + * arrives through their own inject faces and stores). + */ +export interface SettingsSectionOwnerProps { + /** Marker field: section owner props are intentionally empty for now. */ + children?: never +} + +/** + * Registrant-private injected share of the settings shell (assembled in + * apply): locale-resolved nav labels come through `translate`. + */ +export type SettingsRootInjected = { + /** + * Resolve a ":" locale reference to the active-locale text — + * shell chrome copy only (trigger/title/close); nav labels arrive already + * localized. Read at render time; the locale-change re-render rides the + * section ledger bump, not a shell-owned subscription. + */ + translate: (ref: string) => string + /** Read the settings.section ledger version (nav invalidation). */ + sectionsVersion: () => number + /** Subscribe to settings.section ledger changes. */ + subscribeSections: (listener: () => void) => () => void + /** Project the settings.section ledger into nav rows (id/order/label). */ + sections: () => readonly { id: string; order: number; label: string }[] +} + +/** + * Full component props of the settings shell root: the sidebar owner share + * (wide/rail state) plus the declared section render share and the injected + * face. No store is registered — modal open state and active section id are + * component-local viewing state. + */ +export type SettingsRootComponentProps = + PropsRuntime<'sidebar.settings'> & PropsRenderSlots<'settings.section'> & SettingsRootInjected diff --git a/packages/client/ui-settings/src/client/index.ts b/packages/client/ui-settings/src/client/index.ts new file mode 100644 index 0000000000..d5135f373c --- /dev/null +++ b/packages/client/ui-settings/src/client/index.ts @@ -0,0 +1,66 @@ +/** + * Settings shell plugin, browser half. Occupies the sidebar-owned + * `sidebar.settings` hole with the trigger row + modal panel, declares the + * `settings.section` list slot, and projects that ledger into the panel + * navigation. Export discipline: packages/client/AGENTS.md. + */ +import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +// Type-only: pulls the locale plugin's Context/Events merges (ctx.locale, +// 'locale/change') into this program. +import type {} from '@deepseek-ai/dsh-client-locale/client' +import type { SettingsRootInjected } from './contract/slots.ts' +import { SettingsRoot } from './SettingsRoot.tsx' + +export type { SettingsRootComponentProps, SettingsRootInjected, SettingsSectionOwnerProps } from './contract/slots.ts' + +/** + * Required services (cordis fiber inject). The target slot is declared by + * ui-sidebar's apply, whose activation order relative to this one is NOT + * constrained (dshClient.inject edges are informational); registration goes + * through declaration-aware deferral. + */ +export const inject = ['slots', 'locale'] + +/** + * Register the settings shell into `sidebar.settings` once the declaration is + * on the ledger. + * @param ctx - client root context. + */ +export function apply(ctx: ClientContext): void { + ctx.effect(() => { + const disposers = [ + ctx.locale.register('settings', 'zh', { trigger: '设置', title: '设置', close: '关闭' }), + ctx.locale.register('settings', 'en', { trigger: 'Settings', title: 'Settings', close: 'Close' }), + ] + return () => { for (const dispose of disposers) dispose() } + }, 'ui-settings: shell copy dictionaries') + const injected = (): SettingsRootInjected => ({ + translate: (ref) => { + const colon = ref.indexOf(':') + if (colon === -1) return ref + return ctx.locale.bind(ref.slice(0, colon))(ref.slice(colon + 1)) + }, + sectionsVersion: () => ctx.slots.getVersion('settings.section'), + subscribeSections: (listener) => ctx.slots.subscribe('settings.section', listener), + sections: () => ctx.slots.entries('settings.section') + .map(e => ({ id: e.options.id ?? '', order: e.options.order ?? 0, label: e.options.label ?? '' })) + .sort((a, b) => a.order - b.order), + }) + ctx.effect(() => { + let dispose: (() => void) | undefined + const tryRegister = (): void => { + if (ctx.slots.spec('sidebar.settings') === undefined || dispose !== undefined) return + dispose = ctx.slots.register({ + name: 'sidebar.settings', + children: { 'settings.section': { kind: 'list', scope: 'root' } }, + inject: injected, + }, SettingsRoot) + } + const unsubscribe = ctx.slots.subscribe('sidebar.settings', () => { tryRegister() }) + tryRegister() + return () => { + unsubscribe() + dispose?.() + } + }, 'ui-settings: shell registration') +} diff --git a/packages/client/ui-settings/src/css-modules.d.ts b/packages/client/ui-settings/src/css-modules.d.ts new file mode 100644 index 0000000000..bc5e482353 --- /dev/null +++ b/packages/client/ui-settings/src/css-modules.d.ts @@ -0,0 +1,6 @@ +declare module '*.module.css' { + const classes: Record + export default classes +} + +declare module '*.css' diff --git a/packages/client/ui-settings/src/index.ts b/packages/client/ui-settings/src/index.ts new file mode 100644 index 0000000000..52fe4c43e6 --- /dev/null +++ b/packages/client/ui-settings/src/index.ts @@ -0,0 +1,4 @@ +/** Host loader entry for the browser implementation exported from `./client`. */ + +/** Host plugin body — no host-side behavior for the settings shell plugin. */ +export function apply(): void {} diff --git a/packages/client/ui-settings/src/invariant.ts b/packages/client/ui-settings/src/invariant.ts new file mode 100644 index 0000000000..53d7fb066a --- /dev/null +++ b/packages/client/ui-settings/src/invariant.ts @@ -0,0 +1,32 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-settings`. + * @module @deepseek-ai/dsh-client-ui-settings/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-settings' + +/** Cordis companion plugin name. */ +export const name = 'client-ui-settings-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: a presentation shell projecting the settings.section + * ledger into navigation — it emits no cordis events and owns no cross-plugin + * mutable relation; slot declaration/registration conflicts already fail loud + * in the slot core at load time. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/client/ui-settings/tsconfig.json b/packages/client/ui-settings/tsconfig.json new file mode 100644 index 0000000000..db90b908bf --- /dev/null +++ b/packages/client/ui-settings/tsconfig.json @@ -0,0 +1,33 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../ui-slots" + }, + { + "path": "../ui-primitives" + }, + { + "path": "../runtime" + }, + { + "path": "../ui-sidebar" + }, + { + "path": "../locale" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/client/ui-settings/tsdown.config.ts b/packages/client/ui-settings/tsdown.config.ts new file mode 100644 index 0000000000..ba06fdc7c9 --- /dev/null +++ b/packages/client/ui-settings/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-ui-settings', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/client/ui-sidebar/README.md b/packages/client/ui-sidebar/README.md index d8c2f61ee0..e42b226904 100644 --- a/packages/client/ui-sidebar/README.md +++ b/packages/client/ui-sidebar/README.md @@ -4,7 +4,9 @@ Sidebar plugin: real Host Workspaces in stable Host order, each containing its ` New Session starts the runtime's page-local frontend Session Intent; a real Workspace's "+" starts one targeted to that Workspace. The Workspace header "+" opens ui-workspace's shared picker, whose selection also targets a frontend Session. A Workspace Intent does not appear in the sidebar. -`SidebarRootComponentProps` composes the layout owner share, the global `useSessions` and `useWorkspaces` hooks, the declared `sidebar.workspace` child slot, and injected `startSession`, `open`, and sidebar-toggle callbacks. There is no plugin store: `deriveGroups` consumes object-layer snapshots and component-local expansion/search state. +`SidebarRootComponentProps` composes the layout owner share, the global `useSessions` and `useWorkspaces` hooks, the declared `sidebar.workspace` and `sidebar.settings` child slots, and injected `startSession`, `open`, and sidebar-toggle callbacks. There is no plugin store: `deriveGroups` consumes object-layer snapshots and component-local expansion/search state. + +The foot is the `sidebar.settings` seat: the sidebar renders only the bottom-pinned layout slot and shares its column state (`wide`); ui-settings registers the trigger row and settings panel there. The `/client` export surface is the plugin body (`apply`/`inject`) plus the contract types only — SidebarRoot, the row components, and the tree derivation are internal (the slot registration closes over them; tests import src paths directly). diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css index 591aef2330..20431e9d15 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css @@ -49,7 +49,7 @@ .railIn .iconButton, .railIn .newSession, .railIn .searchButton, -.railIn .foot { +.railIn .footArea { animation: rail-in 150ms var(--ds-ease-in-out) 100ms backwards; } @@ -372,45 +372,11 @@ font-size: 13px; } -/* Foot: settings entry (figma 133:7668, 49 hug): the former 18/10 vertical - margins fold into the row so the hover pill spans the full 49px. */ -.foot { +/* Foot seat: pure layout — the flex slot pinning the sidebar.settings slot + content to the column bottom. Row visuals belong to the slot occupant + (ui-settings). */ +.footArea { flex: none; - display: flex; - align-items: center; - gap: 8px; - height: 49px; - margin: 8px 0 0; /* + 49px row + root padBottom 6 keeps the old 57px band */ - padding: 0 2px 0 6px; - border-radius: 12px; - cursor: pointer; - overflow: hidden; - color: var(--dsw-alias-label-primary); -} - -.foot:hover { - background: var(--dsw-alias-interactive-bg-hover); -} - -/* Rail settings: the same 36x36 circle box as the other rail controls. */ -.collapsed .foot { - width: 36px; - height: 36px; - margin: 18px 0 10px; - justify-content: center; - gap: 0; - padding: 0; - border-radius: 50%; -} - -.footLabel { - max-width: 120px; - overflow: hidden; - white-space: nowrap; -} - -.collapsed .footLabel { - max-width: 0; } @media (prefers-reduced-motion: reduce) { @@ -419,7 +385,7 @@ .railIn .iconButton, .railIn .newSession, .railIn .searchButton, - .railIn .foot { + .railIn .footArea { transition: none; animation: none; } diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx index 931eb1ba17..490c3e7b8b 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx @@ -12,7 +12,7 @@ import clsx from 'clsx' import { BrandWordmark, FishLogo, IconCloseFill14, IconNewChatOutline16, IconPanelLeftOutline16, IconPersonalizationOutline16, - IconProjectAddOutline16, IconSearchOutline16, IconSettingsOutline14, + IconProjectAddOutline16, IconSearchOutline16, Menu, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' import type { WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client' @@ -314,9 +314,10 @@ export function SidebarRoot({ )}
-
- - {wide && Settings} + {/* Foot seat: the flex slot pinning the settings entry to the column + bottom; ui-settings occupies it with the trigger row + panel. */} +
+ {renderSlot('sidebar.settings', { wide })}
) diff --git a/packages/client/ui-sidebar/src/client/contract/slots.ts b/packages/client/ui-sidebar/src/client/contract/slots.ts index 0334ca88c9..bb7c0caf07 100644 --- a/packages/client/ui-sidebar/src/client/contract/slots.ts +++ b/packages/client/ui-sidebar/src/client/contract/slots.ts @@ -20,9 +20,24 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { * is claiming); ui-workspace registers the picker. */ 'sidebar.workspace': { kind: 'single'; scope: 'root'; owner: SidebarWorkspaceOwnerProps } + /** + * The settings seat at the sidebar foot. Declared by this package's + * 'sidebar' entry; ui-settings registers its trigger row + modal panel. + * The sidebar passes only its column state — it holds no settings state. + */ + 'sidebar.settings': { kind: 'single'; scope: 'root'; owner: SidebarSettingsOwnerProps } } } +/** + * Owner share of the sidebar settings seat: the column display state the + * occupant's trigger row must render against (wide row vs rail icon). + */ +export interface SidebarSettingsOwnerProps { + /** Whether the sidebar renders wide content (false = 56px rail). */ + wide: boolean +} + /** * Owner share of the sidebar workspace hole: popover geometry plus the * sidebar's pick semantics. The picked Host Workspace is already real; the @@ -62,8 +77,9 @@ export type SidebarRootInjected = { /** * Full component props: layout owner state/actions plus global useSessions - * and useWorkspaces, the declared Workspace picker render share, and this - * package's injected callback. No store is registered. + * and useWorkspaces, the declared child-slot render shares (Workspace picker + * and settings seat), and this package's injected callback. No store is + * registered. */ export type SidebarRootComponentProps = - PropsRuntime<'sidebar'> & PropsRenderSlots<'sidebar.workspace'> & SidebarRootInjected + PropsRuntime<'sidebar'> & PropsRenderSlots<'sidebar.workspace' | 'sidebar.settings'> & SidebarRootInjected diff --git a/packages/client/ui-sidebar/src/client/index.ts b/packages/client/ui-sidebar/src/client/index.ts index 0a1c8ebb12..ad847bb5a5 100644 --- a/packages/client/ui-sidebar/src/client/index.ts +++ b/packages/client/ui-sidebar/src/client/index.ts @@ -3,7 +3,7 @@ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' import type { SidebarRootInjected } from './contract/slots.ts' import { SidebarRoot } from './SidebarRoot.tsx' -export type { SidebarRootComponentProps, SidebarRootInjected, SidebarWorkspaceOwnerProps } from './contract/slots.ts' +export type { SidebarRootComponentProps, SidebarRootInjected, SidebarSettingsOwnerProps, SidebarWorkspaceOwnerProps } from './contract/slots.ts' /** Services required by the sidebar plugin. */ export const inject = ['slots', 'layout', 'sessions', 'workspaces'] @@ -20,9 +20,12 @@ export function apply(ctx: ClientContext): void { ctx.effect( () => ctx.slots.register({ name: 'sidebar', - // SidebarRoot owns this picker site; ui-workspace registers the shared - // picker that selects a Host Workspace for a frontend Session Intent. - children: { 'sidebar.workspace': { kind: 'single', scope: 'root' } }, + // SidebarRoot owns these sites; ui-workspace registers the shared + // picker, ui-settings registers the settings trigger + panel. + children: { + 'sidebar.workspace': { kind: 'single', scope: 'root' }, + 'sidebar.settings': { kind: 'single', scope: 'root' }, + }, inject: injectProps, }, SidebarRoot), 'ui-sidebar: slot registration', diff --git a/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx b/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx index 26adafd793..d4caa4e1be 100644 --- a/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx +++ b/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx @@ -32,16 +32,16 @@ const workspaces: WorkspaceListState = { function mount(sessionState: SessionListState = sessions) { const startSession = vi.fn() const open = vi.fn() - let pickerOwner: unknown + const owners: Record = {} const view = render( { pickerOwner = owner; return null }) as SidebarRootComponentProps['renderSlot']} + renderSlot={((key: string, owner: unknown) => { owners[key] = owner; return null }) as SidebarRootComponentProps['renderSlot']} />, ) - return { view, startSession, open, pickerOwner: () => pickerOwner } + return { view, startSession, open, pickerOwner: () => owners['sidebar.workspace'], settingsOwner: () => owners['sidebar.settings'] } } function mountSidebar({ @@ -58,14 +58,14 @@ function mountSidebar({ const startSession = vi.fn() const open = vi.fn() const toggleSidebar = vi.fn() - let pickerOwner: unknown + const owners: Record = {} let current = { sessionState, workspaceState, collapsed, width } const root = () => ( { pickerOwner = owner; return null }) as SidebarRootComponentProps['renderSlot']} + renderSlot={((key: string, owner: unknown) => { owners[key] = owner; return null }) as SidebarRootComponentProps['renderSlot']} /> ) const view = render(root()) @@ -73,7 +73,8 @@ function mountSidebar({ startSession, open, toggleSidebar, - pickerOwner: () => pickerOwner, + pickerOwner: () => owners['sidebar.workspace'], + settingsOwner: () => owners['sidebar.settings'], rerender(next: Partial) { current = { ...current, ...next } view.rerender(root()) @@ -246,6 +247,7 @@ describe('SidebarRoot', () => { it('keeps wide content during live collapse, then settles to the rail', () => { vi.useFakeTimers() const b = mountSidebar({ width: 320 }) + expect((b.settingsOwner() as { wide: boolean }).wide).toBe(true) fireEvent.click(screen.getByRole('button', { name: 'Collapse sidebar' })) expect(b.toggleSidebar).toHaveBeenCalledOnce() b.rerender({ collapsed: true, width: 56 }) @@ -253,6 +255,8 @@ describe('SidebarRoot', () => { act(() => { vi.advanceTimersByTime(150) }) expect(screen.queryByPlaceholderText('Search name, keywords...')).toBeNull() expect(screen.getByRole('button', { name: 'Open sidebar' })).toBeTruthy() + // The settings seat share tracks the settled column state. + expect((b.settingsOwner() as { wide: boolean }).wide).toBe(false) }) }) diff --git a/packages/client/ui-theme/README.md b/packages/client/ui-theme/README.md index 2ce5d9b9e6..be0b83a0d8 100644 --- a/packages/client/ui-theme/README.md +++ b/packages/client/ui-theme/README.md @@ -1,10 +1,10 @@ # @deepseek-ai/dsh-client-ui-theme -Theme plugin: ThemeService over the --dsw-* token base stylesheets (static scale + alias semantic layers); apply(id) toggles the `body[data-ds-dark-theme]` attribute, so theme switches are pure CSS cascade. Contract: api-contracts v3 §8. +Theme plugin: ThemeService over the --dsw-* token base stylesheets (static scale + alias semantic layers). The service owns the theme preference (`light`/`dark`/`system`, persisted under `dsh.theme`), resolves `system` through `prefers-color-scheme`, and publishes immutable `ThemeSnapshot`s on the `theme/change` event; it never touches the DOM — ui-layout's presenter applies the resolved snapshot (`body[data-ds-dark-theme]` + inline alias tokens). Contract: api-contracts v3 §8. ## Model Experience -None, as the theme service toggles browser CSS; nothing here reaches a model request. +None, as the theme service manages a browser preference; nothing here reaches a model request. #### KV Cache effect @@ -12,6 +12,5 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **No theme-switch control ships in P-I** — the service surface (register/apply/current) is complete but no UI owner mounts a toggle; switching happens programmatically. - **Third-party themes are a surface, not a product** — registering one means overriding same-named alias variables; no validation exists that an override set is complete. - **The token sheets are the sole color authority** — values absent from cssdesign (for example the design's #4176E6 tab blue) are deliberately not appended; the nearest semantic token wins (arbitrated 2026-07-22). diff --git a/packages/client/ui-theme/package.json b/packages/client/ui-theme/package.json index 4e601914e3..4c6ad92cd4 100644 --- a/packages/client/ui-theme/package.json +++ b/packages/client/ui-theme/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-client-ui-theme", - "description": "Theme plugin: ThemeService (apply = toggle body[data-ds-dark-theme]), --dsw-* token base stylesheets", + "description": "Theme plugin: ThemeService (light/dark/system preference, prefers-color-scheme resolution, theme/change snapshots; no DOM), --dsw-* token base stylesheets", "version": "0.0.1", "private": true, "type": "module", @@ -44,5 +44,9 @@ "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" - ] + ], + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + } } diff --git a/packages/client/ui-theme/src/client/index.ts b/packages/client/ui-theme/src/client/index.ts index d9f5b70c3b..5ba5d40b35 100644 --- a/packages/client/ui-theme/src/client/index.ts +++ b/packages/client/ui-theme/src/client/index.ts @@ -1,71 +1,196 @@ /** - * Browser theme registry over the `--dsw-*` token stylesheets. Theme changes - * update CSS variables and `body[data-ds-dark-theme]` without React renders. + * Browser theme registry over the `--dsw-*` token stylesheets. The service + * owns the theme preference (light/dark/system), resolves `system` through + * `prefers-color-scheme`, and publishes immutable snapshots; it never touches + * the DOM — ui-layout's presenter consumes the resolved snapshot. */ import type { Context } from 'cordis' /** Theme token dictionary: --dsw-alias-* overrides keyed by variable name. */ export type ThemeTokens = Record +/** Theme preference: a concrete theme id or follow-the-OS. */ +export type ThemePreference = 'light' | 'dark' | 'system' + +/** One selectable theme: id, dark/light semantics, and alias-token overrides. */ +export interface ThemeDefinition { + /** Theme id (the setTheme argument for concrete themes). */ + id: string + /** + * Which base palette this theme builds on. The presenter switches + * `body[data-ds-dark-theme]` from this field — never from the id. + */ + colorScheme: 'light' | 'dark' + /** Alias-layer overrides applied as inline CSS variables over the base palette. */ + tokens: ThemeTokens +} + +/** Immutable theme state published on every change. */ +export interface ThemeSnapshot { + /** The persisted preference (may be `system`). */ + preference: ThemePreference + /** The resolved active theme (`system` resolved via prefers-color-scheme). */ + active: ThemeDefinition + /** Registered themes in registration order. */ + themes: readonly ThemeDefinition[] + /** Monotonic change counter (registry or active changes). */ + revision: number +} + declare module 'cordis' { interface Context { theme: ThemeService } + interface Events { + /** + * Theme state changed (preference switched, registry updated, or the OS + * color scheme changed while the preference is `system`). + * @param snapshot - Current immutable theme snapshot. + * @mode emit + */ + 'theme/change'(snapshot: ThemeSnapshot): void + } } +/** localStorage key holding the persisted theme preference. */ +export const STORAGE_KEY = 'dsh.theme' + +/** Default preference when nothing (or garbage) is persisted. */ +export const DEFAULT_PREFERENCE: ThemePreference = 'system' + +const BUILTIN_THEMES: readonly ThemeDefinition[] = Object.freeze([ + Object.freeze({ id: 'light', colorScheme: 'light' as const, tokens: Object.freeze({}) }), + Object.freeze({ id: 'dark', colorScheme: 'dark' as const, tokens: Object.freeze({}) }), +]) + /** - * Theme registry and switcher. `light`/`dark` are built in (the base - * stylesheets carry both palettes; the dark palette activates via the - * body[data-ds-dark-theme] attribute). Third-party themes register alias-layer - * overrides applied as inline CSS variables on body, cascading over whichever - * base palette the attribute selects. + * Theme registry and preference owner. `light`/`dark` are built in (the base + * stylesheets carry both palettes); third-party themes register alias-layer + * overrides. Reads go through {@link getTheme}; writes only through + * {@link setTheme}; continuous sync only through the `theme/change` event. + * The service holds the `prefers-color-scheme` media query (environment + * sensing, not presentation) and re-emits when the OS scheme flips while the + * preference is `system`. */ export class ThemeService { - private themes = new Map([['light', {}], ['dark', {}]]) - private appliedTokens: ThemeTokens = {} - private active = 'light' + private readonly ctx: Context + private themes: ThemeDefinition[] = [...BUILTIN_THEMES] + private preference: ThemePreference + private revision = 0 + private snapshot: ThemeSnapshot + private readonly media: MediaQueryList | undefined /** - * Register a theme. Duplicate id throws (single occupant per id; the - * built-in pair counts). - * @param id - theme id. - * @param tokens - alias-layer overrides (variable name to value). - * @returns disposer. Disposing the active theme reverts to `light` so the - * UI never keeps tokens of an unregistered theme. + * @param ctx - owning context (change events are emitted on it; the + * media-query listener is released through ctx.effect on dispose). */ - register(id: string, tokens: ThemeTokens): () => void { - if (this.themes.has(id)) throw new Error(`theme "${id}" is already registered`) - this.themes.set(id, tokens) - return () => { - if (!this.themes.delete(id)) return - if (this.active === id) this.apply('light') + constructor(ctx: Context) { + this.ctx = ctx + this.preference = restorePreference() + this.media = globalThis.matchMedia?.('(prefers-color-scheme: dark)') + this.snapshot = this.buildSnapshot() + if (this.media !== undefined) { + const media = this.media + const onChange = (): void => { + if (this.preference !== 'system') return + this.publish() + } + ctx.effect(() => { + media.addEventListener('change', onChange) + return () => { media.removeEventListener('change', onChange) } + }, 'ui-theme: prefers-color-scheme listener') } } /** - * Activate a theme: toggle body[data-ds-dark-theme] (set only for `dark`) - * and swap the previous theme's inline token overrides for this one's. - * Unregistered id throws. - * @param id - registered theme id. + * Read the current immutable theme snapshot. + * @returns the current snapshot (stable reference until the next change). */ - apply(id: string): void { - const tokens = this.themes.get(id) - if (!tokens) throw new Error(`theme "${id}" is not registered`) - const body = document.body - for (const name of Object.keys(this.appliedTokens)) body.style.removeProperty(name) - if (id === 'dark') body.setAttribute('data-ds-dark-theme', '') - else body.removeAttribute('data-ds-dark-theme') - for (const [name, value] of Object.entries(tokens)) body.style.setProperty(name, value) - this.appliedTokens = tokens - this.active = id + getTheme(): ThemeSnapshot { + return this.snapshot } /** - * Report the active theme id (initially `light`). - * @returns the active theme id. + * Switch the theme preference — the only preference write entry. Persists + * the preference and emits `theme/change`. + * @param id - a registered theme id or `system`; unknown ids throw. */ - current(): string { - return this.active + setTheme(id: string): void { + if (id !== 'system' && !this.themes.some(t => t.id === id)) { + throw new Error(`theme "${id}" is not registered`) + } + if (this.preference === id) return + this.preference = id as ThemePreference + persistPreference(this.preference) + this.publish() + } + + /** + * Register a theme. Duplicate id throws (single occupant per id; the + * built-in pair counts; `system` is a preference, not a registrable id). + * @param definition - theme id, colorScheme, and alias-token overrides. + * @returns disposer. Disposing the theme backing the active preference + * resets the preference to the default so the UI never keeps tokens of an + * unregistered theme. + */ + register(definition: ThemeDefinition): () => void { + if (definition.id === 'system') throw new Error('"system" is a preference, not a registrable theme id') + if (this.themes.some(t => t.id === definition.id)) { + throw new Error(`theme "${definition.id}" is already registered`) + } + this.themes = [...this.themes, definition] + this.publish() + return () => { + if (!this.themes.some(t => t.id === definition.id)) return + this.themes = this.themes.filter(t => t.id !== definition.id) + if (this.preference === definition.id) { + this.preference = DEFAULT_PREFERENCE + persistPreference(this.preference) + } + this.publish() + } + } + + private buildSnapshot(): ThemeSnapshot { + const resolvedId = this.preference === 'system' + ? (this.media?.matches === true ? 'dark' : 'light') + : this.preference + // Both built-ins always exist; a registered preference id resolves or has + // been reset by its disposer, so the lookup cannot miss. + const active = this.themes.find(t => t.id === resolvedId) ?? this.themes[0]! + return Object.freeze({ + preference: this.preference, + active, + themes: Object.freeze([...this.themes]), + revision: this.revision, + }) + } + + private publish(): void { + this.revision += 1 + this.snapshot = this.buildSnapshot() + this.ctx.emit('theme/change', this.snapshot) + } +} + +/** Read the persisted preference; unknown or unreadable values fall back to the default. */ +function restorePreference(): ThemePreference { + try { + const stored = globalThis.localStorage?.getItem(STORAGE_KEY) + if (stored === 'light' || stored === 'dark' || stored === 'system') return stored + } catch { + // Storage access can throw (privacy mode); the default below covers it. + } + return DEFAULT_PREFERENCE +} + +/** Persist the preference; storage failures are non-fatal (preference resets next boot). */ +function persistPreference(preference: ThemePreference): void { + try { + globalThis.localStorage?.setItem(STORAGE_KEY, preference) + } catch { + // Storage access can throw (privacy mode / quota); the preference simply + // does not survive the session. } } @@ -77,5 +202,5 @@ export const inject: string[] = [] * @param ctx - client cordis context. */ export function apply(ctx: Context): void { - ctx.provide('theme', new ThemeService()) + ctx.provide('theme', new ThemeService(ctx)) } diff --git a/packages/client/ui-theme/src/invariant.ts b/packages/client/ui-theme/src/invariant.ts index 292304cbaf..4ec3296cd6 100644 --- a/packages/client/ui-theme/src/invariant.ts +++ b/packages/client/ui-theme/src/invariant.ts @@ -15,9 +15,10 @@ export const name = 'client-ui-theme-invariant' export const inject = ['invariants'] /** - * No runtime invariant: a token-sheet registry whose apply() flips one body - * attribute — it emits no cordis events; registration/apply/current behavior - * is asserted directly by this package's behavior specs. + * No runtime invariant: the theme registry publishes immutable snapshots on + * its own `theme/change` event synchronously with the setter/registry + * mutation in the same service — snapshot/event agreement is asserted + * directly by this package's behavior specs. */ const install: InvariantInstaller = () => {} diff --git a/packages/client/ui-theme/tests/theme.spec.ts b/packages/client/ui-theme/tests/theme.spec.ts index 126957c5eb..9ab0ab8f0f 100644 --- a/packages/client/ui-theme/tests/theme.spec.ts +++ b/packages/client/ui-theme/tests/theme.spec.ts @@ -1,61 +1,90 @@ // @vitest-environment jsdom import { beforeEach, describe, expect, it } from 'vitest' -import { ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client' +import { Context } from 'cordis' +import type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client' +import { STORAGE_KEY, ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client' + +const make = (): { ctx: Context; theme: ThemeService; events: ThemeSnapshot[] } => { + const ctx = new Context() + const events: ThemeSnapshot[] = [] + ctx.on('theme/change', (snapshot) => { events.push(snapshot) }) + return { ctx, theme: new ThemeService(ctx), events } +} describe('ThemeService', () => { beforeEach(() => { - document.body.removeAttribute('data-ds-dark-theme') - document.body.removeAttribute('style') + localStorage.clear() }) - it('starts on light; apply toggles the dark body attribute both ways', () => { - const theme = new ThemeService() - expect(theme.current()).toBe('light') - theme.apply('dark') - expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(true) - expect(theme.current()).toBe('dark') - theme.apply('light') + it('defaults to the system preference resolved against prefers-color-scheme', () => { + const { theme } = make() + const snapshot = theme.getTheme() + expect(snapshot.preference).toBe('system') + // jsdom matchMedia is absent; system resolves to light. + expect(snapshot.active.id).toBe('light') + expect(snapshot.active.colorScheme).toBe('light') + expect(snapshot.themes.map(t => t.id)).toEqual(['light', 'dark']) + }) + + it('setTheme switches, persists, republishes, and keeps DOM untouched', () => { + const { theme, events } = make() + theme.setTheme('dark') + expect(theme.getTheme().preference).toBe('dark') + expect(theme.getTheme().active.colorScheme).toBe('dark') + expect(localStorage.getItem(STORAGE_KEY)).toBe('dark') + expect(events).toHaveLength(1) + expect(events[0]).toBe(theme.getTheme()) + // The service never touches presentation state. expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(false) - expect(theme.current()).toBe('light') + // Same-value set is a no-op (no extra event). + theme.setTheme('dark') + expect(events).toHaveLength(1) }) - it('throws on unregistered apply and duplicate register (built-ins included)', () => { - const theme = new ThemeService() - expect(() => { theme.apply('sepia') }).toThrow('not registered') - expect(() => theme.register('light', {})).toThrow('already registered') - theme.register('sepia', {}) - expect(() => theme.register('sepia', {})).toThrow('already registered') + it('restores a persisted preference and falls back on garbage', () => { + localStorage.setItem(STORAGE_KEY, 'dark') + expect(make().theme.getTheme().preference).toBe('dark') + localStorage.setItem(STORAGE_KEY, 'sepia') + expect(make().theme.getTheme().preference).toBe('system') }) - it('applies third-party token overrides as body inline vars and swaps them on switch', () => { - const theme = new ThemeService() - theme.register('sepia', { '--dsw-alias-bg-base': 'rgb(1, 2, 3)' }) - theme.apply('sepia') - expect(document.body.style.getPropertyValue('--dsw-alias-bg-base')).toBe('rgb(1, 2, 3)') - expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(false) - theme.apply('dark') - expect(document.body.style.getPropertyValue('--dsw-alias-bg-base')).toBe('') - expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(true) + it('throws on unknown setTheme ids, duplicate registration, and the system id', () => { + const { theme } = make() + expect(() => { theme.setTheme('sepia') }).toThrow('not registered') + expect(() => theme.register({ id: 'light', colorScheme: 'light', tokens: {} })).toThrow('already registered') + expect(() => theme.register({ id: 'system', colorScheme: 'light', tokens: {} })).toThrow('preference') }) - it('disposing the active theme reverts to light; disposer is idempotent', () => { - const theme = new ThemeService() - const dispose = theme.register('sepia', { '--dsw-alias-bg-base': 'red' }) - theme.apply('sepia') + it('registered themes join the snapshot; disposing the active one resets to default', () => { + const { theme, events } = make() + const dispose = theme.register({ id: 'sepia', colorScheme: 'light', tokens: { '--dsw-alias-bg-base': 'red' } }) + expect(theme.getTheme().themes.map(t => t.id)).toEqual(['light', 'dark', 'sepia']) + theme.setTheme('sepia') + expect(theme.getTheme().active.tokens['--dsw-alias-bg-base']).toBe('red') dispose() - expect(theme.current()).toBe('light') - expect(document.body.style.getPropertyValue('--dsw-alias-bg-base')).toBe('') - expect(() => { theme.apply('sepia') }).toThrow('not registered') + expect(theme.getTheme().preference).toBe('system') + expect(theme.getTheme().themes.map(t => t.id)).toEqual(['light', 'dark']) + expect(localStorage.getItem(STORAGE_KEY)).toBe('system') + // register + set + dispose = three publishes; disposer is idempotent. + expect(events.length).toBe(3) dispose() - expect(theme.current()).toBe('light') + expect(events.length).toBe(3) }) - it('disposing an inactive theme leaves the active selection untouched', () => { - const theme = new ThemeService() - const dispose = theme.register('sepia', {}) - theme.apply('dark') + it('disposing an inactive theme keeps the active preference', () => { + const { theme } = make() + const dispose = theme.register({ id: 'sepia', colorScheme: 'light', tokens: {} }) + theme.setTheme('dark') dispose() - expect(theme.current()).toBe('dark') - expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(true) + expect(theme.getTheme().preference).toBe('dark') + }) + + it('revision increases monotonically across every publish', () => { + const { theme, events } = make() + theme.setTheme('dark') + theme.setTheme('light') + const dispose = theme.register({ id: 'sepia', colorScheme: 'dark', tokens: {} }) + dispose() + expect(events.map(e => e.revision)).toEqual([1, 2, 3, 4]) }) }) diff --git a/packages/client/web/src/boot.tsx b/packages/client/web/src/boot.tsx index bd755a5fc1..61dbbd850e 100644 --- a/packages/client/web/src/boot.tsx +++ b/packages/client/web/src/boot.tsx @@ -21,7 +21,7 @@ * switches to the real UI in one pass. * * Entry creation waits for the whole immediately tier: materialization runs - * synchronous cross-package require edges (e.g. i18n → runtime/client) that + * synchronous cross-package require edges (e.g. locale → runtime/client) that * fiber inject waiting cannot protect — a bundle's factory must be * registered before any dependent entry materializes. Per-row prefetch * failures still resolve silently (the create-side import refetches and diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7f19dcce27..2410252eb0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -125,9 +125,9 @@ importers: '@deepseek-ai/dsh-client-hmr': specifier: workspace:^ version: link:../../packages/client/hmr - '@deepseek-ai/dsh-client-i18n': + '@deepseek-ai/dsh-client-locale': specifier: workspace:^ - version: link:../../packages/client/i18n + version: link:../../packages/client/locale '@deepseek-ai/dsh-client-modules': specifier: workspace:^ version: link:../../packages/client/modules @@ -143,6 +143,15 @@ importers: '@deepseek-ai/dsh-client-ui-question': specifier: workspace:^ version: link:../../packages/client/ui-question + '@deepseek-ai/dsh-client-ui-settings': + specifier: workspace:^ + version: link:../../packages/client/ui-settings + '@deepseek-ai/dsh-client-ui-settings-general': + specifier: workspace:^ + version: link:../../packages/client/ui-settings-general + '@deepseek-ai/dsh-client-ui-settings-models': + specifier: workspace:^ + version: link:../../packages/client/ui-settings-models '@deepseek-ai/dsh-client-ui-sidebar': specifier: workspace:^ version: link:../../packages/client/ui-sidebar @@ -740,11 +749,7 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) - packages/client/i18n: - dependencies: - '@deepseek-ai/dsh-client-runtime': - specifier: workspace:^ - version: link:../runtime + packages/client/locale: devDependencies: '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -844,6 +849,9 @@ importers: '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots + '@deepseek-ai/dsh-client-ui-theme': + specifier: workspace:^ + version: link:../ui-theme '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -937,6 +945,104 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/client/ui-settings: + dependencies: + clsx: + specifier: ^2.0.0 + version: 2.1.1 + devDependencies: + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../runtime + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-sidebar': + specifier: workspace:^ + version: link:../ui-sidebar + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + react: + specifier: ^18.2.0 + version: 18.3.1 + + packages/client/ui-settings-general: + dependencies: + clsx: + specifier: ^2.0.0 + version: 2.1.1 + devDependencies: + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../runtime + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-settings': + specifier: workspace:^ + version: link:../ui-settings + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-client-ui-theme': + specifier: workspace:^ + version: link:../ui-theme + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + react: + specifier: ^18.2.0 + version: 18.3.1 + + packages/client/ui-settings-models: + devDependencies: + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../runtime + '@deepseek-ai/dsh-client-ui-settings': + specifier: workspace:^ + version: link:../ui-settings + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + react: + specifier: ^18.2.0 + version: 18.3.1 + packages/client/ui-sidebar: dependencies: clsx: diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 20d4195737..da5fe34dc4 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -59,7 +59,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/client/ui-trajectory': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/ui-workspace': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/ui-theme': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, - 'packages/client/i18n': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, + 'packages/client/locale': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/web': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/examples/agent-spine-demo': { kind: 'indirect', reason: 'The bundle only mounts model-facing child plugins.' }, 'packages/fs/fs': { kind: 'indirect', reason: 'The service interface delegates model rendering to dsh-tool-fs.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index a449a34c4e..81e0fc989c 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -115,7 +115,10 @@ "@deepseek-ai/dsh-client-ui-trajectory": ["./packages/client/ui-trajectory/src"], "@deepseek-ai/dsh-client-ui-workspace": ["./packages/client/ui-workspace/src"], "@deepseek-ai/dsh-client-ui-theme": ["./packages/client/ui-theme/src"], - "@deepseek-ai/dsh-client-i18n": ["./packages/client/i18n/src"], + "@deepseek-ai/dsh-client-ui-settings": ["./packages/client/ui-settings/src"], + "@deepseek-ai/dsh-client-ui-settings-general": ["./packages/client/ui-settings-general/src"], + "@deepseek-ai/dsh-client-ui-settings-models": ["./packages/client/ui-settings-models/src"], + "@deepseek-ai/dsh-client-locale": ["./packages/client/locale/src"], "@deepseek-ai/dsh-client-web": ["./packages/client/web/src"], "@deepseek-ai/dsh-*": [ "./packages/core/*/src", diff --git a/tsconfig.client.json b/tsconfig.client.json index 17f34b601f..abe80b8b12 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -38,7 +38,10 @@ { "path": "./packages/client/ui-question" }, { "path": "./packages/client/ui-trajectory" }, { "path": "./packages/client/ui-theme" }, - { "path": "./packages/client/i18n" }, + { "path": "./packages/client/ui-settings" }, + { "path": "./packages/client/ui-settings-general" }, + { "path": "./packages/client/ui-settings-models" }, + { "path": "./packages/client/locale" }, { "path": "./packages/client/web" }, { "path": "./apps/web" } ] From ea8b1178cda98cc945a016d75bd3e4191b35e704 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 26 Jul 2026 00:02:46 +0800 Subject: [PATCH 07/32] feat(web): session list one-list, hover card, row menus, rename, manual ordering Sidebar session list grows the figma 239-10458 feature set and the workspace/session browsing region moves wholesale into ui-workspace: - Group-by menu (WorkSpace / In one list): flat mode lists every session top-level, strictly newest-first; the choice persists across reloads. - Session rows get a 500ms hover detail card (title / relative time / status line) and a ... menu (Rename / Fork session / Delete session, visual-only for now); workspace headers get ... with Rename (wired) and Delete workspace (visual-only). - workspace.rename RPC: trims, rejects duplicate titles on the create chain (workspace-name-conflict), no-op on same title; modal dialog with client-side duplicate pre-check. - workspace.insertSessionBefore RPC (DOM-insertBefore semantics, omitted anchor appends): HTML5 drag reorder of root sessions inside a workspace group; order truth stays host-side, the view refreshes from the response/changed frame. - Activity pinning removed: the session/event touchSession chain is gone; workspace accounts are manually owned (new sessions prepend, explicit reordering only). Contracts and tests updated, api catalog regenerated. - ui-sidebar reduced to the column shell (brand, fold state machine, New Session, Settings) exposing one sidebar.workspaces hole with a two-fact owner share {wide, expandSidebar}; ui-workspace owns the whole region (header, search, grouped/flat lists, dialogs, drag) plus the picker via a shared WorkspaceCreateFlow. The old sidebar.workspace picker slot and its deferral indirection are gone. - ui-primitives: Menu gains label entries, danger rows, and closeOnPointerLeave; new HoverCard (portaled, open-delay, disabled guard). Hover card and row menu never coexist. --- docs/cordis-catalog/services.md | 11 - docs/event-producer-consumer.md | 2 +- .../client/connection/src/client/fixture.ts | 55 +++ packages/client/connection/tests/fake-api.ts | 6 + .../runtime/src/client/workspaces/manager.ts | 41 +- .../runtime/src/client/workspaces/service.ts | 31 +- packages/client/runtime/tests/fake-api.ts | 9 + .../ui-primitives/src/HoverCard.module.css | 22 + .../client/ui-primitives/src/HoverCard.tsx | 109 +++++ .../client/ui-primitives/src/Menu.module.css | 21 + packages/client/ui-primitives/src/Menu.tsx | 33 +- packages/client/ui-primitives/src/index.ts | 3 +- .../client/ui-sidebar/src/client/Rows.tsx | 143 ------ .../src/client/SidebarRoot.module.css | 186 +------- .../ui-sidebar/src/client/SidebarRoot.tsx | 248 +--------- .../ui-sidebar/src/client/contract/slots.ts | 59 +-- .../client/ui-sidebar/src/client/index.ts | 15 +- .../client/ui-sidebar/tests/apply.spec.tsx | 18 +- .../ui-sidebar/tests/sidebar-root.spec.tsx | 274 ++--------- packages/client/ui-workspace/package.json | 3 + .../src/client/WorkspaceBrowser.module.css | 265 +++++++++++ .../src/client/WorkspaceBrowser.tsx | 428 ++++++++++++++++++ .../src/client/WorkspacePicker.tsx | 63 ++- .../ui-workspace/src/client/contract/slots.ts | 63 ++- .../client/ui-workspace/src/client/index.ts | 95 ++-- .../src/client/rows}/Rows.module.css | 53 ++- .../ui-workspace/src/client/rows/Rows.tsx | 285 ++++++++++++ .../client/ui-workspace/src/client/stores.ts | 36 ++ .../src/client/tree.ts | 33 +- .../client/ui-workspace/tests/apply.spec.ts | 68 +-- .../tests/rows.spec.tsx | 4 +- .../tests/tree.spec.ts | 0 .../cordis/tool-cordis/src/api-catalog.ts | 6 +- packages/host/apiproxy/src/api-proxy.ts | 69 ++- packages/host/apiproxy/src/api/rpc-map.ts | 2 + packages/host/apiproxy/src/api/rpc.schema.ts | 1 + packages/host/apiproxy/src/api/rpc.ts | 1 + .../host/apiproxy/src/api/workspace.schema.ts | 26 ++ packages/host/apiproxy/src/api/workspace.ts | 28 +- packages/host/apiproxy/src/fetch/client.ts | 8 + packages/host/apiproxy/src/fetch/handler.ts | 4 + .../apiproxy/tests/client-handler.spec.ts | 2 + .../host/apiproxy/tests/fetch-carrier.spec.ts | 12 + packages/workspace/workspace/src/entity.ts | 56 ++- packages/workspace/workspace/src/index.ts | 37 +- packages/workspace/workspace/src/types.ts | 25 +- .../workspace/tests/workspace.spec.ts | 118 +---- pnpm-lock.yaml | 4 + 48 files changed, 1948 insertions(+), 1133 deletions(-) create mode 100644 packages/client/ui-primitives/src/HoverCard.module.css create mode 100644 packages/client/ui-primitives/src/HoverCard.tsx delete mode 100644 packages/client/ui-sidebar/src/client/Rows.tsx create mode 100644 packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css create mode 100644 packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx rename packages/client/{ui-sidebar/src/client => ui-workspace/src/client/rows}/Rows.module.css (79%) create mode 100644 packages/client/ui-workspace/src/client/rows/Rows.tsx create mode 100644 packages/client/ui-workspace/src/client/stores.ts rename packages/client/{ui-sidebar => ui-workspace}/src/client/tree.ts (89%) rename packages/client/{ui-sidebar => ui-workspace}/tests/rows.spec.tsx (97%) rename packages/client/{ui-sidebar => ui-workspace}/tests/tree.spec.ts (100%) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 2e30e8602b..474f587230 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1981,15 +1981,6 @@ get(id: WorkspaceId): Workspace | undefined */ list(): Workspace[] -/** - * Move one accounted, cwd-validated session to the front of its workspace. - * Ungrouped sessions and candidates filtered by the header check are - * no-ops. The owning workspace's relative position never changes. - * @param sessionId - Session whose activity was observed. - * @returns resolution after the possible record write. - */ -async touchSession(sessionId: SessionId): Promise - /** * Resolve by canonical directory path without creating or mutating a * workspace. A missing path rejects during `realpath`; an existing unowned @@ -2000,8 +1991,6 @@ async touchSession(sessionId: SessionId): Promise async resolveByPath(path: string): Promise ``` -Types: [SessionId](../core-data-structures/core.md) - Source: [`packages/workspace/workspace/src/index.ts:75`](../../packages/workspace/workspace/src/index.ts) ## Inherited `ctx` members (cordis core + loader/hmr/timer) diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index d9521f7d67..7d1e6b691a 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -36,7 +36,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:52`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | | `session/created` | `emit` | [`packages/core/session/src/index.ts:79`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`user-approval`](../packages/ui/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:89`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:101`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace`](../packages/workspace/workspace), [`workspace-context`](../packages/context/workspace-context) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:101`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:111`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:139`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) | | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:113`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 53c18dca5c..d6f3fa61de 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -641,6 +641,59 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { emitHost({ type: 'host/workspace-changed', workspace: { ...created } }) return ok(request, { workspace: { ...created }, created: true }) }, + rename: (request) => { + const { workspaceId, title } = request.payload + const workspace = workspaces.find(w => w.workspaceId === workspaceId) + if (workspace === undefined) { + return err(request, { + code: 'workspace-not-found', + message: `no workspace ${workspaceId}`, + details: { workspaceId }, + }) + } + const trimmed = title.trim() + if (trimmed !== workspace.title) { + if (workspaces.some(w => w.workspaceId !== workspaceId && w.title === trimmed)) { + return err(request, { + code: 'workspace-name-conflict', + message: `workspace name '${trimmed}' is already in use`, + details: { name: trimmed }, + }) + } + workspace.title = trimmed + workspace.updatedAt = new Date().toISOString() + emitHost({ type: 'host/workspace-changed', workspace: { ...workspace } }) + } + return ok(request, { workspace: { ...workspace } }) + }, + insertSessionBefore: (request) => { + const { workspaceId, sessionId, beforeSessionId } = request.payload + const workspace = workspaces.find(w => w.workspaceId === workspaceId) + if (workspace === undefined) { + return err(request, { + code: 'workspace-not-found', + message: `no workspace ${workspaceId}`, + details: { workspaceId }, + }) + } + if (!workspace.sessionIds.includes(sessionId) + || (beforeSessionId !== undefined && !workspace.sessionIds.includes(beforeSessionId))) { + return err(request, { + code: 'workspace-move-invalid', + message: `session or anchor is not accounted by workspace ${workspaceId}`, + details: { workspaceId, sessionId, ...beforeSessionId === undefined ? {} : { beforeSessionId } }, + }) + } + const without = workspace.sessionIds.filter(id => id !== sessionId) + const at = beforeSessionId === undefined ? without.length : without.indexOf(beforeSessionId) + const sessionIds = [...without.slice(0, at), sessionId, ...without.slice(at)] + if (!sessionIds.every((id, index) => id === workspace.sessionIds[index])) { + workspace.sessionIds = sessionIds + workspace.updatedAt = new Date().toISOString() + emitHost({ type: 'host/workspace-changed', workspace: { ...workspace } }) + } + return ok(request, { workspace: { ...workspace } }) + }, }, events: { async *mux(_request, signal) { @@ -757,6 +810,8 @@ export class FixtureApiClient extends AbstractApiClient { case 'host.describe': return this.api.host.describe(request) case 'workspace.list': return this.api.workspace.list(request) case 'workspace.create': return this.api.workspace.create(request) + case 'workspace.rename': return this.api.workspace.rename(request) + case 'workspace.insertSessionBefore': return this.api.workspace.insertSessionBefore(request) } } diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index faca82d5c3..eecacc9581 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -77,6 +77,12 @@ export class FakeApiClient implements IApiClient { workspace: { workspaceId: 'fk-ws' as never, path: '/f/ws', title: 'ws', sessionIds: [], createdAt: '0', updatedAt: '0' }, created: true, }))), + rename: (payload: unknown) => this.record('workspace.rename', payload, Promise.resolve(ok({ + workspace: { workspaceId: 'fk-ws' as never, path: '/f/ws', title: 'ws', sessionIds: [], createdAt: '0', updatedAt: '0' }, + }))), + insertSessionBefore: (payload: unknown) => this.record('workspace.insertSessionBefore', payload, Promise.resolve(ok({ + workspace: { workspaceId: 'fk-ws' as never, path: '/f/ws', title: 'ws', sessionIds: [], createdAt: '0', updatedAt: '0' }, + }))), } /** When true, streams never fire onOpen (misbehaving-carrier material for the handshake timeout guard). */ diff --git a/packages/client/runtime/src/client/workspaces/manager.ts b/packages/client/runtime/src/client/workspaces/manager.ts index 6db4e54c79..c512694816 100644 --- a/packages/client/runtime/src/client/workspaces/manager.ts +++ b/packages/client/runtime/src/client/workspaces/manager.ts @@ -1,7 +1,7 @@ /** Workspace baseline, incremental-frame, and unary-action owner. */ import type { - HostFrame, IApiClient, RpcError, RpcRequest, RpcResult, WorkspaceView, + HostFrame, IApiClient, RpcError, RpcRequest, RpcResult, SessionId, WorkspaceId, WorkspaceView, } from '@deepseek-ai/dsh-client-connection/client' import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api' import { mergeOrderedBaseline } from '../ordered-baseline.ts' @@ -143,6 +143,40 @@ export class WorkspaceManager { return result } + /** + * Rename a Workspace, then publish its returned snapshot without waiting + * for the changed frame. + * @param workspaceId - target workspace. + * @param title - new display title. + * @returns the wire result. + */ + async rename(workspaceId: WorkspaceId, title: string): Promise> { + const { result } = await this.api.workspace.rename({ workspaceId, title }) + if (result.ok) this.upsert(result.value.workspace) + return result + } + + /** + * Move a session within its Workspace's manual order, then publish the + * returned snapshot without waiting for the changed frame. + * @param workspaceId - owning workspace. + * @param sessionId - accounted session to move. + * @param beforeSessionId - accounted anchor to insert before; omitted appends. + * @returns the wire result. + */ + async insertSessionBefore( + workspaceId: WorkspaceId, + sessionId: SessionId, + beforeSessionId?: SessionId, + ): Promise> { + const { result } = await this.api.workspace.insertSessionBefore({ + workspaceId, sessionId, + ...beforeSessionId === undefined ? {} : { beforeSessionId }, + }) + if (result.ok) this.upsert(result.value.workspace) + return result + } + /** * Host-frame entry. Non-workspace frames are ignored so the runtime can * fan one host stream out to both object managers. @@ -189,6 +223,11 @@ export class WorkspaceManager { private upsert(view: WorkspaceView, identity?: Workspace): void { this.refreshFrames?.push(view) const index = this.items.findIndex(item => item.getSnapshot().view?.workspaceId === view.workspaceId) + // Mutation responses and changed frames race (two carriers, no ordering): + // reject a snapshot strictly older than the installed projection so a + // late unary response cannot roll back a newer frame. + const installed = index === -1 ? undefined : this.items[index]?.getSnapshot().view + if (installed !== undefined && Date.parse(view.updatedAt) < Date.parse(installed.updatedAt)) return if (identity !== undefined) { this.items = index === -1 ? [identity, ...this.items] diff --git a/packages/client/runtime/src/client/workspaces/service.ts b/packages/client/runtime/src/client/workspaces/service.ts index 854c53a75f..9768a3fac2 100644 --- a/packages/client/runtime/src/client/workspaces/service.ts +++ b/packages/client/runtime/src/client/workspaces/service.ts @@ -2,7 +2,7 @@ import type { Context } from 'cordis' import type { - IApiClient, RpcError, WorkspaceId, WorkspaceView, + IApiClient, RpcError, SessionId, WorkspaceId, WorkspaceView, } from '@deepseek-ai/dsh-client-connection/client' import type { SnapshotStore } from '../contract/store.ts' import { createSnapshotStore } from '../contract/store.ts' @@ -100,6 +100,35 @@ export class WorkspacesService { return result.value.workspace } + /** + * Rename a Workspace. + * @param workspaceId - target workspace. + * @param title - new display title (trimmed non-empty by the Host). + * @returns the renamed Workspace view. + */ + async rename(workspaceId: WorkspaceId, title: string): Promise { + const result = await this.manager.rename(workspaceId, title) + if (!result.ok) throw new Error(`workspace rename failed: ${result.error.code}: ${result.error.message}`) + return result.value.workspace + } + + /** + * Move a session within its Workspace's manual order (DOM-insertBefore-like). + * @param workspaceId - owning workspace. + * @param sessionId - accounted session to move. + * @param beforeSessionId - accounted anchor to insert before; omitted appends. + * @returns the updated Workspace view. + */ + async insertSessionBefore( + workspaceId: WorkspaceId, + sessionId: SessionId, + beforeSessionId?: SessionId, + ): Promise { + const result = await this.manager.insertSessionBefore(workspaceId, sessionId, beforeSessionId) + if (!result.ok) throw new Error(`workspace move failed: ${result.error.code}: ${result.error.message}`) + return result.value.workspace + } + /** * Refresh the workspace baseline, reusing an in-flight pull. * @returns completion of the current or newly started workspace baseline pull. diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index 45efcf9e36..a9fbda4907 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -92,9 +92,18 @@ export class FakeApiClient implements IApiClient { onWorkspaceCreate: (payload: unknown) => Promise> = () => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws'), created: true })) + onWorkspaceRename: (payload: unknown) => Promise> = + () => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws') })) + + onWorkspaceInsertSessionBefore: (payload: unknown) => Promise> = + () => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws') })) + readonly workspace: IApiClient['workspace'] = { list: (payload: unknown) => this.record('workspace.list', payload, this.onWorkspaceList(payload)), create: (payload: unknown) => this.record('workspace.create', payload, this.onWorkspaceCreate(payload)), + rename: (payload: unknown) => this.record('workspace.rename', payload, this.onWorkspaceRename(payload)), + insertSessionBefore: (payload: unknown) => + this.record('workspace.insertSessionBefore', payload, this.onWorkspaceInsertSessionBefore(payload)), } /** When true, streams never fire onOpen (misbehaving-carrier material for the handshake timeout guard). */ diff --git a/packages/client/ui-primitives/src/HoverCard.module.css b/packages/client/ui-primitives/src/HoverCard.module.css new file mode 100644 index 0000000000..8d8a52100e --- /dev/null +++ b/packages/client/ui-primitives/src/HoverCard.module.css @@ -0,0 +1,22 @@ +/* Block, not inline-flex: consumers wrap full-width list rows and an + * inline wrapper would shrink them; the card still measures this rect. */ +.root { + position: relative; + display: block; +} + +/* Preview card (figma session hover card): 244 wide, r12, pad 12/16, the + * menu card's elevation. Surface is #2C2C2E in both themes (figma value, + * light/dark identical), so a component-level variable, not a theme token. */ +.card { + --dsw-hovercard-bg: #2C2C2E; + position: fixed; + z-index: 100; + box-sizing: border-box; + width: 244px; + padding: 12px 16px; + border-radius: 12px; + background: var(--dsw-hovercard-bg); + box-shadow: var(--dsw-shadow-lv3); + pointer-events: none; +} diff --git a/packages/client/ui-primitives/src/HoverCard.tsx b/packages/client/ui-primitives/src/HoverCard.tsx new file mode 100644 index 0000000000..58e281778e --- /dev/null +++ b/packages/client/ui-primitives/src/HoverCard.tsx @@ -0,0 +1,109 @@ +// HoverCard: delayed hover-preview card portaled to document.body. +// Same portal mechanics as Menu: the wrapper span supplies the anchor rect, +// the card is fixed-positioned at its right edge and repositions on +// scroll/resize while open. Display-only — the card ignores pointer events +// and closes the instant the pointer leaves the anchor (no close delay). + +import { useEffect, useLayoutEffect, useRef, useState } from 'react' +import type { CSSProperties, ReactNode } from 'react' +import { createPortal } from 'react-dom' +import css from './HoverCard.module.css' + +/** + * Render an anchor with a hover-triggered preview card. + * @param props.anchor - the hover target (rendered in place inside a wrapper span). + * @param props.content - card content (display-only, no pointer interaction). + * @param props.openDelayMs - hover dwell before the card shows (default 500). + * @param props.disabled - suppress opening; turning true closes an open card. + * @returns anchor wrapper with the conditional portaled card. + */ +export function HoverCard({ anchor, content, openDelayMs = 500, disabled = false }: { + anchor: ReactNode + content: ReactNode + openDelayMs?: number + disabled?: boolean +}) { + const rootRef = useRef(null) + const cardRef = useRef(null) + const timerRef = useRef | null>(null) + const [open, setOpen] = useState(false) + const [pos, setPos] = useState(null) + + const clearTimer = () => { + if (timerRef.current !== null) { + clearTimeout(timerRef.current) + timerRef.current = null + } + } + + // Owner disabling mid-hover (menu opened, drag started) closes immediately. + useEffect(() => { + if (!disabled) return + clearTimer() + setOpen(false) + }, [disabled]) + + useEffect(() => clearTimer, []) + + // Fixed-position from the anchor rect before paint; track the anchor while + // open (capture-phase scroll catches nested panes), as in Menu portal mode. + useLayoutEffect(() => { + if (!open) { setPos(null); return } + const place = () => { + const r = rootRef.current?.getBoundingClientRect() ?? null + if (r === null) return + const h = cardRef.current?.offsetHeight ?? 0 + const top = r.top + h > window.innerHeight - 8 ? window.innerHeight - h - 8 : r.top + setPos({ left: r.right + 8, top }) + } + place() + window.addEventListener('scroll', place, true) + window.addEventListener('resize', place) + return () => { + window.removeEventListener('scroll', place, true) + window.removeEventListener('resize', place) + } + }, [open]) + + // The first placement ran before the card mounted (height read 0): once the + // card's real height is measurable, correct the bottom-edge clamp. + useLayoutEffect(() => { + if (!open || pos === null || typeof pos.top !== 'number') return + const h = cardRef.current?.offsetHeight ?? 0 + if (pos.top + h > window.innerHeight - 8) { + const top = window.innerHeight - h - 8 + if (pos.top !== top) setPos({ ...pos, top }) + } + }, [open, pos]) + + const card = open && pos !== null && ( +
+ {content} +
+ ) + + return ( + { + if (disabled) return + clearTimer() + timerRef.current = setTimeout(() => { setOpen(true) }, openDelayMs) + }} + onPointerLeave={() => { + clearTimer() + setOpen(false) + }} + // Any press inside the anchor (row click, menu trigger) dismisses the + // card immediately, without waiting for the owner to flip `disabled`. + onPointerDownCapture={() => { + clearTimer() + setOpen(false) + }} + > + {anchor} + {card !== false && createPortal(card, document.body)} + + ) +} diff --git a/packages/client/ui-primitives/src/Menu.module.css b/packages/client/ui-primitives/src/Menu.module.css index b55abe094d..28c3150335 100644 --- a/packages/client/ui-primitives/src/Menu.module.css +++ b/packages/client/ui-primitives/src/Menu.module.css @@ -109,6 +109,27 @@ background: transparent; } +/* Destructive row: error text/icon, danger hover fill. */ +.danger { + color: var(--dsw-alias-state-error-primary); +} + +.danger .itemIcon { + color: var(--dsw-alias-state-error-primary); +} + +.danger:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover-danger); +} + +/* Heading row: non-interactive small grey text, padding aligned with items. */ +.label { + padding: 8px 10px; + font-size: 12px; + line-height: 16px; + color: var(--dsw-alias-label-tertiary); +} + /* Separator cell (figma 122:9481): py 4 / px 2 around the hairline. */ .separator { height: 1px; diff --git a/packages/client/ui-primitives/src/Menu.tsx b/packages/client/ui-primitives/src/Menu.tsx index de015ee534..9abb6a3bb2 100644 --- a/packages/client/ui-primitives/src/Menu.tsx +++ b/packages/client/ui-primitives/src/Menu.tsx @@ -4,6 +4,7 @@ // the anchor rect, for anchors inside overflow-clipping containers (sidebar). // The owner controls `open`; outside-click closing uses one document listener // active only while open. Submenus open on hover/focus inside the same root. +// Entries also cover non-interactive `label` headings and `danger` rows. import { useEffect, useLayoutEffect, useRef, useState } from 'react' import type { CSSProperties, ReactNode } from 'react' @@ -19,6 +20,8 @@ export interface MenuItem { disabled?: boolean /** Leading icon (figma .Menu_cell gap 8). */ icon?: ReactNode + /** Destructive row: error-colored text/icon and danger hover fill. */ + danger?: boolean /** Nested card opened to the right on hover/focus. */ submenu?: readonly MenuItem[] } @@ -29,13 +32,24 @@ export interface MenuSeparator { id: string } -/** One primary-menu entry: a row or a separator. */ -export type MenuEntry = MenuItem | MenuSeparator +/** Non-interactive heading row above a group of items. */ +export interface MenuLabel { + type: 'label' + id: string + text: string +} + +/** One primary-menu entry: a row, a separator, or a heading label. */ +export type MenuEntry = MenuItem | MenuSeparator | MenuLabel function isSeparator(entry: MenuEntry): entry is MenuSeparator { return 'type' in entry && entry.type === 'separator' } +function isLabel(entry: MenuEntry): entry is MenuLabel { + return 'type' in entry && entry.type === 'label' +} + /** * Render an anchored dropdown menu. * @param props.open - whether the list is showing (owner-controlled). @@ -50,6 +64,8 @@ function isSeparator(entry: MenuEntry): entry is MenuSeparator { * from the anchor rect (repositions on scroll/resize while open). Use when an * ancestor's overflow clipping would crop the in-place list; default false * keeps the pure-CSS in-place behavior. + * @param props.closeOnPointerLeave - close the list when the pointer leaves + * it (default false keeps it open until outside click/Escape/selection). * @param props.getAnchorRect - portal mode only: supply the anchor rect * directly (e.g. from a host-owned trigger button) instead of measuring the * Menu's own wrapper span. Required when the wrapper isn't itself laid out at @@ -58,7 +74,7 @@ function isSeparator(entry: MenuEntry): entry is MenuSeparator { * scroll/resize; return null to skip placement for that frame. * @returns anchor wrapper with the conditional list. */ -export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align = 'start', side = 'bottom', portal = false, getAnchorRect, className }: { +export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, getAnchorRect, className }: { open: boolean anchor: ReactNode items: readonly MenuEntry[] @@ -68,6 +84,7 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align align?: 'start' | 'end' side?: 'bottom' | 'top' portal?: boolean + closeOnPointerLeave?: boolean getAnchorRect?: () => DOMRect | null className?: string }) { @@ -135,11 +152,19 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align className={clsx(css.list, portal && css.portal, side === 'top' && !portal && css.sideTop, align === 'end' && !portal && css.alignEnd)} style={fixedPos ?? undefined} role="menu" + onPointerLeave={closeOnPointerLeave ? () => { onClose() } : undefined} + // React portals bubble synthetic events through the REACT tree: without + // this stop, an item click re-fires the anchor row's own onClick + // (open/toggle) after onSelect. + onClick={(e) => { e.stopPropagation() }} > {items.map(entry => { if (isSeparator(entry)) { return
} + if (isLabel(entry)) { + return
{entry.text}
+ } const hasSub = entry.submenu !== undefined && entry.submenu.length > 0 const subOpen = hasSub && openSubmenuId === entry.id return ( @@ -152,7 +177,7 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align - -
- ) -} - -/** - * The selected "New session" row for a frontend Session Intent targeted to a - * real Workspace. The row disappears when the Intent is replaced or connects. - * @returns the placeholder row element. - */ -export function IntentRowItem() { - return ( -
- - - New session -
- ) -} - -/** - * One session subtree: the node's own 34px row (indent by depth, expand - * twist when it has children, running dot, relative time) plus its visible - * children, recursively — the component tree mirrors the derived tree. - * @param props.node - derived session node. - * @param props.depth - 0 = directly under the group header. - * @param props.currentId - selected session id (row highlight). - * @param props.now - epoch ms for relative-time formatting. - * @param props.onOpen - open a session by id. - * @param props.onToggle - unfold/fold a subtree by id. - * @returns the node's row followed by its children. - */ -export function SessionNodeItem({ node, depth, currentId, now, onOpen, onToggle }: { - node: SessionNode - depth: number - currentId: string | undefined - now: number - onOpen: (id: SessionNode['id']) => void - onToggle: (id: SessionNode['id']) => void -}) { - const row = node - const selected = node.id === currentId - // Rail (figma session cell: pad 8, twist slot 16, status slot 16, gap 4 to - // the title): both slots are always reserved so titles align whether or not - // the twist/dot is lit. Extra depth rides the left padding. - const ownRow = ( -
{ onOpen(node.id) }} - > - {row.hasChildren - ? ( - - ) - : } - {row.running && } - {row.title} - {formatRelativeTime(row.updatedAt, now)} -
- ) - return ( - <> - {ownRow} - {node.children.map(child => ( - - ))} - - ) -} diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css index 591aef2330..729855cdde 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css @@ -48,7 +48,6 @@ refresh straight into the collapsed state renders statically. */ .railIn .iconButton, .railIn .newSession, -.railIn .searchButton, .railIn .foot { animation: rail-in 150ms var(--ds-ease-in-out) 100ms backwards; } @@ -184,133 +183,9 @@ max-width: 0; } -/* Section header: 36px, "WorkSpace" label + group-by / new-workspace buttons; - the right-anchored new-workspace button is the row's rail survivor. */ -.sectionHeader { - flex: none; - display: flex; - align-items: center; - justify-content: flex-end; - gap: 4px; - height: 36px; - padding-left: 12px; - margin-bottom: 4px; - box-sizing: border-box; - border-radius: 12px; - overflow: hidden; - color: var(--dsw-alias-label-tertiary); -} - -.collapsed .sectionHeader { - height: 36px; - padding-left: 0; - margin-bottom: 12px; -} - -.sectionLabel { - flex: 1; - min-width: 0; - overflow: hidden; - white-space: nowrap; - line-height: 20px; -} - -/* Search input: 38px capsule (figma 133:7649); collapsed it renders as the - rail's search control. Upstream binds a dedicated design-system variable (light - #F1F3F5 / dark #1B1B1C) matching no shipped alias — a component token - pinned to the static scale mirrors it (ruled compliant: indirect via - custom property, upstream-variable equivalent). */ -.search { - --dsh-search-input-fill: var(--dsw-static-neutral-bluish-75); - flex: none; - display: flex; - align-items: center; - gap: 8px; - height: 38px; - margin: 0 2px 12px; /* bottom: former listArea gap 4 + own 8 (spec padB12 to the first cell) */ - padding: 0 14px; - box-sizing: border-box; - border: 1px solid var(--dsw-alias-border-l2); - border-radius: 24px; - background: var(--dsh-search-input-fill); - color: var(--dsw-alias-label-caption); - overflow: hidden; -} - -:global(body[data-ds-dark-theme]) .search { - --dsh-search-input-fill: var(--dsw-static-neutral-bluish-900); -} - -.collapsed .search { - height: 36px; - padding: 0; - margin: 0 0 12px; - gap: 0; - border-color: transparent; - background: transparent; -} - -/* The capsule's leading icon, upgraded to the rail's search control. While - expanded it is decorative: pointer-events off so clicks reach the label - (native input focus); collapsed it becomes the hit target. */ -.searchButton { - flex: none; - display: inline-flex; - align-items: center; - justify-content: center; - border: none; - border-radius: 50%; - padding: 0; - background: transparent; - pointer-events: none; - color: inherit; -} - -.collapsed .searchButton { - width: 36px; - height: 36px; - pointer-events: auto; - cursor: pointer; - color: var(--dsw-alias-label-primary); -} - -.collapsed .searchButton:hover { - background: var(--dsw-alias-interactive-bg-hover); -} - -.searchInput { - flex: 1; - min-width: 0; - border: none; - outline: none; - background: transparent; - font-size: 14px; - line-height: 20px; - color: var(--dsw-alias-label-primary); -} - -.searchInput::placeholder { - color: var(--dsw-alias-label-tertiary); -} - -.clearButton { - flex: none; - display: inline-flex; - align-items: center; - justify-content: center; - width: 28px; - height: 28px; - border: none; - border-radius: 50%; - padding: 0; - background: transparent; - cursor: pointer; - color: var(--dsw-alias-label-secondary); -} - -/* Tree seat: always mounted so the foot never moves; the tree content inside - is wide-only and clips while the column squeezes. */ -.listArea { +/* Region seat: always mounted so the foot never moves; the browser inside + handles its own wide/rail content. */ +.regionArea { flex: 1; min-height: 0; display: flex; @@ -318,60 +193,6 @@ overflow: hidden; } -/* Relative for the bottom fade overlay. */ -.treeBody { - flex: 1; - min-height: 0; - display: flex; - flex-direction: column; - position: relative; -} - -/* Bottom fade (figma 133:7666): 72px overlay pinned to the visible bottom, - transparent -> sidebar fill so it tracks the theme. */ -.fade { - position: absolute; - left: 0; - right: 0; - bottom: 0; - height: 72px; - background: linear-gradient(to bottom, transparent, var(--dsw-specific-sidebar-fill)); - pointer-events: none; -} - -/* Tree list: the only scrolling region. Block, not a flex column: as flex - items the 54/34 rows would shrink under content overflow (scrollHeight - collapses onto clientHeight and wheel scrolling dies); block children keep - their design heights and the 4px rhythm rides margins instead of gap. */ -.list { - flex: 1; - min-height: 0; - overflow-y: auto; - padding-bottom: 12px; -} - -/* One workspace section: header row + expanded session run. Rows inside - keep the former flat-list 4px gap as sibling margins; the inter-group - breathing room (figma 133:7661 batch separator, 20px after an expanded - run) rides the NEXT section's top margin so the last group adds none. */ -.groupSection > * + * { - margin-top: 4px; -} - -.groupSection + .groupSection { - margin-top: 4px; -} - -.groupSection:has([aria-expanded='true']) + .groupSection { - margin-top: 20px; -} - -.empty { - padding: 16px 12px; - color: var(--dsw-alias-label-tertiary); - font-size: 13px; -} - /* Foot: settings entry (figma 133:7668, 49 hug): the former 18/10 vertical margins fold into the row so the hover pill spans the full 49px. */ .foot { @@ -418,7 +239,6 @@ .fading > *, .railIn .iconButton, .railIn .newSession, - .railIn .searchButton, .railIn .foot { transition: none; animation: none; diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx index 931eb1ba17..bcb03780d5 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx @@ -1,170 +1,38 @@ /** - * Collapse is a slide plus crossfade: content freezes at its expanded - * width (inline style) and fades out in place while the sliding column - * (AppFrame grid tracks) clips it — nothing reflows mid-slide. At settle - * the wide-only content (brand, labels, input, tree) unmounts, dropping - * the sessions subscription, and the control rows snap to the 56px rail - * (one icon each, same top-down order) fading in as the slide ends. Rail - * search expands and focuses the search box. + * Sidebar shell: column geometry only. Collapse is a slide plus crossfade: + * content freezes at its expanded width (inline style) and fades out in place + * while the sliding column (AppFrame grid tracks) clips it — nothing reflows + * mid-slide. At settle the wide-only content unmounts and the control rows + * snap to the 56px rail (one icon each, same top-down order) fading in as the + * slide ends. The workspace/session browsing region between the New Session + * button and the foot is the `sidebar.workspaces` registrant's; the shell + * hands it the wide flag and an expand request callback. */ -import { useEffect, useMemo, useRef, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import clsx from 'clsx' import { BrandWordmark, FishLogo, - IconCloseFill14, IconNewChatOutline16, IconPanelLeftOutline16, IconPersonalizationOutline16, - IconProjectAddOutline16, IconSearchOutline16, IconSettingsOutline14, - Menu, Tooltip, + IconNewChatOutline16, IconPanelLeftOutline16, IconSettingsOutline14, + Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' -import type { WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client' import type { SidebarRootComponentProps } from './contract/slots.ts' -import { deriveGroups, UNGROUPED_KEY } from './tree.ts' -import { IntentRowItem, ProjectRowItem, SessionNodeItem } from './Rows.tsx' import css from './SidebarRoot.module.css' /** Wide-content unmount delay; matches the 150ms wide-content fade-out. */ const COLLAPSE_SETTLE_MS = 150 -/** Column slide length (--ds-transition-duration-slow): rail-search focus waits it out — focus() forces a synchronous layout and would jank the slide. */ -const EXPAND_SLIDE_MS = 300 - -const GROUP_BY_ITEMS = [ - { id: 'workspace', label: 'Workspace' }, - // Only workspace grouping is implemented. - { id: 'update', label: 'Update', disabled: true }, - { id: 'status', label: 'Status', disabled: true }, -] - -/** Immutable membership toggle for the local expansion arrays. */ -function toggled(list: readonly string[], key: string): string[] { - return list.includes(key) ? list.filter((k) => k !== key) : [...list, key] -} - -/** Group-by strategy menu; own open state so it resets with the wide chrome. */ -function GroupByMenu() { - const [open, setOpen] = useState(false) - return ( - { setOpen(false) }} - items={GROUP_BY_ITEMS} - selectedId="workspace" - onSelect={() => { setOpen(false) }} - align="end" - anchor={( - - )} - /> - ) -} - -type SessionTreeProps = Pick< - SidebarRootComponentProps, - 'useSessions' | 'startSession' | 'open' -> & { - workspaces: readonly WorkspaceView[] - /** Live search filter owned by the root (the query outlives the tree). */ - query: string -} - -/** The scrolling session tree; unmounting at collapse settle drops the sessions subscription and expansion state. */ -function SessionTree({ useSessions, startSession, open, workspaces, query }: SessionTreeProps) { - const list = useSessions((s) => s) - const current = list.current - const [expandedProjects, setExpandedProjects] = useState([]) - const [expandedSessions, setExpandedSessions] = useState([]) - // Re-expand when publication moves the selected intent into a real Workspace. - const intent = list.intent - const intentWorkspaceId = intent?.target.kind === 'workspace' - ? intent.target.workspaceId - : undefined - const currentGroup = current === undefined - ? undefined - : intent?.sessionId === current - ? intentWorkspaceId - : (workspaces.find(w => w.sessionIds.includes(current))?.workspaceId as string | undefined) - ?? UNGROUPED_KEY - useEffect(() => { - if (current === undefined || currentGroup === undefined) return - setExpandedProjects((l) => (l.includes(currentGroup) ? l : [...l, currentGroup])) - }, [current, currentGroup]) - const groups = useMemo( - () => deriveGroups(list, workspaces, { expandedProjects, expandedSessions, query }), - [list, workspaces, expandedProjects, expandedSessions, query], - ) - const now = Date.now() - - return ( -
-
- {groups.length === 0 && ( -
{query === '' ? 'No sessions yet' : 'No matches'}
- )} - {groups.map(group => ( - // Group section: header row + expanded session subtree. The - // inter-group breathing room (former flat-list batch separator) - // is the section's own margin (SidebarRoot.module.css). -
- { setExpandedProjects((l) => toggled(l, group.key)) }} - onCreate={() => { - if (group.workspaceId !== undefined) startSession(group.workspaceId) - }} - /> - {group.intentHere && } - {group.sessions.map(node => ( - { setExpandedSessions((l) => toggled(l, id)) }} - /> - ))} -
- ))} -
- -
- ) -} - /** - * Render the sidebar column. + * Render the sidebar column shell. * @param props - composed slot props (runtime share + injected callbacks, contract/slots.ts). * @returns the sidebar element tree. */ export function SidebarRoot({ collapsed, width, - useSessions, - useWorkspaces, startSession, - open, toggleSidebar, renderSlot, }: SidebarRootComponentProps) { - const workspaces = useWorkspaces(state => state.items) - // The query outlives the tree and the input (both wide-only) so collapsing - // does not silently drop an in-progress filter. - const [query, setQuery] = useState('') - const searchInput = useRef(null) - // Section-header + opens the workspace picker (same popover in wide and - // rail states; the hole sits beside the button and opens rightward). - const [wsPickerOpen, setWsPickerOpen] = useState(false) - // Placement anchor for the picker popover: the slot span renders elsewhere - // in the DOM, so the picker positions off this button's rect. - const wsPlusRef = useRef(null) - // Wide content stays mounted while the collapse animates (fading via // .collapsed .wide), unmounts at settle, and remounts right away on expand. const [settled, setSettled] = useState(collapsed) @@ -186,19 +54,6 @@ export function SidebarRoot({ const everWide = useRef(!collapsed) if (!collapsed) everWide.current = true - // Rail search = expand + land in the search box: the flag arms before the - // expand toggle; once expanded the input is mounted and takes focus. - const [searchOnExpand, setSearchOnExpand] = useState(false) - useEffect(() => { - if (!collapsed && searchOnExpand) { - const timer = window.setTimeout(() => { - searchInput.current?.focus({ preventScroll: true }) - setSearchOnExpand(false) - }, EXPAND_SLIDE_MS) - return () => { window.clearTimeout(timer) } - } - }, [collapsed, searchOnExpand]) - return (
-
- {wide && Workspaces} - {wide && } - - - - {/* Picker hole beside the + (same site in wide and rail states). */} - {renderSlot('sidebar.workspace', { - open: wsPickerOpen, - anchorRef: wsPlusRef, - onPick: (workspaceId) => { - setWsPickerOpen(false) - startSession(workspaceId) - }, - onClose: () => { setWsPickerOpen(false) }, + {/* The browsing region fills the column between the controls and the + foot in both states; its rail icon column rides the same slot. */} +
+ {renderSlot('sidebar.workspaces', { + wide, + expandSidebar: () => { if (collapsed) toggleSidebar() }, })}
- {/* Expanded: the row is a click-to-focus field (the leading icon is - decorative). Collapsed: the icon is the rail's search control. */} -
{ if (!collapsed) searchInput.current?.focus() }}> - - - - {wide && ( - { setQuery(e.target.value) }} - /> - )} - {wide && query !== '' && ( - - )} -
- - {/* Always-mounted seat: its flex slot pins the foot to the bottom in - both states while the tree itself is wide-only. */} -
- {wide && ( - - )} -
-
{wide && Settings} diff --git a/packages/client/ui-sidebar/src/client/contract/slots.ts b/packages/client/ui-sidebar/src/client/contract/slots.ts index 0334ca88c9..1fd84312fa 100644 --- a/packages/client/ui-sidebar/src/client/contract/slots.ts +++ b/packages/client/ui-sidebar/src/client/contract/slots.ts @@ -1,69 +1,54 @@ /** * Sidebar slot contract: the registrant-side props composition for the - * layout-owned `sidebar` slot and the Workspace picker hole declared here. - * The runtime share combines layout-owned page state and actions with the - * global useSessions and useWorkspaces hooks; the injected share adds the - * runtime navigation actions and sidebar toggle. + * layout-owned `sidebar` slot, plus the workspace-browser hole this shell + * declares. The shell owns column geometry (fold state machine, brand row, + * New Session, Settings); everything between the section header and the list + * bottom is the `sidebar.workspaces` registrant's (ui-workspace). */ -import type { RefObject } from 'react' import type { PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' // Type-only: pulls ui-layout's SlotMap merge (the 'sidebar' entry) into every // program that sees this contract, so PropsRuntime<'sidebar'> resolves. import type {} from '@deepseek-ai/dsh-client-ui-layout/client' -import type { SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' +import type { WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' declare module '@deepseek-ai/dsh-client-ui-slots' { interface SlotMap { /** - * The workspace picker hole in the sidebar section header (anchored at - * the + button). Declared by this package's 'sidebar' entry (declaring - * is claiming); ui-workspace registers the picker. + * The workspace/session browsing region: section header, search, the + * grouped/flat session list, and every workspace dialog. Declared by this + * package's 'sidebar' entry (declaring is claiming); ui-workspace + * registers the browser. */ - 'sidebar.workspace': { kind: 'single'; scope: 'root'; owner: SidebarWorkspaceOwnerProps } + 'sidebar.workspaces': { kind: 'single'; scope: 'root'; owner: SidebarSectionOwnerProps } } } /** - * Owner share of the sidebar workspace hole: popover geometry plus the - * sidebar's pick semantics. The picked Host Workspace is already real; the - * callback starts a frontend Session Intent targeted to it. + * Owner share of the browser hole — the only facts crossing the shell/region + * seam. Business data and actions arrive through the region's own inject. */ -export interface SidebarWorkspaceOwnerProps { - /** Popover visibility (+ button toggle state, host-local). */ - open: boolean - /** - * The + button element — the popover's placement anchor. The picker's - * slot span renders elsewhere in the DOM, so without this the menu - * positions off the zero-size placement span (order-dependent). Optional - * only until the host passes it; absent falls back to in-place placement. - */ - anchorRef?: RefObject - /** Start a frontend Session in a selected or newly created real Workspace. */ - onPick: (workspaceId: WorkspaceId) => void - /** Close the popover (outside click / Escape / post-pick). */ - onClose: () => void +export interface SidebarSectionOwnerProps { + /** Shell fold-state output: wide renders the full browser, rail the icon column. */ + wide: boolean + /** Rail icons request expansion; the browser rides the wide flip for focus. */ + expandSidebar: () => void } /** * Registrant-private injected share (arrives via the register inject - * factory). Host Workspace and Session data use the global framework hooks; - * navigation and panel actions are plain callbacks, and viewing state remains - * component-local. A type alias supplies the implicit index signature required - * by the registry. + * factory). The shell keeps only its own controls: starting a Session from + * the New Session button and toggling the column. */ export type SidebarRootInjected = { /** Start or replace the current frontend Session Intent. */ startSession: (workspaceId?: WorkspaceId, prompt?: string) => void - /** Open a real Session. */ - open: (sessionId: SessionId) => void /** Toggle the sidebar column through the layout service. */ toggleSidebar: () => void } /** - * Full component props: layout owner state/actions plus global useSessions - * and useWorkspaces, the declared Workspace picker render share, and this - * package's injected callback. No store is registered. + * Full component props: layout owner state/actions plus the browser hole's + * render share and this package's injected callbacks. No store is registered. */ export type SidebarRootComponentProps = - PropsRuntime<'sidebar'> & PropsRenderSlots<'sidebar.workspace'> & SidebarRootInjected + PropsRuntime<'sidebar'> & PropsRenderSlots<'sidebar.workspaces'> & SidebarRootInjected diff --git a/packages/client/ui-sidebar/src/client/index.ts b/packages/client/ui-sidebar/src/client/index.ts index 0a1c8ebb12..0493ef2279 100644 --- a/packages/client/ui-sidebar/src/client/index.ts +++ b/packages/client/ui-sidebar/src/client/index.ts @@ -1,28 +1,27 @@ -/** Registers the sidebar UI into the layout-owned slot. */ +/** Registers the sidebar shell into the layout-owned slot. */ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' import type { SidebarRootInjected } from './contract/slots.ts' import { SidebarRoot } from './SidebarRoot.tsx' -export type { SidebarRootComponentProps, SidebarRootInjected, SidebarWorkspaceOwnerProps } from './contract/slots.ts' +export type { SidebarRootComponentProps, SidebarRootInjected, SidebarSectionOwnerProps } from './contract/slots.ts' /** Services required by the sidebar plugin. */ -export const inject = ['slots', 'layout', 'sessions', 'workspaces'] +export const inject = ['slots', 'layout', 'workspaces'] -/** Registers the sidebar component and its service callbacks. +/** Registers the sidebar shell and its service callbacks. * @param ctx - Client root context. */ export function apply(ctx: ClientContext): void { const injectProps = (): SidebarRootInjected => ({ startSession: (workspaceId, prompt) => { ctx.workspaces.startSession(workspaceId, prompt) }, - open: (sessionId) => { ctx.sessions.open(sessionId) }, toggleSidebar: () => { ctx.layout.toggleSidebar() }, }) ctx.effect( () => ctx.slots.register({ name: 'sidebar', - // SidebarRoot owns this picker site; ui-workspace registers the shared - // picker that selects a Host Workspace for a frontend Session Intent. - children: { 'sidebar.workspace': { kind: 'single', scope: 'root' } }, + // The shell owns geometry; ui-workspace registers the whole browsing + // region (header, search, session list, workspace dialogs) here. + children: { 'sidebar.workspaces': { kind: 'single', scope: 'root' } }, inject: injectProps, }, SidebarRoot), 'ui-sidebar: slot registration', diff --git a/packages/client/ui-sidebar/tests/apply.spec.tsx b/packages/client/ui-sidebar/tests/apply.spec.tsx index 5d285bd20c..681454691c 100644 --- a/packages/client/ui-sidebar/tests/apply.spec.tsx +++ b/packages/client/ui-sidebar/tests/apply.spec.tsx @@ -1,4 +1,4 @@ -/** Sidebar slot registration and its plain runtime/layout callbacks. */ +/** Sidebar shell slot registration and its plain runtime/layout callbacks. */ import { Context } from 'cordis' import { describe, expect, it, vi } from 'vitest' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' @@ -9,10 +9,8 @@ async function bench(declare = true) { const ctx = new Context() await ctx.plugin(SlotsService).await() const layout = { toggleSidebar: vi.fn() } - const sessions = { open: vi.fn() } const workspaces = { startSession: vi.fn() } ctx.provide('layout', layout) - ctx.provide('sessions', sessions as never) ctx.provide('workspaces', workspaces as never) const slots = ctx.get('slots') as SlotsService if (declare) { @@ -21,25 +19,23 @@ async function bench(declare = true) { () => null, ) } - return { ctx, slots, layout, sessions, workspaces } + return { ctx, slots, layout, workspaces } } describe('ui-sidebar apply', () => { it('declares only the services it uses', () => { - expect(inject).toEqual(['slots', 'layout', 'sessions', 'workspaces']) + expect(inject).toEqual(['slots', 'layout', 'workspaces']) }) - it('registers the sidebar and declares its Workspace picker hole', async () => { + it('registers the shell and declares the browsing-region hole', async () => { const b = await bench() await b.ctx.plugin({ inject: [...inject], apply }).await() expect(b.slots.entries('sidebar')).toHaveLength(1) - expect(b.slots.spec('sidebar.workspace')).toEqual({ kind: 'single', scope: 'root' }) + expect(b.slots.spec('sidebar.workspaces')).toEqual({ kind: 'single', scope: 'root' }) const injected = (b.slots.entries('sidebar')[0]!.inject as () => SidebarRootInjected)() - expect(Object.keys(injected)).toEqual(['startSession', 'open', 'toggleSidebar']) + expect(Object.keys(injected)).toEqual(['startSession', 'toggleSidebar']) injected.startSession('workspace' as never, 'prompt') expect(b.workspaces.startSession).toHaveBeenCalledWith('workspace', 'prompt') - injected.open('session' as never) - expect(b.sessions.open).toHaveBeenCalledWith('session') injected.toggleSidebar() expect(b.layout.toggleSidebar).toHaveBeenCalledOnce() }) @@ -55,6 +51,6 @@ describe('ui-sidebar apply', () => { await fiber.await() await fiber.dispose() expect(b.slots.entries('sidebar')).toHaveLength(0) - expect(b.slots.spec('sidebar.workspace')).toBeUndefined() + expect(b.slots.spec('sidebar.workspaces')).toBeUndefined() }) }) diff --git a/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx b/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx index 26adafd793..6bdba770eb 100644 --- a/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx +++ b/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx @@ -1,79 +1,42 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' -import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' -import type { - SessionId, SessionListState, WorkspaceId, WorkspaceListState, WorkspaceView, -} from '@deepseek-ai/dsh-client-runtime/client' -import type { SidebarRootComponentProps } from '../src/client/contract/slots.ts' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import type { SidebarRootComponentProps, SidebarSectionOwnerProps } from '../src/client/contract/slots.ts' import { SidebarRoot } from '../src/client/SidebarRoot.tsx' afterEach(() => { cleanup() vi.useRealTimers() }) -const sid = (id: string) => id as SessionId -const wid = (id: string) => id as WorkspaceId -const hook = (snapshot: T) => (selector: (state: T) => S): S => selector(snapshot) -const workspace: WorkspaceView = { - workspaceId: wid('project'), path: '/projects/project', title: 'Project', sessionIds: [sid('s1')], - createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', -} -const sessions: SessionListState = { - ids: [sid('s1')], - byId: { [sid('s1')]: { id: sid('s1'), displayTitle: 'First session', running: false, updatedAt: 1 } }, - current: undefined, phase: 'ready', - intent: undefined, -} -const workspaces: WorkspaceListState = { - items: [workspace], state: 'idle', phase: 'ready', error: null, - intent: undefined, baselinesReady: true, recentWorkspaceId: workspace.workspaceId, -} -function mount(sessionState: SessionListState = sessions) { - const startSession = vi.fn() - const open = vi.fn() - let pickerOwner: unknown - const view = render( - { pickerOwner = owner; return null }) as SidebarRootComponentProps['renderSlot']} - />, - ) - return { view, startSession, open, pickerOwner: () => pickerOwner } -} +// The shell never reads the global hooks itself, but they ride the standard +// props share; stub them as never-called functions. +const neverHook = (() => { throw new Error('shell must not read global hooks') }) as never -function mountSidebar({ - sessionState = sessions, - workspaceState = workspaces, - collapsed = false, - width = 300, -}: { - sessionState?: SessionListState - workspaceState?: WorkspaceListState - collapsed?: boolean - width?: number -} = {}) { +function mountShell({ collapsed = false, width = 300 }: { collapsed?: boolean; width?: number } = {}) { const startSession = vi.fn() - const open = vi.fn() const toggleSidebar = vi.fn() - let pickerOwner: unknown - let current = { sessionState, workspaceState, collapsed, width } + let regionOwner: SidebarSectionOwnerProps | undefined + let current = { collapsed, width } const root = () => ( { pickerOwner = owner; return null }) as SidebarRootComponentProps['renderSlot']} + useSessions={neverHook} useWorkspaces={neverHook} + startSession={startSession} toggleSidebar={toggleSidebar} + renderSlot={((_key: string, owner: SidebarSectionOwnerProps) => { + regionOwner = owner + return
+ }) as SidebarRootComponentProps['renderSlot']} /> ) const view = render(root()) return { startSession, - open, toggleSidebar, - pickerOwner: () => pickerOwner, + regionOwner: () => { + if (regionOwner === undefined) throw new Error('region owner not rendered') + return regionOwner + }, rerender(next: Partial) { current = { ...current, ...next } view.rerender(root()) @@ -81,181 +44,40 @@ function mountSidebar({ } } -describe('SidebarRoot', () => { - it('renders real Workspaces from useWorkspaces and routes New Session', () => { - const b = mount() - expect(screen.getByText('Project')).toBeTruthy() +describe('SidebarRoot shell', () => { + it('routes New Session and the column toggle', () => { + const b = mountShell() fireEvent.click(screen.getByRole('button', { name: 'New session' })) expect(b.startSession).toHaveBeenCalledWith() - }) - - it('shows a frontend Session under its real Workspace and routes its row plus', () => { - const intent = { sessionId: sid('intent'), target: { kind: 'workspace' as const, workspaceId: workspace.workspaceId }, prompt: '', phase: 'connecting' as const } - const b = mount({ - ...sessions, - current: intent.sessionId, - intent, - }) - expect(screen.getByText('New session')).toBeTruthy() - expect(screen.getByText('2 sessions')).toBeTruthy() - fireEvent.click(screen.getByRole('button', { name: 'New session in Project' })) - expect(b.startSession).toHaveBeenCalledWith(workspace.workspaceId) - }) - - it('forwards Workspace picker selection and closes the picker', () => { - const b = mount() - fireEvent.click(screen.getByRole('button', { name: 'Create workspace' })) - const owner = b.pickerOwner() as { open: boolean; onPick(id: WorkspaceId): void } - expect(owner.open).toBe(true) - owner.onPick(workspace.workspaceId) - expect(b.startSession).toHaveBeenCalledWith(workspace.workspaceId) - }) - - it('opens a real Session through the owner action', () => { - const b = mount({ ...sessions, current: sid('intent'), intent: { - sessionId: sid('intent'), target: { kind: 'workspace', workspaceId: workspace.workspaceId }, prompt: '', phase: 'ready', - } }) - fireEvent.click(screen.getByText('Project')) - fireEvent.click(screen.getByText('First session')) - expect(b.open).toHaveBeenCalledWith(sid('s1')) - }) - - it('opens, selects, dismisses, and toggles the group-by menu', () => { - mount() - const button = screen.getByRole('button', { name: 'Group by' }) - - fireEvent.click(button) - fireEvent.click(screen.getByRole('menuitem', { name: 'Workspace' })) - expect(screen.queryByRole('menu')).toBeNull() - - fireEvent.click(button) - fireEvent.keyDown(document, { key: 'Escape' }) - expect(screen.queryByRole('menu')).toBeNull() - - fireEvent.click(button) - fireEvent.click(button) - expect(screen.queryByRole('menu')).toBeNull() - }) - - it('routes every Workspace picker close path', () => { - const b = mount() - fireEvent.click(screen.getByRole('button', { name: 'Create workspace' })) - const owner = b.pickerOwner() as { open: boolean; onClose(): void } - expect(owner.open).toBe(true) - act(() => { owner.onClose() }) - expect((b.pickerOwner() as { open: boolean }).open).toBe(false) - }) - - it('focuses, filters, and clears search while distinguishing both empty states', () => { - mount() - const input = screen.getByPlaceholderText('Search name, keywords...') - fireEvent.click(input.parentElement!) - expect(document.activeElement).toBe(input) - fireEvent.click(screen.getByRole('button', { name: 'Search sessions' })) - - fireEvent.change(input, { target: { value: 'missing' } }) - expect(screen.getByText('No matches')).toBeTruthy() - fireEvent.click(screen.getByRole('button', { name: 'Clear search' })) - expect(screen.queryByText('No matches')).toBeNull() - - cleanup() - const emptySessions = listState() - const emptyWorkspaces: WorkspaceListState = { ...workspaces, items: [], recentWorkspaceId: undefined } - mountSidebar({ sessionState: emptySessions, workspaceState: emptyWorkspaces }) - expect(screen.getByText('No sessions yet')).toBeTruthy() - }) - - it('toggles Workspace and nested Session expansion in both directions', () => { - const parent = sid('parent') - const child = sid('child') - const nestedSessions: SessionListState = { - ...sessions, - ids: [parent, child], - byId: { - [parent]: { id: parent, displayTitle: 'Parent', running: false, updatedAt: 2 }, - [child]: { id: child, displayTitle: 'Child', running: false, updatedAt: 1, parentId: parent }, - }, - } - const nestedWorkspace: WorkspaceListState = { - ...workspaces, - items: [{ ...workspace, sessionIds: [parent, child] }], - } - mountSidebar({ sessionState: nestedSessions, workspaceState: nestedWorkspace }) - - fireEvent.click(screen.getByText('Project')) - fireEvent.click(screen.getByRole('button', { name: 'Expand' })) - expect(screen.getByText('Child')).toBeTruthy() - fireEvent.click(screen.getByRole('button', { name: 'Collapse' })) - expect(screen.queryByText('Child')).toBeNull() - fireEvent.click(screen.getByText('Project')) - expect(screen.queryByText('Parent')).toBeNull() - }) - - it('does not start a Session from an Ungrouped row create action', () => { - const loose = sid('loose') - const looseSessions: SessionListState = { - ...listState(), - ids: [loose], - byId: { [loose]: { id: loose, displayTitle: 'Loose', running: false, updatedAt: 1 } }, - current: loose, - } - const b = mountSidebar({ - sessionState: looseSessions, - workspaceState: { ...workspaces, items: [], recentWorkspaceId: undefined }, - }) - fireEvent.click(screen.getByRole('button', { name: 'New session in Ungrouped' })) - expect(b.startSession).not.toHaveBeenCalled() - }) - - it('keeps an already expanded selected Workspace open and resolves later Workspace matches', () => { - const b = mountSidebar() - fireEvent.click(screen.getByText('Project')) - const other = { ...workspace, workspaceId: wid('other'), title: 'Other', sessionIds: [] } - b.rerender({ - sessionState: { ...sessions, current: sid('s1') }, - workspaceState: { ...workspaces, items: [other, workspace] }, - }) - expect(screen.getByText('First session')).toBeTruthy() - - b.rerender({ - sessionState: { - ...sessions, - current: sid('draft'), - intent: { sessionId: sid('draft'), target: { kind: 'workspace-intent' }, prompt: '', phase: 'ready' }, - }, - }) - expect(screen.getByText('Project')).toBeTruthy() - }) - - it('renders the static collapsed rail and expands rail search into focused input', () => { - vi.useFakeTimers() - const b = mountSidebar({ collapsed: true }) - expect(screen.getByRole('button', { name: 'Open sidebar' })).toBeTruthy() - expect(screen.queryByPlaceholderText('Search name, keywords...')).toBeNull() - fireEvent.click(screen.getByRole('button', { name: 'Open sidebar' })) - expect(b.toggleSidebar).toHaveBeenCalledOnce() - - fireEvent.click(screen.getByRole('button', { name: 'Search sessions' })) - expect(b.toggleSidebar).toHaveBeenCalledTimes(2) - b.rerender({ collapsed: false }) - const input = screen.getByPlaceholderText('Search name, keywords...') - act(() => { vi.advanceTimersByTime(300) }) - expect(document.activeElement).toBe(input) - }) - - it('keeps wide content during live collapse, then settles to the rail', () => { - vi.useFakeTimers() - const b = mountSidebar({ width: 320 }) fireEvent.click(screen.getByRole('button', { name: 'Collapse sidebar' })) expect(b.toggleSidebar).toHaveBeenCalledOnce() - b.rerender({ collapsed: true, width: 56 }) - expect(screen.getByPlaceholderText('Search name, keywords...')).toBeTruthy() - act(() => { vi.advanceTimersByTime(150) }) - expect(screen.queryByPlaceholderText('Search name, keywords...')).toBeNull() + }) + + it('hands the region its wide flag and clamps expandSidebar to the collapsed state', () => { + const b = mountShell() + expect(b.regionOwner().wide).toBe(true) + // Expanded: the request is a no-op (no accidental collapse). + b.regionOwner().expandSidebar() + expect(b.toggleSidebar).not.toHaveBeenCalled() + }) + + it('keeps the region mounted through collapse and expands on its request', () => { + vi.useFakeTimers() + const b = mountShell() + b.rerender({ collapsed: true }) + // Wide content survives the crossfade window, then settles into the rail. + expect(b.regionOwner().wide).toBe(true) + vi.advanceTimersByTime(200) + b.rerender({}) + expect(b.regionOwner().wide).toBe(false) + expect(screen.getByTestId('region')).toBeTruthy() + b.regionOwner().expandSidebar() + expect(b.toggleSidebar).toHaveBeenCalledOnce() + }) + + it('renders statically collapsed on a cold start (no crossfade classes)', () => { + const b = mountShell({ collapsed: true }) + expect(b.regionOwner().wide).toBe(false) expect(screen.getByRole('button', { name: 'Open sidebar' })).toBeTruthy() }) }) - -function listState(): SessionListState { - return { ids: [], byId: {}, current: undefined, phase: 'ready', intent: undefined } -} diff --git a/packages/client/ui-workspace/package.json b/packages/client/ui-workspace/package.json index 0a6c82c0cf..c36486d2fd 100644 --- a/packages/client/ui-workspace/package.json +++ b/packages/client/ui-workspace/package.json @@ -35,6 +35,9 @@ "watch": "tsdown --watch" }, "license": "BSD-3-Clause", + "dependencies": { + "clsx": "^2.0.0" + }, "peerDependencies": { "@deepseek-ai/dsh-client-runtime": "^0.0.1", "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css b/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css new file mode 100644 index 0000000000..d6375cb698 --- /dev/null +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css @@ -0,0 +1,265 @@ +/* Workspace browsing region (fills the sidebar shell's hole): section + header, search capsule, and the scrolling session list. Wide/rail + variants ride the shell's fold state through the `wide` owner prop — + rail state renders only the two 36x36 icon controls. */ + +.root { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} + +.iconButton { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: none; + border-radius: 50%; + padding: 0; + background: transparent; + cursor: pointer; + color: var(--dsw-alias-label-secondary); +} + +.iconButton:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + +/* Section header: 36px, "Workspaces/Sessions" label + group-by / + new-workspace buttons; the right-anchored new-workspace button is the + row's rail survivor. */ +.sectionHeader { + flex: none; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 4px; + height: 36px; + padding-left: 12px; + margin-bottom: 4px; + box-sizing: border-box; + border-radius: 12px; + overflow: hidden; + color: var(--dsw-alias-label-tertiary); +} + +.sectionLabel { + flex: 1; + min-width: 0; + overflow: hidden; + white-space: nowrap; + line-height: 20px; +} + +/* Search input: 38px capsule (figma 133:7649); rail state renders it as the + region's search control. Upstream binds a dedicated design-system variable + (light #F1F3F5 / dark #1B1B1C) matching no shipped alias — a component + token pinned to the static scale mirrors it. */ +.search { + --dsh-search-input-fill: var(--dsw-static-neutral-bluish-75); + flex: none; + display: flex; + align-items: center; + gap: 8px; + height: 38px; + margin: 0 2px 12px; + padding: 0 14px; + box-sizing: border-box; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 24px; + background: var(--dsh-search-input-fill); + color: var(--dsw-alias-label-caption); + overflow: hidden; +} + +:global(body[data-ds-dark-theme]) .search { + --dsh-search-input-fill: var(--dsw-static-neutral-bluish-900); +} + +/* The capsule's leading icon: decorative while wide (pointer-events off so + clicks reach the input), the hit target in rail state. */ +.searchButton { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + border: none; + border-radius: 50%; + padding: 0; + background: transparent; + pointer-events: none; + color: inherit; +} + +.searchInput { + flex: 1; + min-width: 0; + border: none; + outline: none; + background: transparent; + font-size: 14px; + line-height: 20px; + color: var(--dsw-alias-label-primary); +} + +.searchInput::placeholder { + color: var(--dsw-alias-label-tertiary); +} + +.clearButton { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: none; + border-radius: 50%; + padding: 0; + background: transparent; + cursor: pointer; + color: var(--dsw-alias-label-secondary); +} + +/* Rail variant (own .rail class from the wide owner prop — the region never + reads the shell's class names): the two icon controls stack as 36x36 + circles matching the shell's rail rhythm. */ +.rail .sectionHeader { + padding-left: 0; + margin-bottom: 12px; +} + +.rail .iconButton { + width: 36px; + height: 36px; + color: var(--dsw-alias-label-primary); +} + +.rail .search { + height: 36px; + padding: 0; + margin: 0 0 12px; + gap: 0; + border-color: transparent; + background: transparent; +} + +.rail .searchButton { + width: 36px; + height: 36px; + pointer-events: auto; + cursor: pointer; + color: var(--dsw-alias-label-primary); +} + +.rail .searchButton:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + +/* List seat: always mounted so the shell foot never moves. */ +.listArea { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +/* Relative for the bottom fade overlay. */ +.treeBody { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + position: relative; +} + +/* Bottom fade (figma 133:7666): 72px overlay pinned to the visible bottom, + transparent -> sidebar fill so it tracks the theme. */ +.fade { + position: absolute; + left: 0; + right: 0; + bottom: 0; + height: 72px; + background: linear-gradient(to bottom, transparent, var(--dsw-specific-sidebar-fill)); + pointer-events: none; +} + +/* Wide-only content fades back in on expand remount (mirrors the shell). */ +.wide { + animation: wide-in 200ms var(--ds-ease-in-out); +} + +@keyframes wide-in { + from { opacity: 0; } +} + +/* List: the only scrolling region. Block, not a flex column: as flex items + the 54/34 rows would shrink under content overflow; block children keep + their design heights and the 4px rhythm rides margins instead of gap. */ +.list { + flex: 1; + min-height: 0; + overflow-y: auto; + padding-bottom: 12px; +} + +/* One workspace section: header row + expanded session run. Rows inside + keep the former flat-list 4px gap as sibling margins; the inter-group + breathing room (figma 133:7661 batch separator, 20px after an expanded + run) rides the NEXT section's top margin so the last group adds none. */ +.groupSection > * + * { + margin-top: 4px; +} + +.groupSection + .groupSection { + margin-top: 4px; +} + +.groupSection:has([aria-expanded='true']) + .groupSection { + margin-top: 20px; +} + +.empty { + padding: 16px 12px; + color: var(--dsw-alias-label-tertiary); + font-size: 13px; +} + +/* Rename dialog form (same figma dialog family as the create modals). */ +.renameInput { + box-sizing: border-box; + width: 100%; + height: 44px; + padding: 7px 14px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 22px; + outline: none; + background: transparent; + font-size: 14px; + font-weight: 400; + line-height: 22px; + color: var(--dsw-alias-label-primary); +} + +.renameInput:disabled { + color: var(--dsw-alias-label-dimmed); +} + +.renameError { + margin-top: 8px; + font-size: 12px; + line-height: 18px; + color: var(--dsw-alias-state-error-primary); +} + +@media (prefers-reduced-motion: reduce) { + .wide { + animation: none; + } +} diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx new file mode 100644 index 0000000000..e7d0cdc38c --- /dev/null +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx @@ -0,0 +1,428 @@ +/** + * The workspace/session browsing region filling the sidebar shell's + * `sidebar.workspaces` hole: section header (title + group-by + new + * workspace), search, the grouped tree or flat list, and the workspace + * dialogs. Wide state renders the full browser; rail state renders the two + * region icons (search / new workspace), each requesting shell expansion + * through the owner share. The picker menu and create dialogs live in + * WorkspacePicker (same package — direct composition, no slot between them). + */ +import { useEffect, useMemo, useRef, useState } from 'react' +import clsx from 'clsx' +import { + Button, IconCloseFill14, IconPersonalizationOutline16, + IconProjectAddOutline16, IconSearchOutline16, Menu, Modal, Tooltip, +} from '@deepseek-ai/dsh-client-ui-primitives' +import type { WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client' +import type { WorkspaceBrowserProps } from './contract/slots.ts' +import type { SessionNode } from './tree.ts' +import { deriveFlat, deriveGroups, UNGROUPED_KEY } from './tree.ts' +import { IntentRowItem, ProjectRowItem, SessionNodeItem } from './rows/Rows.tsx' +import { WorkspaceCreateFlow } from './WorkspacePicker.tsx' +import css from './WorkspaceBrowser.module.css' + +/** Column slide length (--ds-transition-duration-slow): rail-search focus waits it out — focus() forces a synchronous layout and would jank the slide. */ +const EXPAND_SLIDE_MS = 300 + +const GROUP_BY_ITEMS = [ + { type: 'label' as const, id: 'group-by', text: 'Group by' }, + { id: 'workspace', label: 'WorkSpace' }, + { id: 'flat', label: 'In one list' }, +] + +/** Immutable membership toggle for the local expansion arrays. */ +function toggled(list: readonly string[], key: string): string[] { + return list.includes(key) ? list.filter((k) => k !== key) : [...list, key] +} + +/** Group-by strategy menu; own open state so it resets with the wide chrome. */ +function GroupByMenu({ groupBy, onPick }: { + groupBy: 'workspace' | 'flat' + onPick: (mode: 'workspace' | 'flat') => void +}) { + const [open, setOpen] = useState(false) + return ( + { setOpen(false) }} + items={GROUP_BY_ITEMS} + selectedId={groupBy} + onSelect={(id) => { + if (id === 'workspace' || id === 'flat') onPick(id) + setOpen(false) + }} + align="end" + // Portal: the section header clips overflow, so an in-place list would + // be cut off at the header's bounds. + portal + anchor={( + + )} + /> + ) +} + +/** In-flight root-row drag: source identity plus the current insert marker. */ +interface DragState { + workspaceId: WorkspaceId + sessionId: SessionNode['id'] + /** Row the marker sits on and which half (insert above/below it). */ + over: { id: SessionNode['id']; half: 'before' | 'after' } | null +} + +type SessionTreeProps = Pick< + WorkspaceBrowserProps, + 'useSessions' | 'startSession' | 'open' | 'insertSessionBefore' +> & { + workspaces: readonly WorkspaceView[] + /** Live search filter owned by the browser root (the query outlives the tree). */ + query: string + /** Open the browser-owned rename dialog for a real Workspace group. */ + onRenameRequest: (workspaceId: WorkspaceId, currentTitle: string) => void +} + +/** The scrolling session tree; unmounting at collapse settle drops the sessions subscription and expansion state. */ +function SessionTree({ useSessions, startSession, open, workspaces, query, onRenameRequest, insertSessionBefore }: SessionTreeProps) { + const list = useSessions((s) => s) + const current = list.current + const [expandedProjects, setExpandedProjects] = useState([]) + const [expandedSessions, setExpandedSessions] = useState([]) + // Transient drag viewing state (never store-bound; order truth stays Host-side). + const [drag, setDrag] = useState(null) + // Re-expand when publication moves the selected intent into a real Workspace. + const intent = list.intent + const intentWorkspaceId = intent?.target.kind === 'workspace' + ? intent.target.workspaceId + : undefined + const currentGroup = current === undefined + ? undefined + : intent?.sessionId === current + ? intentWorkspaceId + : (workspaces.find(w => w.sessionIds.includes(current))?.workspaceId as string | undefined) + ?? UNGROUPED_KEY + useEffect(() => { + if (current === undefined || currentGroup === undefined) return + setExpandedProjects((l) => (l.includes(currentGroup) ? l : [...l, currentGroup])) + }, [current, currentGroup]) + const groups = useMemo( + () => deriveGroups(list, workspaces, { expandedProjects, expandedSessions, query }), + [list, workspaces, expandedProjects, expandedSessions, query], + ) + const now = Date.now() + + return ( +
+
+ {groups.length === 0 && ( +
{query === '' ? 'No sessions yet' : 'No matches'}
+ )} + {groups.map(group => ( + // Group section: header row + expanded session subtree. The + // inter-group breathing room (former flat-list batch separator) + // is the section's own margin (WorkspaceBrowser.module.css). +
+ { setExpandedProjects((l) => toggled(l, group.key)) }} + onCreate={() => { + if (group.workspaceId !== undefined) startSession(group.workspaceId) + }} + onRename={group.workspaceId === undefined + ? undefined + : () => { + if (group.workspaceId !== undefined) onRenameRequest(group.workspaceId, group.label) + }} + /> + {group.expanded && group.intentHere && } + {group.sessions.map((node, index) => { + // Draggable: real-workspace group roots outside search. The drag + // never leaves its group — rows of other groups show no markers + // and reject drops (visual movement confined to this section). + const draggable = group.workspaceId !== undefined && query === '' + const sameGroupDrag = drag !== null && drag.workspaceId === group.workspaceId + const dragProps = !draggable || group.workspaceId === undefined ? undefined : { + start: () => { + setDrag({ workspaceId: group.workspaceId as WorkspaceId, sessionId: node.id, over: null }) + }, + active: sameGroupDrag, + marker: sameGroupDrag && drag.over?.id === node.id ? drag.over.half : null, + hover: (half: 'before' | 'after') => { + setDrag(d => (d === null ? d : { ...d, over: { id: node.id, half } })) + }, + drop: (half: 'before' | 'after') => { + if (drag === null) return + const roots = group.sessions + // Anchor = the row the insert line points at ('after' means + // the next root; end-of-list omits the anchor → append). + const anchor = half === 'before' ? node.id : roots[index + 1]?.id + setDrag(null) + if (anchor === drag.sessionId) return + // No-op when the drop lands back on the source position. + const sourceIndex = roots.findIndex(r => r.id === drag.sessionId) + const anchorIndex = anchor === undefined ? roots.length : roots.findIndex(r => r.id === anchor) + if (sourceIndex !== -1 && (anchorIndex === sourceIndex || anchorIndex === sourceIndex + 1)) return + insertSessionBefore(drag.workspaceId, drag.sessionId, anchor).catch((reason: unknown) => { + console.warn('session reorder rejected:', reason) + }) + }, + end: () => { setDrag(null) }, + } + return ( + { setExpandedSessions((l) => toggled(l, id)) }} + drag={dragProps} + /> + ) + })} +
+ ))} +
+ +
+ ) +} + +/** The flat "In one list" body: every session a top-level row, newest-first. */ +function FlatList({ useSessions, open, query }: Pick) { + const list = useSessions((s) => s) + const rows = useMemo(() => deriveFlat(list, { query }), [list, query]) + const now = Date.now() + // The intent placeholder renders outside search only; it suppresses the + // empty state only while actually rendered (a query hides both). + const intentRow = query === '' && list.intent !== undefined + return ( +
+
+ {rows.length === 0 && !intentRow && ( +
{query === '' ? 'No sessions yet' : 'No matches'}
+ )} + {intentRow && } + {rows.map(node => ( + {}} + flat + /> + ))} +
+ +
+ ) +} + +/** + * Render the browsing region. + * @param props - composed slot props (shell owner share + store + injected actions). + * @returns the region element tree. + */ +export function WorkspaceBrowser({ + wide, + expandSidebar, + useSessions, + useWorkspaces, + useStore, + actions, + startSession, + open, + renameWorkspace, + insertSessionBefore, + createWorkspace, +}: WorkspaceBrowserProps) { + const workspaces = useWorkspaces(state => state.items) + const groupBy = useStore(s => s.groupBy) + // The query outlives the tree and the input (both wide-only) so collapsing + // does not silently drop an in-progress filter. + const [query, setQuery] = useState('') + const searchInput = useRef(null) + // Section-header + opens the picker menu (same popover in wide and rail + // states; the menu anchors on this button). + const [wsPickerOpen, setWsPickerOpen] = useState(false) + const wsPlusRef = useRef(null) + + // Rail search = expand + land in the search box: the flag arms before the + // expand request; once the shell flips wide the input mounts and takes focus. + const [searchOnExpand, setSearchOnExpand] = useState(false) + useEffect(() => { + if (wide && searchOnExpand) { + const timer = window.setTimeout(() => { + searchInput.current?.focus({ preventScroll: true }) + setSearchOnExpand(false) + }, EXPAND_SLIDE_MS) + return () => { window.clearTimeout(timer) } + } + }, [wide, searchOnExpand]) + + // Rename dialog (browser-owned so it outlives row unmounts during collapse). + const [renameTarget, setRenameTarget] = useState<{ workspaceId: WorkspaceId; currentTitle: string } | null>(null) + const [renameDraft, setRenameDraft] = useState('') + const [renaming, setRenaming] = useState(false) + const [renameError, setRenameError] = useState(null) + const renameTrimmed = renameDraft.trim() + const renameDuplicate = renameTarget !== null && renameTrimmed !== '' && renameTrimmed !== renameTarget.currentTitle + && workspaces.some(w => w.title === renameTrimmed) + const renameBlocked = renaming || renameTrimmed === '' + || renameTarget === null || renameTrimmed === renameTarget.currentTitle || renameDuplicate + const closeRename = () => { + if (renaming) return + setRenameTarget(null) + setRenameError(null) + } + const confirmRename = () => { + if (renameBlocked || renameTarget === null) return + setRenaming(true) + setRenameError(null) + renameWorkspace(renameTarget.workspaceId, renameTrimmed).then(() => { + setRenaming(false) + setRenameTarget(null) + }).catch((reason: unknown) => { + setRenaming(false) + setRenameError(reason instanceof Error ? reason.message : String(reason)) + }) + } + + return ( +
+
+ {wide && ( + + {groupBy === 'flat' ? 'Sessions' : 'Workspaces'} + + )} + {wide && { actions.setGroupBy(mode) }} />} + + + + {/* Picker menu + create dialogs (same package — direct composition). */} + { + setWsPickerOpen(false) + startSession(workspaceId) + }} + onClose={() => { setWsPickerOpen(false) }} + /> +
+ + {/* Expanded: the row is a click-to-focus field (the leading icon is + decorative). Rail: the icon is the region's search control. */} +
{ if (wide) searchInput.current?.focus() }}> + + + + {wide && ( + { setQuery(e.target.value) }} + /> + )} + {wide && query !== '' && ( + + )} +
+ + {/* Always-mounted seat keeps the region's flex slot while the list + itself is wide-only. */} +
+ {wide && (groupBy === 'flat' + ? + : ( + { + setRenameTarget({ workspaceId, currentTitle }) + setRenameDraft(currentTitle) + setRenameError(null) + }} + /> + ))} +
+ + + + + + )} + > + { setRenameDraft(e.target.value); setRenameError(null) }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + confirmRename() + } + }} + /> + {renameDuplicate && ( +
A workspace named “{renameTrimmed}” already exists.
+ )} + {renameError !== null &&
{renameError}
} +
+
+ ) +} diff --git a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx index 8f6f483580..2be39875bc 100644 --- a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx +++ b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx @@ -1,9 +1,15 @@ -/** Shared Workspace picker for the sidebar and New Session hero. */ +/** + * Workspace pick/create flow. WorkspaceCreateFlow is the reusable core + * (menu + path/create dialogs) consumed directly by WorkspaceBrowser (same + * package) and wrapped by WorkspacePicker for the conversation empty-state + * slot registration. + */ +import type { RefObject } from 'react' import { useCallback, useState } from 'react' import { Button, IconFolderClose16, IconPlusOutline16, Menu, Modal, type MenuEntry, } from '@deepseek-ai/dsh-client-ui-primitives' -import type { WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' +import type { WorkspaceId, WorkspaceListState, WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client' import type { WorkspacePickerProps } from './contract/slots.ts' import css from './WorkspacePicker.module.css' @@ -13,14 +19,35 @@ const CREATE_NEW = '::create-new' type ModalKind = 'path' | 'create' | null -export function WorkspacePicker({ +/** Core flow props: the owner supplies popover control and pick semantics. */ +export interface WorkspaceCreateFlowProps { + /** Popover visibility (anchor button toggle state, owner-local). */ + open: boolean + /** The anchor button element — the popover's placement anchor. */ + anchorRef?: RefObject | undefined + /** Selector hook over the workspace list (framework standard hook). */ + useWorkspaces: (selector: (state: WorkspaceListState) => S) => S + /** Create or adopt a real Host Workspace. */ + createWorkspace: (input: { name: string } | { path: string }) => Promise + /** A real Workspace was picked or created. */ + onPick: (workspaceId: WorkspaceId) => void + /** Close the popover (outside click / Escape / post-pick). */ + onClose: () => void +} + +/** + * Render the pick menu plus the two create dialogs. + * @param props - owner-controlled flow props. + * @returns menu + dialog elements. + */ +export function WorkspaceCreateFlow({ open, anchorRef, useWorkspaces, + createWorkspace, onPick, onClose, - createWorkspace, -}: WorkspacePickerProps) { +}: WorkspaceCreateFlowProps) { const workspaceSnapshot = useWorkspaces(state => state) const workspaces = workspaceSnapshot.items const getAnchorRect = useCallback( @@ -194,3 +221,29 @@ export function WorkspacePicker({ ) } + +/** + * The conversation empty-state registration: adapts the owner share to the + * core flow (all state and semantics live in the flow / the owner). + * @param props - empty-state slot props (owner share + injected creation callback). + * @returns the flow element. + */ +export function WorkspacePicker({ + open, + anchorRef, + useWorkspaces, + onPick, + onClose, + createWorkspace, +}: WorkspacePickerProps) { + return ( + + ) +} diff --git a/packages/client/ui-workspace/src/client/contract/slots.ts b/packages/client/ui-workspace/src/client/contract/slots.ts index a3045e5070..121e5d60f1 100644 --- a/packages/client/ui-workspace/src/client/contract/slots.ts +++ b/packages/client/ui-workspace/src/client/contract/slots.ts @@ -1,30 +1,59 @@ /** - * Shared Workspace picker contract for the sidebar and page-local Session Intent hero - * slots. Each runtime share provides its owner's popover controls plus the - * global useWorkspaces hook; this package adds the injected Host Workspace - * creation callback. + * ui-workspace contracts. Two registrations share this package: + * + * - WorkspaceBrowser fills the sidebar shell's `sidebar.workspaces` hole — + * the whole browsing region (section header, search, grouped/flat session + * list, workspace dialogs). It registers this package's viewing store and + * consumes the shell's two-fact owner share (wide / expandSidebar). + * - WorkspacePicker fills the conversation empty-state hole (menu + + * create dialogs shared with the browser). */ -import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' -// Type-only: pull both owner SlotMap merges into programs that resolve the -// picker runtime union below. +import type { PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' +// Type-only: pull the owner SlotMap merges into programs that resolve the +// runtime shares below. import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' -import type { WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client' +import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client' +import type { createWorkspaceViewStore } from '../stores.ts' /** - * Registrant-private injected share. Pick semantics remain in each owner's - * onPick callback; this callback creates only the real Host Workspace. A type - * alias supplies the implicit index signature required by the registry. + * Browser-private injected share (arrives via the register inject factory). + * Data reads use the global framework hooks; these are the Host actions the + * browsing region drives. + */ +export type WorkspaceBrowserInjected = { + /** Start or replace the current frontend Session Intent. */ + startSession: (workspaceId?: WorkspaceId, prompt?: string) => void + /** Open a real Session. */ + open: (sessionId: SessionId) => void + /** Rename a Host Workspace (rejects on name conflict; resolves on durability). */ + renameWorkspace: (workspaceId: WorkspaceId, title: string) => Promise + /** + * Reorder a session inside its Workspace account (DOM-insertBefore + * semantics: omitted anchor appends to the end). The view refreshes from + * the Host response/changed frame; failures leave the order unchanged. + */ + insertSessionBefore: (workspaceId: WorkspaceId, sessionId: SessionId, beforeSessionId?: SessionId) => Promise + /** Explicitly create or adopt a real Workspace before targeting a Session. */ + createWorkspace: (input: { name: string } | { path: string }) => Promise +} + +/** Full browser props: shell owner share + viewing store + injected actions. */ +export type WorkspaceBrowserProps = + PropsRuntime<'sidebar.workspaces'> + & PropsStore> + & WorkspaceBrowserInjected + +/** + * Picker-private injected share. Pick semantics remain in the owner's onPick + * callback; this callback creates only the real Host Workspace. A type alias + * supplies the implicit index signature required by the registry. */ export type WorkspacePickerInjected = { /** Explicitly create or adopt a real Workspace before targeting a Session. */ createWorkspace(input: { name: string } | { path: string }): Promise } -/** - * Full picker props: either owner's runtime share, including useWorkspaces, - * plus this package's injected creation callback. - */ +/** Full picker props: the empty-state owner share plus the creation callback. */ export type WorkspacePickerProps = - (PropsRuntime<'sidebar.workspace'> | PropsRuntime<'conversation.empty.workspace'>) - & WorkspacePickerInjected + PropsRuntime<'conversation.empty.workspace'> & WorkspacePickerInjected diff --git a/packages/client/ui-workspace/src/client/index.ts b/packages/client/ui-workspace/src/client/index.ts index aa54587650..50ccb3564c 100644 --- a/packages/client/ui-workspace/src/client/index.ts +++ b/packages/client/ui-workspace/src/client/index.ts @@ -1,54 +1,85 @@ /** - * Shared Workspace picker plugin, browser half. WorkspacePicker registers in - * the sidebar and page-local Session Intent hero slots, reads real Host Workspaces - * through the global useWorkspaces hook, and delegates selection semantics to - * each owner. Its injected share creates a Workspace without creating a - * Session. Export discipline: packages/client/AGENTS.md. + * Workspace plugin, browser half. Two registrations: WorkspaceBrowser fills + * the sidebar shell's `sidebar.workspaces` hole (the whole browsing region), + * and WorkspacePicker fills the conversation empty-state hole. Both read real + * Host Workspaces through the global useWorkspaces hook. Export discipline: + * packages/client/AGENTS.md. */ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' -import type { WorkspacePickerInjected } from './contract/slots.ts' +import type { WorkspaceBrowserInjected, WorkspacePickerInjected } from './contract/slots.ts' +import { createWorkspaceViewStore } from './stores.ts' +import { WorkspaceBrowser } from './WorkspaceBrowser.tsx' import { WorkspacePicker } from './WorkspacePicker.tsx' -export type { WorkspacePickerInjected, WorkspacePickerProps } from './contract/slots.ts' +export type { + WorkspaceBrowserInjected, WorkspaceBrowserProps, WorkspacePickerInjected, WorkspacePickerProps, +} from './contract/slots.ts' /** - * Required services (cordis fiber inject). The target slot is declared by - * the ui-sidebar apply, whose activation order relative to this one is NOT - * constrained: dshClient.inject edges are informational (loading/prefetch - * metadata, never apply sequencing) and the sidebar provides no waitable - * service. apply therefore registers via declaration-aware deferral instead - * of assuming order. + * Required services (cordis fiber inject). The target slots are declared by + * the ui-sidebar / ui-conversation applies, whose activation order relative + * to this one is NOT constrained: dshClient.inject edges are informational + * (loading/prefetch metadata, never apply sequencing) and neither owner + * provides a waitable service. apply therefore registers via + * declaration-aware deferral instead of assuming order. */ -export const inject = ['slots', 'workspaces'] +export const inject = ['slots', 'sessions', 'workspaces'] /** - * Register WorkspacePicker in both owner slots once their declarations are on - * the ledger. The inject factory returns a plain Workspace creation callback; - * data reads use the framework's global useWorkspaces hook. + * Register the browser and picker once their slot declarations are on the + * ledger. Inject factories return plain callbacks; data reads use the + * framework's global hooks. * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { - const injected = (): WorkspacePickerInjected => ({ + const browserInjected = (): WorkspaceBrowserInjected => ({ + startSession: (workspaceId, prompt) => { ctx.workspaces.startSession(workspaceId, prompt) }, + open: (sessionId) => { ctx.sessions.open(sessionId) }, + renameWorkspace: async (workspaceId, title) => { await ctx.workspaces.rename(workspaceId, title) }, + insertSessionBefore: async (workspaceId, sessionId, beforeSessionId) => { + await ctx.workspaces.insertSessionBefore(workspaceId, sessionId, beforeSessionId) + }, createWorkspace: input => ctx.workspaces.create(input), }) - // Declaration-aware registration: the sidebar's declaring apply may - // activate after this one (entry activation order is unconstrained), and a - // register into an undeclared slot throws. Register once the declaration - // is on the ledger; the subscription also re-registers after an HMR - // collapse re-declares the slot (the cascade disposed our entry with it). + const pickerInjected = (): WorkspacePickerInjected => ({ + createWorkspace: input => ctx.workspaces.create(input), + }) + // Declaration-aware registration: each owner's declaring apply may activate + // after this one (entry activation order is unconstrained), and a register + // into an undeclared slot throws. Register once the declaration is on the + // ledger; the subscription also re-registers after an HMR collapse + // re-declares the slot (the cascade disposed our entry with it). ctx.effect(() => { - const slotNames = ['sidebar.workspace', 'conversation.empty.workspace'] as const - const disposers = new Map<(typeof slotNames)[number], () => void>() - const tryRegister = (name: (typeof slotNames)[number]): void => { - if (ctx.slots.spec(name) === undefined) return - if (ctx.slots.entries(name).some(e => e.component === WorkspacePicker)) return - disposers.set(name, ctx.slots.register({ name, inject: injected }, WorkspacePicker)) + const registrations = [ + { + name: 'sidebar.workspaces' as const, + component: WorkspaceBrowser, + register: () => ctx.slots.register( + { name: 'sidebar.workspaces', store: createWorkspaceViewStore(), inject: browserInjected }, + WorkspaceBrowser, + ), + }, + { + name: 'conversation.empty.workspace' as const, + component: WorkspacePicker, + register: () => ctx.slots.register( + { name: 'conversation.empty.workspace', inject: pickerInjected }, + WorkspacePicker, + ), + }, + ] + const disposers = new Map void>() + const tryRegister = (entry: (typeof registrations)[number]): void => { + if (ctx.slots.spec(entry.name) === undefined) return + if (ctx.slots.entries(entry.name).some(e => e.component === entry.component)) return + disposers.set(entry.name, entry.register()) } - const unsubscribers = slotNames.map(name => ctx.slots.subscribe(name, () => { tryRegister(name) })) - for (const name of slotNames) tryRegister(name) + const unsubscribers = registrations.map(entry => + ctx.slots.subscribe(entry.name, () => { tryRegister(entry) })) + for (const entry of registrations) tryRegister(entry) return () => { for (const unsubscribe of unsubscribers) unsubscribe() for (const dispose of disposers.values()) dispose() } - }, 'ui-workspace: picker registrations') + }, 'ui-workspace: browser + picker registrations') } diff --git a/packages/client/ui-sidebar/src/client/Rows.module.css b/packages/client/ui-workspace/src/client/rows/Rows.module.css similarity index 79% rename from packages/client/ui-sidebar/src/client/Rows.module.css rename to packages/client/ui-workspace/src/client/rows/Rows.module.css index f996f03348..7b19284b66 100644 --- a/packages/client/ui-sidebar/src/client/Rows.module.css +++ b/packages/client/ui-workspace/src/client/rows/Rows.module.css @@ -150,14 +150,63 @@ } .projectRow:hover .rowActions, -.sessionRow:hover .rowActions { +.sessionRow:hover .rowActions, +.projectRow.menuOpen .rowActions, +.sessionRow.menuOpen .rowActions { display: inline-flex; } -.sessionRow:hover .time { +.sessionRow:hover .time, +.sessionRow.menuOpen .time { display: none; } +/* An open row menu pins the hover affordances (figma: the row keeps its + hover fill while its dropdown is up). */ +.projectRow.menuOpen, +.sessionRow.menuOpen { + background: var(--dsw-alias-interactive-bg-hover); +} + +/* Drag reorder insert line (workspace-group roots): 2px accent above or + below the hovered row, drawn with box-shadow so no layout shift. */ +.sessionRow.dropBefore { + box-shadow: 0 -2px 0 0 var(--dsw-alias-state-business-primary); +} + +.sessionRow.dropAfter { + box-shadow: 0 2px 0 0 var(--dsw-alias-state-business-primary); +} + +/* Hover-card body (figma 169:16903): dark surface, fixed colors both themes. */ +.hoverContent { + display: flex; + flex-direction: column; + gap: 8px; +} + +.hoverTitle { + font-size: 14px; + line-height: 20px; + color: #FFFFFF; + overflow-wrap: break-word; +} + +.hoverTime { + font-size: 12px; + line-height: 16px; + color: #CFD3D6; +} + +.hoverStatus { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + line-height: 20px; + color: #ADB2B8; +} + .iconButton { flex: none; display: inline-flex; diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx new file mode 100644 index 0000000000..5a3f923be0 --- /dev/null +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -0,0 +1,285 @@ +/** + * Workspace browser tree row components (figma Cell set 14:3080): pure presentational — + * all data and callbacks arrive via props. Hover swaps (folder->chevron, + * time->ellipsis, action buttons) are CSS-only. Row ... menus are visual-only + * except workspace Rename; the session hover card is suppressed while a menu + * is open. + */ +import { useState } from 'react' +import clsx from 'clsx' +import { + HoverCard, IconBranchOutline16, IconEditOutline16, IconEllipsisOutline16, + IconFolderClose16, IconFolderOpen16, IconPlusOutline16, + IconTrashOutline16, IconTriangleRightFill14, Menu, StateDot, +} from '@deepseek-ai/dsh-client-ui-primitives' +import type { GroupNode, SessionNode } from '../tree.ts' +import { formatRelativeTime } from '../tree.ts' +import css from './Rows.module.css' + +/** Indent step per tree level: one 16px slot (figma session cell). */ +const INDENT_STEP = 16 + +const SESSION_MENU_ITEMS = [ + { id: 'rename', label: 'Rename', icon: }, + { id: 'fork', label: 'Fork session', icon: }, + { id: 'delete', label: 'Delete session', icon: , danger: true }, +] + +const WORKSPACE_MENU_ITEMS = [ + { id: 'rename', label: 'Rename', icon: }, + { id: 'delete', label: 'Delete workspace', icon: , danger: true }, +] + +/** + * Project (workspace) header row: 54px, folder + title + session count; + * hover reveals the chevron and create button. `containsCurrent` arrives on + * the node (derivation fact, no renderer scan). + * @param props.group - derived group node. + * @param props.onToggle - expand/collapse the group. + * @param props.onCreate - start a frontend Session inside this Workspace. + * @returns the row element. + */ +export function ProjectRowItem({ group, onToggle, onCreate, onRename }: { + group: GroupNode + onToggle: () => void + onCreate: () => void + /** Open the rename dialog; absent for the ungrouped bucket (no menu shown). */ + onRename?: (() => void) | undefined +}) { + const row = group + const active = group.expanded && group.containsCurrent + const count = `${row.sessionCount} ${row.sessionCount === 1 ? 'session' : 'sessions'}` + const [menuOpen, setMenuOpen] = useState(false) + return ( +
+ + {row.expanded ? : } + + + + + + {row.label} + {count} + + + {onRename !== undefined && ( + { setMenuOpen(false) }} + items={WORKSPACE_MENU_ITEMS} + onSelect={(id) => { + setMenuOpen(false) + if (id === 'rename') onRename() + // Delete is visual-only for now. + }} + portal + closeOnPointerLeave + anchor={( + + )} + /> + )} + + +
+ ) +} + +/** + * The selected "New session" row for a frontend Session Intent targeted to a + * real Workspace. The row disappears when the Intent is replaced or connects. + * @param props.flat - flat-list variant: no twist slot (figma flat cell), so + * only the status slot indents the title. + * @returns the placeholder row element. + */ +export function IntentRowItem({ flat = false }: { flat?: boolean } = {}) { + return ( +
+ {!flat && } + + New session +
+ ) +} + +/** + * One session subtree: the node's own 34px row (indent by depth, expand + * twist when it has children, running dot, relative time) plus its visible + * children, recursively — the component tree mirrors the derived tree. + * @param props.node - derived session node. + * @param props.depth - 0 = directly under the group header. + * @param props.currentId - selected session id (row highlight). + * @param props.now - epoch ms for relative-time formatting. + * @param props.onOpen - open a session by id. + * @param props.onToggle - unfold/fold a subtree by id. + * @returns the node's row followed by its children. + */ +/** Hover-card body: full title, relative time, and the status line (running/idle until wire status lands). */ +function SessionHoverContent({ node, now }: { node: SessionNode; now: number }) { + return ( +
+
{node.title}
+
{`${formatRelativeTime(node.updatedAt, now)} ago`}
+
+ + {node.running ? 'Running' : 'Idle'} +
+
+ ) +} + +/** + * Root-row drag wiring supplied by the group owner (workspace groups only). + * `drop` reports the half of the row the pointer released on: 'before' + * inserts above this row, 'after' below it (the owner resolves the anchor). + */ +export interface RowDragProps { + /** Start dragging this row. */ + start: () => void + /** A drag from the same group is in flight (rows show insert markers). */ + active: boolean + /** Current marker on this row: insert line above, below, or none. */ + marker: 'before' | 'after' | null + /** Report the hovered half while a same-group drag passes over this row. */ + hover: (half: 'before' | 'after') => void + drop: (half: 'before' | 'after') => void + end: () => void +} + +/** Pointer-position half of a row (insert line above or below). */ +function rowHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' | 'after' { + const rect = e.currentTarget.getBoundingClientRect() + return e.clientY < rect.top + rect.height / 2 ? 'before' : 'after' +} + +export function SessionNodeItem({ node, depth, currentId, now, onOpen, onToggle, drag, flat = false }: { + node: SessionNode + depth: number + currentId: string | undefined + now: number + onOpen: (id: SessionNode['id']) => void + onToggle: (id: SessionNode['id']) => void + /** Present only on draggable rows (workspace-group roots outside search). */ + drag?: RowDragProps | undefined + /** Flat-list variant: no twist slot (figma flat cell) — titles align on the status slot. */ + flat?: boolean +}) { + const row = node + const selected = node.id === currentId + const [menuOpen, setMenuOpen] = useState(false) + // Rail (figma session cell: pad 8, twist slot 16, status slot 16, gap 4 to + // the title): both slots are always reserved so titles align whether or not + // the twist/dot is lit. Extra depth rides the left padding. + const ownRow = ( +
{ onOpen(node.id) }} + draggable={drag !== undefined} + onDragStart={drag === undefined + ? undefined + : (e) => { + e.dataTransfer.effectAllowed = 'move' + drag.start() + }} + onDragEnd={drag?.end} + onDragOver={drag === undefined + ? undefined + : (e) => { + if (!drag.active) return + e.preventDefault() + e.dataTransfer.dropEffect = 'move' + drag.hover(rowHalf(e)) + }} + onDrop={drag === undefined + ? undefined + : (e) => { + if (!drag.active) return + e.preventDefault() + drag.drop(rowHalf(e)) + }} + > + {row.hasChildren && !flat + ? ( + + ) + : null} + {row.running && } + {row.title} + {formatRelativeTime(row.updatedAt, now)} + + { setMenuOpen(false) }} + items={SESSION_MENU_ITEMS} + onSelect={() => { setMenuOpen(false) }} // Visual-only for now. + portal + closeOnPointerLeave + anchor={( + + )} + /> + +
+ ) + return ( + <> + } + disabled={menuOpen || drag?.active === true} + /> + {node.children.map(child => ( + + ))} + + ) +} diff --git a/packages/client/ui-workspace/src/client/stores.ts b/packages/client/ui-workspace/src/client/stores.ts new file mode 100644 index 0000000000..ed89d80d9e --- /dev/null +++ b/packages/client/ui-workspace/src/client/stores.ts @@ -0,0 +1,36 @@ +/** + * The workspace browser's viewing store: the session-list grouping mode, + * persisted across reloads. Module level exports the factory only (a + * module-level handle would pin the store identity across plugin reloads); + * register() receives the factory and the browser derives its PropsStore + * share from the return type. + */ +import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-runtime/client' + +/** Session-list grouping mode: workspace sections or one flat recency list. */ +export type WorkspaceGroupBy = 'workspace' | 'flat' + +/** Workspace browser viewing state (grouping mode only; transient UI facts stay component-local). */ +type WorkspaceViewState = { groupBy: WorkspaceGroupBy } + +/** + * Annotation twin of the actions literal below (the export needs a declared + * return type); drift fails assignability at the defineStore call. + */ +type WorkspaceViewActions = { + setGroupBy: (draft: WorkspaceViewState, mode: WorkspaceGroupBy) => void +} + +/** + * Create the workspace browser viewing store handle. + * @returns the store handle (spec + type + identity + factory in one). + */ +export function createWorkspaceViewStore(): EngineStoreHandle { + return defineStore({ + init: (): WorkspaceViewState => ({ groupBy: 'workspace' }), + persist: 'dsh.workspace.view', + actions: { + setGroupBy: (d, mode: WorkspaceGroupBy) => { d.groupBy = mode }, + }, + }) +} diff --git a/packages/client/ui-sidebar/src/client/tree.ts b/packages/client/ui-workspace/src/client/tree.ts similarity index 89% rename from packages/client/ui-sidebar/src/client/tree.ts rename to packages/client/ui-workspace/src/client/tree.ts index 64fda519e6..39a479f3b3 100644 --- a/packages/client/ui-sidebar/src/client/tree.ts +++ b/packages/client/ui-workspace/src/client/tree.ts @@ -1,5 +1,5 @@ /** - * Derives the sidebar tree from Host Workspace order and membership. + * Derives the workspace browser tree from Host Workspace order and membership. * Unassigned Sessions trail under Ungrouped; only Intents targeting real Workspaces render. */ import type { SessionId, SessionListState, SessionSummary, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client' @@ -223,11 +223,11 @@ function buildSearch(g: Group, visible: ReadonlySet): SessionNode[] { } /** - * Derive the nested sidebar group structure. + * Derive the nested workspace browser group structure. * * Normal mode: every group shows; sessions populate under expanded groups, * descending only into expanded sessions. A frontend Session Intent targeting - * a real Workspace marks that group `intentHere` and forces it expanded. Search mode (non-blank query, + * a real Workspace marks that group `intentHere` (rendered only while the group is expanded; expansion stays viewer-owned). Search mode (non-blank query, * case-insensitive display-title substring): expansion state is ignored — * matched sessions and their ancestor chains are forced visible, groups * without a display-title or label hit are dropped, a label-only hit keeps @@ -263,7 +263,9 @@ export function deriveGroups( && g.workspaceId !== undefined && intentWorkspaceId === g.workspaceId const intentHere = q === '' && hasIntent if (q === '') { - const expanded = intentHere || expandedProjects.has(g.key) + // The intent never forces expansion — the viewer auto-expands the + // target group once (current-group effect); the toggle stays live. + const expanded = expandedProjects.has(g.key) groups.push({ key: g.key, workspaceId: g.workspaceId, @@ -294,6 +296,29 @@ export function deriveGroups( return groups } +/** + * Derive the flat session list ("In one list" mode): every session — fork + * children included — as a top-level row, strictly newest-first. No grouping, + * no parent/child adjacency; rows reuse SessionNode with children always + * empty so the renderer stays branch-free. Search mode filters by + * case-insensitive display-title substring. + * @param list - sessions list snapshot. + * @param view - the search query (expansion state does not apply). + * @returns flat rows in render order. + */ +export function deriveFlat(list: SessionListState, view: Pick): SessionNode[] { + const q = view.query.trim().toLowerCase() + const rows: SessionSummary[] = [] + for (const id of list.ids) { + const s = list.byId[id] + if (s === undefined) continue + if (q !== '' && !s.displayTitle.toLowerCase().includes(q)) continue + rows.push(s) + } + rows.sort(byRecency) + return rows.map(s => sessionNode(s, [], false, false)) +} + /** * Compact relative time for session rows ("now", "5min", "3h", "2d", "4mo", "1y"). * @param updatedAt - epoch ms of the session's last activity. diff --git a/packages/client/ui-workspace/tests/apply.spec.ts b/packages/client/ui-workspace/tests/apply.spec.ts index 9352caa0b4..6e1c7a3a3f 100644 --- a/packages/client/ui-workspace/tests/apply.spec.ts +++ b/packages/client/ui-workspace/tests/apply.spec.ts @@ -2,7 +2,8 @@ import { Context } from 'cordis' import { describe, expect, it, vi } from 'vitest' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import { apply, inject } from '@deepseek-ai/dsh-client-ui-workspace/client' -import type { WorkspacePickerInjected } from '@deepseek-ai/dsh-client-ui-workspace/client' +import type { WorkspaceBrowserInjected, WorkspacePickerInjected } from '@deepseek-ai/dsh-client-ui-workspace/client' +import { WorkspaceBrowser } from '../src/client/WorkspaceBrowser.tsx' import { WorkspacePicker } from '../src/client/WorkspacePicker.tsx' async function bench() { @@ -13,32 +14,33 @@ async function bench() { path: 'name' in input ? `/projects/${input.name}` : input.path, title: 'new', sessionIds: [], createdAt: '0', updatedAt: '0', })) - ctx.provide('workspaces', { create }) - return { ctx, slots: ctx.get('slots') as SlotsService, create } + const startSession = vi.fn() + const rename = vi.fn(async () => ({})) + const insertSessionBefore = vi.fn(async () => ({})) + const open = vi.fn() + ctx.provide('workspaces', { create, startSession, rename, insertSessionBefore } as never) + ctx.provide('sessions', { open } as never) + return { ctx, slots: ctx.get('slots') as SlotsService, create, startSession, rename, insertSessionBefore, open } } -function declare(slots: SlotsService, name: 'sidebar.workspace' | 'conversation.empty.workspace'): () => void { - return slots.register( - { name: 'root', children: { [name]: { kind: 'single', scope: 'root' } } } as never, - () => null, - ) -} +type HoleName = 'sidebar.workspaces' | 'conversation.empty.workspace' -function injectedOf(slots: SlotsService, name: 'sidebar.workspace' | 'conversation.empty.workspace'): WorkspacePickerInjected { - const entry = slots.entries(name)[0]! - return (entry.inject as () => WorkspacePickerInjected)() +/** Declare one or both holes with a single root registration ('root' is a single slot). */ +function declare(slots: SlotsService, ...names: HoleName[]): () => void { + const children = Object.fromEntries(names.map(name => [name, { kind: 'single', scope: 'root' }])) + return slots.register({ name: 'root', children } as never, () => null) } describe('ui-workspace apply', () => { - it('declares the independent Workspace service', () => { - expect(inject).toEqual(['slots', 'workspaces']) + it('declares the services it drives', () => { + expect(inject).toEqual(['slots', 'sessions', 'workspaces']) }) - it('registers the shared picker for declarations that arrive before or after apply', async () => { + it('registers browser and picker for declarations arriving before or after apply', async () => { const before = await bench() - declare(before.slots, 'sidebar.workspace') + declare(before.slots, 'sidebar.workspaces') await before.ctx.plugin({ inject: [...inject], apply }).await() - expect(before.slots.entries('sidebar.workspace')[0]!.component).toBe(WorkspacePicker) + expect(before.slots.entries('sidebar.workspaces')[0]!.component).toBe(WorkspaceBrowser) const after = await bench() await after.ctx.plugin({ inject: [...inject], apply }).await() @@ -47,23 +49,35 @@ describe('ui-workspace apply', () => { expect(after.slots.entries('conversation.empty.workspace')[0]!.component).toBe(WorkspacePicker) }) - it('routes name and path creation to WorkspacesService', async () => { + it('routes browser actions and picker creation to the services', async () => { const b = await bench() - declare(b.slots, 'sidebar.workspace') + declare(b.slots, 'sidebar.workspaces', 'conversation.empty.workspace') await b.ctx.plugin({ inject: [...inject], apply }).await() - const injected = injectedOf(b.slots, 'sidebar.workspace') - await injected.createWorkspace({ name: 'project' }) - await injected.createWorkspace({ path: '/tmp/project' }) - expect(b.create).toHaveBeenNthCalledWith(1, { name: 'project' }) - expect(b.create).toHaveBeenNthCalledWith(2, { path: '/tmp/project' }) + + const browser = (b.slots.entries('sidebar.workspaces')[0]!.inject as () => WorkspaceBrowserInjected)() + browser.startSession('ws' as never, 'prompt') + expect(b.startSession).toHaveBeenCalledWith('ws', 'prompt') + browser.open('session' as never) + expect(b.open).toHaveBeenCalledWith('session') + await browser.renameWorkspace('ws' as never, 'renamed') + expect(b.rename).toHaveBeenCalledWith('ws', 'renamed') + await browser.insertSessionBefore('ws' as never, 's1' as never, 's2' as never) + expect(b.insertSessionBefore).toHaveBeenCalledWith('ws', 's1', 's2') + await browser.createWorkspace({ name: 'project' }) + expect(b.create).toHaveBeenCalledWith({ name: 'project' }) + + const picker = (b.slots.entries('conversation.empty.workspace')[0]!.inject as () => WorkspacePickerInjected)() + await picker.createWorkspace({ path: '/tmp/project' }) + expect(b.create).toHaveBeenCalledWith({ path: '/tmp/project' }) }) - it('unregisters picker entries on teardown', async () => { + it('unregisters both entries on teardown', async () => { const b = await bench() - declare(b.slots, 'sidebar.workspace') + declare(b.slots, 'sidebar.workspaces', 'conversation.empty.workspace') const fiber = b.ctx.plugin({ inject: [...inject], apply }) await fiber.await() await fiber.dispose() - expect(b.slots.entries('sidebar.workspace')).toHaveLength(0) + expect(b.slots.entries('sidebar.workspaces')).toHaveLength(0) + expect(b.slots.entries('conversation.empty.workspace')).toHaveLength(0) }) }) diff --git a/packages/client/ui-sidebar/tests/rows.spec.tsx b/packages/client/ui-workspace/tests/rows.spec.tsx similarity index 97% rename from packages/client/ui-sidebar/tests/rows.spec.tsx rename to packages/client/ui-workspace/tests/rows.spec.tsx index 468ce550d9..fdc5c43b8e 100644 --- a/packages/client/ui-sidebar/tests/rows.spec.tsx +++ b/packages/client/ui-workspace/tests/rows.spec.tsx @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, screen } from '@testing-library/react' import type { SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' -import { IntentRowItem, ProjectRowItem, SessionNodeItem } from '../src/client/Rows.tsx' +import { IntentRowItem, ProjectRowItem, SessionNodeItem } from '../src/client/rows/Rows.tsx' import type { GroupNode, SessionNode } from '../src/client/tree.ts' afterEach(cleanup) @@ -10,7 +10,7 @@ afterEach(cleanup) const sid = (id: string) => id as SessionId const wid = (id: string) => id as WorkspaceId -describe('sidebar rows', () => { +describe('workspace browser rows', () => { it('renders an active Workspace and keeps its create action separate from toggling', () => { const onToggle = vi.fn() const onCreate = vi.fn() diff --git a/packages/client/ui-sidebar/tests/tree.spec.ts b/packages/client/ui-workspace/tests/tree.spec.ts similarity index 100% rename from packages/client/ui-sidebar/tests/tree.spec.ts rename to packages/client/ui-workspace/tests/tree.spec.ts diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 2b76d407f7..200e0811ce 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -934,10 +934,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'list(): Workspace[]', jsDoc: '/**\n * Synchronous workspace projection in durable registry order. Every\n * entity\'s `sessionIds` getter is already filtered by the startup/live\n * canonical-cwd header index; this method performs no persistence reads.\n * @returns a fresh ordered array of workspace entities.\n */', }, - { - signature: 'async touchSession(sessionId: SessionId): Promise', - jsDoc: '/**\n * Move one accounted, cwd-validated session to the front of its workspace.\n * Ungrouped sessions and candidates filtered by the header check are\n * no-ops. The owning workspace\'s relative position never changes.\n * @param sessionId - Session whose activity was observed.\n * @returns resolution after the possible record write.\n */', - }, { signature: 'async resolveByPath(path: string): Promise', jsDoc: '/**\n * Resolve by canonical directory path without creating or mutating a\n * workspace. A missing path rejects during `realpath`; an existing unowned\n * directory returns `undefined`.\n * @param path - Existing directory path in any spelling.\n * @returns the workspace owning the canonical path, when one exists.\n */', @@ -2498,7 +2494,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'Workspace', - declaration: 'export interface Workspace {\n readonly id: WorkspaceId;\n readonly path: string;\n readonly title: string;\n readonly createdAt: string;\n readonly updatedAt: string;\n readonly sessionIds: readonly SessionId[];\n setTitle(title: string): Promise;\n attachSession(sessionId: SessionId): Promise;\n detachSession(sessionId: SessionId): Promise;\n status(): Promise<\'ok\' | \'missing-dir\'>;\n}', + declaration: 'export interface Workspace {\n readonly id: WorkspaceId;\n readonly path: string;\n readonly title: string;\n readonly createdAt: string;\n readonly updatedAt: string;\n readonly sessionIds: readonly SessionId[];\n setTitle(title: string): Promise;\n attachSession(sessionId: SessionId): Promise;\n insertSessionBefore(sessionId: SessionId, beforeSessionId?: SessionId): Promise;\n detachSession(sessionId: SessionId): Promise;\n status(): Promise<\'ok\' | \'missing-dir\'>;\n}', }, ] diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 201bc555a9..d50dd000e1 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -14,7 +14,8 @@ import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' import { foldSessionTitle } from '@deepseek-ai/dsh-session-title' import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace' import { - workspaceDomainState, workspaceRecord, WorkspaceId as brandWorkspaceId, WorkspaceNameConflictError, + workspaceDomainState, workspaceRecord, WorkspaceId as brandWorkspaceId, + WorkspaceMoveInvalidError, WorkspaceNameConflictError, } from '@deepseek-ai/dsh-workspace' // Type-only: brings the `ctx.tools` Context merge into this program (viewFor reads presenters). import type {} from '@deepseek-ai/dsh-tools' @@ -680,6 +681,72 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } }, + async rename(request) { + const { payload } = request + const workspace = ctx.workspace.get(brandWorkspaceId(payload.workspaceId)) + if (workspace === undefined) { + return err(request, { + code: 'workspace-not-found', + message: `workspace "${payload.workspaceId}" not found`, + details: { workspaceId: payload.workspaceId }, + }) + } + const title = payload.title.trim() + // Uniqueness AND the same-title no-op both ride the create chain so + // they observe the state left by earlier queued renames — checked + // up front, a queued A→A could report success while an earlier A→B + // still lands afterwards. + const operation = workspaceCreationChain.then(async () => { + if (title === workspace.title) return + if (ctx.workspace.list().some(other => other.id !== workspace.id && other.title === title)) { + throw new WorkspaceNameConflictError(title) + } + await workspace.setTitle(title) + }) + workspaceCreationChain = operation.then(() => undefined, () => undefined) + try { + await operation + } catch (error: unknown) { + if (error instanceof WorkspaceNameConflictError) { + return err(request, { + code: 'workspace-name-conflict', + message: error.message, + details: { name: error.workspaceName }, + }) + } + throw error + } + return ok(request, { workspace: workspaceView(workspace) }) + }, + + async insertSessionBefore(request) { + const { payload } = request + const workspace = ctx.workspace.get(brandWorkspaceId(payload.workspaceId)) + if (workspace === undefined) { + return err(request, { + code: 'workspace-not-found', + message: `workspace "${payload.workspaceId}" not found`, + details: { workspaceId: payload.workspaceId }, + }) + } + try { + await workspace.insertSessionBefore(payload.sessionId, payload.beforeSessionId) + } catch (error: unknown) { + // Only the entity's unaccounted-id rejection is the business code; + // storage/durability failures propagate as internal errors. + if (!(error instanceof WorkspaceMoveInvalidError)) throw error + return err(request, { + code: 'workspace-move-invalid', + message: error.message, + details: { + workspaceId: payload.workspaceId, + sessionId: payload.sessionId, + ...payload.beforeSessionId === undefined ? {} : { beforeSessionId: payload.beforeSessionId }, + }, + }) + } + return ok(request, { workspace: workspaceView(workspace) }) + }, }, host: { diff --git a/packages/host/apiproxy/src/api/rpc-map.ts b/packages/host/apiproxy/src/api/rpc-map.ts index 1f0f9acef4..68b6289858 100644 --- a/packages/host/apiproxy/src/api/rpc-map.ts +++ b/packages/host/apiproxy/src/api/rpc-map.ts @@ -19,6 +19,8 @@ export interface RpcMethodMap { 'host.describe': HostApi['describe'] 'workspace.list': WorkspaceApi['list'] 'workspace.create': WorkspaceApi['create'] + 'workspace.rename': WorkspaceApi['rename'] + 'workspace.insertSessionBefore': WorkspaceApi['insertSessionBefore'] } /** Business request payload of method K (reaches through the RpcRequest narrow form to payload). */ diff --git a/packages/host/apiproxy/src/api/rpc.schema.ts b/packages/host/apiproxy/src/api/rpc.schema.ts index 3b290e18c3..d83ae2ce98 100644 --- a/packages/host/apiproxy/src/api/rpc.schema.ts +++ b/packages/host/apiproxy/src/api/rpc.schema.ts @@ -40,6 +40,7 @@ export const rpcErrorSchema: z.ZodType = z.discriminatedUnion('code', z.object({ code: z.literal('workspace-not-found'), message: z.string(), details: z.object({ workspaceId: z.string() }) }), z.object({ code: z.literal('workspace-invalid-path'), message: z.string(), details: z.object({ path: z.string() }) }), z.object({ code: z.literal('workspace-name-conflict'), message: z.string(), details: z.object({ name: z.string() }) }), + z.object({ code: z.literal('workspace-move-invalid'), message: z.string(), details: z.object({ workspaceId: z.string(), sessionId: z.string(), beforeSessionId: z.string().optional() }) }), z.object({ code: z.literal('agent-busy'), message: z.string(), details: z.object({ reason: z.string() }) }), z.object({ code: z.literal('internal'), message: z.string(), details: z.object({}) }), ]) as unknown as z.ZodType diff --git a/packages/host/apiproxy/src/api/rpc.ts b/packages/host/apiproxy/src/api/rpc.ts index dbcc975d04..ad06c42fbe 100644 --- a/packages/host/apiproxy/src/api/rpc.ts +++ b/packages/host/apiproxy/src/api/rpc.ts @@ -37,6 +37,7 @@ export interface RpcErrorDetailsMap { 'workspace-not-found': { workspaceId: string } 'workspace-invalid-path': { path: string } 'workspace-name-conflict': { name: string } + 'workspace-move-invalid': { workspaceId: string; sessionId: SessionId; beforeSessionId?: SessionId } 'agent-busy': { reason: string } 'internal': {} } diff --git a/packages/host/apiproxy/src/api/workspace.schema.ts b/packages/host/apiproxy/src/api/workspace.schema.ts index a193fb0c57..47c3ae6d59 100644 --- a/packages/host/apiproxy/src/api/workspace.schema.ts +++ b/packages/host/apiproxy/src/api/workspace.schema.ts @@ -44,3 +44,29 @@ export const workspaceCreateValueSchema = z.object({ workspace: workspaceViewSchema, created: z.boolean(), }) satisfies z.ZodType>> + +/** workspace.rename request payload: the new title must be non-blank. */ +export const workspaceRenameRequestSchema = z.object({ + workspaceId: workspaceIdSchema, + title: z.string(), +}).refine( + payload => payload.title.trim() !== '', + { message: 'workspace.rename requires a non-blank title' }, +) satisfies z.ZodType>> + +/** workspace.rename response value. */ +export const workspaceRenameValueSchema = z.object({ + workspace: workspaceViewSchema, +}) satisfies z.ZodType>> + +/** workspace.insertSessionBefore request payload (anchor omitted = append to end). */ +export const workspaceInsertSessionBeforeRequestSchema = z.object({ + workspaceId: workspaceIdSchema, + sessionId: sessionIdSchema, + beforeSessionId: sessionIdSchema.optional(), +}) satisfies z.ZodType>> + +/** workspace.insertSessionBefore response value. */ +export const workspaceInsertSessionBeforeValueSchema = z.object({ + workspace: workspaceViewSchema, +}) satisfies z.ZodType>> diff --git a/packages/host/apiproxy/src/api/workspace.ts b/packages/host/apiproxy/src/api/workspace.ts index 86c20e2ff5..6ec636126b 100644 --- a/packages/host/apiproxy/src/api/workspace.ts +++ b/packages/host/apiproxy/src/api/workspace.ts @@ -24,7 +24,10 @@ export interface WorkspaceView { path: string /** Unique display title (defaults to the path basename at create). */ title: string - /** Sessions accounted under this workspace, newest-first for display. */ + /** + * Sessions accounted under this workspace, in manually owned order + * (attach prepends, insertSessionBefore reorders; activity never does). + */ sessionIds: SessionId[] /** ISO-8601 creation instant. */ createdAt: string @@ -52,4 +55,27 @@ export interface WorkspaceApi { */ create(request: RpcRequest<{ path?: string; name?: string }>): Promise> + + /** + * Renames a workspace. `title` is trimmed and must be non-empty + * (schema-enforced). An unknown id fails with `workspace-not-found`; a + * title equal to another workspace's fails with `workspace-name-conflict`. + * Renaming to the current title is a no-op success (no durable write). + */ + rename(request: RpcRequest<{ workspaceId: WorkspaceId; title: string }>): + Promise> + + /** + * Moves an accounted session within its workspace's manual order, + * DOM-insertBefore-like: with `beforeSessionId` the session is inserted + * before that anchor; omitted appends to the end. An unknown workspace + * fails with `workspace-not-found`; a session or anchor not accounted by + * the workspace fails with `workspace-move-invalid`. A move to the current + * position is a no-op success. + */ + insertSessionBefore(request: RpcRequest<{ + workspaceId: WorkspaceId + sessionId: SessionId + beforeSessionId?: SessionId + }>): Promise> } diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts index 81749fc219..91c4405ace 100644 --- a/packages/host/apiproxy/src/fetch/client.ts +++ b/packages/host/apiproxy/src/fetch/client.ts @@ -23,7 +23,9 @@ import { } from '../api/sessions.schema.ts' import { workspaceCreateValueSchema, + workspaceInsertSessionBeforeValueSchema, workspaceListValueSchema, + workspaceRenameValueSchema, } from '../api/workspace.schema.ts' /** @@ -55,6 +57,8 @@ export interface IApiClient { workspace: { list(payload: RequestPayload<'workspace.list'>, signal?: AbortSignal): Promise>> create(payload: RequestPayload<'workspace.create'>, signal?: AbortSignal): Promise>> + rename(payload: RequestPayload<'workspace.rename'>, signal?: AbortSignal): Promise>> + insertSessionBefore(payload: RequestPayload<'workspace.insertSessionBefore'>, signal?: AbortSignal): Promise>> } events: { mux(payload: Parameters[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable> @@ -77,6 +81,8 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType this.callUnary('workspace.list', payload, signal), create: (payload, signal) => this.callUnary('workspace.create', payload, signal), + rename: (payload, signal) => this.callUnary('workspace.rename', payload, signal), + insertSessionBefore: (payload, signal) => this.callUnary('workspace.insertSessionBefore', payload, signal), } readonly events: IApiClient['events'] = { diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index e876d664b2..91762810e8 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -24,7 +24,9 @@ import { import { hostDescribeRequestSchema } from '../api/host.schema.ts' import { workspaceCreateRequestSchema, + workspaceInsertSessionBeforeRequestSchema, workspaceListRequestSchema, + workspaceRenameRequestSchema, } from '../api/workspace.schema.ts' /** @@ -50,6 +52,8 @@ const UNARY_ROUTES: UnaryRoutes = { 'host.describe': { schema: hostDescribeRequestSchema, invoke: (api, r) => api.host.describe(r) }, 'workspace.list': { schema: workspaceListRequestSchema, invoke: (api, r) => api.workspace.list(r) }, 'workspace.create': { schema: workspaceCreateRequestSchema, invoke: (api, r) => api.workspace.create(r) }, + 'workspace.rename': { schema: workspaceRenameRequestSchema, invoke: (api, r) => api.workspace.rename(r) }, + 'workspace.insertSessionBefore': { schema: workspaceInsertSessionBeforeRequestSchema, invoke: (api, r) => api.workspace.insertSessionBefore(r) }, } /** Route lookup that narrows an arbitrary path segment to a map key (single cast point for the string→key refinement). */ diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index 7dec5980eb..25b095b99f 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -37,6 +37,8 @@ function scriptedApi(overrides: { workspace: { list: r => ok(r, { items: [] }), create: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' }, created: true }), + rename: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' } }), + insertSessionBefore: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' } }), }, events: { mux: () => empty(), host: () => empty(), ...overrides.events }, respond: overrides.respond ?? (() => Promise.resolve({ accepted: false as const, reason: 'not-pending' as const })), diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index ede6acb9ac..e4b1d33e87 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -52,6 +52,18 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra result: { ok: true, value: { workspace: { workspaceId: 'w1' as never, path: '/w', title: 'w', sessionIds: [], createdAt: 't', updatedAt: 't' }, created: true } }, } }, + async rename(request) { + return { + rpcId: request.rpcId, + result: { ok: true, value: { workspace: { workspaceId: 'w1' as never, path: '/w', title: 'w', sessionIds: [], createdAt: 't', updatedAt: 't' } } }, + } + }, + async insertSessionBefore(request) { + return { + rpcId: request.rpcId, + result: { ok: true, value: { workspace: { workspaceId: 'w1' as never, path: '/w', title: 'w', sessionIds: [], createdAt: 't', updatedAt: 't' } } }, + } + }, }, events: { mux: (_request, signal) => stream(muxFrames, signal), diff --git a/packages/workspace/workspace/src/entity.ts b/packages/workspace/workspace/src/entity.ts index 7f213db0b8..1df3eacb3d 100644 --- a/packages/workspace/workspace/src/entity.ts +++ b/packages/workspace/workspace/src/entity.ts @@ -15,6 +15,17 @@ import type { WorkspaceRecord } from './spec.ts' import type { Workspace, WorkspaceId } from './types.ts' import { realpathNormalize } from './paths.ts' +/** An insertSessionBefore request named a session or anchor not on the account (storage failures stay plain errors). */ +export class WorkspaceMoveInvalidError extends Error { + /** + * @param message - Which id was unaccounted and where. + */ + constructor(message: string) { + super(message) + this.name = 'WorkspaceMoveInvalidError' + } +} + /** * The registry-owned machinery an entity mutates through. Entities never see * the registry itself — only the open table, the canonical session-path @@ -137,30 +148,27 @@ export class WorkspaceEntity implements Workspace { : { ...record, sessionIds: [sessionId, ...record.sessionIds] }) } - /** - * Test the durable candidate account without applying header projection. - * @param sessionId - Candidate session id. - * @returns whether this workspace's stored account contains the id. - */ - hasSession(sessionId: SessionId): boolean { - return this.record.sessionIds.includes(sessionId) - } - - /** - * Move one validated accounted session to the front without touching peers. - * @param sessionId - Accounted session whose activity was observed. - */ - async touchSession(sessionId: SessionId): Promise { - if ( - this.host.sessionPath(sessionId) !== this.record.path - || this.record.sessionIds[0] === sessionId - ) return - await this.mutate(record => !record.sessionIds.includes(sessionId) || record.sessionIds[0] === sessionId - ? record - : { - ...record, - sessionIds: [sessionId, ...record.sessionIds.filter(id => id !== sessionId)], - }) + async insertSessionBefore(sessionId: SessionId, beforeSessionId?: SessionId): Promise { + await this.mutate((record) => { + if (!record.sessionIds.includes(sessionId)) { + throw new WorkspaceMoveInvalidError( + `cannot move session '${sessionId}' in workspace '${record.path}': the session is not accounted`, + ) + } + if (beforeSessionId !== undefined && !record.sessionIds.includes(beforeSessionId)) { + throw new WorkspaceMoveInvalidError( + `cannot move session '${sessionId}' before '${beforeSessionId}' in workspace '${record.path}': ` + + 'the anchor session is not accounted', + ) + } + if (beforeSessionId === sessionId) return record + const without = record.sessionIds.filter(id => id !== sessionId) + const at = beforeSessionId === undefined ? without.length : without.indexOf(beforeSessionId) + const sessionIds = [...without.slice(0, at), sessionId, ...without.slice(at)] + return sessionIds.every((id, index) => id === record.sessionIds[index]) + ? record + : { ...record, sessionIds } + }) } async detachSession(sessionId: SessionId): Promise { diff --git a/packages/workspace/workspace/src/index.ts b/packages/workspace/workspace/src/index.ts index c20c2143c6..0f849e7374 100644 --- a/packages/workspace/workspace/src/index.ts +++ b/packages/workspace/workspace/src/index.ts @@ -14,6 +14,8 @@ import type {} from '@deepseek-ai/dsh-session-persistence' import type { DomainGlobal, KvTable } from '@deepseek-ai/dsh-storage-domain' import { WorkspaceEntity } from './entity.ts' import type { WorkspaceEntityHost } from './entity.ts' + +export { WorkspaceMoveInvalidError } from './entity.ts' import { realpathNormalize } from './paths.ts' import { workspaceDomainSpec } from './spec.ts' import type { WorkspaceDomainState, WorkspaceRecord } from './spec.ts' @@ -47,6 +49,7 @@ export class WorkspaceNameConflictError extends Error { } } + declare module 'cordis' { interface Context { workspace: WorkspaceRegistry @@ -82,7 +85,6 @@ export class WorkspaceRegistry extends Service { private readonly headers = new Map() private readonly sessionPaths = new Map() private readonly invalidSessionPaths = new Map() - private readonly pendingTouches = new Map>() private operationTail: Promise = Promise.resolve() private readonly host: WorkspaceEntityHost = { @@ -120,13 +122,6 @@ export class WorkspaceRegistry extends Service { this.validateStoredState(this.requireState()) this.rebuildEntities() this.reportFilteredCandidates() - // Session activity is authoritative even when no RPC/SSE consumer is - // connected. This service-owned listener is disposed with the registry. - this.ctx.on('session/event', (session) => { - void this.touchSession(session.id).catch((error: unknown) => { - this.ctx.logger.warn(`workspace activity touch failed for session '${session.id}': ${String(error)}`) - }) - }) } /** @@ -173,32 +168,6 @@ export class WorkspaceRegistry extends Service { }) } - /** - * Move one accounted, cwd-validated session to the front of its workspace. - * Ungrouped sessions and candidates filtered by the header check are - * no-ops. The owning workspace's relative position never changes. - * @param sessionId - Session whose activity was observed. - * @returns resolution after the possible record write. - */ - async touchSession(sessionId: SessionId): Promise { - const pending = this.pendingTouches.get(sessionId) - if (pending !== undefined) { - await pending - return - } - for (const entity of this.entities.values()) { - if (!entity.hasSession(sessionId)) continue - const touch = entity.touchSession(sessionId) - this.pendingTouches.set(sessionId, touch) - try { - await touch - } finally { - this.pendingTouches.delete(sessionId) - } - return - } - } - /** * Resolve by canonical directory path without creating or mutating a * workspace. A missing path rejects during `realpath`; an existing unowned diff --git a/packages/workspace/workspace/src/types.ts b/packages/workspace/workspace/src/types.ts index ca254ca2cb..09d37213cc 100644 --- a/packages/workspace/workspace/src/types.ts +++ b/packages/workspace/workspace/src/types.ts @@ -41,10 +41,12 @@ export interface Workspace { readonly updatedAt: string /** - * Header-validated sessions in newest-first display order. The durable - * candidate account is filtered synchronously: missing headers, invalid - * cwd values, and canonical cwd mismatches are never returned. A subsequent - * workspace mutation prunes those filtered candidates durably. + * Header-validated sessions in manually owned order: a new session is + * prepended at attach, explicit reordering goes through + * `insertSessionBefore`, and activity never reorders. The durable candidate + * account is filtered synchronously: missing headers, invalid cwd values, + * and canonical cwd mismatches are never returned. A subsequent workspace + * mutation prunes those filtered candidates durably. */ readonly sessionIds: readonly SessionId[] @@ -57,8 +59,7 @@ export interface Workspace { /** * Prepend a session to this workspace's candidate account. An already - * accounted id resolves without writing; activity-driven reordering uses - * `WorkspaceRegistry.touchSession` instead. A new id's live or persisted + * accounted id resolves without writing. A new id's live or persisted * header cwd must resolve to an existing directory equal to {@link path}; * unknown ids, missing or invalid cwd values, and mismatches reject without * writing. @@ -67,6 +68,18 @@ export interface Workspace { */ attachSession(sessionId: SessionId): Promise + /** + * Move an accounted session within the manual order, DOM-insertBefore-like: + * with an anchor the session lands before it, without one it appends to the + * end. Only the moved id changes position. A session or anchor absent from + * the account rejects without writing; a move to the current position + * resolves without writing (decided on the domain write chain). + * @param sessionId - The accounted session to move. + * @param beforeSessionId - Accounted anchor to insert before; omitted appends. + * @returns resolution after durability. + */ + insertSessionBefore(sessionId: SessionId, beforeSessionId?: SessionId): Promise + /** * Remove a session from this workspace's account. Idempotent: an id not on * the account resolves without writing (decided on the domain write chain, diff --git a/packages/workspace/workspace/tests/workspace.spec.ts b/packages/workspace/workspace/tests/workspace.spec.ts index cde0b35098..bdefc2ec82 100644 --- a/packages/workspace/workspace/tests/workspace.spec.ts +++ b/packages/workspace/workspace/tests/workspace.spec.ts @@ -10,7 +10,6 @@ import type { DomainChanged } from '@deepseek-ai/dsh-storage-domain' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { SessionHeader } from '@deepseek-ai/dsh-session' import { MemoryMediaPool, MemoryStorageBackend } from '../../../storage/storage-domain/tests/helpers/memory-backend.ts' -import { WorkspaceEntity } from '../src/entity.ts' import WorkspaceRegistry, { WorkspaceId, WorkspaceNameConflictError } from '../src/index.ts' import type { WorkspaceDomainState, WorkspaceRecord } from '../src/index.ts' @@ -434,13 +433,12 @@ describe('WorkspaceRegistry create and lookup', () => { }) describe('Workspace session ordering', () => { - it('prepends new attaches, keeps repeat attach idempotent, and touches one id only', async () => { + it('prepends new attaches and keeps repeat attach idempotent', async () => { const dir = await makeDir('attach-order') const result = await harness() result.setSessions([ header('s1', dir, 1), header('s2', dir, 2), - header('ungrouped', dir, 3), ]) const workspace = await result.registry.create(dir) await workspace.attachSession(SessionId('s1')) @@ -448,59 +446,7 @@ describe('Workspace session ordering', () => { expect(workspace.sessionIds).toEqual(['s2', 's1']) await workspace.attachSession(SessionId('s1')) expect(workspace.sessionIds).toEqual(['s2', 's1']) - - const beforeTouch = result.changes.length - await Promise.all([ - result.registry.touchSession(SessionId('s1')), - result.registry.touchSession(SessionId('s1')), - ]) - expect(workspace.sessionIds).toEqual(['s1', 's2']) - expect(result.changes).toHaveLength(beforeTouch + 1) - await result.registry.touchSession(SessionId('s1')) - expect(result.changes).toHaveLength(beforeTouch + 1) - await result.registry.touchSession(SessionId('ungrouped')) - expect(result.changes).toHaveLength(beforeTouch + 1) - expect(storedRecord(result.pool, workspace.id).sessionIds).toEqual(['s1', 's2']) - }) - - it('does not resurrect a session detached before its queued touch', async () => { - const dir = await makeDir('detach-touch-race') - const result = await harness({ sessions: [header('s1', dir), header('s2', dir)] }) - const workspace = await result.registry.create(dir) - await workspace.attachSession(SessionId('s1')) - await workspace.attachSession(SessionId('s2')) - await Promise.all([ - workspace.detachSession(SessionId('s1')), - result.registry.touchSession(SessionId('s1')), - ]) - const written = result.changes.length - await workspace.detachSession(SessionId('absent')) - expect(result.changes).toHaveLength(written) - expect(workspace.sessionIds).toEqual(['s2']) - }) - - it('does not reinsert a candidate absent at the durable touch slot', async () => { - const dir = await makeDir('stale-touch') - const id = WorkspaceId('00000000-0000-4000-8000-000000000030') - let durable = record(dir, ['s2', 's1']) - const table = { - update: async ( - _id: WorkspaceId, - update: (current: WorkspaceRecord) => WorkspaceRecord, - ): Promise => { - durable = { ...durable, sessionIds: [SessionId('s2')] } - durable = update(durable) - return durable - }, - } - const entity = new WorkspaceEntity({ - table: () => table as never, - sessionPath: () => dir, - readSessionHeader: async () => header('s1', dir), - rememberSessionPath: () => {}, - }, id, record(dir, ['s2', 's1'])) - await entity.touchSession(SessionId('s1')) - expect(durable.sessionIds).toEqual(['s2']) + expect(storedRecord(result.pool, workspace.id).sessionIds).toEqual(['s2', 's1']) }) it('validates a lazy live session without requiring it in persistence.list()', async () => { @@ -546,66 +492,6 @@ describe('Workspace session ordering', () => { expect(workspace.sessionIds).toEqual(['s1']) }) - it('keeps workspace order stable while touch order survives reload', async () => { - const older = await makeDir('stable-older') - const newer = await makeDir('stable-newer') - const sessions = [ - header('old-1', older, 100), - header('old-2', older, 200), - header('new-1', newer, 300), - ] - const pool = new MemoryMediaPool() - const first = await harness({ pool, sessions }) - const originalWorkspaceIds = first.registry.list().map(workspace => workspace.id) - const oldWorkspace = first.registry.list().find(workspace => workspace.path === older)! - expect(oldWorkspace.sessionIds).toEqual(['old-2', 'old-1']) - await first.registry.touchSession(SessionId('old-1')) - expect(oldWorkspace.sessionIds).toEqual(['old-1', 'old-2']) - expect(first.registry.list().map(workspace => workspace.id)).toEqual(originalWorkspaceIds) - await first.fiber.dispose() - - const reloaded = await harness({ pool, sessions }) - expect(reloaded.registry.list().map(workspace => workspace.id)).toEqual(originalWorkspaceIds) - expect(reloaded.registry.list().find(workspace => workspace.path === older)!.sessionIds) - .toEqual(['old-1', 'old-2']) - }) - - it('persists activity order from session/event without any stream consumer', async () => { - const dir = await makeDir('event-touch') - const result = await harness({ sessionStore: true }) - const workspace = await result.registry.create(dir) - const first = result.ctx.sessions.create(SessionId('event-first'), { meta: { cwd: dir } }) - result.ctx.sessions.create(SessionId('event-second'), { meta: { cwd: dir } }) - await workspace.attachSession(SessionId('event-first')) - await workspace.attachSession(SessionId('event-second')) - expect(workspace.sessionIds).toEqual(['event-second', 'event-first']) - - first.append('turn/start', { - turn: 1, - trigger: { kind: 'message', source: { kind: 'user' } }, - }) - await vi.waitFor(() => { expect(workspace.sessionIds).toEqual(['event-first', 'event-second']) }) - expect(storedRecord(result.pool, workspace.id).sessionIds) - .toEqual(['event-first', 'event-second']) - }) - - it('contains a background activity write failure at the service listener', async () => { - const dir = await makeDir('event-touch-failure') - const result = await harness({ sessionStore: true }) - const workspace = await result.registry.create(dir) - const first = result.ctx.sessions.create(SessionId('failed-first'), { meta: { cwd: dir } }) - result.ctx.sessions.create(SessionId('failed-second'), { meta: { cwd: dir } }) - await workspace.attachSession(SessionId('failed-first')) - await workspace.attachSession(SessionId('failed-second')) - const warn = vi.spyOn(result.ctx.logger, 'warn') - result.pool.failNextWrites = 1 - first.append('turn/start', { - turn: 1, - trigger: { kind: 'message', source: { kind: 'user' } }, - }) - await vi.waitFor(() => { expect(warn).toHaveBeenCalledWith(expect.stringContaining('touch failed')) }) - expect(workspace.sessionIds).toEqual(['failed-second', 'failed-first']) - }) }) describe('header-validated membership projection', () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7f19dcce27..7301079cd1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1014,6 +1014,10 @@ importers: version: 18.3.1 packages/client/ui-workspace: + dependencies: + clsx: + specifier: ^2.0.0 + version: 2.1.1 devDependencies: '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ From b1b180e098aef9e39e3c2e889dca7dcd48fea6c1 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 26 Jul 2026 00:57:21 +0800 Subject: [PATCH 08/32] =?UTF-8?q?fix(gui):=20CI=20gate=20repairs=20?= =?UTF-8?q?=E2=80=94=20docs,=20coverage,=20HMR=20re-registration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regenerate the module-graph/config-catalog/event-graph docs for the locale rename and new packages; allowlist the three settings READMEs; complete the RFC code block and add its English pair. Cover the ThemeService media-query paths (stubbed matchMedia) and mark the unreachable registry fallback. General section re-registration now judges presence on the slot ledger instead of a local disposer, which went stale when an HMR collapse removed the entry. --- ...-25-client-settings-locale-theme.i18n.yaml | 6 + ...2026-07-25-client-settings-locale-theme.md | 112 ++++++++++++++++++ ...6-07-25-client-settings-locale-theme.zh.md | 13 ++ docs/config-catalog.md | 5 +- docs/event-producer-consumer.md | 2 + docs/module-graph.md | 26 +++- .../ui-settings-general/src/client/index.ts | 20 ++-- packages/client/ui-theme/src/client/index.ts | 1 + packages/client/ui-theme/tests/theme.spec.ts | 51 +++++++- .../verify-package-readme-model-experience.ts | 3 + 10 files changed, 225 insertions(+), 14 deletions(-) create mode 100644 .agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.i18n.yaml create mode 100644 .agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.md diff --git a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.i18n.yaml b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.i18n.yaml new file mode 100644 index 0000000000..54fb6ee9ee --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.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 +2026-07-25-client-settings-locale-theme.md: 25e22c728ea509613c4d7f6cdfcd09faf4685760 +2026-07-25-client-settings-locale-theme.zh.md: 9c2457b254aa826291d4c5a4e115e9ba2d391974 diff --git a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.md b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.md new file mode 100644 index 0000000000..25e22c728e --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.md @@ -0,0 +1,112 @@ +# Agent Note: Client Settings, Locale, and Theme layering + +Status: proposed + +English | [中文](2026-07-25-client-settings-locale-theme.zh.md) + +## Problem + +The browser client's existing Settings is written directly inside the Sidebar, and language and theme are applied by component-local state mutating the DOM directly. As a result Settings cannot be extended by independent plugins, preference state has no stable cross-plugin service contract, and the theme registry carries both state and presentation responsibilities. + +## Proposal + +The Sidebar declares the `sidebar.settings` single slot; `ui-settings` occupies it and declares the `settings.section` list slot. Each section is contributed by an independent plugin; the Settings shell only reads entry metadata from the slot ledger to build the navigation, rendering the current section via `only`. + +The Settings entry is the Settings row in the sidebar Foot; clicking it directly opens a 1080×700 centered overlay (black 24% mask); the close button, a mask click, and ESC all close it. There is no intermediate menu form of any kind. + +`@deepseek-ai/dsh-client-locale` provides `ctx.locale`; `ui-theme` provides `ctx.theme`. Both services read through a getter, write through a setter, and publish immutable snapshots via typed Cordis change events; each service persists its own preference (storing only the id, with bad values falling back to the default). + +General's apply layer subscribes to `locale/change` and `theme/change` and projects the snapshots into the Zustand store declared by that section. React components only read `useStore` and write through the injected setter callbacks, never reading ctx or the services. + +The theme preference has three states — `light`, `dark`, `system` — defaulting to `system` (when no persisted preference exists or the value is bad). Resolving system belongs to the theme domain: ThemeService holds the `prefers-color-scheme` matchMedia listener (environment sensing, not DOM presentation) and re-emits the snapshot when the preference is system and the system color scheme changes; the snapshot carries both `preference` and the resolved `active` definition. + +The theme service never touches the DOM. `ui-layout` reads the Theme getter initially and then subscribes to `theme/change`; the presenter owned by Layout updates `body[data-ds-dark-theme]` and the theme tokens according to `active`. The presenter has no notion of system — it consumes only resolved results. + +### First-phase section scope + +| section | Plugin | First-phase content | +|---|---|---| +| General | `ui-settings-general` | Language (Selector dropdown) and Appearance (Light/Dark/System three cubes) genuinely switch; Permission and Tool Call are visual skeletons only, with no write operations | +| Models | `ui-settings-models` | Navigation item only; the content area is empty | +| Plugin | no package | Not built this phase, and the navigation does not show the item (an external-link entry with no target never renders; once a later plugin registers the section it appears automatically) | + +The first phase localizes only the copy inside the Settings overlay (the General rows plus the navigation); copy on other pages is untouched. + +### Slot topology + +```text +root +└─ sidebar + └─ sidebar.settings single/root + └─ ui-settings + └─ settings.section list/root + ├─ general ui-settings-general + └─ models ui-settings-models +``` + +Section contributions use declaration-aware deferral and do not depend on the client manifest's apply order. + +### Service contracts + +```ts +type ThemePreference = 'light' | 'dark' | 'system' + +interface ThemeDefinition { + id: string + colorScheme: 'light' | 'dark' + tokens: Record +} + +interface ThemeSnapshot { + preference: ThemePreference + active: ThemeDefinition // system 已解析为具体 light/dark 定义 + themes: readonly ThemeDefinition[] + revision: number +} + +interface LocaleDefinition { + id: 'zh' | 'en' + label: string +} + +interface LocaleSnapshot { + active: 'zh' | 'en' + locales: readonly LocaleDefinition[] + revision: number +} + +interface Events { + /** @param snapshot - Current locale registry snapshot. @mode emit */ + 'locale/change'(snapshot: LocaleSnapshot): void + /** @param snapshot - Current theme registry snapshot. @mode emit */ + 'theme/change'(snapshot: ThemeSnapshot): void +} +``` + +Locale ships with 中文 and English built in; `setLocale`/`setTheme` are the only write entry points, and an unknown id fails. + +## Alternatives considered + +**Having the app shell subscribe to preferences centrally and re-render the root slot tree.** A language or theme change only needs to update the actual consumers; a whole-tree refresh amplifies the blast radius and wires business preferences into the shell. + +**The theme service mutating the DOM directly.** The registry service would then depend on the presentation environment, with unclear lifecycle and global-style ownership; Layout already owns the page-root presentation boundary. + +**Resolving system in the Layout presenter.** The presenter would need its own matchMedia subscription and would pick the concrete definition out of the themes list, forcing the presentation layer to understand preference semantics; resolving on the service side gives every consumer the same resolved snapshot. + +**Settings importing and enumerating the sections.** Adding a page would require modifying the shell plugin, breaking the composition model where each feature occupies a slot from its own plugin. + +**Injecting the Locale/Theme snapshots into React directly.** Inject results are cached by entry identity, so volatile values go stale; hand-rolling a React hook per service also bypasses the slot store's unified binding. + +## Acceptance criteria + +- The Settings shell depends only on the slot ledger, never on any section implementation. +- Locale and Theme writes go only through the setters; ongoing synchronization goes only through the change events. +- The General store initializes from the getters and is thereafter updated by the two events with local re-renders. +- Layout applies the theme snapshot on its own and the theme service never accesses the DOM; no system branch appears in the presenter. +- 中文/English and Light/Dark/System switch and are restored after a refresh; with the preference on system, a system color-scheme change takes effect immediately. +- Models has only a navigation item and an empty content area; the Permission and Tool Call skeletons perform no writes. +- The overlay closes via the close button, a mask click, and ESC. + +## Risks + +The apply order of slot declarations and contributions is not fixed, so every new section must keep declaration-aware registration and idempotence guards. Service events may fire before a section's first render, so both the General store's init and the controller attach must align to the current snapshot from the getters. Layout must clean up the global attributes it set on unmount, and ThemeService must remove its matchMedia listener on dispose, so nothing lingers after HMR. diff --git a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.zh.md b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.zh.md index d3c61794b9..9c2457b254 100644 --- a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.zh.md +++ b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.zh.md @@ -2,6 +2,8 @@ Status: proposed +[English](2026-07-25-client-settings-locale-theme.md) | 中文 + ## Problem 浏览器端已有的 Settings 直接写在 Sidebar 内,语言和主题也由组件本地状态直接改 DOM。这使 Settings 无法由独立插件扩展,偏好状态没有稳定的跨插件服务契约,主题 registry 同时承担状态与呈现职责。 @@ -49,6 +51,12 @@ section contribution 使用 declaration-aware deferral,不依赖 client manife ```ts type ThemePreference = 'light' | 'dark' | 'system' +interface ThemeDefinition { + id: string + colorScheme: 'light' | 'dark' + tokens: Record +} + interface ThemeSnapshot { preference: ThemePreference active: ThemeDefinition // system 已解析为具体 light/dark 定义 @@ -56,6 +64,11 @@ interface ThemeSnapshot { revision: number } +interface LocaleDefinition { + id: 'zh' | 'en' + label: string +} + interface LocaleSnapshot { active: 'zh' | 'en' locales: readonly LocaleDefinition[] diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 6c7627aca4..ede904e1a5 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2017,12 +2017,15 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-agent` ([`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts)) - `@deepseek-ai/dsh-client-connection` — requires `httpServer` · `apiProxy` ([`packages/client/connection/src/index.ts`](../packages/client/connection/src/index.ts)) -- `@deepseek-ai/dsh-client-i18n` ([`packages/client/i18n/src/index.ts`](../packages/client/i18n/src/index.ts)) +- `@deepseek-ai/dsh-client-locale` ([`packages/client/locale/src/index.ts`](../packages/client/locale/src/index.ts)) - `@deepseek-ai/dsh-client-modules` — requires `httpServer` · `loader` ([`packages/client/modules/src/index.ts`](../packages/client/modules/src/index.ts)) - `@deepseek-ai/dsh-client-runtime` ([`packages/client/runtime/src/index.ts`](../packages/client/runtime/src/index.ts)) - `@deepseek-ai/dsh-client-ui-conversation` ([`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts)) - `@deepseek-ai/dsh-client-ui-layout` ([`packages/client/ui-layout/src/index.ts`](../packages/client/ui-layout/src/index.ts)) - `@deepseek-ai/dsh-client-ui-question` — requires `tools` · `userInteraction` ([`packages/client/ui-question/src/index.ts`](../packages/client/ui-question/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-settings` ([`packages/client/ui-settings/src/index.ts`](../packages/client/ui-settings/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-settings-general` ([`packages/client/ui-settings-general/src/index.ts`](../packages/client/ui-settings-general/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-settings-models` ([`packages/client/ui-settings-models/src/index.ts`](../packages/client/ui-settings-models/src/index.ts)) - `@deepseek-ai/dsh-client-ui-sidebar` ([`packages/client/ui-sidebar/src/index.ts`](../packages/client/ui-sidebar/src/index.ts)) - `@deepseek-ai/dsh-client-ui-theme` ([`packages/client/ui-theme/src/index.ts`](../packages/client/ui-theme/src/index.ts)) - `@deepseek-ai/dsh-client-ui-trajectory` ([`packages/client/ui-trajectory/src/index.ts`](../packages/client/ui-trajectory/src/index.ts)) diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index d9521f7d67..2c1e85fb3c 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -63,6 +63,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `internal/dispatch` | - | [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`pty-local`](../packages/pty/pty-local), `runtime`, [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`workflow`](../packages/workflow/workflow) | | `internal/plugin` | - | `hmr`, `modules`, `webserver` | | `internal/status` | - | [`agent`](../packages/core/agent) | +| `locale/change` | `locale` (`emit`) | `ui-settings-general`, `ui-settings-models` | | `slots/changed` | `runtime` (`emit`) | - | +| `theme/change` | `ui-theme` (`emit`) | `ui-layout`, `ui-settings-general` | Maintenance mode: generated: Cordis event declarations and producer/listener edges are resolved from the repository TypeScript Program. diff --git a/docs/module-graph.md b/docs/module-graph.md index 9e8b3adf8a..c47bd92d83 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -136,13 +136,16 @@ flowchart TD subgraph group_client["packages/client"] pkg_client_connection["client-connection"] pkg_client_hmr["client-hmr"] - pkg_client_i18n["client-i18n"] + pkg_client_locale["client-locale"] pkg_client_modules["client-modules"] pkg_client_runtime["client-runtime"] pkg_client_ui_conversation["client-ui-conversation"] pkg_client_ui_layout["client-ui-layout"] pkg_client_ui_primitives["client-ui-primitives"] pkg_client_ui_question["client-ui-question"] + pkg_client_ui_settings["client-ui-settings"] + pkg_client_ui_settings_general["client-ui-settings-general"] + pkg_client_ui_settings_models["client-ui-settings-models"] pkg_client_ui_sidebar["client-ui-sidebar"] pkg_client_ui_slots["client-ui-slots"] pkg_client_ui_theme["client-ui-theme"] @@ -225,7 +228,7 @@ flowchart TD pkg_subagent_subprocess --> pkg_invariants pkg_acp_snapshot --> pkg_invariants pkg_loader_smoke --> pkg_invariants - pkg_client_i18n --> pkg_invariants + pkg_client_locale --> pkg_invariants pkg_client_modules --> pkg_invariants pkg_client_runtime --> pkg_invariants pkg_client_ui_primitives --> pkg_invariants @@ -253,7 +256,19 @@ flowchart TD pkg_client_ui_conversation --> pkg_invariants pkg_client_ui_layout --> pkg_client_runtime pkg_client_ui_layout --> pkg_client_ui_slots + pkg_client_ui_layout --> pkg_client_ui_theme pkg_client_ui_layout --> pkg_invariants + pkg_client_ui_settings --> pkg_client_runtime + pkg_client_ui_settings --> pkg_client_ui_primitives + pkg_client_ui_settings --> pkg_client_ui_slots + pkg_client_ui_settings --> pkg_invariants + pkg_client_ui_settings_general --> pkg_client_runtime + pkg_client_ui_settings_general --> pkg_client_ui_primitives + pkg_client_ui_settings_general --> pkg_client_ui_slots + pkg_client_ui_settings_general --> pkg_invariants + pkg_client_ui_settings_models --> pkg_client_runtime + pkg_client_ui_settings_models --> pkg_client_ui_slots + pkg_client_ui_settings_models --> pkg_invariants pkg_client_ui_sidebar --> pkg_client_runtime pkg_client_ui_sidebar --> pkg_client_ui_primitives pkg_client_ui_sidebar --> pkg_client_ui_slots @@ -798,7 +813,7 @@ flowchart TD | [`subagent-subprocess`](../packages/subagent/subagent-subprocess) | `subagent` | [`invariants`](../packages/support/invariants) | | [`acp-snapshot`](../packages/support/acp-snapshot) | `support` | [`invariants`](../packages/support/invariants) | | [`loader-smoke`](../packages/support/loader-smoke) | `support` | [`invariants`](../packages/support/invariants) | -| [`client-i18n`](../packages/client/i18n) | `client` | [`invariants`](../packages/support/invariants) | +| [`client-locale`](../packages/client/locale) | `client` | [`invariants`](../packages/support/invariants) | | [`client-modules`](../packages/client/modules) | `client` | [`invariants`](../packages/support/invariants) | | [`client-runtime`](../packages/client/runtime) | `client` | [`invariants`](../packages/support/invariants) | | [`client-ui-primitives`](../packages/client/ui-primitives) | `client` | [`invariants`](../packages/support/invariants) | @@ -817,7 +832,10 @@ flowchart TD | [`client-connection`](../packages/client/connection) | `client` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) | +| [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-settings-models`](../packages/client/ui-settings-models) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | diff --git a/packages/client/ui-settings-general/src/client/index.ts b/packages/client/ui-settings-general/src/client/index.ts index 0e59ce2116..f8f8a73304 100644 --- a/packages/client/ui-settings-general/src/client/index.ts +++ b/packages/client/ui-settings-general/src/client/index.ts @@ -79,7 +79,14 @@ export function apply(ctx: ClientContext): void { ctx.effect(() => { let dispose: (() => void) | undefined - const register = (): void => { + // Presence is judged on the ledger, not on the local disposer: an HMR + // collapse of the declaring entry removes this entry from the slot core + // while `dispose` stays set (the stale disposer is a no-op), so a local + // guard would block the re-registration when the declaration returns. + const registered = (): boolean => + ctx.slots.entries('settings.section').some(e => e.component === GeneralSection) + const tryRegister = (): void => { + if (ctx.slots.spec('settings.section') === undefined || registered()) return dispose = ctx.slots.register({ name: 'settings.section', id: 'general', @@ -89,16 +96,13 @@ export function apply(ctx: ClientContext): void { inject: injected, }, GeneralSection) } - const tryRegister = (): void => { - if (ctx.slots.spec('settings.section') === undefined || dispose !== undefined) return - register() - } // Nav labels are registrant-localized: re-register on locale change so // the ledger carries fresh text (the version bump re-renders the shell). const offLocale = ctx.on('locale/change', () => { - if (dispose === undefined) return - dispose() - register() + if (!registered()) return + dispose?.() + dispose = undefined + tryRegister() }) const unsubscribe = ctx.slots.subscribe('settings.section', () => { tryRegister() }) tryRegister() diff --git a/packages/client/ui-theme/src/client/index.ts b/packages/client/ui-theme/src/client/index.ts index 5ba5d40b35..b8a75c4bed 100644 --- a/packages/client/ui-theme/src/client/index.ts +++ b/packages/client/ui-theme/src/client/index.ts @@ -157,6 +157,7 @@ export class ThemeService { : this.preference // Both built-ins always exist; a registered preference id resolves or has // been reset by its disposer, so the lookup cannot miss. + /* v8 ignore next -- the ?? arm needs a registry without light/dark, which register()/dispose() cannot produce */ const active = this.themes.find(t => t.id === resolvedId) ?? this.themes[0]! return Object.freeze({ preference: this.preference, diff --git a/packages/client/ui-theme/tests/theme.spec.ts b/packages/client/ui-theme/tests/theme.spec.ts index 9ab0ab8f0f..fdac111e73 100644 --- a/packages/client/ui-theme/tests/theme.spec.ts +++ b/packages/client/ui-theme/tests/theme.spec.ts @@ -1,5 +1,5 @@ // @vitest-environment jsdom -import { beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client' import { STORAGE_KEY, ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client' @@ -87,4 +87,53 @@ describe('ThemeService', () => { dispose() expect(events.map(e => e.revision)).toEqual([1, 2, 3, 4]) }) + + describe('prefers-color-scheme resolution (stubbed matchMedia)', () => { + type Listener = () => void + const stubMedia = (initialMatches: boolean) => { + const listeners = new Set() + const media = { + matches: initialMatches, + addEventListener: (_: 'change', fn: Listener) => { listeners.add(fn) }, + removeEventListener: (_: 'change', fn: Listener) => { listeners.delete(fn) }, + flip() { + this.matches = !this.matches + for (const fn of listeners) fn() + }, + listenerCount: () => listeners.size, + } + vi.stubGlobal('matchMedia', () => media) + return media + } + + afterEach(() => { vi.unstubAllGlobals() }) + + it('system resolves against the media query and follows OS flips', () => { + const media = stubMedia(true) + const { theme, events } = make() + expect(theme.getTheme().preference).toBe('system') + expect(theme.getTheme().active.id).toBe('dark') + media.flip() + expect(theme.getTheme().active.id).toBe('light') + expect(events).toHaveLength(1) + }) + + it('OS flips do not republish while a concrete preference is set', () => { + const media = stubMedia(false) + const { theme, events } = make() + theme.setTheme('light') + expect(events).toHaveLength(1) + media.flip() + expect(events).toHaveLength(1) + expect(theme.getTheme().active.id).toBe('light') + }) + + it('context dispose releases the media listener', async () => { + const media = stubMedia(false) + const { ctx } = make() + expect(media.listenerCount()).toBe(1) + await ctx.fiber.dispose() + expect(media.listenerCount()).toBe(0) + }) + }) }) diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index da5fe34dc4..cb8ebb6a48 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -59,6 +59,9 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/client/ui-trajectory': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/ui-workspace': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/ui-theme': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, + 'packages/client/ui-settings': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, + 'packages/client/ui-settings-general': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, + 'packages/client/ui-settings-models': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/locale': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/web': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/examples/agent-spine-demo': { kind: 'indirect', reason: 'The bundle only mounts model-facing child plugins.' }, From c5cc348816bb27536ca1ba34928c33d771b3c8dc Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 26 Jul 2026 01:10:23 +0800 Subject: [PATCH 09/32] test(gui): settings package suites; ledger-judged re-registration Full per-file coverage for the three settings packages: invariant companions, store mirroring with revision guards, behavior-shaped section/shell specs (props-fed, real store engine), and apply-level suites on a real Context + SlotCore covering declaration-aware deferral and HMR collapse recovery. All three registrants now judge presence on the slot ledger instead of a local disposer, which went stale when a parent redeclaration cascade removed the entry (ds-review-bot finding); the locale re-register path keeps the same idempotence. --- .../ui-settings-general/src/client/index.ts | 6 +- .../ui-settings-general/tests/apply.spec.ts | 139 ++++++++++++++++ .../tests/general-section.spec.tsx | 112 +++++++++++++ .../tests/invariant.spec.ts | 18 ++ .../ui-settings-general/tests/store.spec.ts | 56 +++++++ .../ui-settings-models/src/client/index.ts | 20 ++- .../ui-settings-models/tests/apply.spec.ts | 95 +++++++++++ .../tests/invariant.spec.ts | 23 +++ .../client/ui-settings/src/client/index.ts | 14 +- .../client/ui-settings/tests/apply.spec.ts | 120 ++++++++++++++ .../ui-settings/tests/invariant.spec.ts | 18 ++ .../ui-settings/tests/settings-root.spec.tsx | 155 ++++++++++++++++++ 12 files changed, 764 insertions(+), 12 deletions(-) create mode 100644 packages/client/ui-settings-general/tests/apply.spec.ts create mode 100644 packages/client/ui-settings-general/tests/general-section.spec.tsx create mode 100644 packages/client/ui-settings-general/tests/invariant.spec.ts create mode 100644 packages/client/ui-settings-general/tests/store.spec.ts create mode 100644 packages/client/ui-settings-models/tests/apply.spec.ts create mode 100644 packages/client/ui-settings-models/tests/invariant.spec.ts create mode 100644 packages/client/ui-settings/tests/apply.spec.ts create mode 100644 packages/client/ui-settings/tests/invariant.spec.ts create mode 100644 packages/client/ui-settings/tests/settings-root.spec.tsx diff --git a/packages/client/ui-settings-general/src/client/index.ts b/packages/client/ui-settings-general/src/client/index.ts index f8f8a73304..52655be7fa 100644 --- a/packages/client/ui-settings-general/src/client/index.ts +++ b/packages/client/ui-settings-general/src/client/index.ts @@ -98,9 +98,11 @@ export function apply(ctx: ClientContext): void { } // Nav labels are registrant-localized: re-register on locale change so // the ledger carries fresh text (the version bump re-renders the shell). + // The ledger check mirrors tryRegister: after an HMR collapse `dispose` + // stays set while the entry is gone — relabeling then must stay quiet. const offLocale = ctx.on('locale/change', () => { - if (!registered()) return - dispose?.() + if (dispose === undefined || !registered()) return + dispose() dispose = undefined tryRegister() }) diff --git a/packages/client/ui-settings-general/tests/apply.spec.ts b/packages/client/ui-settings-general/tests/apply.spec.ts new file mode 100644 index 0000000000..8654275831 --- /dev/null +++ b/packages/client/ui-settings-general/tests/apply.spec.ts @@ -0,0 +1,139 @@ +/** apply wiring: dictionary registration, declaration-aware section entry, + * snapshot projection into the slot store, locale-driven relabeling, and + * recovery after an HMR collapse of the declaring entry. */ +import { Context } from 'cordis' +import { describe, expect, it } from 'vitest' +import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' +import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' +import { ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client' +import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings-general/client' +import type { GeneralSectionInjected } from '@deepseek-ai/dsh-client-ui-settings-general/client' +import { GeneralSection } from '../src/client/GeneralSection.tsx' +import type { createGeneralSettingsStore } from '../src/client/store.ts' + +const NS = 'settings.general' + +async function bench() { + const ctx = new Context() + await ctx.plugin(SlotsService).await() + const locale = new LocaleService(ctx) + const theme = new ThemeService(ctx) + ctx.provide('locale', locale) + ctx.provide('theme', theme) + return { ctx, slots: ctx.get('slots') as SlotsService, locale, theme } +} + +/** Stand in for the settings shell: declare the section list slot from root. */ +function declareSection(slots: SlotsService): () => void { + return slots.register( + { name: 'root', children: { 'settings.section': { kind: 'list', scope: 'root' } } } as never, + () => null, + ) +} + +/** Mirror the framework's inject choreography: bake a real instance from the + * declared handle and hand its actions to the entry's inject factory. */ +function faceOf(slots: SlotsService) { + const entry = slots.entries('settings.section')[0]! + const handle = entry.store as ReturnType + const instance = handle.create() + const face = (entry.inject as unknown as (a: typeof instance.actions) => GeneralSectionInjected)(instance.actions) + return { entry, instance, face } +} + +describe('ui-settings-general apply', () => { + it('declares the slot, locale, and theme services', () => { + expect(inject).toEqual(['slots', 'locale', 'theme']) + }) + + it('registers dictionaries and the section entry for declarations before or after apply', async () => { + const before = await bench() + declareSection(before.slots) + await before.ctx.plugin({ inject: [...inject], apply }).await() + const entry = before.slots.entries('settings.section')[0]! + expect(entry.component).toBe(GeneralSection) + expect(entry.options).toMatchObject({ id: 'general', order: 0, label: '通用设置' }) + expect(before.locale.bind(NS)('nav')).toBe('通用设置') + + const after = await bench() + const fiber = after.ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + expect(after.slots.entries('settings.section')).toHaveLength(0) + declareSection(after.slots) + await Promise.resolve() + expect(after.slots.entries('settings.section')[0]!.component).toBe(GeneralSection) + // Teardown without a live registration exercises the undefined-disposer arm. + await fiber.dispose() + expect(after.slots.entries('settings.section')).toHaveLength(0) + }) + + it('projects service snapshots into the store and routes face writes back', async () => { + const b = await bench() + declareSection(b.slots) + await b.ctx.plugin({ inject: [...inject], apply }).await() + // Events ahead of any inject hit the unbound-actions arm without a store. + b.theme.setTheme('dark') + + const { instance, face } = faceOf(b.slots) + // The inject-time re-sync sealed the init window: both mirrors are current. + expect(instance.getSnapshot().localeActive).toBe('zh') + expect(instance.getSnapshot().localeOptions.map(l => l.id)).toEqual(['zh', 'en']) + expect(instance.getSnapshot().themePreference).toBe('dark') + expect(face.t('nav')).toBe('通用设置') + + face.setLocale('en') + expect(b.locale.getLocale().active).toBe('en') + expect(instance.getSnapshot().localeActive).toBe('en') + expect(face.t('nav')).toBe('General') + + face.setTheme('system') + expect(b.theme.getTheme().preference).toBe('system') + expect(instance.getSnapshot().themePreference).toBe('system') + }) + + it('re-registers with a fresh ledger label when the locale changes', async () => { + const b = await bench() + declareSection(b.slots) + await b.ctx.plugin({ inject: [...inject], apply }).await() + expect(b.slots.entries('settings.section')[0]!.options.label).toBe('通用设置') + b.locale.setLocale('en') + const entry = b.slots.entries('settings.section')[0]! + expect(entry.options.label).toBe('General') + expect(entry.component).toBe(GeneralSection) + }) + + it('recovers after an HMR collapse of the declaring entry (stale disposer must not block)', async () => { + const b = await bench() + const host = declareSection(b.slots) + await b.ctx.plugin({ inject: [...inject], apply }).await() + expect(b.slots.entries('settings.section')).toHaveLength(1) + + // Collapse: the declarer dies, the cascade removes our entry while the + // apply closure still holds its (now stale) disposer. + host() + expect(b.slots.entries('settings.section')).toHaveLength(0) + + // A locale change inside the collapsed window must stay quiet. + b.locale.setLocale('en') + expect(b.slots.entries('settings.section')).toHaveLength(0) + + // Redeclaration restores the entry — with the current locale's label. + declareSection(b.slots) + await Promise.resolve() + const entry = b.slots.entries('settings.section')[0]! + expect(entry.component).toBe(GeneralSection) + expect(entry.options.label).toBe('General') + }) + + it('removes the entry and the dictionaries on teardown', async () => { + const b = await bench() + declareSection(b.slots) + const fiber = b.ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + expect(b.slots.entries('settings.section')).toHaveLength(1) + await fiber.dispose() + expect(b.slots.entries('settings.section')).toHaveLength(0) + // Dictionary disposal: translation falls back to the bare key. + expect(b.locale.bind(NS)('nav')).toBe('nav') + }) +}) diff --git a/packages/client/ui-settings-general/tests/general-section.spec.tsx b/packages/client/ui-settings-general/tests/general-section.spec.tsx new file mode 100644 index 0000000000..e82ed0b1fc --- /dev/null +++ b/packages/client/ui-settings-general/tests/general-section.spec.tsx @@ -0,0 +1,112 @@ +// @vitest-environment jsdom +/** GeneralSection behavior: skeleton rows stay inert, Language menu drives + * setLocale, Appearance cubes follow the preference and drive setTheme. */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { createSnapshotStore, type SessionListState, type WorkspaceListState } from '@deepseek-ai/dsh-client-runtime/client' +import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' +import { GeneralSection } from '../src/client/GeneralSection.tsx' +import { createGeneralSettingsStore } from '../src/client/store.ts' +import { en } from '../src/client/locales.ts' +import type { GeneralSectionComponentProps } from '../src/client/contract.ts' + +afterEach(cleanup) + +const LOCALES = [{ id: 'zh', label: '中文' }, { id: 'en', label: 'English' }] + +/** Empty global standard-kit hooks (the section reads neither). */ +function emptySessions() { + const store = createSnapshotStore( + { ids: [], byId: {}, current: undefined, intent: undefined, phase: 'ready' }) + return bindSnapshotSelector(store) +} +function emptyWorkspaces() { + const store = createSnapshotStore({ + items: [], intent: undefined, state: 'idle', phase: 'ready', error: null, + baselinesReady: true, recentWorkspaceId: undefined, + }) + return bindSnapshotSelector(store) +} + +function mount(init?: { active?: string; preference?: 'light' | 'dark' | 'system' }) { + // Real store instance — the sanctioned zero-machinery path for tests. + const store = createGeneralSettingsStore().create() + store.actions.syncLocale(init?.active ?? 'en', LOCALES, 0) + store.actions.syncTheme(init?.preference ?? 'system', 0) + const setLocale = vi.fn() + const setTheme = vi.fn() + const props: GeneralSectionComponentProps = { + useSessions: emptySessions(), + useWorkspaces: emptyWorkspaces(), + useStore: bindSnapshotSelector(store), + actions: store.actions, + t: (key: string) => en[key] ?? key, + setLocale, + setTheme, + } + render() + return { store, setLocale, setTheme } +} + +const pressed = (name: RegExp): string | null => + screen.getByRole('button', { name }).getAttribute('aria-pressed') + +describe('GeneralSection', () => { + it('renders the four groups with skeleton rows inert', () => { + const b = mount() + // Permission: disabled selector showing the fixed value. + const permission = screen.getByRole('button', { name: /Read only/ }) as HTMLButtonElement + expect(permission.disabled).toBe(true) + fireEvent.click(permission) + // Tool Call: both mode cubes render as plain text, no buttons. + expect(screen.getByText('Schema mode')).toBeDefined() + expect(screen.getByText('Code mode')).toBeDefined() + expect(screen.queryByRole('button', { name: /Schema mode/ })).toBeNull() + expect(b.setLocale).not.toHaveBeenCalled() + expect(b.setTheme).not.toHaveBeenCalled() + }) + + it('opens the language menu, selects a locale, and closes', () => { + const b = mount({ active: 'en' }) + const trigger = screen.getByRole('button', { name: /English/ }) + expect(trigger.getAttribute('aria-expanded')).toBe('false') + fireEvent.click(trigger) + expect(trigger.getAttribute('aria-expanded')).toBe('true') + fireEvent.click(screen.getByRole('menuitem', { name: '中文' })) + expect(b.setLocale).toHaveBeenCalledWith('zh') + expect(trigger.getAttribute('aria-expanded')).toBe('false') + expect(screen.queryByRole('menuitem', { name: '中文' })).toBeNull() + }) + + it('closes the language menu on outside pointerdown without selecting', () => { + const b = mount({ active: 'en' }) + const trigger = screen.getByRole('button', { name: /English/ }) + fireEvent.click(trigger) + expect(screen.getByRole('menuitem', { name: '中文' })).toBeDefined() + fireEvent.pointerDown(document.body) + expect(trigger.getAttribute('aria-expanded')).toBe('false') + expect(screen.queryByRole('menuitem', { name: '中文' })).toBeNull() + expect(b.setLocale).not.toHaveBeenCalled() + }) + + it('reflects a store locale change in the trigger label (unknown id falls back to the id)', () => { + const b = mount({ active: 'en' }) + act(() => { b.store.actions.syncLocale('zh', LOCALES, 1) }) + expect(screen.getByRole('button', { name: /中文/ })).toBeDefined() + act(() => { b.store.actions.syncLocale('fr', LOCALES, 2) }) + expect(screen.getByRole('button', { name: /fr/ })).toBeDefined() + }) + + it('marks the appearance cube matching the preference and switches on click', () => { + const b = mount({ preference: 'dark' }) + expect(pressed(/Dark/)).toBe('true') + expect(pressed(/Light/)).toBe('false') + expect(pressed(/System/)).toBe('false') + fireEvent.click(screen.getByRole('button', { name: /Light/ })) + expect(b.setTheme).toHaveBeenCalledWith('light') + // Selection follows the store mirror, not the click echo. + act(() => { b.store.actions.syncTheme('light', 1) }) + expect(pressed(/Light/)).toBe('true') + expect(pressed(/Dark/)).toBe('false') + }) +}) diff --git a/packages/client/ui-settings-general/tests/invariant.spec.ts b/packages/client/ui-settings-general/tests/invariant.spec.ts new file mode 100644 index 0000000000..7b0527c0ff --- /dev/null +++ b/packages/client/ui-settings-general/tests/invariant.spec.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import * as GeneralInvariant from '@deepseek-ai/dsh-client-ui-settings-general/invariant' +import InvariantService from '@deepseek-ai/dsh-invariants' + +describe('invariant companion', () => { + it('registers under the package name with an empty installer', async () => { + const ctx = new Context() + await ctx.plugin(InvariantService, { enabled: true }) + await expect(ctx.plugin(GeneralInvariant).await()).resolves.toBeDefined() + }) + + it('node-half apply is a no-op host placeholder', async () => { + const { apply } = await import('@deepseek-ai/dsh-client-ui-settings-general') + apply() + expect(true).toBe(true) // reaching here without throw is the contract + }) +}) diff --git a/packages/client/ui-settings-general/tests/store.spec.ts b/packages/client/ui-settings-general/tests/store.spec.ts new file mode 100644 index 0000000000..b291418471 --- /dev/null +++ b/packages/client/ui-settings-general/tests/store.spec.ts @@ -0,0 +1,56 @@ +/** General settings store: snapshot-mirror actions and the revision guard. */ +import { describe, expect, it } from 'vitest' +import { createGeneralSettingsStore } from '../src/client/store.ts' + +const LOCALES = [{ id: 'zh', label: '中文' }, { id: 'en', label: 'English' }] + +describe('createGeneralSettingsStore', () => { + it('init shape: empty mirrors with revisions at -1', () => { + const store = createGeneralSettingsStore().create() + expect(store.getSnapshot()).toEqual({ + localeActive: '', + localeOptions: [], + localeRevision: -1, + themePreference: 'system', + themeRevision: -1, + }) + }) + + it('syncLocale mirrors the snapshot and advances the revision', () => { + const store = createGeneralSettingsStore().create() + store.actions.syncLocale('zh', LOCALES, 0) + expect(store.getSnapshot().localeActive).toBe('zh') + expect(store.getSnapshot().localeOptions).toEqual(LOCALES) + expect(store.getSnapshot().localeRevision).toBe(0) + + store.actions.syncLocale('en', LOCALES, 1) + expect(store.getSnapshot().localeActive).toBe('en') + expect(store.getSnapshot().localeRevision).toBe(1) + }) + + it('syncLocale revision guard drops stale and duplicate writes', () => { + const store = createGeneralSettingsStore().create() + store.actions.syncLocale('en', LOCALES, 5) + // Stale (lower) and duplicate (equal) revisions leave the mirror intact. + store.actions.syncLocale('zh', LOCALES, 4) + store.actions.syncLocale('zh', LOCALES, 5) + expect(store.getSnapshot().localeActive).toBe('en') + expect(store.getSnapshot().localeRevision).toBe(5) + }) + + it('syncTheme mirrors the preference and guards its revision independently', () => { + const store = createGeneralSettingsStore().create() + store.actions.syncTheme('dark', 0) + expect(store.getSnapshot().themePreference).toBe('dark') + expect(store.getSnapshot().themeRevision).toBe(0) + + store.actions.syncTheme('light', 2) + expect(store.getSnapshot().themePreference).toBe('light') + + // Stale theme write is dropped; the locale revision axis is untouched. + store.actions.syncTheme('system', 1) + expect(store.getSnapshot().themePreference).toBe('light') + expect(store.getSnapshot().themeRevision).toBe(2) + expect(store.getSnapshot().localeRevision).toBe(-1) + }) +}) diff --git a/packages/client/ui-settings-models/src/client/index.ts b/packages/client/ui-settings-models/src/client/index.ts index 8ad2ebb750..c35946fcc8 100644 --- a/packages/client/ui-settings-models/src/client/index.ts +++ b/packages/client/ui-settings-models/src/client/index.ts @@ -31,9 +31,15 @@ export function apply(ctx: ClientContext): void { ] return () => { for (const dispose of disposers) dispose() } }, 'ui-settings-models: nav copy dictionaries') + // Declaration-aware registration; the LEDGER is the has-registered judge + // (not a local flag): after an HMR collapse re-declares the slot, the + // cascade already removed our entry, and a stale disposer must not block + // the re-registration. ctx.effect(() => { let dispose: (() => void) | undefined - const register = (): void => { + const tryRegister = (): void => { + if (ctx.slots.spec('settings.section') === undefined) return + if (ctx.slots.entries('settings.section').some(e => e.component === ModelsSection)) return dispose = ctx.slots.register({ name: 'settings.section', id: 'models', @@ -41,16 +47,14 @@ export function apply(ctx: ClientContext): void { label: ctx.locale.bind('settings.models')('nav'), }, ModelsSection) } - const tryRegister = (): void => { - if (ctx.slots.spec('settings.section') === undefined || dispose !== undefined) return - register() - } // Nav labels are registrant-localized: re-register on locale change so // the ledger carries fresh text (the version bump re-renders the shell). + // Dispose-then-requery: after an HMR collapse the disposer is stale and + // the ledger/spec re-check keeps this path an idempotent no-op. const offLocale = ctx.on('locale/change', () => { - if (dispose === undefined) return - dispose() - register() + dispose?.() + dispose = undefined + tryRegister() }) const unsubscribe = ctx.slots.subscribe('settings.section', () => { tryRegister() }) tryRegister() diff --git a/packages/client/ui-settings-models/tests/apply.spec.ts b/packages/client/ui-settings-models/tests/apply.spec.ts new file mode 100644 index 0000000000..b7aa3cf2ab --- /dev/null +++ b/packages/client/ui-settings-models/tests/apply.spec.ts @@ -0,0 +1,95 @@ +/** Models section registration: declaration-aware deferral, locale re-registration, and HMR recovery. */ +import { Context } from 'cordis' +import { describe, expect, it } from 'vitest' +import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' +import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' +import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings-models/client' +import { ModelsSection } from '../src/client/ModelsSection.tsx' + +async function bench() { + const ctx = new Context() + await ctx.plugin(SlotsService).await() + const locale = new LocaleService(ctx) + ctx.provide('locale', locale) + return { ctx, slots: ctx.get('slots') as SlotsService, locale } +} + +function declare(slots: SlotsService): () => void { + return slots.register( + { name: 'root', children: { 'settings.section': { kind: 'list', scope: 'root' } } } as never, + () => null, + ) +} + +describe('ui-settings-models apply', () => { + it('declares the services it uses', () => { + expect(inject).toEqual(['slots', 'locale']) + }) + + it('registers the models nav entry for declarations before or after apply', async () => { + const before = await bench() + declare(before.slots) + await before.ctx.plugin({ inject: [...inject], apply }).await() + const entry = before.slots.entries('settings.section')[0]! + expect(entry.component).toBe(ModelsSection) + expect(entry.options).toEqual({ id: 'models', order: 10, label: '模型' }) + + const after = await bench() + await after.ctx.plugin({ inject: [...inject], apply }).await() + expect(after.slots.entries('settings.section')).toHaveLength(0) + declare(after.slots) + await Promise.resolve() + expect(after.slots.entries('settings.section')[0]!.component).toBe(ModelsSection) + // The self-inflicted ledger notifications hit the duplicate guard. + expect(after.slots.entries('settings.section')).toHaveLength(1) + }) + + it('re-registers with fresh label text on locale change', async () => { + const b = await bench() + declare(b.slots) + await b.ctx.plugin({ inject: [...inject], apply }).await() + b.locale.setLocale('en') + expect(b.slots.entries('settings.section')[0]!.options.label).toBe('Models') + b.locale.setLocale('zh') + expect(b.slots.entries('settings.section')[0]!.options.label).toBe('模型') + }) + + it('locale change while the slot is undeclared stays a no-op', async () => { + const b = await bench() + await b.ctx.plugin({ inject: [...inject], apply }).await() + b.locale.setLocale('en') + expect(b.slots.entries('settings.section')).toHaveLength(0) + b.locale.setLocale('zh') + }) + + it('re-registers after an HMR collapse re-declares the slot (stale disposer must not block)', async () => { + const b = await bench() + const redeclare = declare(b.slots) + await b.ctx.plugin({ inject: [...inject], apply }).await() + expect(b.slots.entries('settings.section')).toHaveLength(1) + // Declarer unload: the cascade removes our entry while our local + // disposer variable goes stale. + redeclare() + expect(b.slots.entries('settings.section')).toHaveLength(0) + declare(b.slots) + await Promise.resolve() + expect(b.slots.entries('settings.section')[0]!.component).toBe(ModelsSection) + // The locale path also recovers through the same ledger re-check. + b.locale.setLocale('en') + expect(b.slots.entries('settings.section')[0]!.options.label).toBe('Models') + b.locale.setLocale('zh') + }) + + it('registers the zh/en nav dictionaries and disposes everything with the fiber', async () => { + const b = await bench() + declare(b.slots) + const fiber = b.ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + expect(b.locale.bind('settings.models')('nav')).toBe('模型') + await fiber.dispose() + expect(b.slots.entries('settings.section')).toHaveLength(0) + // The (ns, locale) seats are free again — the dictionary disposers ran. + expect(() => b.locale.register('settings.models', 'zh', {})).not.toThrow() + expect(() => b.locale.register('settings.models', 'en', {})).not.toThrow() + }) +}) diff --git a/packages/client/ui-settings-models/tests/invariant.spec.ts b/packages/client/ui-settings-models/tests/invariant.spec.ts new file mode 100644 index 0000000000..65c7c1094a --- /dev/null +++ b/packages/client/ui-settings-models/tests/invariant.spec.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import * as ModelsInvariant from '@deepseek-ai/dsh-client-ui-settings-models/invariant' +import InvariantService from '@deepseek-ai/dsh-invariants' +import { ModelsSection } from '../src/client/ModelsSection.tsx' + +describe('invariant companion', () => { + it('registers under the package name with an empty installer', async () => { + const ctx = new Context() + await ctx.plugin(InvariantService, { enabled: true }) + await expect(ctx.plugin(ModelsInvariant).await()).resolves.toBeDefined() + }) + + it('node-half apply is a no-op host placeholder', async () => { + const { apply } = await import('@deepseek-ai/dsh-client-ui-settings-models') + apply() + expect(true).toBe(true) // reaching here without throw is the contract + }) + + it('the section content column is intentionally empty this phase', () => { + expect(ModelsSection()).toBeNull() + }) +}) diff --git a/packages/client/ui-settings/src/client/index.ts b/packages/client/ui-settings/src/client/index.ts index d5135f373c..688436c25b 100644 --- a/packages/client/ui-settings/src/client/index.ts +++ b/packages/client/ui-settings/src/client/index.ts @@ -43,13 +43,23 @@ export function apply(ctx: ClientContext): void { sectionsVersion: () => ctx.slots.getVersion('settings.section'), subscribeSections: (listener) => ctx.slots.subscribe('settings.section', listener), sections: () => ctx.slots.entries('settings.section') - .map(e => ({ id: e.options.id ?? '', order: e.options.order ?? 0, label: e.options.label ?? '' })) + .map(e => ({ + /* v8 ignore next -- list-slot registration requires id (SlotCore rejects an entry without one) */ + id: e.options.id ?? '', + order: e.options.order ?? 0, + label: e.options.label ?? '', + })) .sort((a, b) => a.order - b.order), }) + // Declaration-aware registration; the LEDGER is the has-registered judge + // (not a local flag): after an HMR collapse re-declares the slot, the + // cascade already removed our entry, and a stale disposer must not block + // the re-registration. ctx.effect(() => { let dispose: (() => void) | undefined const tryRegister = (): void => { - if (ctx.slots.spec('sidebar.settings') === undefined || dispose !== undefined) return + if (ctx.slots.spec('sidebar.settings') === undefined) return + if (ctx.slots.entries('sidebar.settings').some(e => e.component === SettingsRoot)) return dispose = ctx.slots.register({ name: 'sidebar.settings', children: { 'settings.section': { kind: 'list', scope: 'root' } }, diff --git a/packages/client/ui-settings/tests/apply.spec.ts b/packages/client/ui-settings/tests/apply.spec.ts new file mode 100644 index 0000000000..a94406e4ce --- /dev/null +++ b/packages/client/ui-settings/tests/apply.spec.ts @@ -0,0 +1,120 @@ +/** Settings shell registration: declaration-aware deferral, the injected face, and HMR recovery. */ +import { Context } from 'cordis' +import { describe, expect, it, vi } from 'vitest' +import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' +import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' +import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings/client' +import type { SettingsRootInjected } from '@deepseek-ai/dsh-client-ui-settings/client' +import { SettingsRoot } from '../src/client/SettingsRoot.tsx' + +async function bench() { + const ctx = new Context() + await ctx.plugin(SlotsService).await() + const locale = new LocaleService(ctx) + ctx.provide('locale', locale) + return { ctx, slots: ctx.get('slots') as SlotsService, locale } +} + +function declare(slots: SlotsService): () => void { + return slots.register( + { name: 'root', children: { 'sidebar.settings': { kind: 'single', scope: 'root' } } } as never, + () => null, + ) +} + +function injectedOf(slots: SlotsService): SettingsRootInjected { + const entry = slots.entries('sidebar.settings')[0]! + return (entry.inject as () => SettingsRootInjected)() +} + +describe('ui-settings apply', () => { + it('declares the services it uses', () => { + expect(inject).toEqual(['slots', 'locale']) + }) + + it('registers the shell for declarations that arrive before or after apply', async () => { + const before = await bench() + declare(before.slots) + await before.ctx.plugin({ inject: [...inject], apply }).await() + expect(before.slots.entries('sidebar.settings')[0]!.component).toBe(SettingsRoot) + expect(before.slots.spec('settings.section')).toEqual({ kind: 'list', scope: 'root' }) + + const after = await bench() + await after.ctx.plugin({ inject: [...inject], apply }).await() + expect(after.slots.entries('sidebar.settings')).toHaveLength(0) + declare(after.slots) + await Promise.resolve() + expect(after.slots.entries('sidebar.settings')[0]!.component).toBe(SettingsRoot) + // The self-inflicted ledger notifications hit the duplicate guard. + expect(after.slots.entries('sidebar.settings')).toHaveLength(1) + }) + + it('registers the zh/en shell dictionaries and disposes them with the fiber', async () => { + const b = await bench() + declare(b.slots) + const fiber = b.ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + expect(b.locale.bind('settings')('title')).toBe('设置') + b.locale.setLocale('en') + expect(b.locale.bind('settings')('close')).toBe('Close') + await fiber.dispose() + // The (ns, locale) seats are free again — the dictionary disposers ran. + expect(() => b.locale.register('settings', 'zh', {})).not.toThrow() + expect(() => b.locale.register('settings', 'en', {})).not.toThrow() + }) + + it('exposes translate over ":" refs with literal echo for plain text', async () => { + const b = await bench() + declare(b.slots) + await b.ctx.plugin({ inject: [...inject], apply }).await() + const injected = injectedOf(b.slots) + expect(injected.translate('settings:title')).toBe('设置') + expect(injected.translate('no colon ref')).toBe('no colon ref') + }) + + it('projects the section ledger into ordered nav rows with option defaults', async () => { + const b = await bench() + declare(b.slots) + await b.ctx.plugin({ inject: [...inject], apply }).await() + const injected = injectedOf(b.slots) + expect(injected.sections()).toEqual([]) + b.slots.register({ name: 'settings.section', id: 'z', order: 20, label: 'Z' } as never, () => null) + b.slots.register({ name: 'settings.section', id: 'a' } as never, () => null) + expect(injected.sections()).toEqual([ + { id: 'a', order: 0, label: '' }, + { id: 'z', order: 20, label: 'Z' }, + ]) + expect(injected.sectionsVersion()).toBe(b.slots.getVersion('settings.section')) + const listener = vi.fn() + const off = injected.subscribeSections(listener) + b.slots.register({ name: 'settings.section', id: 'b', order: 1, label: 'B' } as never, () => null) + await Promise.resolve() + expect(listener).toHaveBeenCalled() + off() + }) + + it('re-registers after an HMR collapse re-declares the slot (stale disposer must not block)', async () => { + const b = await bench() + const redeclare = declare(b.slots) + await b.ctx.plugin({ inject: [...inject], apply }).await() + expect(b.slots.entries('sidebar.settings')).toHaveLength(1) + // Declarer unload: the cascade removes our entry and the slot spec while + // our local disposer variable goes stale. + redeclare() + expect(b.slots.entries('sidebar.settings')).toHaveLength(0) + declare(b.slots) + await Promise.resolve() + expect(b.slots.entries('sidebar.settings')[0]!.component).toBe(SettingsRoot) + expect(b.slots.spec('settings.section')).toEqual({ kind: 'list', scope: 'root' }) + }) + + it('unregisters the shell and collapses settings.section on teardown', async () => { + const b = await bench() + declare(b.slots) + const fiber = b.ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + await fiber.dispose() + expect(b.slots.entries('sidebar.settings')).toHaveLength(0) + expect(b.slots.spec('settings.section')).toBeUndefined() + }) +}) diff --git a/packages/client/ui-settings/tests/invariant.spec.ts b/packages/client/ui-settings/tests/invariant.spec.ts new file mode 100644 index 0000000000..c3474d5bdd --- /dev/null +++ b/packages/client/ui-settings/tests/invariant.spec.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import * as SettingsInvariant from '@deepseek-ai/dsh-client-ui-settings/invariant' +import InvariantService from '@deepseek-ai/dsh-invariants' + +describe('invariant companion', () => { + it('registers under the package name with an empty installer', async () => { + const ctx = new Context() + await ctx.plugin(InvariantService, { enabled: true }) + await expect(ctx.plugin(SettingsInvariant).await()).resolves.toBeDefined() + }) + + it('node-half apply is a no-op host placeholder', async () => { + const { apply } = await import('@deepseek-ai/dsh-client-ui-settings') + apply() + expect(true).toBe(true) // reaching here without throw is the contract + }) +}) diff --git a/packages/client/ui-settings/tests/settings-root.spec.tsx b/packages/client/ui-settings/tests/settings-root.spec.tsx new file mode 100644 index 0000000000..ab56365075 --- /dev/null +++ b/packages/client/ui-settings/tests/settings-root.spec.tsx @@ -0,0 +1,155 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import type { SettingsRootComponentProps } from '../src/client/contract/slots.ts' +import { SettingsRoot } from '../src/client/SettingsRoot.tsx' + +afterEach(cleanup) + +const DICT: Record = { + 'settings:trigger': 'Settings', + 'settings:title': 'Settings', + 'settings:close': 'Close', +} + +type Row = { id: string; order: number; label: string } + +function mount({ + wide = true, + rows = [ + { id: 'general', order: 0, label: 'General' }, + { id: 'models', order: 10, label: 'Models' }, + ], +}: { wide?: boolean; rows?: Row[] } = {}) { + // Mutable row store standing in for the ledger; bump() plays a change. + let current = rows + let version = 0 + const listeners = new Set<() => void>() + const renderSlot = vi.fn( + ((_key: string, _owner: unknown, opts?: { only?: string }) => +
) as SettingsRootComponentProps['renderSlot'], + ) + // Global standard kit stubs: the shell consumes neither hook. + const unusedHook = (() => { throw new Error('unused by SettingsRoot') }) as never + const props: SettingsRootComponentProps = { + useSessions: unusedHook, + useWorkspaces: unusedHook, + wide, + translate: (ref) => DICT[ref] ?? ref, + sectionsVersion: () => version, + subscribeSections: (listener) => { + listeners.add(listener) + return () => { listeners.delete(listener) } + }, + sections: () => current, + renderSlot, + } + const view = render() + const bump = (next: Row[]) => { + act(() => { + current = next + version += 1 + for (const fn of [...listeners]) fn() + }) + } + return { view, renderSlot, bump, listeners } +} + +function openPanel() { + fireEvent.click(screen.getByRole('button', { name: 'Settings' })) +} + +describe('SettingsRoot trigger', () => { + it('renders the wide row with the label and opens the dialog', () => { + mount() + const trigger = screen.getByRole('button', { name: 'Settings' }) + expect(trigger.textContent).toContain('Settings') + expect(trigger.getAttribute('aria-expanded')).toBe('false') + fireEvent.click(trigger) + expect(screen.getByRole('dialog')).toBeTruthy() + expect(screen.getByRole('button', { name: 'Settings', expanded: true })).toBeTruthy() + }) + + it('drops the label in the rail state', () => { + mount({ wide: false }) + expect(screen.getByRole('button', { name: 'Settings' }).textContent).toBe('') + }) +}) + +describe('SettingsPanel close paths', () => { + it('closes via the header button', () => { + mount() + openPanel() + fireEvent.click(screen.getByRole('button', { name: 'Close' })) + expect(screen.queryByRole('dialog')).toBeNull() + }) + + it('closes via a mask click', () => { + mount() + openPanel() + const dialog = screen.getByRole('dialog') + fireEvent.click(dialog.parentElement!.firstElementChild!) + expect(screen.queryByRole('dialog')).toBeNull() + }) + + it('closes via document-level Escape and unhooks the listener with the panel', () => { + mount() + openPanel() + fireEvent.keyDown(document, { key: 'Escape' }) + expect(screen.queryByRole('dialog')).toBeNull() + // Ignored while closed (listener removed with the panel) and non-Escape + // keys are ignored while open. + fireEvent.keyDown(document, { key: 'Escape' }) + openPanel() + fireEvent.keyDown(document, { key: 'Enter' }) + expect(screen.getByRole('dialog')).toBeTruthy() + }) + + it('lands focus on the close button when the dialog opens', () => { + mount() + openPanel() + expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Close' })) + }) +}) + +describe('SettingsPanel navigation', () => { + it('projects rows, marks the first active, and renders only that section', () => { + mount() + openPanel() + expect(screen.getByRole('button', { name: 'General' }).getAttribute('aria-current')).toBe('true') + expect(screen.getByRole('button', { name: 'Models' }).getAttribute('aria-current')).toBeNull() + expect(screen.getByTestId('section-general')).toBeTruthy() + }) + + it('switches the rendered section on nav click', () => { + mount() + openPanel() + fireEvent.click(screen.getByRole('button', { name: 'Models' })) + expect(screen.getByRole('button', { name: 'Models' }).getAttribute('aria-current')).toBe('true') + expect(screen.getByTestId('section-models')).toBeTruthy() + expect(screen.queryByTestId('section-general')).toBeNull() + }) + + it('falls back to the first row when the active entry unregisters', () => { + const { bump } = mount() + openPanel() + fireEvent.click(screen.getByRole('button', { name: 'Models' })) + bump([{ id: 'general', order: 0, label: 'General' }]) + expect(screen.queryByRole('button', { name: 'Models' })).toBeNull() + expect(screen.getByTestId('section-general')).toBeTruthy() + }) + + it('renders an empty content column when the ledger is empty', () => { + const { renderSlot } = mount({ rows: [] }) + openPanel() + expect(screen.getByRole('dialog')).toBeTruthy() + expect(renderSlot).not.toHaveBeenCalled() + }) + + it('drops the ledger subscription on unmount', () => { + const { view, listeners } = mount() + expect(listeners.size).toBe(1) + view.unmount() + expect(listeners.size).toBe(0) + }) +}) From f1b877e3c61ded6f94a1a07bbdb95555297af63f Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 26 Jul 2026 01:11:17 +0800 Subject: [PATCH 10/32] fix(web): review-bot round one + regenerate doc catalogs - rename same-title no-op moves inside the serialized creation chain - insertSessionBefore maps only the typed WorkspaceMoveInvalidError to workspace-move-invalid; storage failures stay internal - workspace upsert rejects snapshots older than the installed projection - flat-mode empty state shows when the query hides the intent row - intent row no longer forces group expansion; header twist stays live - group-by menu rides a portal; menu clicks stop propagating to the row - intent row uses the same single-slot indent in both list modes - regenerate cordis api/catalog + doc graphs --- ...ssion-list-browsing-and-manual-order.zh.md | 60 +++++++++++++++++++ docs/cordis-catalog/services.md | 2 +- .../src/client/WorkspaceBrowser.tsx | 2 +- .../ui-workspace/src/client/rows/Rows.tsx | 7 +-- 4 files changed, 65 insertions(+), 6 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md diff --git a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md new file mode 100644 index 0000000000..e897903f83 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md @@ -0,0 +1,60 @@ +# Agent Note: Session List Browsing and Manual Workspace Order + +Status: implemented + +[English](2026-07-25-session-list-browsing-and-manual-order.md) | 中文 + +## Problem + +[Workspace UI 完整产品流](2026-07-25-workspace-ui-product-flow.zh.md)交付了分组 session 列表的首个形态,并把 Rename、拖拽排序等操作明确划出当期范围。设计稿(figma 239-10458 及关联画面)随后补齐了这些交互:列表要能切换成不分组的平铺视图、session 行悬停要出详情卡与操作菜单、workspace 要能改名、组内 session 要能手动排序。 + +两条既有机制挡在前面。其一,host 在每条 `session/event` 上把活跃 session durable 地提到 workspace 账本最前(活动置顶),任何手动排序都会被下一次活动打乱——两种排序权威不可调和。其二,浏览区域被劈在两个包里:ui-sidebar 拥有列表、搜索和组头行,ui-workspace 只借一个 picker 坑放弹层;每加一个 workspace 域的对话框都要跨包接线,归属越来越拧。 + +## Decision + +### 平铺视图与浏览态 + +group-by 菜单提供 WorkSpace / In one list 两种模式。平铺模式把所有 session(含 fork 子)一律作为顶层行,严格按 `updatedAt` 新→旧排序,不保持父子相邻;Intent 占位行渲染在列表首行。模式选择持久化在浏览器(`dsh.workspace.view`),刷新保持。 + +### 行交互 + +- session 行悬停 500ms 出详情卡(全名/相对时间/状态行;状态本期只有 running/idle 两态,枚举扩展待 wire 增补 status 字段)。卡片与行菜单互斥:菜单开启或拖拽进行中不出卡。 +- session 行 … 菜单:Rename / Fork session / Delete session,本期纯视觉;workspace 组头 … 菜单:Rename(已接线)/ Delete workspace(纯视觉)。菜单鼠标移出即关。 +- 支撑件:`Menu` 新增 label 条目、danger 行、`closeOnPointerLeave`;新增 `HoverCard`(portal 定位、开启延时、disabled 守卫)。 + +### workspace.rename + +`workspace.rename({ workspaceId, title })`:title trim 后非空;同名 no-op 与重名查重都在 host 的 workspace 创建串行链内求值(与 create 共链,并发 create/rename 不能穿插出重名或乱序假成功),冲突回 `workspace-name-conflict`。落盘经 `setTitle` 的 mutate 通道,`domain/changed` 监听自动广播 `host/workspace-changed` 帧。UI 为标准 Modal,client 侧另做重名预检。 + +### 手动排序:insertSessionBefore 取代活动置顶 + +`session/event` → `touchSession` 活动置顶链整体删除;workspace 账本序改为纯手动拥有——新 session attach 时前插,显式重排走 `workspace.insertSessionBefore({ workspaceId, sessionId, beforeSessionId? })`(DOM insertBefore 语义:锚给了插锚前,缺省 append 到末尾)。实体只对不在账的 session/锚抛类型化的 `WorkspaceMoveInvalidError`,handler 仅把它映射为业务码 `workspace-move-invalid`,存储故障保持 internal。 + +UI 为组内 root 行的 HTML5 拖拽(仅 workspace 分组、非搜索态;fork 子随父不单独拖)。顺序权威完全在 host:drop 只发 RPC,client 零本地重排,视图靠响应体 upsert 与 changed 帧刷新;失败即无事发生。client 的 upsert 拒绝比已装载投影更旧(`updatedAt`)的快照,防迟到的一元响应回滚更新的帧。 + +### 壳/区域切分 + +ui-sidebar 缩为列几何壳:品牌行、折叠状态机、New Session、Settings,以及一个 `sidebar.workspaces` 洞;壳与区域的契约只有两个事实 `{ wide, expandSidebar }`。ui-workspace 全权拥有浏览区域(section header、搜索、分组树与平铺、全部 workspace 对话框、拖拽)及其 groupBy store;rail 态的搜索/新建图标也归区域,经 `expandSidebar()` 请求壳展开。picker 拆为核心件 `WorkspaceCreateFlow`(区域内直接组件组合)与薄包装 `WorkspacePicker`(继续填 ui-conversation 的 hero 坑);原 `sidebar.workspace` picker 坑与声明感知延迟注册随之删除。 + +## Alternatives considered + +**保留活动置顶、拖拽仅作临时调整** —— 手动序在下一次 session 活动即被打乱,形同虚设;两种排序权威并存无法向用户解释。也考虑过「拖过一次即冻结该 workspace 的活动置顶」的折中,状态多一档、语义更难讲,直接删除更干净。 + +**排序报文用数字下标** —— `{ index }` 在拖拽窗口期会漂移:host 前插新 session(如 Intent 材料化)后同一下标指向别的行。锚点式 insertBefore 对前插与过滤投影天然免疫。 + +**drop 后乐观重排** —— client 先行重排需失败回滚,对象层多一块纠缠态;本地/局域网往返毫秒级,等 host 响应的简单方案肉眼无感。顺序权威单一化(完全信 host)后,前端永不发明顺序。 + +**rename 对话框留在 ui-sidebar(最小改动)** —— 正是问题本身:workspace 域的对话框散落在借来的坑里,每加一个(Delete 确认框将至)都重演跨包接线。评审中先议了「只挪 rename Modal」的中间态,最终裁定整个浏览区域归 ui-workspace,壳只留几何。 + +**平铺模式保持父子相邻成组** —— 与「严格按时间」矛盾(子新于兄则插不进相邻位),且平铺本意就是取消层级;拉平并禁用平铺下的拖拽(无持久化载体)更一致。 + +## Consequences + +- 手动序是唯一的 workspace 账本序权威:用户排好的顺序不再被活动打乱;代价是「最近活跃浮到最上」的行为消失,活跃感知转由行内状态点与时间标签承担。`WorkspaceView.sessionIds` 的 wire 契约随之改为手动序措辞。 +- 壳/区域两事实契约把 workspace 域的后续功能(Delete 确认、跨组移动、Ungrouped 收编)全部收进 ui-workspace 单包;ui-sidebar 不再随 session 列表功能演进。 +- 平铺模式不支持排序与分组入口(建到指定 workspace 需切回分组视图),是拍板接受的范围收窄。 +- session 菜单三项与 workspace Delete 的功能接线、状态枚举扩 wire,留待后续迭代。 + +## Testing + +包级用例覆盖派生(deriveGroups/deriveFlat)、行组件、两处 apply 注册与透传、host 实体移位语义、rename/insertSessionBefore 的 RPC 实现与 fixture 桩;`apps/web` keyless snapshot 回归覆盖装配后的应用;交付验收另以 playwright(chromium headless)过 12 项清单(分组默认、平铺切换与持久化、hover 卡出现与抑制、双菜单、rename 全链、拖拽落盘),并对真 host 直打 wire 验证 rename 成功/重名拒绝/`workspace-move-invalid` 三径。 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 474f587230..387ff39c3e 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1991,7 +1991,7 @@ list(): Workspace[] async resolveByPath(path: string): Promise ``` -Source: [`packages/workspace/workspace/src/index.ts:75`](../../packages/workspace/workspace/src/index.ts) +Source: [`packages/workspace/workspace/src/index.ts:78`](../../packages/workspace/workspace/src/index.ts) ## Inherited `ctx` members (cordis core + loader/hmr/timer) diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx index e7d0cdc38c..57de2cbaf5 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx @@ -209,7 +209,7 @@ function FlatList({ useSessions, open, query }: Pick{query === '' ? 'No sessions yet' : 'No matches'}
)} - {intentRow && } + {intentRow && } {rows.map(node => ( - {!flat && } New session
From 8cc46e602504fc562427b1614545d5dcf27c290c Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 26 Jul 2026 01:16:39 +0800 Subject: [PATCH 11/32] docs(notes): export settings note contract types Both sides of the bilingual pair compile in one doc-typecheck program; module-scoped (exported) declarations keep the shared identifiers from colliding across the pair. --- ...2026-07-25-client-settings-locale-theme.i18n.yaml | 4 ++-- .../2026-07-25-client-settings-locale-theme.md | 12 ++++++------ .../2026-07-25-client-settings-locale-theme.zh.md | 12 ++++++------ 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.i18n.yaml b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.i18n.yaml index 54fb6ee9ee..8583642403 100644 --- a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.i18n.yaml +++ b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.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 -2026-07-25-client-settings-locale-theme.md: 25e22c728ea509613c4d7f6cdfcd09faf4685760 -2026-07-25-client-settings-locale-theme.zh.md: 9c2457b254aa826291d4c5a4e115e9ba2d391974 +2026-07-25-client-settings-locale-theme.md: e1245a9e1fac82fb0feb84af7a59945b17fa1daf +2026-07-25-client-settings-locale-theme.zh.md: 195b2e5ffa3556dd1b8bf2dd6ec8ae84115fbdb2 diff --git a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.md b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.md index 25e22c728e..e1245a9e1f 100644 --- a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.md +++ b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.md @@ -49,33 +49,33 @@ Section contributions use declaration-aware deferral and do not depend on the cl ### Service contracts ```ts -type ThemePreference = 'light' | 'dark' | 'system' +export type ThemePreference = 'light' | 'dark' | 'system' -interface ThemeDefinition { +export interface ThemeDefinition { id: string colorScheme: 'light' | 'dark' tokens: Record } -interface ThemeSnapshot { +export interface ThemeSnapshot { preference: ThemePreference active: ThemeDefinition // system 已解析为具体 light/dark 定义 themes: readonly ThemeDefinition[] revision: number } -interface LocaleDefinition { +export interface LocaleDefinition { id: 'zh' | 'en' label: string } -interface LocaleSnapshot { +export interface LocaleSnapshot { active: 'zh' | 'en' locales: readonly LocaleDefinition[] revision: number } -interface Events { +export interface Events { /** @param snapshot - Current locale registry snapshot. @mode emit */ 'locale/change'(snapshot: LocaleSnapshot): void /** @param snapshot - Current theme registry snapshot. @mode emit */ diff --git a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.zh.md b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.zh.md index 9c2457b254..195b2e5ffa 100644 --- a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.zh.md +++ b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.zh.md @@ -49,33 +49,33 @@ section contribution 使用 declaration-aware deferral,不依赖 client manife ### Service contracts ```ts -type ThemePreference = 'light' | 'dark' | 'system' +export type ThemePreference = 'light' | 'dark' | 'system' -interface ThemeDefinition { +export interface ThemeDefinition { id: string colorScheme: 'light' | 'dark' tokens: Record } -interface ThemeSnapshot { +export interface ThemeSnapshot { preference: ThemePreference active: ThemeDefinition // system 已解析为具体 light/dark 定义 themes: readonly ThemeDefinition[] revision: number } -interface LocaleDefinition { +export interface LocaleDefinition { id: 'zh' | 'en' label: string } -interface LocaleSnapshot { +export interface LocaleSnapshot { active: 'zh' | 'en' locales: readonly LocaleDefinition[] revision: number } -interface Events { +export interface Events { /** @param snapshot - Current locale registry snapshot. @mode emit */ 'locale/change'(snapshot: LocaleSnapshot): void /** @param snapshot - Current theme registry snapshot. @mode emit */ From ba5c1368710709b5b524f1de3a544bd93c281953 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 26 Jul 2026 01:30:14 +0800 Subject: [PATCH 12/32] docs(notes): record the session-list browsing and manual-order decisions --- ...n-list-browsing-and-manual-order.i18n.yaml | 6 ++ ...-session-list-browsing-and-manual-order.md | 60 +++++++++++++++++++ ...ssion-list-browsing-and-manual-order.zh.md | 2 +- 3 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 .agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md diff --git a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.i18n.yaml b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.i18n.yaml new file mode 100644 index 0000000000..590d9227b8 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.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 +2026-07-25-session-list-browsing-and-manual-order.md: 586995bf459aeaee88672863977f7acf2a7061a3 +2026-07-25-session-list-browsing-and-manual-order.zh.md: 432d5167a57d30bc04a0b4faf213e4341f07bd2f diff --git a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md new file mode 100644 index 0000000000..586995bf45 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md @@ -0,0 +1,60 @@ +# Agent Note: Session List Browsing and Manual Workspace Order + +Status: implemented + +English | [中文](2026-07-25-session-list-browsing-and-manual-order.zh.md) + +## Problem + +[Workspace UI Complete Product Flow](2026-07-25-workspace-ui-product-flow.md) shipped the first form of the grouped session list and explicitly scoped out operations such as Rename and drag ordering. The design file (figma 239-10458 and its companion screens) has since filled in those interactions: the list must switch to an ungrouped flat view, session rows need a hover detail card and an action menu, workspaces need renaming, and sessions need manual ordering inside their group. + +Two existing mechanisms stood in the way. First, the host durably promoted the active session to the front of its workspace account on every `session/event` (activity pinning), so any manual order would be scrambled by the next activity — two ordering authorities cannot coexist. Second, the browsing area was split across two packages: ui-sidebar owned the list, search, and header rows while ui-workspace only borrowed a picker slot for its popover; every new workspace-domain dialog required cross-package wiring, and ownership grew more twisted with each one. + +## Decision + +### Flat view and viewing state + +The group-by menu offers two modes, WorkSpace / In one list. Flat mode renders every session (fork children included) as a top-level row, strictly newest-first by `updatedAt`, with no parent/child adjacency; the Intent placeholder renders as the first row. The mode choice persists in the browser (`dsh.workspace.view`) across reloads. + +### Row interactions + +- Session rows show a detail card after a 500ms hover dwell (full title / relative time / status line; the status line has only running/idle until the wire grows a status field). The card and the row menu are mutually exclusive: no card while a menu is open or a drag is in flight. +- Session-row … menu: Rename / Fork session / Delete session, visual-only this iteration; workspace-header … menu: Rename (wired) / Delete workspace (visual-only). Menus close when the pointer leaves them. +- Supporting primitives: `Menu` gains label entries, danger rows, and `closeOnPointerLeave`; a new `HoverCard` (portaled placement, open delay, disabled guard). + +### workspace.rename + +`workspace.rename({ workspaceId, title })`: the title is trimmed and must be non-blank; both the same-title no-op and the duplicate check evaluate inside the host's serialized workspace-creation chain (shared with create, so concurrent create/rename cannot interleave a duplicate or an out-of-order fake success), and a conflict returns `workspace-name-conflict`. Durability goes through `setTitle`'s mutate path, and the `domain/changed` listener broadcasts the `host/workspace-changed` frame automatically. The UI is a standard modal with a client-side duplicate pre-check. + +### Manual order: insertSessionBefore replaces activity pinning + +The `session/event` → `touchSession` activity-pinning chain is deleted wholesale; the workspace account order is now manually owned — new sessions prepend at attach, and explicit reordering goes through `workspace.insertSessionBefore({ workspaceId, sessionId, beforeSessionId? })` (DOM insertBefore semantics: with an anchor it inserts before it, omitted appends to the end). The entity throws a typed `WorkspaceMoveInvalidError` only for unaccounted session/anchor ids; the handler maps exactly that to the business code `workspace-move-invalid`, while storage failures stay internal. + +The UI is HTML5 drag on root rows inside a group (workspace grouping only, outside search; fork children ride with their parent and are not draggable). Order authority stays entirely host-side: drop only sends the RPC, the client performs zero local reordering, and the view refreshes from the response upsert and the changed frame; a failed move changes nothing. The client's upsert rejects snapshots older (`updatedAt`) than the installed projection so a late unary response cannot roll back a newer frame. + +### Shell/region split + +ui-sidebar shrinks to the column-geometry shell: brand row, fold state machine, New Session, Settings, and one `sidebar.workspaces` hole; the shell↔region contract is two facts, `{ wide, expandSidebar }`. ui-workspace fully owns the browsing region (section header, search, grouped tree and flat list, every workspace dialog, drag) plus its groupBy store; the rail-state search/new-workspace icons belong to the region too and request shell expansion via `expandSidebar()`. The picker splits into the core `WorkspaceCreateFlow` (composed directly inside the region) and the thin `WorkspacePicker` wrapper (still filling ui-conversation's hero slot); the old `sidebar.workspace` picker slot and its declaration-aware deferral are deleted with it. + +## Alternatives considered + +**Keep activity pinning; treat drag as a transient adjustment** — the manual order would be scrambled by the next session activity, making it a fiction; two coexisting ordering authorities cannot be explained to the user. A middle ground — freeze pinning per workspace after the first drag — adds a state tier with murkier semantics; deleting outright is cleaner. + +**Numeric index in the reorder payload** — `{ index }` drifts during the drag window: after the host prepends a new session (e.g. Intent materialization) the same index points at a different row. Anchor-style insertBefore is naturally immune to prepends and filtered projections. + +**Optimistic reordering on drop** — client-first reordering needs failure rollback, one more entangled state in the object layer; local/LAN round-trips are millisecond-scale, so waiting for the host response is imperceptible. With a single order authority (trust the host completely), the frontend never invents an order. + +**Keep the rename dialog in ui-sidebar (smallest change)** — that is the problem itself: workspace-domain dialogs scattered in a borrowed slot, with each addition (the Delete confirmation is coming) repeating the cross-package wiring. Review first considered moving only the rename modal; the ruling was to give the whole browsing region to ui-workspace and leave the shell geometry-only. + +**Keep parent/child adjacency in flat mode** — contradicts strict recency (a child newer than its parent's sibling cannot slot adjacently), and the flat view's purpose is dropping the hierarchy; flattening fully and disabling drag in flat mode (no persistence carrier) is more consistent. + +## Consequences + +- Manual order is the sole authority over the workspace account: an order the user arranges is never scrambled by activity; the cost is losing float-to-top-on-activity, whose signal now rides the row status dot and time label. The `WorkspaceView.sessionIds` wire contract is reworded to the manual-order semantics. +- The two-fact shell/region contract funnels every future workspace-domain feature (Delete confirmation, cross-group moves, Ungrouped adoption) into the single ui-workspace package; ui-sidebar no longer evolves with session-list features. +- Flat mode supports neither reordering nor a create-in-workspace entry point (switching back to grouped view is required) — an accepted scope reduction. +- Wiring the three session-menu items and workspace Delete, and growing the wire status enum, remain future iterations. + +## Testing + +Package-level suites cover the derivations (deriveGroups/deriveFlat), row components, both apply registrations and passthroughs, host entity move semantics, and the rename/insertSessionBefore RPC implementations with their fixture stubs; the `apps/web` keyless snapshots regress the assembled application; delivery acceptance additionally runs a 12-item playwright (chromium headless) checklist (grouped default, flat switch and persistence, hover-card appearance and suppression, both menus, the full rename chain, drag persistence) and drives the real host over the wire for rename success / duplicate rejection / `workspace-move-invalid`. diff --git a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md index e897903f83..432d5167a5 100644 --- a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md +++ b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md @@ -6,7 +6,7 @@ Status: implemented ## Problem -[Workspace UI 完整产品流](2026-07-25-workspace-ui-product-flow.zh.md)交付了分组 session 列表的首个形态,并把 Rename、拖拽排序等操作明确划出当期范围。设计稿(figma 239-10458 及关联画面)随后补齐了这些交互:列表要能切换成不分组的平铺视图、session 行悬停要出详情卡与操作菜单、workspace 要能改名、组内 session 要能手动排序。 +[Workspace UI 完整产品流](2026-07-25-workspace-ui-product-flow.md)交付了分组 session 列表的首个形态,并把 Rename、拖拽排序等操作明确划出当期范围。设计稿(figma 239-10458 及关联画面)随后补齐了这些交互:列表要能切换成不分组的平铺视图、session 行悬停要出详情卡与操作菜单、workspace 要能改名、组内 session 要能手动排序。 两条既有机制挡在前面。其一,host 在每条 `session/event` 上把活跃 session durable 地提到 workspace 账本最前(活动置顶),任何手动排序都会被下一次活动打乱——两种排序权威不可调和。其二,浏览区域被劈在两个包里:ui-sidebar 拥有列表、搜索和组头行,ui-workspace 只借一个 picker 坑放弹层;每加一个 workspace 域的对话框都要跨包接线,归属越来越拧。 From 2ee4cda0667d990525938dd559c90fcd52e35f3d Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 26 Jul 2026 01:30:46 +0800 Subject: [PATCH 13/32] fix(client): lint conformance for the preference services Replace optional chains on always-present globals with the repo's typeof guards (store.ts precedent), drop the non-null assertion by failing loud on an impossible registry miss, and fix two arrow-parens slips; cover the no-localStorage boot path in both service suites. --- packages/client/locale/src/client/index.ts | 7 +++++-- packages/client/locale/tests/locale.spec.ts | 14 +++++++++++++- packages/client/ui-layout/src/client/index.ts | 2 +- packages/client/ui-settings/src/client/index.ts | 2 +- packages/client/ui-theme/src/client/index.ts | 15 ++++++++++----- packages/client/ui-theme/tests/theme.spec.ts | 12 ++++++++++++ 6 files changed, 42 insertions(+), 10 deletions(-) diff --git a/packages/client/locale/src/client/index.ts b/packages/client/locale/src/client/index.ts index e01d69ea25..35f29a0e52 100644 --- a/packages/client/locale/src/client/index.ts +++ b/packages/client/locale/src/client/index.ts @@ -161,8 +161,10 @@ export class LocaleService { /** Read the persisted locale id; unknown or unreadable values fall back to zh. */ function restorePreference(): LocaleId { + // Non-browser runs (node e2e booting the client tree) have no localStorage. + if (typeof localStorage === 'undefined') return FALLBACK_LOCALE try { - const stored = globalThis.localStorage?.getItem(STORAGE_KEY) + const stored = localStorage.getItem(STORAGE_KEY) if (stored === 'zh' || stored === 'en') return stored } catch { // Storage access can throw (privacy mode); the default below covers it. @@ -172,8 +174,9 @@ function restorePreference(): LocaleId { /** Persist the locale id; storage failures are non-fatal (preference resets next boot). */ function persistPreference(id: LocaleId): void { + if (typeof localStorage === 'undefined') return try { - globalThis.localStorage?.setItem(STORAGE_KEY, id) + localStorage.setItem(STORAGE_KEY, id) } catch { // Storage access can throw (privacy mode / quota); the preference simply // does not survive the session. diff --git a/packages/client/locale/tests/locale.spec.ts b/packages/client/locale/tests/locale.spec.ts index 750c2452e8..3f9efaed19 100644 --- a/packages/client/locale/tests/locale.spec.ts +++ b/packages/client/locale/tests/locale.spec.ts @@ -1,5 +1,5 @@ // @vitest-environment jsdom -import { beforeEach, describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import type { LocaleSnapshot } from '@deepseek-ai/dsh-client-locale/client' import { LocaleService, STORAGE_KEY } from '@deepseek-ai/dsh-client-locale/client' @@ -80,6 +80,18 @@ describe('LocaleService', () => { expect(make().svc.getLocale().active).toBe('zh') }) + it('runs without localStorage (node boots): defaults on read, no-op on write', () => { + vi.stubGlobal('localStorage', undefined) + try { + const { svc } = make() + expect(svc.getLocale().active).toBe('zh') + svc.setLocale('en') + expect(svc.getLocale().active).toBe('en') + } finally { + vi.unstubAllGlobals() + } + }) + it('exposes the two shipped locales with self-described labels', () => { const { svc } = make() expect(svc.getLocale().locales).toEqual([ diff --git a/packages/client/ui-layout/src/client/index.ts b/packages/client/ui-layout/src/client/index.ts index 39fee1fb52..7474cd69c3 100644 --- a/packages/client/ui-layout/src/client/index.ts +++ b/packages/client/ui-layout/src/client/index.ts @@ -107,7 +107,7 @@ export function apply(ctx: ClientContext): void { ctx.effect(() => { const presenter = new ThemePresenter() presenter.apply(ctx.theme.getTheme()) - const off = ctx.on('theme/change', snapshot => { presenter.apply(snapshot) }) + const off = ctx.on('theme/change', (snapshot) => { presenter.apply(snapshot) }) return () => { off() presenter.dispose() diff --git a/packages/client/ui-settings/src/client/index.ts b/packages/client/ui-settings/src/client/index.ts index 688436c25b..3613476ede 100644 --- a/packages/client/ui-settings/src/client/index.ts +++ b/packages/client/ui-settings/src/client/index.ts @@ -41,7 +41,7 @@ export function apply(ctx: ClientContext): void { return ctx.locale.bind(ref.slice(0, colon))(ref.slice(colon + 1)) }, sectionsVersion: () => ctx.slots.getVersion('settings.section'), - subscribeSections: (listener) => ctx.slots.subscribe('settings.section', listener), + subscribeSections: listener => ctx.slots.subscribe('settings.section', listener), sections: () => ctx.slots.entries('settings.section') .map(e => ({ /* v8 ignore next -- list-slot registration requires id (SlotCore rejects an entry without one) */ diff --git a/packages/client/ui-theme/src/client/index.ts b/packages/client/ui-theme/src/client/index.ts index b8a75c4bed..5c7ca3fe12 100644 --- a/packages/client/ui-theme/src/client/index.ts +++ b/packages/client/ui-theme/src/client/index.ts @@ -87,7 +87,8 @@ export class ThemeService { constructor(ctx: Context) { this.ctx = ctx this.preference = restorePreference() - this.media = globalThis.matchMedia?.('(prefers-color-scheme: dark)') + // Non-browser runs (node e2e booting the client tree) have no matchMedia. + this.media = typeof matchMedia === 'undefined' ? undefined : matchMedia('(prefers-color-scheme: dark)') this.snapshot = this.buildSnapshot() if (this.media !== undefined) { const media = this.media @@ -157,8 +158,9 @@ export class ThemeService { : this.preference // Both built-ins always exist; a registered preference id resolves or has // been reset by its disposer, so the lookup cannot miss. - /* v8 ignore next -- the ?? arm needs a registry without light/dark, which register()/dispose() cannot produce */ - const active = this.themes.find(t => t.id === resolvedId) ?? this.themes[0]! + const active = this.themes.find(t => t.id === resolvedId) + /* v8 ignore next 2 -- needs a registry without light/dark, which register()/dispose() cannot produce */ + if (active === undefined) throw new Error(`theme registry lost "${resolvedId}"`) return Object.freeze({ preference: this.preference, active, @@ -176,8 +178,10 @@ export class ThemeService { /** Read the persisted preference; unknown or unreadable values fall back to the default. */ function restorePreference(): ThemePreference { + // Non-browser runs (node e2e booting the client tree) have no localStorage. + if (typeof localStorage === 'undefined') return DEFAULT_PREFERENCE try { - const stored = globalThis.localStorage?.getItem(STORAGE_KEY) + const stored = localStorage.getItem(STORAGE_KEY) if (stored === 'light' || stored === 'dark' || stored === 'system') return stored } catch { // Storage access can throw (privacy mode); the default below covers it. @@ -187,8 +191,9 @@ function restorePreference(): ThemePreference { /** Persist the preference; storage failures are non-fatal (preference resets next boot). */ function persistPreference(preference: ThemePreference): void { + if (typeof localStorage === 'undefined') return try { - globalThis.localStorage?.setItem(STORAGE_KEY, preference) + localStorage.setItem(STORAGE_KEY, preference) } catch { // Storage access can throw (privacy mode / quota); the preference simply // does not survive the session. diff --git a/packages/client/ui-theme/tests/theme.spec.ts b/packages/client/ui-theme/tests/theme.spec.ts index fdac111e73..c853c9fd67 100644 --- a/packages/client/ui-theme/tests/theme.spec.ts +++ b/packages/client/ui-theme/tests/theme.spec.ts @@ -88,6 +88,18 @@ describe('ThemeService', () => { expect(events.map(e => e.revision)).toEqual([1, 2, 3, 4]) }) + it('runs without localStorage (node boots): defaults on read, no-op on write', () => { + vi.stubGlobal('localStorage', undefined) + try { + const { theme } = make() + expect(theme.getTheme().preference).toBe('system') + theme.setTheme('dark') + expect(theme.getTheme().preference).toBe('dark') + } finally { + vi.unstubAllGlobals() + } + }) + describe('prefers-color-scheme resolution (stubbed matchMedia)', () => { type Listener = () => void const stubMedia = (initialMatches: boolean) => { From 9fc8a616a9acc9ca303fdadb41313c7f6b1e0454 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 26 Jul 2026 01:48:36 +0800 Subject: [PATCH 14/32] test(web): close the per-file coverage gate for the session-list surfaces New and touched sources reach the CI per-file 100% thresholds: HoverCard (timers, placement clamp, disabled guard), Menu label/danger/pointer-leave branches, WorkspaceBrowser (mode switch, search, rail icons, rename dialog, drag), rows and tree derivations, the workspace fixture stubs, the rename/ insertSessionBefore wire rows, and the entity move semantics. HoverCard's position state narrows to {left, top} (equivalent refactor, no behavior change). --- .../client/connection/tests/fixture.spec.ts | 63 +++ .../client/ui-primitives/src/HoverCard.tsx | 19 +- .../client/ui-primitives/tests/atoms.spec.tsx | 45 ++ .../ui-primitives/tests/hover-card.spec.tsx | 148 ++++++ .../src/client/WorkspaceBrowser.tsx | 5 + .../client/ui-workspace/tests/rows.spec.tsx | 177 ++++++- .../client/ui-workspace/tests/tree.spec.ts | 42 +- .../tests/workspace-browser.spec.tsx | 458 ++++++++++++++++++ .../apiproxy/tests/client-handler.spec.ts | 13 + .../host/apiproxy/tests/rpc-schemas.spec.ts | 29 +- .../workspace/tests/workspace.spec.ts | 52 +- 11 files changed, 1037 insertions(+), 14 deletions(-) create mode 100644 packages/client/ui-primitives/tests/hover-card.spec.tsx create mode 100644 packages/client/ui-workspace/tests/workspace-browser.spec.tsx diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts index 16fa4b4ed6..0baabaf617 100644 --- a/packages/client/connection/tests/fixture.spec.ts +++ b/packages/client/connection/tests/fixture.spec.ts @@ -311,6 +311,60 @@ describe('createFixtureApi', () => { expect(rootPath.result.value.workspace.title).toBe('/') }) + it('workspace.rename covers not-found, conflict, no-op, and the changed frame', async () => { + const api = createFixtureApi() + const abort = new AbortController() + const seen: HostFrame[] = [] + const consuming = (async () => { + for await (const envelope of api.events.host(req({}), abort.signal)) { + seen.push(envelope.payload) + if (seen.length >= 2) abort.abort() + } + })() + await new Promise(resolve => setTimeout(resolve, 10)) + const wsid = 'fx-ws-fixture' as WorkspaceId + const missing = await api.workspace.rename(req({ workspaceId: 'fx-ws-void' as WorkspaceId, title: 'x' })) + expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace-not-found', details: { workspaceId: 'fx-ws-void' } } }) + + await api.workspace.create(req({ name: 'occupied' })) + const conflict = await api.workspace.rename(req({ workspaceId: wsid, title: ' occupied ' })) + expect(conflict.result).toMatchObject({ ok: false, error: { code: 'workspace-name-conflict', details: { name: 'occupied' } } }) + + const noop = await api.workspace.rename(req({ workspaceId: wsid, title: ' fixture ' })) + if (!noop.result.ok) throw new Error('no-op rename failed') + expect(noop.result.value.workspace.title).toBe('fixture') + + const renamed = await api.workspace.rename(req({ workspaceId: wsid, title: 'renamed' })) + if (!renamed.result.ok) throw new Error('rename failed') + expect(renamed.result.value.workspace.title).toBe('renamed') + await consuming + // Only the create and the effective rename emit frames; the no-op stays silent. + expect(seen.map(f => f.type)).toEqual(['host/workspace-changed', 'host/workspace-changed']) + }) + + it('workspace.insertSessionBefore moves, appends, no-ops, and rejects invalid ids', async () => { + const api = createFixtureApi() + const wsid = 'fx-ws-fixture' as WorkspaceId + const missing = await api.workspace.insertSessionBefore(req({ workspaceId: 'fx-ws-void' as WorkspaceId, sessionId: sid('fx-alpha') })) + expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace-not-found' } }) + const ghost = await api.workspace.insertSessionBefore(req({ workspaceId: wsid, sessionId: sid('fx-ghost') })) + expect(ghost.result).toMatchObject({ ok: false, error: { code: 'workspace-move-invalid', details: { sessionId: 'fx-ghost' } } }) + const badAnchor = await api.workspace.insertSessionBefore(req({ workspaceId: wsid, sessionId: sid('fx-alpha'), beforeSessionId: sid('fx-ghost') })) + expect(badAnchor.result).toMatchObject({ ok: false, error: { code: 'workspace-move-invalid', details: { beforeSessionId: 'fx-ghost' } } }) + + const moved = await api.workspace.insertSessionBefore(req({ workspaceId: wsid, sessionId: sid('fx-gamma'), beforeSessionId: sid('fx-beta') })) + if (!moved.result.ok) throw new Error('move failed') + expect(moved.result.value.workspace.sessionIds).toEqual(['fx-alpha', 'fx-gamma', 'fx-beta']) + const appended = await api.workspace.insertSessionBefore(req({ workspaceId: wsid, sessionId: sid('fx-alpha') })) + if (!appended.result.ok) throw new Error('append failed') + expect(appended.result.value.workspace.sessionIds).toEqual(['fx-gamma', 'fx-beta', 'fx-alpha']) + const before = appended.result.value.workspace.updatedAt + const noop = await api.workspace.insertSessionBefore(req({ workspaceId: wsid, sessionId: sid('fx-alpha') })) + if (!noop.result.ok) throw new Error('no-op move failed') + expect(noop.result.value.workspace.sessionIds).toEqual(['fx-gamma', 'fx-beta', 'fx-alpha']) + expect(noop.result.value.workspace.updatedAt).toBe(before) + }) + it('session.create({workspaceId}) lands on the account and unknown ids error', async () => { const api = createFixtureApi() const abort = new AbortController() @@ -558,6 +612,15 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => { const workspace = await client.workspace.create({ name: 'via-client' }) if (!workspace.result.ok) throw new Error('workspace create failed') expect(workspace.result.value.workspace.title).toBe('via-client') + const wsid = workspace.result.value.workspace.workspaceId + const renamed = await client.workspace.rename({ workspaceId: wsid, title: 'via-client-2' }) + if (!renamed.result.ok) throw new Error('workspace rename failed') + expect(renamed.result.value.workspace.title).toBe('via-client-2') + const attached = await client.sessions.create({ workspaceId: wsid }) + if (!attached.result.ok) throw new Error('attached create failed') + const moved = await client.workspace.insertSessionBefore({ workspaceId: wsid, sessionId: attached.result.value.sessionId }) + if (!moved.result.ok) throw new Error('workspace move failed') + expect(moved.result.value.workspace.sessionIds).toEqual([attached.result.value.sessionId]) }) it('maps empty, prompt-reject, and workspace-first query scenarios', async () => { diff --git a/packages/client/ui-primitives/src/HoverCard.tsx b/packages/client/ui-primitives/src/HoverCard.tsx index 58e281778e..1720a0b79c 100644 --- a/packages/client/ui-primitives/src/HoverCard.tsx +++ b/packages/client/ui-primitives/src/HoverCard.tsx @@ -5,7 +5,7 @@ // and closes the instant the pointer leaves the anchor (no close delay). import { useEffect, useLayoutEffect, useRef, useState } from 'react' -import type { CSSProperties, ReactNode } from 'react' +import type { ReactNode } from 'react' import { createPortal } from 'react-dom' import css from './HoverCard.module.css' @@ -27,7 +27,7 @@ export function HoverCard({ anchor, content, openDelayMs = 500, disabled = false const cardRef = useRef(null) const timerRef = useRef | null>(null) const [open, setOpen] = useState(false) - const [pos, setPos] = useState(null) + const [pos, setPos] = useState<{ left: number; top: number } | null>(null) const clearTimer = () => { if (timerRef.current !== null) { @@ -50,8 +50,10 @@ export function HoverCard({ anchor, content, openDelayMs = 500, disabled = false useLayoutEffect(() => { if (!open) { setPos(null); return } const place = () => { - const r = rootRef.current?.getBoundingClientRect() ?? null - if (r === null) return + const wrapper = rootRef.current + /* v8 ignore next -- the ref is attached before the layout effect runs and the listeners die with it. */ + if (wrapper === null) return + const r = wrapper.getBoundingClientRect() const h = cardRef.current?.offsetHeight ?? 0 const top = r.top + h > window.innerHeight - 8 ? window.innerHeight - h - 8 : r.top setPos({ left: r.right + 8, top }) @@ -66,13 +68,14 @@ export function HoverCard({ anchor, content, openDelayMs = 500, disabled = false }, [open]) // The first placement ran before the card mounted (height read 0): once the - // card's real height is measurable, correct the bottom-edge clamp. + // card's real height is measurable, correct the bottom-edge clamp. The + // correction converges — a clamped top satisfies the guard, so it runs once. useLayoutEffect(() => { - if (!open || pos === null || typeof pos.top !== 'number') return + if (!open || pos === null) return + /* v8 ignore next -- the card is mounted whenever pos is set, so the ref is attached here. */ const h = cardRef.current?.offsetHeight ?? 0 if (pos.top + h > window.innerHeight - 8) { - const top = window.innerHeight - h - 8 - if (pos.top !== top) setPos({ ...pos, top }) + setPos({ left: pos.left, top: window.innerHeight - h - 8 }) } }, [open, pos]) diff --git a/packages/client/ui-primitives/tests/atoms.spec.tsx b/packages/client/ui-primitives/tests/atoms.spec.tsx index 440f4ba6ef..9e298d057a 100644 --- a/packages/client/ui-primitives/tests/atoms.spec.tsx +++ b/packages/client/ui-primitives/tests/atoms.spec.tsx @@ -136,6 +136,51 @@ describe('Menu', () => { expect(screen.getByRole('separator')).toBeDefined() }) + it('renders a non-interactive heading label and a danger row', () => { + const onSelect = vi.fn() + render( + trigger} + items={[ + { type: 'label', id: 'h', text: 'Group by' }, + { id: 'del', label: 'Delete', danger: true }, + ]} + onSelect={onSelect} + onClose={() => {}} + />) + const heading = screen.getByText('Group by') + expect(heading.getAttribute('role')).toBe('presentation') + // The heading is not a menu item — only the danger row is interactive. + expect(screen.getAllByRole('menuitem')).toHaveLength(1) + const danger = screen.getByRole('menuitem', { name: 'Delete' }) + expect(danger.className).toMatch(/danger/) + fireEvent.click(danger) + expect(onSelect).toHaveBeenCalledWith('del') + }) + + it('closeOnPointerLeave closes when the pointer leaves the list; default stays open', () => { + const onClose = vi.fn() + const { rerender } = render( + trigger} items={items} onSelect={() => {}} onClose={onClose} />) + fireEvent.pointerLeave(screen.getByRole('menu')) + expect(onClose).toHaveBeenCalledTimes(1) + rerender( + trigger} items={items} onSelect={() => {}} onClose={onClose} />) + fireEvent.pointerLeave(screen.getByRole('menu')) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('a list click does not bubble to the anchor row (portal synthetic-event path)', () => { + const rowClick = vi.fn() + render( +
+ trigger} items={items} onSelect={() => {}} onClose={() => {}} /> +
) + fireEvent.click(screen.getByRole('menuitem', { name: 'Alpha' })) + expect(rowClick).not.toHaveBeenCalled() + }) + it('opens a submenu on hover and selects a nested item', () => { const onSelect = vi.fn() render( diff --git a/packages/client/ui-primitives/tests/hover-card.spec.tsx b/packages/client/ui-primitives/tests/hover-card.spec.tsx new file mode 100644 index 0000000000..c7a95f49fb --- /dev/null +++ b/packages/client/ui-primitives/tests/hover-card.spec.tsx @@ -0,0 +1,148 @@ +// @vitest-environment jsdom +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { HoverCard } from '@deepseek-ai/dsh-client-ui-primitives' + +afterEach(cleanup) +beforeEach(() => { vi.useFakeTimers() }) +afterEach(() => { vi.useRealTimers() }) + +/** Anchor wrapper rect: the card positions from this (jsdom rects are all-zero by default). */ +function stubAnchorRect(anchor: HTMLElement, rect: { top: number; right: number }): void { + const wrapper = anchor.parentElement as HTMLElement + wrapper.getBoundingClientRect = () => ({ + top: rect.top, right: rect.right, left: rect.right - 100, bottom: rect.top + 34, + width: 100, height: 34, x: rect.right - 100, y: rect.top, toJSON: () => ({}), + } as DOMRect) +} + +function mount(props: { openDelayMs?: number; disabled?: boolean } = {}) { + const view = render( + row} content={
card body
} {...props} />, + ) + const anchor = screen.getByText('row') + stubAnchorRect(anchor, { top: 40, right: 200 }) + return { view, anchor, wrapper: anchor.parentElement as HTMLElement } +} + +describe('HoverCard', () => { + it('opens after the dwell delay, positioned right of the anchor', () => { + const { wrapper } = mount() + fireEvent.pointerEnter(wrapper) + expect(screen.queryByText('card body')).toBeNull() + act(() => { vi.advanceTimersByTime(499) }) + expect(screen.queryByText('card body')).toBeNull() + act(() => { vi.advanceTimersByTime(1) }) + const card = screen.getByText('card body').parentElement as HTMLElement + expect(card.parentElement).toBe(document.body) + expect(card.style.left).toBe('208px') + expect(card.style.top).toBe('40px') + }) + + it('honors a custom openDelayMs', () => { + const { wrapper } = mount({ openDelayMs: 50 }) + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(50) }) + expect(screen.getByText('card body')).toBeTruthy() + }) + + it('pointerleave before the delay cancels the pending open', () => { + const { wrapper } = mount() + fireEvent.pointerEnter(wrapper) + fireEvent.pointerLeave(wrapper) + act(() => { vi.advanceTimersByTime(1000) }) + expect(screen.queryByText('card body')).toBeNull() + }) + + it('pointerleave closes an open card immediately; re-enter restarts the dwell', () => { + const { wrapper } = mount() + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + expect(screen.getByText('card body')).toBeTruthy() + fireEvent.pointerLeave(wrapper) + expect(screen.queryByText('card body')).toBeNull() + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + expect(screen.getByText('card body')).toBeTruthy() + }) + + it('a press inside the anchor dismisses the card without waiting for disabled', () => { + const { wrapper } = mount() + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + expect(screen.getByText('card body')).toBeTruthy() + fireEvent.pointerDown(screen.getByText('row')) + expect(screen.queryByText('card body')).toBeNull() + // The pending timer is also cleared: no reopen after the dwell. + act(() => { vi.advanceTimersByTime(1000) }) + expect(screen.queryByText('card body')).toBeNull() + }) + + it('disabled suppresses opening entirely', () => { + const { wrapper } = mount({ disabled: true }) + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(1000) }) + expect(screen.queryByText('card body')).toBeNull() + }) + + it('flipping disabled true closes an open card', () => { + const { view, wrapper } = mount() + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + expect(screen.getByText('card body')).toBeTruthy() + view.rerender(row} content={
card body
} disabled />) + expect(screen.queryByText('card body')).toBeNull() + }) + + it('corrects the bottom-edge clamp once the mounted card height is measurable', () => { + // First placement reads height 0 (card not yet mounted) and keeps the + // anchor top; the post-mount correction re-clamps with the real height. + window.innerHeight = 300 + const offsetHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetHeight')! + Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { configurable: true, get: () => 120 }) + try { + const { wrapper } = mount() + stubAnchorRect(screen.getByText('row'), { top: 280, right: 200 }) + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + const card = screen.getByText('card body').parentElement as HTMLElement + // 300 - 120 - 8 = 172, instead of the anchor top 280. + expect(card.style.top).toBe('172px') + } finally { + Object.defineProperty(HTMLElement.prototype, 'offsetHeight', offsetHeight) + } + }) + + it('clamps inside placement itself when the card is already measured (resize path)', () => { + window.innerHeight = 300 + const { wrapper } = mount() + stubAnchorRect(screen.getByText('row'), { top: 280, right: 200 }) + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + const card = screen.getByText('card body').parentElement as HTMLElement + Object.defineProperty(card, 'offsetHeight', { value: 120 }) + act(() => { fireEvent.resize(window) }) + expect(card.style.top).toBe('172px') + }) + + it('repositions on capture-phase scroll while open and stops listening after close', () => { + const { wrapper } = mount() + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + stubAnchorRect(screen.getByText('row'), { top: 90, right: 300 }) + act(() => { fireEvent.scroll(document) }) + const card = screen.getByText('card body').parentElement as HTMLElement + expect(card.style.left).toBe('308px') + expect(card.style.top).toBe('90px') + fireEvent.pointerLeave(wrapper) + expect(screen.queryByText('card body')).toBeNull() + }) + + it('unmount clears a pending open timer', () => { + const { view, wrapper } = mount() + fireEvent.pointerEnter(wrapper) + view.unmount() + act(() => { vi.advanceTimersByTime(1000) }) + expect(screen.queryByText('card body')).toBeNull() + }) +}) diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx index 57de2cbaf5..d051df2fdb 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx @@ -48,6 +48,7 @@ function GroupByMenu({ groupBy, onPick }: { items={GROUP_BY_ITEMS} selectedId={groupBy} onSelect={(id) => { + /* v8 ignore next -- narrowing guard: the heading label is not selectable, so the only arriving ids are the two modes. */ if (id === 'workspace' || id === 'flat') onPick(id) setOpen(false) }} @@ -137,6 +138,7 @@ function SessionTree({ useSessions, startSession, open, workspaces, query, onRen onRename={group.workspaceId === undefined ? undefined : () => { + /* v8 ignore next -- narrowing guard: the closure is only created for real-workspace groups. */ if (group.workspaceId !== undefined) onRenameRequest(group.workspaceId, group.label) }} /> @@ -154,9 +156,11 @@ function SessionTree({ useSessions, startSession, open, workspaces, query, onRen active: sameGroupDrag, marker: sameGroupDrag && drag.over?.id === node.id ? drag.over.half : null, hover: (half: 'before' | 'after') => { + /* v8 ignore next -- narrowing guard: Rows gates hover on `active`, which is false while the drag state is null. */ setDrag(d => (d === null ? d : { ...d, over: { id: node.id, half } })) }, drop: (half: 'before' | 'after') => { + /* v8 ignore next -- narrowing guard: Rows gates drop on `active`, which is false while the drag state is null. */ if (drag === null) return const roots = group.sessions // Anchor = the row the insert line points at ('after' means @@ -218,6 +222,7 @@ function FlatList({ useSessions, open, query }: Pick {}} flat /> diff --git a/packages/client/ui-workspace/tests/rows.spec.tsx b/packages/client/ui-workspace/tests/rows.spec.tsx index fdc5c43b8e..b90fb9a601 100644 --- a/packages/client/ui-workspace/tests/rows.spec.tsx +++ b/packages/client/ui-workspace/tests/rows.spec.tsx @@ -1,7 +1,8 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' -import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { act, cleanup, createEvent, fireEvent, render, screen } from '@testing-library/react' import type { SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' +import type { RowDragProps } from '../src/client/rows/Rows.tsx' import { IntentRowItem, ProjectRowItem, SessionNodeItem } from '../src/client/rows/Rows.tsx' import type { GroupNode, SessionNode } from '../src/client/tree.ts' @@ -10,6 +11,32 @@ afterEach(cleanup) const sid = (id: string) => id as SessionId const wid = (id: string) => id as WorkspaceId +/** Half detection reads the row rect; jsdom rects are all-zero by default. */ +function stubRect(row: HTMLElement): void { + row.getBoundingClientRect = () => ({ + top: 100, bottom: 134, left: 0, right: 200, width: 200, height: 34, + x: 0, y: 100, toJSON: () => ({}), + } as DOMRect) +} + +function dragProps(overrides: Partial = {}): RowDragProps { + return { + start: vi.fn(), active: false, marker: null, + hover: vi.fn(), drop: vi.fn(), end: vi.fn(), + ...overrides, + } +} + +const dataTransfer = { effectAllowed: '', dropEffect: '' } + +/** jsdom lacks DragEvent — the fireEvent fallback drops clientY, so pin it on the built event. */ +function fireDrag(row: HTMLElement, kind: 'dragOver' | 'drop', clientY: number): void { + const event = kind === 'dragOver' ? createEvent.dragOver(row) : createEvent.drop(row) + Object.defineProperty(event, 'clientY', { value: clientY }) + Object.defineProperty(event, 'dataTransfer', { value: { ...dataTransfer } }) + fireEvent(row, event) +} + describe('workspace browser rows', () => { it('renders an active Workspace and keeps its create action separate from toggling', () => { const onToggle = vi.fn() @@ -73,4 +100,152 @@ describe('workspace browser rows', () => { expect(screen.getByRole('treeitem').getAttribute('aria-selected')).toBe('false') expect(screen.getByRole('treeitem').style.paddingLeft).toBe('24px') }) + + it('workspace row menu opens on the ellipsis, renames, and shows the danger delete row', () => { + const onRename = vi.fn() + const onToggle = vi.fn() + const group: GroupNode = { + key: 'project', workspaceId: wid('project'), cwd: '/projects/project', label: 'Project', + sessionCount: 0, expanded: false, containsCurrent: false, intentHere: false, sessions: [], + } + render() + fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Project' })) + // Opening the menu neither toggles the group nor renames yet. + expect(onToggle).not.toHaveBeenCalled() + expect(screen.getByRole('menuitem', { name: 'Delete workspace' }).className).toMatch(/danger/) + fireEvent.click(screen.getByRole('menuitem', { name: 'Rename' })) + expect(onRename).toHaveBeenCalledOnce() + expect(screen.queryByRole('menu')).toBeNull() + // Delete stays visual-only: selecting it just closes the menu. + fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Project' })) + fireEvent.click(screen.getByRole('menuitem', { name: 'Delete workspace' })) + expect(screen.queryByRole('menu')).toBeNull() + expect(onRename).toHaveBeenCalledOnce() + // Escape closes without selecting (Menu onClose path). + fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Project' })) + fireEvent.keyDown(document, { key: 'Escape' }) + expect(screen.queryByRole('menu')).toBeNull() + }) + + it('ungrouped bucket renders no workspace menu', () => { + const group: GroupNode = { + key: '', workspaceId: undefined, cwd: undefined, label: 'Ungrouped', + sessionCount: 0, expanded: false, containsCurrent: false, intentHere: false, sessions: [], + } + render() + expect(screen.queryByRole('button', { name: /Workspace actions/ })).toBeNull() + }) + + it('session row menu opens without opening the session and closes on selection', () => { + const onOpen = vi.fn() + const node: SessionNode = { + id: sid('s1'), title: 'One', children: [], hasChildren: false, + expanded: false, running: false, updatedAt: 0, + } + render() + fireEvent.click(screen.getByRole('button', { name: 'Session actions for One' })) + expect(onOpen).not.toHaveBeenCalled() + expect(screen.getByRole('menuitem', { name: 'Delete session' }).className).toMatch(/danger/) + fireEvent.click(screen.getByRole('menuitem', { name: 'Fork session' })) + expect(screen.queryByRole('menu')).toBeNull() + expect(onOpen).not.toHaveBeenCalled() + // Escape closes without selecting (Menu onClose path). + fireEvent.click(screen.getByRole('button', { name: 'Session actions for One' })) + fireEvent.keyDown(document, { key: 'Escape' }) + expect(screen.queryByRole('menu')).toBeNull() + }) + + it('flat variant renders no twist even for a parent and ignores toggling', () => { + const node: SessionNode = { + id: sid('p'), title: 'Parent', children: [], hasChildren: true, + expanded: false, running: false, updatedAt: 0, + } + render() + expect(screen.queryByRole('button', { name: 'Expand' })).toBeNull() + }) + + it('shows the hover card after the dwell and suppresses it while the row menu is open', () => { + vi.useFakeTimers() + try { + const node: SessionNode = { + id: sid('s1'), title: 'Hovered', children: [], hasChildren: false, + expanded: false, running: true, updatedAt: 0, + } + render() + const wrapper = screen.getByRole('treeitem').parentElement as HTMLElement + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + // Card body: full title + relative time + running status. + expect(screen.getAllByText('Hovered')).toHaveLength(2) + expect(screen.getByText('1min ago')).toBeTruthy() + expect(screen.getByText('Running')).toBeTruthy() + fireEvent.pointerLeave(wrapper) + // Menu open (disabled=true) suppresses the card for the same hover. + fireEvent.click(screen.getByRole('button', { name: 'Session actions for Hovered' })) + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(1000) }) + expect(screen.queryByText('1min ago')).toBeNull() + } finally { + vi.useRealTimers() + } + }) + + it('idle hover card shows the Idle status line', () => { + vi.useFakeTimers() + try { + const node: SessionNode = { + id: sid('s1'), title: 'Quiet', children: [], hasChildren: false, + expanded: false, running: false, updatedAt: 0, + } + render() + fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement) + act(() => { vi.advanceTimersByTime(500) }) + expect(screen.getByText('Idle')).toBeTruthy() + expect(screen.getByText('now ago')).toBeTruthy() + } finally { + vi.useRealTimers() + } + }) + + it('draggable row wires start/end and gates hover/drop on an active same-group drag', () => { + const node: SessionNode = { + id: sid('s1'), title: 'Drag me', children: [], hasChildren: false, + expanded: false, running: false, updatedAt: 0, + } + const inactive = dragProps() + const { rerender } = render( + , + ) + const row = screen.getByRole('treeitem') + stubRect(row) + expect(row.getAttribute('draggable')).toBe('true') + fireEvent.dragStart(row, { dataTransfer }) + expect(inactive.start).toHaveBeenCalledOnce() + // Inactive drag: hover and drop are rejected. + fireEvent.dragOver(row, { dataTransfer }) + fireEvent.drop(row, { dataTransfer }) + expect(inactive.hover).not.toHaveBeenCalled() + expect(inactive.drop).not.toHaveBeenCalled() + fireEvent.dragEnd(row) + expect(inactive.end).toHaveBeenCalledOnce() + + const active = dragProps({ active: true, marker: 'before' }) + rerender( + , + ) + stubRect(screen.getByRole('treeitem')) + // Top half hovers/drops 'before'; bottom half 'after' (row mid = 117). + fireDrag(screen.getByRole('treeitem'), 'dragOver', 105) + expect(active.hover).toHaveBeenCalledWith('before') + fireDrag(screen.getByRole('treeitem'), 'dragOver', 130) + expect(active.hover).toHaveBeenCalledWith('after') + fireDrag(screen.getByRole('treeitem'), 'drop', 130) + expect(active.drop).toHaveBeenCalledWith('after') + + const after = dragProps({ active: true, marker: 'after' }) + rerender( + , + ) + expect(screen.getByRole('treeitem').className).toMatch(/dropAfter/) + }) }) diff --git a/packages/client/ui-workspace/tests/tree.spec.ts b/packages/client/ui-workspace/tests/tree.spec.ts index d114d43dea..b314e14571 100644 --- a/packages/client/ui-workspace/tests/tree.spec.ts +++ b/packages/client/ui-workspace/tests/tree.spec.ts @@ -2,7 +2,8 @@ import { describe, expect, it } from 'vitest' import type { SessionId, SessionListState, SessionSummary, WorkspaceId, WorkspaceView, } from '@deepseek-ai/dsh-client-runtime/client' -import { deriveGroups, formatRelativeTime, projectLabel, UNGROUPED_KEY, UNGROUPED_LABEL } from '../src/client/tree.ts' +import { deriveFlat, deriveGroups, formatRelativeTime, projectLabel, UNGROUPED_KEY, UNGROUPED_LABEL } from '../src/client/tree.ts' +import { createWorkspaceViewStore } from '../src/client/stores.ts' const sid = (id: string) => id as SessionId const wid = (id: string) => id as WorkspaceId @@ -52,6 +53,12 @@ describe('deriveGroups', () => { expect(deriveGroups({ ...list(), intent: hiddenIntent }, [target], view())[0]!.intentHere).toBe(false) }) + it('an Intent no longer forces its target group expanded (viewer owns expansion)', () => { + const intent = { sessionId: sid('intent'), target: { kind: 'workspace' as const, workspaceId: wid('first') }, prompt: '', phase: 'connecting' as const } + const groups = deriveGroups({ ...list(), intent }, [workspace('first', [])], view()) + expect(groups[0]).toEqual(expect.objectContaining({ intentHere: true, expanded: false })) + }) + it('search filters real Sessions and omits the Intent placeholder', () => { const intent = { sessionId: sid('intent'), target: { kind: 'workspace' as const, workspaceId: wid('first') }, prompt: '', phase: 'ready' as const } const groups = deriveGroups({ ...list(summary('match', 1)), intent }, [workspace('first', ['match'])], view([], 'match')) @@ -135,6 +142,39 @@ describe('deriveGroups', () => { }) }) +describe('deriveFlat', () => { + it('flattens every session — fork children included — newest-first with id tiebreak', () => { + const parent = summary('parent', 10) + const child = { ...summary('child', 30), parentId: parent.id } + const tieB = summary('tie-b', 20) + const tieA = summary('tie-a', 20) + const rows = deriveFlat(list(parent, child, tieB, tieA), { query: '' }) + expect(rows.map(row => row.id)).toEqual([sid('child'), sid('tie-a'), sid('tie-b'), sid('parent')]) + // Rows are branch-free: no children, no expansion. + expect(rows.every(row => row.children.length === 0 && !row.hasChildren && !row.expanded)).toBe(true) + }) + + it('search filters by case-insensitive display-title substring', () => { + const hit = { ...summary('hit', 2), displayTitle: 'Needle row' } + const miss = { ...summary('miss', 1), displayTitle: 'Other' } + expect(deriveFlat(list(hit, miss), { query: ' NEEDLE ' }).map(row => row.id)).toEqual([sid('hit')]) + }) + + it('tolerates ids whose summary has not landed yet', () => { + const partial: SessionListState = { ...list(summary('present', 1)), ids: [sid('ghost'), sid('present')] } + expect(deriveFlat(partial, { query: '' }).map(row => row.id)).toEqual([sid('present')]) + }) +}) + +describe('createWorkspaceViewStore', () => { + it('defaults to workspace grouping; setGroupBy is the sole mutation', () => { + const store = createWorkspaceViewStore().create() + expect(store.getSnapshot().groupBy).toBe('workspace') + store.actions.setGroupBy('flat') + expect(store.getSnapshot().groupBy).toBe('flat') + }) +}) + describe('projectLabel', () => { it('uses the Ungrouped fallback and extracts POSIX and Windows basenames', () => { expect(projectLabel(undefined)).toBe(UNGROUPED_LABEL) diff --git a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx new file mode 100644 index 0000000000..3e592c3168 --- /dev/null +++ b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx @@ -0,0 +1,458 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, createEvent, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' +import type { + SessionId, SessionListState, SessionSummary, WorkspaceId, WorkspaceListState, WorkspaceView, +} from '@deepseek-ai/dsh-client-runtime/client' +import type { WorkspaceBrowserProps } from '../src/client/contract/slots.ts' +import { createWorkspaceViewStore } from '../src/client/stores.ts' +import { WorkspaceBrowser } from '../src/client/WorkspaceBrowser.tsx' + +afterEach(cleanup) +beforeEach(() => { localStorage.clear() }) + +const sid = (id: string) => id as SessionId +const wid = (id: string) => id as WorkspaceId +const summary = (id: string, updatedAt: number, overrides: Partial = {}): SessionSummary => ({ + id: sid(id), displayTitle: id, running: false, updatedAt, ...overrides, +}) +const sessionState = (items: readonly SessionSummary[], overrides: Partial = {}): SessionListState => ({ + ids: items.map(item => item.id), + byId: Object.fromEntries(items.map(item => [item.id, item])), + current: undefined, + phase: 'ready', + intent: undefined, + ...overrides, +}) +const workspace = (id: string, sessionIds: string[], title = id): WorkspaceView => ({ + workspaceId: wid(id), path: `/projects/${id}`, title, + sessionIds: sessionIds.map(sid), createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', +}) +const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState => ({ + items, intent: undefined, state: 'idle', phase: 'ready', error: null, baselinesReady: true, + recentWorkspaceId: items[0]?.workspaceId, +}) +const hook = (snapshot: T) => (selector: (state: T) => S): S => selector(snapshot) + +/** jsdom lacks DragEvent — the fireEvent fallback drops clientY, so pin it on the built event. */ +function fireDrag(row: HTMLElement, kind: 'dragOver' | 'drop', clientY: number): void { + const event = kind === 'dragOver' ? createEvent.dragOver(row) : createEvent.drop(row) + Object.defineProperty(event, 'clientY', { value: clientY }) + Object.defineProperty(event, 'dataTransfer', { value: { effectAllowed: '', dropEffect: '' } }) + fireEvent(row, event) +} + +function mount(overrides: Partial = {}) { + const store = createWorkspaceViewStore().create() + const props: WorkspaceBrowserProps = { + wide: true, + expandSidebar: vi.fn(), + useSessions: hook(sessionState([])), + useWorkspaces: hook(workspaceState([])), + useStore: bindSnapshotSelector(store), + actions: store.actions, + startSession: vi.fn(), + open: vi.fn(), + renameWorkspace: vi.fn(async () => {}), + insertSessionBefore: vi.fn(async () => {}), + createWorkspace: vi.fn(async () => workspace('created', [])), + ...overrides, + } + const view = render() + return { view, props, store } +} + +/** Re-render with (possibly) changed props — WorkspaceBrowser has no side channel. */ +function rerender(b: ReturnType, overrides: Partial) { + Object.assign(b.props, overrides) + b.view.rerender() +} + +describe('WorkspaceBrowser', () => { + it('renders the grouped tree by default and switches to the flat list via Group by', () => { + const sessions = sessionState([summary('alpha-s', 2), summary('beta-s', 1)]) + const b = mount({ + useSessions: hook(sessions), + useWorkspaces: hook(workspaceState([workspace('alpha', ['alpha-s']), workspace('beta', ['beta-s'])])), + }) + expect(screen.getByText('Workspaces')).toBeTruthy() + expect(screen.getByText('alpha')).toBeTruthy() + // Sessions hidden while their group is folded. + expect(screen.queryByText('alpha-s')).toBeNull() + + fireEvent.click(screen.getByRole('button', { name: 'Group by' })) + expect(screen.getByText('Group by')).toBeTruthy() // the menu heading label + fireEvent.click(screen.getByRole('menuitem', { name: 'In one list' })) + // Store-driven flip: title changes, rows flatten newest-first, headers gone. + expect(b.store.getSnapshot().groupBy).toBe('flat') + expect(screen.getByText('Sessions')).toBeTruthy() + expect(screen.queryByText('alpha')).toBeNull() + expect(screen.getByText('alpha-s')).toBeTruthy() + expect(screen.getByText('beta-s')).toBeTruthy() + + // Back to workspace grouping through the same menu. + fireEvent.click(screen.getByRole('button', { name: 'Group by' })) + fireEvent.click(screen.getByRole('menuitem', { name: 'WorkSpace' })) + expect(b.store.getSnapshot().groupBy).toBe('workspace') + expect(screen.getByText('Workspaces')).toBeTruthy() + + // Escape closes the menu without picking. + fireEvent.click(screen.getByRole('button', { name: 'Group by' })) + fireEvent.keyDown(document, { key: 'Escape' }) + expect(screen.queryByRole('menu')).toBeNull() + expect(b.store.getSnapshot().groupBy).toBe('workspace') + }) + + it('expands a group on click and opens a session row', () => { + const open = vi.fn() + mount({ + useSessions: hook(sessionState([summary('alpha-s', 1)])), + useWorkspaces: hook(workspaceState([workspace('alpha', ['alpha-s'])])), + open, + }) + fireEvent.click(screen.getByText('alpha')) + fireEvent.click(screen.getByText('alpha-s')) + expect(open).toHaveBeenCalledWith(sid('alpha-s')) + // Collapse hides the row again. + fireEvent.click(screen.getByText('alpha')) + expect(screen.queryByText('alpha-s')).toBeNull() + }) + + it('unfolds a session subtree through the row twist', () => { + const parent = summary('parent-s', 2) + const child = { ...summary('child-s', 1), parentId: parent.id } + mount({ + useSessions: hook(sessionState([parent, child])), + useWorkspaces: hook(workspaceState([workspace('alpha', ['parent-s', 'child-s'])])), + }) + fireEvent.click(screen.getByText('alpha')) + expect(screen.queryByText('child-s')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Expand' })) + expect(screen.getByText('child-s')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Collapse' })) + expect(screen.queryByText('child-s')).toBeNull() + }) + + it('auto-expands the selected session group and starts a session from the group +', () => { + const startSession = vi.fn() + mount({ + useSessions: hook(sessionState([summary('alpha-s', 1)], { current: sid('alpha-s') })), + useWorkspaces: hook(workspaceState([workspace('alpha', ['alpha-s'])])), + startSession, + }) + // The current-group effect expanded the owning group without a click. + expect(screen.getByText('alpha-s')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'New session in alpha' })) + expect(startSession).toHaveBeenCalledWith(wid('alpha')) + }) + + it('auto-expands the Ungrouped bucket for a loose current session; its header has no menu and its + is inert', () => { + const startSession = vi.fn() + mount({ + useSessions: hook(sessionState([summary('loose', 1)], { current: sid('loose') })), + useWorkspaces: hook(workspaceState([workspace('alpha', [])])), + startSession, + }) + // The loose session's group is UNGROUPED_KEY: expanded by the effect. + expect(screen.getByText('loose')).toBeTruthy() + expect(screen.queryByRole('button', { name: 'Workspace actions for Ungrouped' })).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'New session in Ungrouped' })) + expect(startSession).not.toHaveBeenCalled() + }) + + it('keeps an already-expanded group when the selection moves within it', () => { + const first = sessionState([summary('a', 2), summary('b', 1)], { current: sid('a') }) + const b = mount({ + useSessions: hook(first), + useWorkspaces: hook(workspaceState([workspace('alpha', ['a', 'b'])])), + }) + expect(screen.getByText('a')).toBeTruthy() + // Selection hop inside the same group: the effect re-runs and leaves the + // expansion list unchanged (no duplicate key, group still open). + rerender(b, { useSessions: hook({ ...first, current: sid('b') }) }) + expect(screen.getByText('b')).toBeTruthy() + fireEvent.click(screen.getByText('alpha')) + expect(screen.queryByText('b')).toBeNull() + }) + + it('renders the intent placeholder in both modes', () => { + const intent = { sessionId: sid('intent'), target: { kind: 'workspace' as const, workspaceId: wid('alpha') }, prompt: '', phase: 'connecting' as const } + const sessions = sessionState([], { intent, current: sid('intent') }) + const b = mount({ + useSessions: hook(sessions), + useWorkspaces: hook(workspaceState([workspace('alpha', [])])), + }) + // Grouped: the current-group effect expands the target group. + expect(screen.getByText('New session')).toBeTruthy() + b.store.actions.setGroupBy('flat') + rerender(b, {}) + expect(screen.getByText('New session')).toBeTruthy() + }) + + it('searches across groups, clears via the clear button, and shows the empty states', () => { + const sessions = sessionState([ + summary('needle-row', 2, { displayTitle: 'Needle row' }), + summary('other-row', 1, { displayTitle: 'Other row' }), + ]) + mount({ + useSessions: hook(sessions), + useWorkspaces: hook(workspaceState([workspace('alpha', ['needle-row', 'other-row'])])), + }) + const input = screen.getByPlaceholderText('Search name, keywords...') + fireEvent.change(input, { target: { value: 'needle' } }) + // Search forces matches visible without expansion state. + expect(screen.getByText('Needle row')).toBeTruthy() + expect(screen.queryByText('Other row')).toBeNull() + fireEvent.change(input, { target: { value: 'zzz' } }) + expect(screen.getByText('No matches')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Clear search' })) + expect(input.value).toBe('') + // Clicking the field row focuses the input (wide mode). + fireEvent.click(input.parentElement as HTMLElement) + expect(document.activeElement).toBe(input) + }) + + it('shows the no-sessions empty state in both modes', () => { + const b = mount() + expect(screen.getByText('No sessions yet')).toBeTruthy() + b.store.actions.setGroupBy('flat') + rerender(b, {}) + expect(screen.getByText('No sessions yet')).toBeTruthy() + // Flat search misses show No matches. + fireEvent.change(screen.getByPlaceholderText('Search name, keywords...'), { target: { value: 'x' } }) + expect(screen.getByText('No matches')).toBeTruthy() + }) + + it('rail state renders icon controls that request expansion', () => { + vi.useFakeTimers() + try { + const expandSidebar = vi.fn() + const b = mount({ wide: false, expandSidebar }) + // No wide chrome in rail state. + expect(screen.queryByText('Workspaces')).toBeNull() + expect(screen.queryByPlaceholderText('Search name, keywords...')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Search sessions' })) + expect(expandSidebar).toHaveBeenCalledTimes(1) + // The wide flip mounts the input and focuses it after the slide. + rerender(b, { wide: true }) + const input = screen.getByPlaceholderText('Search name, keywords...') + act(() => { vi.advanceTimersByTime(300) }) + expect(document.activeElement).toBe(input) + // Wide search button is decorative (tabIndex -1, no expand call). + fireEvent.click(screen.getByRole('button', { name: 'Search sessions' })) + expect(expandSidebar).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + } + }) + + it('rail create-workspace expands the shell and opens the picker; wide toggles in place', () => { + const expandSidebar = vi.fn() + const b = mount({ wide: false, expandSidebar, useWorkspaces: hook(workspaceState([workspace('alpha', [])])) }) + fireEvent.click(screen.getByRole('button', { name: 'Create workspace' })) + expect(expandSidebar).toHaveBeenCalledTimes(1) + rerender(b, { wide: true }) + // The picker menu is open (anchored on the +); picking starts a session. + fireEvent.click(screen.getByRole('menuitem', { name: 'alpha' })) + expect(b.props.startSession).toHaveBeenCalledWith(wid('alpha')) + expect(screen.queryByRole('menu')).toBeNull() + // Wide toggle: open and close without expand requests. + fireEvent.click(screen.getByRole('button', { name: 'Create workspace' })) + expect(screen.getByRole('menu')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Create workspace' })) + expect(screen.queryByRole('menu')).toBeNull() + expect(expandSidebar).toHaveBeenCalledTimes(1) + + // Escape closes the picker through its own onClose. + fireEvent.click(screen.getByRole('button', { name: 'Create workspace' })) + fireEvent.keyDown(document, { key: 'Escape' }) + expect(screen.queryByRole('menu')).toBeNull() + }) + + it('drag reorder reports the anchor to insertSessionBefore and skips no-op drops', () => { + const insertSessionBefore = vi.fn(async () => {}) + const sessions = sessionState([summary('one', 3), summary('two', 2), summary('three', 1)]) + mount({ + useSessions: hook(sessions), + useWorkspaces: hook(workspaceState([workspace('alpha', ['one', 'two', 'three'])])), + insertSessionBefore, + }) + fireEvent.click(screen.getByText('alpha')) + const rows = screen.getAllByRole('treeitem').slice(1) // drop the group header + const [one, , three] = rows as [HTMLElement, HTMLElement, HTMLElement] + three.getBoundingClientRect = () => ({ + top: 200, bottom: 234, left: 0, right: 200, width: 200, height: 34, x: 0, y: 200, toJSON: () => ({}), + } as DOMRect) + const dataTransfer = { effectAllowed: '', dropEffect: '' } + fireEvent.dragStart(one, { dataTransfer }) + // Drop on the top half of "three": insert one before three. + fireDrag(three, 'dragOver', 205) + fireDrag(three, 'drop', 205) + expect(insertSessionBefore).toHaveBeenCalledWith(wid('alpha'), sid('one'), sid('three')) + + // Dropping right back onto its own position is a no-op — top half + // (anchor = itself) and bottom half (anchor = the next root) alike. + fireEvent.dragStart(one, { dataTransfer }) + one.getBoundingClientRect = () => ({ + top: 100, bottom: 134, left: 0, right: 200, width: 200, height: 34, x: 0, y: 100, toJSON: () => ({}), + } as DOMRect) + fireDrag(one, 'dragOver', 105) + fireDrag(one, 'drop', 105) + expect(insertSessionBefore).toHaveBeenCalledTimes(1) + fireEvent.dragStart(one, { dataTransfer }) + fireDrag(one, 'drop', 130) + expect(insertSessionBefore).toHaveBeenCalledTimes(1) + }) + + it('still sends the reorder when the dragged row left the group mid-drag', () => { + const insertSessionBefore = vi.fn(async () => {}) + const sessions = sessionState([summary('one', 2), summary('two', 1)]) + const b = mount({ + useSessions: hook(sessions), + useWorkspaces: hook(workspaceState([workspace('alpha', ['one', 'two'])])), + insertSessionBefore, + }) + fireEvent.click(screen.getByText('alpha')) + const one = screen.getByText('one').closest('[role="treeitem"]') as HTMLElement + fireEvent.dragStart(one, { dataTransfer: { effectAllowed: '', dropEffect: '' } }) + // The host dropped "one" from the workspace account while the drag is in + // flight: the source index is gone but the drop still resolves its anchor. + rerender(b, { useWorkspaces: hook(workspaceState([workspace('alpha', ['two'])])) }) + const two = screen.getByText('two').closest('[role="treeitem"]') as HTMLElement + two.getBoundingClientRect = () => ({ + top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}), + } as DOMRect) + fireDrag(two, 'drop', 155) + expect(insertSessionBefore).toHaveBeenCalledWith(wid('alpha'), sid('one'), sid('two')) + }) + + it('drag end without a drop clears markers; bottom-half drop appends past the last row', () => { + const insertSessionBefore = vi.fn(async () => {}) + const sessions = sessionState([summary('one', 2), summary('two', 1)]) + mount({ + useSessions: hook(sessions), + useWorkspaces: hook(workspaceState([workspace('alpha', ['one', 'two'])])), + insertSessionBefore, + }) + fireEvent.click(screen.getByText('alpha')) + const [one, two] = screen.getAllByRole('treeitem').slice(1) as [HTMLElement, HTMLElement] + two.getBoundingClientRect = () => ({ + top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}), + } as DOMRect) + const dataTransfer = { effectAllowed: '', dropEffect: '' } + fireEvent.dragStart(one, { dataTransfer }) + fireEvent.dragEnd(one) + // The drag ended: rows no longer accept drops. + fireDrag(two, 'drop', 180) + expect(insertSessionBefore).not.toHaveBeenCalled() + + // Bottom half of the last row: append (anchor omitted). + fireEvent.dragStart(one, { dataTransfer }) + fireDrag(two, 'dragOver', 180) + fireDrag(two, 'drop', 180) + expect(insertSessionBefore).toHaveBeenCalledWith(wid('alpha'), sid('one'), undefined) + }) + + it('logs and keeps the order when the reorder call rejects', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + const insertSessionBefore = vi.fn(async () => { throw new Error('stale anchor') }) + const sessions = sessionState([summary('one', 2), summary('two', 1)]) + mount({ + useSessions: hook(sessions), + useWorkspaces: hook(workspaceState([workspace('alpha', ['one', 'two'])])), + insertSessionBefore, + }) + fireEvent.click(screen.getByText('alpha')) + const [one, two] = screen.getAllByRole('treeitem').slice(1) as [HTMLElement, HTMLElement] + two.getBoundingClientRect = () => ({ + top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}), + } as DOMRect) + const dataTransfer = { effectAllowed: '', dropEffect: '' } + fireEvent.dragStart(one, { dataTransfer }) + fireDrag(two, 'drop', 180) + await waitFor(() => { expect(warn).toHaveBeenCalledWith('session reorder rejected:', expect.any(Error)) }) + } finally { + warn.mockRestore() + } + }) + + it('renames a workspace through the row menu dialog', async () => { + let resolveRename!: () => void + const renameWorkspace = vi.fn(() => new Promise((resolve) => { resolveRename = resolve })) + mount({ + useWorkspaces: hook(workspaceState([workspace('alpha', [], 'Alpha'), workspace('beta', [], 'Beta')])), + renameWorkspace, + }) + fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Alpha' })) + fireEvent.click(screen.getByRole('menuitem', { name: 'Rename' })) + const input = screen.getByLabelText('Workspace name') + expect(input.value).toBe('Alpha') + // Unchanged and blank names stay blocked. + expect((screen.getByRole('button', { name: 'Rename' }) as HTMLButtonElement).disabled).toBe(true) + fireEvent.change(input, { target: { value: ' ' } }) + expect((screen.getByRole('button', { name: 'Rename' }) as HTMLButtonElement).disabled).toBe(true) + // A duplicate of another workspace's title shows the inline conflict. + fireEvent.change(input, { target: { value: ' Beta ' } }) + expect(screen.getByRole('alert').textContent).toBe('A workspace named “Beta” already exists.') + expect((screen.getByRole('button', { name: 'Rename' }) as HTMLButtonElement).disabled).toBe(true) + fireEvent.change(input, { target: { value: 'Gamma' } }) + fireEvent.click(screen.getByRole('button', { name: 'Rename' })) + expect(renameWorkspace).toHaveBeenCalledWith(wid('alpha'), 'Gamma') + // While renaming: input disabled, close blocked, Enter ignored. + expect(input.disabled).toBe(true) + fireEvent.keyDown(document, { key: 'Escape' }) + expect(screen.getByRole('dialog')).toBeTruthy() + await act(async () => { resolveRename() }) + expect(screen.queryByRole('dialog')).toBeNull() + }) + + it('rename via Enter, failure surfaces the error, Cancel closes', async () => { + const renameWorkspace = vi.fn(async () => { throw new Error('rename conflict') }) + mount({ + useWorkspaces: hook(workspaceState([workspace('alpha', [], 'Alpha')])), + renameWorkspace, + }) + fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Alpha' })) + fireEvent.click(screen.getByRole('menuitem', { name: 'Rename' })) + const input = screen.getByLabelText('Workspace name') + // Enter with a blocked draft (unchanged) does nothing. + fireEvent.keyDown(input, { key: 'Enter' }) + expect(renameWorkspace).not.toHaveBeenCalled() + fireEvent.change(input, { target: { value: 'Renamed' } }) + fireEvent.keyDown(input, { key: 'a' }) + fireEvent.keyDown(input, { key: 'Enter' }) + expect(renameWorkspace).toHaveBeenCalledWith(wid('alpha'), 'Renamed') + await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('rename conflict') }) + // The dialog stays for retry; typing clears the error; Cancel closes. + fireEvent.change(input, { target: { value: 'Renamed2' } }) + expect(screen.queryByRole('alert')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + expect(screen.queryByRole('dialog')).toBeNull() + }) + + it('reports non-Error rename failures as text', async () => { + const renameWorkspace = vi.fn(async () => { throw 'denied' }) + mount({ + useWorkspaces: hook(workspaceState([workspace('alpha', [], 'Alpha')])), + renameWorkspace, + }) + fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Alpha' })) + fireEvent.click(screen.getByRole('menuitem', { name: 'Rename' })) + fireEvent.change(screen.getByLabelText('Workspace name'), { target: { value: 'Other' } }) + fireEvent.click(screen.getByRole('button', { name: 'Rename' })) + await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('denied') }) + }) + + it('search hides drag affordances (rows are not draggable during search)', () => { + const sessions = sessionState([summary('needle-a', 2, { displayTitle: 'Needle A' })]) + mount({ + useSessions: hook(sessions), + useWorkspaces: hook(workspaceState([workspace('alpha', ['needle-a'])])), + }) + fireEvent.change(screen.getByPlaceholderText('Search name, keywords...'), { target: { value: 'needle' } }) + const row = screen.getByText('Needle A').closest('[role="treeitem"]') as HTMLElement + expect(row.getAttribute('draggable')).toBe('false') + }) +}) diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index 25b095b99f..a81aadd94b 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -68,6 +68,19 @@ describe('unary round trip', () => { expect(response.result).toEqual({ ok: true, value: { items: [{ sessionId: 's1', updatedAt: 7, running: false }] } }) }) + it('routes workspace rename and insertSessionBefore through the wire', async () => { + const api = scriptedApi() + const c = client(api) + const renamed = await c.workspace.rename({ workspaceId: 'w1' as never, title: 'next' }) + expect(renamed.result.ok).toBe(true) + const blankTitle = await c.workspace.rename({ workspaceId: 'w1' as never, title: ' ' }) + expect(blankTitle.result).toMatchObject({ ok: false, error: { code: 'bad-request' } }) + const anchored = await c.workspace.insertSessionBefore({ workspaceId: 'w1' as never, sessionId: sid('s1'), beforeSessionId: sid('s2') }) + expect(anchored.result.ok).toBe(true) + const appended = await c.workspace.insertSessionBefore({ workspaceId: 'w1' as never, sessionId: sid('s1') }) + expect(appended.result.ok).toBe(true) + }) + it('passes business errors through as 200 + err result, not a throw', async () => { const api = scriptedApi({ sessions: { diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index d0f2ddd128..ebb7931e6e 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { RpcId } from '../src/api/rpc.ts' +import { RpcId, transportError } from '../src/api/rpc.ts' import { clientRequestSchema, clientResponseSchema, rpcErrorSchema, rpcIdSchema, rpcMessageSchema, rpcReceiptSchema, rpcResultSchema, serverRequestSchema, serverResponseSchema, @@ -13,8 +13,10 @@ import { } from '../src/api/sessions.schema.ts' import { hostDescribeRequestSchema, hostDescribeValueSchema } from '../src/api/host.schema.ts' import { - workspaceCreateRequestSchema, workspaceCreateValueSchema, workspaceIdSchema, workspaceListRequestSchema, - workspaceListValueSchema, workspaceViewSchema, + workspaceCreateRequestSchema, workspaceCreateValueSchema, workspaceIdSchema, + workspaceInsertSessionBeforeRequestSchema, workspaceInsertSessionBeforeValueSchema, + workspaceListRequestSchema, workspaceListValueSchema, + workspaceRenameRequestSchema, workspaceRenameValueSchema, workspaceViewSchema, } from '../src/api/workspace.schema.ts' import { hostFrameSchema, muxFrameSchema, askUserQuestionItemSchema } from '../src/api/events.schema.ts' import { approvalRequestIdSchema, approvalResponsePayloadSchema } from '../src/api/approvals.schema.ts' @@ -30,6 +32,13 @@ describe('RpcId', () => { }) }) +describe('transportError', () => { + it('folds Error and non-Error throws into the internal error branch', () => { + expect(transportError(new Error('wire down'))).toEqual({ ok: false, error: { code: 'internal', message: 'wire down', details: {} } }) + expect(transportError('raw')).toMatchObject({ ok: false, error: { code: 'internal', message: 'raw' } }) + }) +}) + describe('rpcErrorSchema', () => { it('accepts every code branch with its required details', () => { expect(rpcErrorSchema.parse({ code: 'bad-request', message: 'm', details: { issues: [] } }).code).toBe('bad-request') @@ -40,6 +49,7 @@ describe('rpcErrorSchema', () => { expect(rpcErrorSchema.parse({ code: 'workspace-not-found', message: 'm', details: { workspaceId: 'w' } }).code).toBe('workspace-not-found') expect(rpcErrorSchema.parse({ code: 'workspace-invalid-path', message: 'm', details: { path: '/x' } }).code).toBe('workspace-invalid-path') expect(rpcErrorSchema.parse({ code: 'workspace-name-conflict', message: 'm', details: { name: 'x' } }).code).toBe('workspace-name-conflict') + expect(rpcErrorSchema.parse({ code: 'workspace-move-invalid', message: 'm', details: { workspaceId: 'w', sessionId: 's' } }).code).toBe('workspace-move-invalid') expect(rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: { reason: 'r' } }).code).toBe('agent-busy') expect(rpcErrorSchema.parse({ code: 'internal', message: 'm', details: {} }).code).toBe('internal') }) @@ -154,6 +164,19 @@ describe('workspace domain schemas', () => { expect(workspaceCreateValueSchema.parse({ workspace: view, created: false }).created).toBe(false) }) + it('rename requires a non-blank title (both refine arms)', () => { + expect(workspaceRenameRequestSchema.parse({ workspaceId: 'w1', title: 'new' }).title).toBe('new') + expect(() => workspaceRenameRequestSchema.parse({ workspaceId: 'w1', title: ' ' })).toThrow(/non-blank/) + expect(workspaceRenameValueSchema.parse({ workspace: view }).workspace.workspaceId).toBe('w1') + }) + + it('insertSessionBefore accepts an anchored and an anchorless move', () => { + expect(workspaceInsertSessionBeforeRequestSchema.parse({ workspaceId: 'w1', sessionId: 's1', beforeSessionId: 's2' }).beforeSessionId).toBe('s2') + expect(workspaceInsertSessionBeforeRequestSchema.parse({ workspaceId: 'w1', sessionId: 's1' }).beforeSessionId).toBeUndefined() + expect(() => workspaceInsertSessionBeforeRequestSchema.parse({ workspaceId: 'w1' })).toThrow() + expect(workspaceInsertSessionBeforeValueSchema.parse({ workspace: view }).workspace.workspaceId).toBe('w1') + }) + }) describe('events frame schemas', () => { diff --git a/packages/workspace/workspace/tests/workspace.spec.ts b/packages/workspace/workspace/tests/workspace.spec.ts index bdefc2ec82..8ce70cc7d5 100644 --- a/packages/workspace/workspace/tests/workspace.spec.ts +++ b/packages/workspace/workspace/tests/workspace.spec.ts @@ -10,7 +10,7 @@ import type { DomainChanged } from '@deepseek-ai/dsh-storage-domain' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { SessionHeader } from '@deepseek-ai/dsh-session' import { MemoryMediaPool, MemoryStorageBackend } from '../../../storage/storage-domain/tests/helpers/memory-backend.ts' -import WorkspaceRegistry, { WorkspaceId, WorkspaceNameConflictError } from '../src/index.ts' +import WorkspaceRegistry, { WorkspaceId, WorkspaceMoveInvalidError, WorkspaceNameConflictError } from '../src/index.ts' import type { WorkspaceDomainState, WorkspaceRecord } from '../src/index.ts' const DOMAIN_VERSION = 2 @@ -449,6 +449,56 @@ describe('Workspace session ordering', () => { expect(storedRecord(result.pool, workspace.id).sessionIds).toEqual(['s2', 's1']) }) + it('moves one id before an anchor or to the end, durably', async () => { + const dir = await makeDir('insert-before') + const result = await harness() + result.setSessions([header('s1', dir, 1), header('s2', dir, 2), header('s3', dir, 3)]) + const workspace = await result.registry.create(dir) + await workspace.attachSession(SessionId('s1')) + await workspace.attachSession(SessionId('s2')) + await workspace.attachSession(SessionId('s3')) + expect(workspace.sessionIds).toEqual(['s3', 's2', 's1']) + + await workspace.insertSessionBefore(SessionId('s1'), SessionId('s2')) + expect(workspace.sessionIds).toEqual(['s3', 's1', 's2']) + await workspace.insertSessionBefore(SessionId('s3')) + expect(workspace.sessionIds).toEqual(['s1', 's2', 's3']) + expect(storedRecord(result.pool, workspace.id).sessionIds).toEqual(['s1', 's2', 's3']) + }) + + it('treats self-anchored and already-in-place moves as no-ops without writing', async () => { + const dir = await makeDir('insert-noop') + const result = await harness() + result.setSessions([header('s1', dir, 1), header('s2', dir, 2)]) + const workspace = await result.registry.create(dir) + await workspace.attachSession(SessionId('s1')) + await workspace.attachSession(SessionId('s2')) + const written = result.changes.length + + await workspace.insertSessionBefore(SessionId('s1'), SessionId('s1')) + await workspace.insertSessionBefore(SessionId('s2'), SessionId('s1')) + await workspace.insertSessionBefore(SessionId('s1')) + await workspace.detachSession(SessionId('absent')) + expect(result.changes).toHaveLength(written) + expect(workspace.sessionIds).toEqual(['s2', 's1']) + }) + + it('rejects moves naming an unaccounted session or anchor', async () => { + const dir = await makeDir('insert-invalid') + const result = await harness() + result.setSessions([header('s1', dir, 1)]) + const workspace = await result.registry.create(dir) + await workspace.attachSession(SessionId('s1')) + const written = result.changes.length + + await expect(workspace.insertSessionBefore(SessionId('ghost'))) + .rejects.toBeInstanceOf(WorkspaceMoveInvalidError) + await expect(workspace.insertSessionBefore(SessionId('s1'), SessionId('ghost'))) + .rejects.toThrow(/anchor session is not accounted/) + expect(result.changes).toHaveLength(written) + expect(workspace.sessionIds).toEqual(['s1']) + }) + it('validates a lazy live session without requiring it in persistence.list()', async () => { const dir = await makeDir('live') const result = await harness({ sessions: [], liveSessions: [header('live', dir, 1)] }) From d147673dfdcdf393ccf6a62ed0549a291831b18d Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 26 Jul 2026 01:58:53 +0800 Subject: [PATCH 15/32] refactor(apiproxy): share the workspace-not-found response The rename/insertSessionBefore lookups tripped the cross-file clone gate; one helper owns the error row now. --- packages/host/apiproxy/src/api-proxy.ts | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index d50dd000e1..c7d3cb7ef9 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -299,6 +299,15 @@ class SessionCwdConflict extends Error { /** Host failed before the registry could adopt a name-created directory. */ class WorkspaceDirectoryCreationError extends Error {} +/** Shared workspace-not-found error response of the workspace.* mutation rows. */ +function workspaceNotFound(request: RpcRequest, workspaceId: string): RpcResponse { + return err(request, { + code: 'workspace-not-found', + message: `workspace "${workspaceId}" not found`, + details: { workspaceId }, + }) +} + /** Wire projection of one workspace entity (the workspace.* value row). */ function workspaceView(workspace: Workspace): WorkspaceView { return { @@ -684,13 +693,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro async rename(request) { const { payload } = request const workspace = ctx.workspace.get(brandWorkspaceId(payload.workspaceId)) - if (workspace === undefined) { - return err(request, { - code: 'workspace-not-found', - message: `workspace "${payload.workspaceId}" not found`, - details: { workspaceId: payload.workspaceId }, - }) - } + if (workspace === undefined) return workspaceNotFound(request, payload.workspaceId) const title = payload.title.trim() // Uniqueness AND the same-title no-op both ride the create chain so // they observe the state left by earlier queued renames — checked @@ -722,13 +725,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro async insertSessionBefore(request) { const { payload } = request const workspace = ctx.workspace.get(brandWorkspaceId(payload.workspaceId)) - if (workspace === undefined) { - return err(request, { - code: 'workspace-not-found', - message: `workspace "${payload.workspaceId}" not found`, - details: { workspaceId: payload.workspaceId }, - }) - } + if (workspace === undefined) return workspaceNotFound(request, payload.workspaceId) try { await workspace.insertSessionBefore(payload.sessionId, payload.beforeSessionId) } catch (error: unknown) { From 4ae86001a5cdd6f6a03a34909a49f417151d9836 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 26 Jul 2026 02:03:05 +0800 Subject: [PATCH 16/32] style(web): wrap a long tree.ts doc line --- packages/client/ui-workspace/src/client/tree.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/client/ui-workspace/src/client/tree.ts b/packages/client/ui-workspace/src/client/tree.ts index 39a479f3b3..7de4d14e8d 100644 --- a/packages/client/ui-workspace/src/client/tree.ts +++ b/packages/client/ui-workspace/src/client/tree.ts @@ -227,7 +227,8 @@ function buildSearch(g: Group, visible: ReadonlySet): SessionNode[] { * * Normal mode: every group shows; sessions populate under expanded groups, * descending only into expanded sessions. A frontend Session Intent targeting - * a real Workspace marks that group `intentHere` (rendered only while the group is expanded; expansion stays viewer-owned). Search mode (non-blank query, + * a real Workspace marks that group `intentHere` (rendered only while the + * group is expanded; expansion stays viewer-owned). Search mode (non-blank query, * case-insensitive display-title substring): expansion state is ignored — * matched sessions and their ancestor chains are forced visible, groups * without a display-title or label hit are dropped, a label-only hit keeps From ad419065a0a71a49f801028d66cd0afbdd56df36 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 26 Jul 2026 02:10:59 +0800 Subject: [PATCH 17/32] test(web): stop pre-clicking the fixture group in the title snapshot The Intent's current-group effect already expands the target workspace; with intent no longer forcing expansion, the header click collapsed it. --- apps/web/tests/session-title.snapshot.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/web/tests/session-title.snapshot.ts b/apps/web/tests/session-title.snapshot.ts index c1616bb724..532c6866da 100644 --- a/apps/web/tests/session-title.snapshot.ts +++ b/apps/web/tests/session-title.snapshot.ts @@ -94,10 +94,10 @@ it('projects initial and revised durable titles through the built nine-plugin fi }) const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 }) - const projectCount = await within(tree).findByText('4 sessions') - const projectRow = projectCount.closest('[role="treeitem"]') - if (projectRow === null) throw new Error('fixture project row missing') - fireEvent.click(projectRow) + // The fixture Intent selects the workspace, so the current-group effect + // already expanded it; clicking the header would now collapse (the twist + // stays live since intent stopped forcing expansion). + await within(tree).findByText('4 sessions') const initialLabel = 'Fixture 历史会话' const initialRowLabel = await screen.findByText(initialLabel) From b2d8815a34b584a8704d2285e33ea45b279fc337 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 26 Jul 2026 02:29:13 +0800 Subject: [PATCH 18/32] test(llm-mock-server): poll for the client-closed outcome The fixed 5ms sleep raced slow CI runners (the server observes the socket close asynchronously); vi.waitFor polls until the outcome lands. --- packages/support/llm-mock-server/tests/server.spec.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/support/llm-mock-server/tests/server.spec.ts b/packages/support/llm-mock-server/tests/server.spec.ts index 1f15ba1a0b..34db57aa82 100644 --- a/packages/support/llm-mock-server/tests/server.spec.ts +++ b/packages/support/llm-mock-server/tests/server.spec.ts @@ -1,5 +1,5 @@ import { request } from 'node:http' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { MockLlmBehavior, MockLlmServer, MockLlmServerEvent } from '../src/index.ts' import { startMockLlmServer } from '../src/index.ts' @@ -179,9 +179,11 @@ describe('mock LLM server wire behaviors', () => { const response = await chat(server, { signal: controller.signal }) controller.abort() await expect(response.text()).rejects.toThrow() - await new Promise((resolve) => { setTimeout(resolve, 5) }) - - expect(server.requests[0]).toMatchObject({ behavior, outcome: 'client_closed' }) + // The server observes the socket close asynchronously; a fixed sleep + // raced slow runners, so poll until the outcome lands. + await vi.waitFor(() => { + expect(server.requests[0]).toMatchObject({ behavior, outcome: 'client_closed' }) + }) expect(events.filter(event => event.type === 'result')).toEqual([ expect.objectContaining({ behavior, outcome: 'client_closed' }), ]) From 5fad2fc93445ae2875048b7009ab2deef6161f9d Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 02:45:56 +0800 Subject: [PATCH 19/32] docs: define extensible PR labels --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index eae3007c86..e68ef8086a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -111,7 +111,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, - **A tool's UI render intent is part of its design**, decided up front (`generic`/`terminal`/`diff`, `locations`); presentation methods are pure functions of `args` ([cookbook](docs/cookbook/adding-a-tool.md)). - **Plan unit, e2e, and snapshot coverage** for new seams, lifecycle shapes, and transcript surfaces; missing snapshot-harness support is part of the implementation, not deferred follow-up. - **Keep PRs coherent and merge with merge commits.** Split an independently meaningful feature or design decision into a separate or stacked PR when combining it obscures ownership, intent, or verification. Never squash/rebase or rewrite pushed branches; put a review fix on its introducing PR, then merge down the stack ([guide](docs/cookbook/responding-to-pr-review-on-a-stack.md)). -- Pick matching existing GitHub labels such as `documentation`, `web`, `tui`, or `core`; never create new labels. +- **Label PRs:** exactly one kind (`feature`/`bug-fix`/`doc`/`testing`/`cleanup`), all matching areas; the taxonomy remains extensible for recurring distinctions. - TODO markers: `FIXME`/`TODO`/`XXX` by urgency ([semantics](docs/development.md)). - Files end with exactly one trailing newline; `git diff --cached --check` (pre-commit) gates it. From 23a60ade67215b2ad6da00e24921fba8dafda4b9 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 26 Jul 2026 02:51:36 +0800 Subject: [PATCH 20/32] refactor(gui): features register their own settings surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings collaboration direction (recorded in the note): the shell only provides composition faces — feature plugins register themselves. The General section moves into the ui-settings shell (order 0, skeleton rows) and declares the settings.general.item list slot; locale registers the Language row and ui-theme the Appearance row (each with its own store mirror, dictionaries, and ledger-judged deferral); the ui-settings-general package is gone. ui-settings-models becomes ui-models — a feature package that contributes its Settings section rather than a settings-owned satellite. The item-slot SlotMap entry is authored in the ui-settings contract and repeated verbatim in locale/ui-theme (reference-cycle avoidance; declaration merging keeps the copies identical). --- ...2026-07-25-client-settings-locale-theme.md | 42 ++++-- ...6-07-25-client-settings-locale-theme.zh.md | 44 +++--- apps/cli/cordis.yml | 7 +- apps/cli/package.json | 3 +- apps/cli/tsconfig.json | 5 +- apps/web/tests/session-title.snapshot.ts | 3 +- apps/web/tests/workspace-flow.snapshot.ts | 3 +- packages/client/locale/package.json | 22 ++- .../locale/src/client/LanguageRow.module.css | 47 ++++++ .../client/locale/src/client/LanguageRow.tsx | 68 +++++++++ packages/client/locale/src/client/index.ts | 72 ++++++++- .../locale/src/client/settings-contract.ts | 18 +++ .../locale/src/client/settings-store.ts | 47 ++++++ .../src/css-modules.d.ts | 0 .../client/locale/tests/invariant.spec.ts | 6 +- packages/client/locale/tsconfig.json | 9 ++ .../README.md | 2 +- .../package.json | 4 +- .../src/client/ModelsSection.tsx | 0 .../src/client/index.ts | 4 +- .../src/css-modules.d.ts | 0 .../src/index.ts | 0 .../src/invariant.ts | 8 +- .../tests/apply.spec.ts | 4 +- .../tests/invariant.spec.ts | 4 +- .../tsconfig.json | 0 packages/client/ui-models/tsdown.config.ts | 3 + packages/client/ui-settings-general/README.md | 15 -- .../client/ui-settings-general/package.json | 70 --------- .../src/client/GeneralSection.tsx | 118 --------------- .../src/client/contract.ts | 66 -------- .../ui-settings-general/src/client/index.ts | 117 --------------- .../ui-settings-general/src/client/store.ts | 43 ------ .../client/ui-settings-general/src/index.ts | 4 - .../ui-settings-general/src/invariant.ts | 32 ---- .../ui-settings-general/tests/apply.spec.ts | 139 ----------------- .../tests/general-section.spec.tsx | 112 -------------- .../tests/invariant.spec.ts | 18 --- .../ui-settings-general/tests/store.spec.ts | 56 ------- .../client/ui-settings-general/tsconfig.json | 36 ----- .../ui-settings-general/tsdown.config.ts | 3 - .../ui-settings-models/tsdown.config.ts | 3 - .../src/client/GeneralSection.module.css | 38 ++--- .../ui-settings/src/client/GeneralSection.tsx | 51 +++++++ .../ui-settings/src/client/contract/slots.ts | 38 ++++- .../client/ui-settings/src/client/index.ts | 59 +++++++- .../src/client/locales.ts | 26 ++-- .../client/ui-settings/tests/apply.spec.ts | 81 +++++++++- .../tests/general-section.spec.tsx | 47 ++++++ packages/client/ui-theme/package.json | 25 +++- .../src/client/AppearanceRow.module.css | 51 +++++++ .../ui-theme/src/client/AppearanceRow.tsx | 63 ++++++++ packages/client/ui-theme/src/client/index.ts | 89 ++++++++++- .../ui-theme/src/client/settings-contract.ts | 17 +++ .../ui-theme/src/client/settings-store.ts | 37 +++++ packages/client/ui-theme/src/css-modules.d.ts | 6 + .../client/ui-theme/tests/invariant.spec.ts | 10 +- packages/client/ui-theme/tsconfig.json | 12 ++ pnpm-lock.yaml | 141 +++++++++--------- .../verify-package-readme-model-experience.ts | 3 +- tsconfig.base.json | 3 +- tsconfig.client.json | 3 +- 62 files changed, 1008 insertions(+), 1049 deletions(-) create mode 100644 packages/client/locale/src/client/LanguageRow.module.css create mode 100644 packages/client/locale/src/client/LanguageRow.tsx create mode 100644 packages/client/locale/src/client/settings-contract.ts create mode 100644 packages/client/locale/src/client/settings-store.ts rename packages/client/{ui-settings-general => locale}/src/css-modules.d.ts (100%) rename packages/client/{ui-settings-models => ui-models}/README.md (92%) rename packages/client/{ui-settings-models => ui-models}/package.json (89%) rename packages/client/{ui-settings-models => ui-models}/src/client/ModelsSection.tsx (100%) rename packages/client/{ui-settings-models => ui-models}/src/client/index.ts (96%) rename packages/client/{ui-settings-models => ui-models}/src/css-modules.d.ts (100%) rename packages/client/{ui-settings-models => ui-models}/src/index.ts (100%) rename packages/client/{ui-settings-models => ui-models}/src/invariant.ts (81%) rename packages/client/{ui-settings-models => ui-models}/tests/apply.spec.ts (96%) rename packages/client/{ui-settings-models => ui-models}/tests/invariant.spec.ts (82%) rename packages/client/{ui-settings-models => ui-models}/tsconfig.json (100%) create mode 100644 packages/client/ui-models/tsdown.config.ts delete mode 100644 packages/client/ui-settings-general/README.md delete mode 100644 packages/client/ui-settings-general/package.json delete mode 100644 packages/client/ui-settings-general/src/client/GeneralSection.tsx delete mode 100644 packages/client/ui-settings-general/src/client/contract.ts delete mode 100644 packages/client/ui-settings-general/src/client/index.ts delete mode 100644 packages/client/ui-settings-general/src/client/store.ts delete mode 100644 packages/client/ui-settings-general/src/index.ts delete mode 100644 packages/client/ui-settings-general/src/invariant.ts delete mode 100644 packages/client/ui-settings-general/tests/apply.spec.ts delete mode 100644 packages/client/ui-settings-general/tests/general-section.spec.tsx delete mode 100644 packages/client/ui-settings-general/tests/invariant.spec.ts delete mode 100644 packages/client/ui-settings-general/tests/store.spec.ts delete mode 100644 packages/client/ui-settings-general/tsconfig.json delete mode 100644 packages/client/ui-settings-general/tsdown.config.ts delete mode 100644 packages/client/ui-settings-models/tsdown.config.ts rename packages/client/{ui-settings-general => ui-settings}/src/client/GeneralSection.module.css (75%) create mode 100644 packages/client/ui-settings/src/client/GeneralSection.tsx rename packages/client/{ui-settings-general => ui-settings}/src/client/locales.ts (64%) create mode 100644 packages/client/ui-settings/tests/general-section.spec.tsx create mode 100644 packages/client/ui-theme/src/client/AppearanceRow.module.css create mode 100644 packages/client/ui-theme/src/client/AppearanceRow.tsx create mode 100644 packages/client/ui-theme/src/client/settings-contract.ts create mode 100644 packages/client/ui-theme/src/client/settings-store.ts create mode 100644 packages/client/ui-theme/src/css-modules.d.ts diff --git a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.md b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.md index e1245a9e1f..0dfcb4ea90 100644 --- a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.md +++ b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.md @@ -10,41 +10,48 @@ The browser client's existing Settings is written directly inside the Sidebar, a ## Proposal -The Sidebar declares the `sidebar.settings` single slot; `ui-settings` occupies it and declares the `settings.section` list slot. Each section is contributed by an independent plugin; the Settings shell only reads entry metadata from the slot ledger to build the navigation, rendering the current section via `only`. +**Collaboration doctrine (how every later module joins Settings): feature owners self-register.** The Settings shell provides only the composition surface (the top-level section list plus the item list inside General) and neither imports nor enumerates any feature; for a feature to appear in Settings, its own plugin registers into the corresponding slot — locale registers the Language row, ui-theme registers the Appearance row, ui-models registers the Models top-level panel. No separate `ui-settings-*` package is created for "a feature's settings page": the settings surface belongs to the feature package itself (shipping the Theme feature means Theme's settings choices ship with ui-theme). The only content the shell carries itself is the first top-level directory, General (skeleton rows plus the item slot declaration), because it belongs to no single feature. + +The Sidebar declares the `sidebar.settings` single slot; `ui-settings` occupies it and declares the `settings.section` list slot. Each section is contributed by a feature plugin; the Settings shell only reads entry metadata from the slot ledger to build the navigation, rendering the current section via `only`. General is registered by the shell itself (order 0) and declares the `settings.general.item` list slot, into which the feature plugins' preference rows slot by order. The Settings entry is the Settings row in the sidebar Foot; clicking it directly opens a 1080×700 centered overlay (black 24% mask); the close button, a mask click, and ESC all close it. There is no intermediate menu form of any kind. `@deepseek-ai/dsh-client-locale` provides `ctx.locale`; `ui-theme` provides `ctx.theme`. Both services read through a getter, write through a setter, and publish immutable snapshots via typed Cordis change events; each service persists its own preference (storing only the id, with bad values falling back to the default). -General's apply layer subscribes to `locale/change` and `theme/change` and projects the snapshots into the Zustand store declared by that section. React components only read `useStore` and write through the injected setter callbacks, never reading ctx or the services. +Each feature row's apply layer subscribes to its own change event (locale to `locale/change`, ui-theme to `theme/change`) and projects the snapshot into the slot store declared when that row registered. React components only read `useStore` and write through the injected setter callbacks, never reading ctx or the services. The theme preference has three states — `light`, `dark`, `system` — defaulting to `system` (when no persisted preference exists or the value is bad). Resolving system belongs to the theme domain: ThemeService holds the `prefers-color-scheme` matchMedia listener (environment sensing, not DOM presentation) and re-emits the snapshot when the preference is system and the system color scheme changes; the snapshot carries both `preference` and the resolved `active` definition. The theme service never touches the DOM. `ui-layout` reads the Theme getter initially and then subscribes to `theme/change`; the presenter owned by Layout updates `body[data-ds-dark-theme]` and the theme tokens according to `active`. The presenter has no notion of system — it consumes only resolved results. -### First-phase section scope +### First-phase registration surfaces -| section | Plugin | First-phase content | +| Registration surface | Owning plugin | First-phase content | |---|---|---| -| General | `ui-settings-general` | Language (Selector dropdown) and Appearance (Light/Dark/System three cubes) genuinely switch; Permission and Tool Call are visual skeletons only, with no write operations | -| Models | `ui-settings-models` | Navigation item only; the content area is empty | -| Plugin | no package | Not built this phase, and the navigation does not show the item (an external-link entry with no target never renders; once a later plugin registers the section it appears automatically) | +| General section (order 0) | built into the `ui-settings` shell | Permission and Tool Call visual skeletons (no write operations) plus the `settings.general.item` slot declaration | +| Language row (item order 0) | `locale` | Selector dropdown; 中文/English genuinely switch | +| Appearance row (item order 10) | `ui-theme` | Light/Dark/System three cubes genuinely switch (the selected state reflects preference) | +| Models section (order 10) | `ui-models` | Navigation item only, with an empty content area; later model-management features land in that package | +| Plugin | none | Not built this phase, and the navigation does not show the item (once a later plugin feature package registers the section it appears automatically) | -The first phase localizes only the copy inside the Settings overlay (the General rows plus the navigation); copy on other pages is untouched. +The first phase localizes only the copy inside the Settings overlay; dictionaries stay close to their owners — shell copy (the chrome plus the General skeletons) lives in the `settings` namespace, and feature-row copy lives in each feature package (`settings.locale`, `settings.theme`, `settings.models`). ### Slot topology ```text root └─ sidebar - └─ sidebar.settings single/root - └─ ui-settings - └─ settings.section list/root - ├─ general ui-settings-general - └─ models ui-settings-models + └─ sidebar.settings single/root + └─ ui-settings(壳) + └─ settings.section list/root + ├─ general (order 0) ui-settings 壳自带 + │ └─ settings.general.item list/root + │ ├─ language (0) locale 注册 + │ └─ appearance (10) ui-theme 注册 + └─ models (order 10) ui-models 注册 ``` -Section contributions use declaration-aware deferral and do not depend on the client manifest's apply order. +Section and item contributions both use declaration-aware deferral and do not depend on the client manifest's apply order. The `settings.general.item` SlotMap entry's canonical home is the ui-settings contract; locale/ui-theme, because of the reference cycle (the shell consumes ctx.locale), consume that entry as verbatim duplicated merges, with declaration merging guaranteeing the copies agree. ### Service contracts @@ -95,13 +102,16 @@ Locale ships with 中文 and English built in; `setLocale`/`setTheme` are the on **Settings importing and enumerating the sections.** Adding a page would require modifying the shell plugin, breaking the composition model where each feature occupies a slot from its own plugin. +**One `ui-settings-*` package per section (the first-cut implementation).** It divorces the settings surface from the feature itself: changing Theme behavior touches two packages, the package count grows linearly with settings items, and settings-general depending back on the locale/theme services forms an intermediate layer that exists purely for the package split. After converging on feature-owner self-registration, General belongs to the shell (it belongs to no single feature) and preference rows ship with their feature packages. + **Injecting the Locale/Theme snapshots into React directly.** Inject results are cached by entry identity, so volatile values go stale; hand-rolling a React hook per service also bypasses the slot store's unified binding. ## Acceptance criteria -- The Settings shell depends only on the slot ledger, never on any section implementation. +- The Settings shell depends only on the slot ledger, never on any feature implementation; General's item list likewise depends only on the ledger. +- Adding a settings item = the feature package registering it itself (a section or a general item), with zero shell changes. - Locale and Theme writes go only through the setters; ongoing synchronization goes only through the change events. -- The General store initializes from the getters and is thereafter updated by the two events with local re-renders. +- Each feature row's store initializes from the getter and is thereafter updated by its own change event with local re-renders. - Layout applies the theme snapshot on its own and the theme service never accesses the DOM; no system branch appears in the presenter. - 中文/English and Light/Dark/System switch and are restored after a refresh; with the preference on system, a system color-scheme change takes effect immediately. - Models has only a navigation item and an empty content area; the Permission and Tool Call skeletons perform no writes. diff --git a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.zh.md b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.zh.md index 195b2e5ffa..dfe93a0ca1 100644 --- a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.zh.md +++ b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.zh.md @@ -10,41 +10,48 @@ Status: proposed ## Proposal -Sidebar 声明 `sidebar.settings` 单坑位,`ui-settings` 占用它并声明 `settings.section` list 坑位。每个 section 由独立插件贡献;Settings 壳只从 slot ledger 读取 entry metadata 生成导航,通过 `only` 渲染当前 section。 +**协作导向(后续所有模块接入 Settings 的方式):功能属主自注册。** Settings 壳只提供组合面(一级 section 列表 + General 内的 item 列表),不 import 也不枚举任何功能;一个功能要出现在 Settings 里,由它自己的插件向对应坑位注册——locale 注册 Language 行,ui-theme 注册 Appearance 行,ui-models 注册 Models 一级面板。不为「某功能的设置页」单开 `ui-settings-*` 包:设置面属于功能包本身(做 Theme 功能,Theme 的设置选择就随 ui-theme 一起交付)。壳自带的唯一内容是第一个一级目录 General(骨架行 + item 坑位声明),因为它不属于任何单一功能。 + +Sidebar 声明 `sidebar.settings` 单坑位,`ui-settings` 占用它并声明 `settings.section` list 坑位。每个 section 由功能插件贡献;Settings 壳只从 slot ledger 读取 entry metadata 生成导航,通过 `only` 渲染当前 section。General 由壳自己注册(order 0)并声明 `settings.general.item` list 坑位,功能插件的偏好行按 order 排入。 Settings 入口是 sidebar Foot 的 Settings 行,点击直接打开 1080×700 居中浮层(黑 24% 遮罩);close 按钮、点击遮罩、ESC 均关闭。无任何中间菜单形态。 `@deepseek-ai/dsh-client-locale` 提供 `ctx.locale`,`ui-theme` 提供 `ctx.theme`。两个 service 都以 getter 读取、setter 写入并用 typed Cordis change event 发布 immutable snapshot;service 自己持久化偏好(只存 id,坏值回退默认)。 -General 的 apply 层订阅 `locale/change` 和 `theme/change`,把 snapshot 投影到该 section 声明的 Zustand store。React 组件只读 `useStore`、写注入的 setter callback,不读取 ctx 或 service。 +功能行的 apply 层各自订阅自家 change event(locale 订 `locale/change`,ui-theme 订 `theme/change`),把 snapshot 投影到该行注册时声明的 slot store。React 组件只读 `useStore`、写注入的 setter callback,不读取 ctx 或 service。 Theme 偏好三态:`light`、`dark`、`system`,默认 `system`(无持久化偏好或坏值时)。system 的解析属主题领域:ThemeService 持有 `prefers-color-scheme` matchMedia 监听(环境感知,非 DOM 呈现),偏好为 system 且系统配色变化时重发 snapshot;snapshot 同时携带 `preference` 与解析后的 `active` 定义。 Theme service 不操作 DOM。`ui-layout` 初始读取 Theme getter,随后订阅 `theme/change`,由 Layout 持有的 presenter 按 `active` 更新 `body[data-ds-dark-theme]` 和主题 token;presenter 不感知 system,只消费已解析结果。 -### 首期 section 范围 +### 首期注册面 -| section | 插件 | 首期内容 | +| 注册面 | 属主插件 | 首期内容 | |---|---|---| -| General | `ui-settings-general` | Language(Selector 下拉)与 Appearance(Light/Dark/System 三 cube)真实可切;Permission、Tool Call 仅视觉骨架,无写操作 | -| Models | `ui-settings-models` | 仅导航项,内容区为空 | -| Plugin | 不建包 | 首期不做,导航不出现该项(无目标的外链入口不上屏;后续插件注册 section 即自动出现) | +| General section(order 0)| `ui-settings` 壳自带 | Permission、Tool Call 视觉骨架(无写操作)+ `settings.general.item` 坑位声明 | +| Language 行(item order 0)| `locale` | Selector 下拉,中文/English 真实可切 | +| Appearance 行(item order 10)| `ui-theme` | Light/Dark/System 三 cube 真实可切(选中态看 preference) | +| Models section(order 10)| `ui-models` | 仅导航项,内容区为空;后续模型管理功能落在该包 | +| Plugin | 无 | 首期不做,导航不出现该项(后续插件功能包注册 section 即自动出现) | -首期只翻译 Settings 浮层内文案(General 各行 + 导航);其他页面文案不动。 +首期只翻译 Settings 浮层内文案;字典就近——壳文案(chrome + General 骨架)归 `settings` namespace,功能行文案归各功能包(`settings.locale`、`settings.theme`、`settings.models`)。 ### Slot topology ```text root └─ sidebar - └─ sidebar.settings single/root - └─ ui-settings - └─ settings.section list/root - ├─ general ui-settings-general - └─ models ui-settings-models + └─ sidebar.settings single/root + └─ ui-settings(壳) + └─ settings.section list/root + ├─ general (order 0) ui-settings 壳自带 + │ └─ settings.general.item list/root + │ ├─ language (0) locale 注册 + │ └─ appearance (10) ui-theme 注册 + └─ models (order 10) ui-models 注册 ``` -section contribution 使用 declaration-aware deferral,不依赖 client manifest 的 apply 顺序。 +section/item contribution 均使用 declaration-aware deferral,不依赖 client manifest 的 apply 顺序。`settings.general.item` 的 SlotMap 条目正家在 ui-settings contract;locale/ui-theme 因引用环(壳消费 ctx.locale)以逐字重复合并的方式消费该条目,declaration merging 保证副本一致。 ### Service contracts @@ -95,13 +102,16 @@ Locale 内置中文和 English;`setLocale`/`setTheme` 是唯一写入口,未 **Settings import 并枚举各 section。** 新增页面必须修改壳插件,破坏「每个功能由自己的插件占坑」的组合模型。 +**每个 section 单开 `ui-settings-*` 包(首版实现)。** 设置面与功能本体分家:改 Theme 行为要动两个包,包数随设置项线性膨胀,且 settings-general 反向依赖 locale/theme 服务形成纯粹为拆包而生的中间层。收敛为功能属主自注册后,General 归壳(不属任何单一功能),preference 行随功能包交付。 + **把 Locale/Theme snapshot 直接注入 React。** inject 结果按 entry identity 缓存,易变值会陈旧;为每个 service 自造 React hook 也绕开 slot store 的统一绑定。 ## Acceptance criteria -- Settings 壳只依赖 slot ledger,不依赖任一 section 实现。 +- Settings 壳只依赖 slot ledger,不依赖任一功能实现;General 的 item 列表同样只依赖 ledger。 +- 新增一个设置项 = 功能包自己注册(section 或 general item),零壳改动。 - Locale 与 Theme 的写入只走 setter,持续同步只走 change event。 -- General store 初始化走 getter,后续由两个 event 更新并局部重渲染。 +- 功能行 store 初始化走 getter,后续由自家 change event 更新并局部重渲染。 - Layout 独立应用 Theme snapshot,Theme service 不访问 DOM;presenter 不出现 system 分支。 - 中文/English 与 Light/Dark/System 能切换并刷新后恢复;偏好为 system 时系统配色变化即时生效。 - Models 只有导航项与空内容区;Permission、Tool Call 骨架无写操作。 @@ -109,4 +119,4 @@ Locale 内置中文和 English;`setLocale`/`setTheme` 是唯一写入口,未 ## Risks -slot 声明与 contribution 的 apply 顺序不固定,所有新 section 必须保留 declaration-aware registration 和幂等防护。service event 可能早于 section 首次渲染,General store 的 init 与 controller attach 都必须从 getter 对齐当前 snapshot。Layout 卸载时必须清理自己设置的全局属性,ThemeService dispose 时必须移除 matchMedia 监听,避免 HMR 后残留。 +slot 声明与 contribution 的 apply 顺序不固定,所有 section/item 注册方必须保留 declaration-aware registration,并以 ledger(而非本地 disposer)判定在位。service event 可能早于行首次渲染,功能行 store 的 init 与 inject attach 都必须从 getter 对齐当前 snapshot。`settings.general.item` 的重复合并副本(locale、ui-theme)与 ui-settings 正家必须逐字一致,漂移即三处一起改。Layout 卸载时必须清理自己设置的全局属性,ThemeService dispose 时必须移除 matchMedia 监听,避免 HMR 后残留。 diff --git a/apps/cli/cordis.yml b/apps/cli/cordis.yml index 8412a313b4..39803aa603 100644 --- a/apps/cli/cordis.yml +++ b/apps/cli/cordis.yml @@ -233,11 +233,8 @@ - id: ui-settings name: '@deepseek-ai/dsh-client-ui-settings' -- id: ui-settings-general - name: '@deepseek-ai/dsh-client-ui-settings-general' - -- id: ui-settings-models - name: '@deepseek-ai/dsh-client-ui-settings-models' +- id: ui-models + name: '@deepseek-ai/dsh-client-ui-models' - id: ui-conversation name: '@deepseek-ai/dsh-client-ui-conversation' diff --git a/apps/cli/package.json b/apps/cli/package.json index 74a6e417e1..ddf8a08a87 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -31,8 +31,7 @@ "@deepseek-ai/dsh-client-ui-question": "workspace:^", "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", - "@deepseek-ai/dsh-client-ui-settings-general": "workspace:^", - "@deepseek-ai/dsh-client-ui-settings-models": "workspace:^", + "@deepseek-ai/dsh-client-ui-models": "workspace:^", "@deepseek-ai/dsh-client-ui-theme": "workspace:^", "@deepseek-ai/dsh-client-ui-trajectory": "workspace:^", "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index 7614ae2e31..3000fd1f8d 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -45,10 +45,7 @@ "path": "../../packages/client/ui-settings" }, { - "path": "../../packages/client/ui-settings-general" - }, - { - "path": "../../packages/client/ui-settings-models" + "path": "../../packages/client/ui-models" }, { "path": "../../packages/client/locale" diff --git a/apps/web/tests/session-title.snapshot.ts b/apps/web/tests/session-title.snapshot.ts index b8ee3408a3..35356fa0b3 100644 --- a/apps/web/tests/session-title.snapshot.ts +++ b/apps/web/tests/session-title.snapshot.ts @@ -14,8 +14,7 @@ const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [ { id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] }, { id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, { id: '@deepseek-ai/dsh-client-ui-settings', dir: 'ui-settings', url: '/plugins/ui-settings.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-sidebar', '@deepseek-ai/dsh-client-locale'] }, - { id: '@deepseek-ai/dsh-client-ui-settings-general', dir: 'ui-settings-general', url: '/plugins/ui-settings-general.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-settings'] }, - { id: '@deepseek-ai/dsh-client-ui-settings-models', dir: 'ui-settings-models', url: '/plugins/ui-settings-models.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-settings'] }, + { id: '@deepseek-ai/dsh-client-ui-models', dir: 'ui-models', url: '/plugins/ui-models.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-settings'] }, { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, { id: '@deepseek-ai/dsh-client-ui-workspace', dir: 'ui-workspace', url: '/plugins/ui-workspace.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-conversation', '@deepseek-ai/dsh-client-ui-sidebar'] }, { id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] }, diff --git a/apps/web/tests/workspace-flow.snapshot.ts b/apps/web/tests/workspace-flow.snapshot.ts index e232d8e123..738357a551 100644 --- a/apps/web/tests/workspace-flow.snapshot.ts +++ b/apps/web/tests/workspace-flow.snapshot.ts @@ -14,8 +14,7 @@ const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [ { id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] }, { id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, { id: '@deepseek-ai/dsh-client-ui-settings', dir: 'ui-settings', url: '/plugins/ui-settings.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-sidebar', '@deepseek-ai/dsh-client-locale'] }, - { id: '@deepseek-ai/dsh-client-ui-settings-general', dir: 'ui-settings-general', url: '/plugins/ui-settings-general.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-settings'] }, - { id: '@deepseek-ai/dsh-client-ui-settings-models', dir: 'ui-settings-models', url: '/plugins/ui-settings-models.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-settings'] }, + { id: '@deepseek-ai/dsh-client-ui-models', dir: 'ui-models', url: '/plugins/ui-models.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-settings'] }, { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, { id: '@deepseek-ai/dsh-client-ui-workspace', diff --git a/packages/client/locale/package.json b/packages/client/locale/package.json index 340efe042e..d53d2e4269 100644 --- a/packages/client/locale/package.json +++ b/packages/client/locale/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-client-locale", - "description": "Locale plugin: LocaleService (zh/en preference with getter/setter/change event + persistence; ns x locale dictionaries, bind(ns) -> t)", + "description": "Locale plugin: LocaleService (zh/en preference with getter/setter/change event + persistence; ns x locale dictionaries, bind(ns) -> t); registers the Language settings row", "version": "0.0.1", "private": true, "type": "module", @@ -23,18 +23,29 @@ "./package.json": "./package.json" }, "dshClient": { - "inject": [], + "inject": [ + "@deepseek-ai/dsh-client-runtime" + ], "platform": "web", "immediately": true }, "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-client-runtime": "^0.0.1", + "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", + "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "cordis": "^4.0.0-rc.7", + "react": "^18.2.0" }, "devDependencies": { + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@types/react": "~18.3.1", + "cordis": "^4.0.0-rc.7", + "react": "^18.2.0" }, "files": [ "lib/index.js", @@ -47,5 +58,8 @@ "scripts": { "bundle": "tsdown", "watch": "tsdown --watch" + }, + "dependencies": { + "clsx": "^2.0.0" } } diff --git a/packages/client/locale/src/client/LanguageRow.module.css b/packages/client/locale/src/client/LanguageRow.module.css new file mode 100644 index 0000000000..f17a67d279 --- /dev/null +++ b/packages/client/locale/src/client/LanguageRow.module.css @@ -0,0 +1,47 @@ +/* Language row (figma 'Setting-Cell': gap 8, pad 16/0, hairline separator; + * the section column removes the separator on its last child). */ + +.row { + display: flex; + align-items: center; + gap: 8px; + padding: 16px 0; + border-bottom: 1px solid var(--dsw-alias-border-l2); +} + +.rowText { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; + padding-right: 48px; +} + +.title { + font-size: 14px; + font-weight: 400; + line-height: 22px; + color: var(--dsw-alias-label-primary); +} + +/* Selector pill (figma 'Selector': h36 r18, fill #F5F6F7, pad 0/14, gap 12). */ +.selector { + display: inline-flex; + align-items: center; + gap: 12px; + height: 36px; + padding: 0 14px; + border: none; + border-radius: 18px; + background: var(--dsw-alias-bg-module-platform); + font: inherit; + font-size: 14px; + line-height: 22px; + color: var(--dsw-alias-label-primary); + cursor: pointer; +} + +.chevron { + flex: none; +} diff --git a/packages/client/locale/src/client/LanguageRow.tsx b/packages/client/locale/src/client/LanguageRow.tsx new file mode 100644 index 0000000000..a824bc6752 --- /dev/null +++ b/packages/client/locale/src/client/LanguageRow.tsx @@ -0,0 +1,68 @@ +/** + * Language preference row registered into the General section item slot + * (figma 501:30011 'Setting-Cell'): title + selector pill opening the locale + * menu. Registered by this package — the locale feature owns its own + * settings surface. + */ +import { useState } from 'react' +import type { PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' +import { IconChevronDownOutline14, Menu } from '@deepseek-ai/dsh-client-ui-primitives' +import type {} from './settings-contract.ts' +import type { createLanguageRowStore } from './settings-store.ts' +import css from './LanguageRow.module.css' + +/** Injected business face: namespace-bound translate + the preference write. */ +export interface LanguageRowInjected { + /** Translate a `settings.locale` dictionary key to the active-locale text. */ + t: (key: string) => string + /** Switch the active locale (a registered locale id). */ + setLocale: (id: string) => void +} + +/** Full component props: runtime share + store share + injected face. */ +export type LanguageRowComponentProps = + PropsRuntime<'settings.general.item'> & PropsStore> & LanguageRowInjected + +/** + * Render the Language row. + * @param props - composed slot props. + * @returns the row element tree. + */ +export function LanguageRow({ t, setLocale, useStore }: LanguageRowComponentProps) { + const active = useStore(s => s.active) + const options = useStore(s => s.options) + const [open, setOpen] = useState(false) + const activeLabel = options.find(o => o.id === active)?.label ?? active + + return ( +
+
+
{t('language.title')}
+
+ { setOpen(false) }} + items={options.map(o => ({ id: o.id, label: o.label }))} + selectedId={active} + onSelect={(id) => { + setLocale(id) + setOpen(false) + }} + align="end" + portal + anchor={( + + )} + /> +
+ ) +} diff --git a/packages/client/locale/src/client/index.ts b/packages/client/locale/src/client/index.ts index 35f29a0e52..444594f9b8 100644 --- a/packages/client/locale/src/client/index.ts +++ b/packages/client/locale/src/client/index.ts @@ -1,10 +1,20 @@ /** * Browser-side locale registry. Bound translation functions retain stable - * identity for injected consumers. + * identity for injected consumers. The plugin also registers the Language + * preference row into the settings General section — the locale feature owns + * its own settings surface. */ import type { Context } from 'cordis' +import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots' +import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' import { en } from '../locales/en.ts' import { zh } from '../locales/zh.ts' +import type { LanguageRowInjected } from './LanguageRow.tsx' +import { LanguageRow } from './LanguageRow.tsx' +import { createLanguageRowStore } from './settings-store.ts' + +export type { LanguageRowComponentProps, LanguageRowInjected } from './LanguageRow.tsx' +export type { LanguageOptionRow, LanguageRowState } from './settings-store.ts' /** Translate a key with optional params. */ export type Translate = (key: string, params?: Record) => string @@ -53,6 +63,9 @@ export const FALLBACK_LOCALE: LocaleId = 'zh' /** Shared namespace for shell-level texts. */ export const COMMON_NS = 'common' +/** Namespace owning this feature's settings-row copy. */ +export const SETTINGS_NS = 'settings.locale' + /** localStorage key holding the persisted locale id. */ export const STORAGE_KEY = 'dsh.locale' @@ -183,16 +196,65 @@ function persistPreference(id: LocaleId): void { } } -/** Required services (none; the loader passes the export surface as an object plugin). */ -export const inject: string[] = [] +/** Required services: the slot registry (the feature registers its own settings row). */ +export const inject = ['slots'] /** - * Client plugin body: provide the locale service with base dictionaries. + * Client plugin body: provide the locale service with base dictionaries and + * register the feature-owned Language preference row into the General + * section's item slot (a feature owns its settings surface). * @param ctx - client cordis context. */ -export function apply(ctx: Context): void { +export function apply(ctx: ClientContext): void { const locale = new LocaleService(ctx) locale.register(COMMON_NS, 'zh', zh) locale.register(COMMON_NS, 'en', en) + locale.register(SETTINGS_NS, 'zh', { 'language.title': '语言' }) + locale.register(SETTINGS_NS, 'en', { 'language.title': 'Language' }) ctx.provide('locale', locale) + + const store = createLanguageRowStore() + let bound: BoundActions | undefined + const sync = (snapshot: LocaleSnapshot): void => { + bound?.sync( + snapshot.active, + snapshot.locales.map(l => ({ id: l.id, label: l.label })), + snapshot.revision, + ) + } + ctx.on('locale/change', sync) + const injected = (actions: BoundActions): LanguageRowInjected => { + bound = actions + // Re-sync from the getter so no event is lost between registration and + // first render (the store's revision guard drops stale duplicates). + sync(locale.getLocale()) + return { + t: locale.bind(SETTINGS_NS), + setLocale: (id) => { locale.setLocale(id) }, + } + } + // Declaration-aware registration; the LEDGER is the has-registered judge + // (not a local flag): after an HMR collapse re-declares the slot, the + // cascade already removed our entry, and a stale disposer must not block + // the re-registration. + ctx.effect(() => { + let dispose: (() => void) | undefined + const tryRegister = (): void => { + if (ctx.slots.spec('settings.general.item') === undefined) return + if (ctx.slots.entries('settings.general.item').some(e => e.component === LanguageRow)) return + dispose = ctx.slots.register({ + name: 'settings.general.item', + id: 'language', + order: 0, + store, + inject: injected, + }, LanguageRow) + } + const unsubscribe = ctx.slots.subscribe('settings.general.item', () => { tryRegister() }) + tryRegister() + return () => { + unsubscribe() + dispose?.() + } + }, 'locale: language settings row registration') } diff --git a/packages/client/locale/src/client/settings-contract.ts b/packages/client/locale/src/client/settings-contract.ts new file mode 100644 index 0000000000..add314122c --- /dev/null +++ b/packages/client/locale/src/client/settings-contract.ts @@ -0,0 +1,18 @@ +/** + * Settings-surface slot merge consumed by this package's Language row. The + * AUTHORITATIVE home for 'settings.general.item' is the ui-settings contract + * (declaring is claiming: the shell's General entry declares the slot); this + * file repeats the entry verbatim because the shell consumes ctx.locale + * (project reference ui-settings -> locale), so importing the shell's types + * from here would close a reference cycle. TypeScript declaration merging + * rejects diverging duplicates, so every program that sees both copies (the + * shell's own build, the client aggregate) enforces identity. + */ +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface SlotMap { + /** One preference row inside the General section (duplicate-identical merge; authority: ui-settings contract). */ + 'settings.general.item': { kind: 'list'; scope: 'root'; owner: { children?: never } } + } +} + +export {} diff --git a/packages/client/locale/src/client/settings-store.ts b/packages/client/locale/src/client/settings-store.ts new file mode 100644 index 0000000000..485fd409f0 --- /dev/null +++ b/packages/client/locale/src/client/settings-store.ts @@ -0,0 +1,47 @@ +/** + * Language row slot store: a mirror of the locale service snapshot. The + * plugin's apply-world change listener is the only writer; the row component + * reads via props.useStore. + */ +import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-runtime/client' + +/** One selectable locale row (id + self-described label). */ +export interface LanguageOptionRow { + /** Locale id (the setLocale argument). */ + id: string + /** Display name in its own language (中文 / English). */ + label: string +} + +/** Store state mirrored from the locale snapshot. */ +export interface LanguageRowState { + /** Active locale id. */ + active: string + /** Selectable locales in display order. */ + options: LanguageOptionRow[] + /** Service revision; -1 until first sync so revision 0 lands as a change. */ + revision: number +} + +/** Declared action shape giving the exported factory a stable return type. */ +type LanguageRowActions = { + sync: (draft: LanguageRowState, active: string, options: LanguageOptionRow[], revision: number) => void +} + +/** + * Declares the Language row state and write surface. + * @returns the store handle. + */ +export function createLanguageRowStore(): EngineStoreHandle { + return defineStore({ + init: (): LanguageRowState => ({ active: '', options: [], revision: -1 }), + actions: { + sync: (d, active: string, options: LanguageOptionRow[], revision: number) => { + if (revision <= d.revision) return + d.active = active + d.options = options + d.revision = revision + }, + }, + }) +} diff --git a/packages/client/ui-settings-general/src/css-modules.d.ts b/packages/client/locale/src/css-modules.d.ts similarity index 100% rename from packages/client/ui-settings-general/src/css-modules.d.ts rename to packages/client/locale/src/css-modules.d.ts diff --git a/packages/client/locale/tests/invariant.spec.ts b/packages/client/locale/tests/invariant.spec.ts index e9f4fdee36..fa62ca79f6 100644 --- a/packages/client/locale/tests/invariant.spec.ts +++ b/packages/client/locale/tests/invariant.spec.ts @@ -1,8 +1,10 @@ +// @vitest-environment jsdom import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { apply as nodeApply } from '@deepseek-ai/dsh-client-locale' import { apply as clientApply, COMMON_NS, LocaleService, inject } from '@deepseek-ai/dsh-client-locale/client' import * as LocaleInvariant from '@deepseek-ai/dsh-client-locale/invariant' +import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import InvariantService from '@deepseek-ai/dsh-invariants' describe('invariant companion', () => { @@ -18,8 +20,10 @@ describe('invariant companion', () => { }) it('client apply provides ctx.locale seeded with the zh/en common namespace', async () => { - expect(inject).toEqual([]) + // The feature registers its own Language settings row, hence the slots edge. + expect(inject).toEqual(['slots']) const ctx = new Context() + new SlotsService(ctx) await ctx.plugin({ inject, apply: clientApply }).await() const locale = ctx.get('locale') expect(locale).toBeInstanceOf(LocaleService) diff --git a/packages/client/locale/tsconfig.json b/packages/client/locale/tsconfig.json index 51f9171643..8585ba74ca 100644 --- a/packages/client/locale/tsconfig.json +++ b/packages/client/locale/tsconfig.json @@ -8,6 +8,15 @@ "src" ], "references": [ + { + "path": "../runtime" + }, + { + "path": "../ui-primitives" + }, + { + "path": "../ui-slots" + }, { "path": "../../../vendor/cordis" }, diff --git a/packages/client/ui-settings-models/README.md b/packages/client/ui-models/README.md similarity index 92% rename from packages/client/ui-settings-models/README.md rename to packages/client/ui-models/README.md index c96e2843e4..b5f6c9bea9 100644 --- a/packages/client/ui-settings-models/README.md +++ b/packages/client/ui-models/README.md @@ -1,4 +1,4 @@ -# @deepseek-ai/dsh-client-ui-settings-models +# @deepseek-ai/dsh-client-ui-models Models settings section plugin: registers the `models` nav entry into `settings.section` with an intentionally empty content column — model management lands in a later phase. diff --git a/packages/client/ui-settings-models/package.json b/packages/client/ui-models/package.json similarity index 89% rename from packages/client/ui-settings-models/package.json rename to packages/client/ui-models/package.json index da8fa8c18d..df8cba7c8b 100644 --- a/packages/client/ui-settings-models/package.json +++ b/packages/client/ui-models/package.json @@ -1,6 +1,6 @@ { - "name": "@deepseek-ai/dsh-client-ui-settings-models", - "description": "Models settings section plugin: nav entry with an empty content column (model management lands later)", + "name": "@deepseek-ai/dsh-client-ui-models", + "description": "Models feature plugin: registers its Settings section (nav entry, empty content column; model management lands later)", "version": "0.0.1", "private": true, "type": "module", diff --git a/packages/client/ui-settings-models/src/client/ModelsSection.tsx b/packages/client/ui-models/src/client/ModelsSection.tsx similarity index 100% rename from packages/client/ui-settings-models/src/client/ModelsSection.tsx rename to packages/client/ui-models/src/client/ModelsSection.tsx diff --git a/packages/client/ui-settings-models/src/client/index.ts b/packages/client/ui-models/src/client/index.ts similarity index 96% rename from packages/client/ui-settings-models/src/client/index.ts rename to packages/client/ui-models/src/client/index.ts index c35946fcc8..4f080ec928 100644 --- a/packages/client/ui-settings-models/src/client/index.ts +++ b/packages/client/ui-models/src/client/index.ts @@ -30,7 +30,7 @@ export function apply(ctx: ClientContext): void { ctx.locale.register('settings.models', 'en', { nav: 'Models' }), ] return () => { for (const dispose of disposers) dispose() } - }, 'ui-settings-models: nav copy dictionaries') + }, 'ui-models: nav copy dictionaries') // Declaration-aware registration; the LEDGER is the has-registered judge // (not a local flag): after an HMR collapse re-declares the slot, the // cascade already removed our entry, and a stale disposer must not block @@ -63,5 +63,5 @@ export function apply(ctx: ClientContext): void { unsubscribe() dispose?.() } - }, 'ui-settings-models: section registration') + }, 'ui-models: settings section registration') } diff --git a/packages/client/ui-settings-models/src/css-modules.d.ts b/packages/client/ui-models/src/css-modules.d.ts similarity index 100% rename from packages/client/ui-settings-models/src/css-modules.d.ts rename to packages/client/ui-models/src/css-modules.d.ts diff --git a/packages/client/ui-settings-models/src/index.ts b/packages/client/ui-models/src/index.ts similarity index 100% rename from packages/client/ui-settings-models/src/index.ts rename to packages/client/ui-models/src/index.ts diff --git a/packages/client/ui-settings-models/src/invariant.ts b/packages/client/ui-models/src/invariant.ts similarity index 81% rename from packages/client/ui-settings-models/src/invariant.ts rename to packages/client/ui-models/src/invariant.ts index 9ffdc668d7..c7c4996748 100644 --- a/packages/client/ui-settings-models/src/invariant.ts +++ b/packages/client/ui-models/src/invariant.ts @@ -1,16 +1,16 @@ /** - * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-settings-models`. - * @module @deepseek-ai/dsh-client-ui-settings-models/invariant + * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-models`. + * @module @deepseek-ai/dsh-client-ui-models/invariant */ /* jscpd:ignore-start */ import type { Context } from 'cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' -const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-settings-models' +const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-models' /** Cordis companion plugin name. */ -export const name = 'client-ui-settings-models-invariant' +export const name = 'client-ui-models-invariant' /** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] diff --git a/packages/client/ui-settings-models/tests/apply.spec.ts b/packages/client/ui-models/tests/apply.spec.ts similarity index 96% rename from packages/client/ui-settings-models/tests/apply.spec.ts rename to packages/client/ui-models/tests/apply.spec.ts index b7aa3cf2ab..1842000675 100644 --- a/packages/client/ui-settings-models/tests/apply.spec.ts +++ b/packages/client/ui-models/tests/apply.spec.ts @@ -3,7 +3,7 @@ import { Context } from 'cordis' import { describe, expect, it } from 'vitest' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' -import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings-models/client' +import { apply, inject } from '@deepseek-ai/dsh-client-ui-models/client' import { ModelsSection } from '../src/client/ModelsSection.tsx' async function bench() { @@ -21,7 +21,7 @@ function declare(slots: SlotsService): () => void { ) } -describe('ui-settings-models apply', () => { +describe('ui-models apply', () => { it('declares the services it uses', () => { expect(inject).toEqual(['slots', 'locale']) }) diff --git a/packages/client/ui-settings-models/tests/invariant.spec.ts b/packages/client/ui-models/tests/invariant.spec.ts similarity index 82% rename from packages/client/ui-settings-models/tests/invariant.spec.ts rename to packages/client/ui-models/tests/invariant.spec.ts index 65c7c1094a..05fb52ee1b 100644 --- a/packages/client/ui-settings-models/tests/invariant.spec.ts +++ b/packages/client/ui-models/tests/invariant.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import * as ModelsInvariant from '@deepseek-ai/dsh-client-ui-settings-models/invariant' +import * as ModelsInvariant from '@deepseek-ai/dsh-client-ui-models/invariant' import InvariantService from '@deepseek-ai/dsh-invariants' import { ModelsSection } from '../src/client/ModelsSection.tsx' @@ -12,7 +12,7 @@ describe('invariant companion', () => { }) it('node-half apply is a no-op host placeholder', async () => { - const { apply } = await import('@deepseek-ai/dsh-client-ui-settings-models') + const { apply } = await import('@deepseek-ai/dsh-client-ui-models') apply() expect(true).toBe(true) // reaching here without throw is the contract }) diff --git a/packages/client/ui-settings-models/tsconfig.json b/packages/client/ui-models/tsconfig.json similarity index 100% rename from packages/client/ui-settings-models/tsconfig.json rename to packages/client/ui-models/tsconfig.json diff --git a/packages/client/ui-models/tsdown.config.ts b/packages/client/ui-models/tsdown.config.ts new file mode 100644 index 0000000000..fc044b07d6 --- /dev/null +++ b/packages/client/ui-models/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-ui-models', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/client/ui-settings-general/README.md b/packages/client/ui-settings-general/README.md deleted file mode 100644 index 435dd8c1b4..0000000000 --- a/packages/client/ui-settings-general/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# @deepseek-ai/dsh-client-ui-settings-general - -General settings section plugin: registers the `general` entry into `settings.section`. Language (中文/English) and Appearance (Light/Dark/System) are live preferences wired to `ctx.locale` / `ctx.theme`; Permission and Tool Call rows are visual skeletons with no write surface. - -## Model Experience - -None, as the section renders browser preference UI; nothing here reaches a model request. - -#### KV Cache effect - -None; this package neither assembles nor sends a provider request. - -## Known Limitations and Deferred Work - -- **Permission and Tool Call are display skeletons** — the backing host services and RPC methods do not exist yet; the controls are disabled and write nothing. diff --git a/packages/client/ui-settings-general/package.json b/packages/client/ui-settings-general/package.json deleted file mode 100644 index 9b3d83e472..0000000000 --- a/packages/client/ui-settings-general/package.json +++ /dev/null @@ -1,70 +0,0 @@ -{ - "name": "@deepseek-ai/dsh-client-ui-settings-general", - "description": "General settings section plugin: Language and Appearance preferences (live), Permission and Tool Call skeleton rows", - "version": "0.0.1", - "private": true, - "type": "module", - "main": "lib/index.js", - "types": "lib/types/index.d.ts", - "exports": { - ".": { - "types": "./lib/types/index.d.ts", - "default": "./lib/index.js" - }, - "./invariant": { - "types": "./lib/types/invariant.d.ts", - "default": "./lib/invariant.js" - }, - "./client": { - "types": "./lib/types/client/index.d.ts", - "default": "./lib/client.js" - }, - "./src/*": "./src/*", - "./package.json": "./package.json" - }, - "dshClient": { - "inject": [ - "@deepseek-ai/dsh-client-runtime", - "@deepseek-ai/dsh-client-ui-settings", - "@deepseek-ai/dsh-client-locale", - "@deepseek-ai/dsh-client-ui-theme" - ], - "platform": "web" - }, - "scripts": { - "bundle": "tsdown", - "watch": "tsdown --watch" - }, - "license": "BSD-3-Clause", - "dependencies": { - "clsx": "^2.0.0" - }, - "peerDependencies": { - "@deepseek-ai/dsh-client-runtime": "^0.0.1", - "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", - "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7", - "react": "^18.2.0" - }, - "devDependencies": { - "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", - "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", - "@deepseek-ai/dsh-client-ui-settings": "workspace:^", - "@deepseek-ai/dsh-client-ui-slots": "workspace:^", - "@deepseek-ai/dsh-client-ui-theme": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@types/react": "~18.3.1", - "cordis": "^4.0.0-rc.7", - "react": "^18.2.0" - }, - "files": [ - "lib/index.js", - "lib/invariant.js", - "lib/client.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" - ] -} diff --git a/packages/client/ui-settings-general/src/client/GeneralSection.tsx b/packages/client/ui-settings-general/src/client/GeneralSection.tsx deleted file mode 100644 index d2947c0435..0000000000 --- a/packages/client/ui-settings-general/src/client/GeneralSection.tsx +++ /dev/null @@ -1,118 +0,0 @@ -/** - * General settings section: Permission and Tool Call skeleton rows (visual - * only, no interaction), live Language and Appearance preference rows wired - * through the injected setLocale/setTheme callbacks and the snapshot-mirror - * store. Figma: Settings > Content > Options (501:29983). - */ -import { useState } from 'react' -import clsx from 'clsx' -import { - IconChevronDownOutline14, IconDarkOutline16, IconFollowsystemOutline16, IconLightOutline16, - Menu, -} from '@deepseek-ai/dsh-client-ui-primitives' -import type { GeneralSectionComponentProps, ThemePreferenceId } from './contract.ts' -import css from './GeneralSection.module.css' - -/** Appearance cube order and icons (figma 501:30015-30017: Light, Dark, System). */ -const THEME_CUBES: readonly { id: ThemePreferenceId; labelKey: string; Icon: typeof IconLightOutline16 }[] = [ - { id: 'light', labelKey: 'appearance.light', Icon: IconLightOutline16 }, - { id: 'dark', labelKey: 'appearance.dark', Icon: IconDarkOutline16 }, - { id: 'system', labelKey: 'appearance.system', Icon: IconFollowsystemOutline16 }, -] - -/** - * Render the General section content column. - * @param props - composed slot props (contract.ts). - * @returns the section element tree. - */ -export function GeneralSection(props: GeneralSectionComponentProps) { - const { t, setLocale, setTheme, useStore } = props - const localeActive = useStore(s => s.localeActive) - const localeOptions = useStore(s => s.localeOptions) - const themePreference = useStore(s => s.themePreference) - const [languageOpen, setLanguageOpen] = useState(false) - - const activeLocaleLabel = localeOptions.find(l => l.id === localeActive)?.label ?? localeActive - - return ( -
- {/* Permission (skeleton): disabled selector pill. */} -
-
-
{t('permission.title')}
-
{t('permission.desc')}
-
- -
- - {/* Tool Call (skeleton): schema cube pinned selected, code cube unselected. */} -
-
{t('toolcall.title')}
-
-
-
{t('toolcall.schema.title')}
-
{t('toolcall.schema.desc')}
-
-
-
{t('toolcall.code.title')}
-
{t('toolcall.code.desc')}
-
-
-
- - {/* Language: selector pill opens the locale menu. */} -
-
-
{t('language.title')}
-
- { setLanguageOpen(false) }} - items={localeOptions.map(l => ({ id: l.id, label: l.label }))} - selectedId={localeActive} - onSelect={(id) => { - setLocale(id) - setLanguageOpen(false) - }} - align="end" - portal - anchor={( - - )} - /> -
- - {/* Appearance: three preference cubes; selection follows the persisted - * preference, never the resolved active theme. */} -
-
{t('appearance.title')}
-
- {THEME_CUBES.map(({ id, labelKey, Icon }) => ( - - ))} -
-
-
- ) -} diff --git a/packages/client/ui-settings-general/src/client/contract.ts b/packages/client/ui-settings-general/src/client/contract.ts deleted file mode 100644 index 912d925615..0000000000 --- a/packages/client/ui-settings-general/src/client/contract.ts +++ /dev/null @@ -1,66 +0,0 @@ -/** - * General section component contract: the slot-store state shape, the - * injected business face, and the composed props type. The component imports - * only from here; service snapshot shapes are mirrored as plain rows so the - * presentation layer stays decoupled from the locale/theme packages. - */ -import type { PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' -// Type-only: pulls the shell's SlotMap merge (the 'settings.section' entry). -import type {} from '@deepseek-ai/dsh-client-ui-settings/client' -import type { createGeneralSettingsStore } from './store.ts' - -/** One selectable locale row projected into the store (id + self-described label). */ -export interface LocaleOptionRow { - /** Locale id (the setLocale argument). */ - id: string - /** Display name in its own language (中文 / English). */ - label: string -} - -/** Theme preference union mirrored from the theme service snapshot. */ -export type ThemePreferenceId = 'light' | 'dark' | 'system' - -/** - * Store state: mirrors of the locale/theme service snapshots, written only by - * the plugin's apply-world change listeners (components have no write path — - * preference writes go through the injected callbacks to the services, and - * the resulting change events flow back into this mirror). - */ -export interface GeneralSettingsState { - /** Active locale id. */ - localeActive: string - /** Selectable locales in display order. */ - localeOptions: LocaleOptionRow[] - /** Locale service revision (re-renders translated copy on dictionary/locale changes); -1 until first sync. */ - localeRevision: number - /** Persisted theme preference (selection state reads this, never the resolved active theme). */ - themePreference: ThemePreferenceId - /** Theme service revision; -1 until first sync. */ - themeRevision: number -} - -/** - * Registrant-private injected share of the General section (assembled in - * apply): the namespace-bound translate function (stable identity — re-render - * on locale change comes from the store revision, not from `t`) and the two - * preference write callbacks. - */ -export interface GeneralSectionInjected { - /** Translate a `settings.general` dictionary key to the active-locale text. */ - t: (key: string) => string - /** Switch the active locale (a registered locale id). */ - setLocale: (id: string) => void - /** Switch the theme preference. */ - setTheme: (id: ThemePreferenceId) => void -} - -/** Store handle type for the props share (type-only; the factory stays internal to apply and tests). */ -export type GeneralSettingsStoreHandle = ReturnType - -/** - * Full component props of the General section: the section owner share - * (empty marker) plus the store share and the injected face. No child slots - * are declared; menu open state is component-local viewing state. - */ -export type GeneralSectionComponentProps = - PropsRuntime<'settings.section'> & PropsStore & GeneralSectionInjected diff --git a/packages/client/ui-settings-general/src/client/index.ts b/packages/client/ui-settings-general/src/client/index.ts deleted file mode 100644 index 52655be7fa..0000000000 --- a/packages/client/ui-settings-general/src/client/index.ts +++ /dev/null @@ -1,117 +0,0 @@ -/** - * General settings section plugin, browser half. Registers the `general` - * entry into the shell-declared `settings.section` list slot; Language and - * Appearance are live preferences projected from ctx.locale / ctx.theme - * through this entry's slot store. Export discipline: packages/client/AGENTS.md. - */ -import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' -import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots' -// Type-only: pulls the shell's SlotMap merge (the 'settings.section' entry). -import type {} from '@deepseek-ai/dsh-client-ui-settings/client' -// Type-only: the locale/theme Context+Events merges and snapshot shapes. -import type { LocaleSnapshot } from '@deepseek-ai/dsh-client-locale/client' -import type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client' -import type { GeneralSectionInjected } from './contract.ts' -import { createGeneralSettingsStore } from './store.ts' -import { en, zh } from './locales.ts' -import { GeneralSection } from './GeneralSection.tsx' - -export type { - GeneralSectionComponentProps, GeneralSectionInjected, GeneralSettingsState, - GeneralSettingsStoreHandle, LocaleOptionRow, ThemePreferenceId, -} from './contract.ts' - -/** Dictionary namespace owned by this section (also the nav-label reference prefix). */ -const NS = 'settings.general' - -/** - * Required services (cordis fiber inject). The target slot is declared by - * ui-settings' apply, whose activation order relative to this one is NOT - * constrained; registration goes through declaration-aware deferral. - */ -export const inject = ['slots', 'locale', 'theme'] - -/** - * Register the `settings.general` dictionaries and the General section entry - * once the `settings.section` declaration is on the ledger. The slot store - * mirrors the locale/theme snapshots: change listeners attach here in apply, - * write through the bound actions captured at inject time, and the inject - * factory re-syncs from the getters so no event is lost between registration - * and first render (the store's revision guard drops stale duplicates). - * @param ctx - client root context. - */ -export function apply(ctx: ClientContext): void { - ctx.effect(() => { - const disposeZh = ctx.locale.register(NS, 'zh', zh) - const disposeEn = ctx.locale.register(NS, 'en', en) - return () => { - disposeZh() - disposeEn() - } - }, 'ui-settings-general: dictionaries') - - const store = createGeneralSettingsStore() - let bound: BoundActions | undefined - - const syncLocale = (snapshot: LocaleSnapshot): void => { - bound?.syncLocale( - snapshot.active, - snapshot.locales.map(l => ({ id: l.id, label: l.label })), - snapshot.revision, - ) - } - const syncTheme = (snapshot: ThemeSnapshot): void => { - bound?.syncTheme(snapshot.preference, snapshot.revision) - } - ctx.on('locale/change', syncLocale) - ctx.on('theme/change', syncTheme) - - const injected = (actions: BoundActions): GeneralSectionInjected => { - bound = actions - syncLocale(ctx.locale.getLocale()) - syncTheme(ctx.theme.getTheme()) - return { - t: ctx.locale.bind(NS), - setLocale: (id) => { ctx.locale.setLocale(id) }, - setTheme: (id) => { ctx.theme.setTheme(id) }, - } - } - - ctx.effect(() => { - let dispose: (() => void) | undefined - // Presence is judged on the ledger, not on the local disposer: an HMR - // collapse of the declaring entry removes this entry from the slot core - // while `dispose` stays set (the stale disposer is a no-op), so a local - // guard would block the re-registration when the declaration returns. - const registered = (): boolean => - ctx.slots.entries('settings.section').some(e => e.component === GeneralSection) - const tryRegister = (): void => { - if (ctx.slots.spec('settings.section') === undefined || registered()) return - dispose = ctx.slots.register({ - name: 'settings.section', - id: 'general', - order: 0, - label: ctx.locale.bind(NS)('nav'), - store, - inject: injected, - }, GeneralSection) - } - // Nav labels are registrant-localized: re-register on locale change so - // the ledger carries fresh text (the version bump re-renders the shell). - // The ledger check mirrors tryRegister: after an HMR collapse `dispose` - // stays set while the entry is gone — relabeling then must stay quiet. - const offLocale = ctx.on('locale/change', () => { - if (dispose === undefined || !registered()) return - dispose() - dispose = undefined - tryRegister() - }) - const unsubscribe = ctx.slots.subscribe('settings.section', () => { tryRegister() }) - tryRegister() - return () => { - offLocale() - unsubscribe() - dispose?.() - } - }, 'ui-settings-general: section registration') -} diff --git a/packages/client/ui-settings-general/src/client/store.ts b/packages/client/ui-settings-general/src/client/store.ts deleted file mode 100644 index 121e3f52a9..0000000000 --- a/packages/client/ui-settings-general/src/client/store.ts +++ /dev/null @@ -1,43 +0,0 @@ -/** - * General section slot store: locale/theme snapshot mirrors. The plugin - * creates the handle at apply time (identity follows the fiber) and its - * change listeners are the only writers; components read via props.useStore. - */ -import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-runtime/client' -import type { GeneralSettingsState, LocaleOptionRow, ThemePreferenceId } from './contract.ts' - -/** Declared action shape used to give the exported factory a stable return type. */ -type GeneralSettingsActions = { - syncLocale: (draft: GeneralSettingsState, active: string, options: LocaleOptionRow[], revision: number) => void - syncTheme: (draft: GeneralSettingsState, preference: ThemePreferenceId, revision: number) => void -} - -/** - * Declares the General section state and write surface. Revisions start at -1 - * so the apply-time initial sync (revision 0) always lands as a change. - * @returns the store handle. - */ -export function createGeneralSettingsStore(): EngineStoreHandle { - return defineStore({ - init: (): GeneralSettingsState => ({ - localeActive: '', - localeOptions: [], - localeRevision: -1, - themePreference: 'system', - themeRevision: -1, - }), - actions: { - syncLocale: (d, active: string, options: LocaleOptionRow[], revision: number) => { - if (revision <= d.localeRevision) return - d.localeActive = active - d.localeOptions = options - d.localeRevision = revision - }, - syncTheme: (d, preference: ThemePreferenceId, revision: number) => { - if (revision <= d.themeRevision) return - d.themePreference = preference - d.themeRevision = revision - }, - }, - }) -} diff --git a/packages/client/ui-settings-general/src/index.ts b/packages/client/ui-settings-general/src/index.ts deleted file mode 100644 index 94b9bdf674..0000000000 --- a/packages/client/ui-settings-general/src/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -/** Host loader entry for the browser implementation exported from `./client`. */ - -/** Host plugin body — no host-side behavior for the general settings plugin. */ -export function apply(): void {} diff --git a/packages/client/ui-settings-general/src/invariant.ts b/packages/client/ui-settings-general/src/invariant.ts deleted file mode 100644 index a40cc3cc00..0000000000 --- a/packages/client/ui-settings-general/src/invariant.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-settings-general`. - * @module @deepseek-ai/dsh-client-ui-settings-general/invariant - */ - -/* jscpd:ignore-start */ -import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' - -const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-settings-general' - -/** Cordis companion plugin name. */ -export const name = 'client-ui-settings-general-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: a section plugin projecting two service change events - * into its own slot store — it emits no cordis events of its own and owns no - * cross-plugin mutable relation; snapshot/store agreement is asserted by this - * package's behavior specs. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/client/ui-settings-general/tests/apply.spec.ts b/packages/client/ui-settings-general/tests/apply.spec.ts deleted file mode 100644 index 8654275831..0000000000 --- a/packages/client/ui-settings-general/tests/apply.spec.ts +++ /dev/null @@ -1,139 +0,0 @@ -/** apply wiring: dictionary registration, declaration-aware section entry, - * snapshot projection into the slot store, locale-driven relabeling, and - * recovery after an HMR collapse of the declaring entry. */ -import { Context } from 'cordis' -import { describe, expect, it } from 'vitest' -import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' -import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' -import { ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client' -import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings-general/client' -import type { GeneralSectionInjected } from '@deepseek-ai/dsh-client-ui-settings-general/client' -import { GeneralSection } from '../src/client/GeneralSection.tsx' -import type { createGeneralSettingsStore } from '../src/client/store.ts' - -const NS = 'settings.general' - -async function bench() { - const ctx = new Context() - await ctx.plugin(SlotsService).await() - const locale = new LocaleService(ctx) - const theme = new ThemeService(ctx) - ctx.provide('locale', locale) - ctx.provide('theme', theme) - return { ctx, slots: ctx.get('slots') as SlotsService, locale, theme } -} - -/** Stand in for the settings shell: declare the section list slot from root. */ -function declareSection(slots: SlotsService): () => void { - return slots.register( - { name: 'root', children: { 'settings.section': { kind: 'list', scope: 'root' } } } as never, - () => null, - ) -} - -/** Mirror the framework's inject choreography: bake a real instance from the - * declared handle and hand its actions to the entry's inject factory. */ -function faceOf(slots: SlotsService) { - const entry = slots.entries('settings.section')[0]! - const handle = entry.store as ReturnType - const instance = handle.create() - const face = (entry.inject as unknown as (a: typeof instance.actions) => GeneralSectionInjected)(instance.actions) - return { entry, instance, face } -} - -describe('ui-settings-general apply', () => { - it('declares the slot, locale, and theme services', () => { - expect(inject).toEqual(['slots', 'locale', 'theme']) - }) - - it('registers dictionaries and the section entry for declarations before or after apply', async () => { - const before = await bench() - declareSection(before.slots) - await before.ctx.plugin({ inject: [...inject], apply }).await() - const entry = before.slots.entries('settings.section')[0]! - expect(entry.component).toBe(GeneralSection) - expect(entry.options).toMatchObject({ id: 'general', order: 0, label: '通用设置' }) - expect(before.locale.bind(NS)('nav')).toBe('通用设置') - - const after = await bench() - const fiber = after.ctx.plugin({ inject: [...inject], apply }) - await fiber.await() - expect(after.slots.entries('settings.section')).toHaveLength(0) - declareSection(after.slots) - await Promise.resolve() - expect(after.slots.entries('settings.section')[0]!.component).toBe(GeneralSection) - // Teardown without a live registration exercises the undefined-disposer arm. - await fiber.dispose() - expect(after.slots.entries('settings.section')).toHaveLength(0) - }) - - it('projects service snapshots into the store and routes face writes back', async () => { - const b = await bench() - declareSection(b.slots) - await b.ctx.plugin({ inject: [...inject], apply }).await() - // Events ahead of any inject hit the unbound-actions arm without a store. - b.theme.setTheme('dark') - - const { instance, face } = faceOf(b.slots) - // The inject-time re-sync sealed the init window: both mirrors are current. - expect(instance.getSnapshot().localeActive).toBe('zh') - expect(instance.getSnapshot().localeOptions.map(l => l.id)).toEqual(['zh', 'en']) - expect(instance.getSnapshot().themePreference).toBe('dark') - expect(face.t('nav')).toBe('通用设置') - - face.setLocale('en') - expect(b.locale.getLocale().active).toBe('en') - expect(instance.getSnapshot().localeActive).toBe('en') - expect(face.t('nav')).toBe('General') - - face.setTheme('system') - expect(b.theme.getTheme().preference).toBe('system') - expect(instance.getSnapshot().themePreference).toBe('system') - }) - - it('re-registers with a fresh ledger label when the locale changes', async () => { - const b = await bench() - declareSection(b.slots) - await b.ctx.plugin({ inject: [...inject], apply }).await() - expect(b.slots.entries('settings.section')[0]!.options.label).toBe('通用设置') - b.locale.setLocale('en') - const entry = b.slots.entries('settings.section')[0]! - expect(entry.options.label).toBe('General') - expect(entry.component).toBe(GeneralSection) - }) - - it('recovers after an HMR collapse of the declaring entry (stale disposer must not block)', async () => { - const b = await bench() - const host = declareSection(b.slots) - await b.ctx.plugin({ inject: [...inject], apply }).await() - expect(b.slots.entries('settings.section')).toHaveLength(1) - - // Collapse: the declarer dies, the cascade removes our entry while the - // apply closure still holds its (now stale) disposer. - host() - expect(b.slots.entries('settings.section')).toHaveLength(0) - - // A locale change inside the collapsed window must stay quiet. - b.locale.setLocale('en') - expect(b.slots.entries('settings.section')).toHaveLength(0) - - // Redeclaration restores the entry — with the current locale's label. - declareSection(b.slots) - await Promise.resolve() - const entry = b.slots.entries('settings.section')[0]! - expect(entry.component).toBe(GeneralSection) - expect(entry.options.label).toBe('General') - }) - - it('removes the entry and the dictionaries on teardown', async () => { - const b = await bench() - declareSection(b.slots) - const fiber = b.ctx.plugin({ inject: [...inject], apply }) - await fiber.await() - expect(b.slots.entries('settings.section')).toHaveLength(1) - await fiber.dispose() - expect(b.slots.entries('settings.section')).toHaveLength(0) - // Dictionary disposal: translation falls back to the bare key. - expect(b.locale.bind(NS)('nav')).toBe('nav') - }) -}) diff --git a/packages/client/ui-settings-general/tests/general-section.spec.tsx b/packages/client/ui-settings-general/tests/general-section.spec.tsx deleted file mode 100644 index e82ed0b1fc..0000000000 --- a/packages/client/ui-settings-general/tests/general-section.spec.tsx +++ /dev/null @@ -1,112 +0,0 @@ -// @vitest-environment jsdom -/** GeneralSection behavior: skeleton rows stay inert, Language menu drives - * setLocale, Appearance cubes follow the preference and drive setTheme. */ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' -import { createSnapshotStore, type SessionListState, type WorkspaceListState } from '@deepseek-ai/dsh-client-runtime/client' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' -import { GeneralSection } from '../src/client/GeneralSection.tsx' -import { createGeneralSettingsStore } from '../src/client/store.ts' -import { en } from '../src/client/locales.ts' -import type { GeneralSectionComponentProps } from '../src/client/contract.ts' - -afterEach(cleanup) - -const LOCALES = [{ id: 'zh', label: '中文' }, { id: 'en', label: 'English' }] - -/** Empty global standard-kit hooks (the section reads neither). */ -function emptySessions() { - const store = createSnapshotStore( - { ids: [], byId: {}, current: undefined, intent: undefined, phase: 'ready' }) - return bindSnapshotSelector(store) -} -function emptyWorkspaces() { - const store = createSnapshotStore({ - items: [], intent: undefined, state: 'idle', phase: 'ready', error: null, - baselinesReady: true, recentWorkspaceId: undefined, - }) - return bindSnapshotSelector(store) -} - -function mount(init?: { active?: string; preference?: 'light' | 'dark' | 'system' }) { - // Real store instance — the sanctioned zero-machinery path for tests. - const store = createGeneralSettingsStore().create() - store.actions.syncLocale(init?.active ?? 'en', LOCALES, 0) - store.actions.syncTheme(init?.preference ?? 'system', 0) - const setLocale = vi.fn() - const setTheme = vi.fn() - const props: GeneralSectionComponentProps = { - useSessions: emptySessions(), - useWorkspaces: emptyWorkspaces(), - useStore: bindSnapshotSelector(store), - actions: store.actions, - t: (key: string) => en[key] ?? key, - setLocale, - setTheme, - } - render() - return { store, setLocale, setTheme } -} - -const pressed = (name: RegExp): string | null => - screen.getByRole('button', { name }).getAttribute('aria-pressed') - -describe('GeneralSection', () => { - it('renders the four groups with skeleton rows inert', () => { - const b = mount() - // Permission: disabled selector showing the fixed value. - const permission = screen.getByRole('button', { name: /Read only/ }) as HTMLButtonElement - expect(permission.disabled).toBe(true) - fireEvent.click(permission) - // Tool Call: both mode cubes render as plain text, no buttons. - expect(screen.getByText('Schema mode')).toBeDefined() - expect(screen.getByText('Code mode')).toBeDefined() - expect(screen.queryByRole('button', { name: /Schema mode/ })).toBeNull() - expect(b.setLocale).not.toHaveBeenCalled() - expect(b.setTheme).not.toHaveBeenCalled() - }) - - it('opens the language menu, selects a locale, and closes', () => { - const b = mount({ active: 'en' }) - const trigger = screen.getByRole('button', { name: /English/ }) - expect(trigger.getAttribute('aria-expanded')).toBe('false') - fireEvent.click(trigger) - expect(trigger.getAttribute('aria-expanded')).toBe('true') - fireEvent.click(screen.getByRole('menuitem', { name: '中文' })) - expect(b.setLocale).toHaveBeenCalledWith('zh') - expect(trigger.getAttribute('aria-expanded')).toBe('false') - expect(screen.queryByRole('menuitem', { name: '中文' })).toBeNull() - }) - - it('closes the language menu on outside pointerdown without selecting', () => { - const b = mount({ active: 'en' }) - const trigger = screen.getByRole('button', { name: /English/ }) - fireEvent.click(trigger) - expect(screen.getByRole('menuitem', { name: '中文' })).toBeDefined() - fireEvent.pointerDown(document.body) - expect(trigger.getAttribute('aria-expanded')).toBe('false') - expect(screen.queryByRole('menuitem', { name: '中文' })).toBeNull() - expect(b.setLocale).not.toHaveBeenCalled() - }) - - it('reflects a store locale change in the trigger label (unknown id falls back to the id)', () => { - const b = mount({ active: 'en' }) - act(() => { b.store.actions.syncLocale('zh', LOCALES, 1) }) - expect(screen.getByRole('button', { name: /中文/ })).toBeDefined() - act(() => { b.store.actions.syncLocale('fr', LOCALES, 2) }) - expect(screen.getByRole('button', { name: /fr/ })).toBeDefined() - }) - - it('marks the appearance cube matching the preference and switches on click', () => { - const b = mount({ preference: 'dark' }) - expect(pressed(/Dark/)).toBe('true') - expect(pressed(/Light/)).toBe('false') - expect(pressed(/System/)).toBe('false') - fireEvent.click(screen.getByRole('button', { name: /Light/ })) - expect(b.setTheme).toHaveBeenCalledWith('light') - // Selection follows the store mirror, not the click echo. - act(() => { b.store.actions.syncTheme('light', 1) }) - expect(pressed(/Light/)).toBe('true') - expect(pressed(/Dark/)).toBe('false') - }) -}) diff --git a/packages/client/ui-settings-general/tests/invariant.spec.ts b/packages/client/ui-settings-general/tests/invariant.spec.ts deleted file mode 100644 index 7b0527c0ff..0000000000 --- a/packages/client/ui-settings-general/tests/invariant.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' -import * as GeneralInvariant from '@deepseek-ai/dsh-client-ui-settings-general/invariant' -import InvariantService from '@deepseek-ai/dsh-invariants' - -describe('invariant companion', () => { - it('registers under the package name with an empty installer', async () => { - const ctx = new Context() - await ctx.plugin(InvariantService, { enabled: true }) - await expect(ctx.plugin(GeneralInvariant).await()).resolves.toBeDefined() - }) - - it('node-half apply is a no-op host placeholder', async () => { - const { apply } = await import('@deepseek-ai/dsh-client-ui-settings-general') - apply() - expect(true).toBe(true) // reaching here without throw is the contract - }) -}) diff --git a/packages/client/ui-settings-general/tests/store.spec.ts b/packages/client/ui-settings-general/tests/store.spec.ts deleted file mode 100644 index b291418471..0000000000 --- a/packages/client/ui-settings-general/tests/store.spec.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** General settings store: snapshot-mirror actions and the revision guard. */ -import { describe, expect, it } from 'vitest' -import { createGeneralSettingsStore } from '../src/client/store.ts' - -const LOCALES = [{ id: 'zh', label: '中文' }, { id: 'en', label: 'English' }] - -describe('createGeneralSettingsStore', () => { - it('init shape: empty mirrors with revisions at -1', () => { - const store = createGeneralSettingsStore().create() - expect(store.getSnapshot()).toEqual({ - localeActive: '', - localeOptions: [], - localeRevision: -1, - themePreference: 'system', - themeRevision: -1, - }) - }) - - it('syncLocale mirrors the snapshot and advances the revision', () => { - const store = createGeneralSettingsStore().create() - store.actions.syncLocale('zh', LOCALES, 0) - expect(store.getSnapshot().localeActive).toBe('zh') - expect(store.getSnapshot().localeOptions).toEqual(LOCALES) - expect(store.getSnapshot().localeRevision).toBe(0) - - store.actions.syncLocale('en', LOCALES, 1) - expect(store.getSnapshot().localeActive).toBe('en') - expect(store.getSnapshot().localeRevision).toBe(1) - }) - - it('syncLocale revision guard drops stale and duplicate writes', () => { - const store = createGeneralSettingsStore().create() - store.actions.syncLocale('en', LOCALES, 5) - // Stale (lower) and duplicate (equal) revisions leave the mirror intact. - store.actions.syncLocale('zh', LOCALES, 4) - store.actions.syncLocale('zh', LOCALES, 5) - expect(store.getSnapshot().localeActive).toBe('en') - expect(store.getSnapshot().localeRevision).toBe(5) - }) - - it('syncTheme mirrors the preference and guards its revision independently', () => { - const store = createGeneralSettingsStore().create() - store.actions.syncTheme('dark', 0) - expect(store.getSnapshot().themePreference).toBe('dark') - expect(store.getSnapshot().themeRevision).toBe(0) - - store.actions.syncTheme('light', 2) - expect(store.getSnapshot().themePreference).toBe('light') - - // Stale theme write is dropped; the locale revision axis is untouched. - store.actions.syncTheme('system', 1) - expect(store.getSnapshot().themePreference).toBe('light') - expect(store.getSnapshot().themeRevision).toBe(2) - expect(store.getSnapshot().localeRevision).toBe(-1) - }) -}) diff --git a/packages/client/ui-settings-general/tsconfig.json b/packages/client/ui-settings-general/tsconfig.json deleted file mode 100644 index 9b2cc5b838..0000000000 --- a/packages/client/ui-settings-general/tsconfig.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "extends": "../../../tsconfig.base.client.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib/types" - }, - "include": [ - "src" - ], - "references": [ - { - "path": "../../../vendor/cordis" - }, - { - "path": "../ui-slots" - }, - { - "path": "../ui-primitives" - }, - { - "path": "../runtime" - }, - { - "path": "../ui-settings" - }, - { - "path": "../locale" - }, - { - "path": "../ui-theme" - }, - { - "path": "../../support/invariants" - } - ] -} diff --git a/packages/client/ui-settings-general/tsdown.config.ts b/packages/client/ui-settings-general/tsdown.config.ts deleted file mode 100644 index bf67c4f10f..0000000000 --- a/packages/client/ui-settings-general/tsdown.config.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { clientBundle } from '../tsdown.client.ts' - -export default clientBundle('@deepseek-ai/dsh-client-ui-settings-general', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/client/ui-settings-models/tsdown.config.ts b/packages/client/ui-settings-models/tsdown.config.ts deleted file mode 100644 index 7a2688a097..0000000000 --- a/packages/client/ui-settings-models/tsdown.config.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { clientBundle } from '../tsdown.client.ts' - -export default clientBundle('@deepseek-ai/dsh-client-ui-settings-models', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/client/ui-settings-general/src/client/GeneralSection.module.css b/packages/client/ui-settings/src/client/GeneralSection.module.css similarity index 75% rename from packages/client/ui-settings-general/src/client/GeneralSection.module.css rename to packages/client/ui-settings/src/client/GeneralSection.module.css index dffafc419d..5e053a49c5 100644 --- a/packages/client/ui-settings-general/src/client/GeneralSection.module.css +++ b/packages/client/ui-settings/src/client/GeneralSection.module.css @@ -1,6 +1,7 @@ -/* General section rows (figma 501:29983 'Options'): four groups, 16px - * vertical padding each, hairline separator under all but the last. The - * shell's content column owns the outer horizontal padding. */ +/* General section rows (figma 501:29983 'Options'): stacked groups, 16px + * vertical padding each, hairline separator under all but the last child + * (feature-contributed rows carry their own row chrome and separators; the + * :last-child rule strips the trailing one wherever the column ends). */ .section { display: flex; @@ -8,6 +9,10 @@ width: 100%; } +.section > :last-child { + border-bottom: none; +} + /* Title + trailing control row (figma 'Setting-Cell': gap 8, pad 16/0). */ .row { display: flex; @@ -26,10 +31,6 @@ border-bottom: 1px solid var(--dsw-alias-border-l2); } -.last { - border-bottom: none; -} - /* Leading text column (figma 'Frame 2036083120': gap 4, pad-right 48). */ .rowText { flex: 1; @@ -79,7 +80,7 @@ flex: none; } -/* Cube rows share an 8px gap; cubes stretch to equal height. */ +/* Tool Call mode cubes share an 8px gap. */ .cubeRow { display: flex; align-items: stretch; @@ -102,27 +103,6 @@ text-align: left; } -/* Appearance cube (figma '.Selector Cube' 276x82 r16, pad 20/32, centered - * icon-over-label column, gap 4). */ -.themeCube { - box-sizing: border-box; - width: 276px; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 4px; - padding: 20px 32px; - border: 1px solid var(--dsw-alias-border-l2); - border-radius: 16px; - background: transparent; - font: inherit; - font-size: 14px; - line-height: 22px; - color: var(--dsw-alias-label-primary); - cursor: pointer; -} - /* Selected cube: #F5F6F7 fill + #ADB2B8 border (static token — the bluish-400 * step has no alias-layer name). */ .selected { diff --git a/packages/client/ui-settings/src/client/GeneralSection.tsx b/packages/client/ui-settings/src/client/GeneralSection.tsx new file mode 100644 index 0000000000..31714a9238 --- /dev/null +++ b/packages/client/ui-settings/src/client/GeneralSection.tsx @@ -0,0 +1,51 @@ +/** + * Shell-owned General section (figma 501:29983 'Options'): Permission and + * Tool Call skeleton rows, then the feature-contributed preference rows from + * the `settings.general.item` slot (locale → Language, ui-theme → + * Appearance). The section column stacks rows; each row draws its own + * internals and separator. + */ +import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' +import type { GeneralSectionComponentProps } from './contract/slots.ts' +import css from './GeneralSection.module.css' + +/** + * Render the General section content column. + * @param props - composed slot props (contract/slots.ts). + * @returns the section element tree. + */ +export function GeneralSection({ t, renderSlot }: GeneralSectionComponentProps) { + return ( +
+ {/* Permission (skeleton): disabled selector pill. */} +
+
+
{t('permission.title')}
+
{t('permission.desc')}
+
+ +
+ + {/* Tool Call (skeleton): schema cube pinned selected, code cube unselected. */} +
+
{t('toolcall.title')}
+
+
+
{t('toolcall.schema.title')}
+
{t('toolcall.schema.desc')}
+
+
+
{t('toolcall.code.title')}
+
{t('toolcall.code.desc')}
+
+
+
+ + {/* Feature-owned preference rows (Language, Appearance, …). */} + {renderSlot('settings.general.item', {})} +
+ ) +} diff --git a/packages/client/ui-settings/src/client/contract/slots.ts b/packages/client/ui-settings/src/client/contract/slots.ts index 10e4e0ecbe..87db2f8999 100644 --- a/packages/client/ui-settings/src/client/contract/slots.ts +++ b/packages/client/ui-settings/src/client/contract/slots.ts @@ -1,7 +1,11 @@ /** - * Settings shell slot contract: the shell occupies the sidebar-owned - * `sidebar.settings` hole and declares the `settings.section` list slot that - * section plugins (General, Models, …) contribute pages into. + * Settings shell slot contract. The shell occupies the sidebar-owned + * `sidebar.settings` hole, declares the `settings.section` list slot that + * feature plugins contribute top-level pages into, and ships the first + * section itself: General, whose `settings.general.item` list slot receives + * preference rows from the features that own them (locale → Language, + * ui-theme → Appearance). A feature owns its settings surface — adding a + * setting never means editing the shell. */ import type { PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' // Type-only: pulls ui-sidebar's SlotMap merge (the 'sidebar.settings' entry) @@ -19,6 +23,16 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { * re-render trigger). Sections render inside the panel content column. */ 'settings.section': { kind: 'list'; scope: 'root'; owner: SettingsSectionOwnerProps } + /** + * One preference row inside the General section, contributed by the + * feature plugin that owns the preference (locale → Language, ui-theme → + * Appearance). Options: `id` (row key), `order` (row position). Rows + * draw their own internals (row layout, separators via CSS); the section + * column only stacks them. NOTE: packages/client/locale and ui-theme + * repeat this entry verbatim (reference-cycle avoidance) — declaration + * merging enforces the copies stay identical; edit all three together. + */ + 'settings.general.item': { kind: 'list'; scope: 'root'; owner: { children?: never } } } } @@ -60,3 +74,21 @@ export type SettingsRootInjected = { */ export type SettingsRootComponentProps = PropsRuntime<'sidebar.settings'> & PropsRenderSlots<'settings.section'> & SettingsRootInjected + +/** + * Injected share of the shell-owned General section: the shell's own + * `settings` namespace translate function for the skeleton rows (Permission, + * Tool Call). Live preference rows arrive through the item slot with their + * own faces. + */ +export type GeneralSectionInjected = { + /** Translate a `settings` dictionary key to the active-locale text. */ + t: (key: string) => string +} + +/** + * Full component props of the shell-owned General section: the section owner + * share, the declared item render share, and the injected face. + */ +export type GeneralSectionComponentProps = + PropsRuntime<'settings.section'> & PropsRenderSlots<'settings.general.item'> & GeneralSectionInjected diff --git a/packages/client/ui-settings/src/client/index.ts b/packages/client/ui-settings/src/client/index.ts index 3613476ede..c360a84201 100644 --- a/packages/client/ui-settings/src/client/index.ts +++ b/packages/client/ui-settings/src/client/index.ts @@ -1,17 +1,24 @@ /** * Settings shell plugin, browser half. Occupies the sidebar-owned * `sidebar.settings` hole with the trigger row + modal panel, declares the - * `settings.section` list slot, and projects that ledger into the panel - * navigation. Export discipline: packages/client/AGENTS.md. + * `settings.section` list slot, projects that ledger into the panel + * navigation, and ships the first section itself: General, which declares + * the `settings.general.item` slot that feature plugins contribute + * preference rows into. Export discipline: packages/client/AGENTS.md. */ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' // Type-only: pulls the locale plugin's Context/Events merges (ctx.locale, // 'locale/change') into this program. import type {} from '@deepseek-ai/dsh-client-locale/client' -import type { SettingsRootInjected } from './contract/slots.ts' +import type { GeneralSectionInjected, SettingsRootInjected } from './contract/slots.ts' import { SettingsRoot } from './SettingsRoot.tsx' +import { GeneralSection } from './GeneralSection.tsx' +import { en, zh } from './locales.ts' -export type { SettingsRootComponentProps, SettingsRootInjected, SettingsSectionOwnerProps } from './contract/slots.ts' +export type { + GeneralSectionComponentProps, GeneralSectionInjected, + SettingsRootComponentProps, SettingsRootInjected, SettingsSectionOwnerProps, +} from './contract/slots.ts' /** * Required services (cordis fiber inject). The target slot is declared by @@ -22,18 +29,20 @@ export type { SettingsRootComponentProps, SettingsRootInjected, SettingsSectionO export const inject = ['slots', 'locale'] /** - * Register the settings shell into `sidebar.settings` once the declaration is - * on the ledger. + * Register the settings shell into `sidebar.settings` and the shell-owned + * General section into `settings.section`, each once its declaration is on + * the ledger. * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { ctx.effect(() => { const disposers = [ - ctx.locale.register('settings', 'zh', { trigger: '设置', title: '设置', close: '关闭' }), - ctx.locale.register('settings', 'en', { trigger: 'Settings', title: 'Settings', close: 'Close' }), + ctx.locale.register('settings', 'zh', zh), + ctx.locale.register('settings', 'en', en), ] return () => { for (const dispose of disposers) dispose() } }, 'ui-settings: shell copy dictionaries') + const injected = (): SettingsRootInjected => ({ translate: (ref) => { const colon = ref.indexOf(':') @@ -73,4 +82,38 @@ export function apply(ctx: ClientContext): void { dispose?.() } }, 'ui-settings: shell registration') + + // The shell's own General section: first page, declares the item slot the + // feature plugins (locale, ui-theme, …) contribute preference rows into. + // Same ledger-judged deferral; label re-registers on locale change. + const generalInjected = (): GeneralSectionInjected => ({ + t: ctx.locale.bind('settings'), + }) + ctx.effect(() => { + let dispose: (() => void) | undefined + const tryRegister = (): void => { + if (ctx.slots.spec('settings.section') === undefined) return + if (ctx.slots.entries('settings.section').some(e => e.component === GeneralSection)) return + dispose = ctx.slots.register({ + name: 'settings.section', + id: 'general', + order: 0, + label: ctx.locale.bind('settings')('general.nav'), + children: { 'settings.general.item': { kind: 'list', scope: 'root' } }, + inject: generalInjected, + }, GeneralSection) + } + const offLocale = ctx.on('locale/change', () => { + dispose?.() + dispose = undefined + tryRegister() + }) + const unsubscribe = ctx.slots.subscribe('settings.section', () => { tryRegister() }) + tryRegister() + return () => { + offLocale() + unsubscribe() + dispose?.() + } + }, 'ui-settings: general section registration') } diff --git a/packages/client/ui-settings-general/src/client/locales.ts b/packages/client/ui-settings/src/client/locales.ts similarity index 64% rename from packages/client/ui-settings-general/src/client/locales.ts rename to packages/client/ui-settings/src/client/locales.ts index ff10ce5c37..6fc3295561 100644 --- a/packages/client/ui-settings-general/src/client/locales.ts +++ b/packages/client/ui-settings/src/client/locales.ts @@ -1,7 +1,9 @@ /** - * `settings.general` namespace dictionaries. Skeleton-row technical copy + * `settings` namespace dictionaries: shell chrome plus the shell-owned + * General section (nav label, skeleton rows). Skeleton-row technical copy * (Read only / Schema mode / Code mode and their descriptions) is shared - * verbatim across locales per the Figma design. + * verbatim across locales per the Figma design. Feature-owned rows + * (Language, Appearance) ship their copy in their own packages. */ import type { LocaleDict } from '@deepseek-ai/dsh-client-locale/client' @@ -16,27 +18,23 @@ const SHARED = { /** Simplified Chinese dictionary. */ export const zh: LocaleDict = { ...SHARED, - 'nav': '通用设置', + 'trigger': '设置', + 'title': '设置', + 'close': '关闭', + 'general.nav': '通用设置', 'permission.title': '权限', 'permission.desc': '选择默认权限模式', 'toolcall.title': '工具调用', - 'language.title': '语言', - 'appearance.title': '外观', - 'appearance.light': '浅色', - 'appearance.dark': '深色', - 'appearance.system': '跟随系统', } /** English dictionary. */ export const en: LocaleDict = { ...SHARED, - 'nav': 'General', + 'trigger': 'Settings', + 'title': 'Settings', + 'close': 'Close', + 'general.nav': 'General', 'permission.title': 'Permission', 'permission.desc': 'Choose default permission mode', 'toolcall.title': 'Tool Call', - 'language.title': 'Language', - 'appearance.title': 'Appearance', - 'appearance.light': 'Light', - 'appearance.dark': 'Dark', - 'appearance.system': 'System', } diff --git a/packages/client/ui-settings/tests/apply.spec.ts b/packages/client/ui-settings/tests/apply.spec.ts index a94406e4ce..95684404a1 100644 --- a/packages/client/ui-settings/tests/apply.spec.ts +++ b/packages/client/ui-settings/tests/apply.spec.ts @@ -4,8 +4,9 @@ import { describe, expect, it, vi } from 'vitest' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings/client' -import type { SettingsRootInjected } from '@deepseek-ai/dsh-client-ui-settings/client' +import type { GeneralSectionInjected, SettingsRootInjected } from '@deepseek-ai/dsh-client-ui-settings/client' import { SettingsRoot } from '../src/client/SettingsRoot.tsx' +import { GeneralSection } from '../src/client/GeneralSection.tsx' async function bench() { const ctx = new Context() @@ -77,11 +78,14 @@ describe('ui-settings apply', () => { declare(b.slots) await b.ctx.plugin({ inject: [...inject], apply }).await() const injected = injectedOf(b.slots) - expect(injected.sections()).toEqual([]) + // The shell ships its own General section (order 0) — the ledger is never + // empty once apply settles. + expect(injected.sections()).toEqual([{ id: 'general', order: 0, label: '通用设置' }]) b.slots.register({ name: 'settings.section', id: 'z', order: 20, label: 'Z' } as never, () => null) - b.slots.register({ name: 'settings.section', id: 'a' } as never, () => null) + b.slots.register({ name: 'settings.section', id: 'a', order: 5 } as never, () => null) expect(injected.sections()).toEqual([ - { id: 'a', order: 0, label: '' }, + { id: 'general', order: 0, label: '通用设置' }, + { id: 'a', order: 5, label: '' }, { id: 'z', order: 20, label: 'Z' }, ]) expect(injected.sectionsVersion()).toBe(b.slots.getVersion('settings.section')) @@ -118,3 +122,72 @@ describe('ui-settings apply', () => { expect(b.slots.spec('settings.section')).toBeUndefined() }) }) + +describe('ui-settings general section', () => { + it('registers the shell-owned General entry and declares the item slot', async () => { + const b = await bench() + declare(b.slots) + await b.ctx.plugin({ inject: [...inject], apply }).await() + const entry = b.slots.entries('settings.section')[0]! + expect(entry.component).toBe(GeneralSection) + expect(entry.options).toEqual({ id: 'general', order: 0, label: '通用设置' }) + expect(b.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' }) + const injected = (entry.inject as () => GeneralSectionInjected)() + expect(injected.t('permission.title')).toBe('权限') + }) + + it('re-registers with fresh label text on locale change', async () => { + const b = await bench() + declare(b.slots) + await b.ctx.plugin({ inject: [...inject], apply }).await() + b.locale.setLocale('en') + expect(b.slots.entries('settings.section')[0]!.options.label).toBe('General') + b.locale.setLocale('zh') + expect(b.slots.entries('settings.section')[0]!.options.label).toBe('通用设置') + }) + + it('locale change while settings.section is undeclared stays a no-op', async () => { + const b = await bench() + // No sidebar.settings declaration: the shell never registers, so + // settings.section is never declared either. + await b.ctx.plugin({ inject: [...inject], apply }).await() + b.locale.setLocale('en') + expect(b.slots.entries('settings.section')).toHaveLength(0) + b.locale.setLocale('zh') + }) + + it('re-registers after an HMR collapse of the whole chain (stale disposer must not block)', async () => { + const b = await bench() + const redeclare = declare(b.slots) + await b.ctx.plugin({ inject: [...inject], apply }).await() + expect(b.slots.entries('settings.section')).toHaveLength(1) + // Root declarer unload: the cascade removes the shell entry, the + // settings.section declaration, and the General entry below it. + redeclare() + expect(b.slots.entries('settings.section')).toHaveLength(0) + expect(b.slots.spec('settings.general.item')).toBeUndefined() + declare(b.slots) + // Two deferral hops: the shell re-registers (re-declaring + // settings.section), then General re-registers into it. + await Promise.resolve() + await Promise.resolve() + const entry = b.slots.entries('settings.section')[0]! + expect(entry.component).toBe(GeneralSection) + expect(b.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' }) + // The recovered registration still rides the locale path. + b.locale.setLocale('en') + expect(b.slots.entries('settings.section')[0]!.options.label).toBe('General') + b.locale.setLocale('zh') + }) + + it('removes the General entry and its item declaration on teardown', async () => { + const b = await bench() + declare(b.slots) + const fiber = b.ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + expect(b.slots.spec('settings.general.item')).toBeDefined() + await fiber.dispose() + expect(b.slots.entries('settings.section')).toHaveLength(0) + expect(b.slots.spec('settings.general.item')).toBeUndefined() + }) +}) diff --git a/packages/client/ui-settings/tests/general-section.spec.tsx b/packages/client/ui-settings/tests/general-section.spec.tsx new file mode 100644 index 0000000000..a944dd8b15 --- /dev/null +++ b/packages/client/ui-settings/tests/general-section.spec.tsx @@ -0,0 +1,47 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, render, screen } from '@testing-library/react' +import type { GeneralSectionComponentProps } from '../src/client/contract/slots.ts' +import { GeneralSection } from '../src/client/GeneralSection.tsx' +import { en } from '../src/client/locales.ts' + +afterEach(cleanup) + +function mount() { + const renderSlot = vi.fn( + ((key: string) =>
) as GeneralSectionComponentProps['renderSlot'], + ) + const props: GeneralSectionComponentProps = { + t: (key) => en[key] ?? key, + renderSlot, + } + const view = render() + return { view, renderSlot } +} + +describe('GeneralSection', () => { + it('renders the Permission skeleton row with the disabled selector', () => { + mount() + expect(screen.getByText('Permission')).toBeTruthy() + expect(screen.getByText('Choose default permission mode')).toBeTruthy() + const selector = screen.getByRole('button', { name: /Read only/ }) as HTMLButtonElement + expect(selector.disabled).toBe(true) + }) + + it('renders the Tool Call skeleton cubes with schema pinned selected', () => { + mount() + expect(screen.getByText('Tool Call')).toBeTruthy() + const schema = screen.getByText('Schema mode') + const code = screen.getByText('Code mode') + expect(schema.parentElement!.className).toContain('selected') + expect(code.parentElement!.className).not.toContain('selected') + expect(screen.getByText('Traditional function calling — invoke tools one at a time')).toBeTruthy() + expect(screen.getByText('Chain multiple tools with code — multi-step orchestration')).toBeTruthy() + }) + + it('renders the feature-contributed item slot after the skeleton rows', () => { + const { renderSlot } = mount() + expect(renderSlot).toHaveBeenCalledWith('settings.general.item', {}) + expect(screen.getByTestId('slot-settings.general.item')).toBeTruthy() + }) +}) diff --git a/packages/client/ui-theme/package.json b/packages/client/ui-theme/package.json index 4c6ad92cd4..e60289b52d 100644 --- a/packages/client/ui-theme/package.json +++ b/packages/client/ui-theme/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-client-ui-theme", - "description": "Theme plugin: ThemeService (light/dark/system preference, prefers-color-scheme resolution, theme/change snapshots; no DOM), --dsw-* token base stylesheets", + "description": "Theme plugin: ThemeService (light/dark/system preference, prefers-color-scheme resolution, theme/change snapshots; no DOM), --dsw-* token base stylesheets; registers the Appearance settings row", "version": "0.0.1", "private": true, "type": "module", @@ -24,18 +24,32 @@ "./package.json": "./package.json" }, "dshClient": { - "inject": [], + "inject": [ + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-locale" + ], "platform": "web", "immediately": true }, "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-client-locale": "^0.0.1", + "@deepseek-ai/dsh-client-runtime": "^0.0.1", + "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", + "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "cordis": "^4.0.0-rc.7", + "react": "^18.2.0" }, "devDependencies": { + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@types/react": "~18.3.1", + "cordis": "^4.0.0-rc.7", + "react": "^18.2.0" }, "files": [ "lib/index.js", @@ -48,5 +62,8 @@ "scripts": { "bundle": "tsdown", "watch": "tsdown --watch" + }, + "dependencies": { + "clsx": "^2.0.0" } } diff --git a/packages/client/ui-theme/src/client/AppearanceRow.module.css b/packages/client/ui-theme/src/client/AppearanceRow.module.css new file mode 100644 index 0000000000..9619caa823 --- /dev/null +++ b/packages/client/ui-theme/src/client/AppearanceRow.module.css @@ -0,0 +1,51 @@ +/* Appearance row (figma 'Frame 2117131228': title + cube row, column gap 8, + * pad 16/0, hairline separator; the section column strips it when last). */ + +.group { + display: flex; + flex-direction: column; + gap: 8px; + padding: 16px 0; + border-bottom: 1px solid var(--dsw-alias-border-l2); +} + +.title { + font-size: 14px; + font-weight: 400; + line-height: 22px; + color: var(--dsw-alias-label-primary); +} + +.cubeRow { + display: flex; + align-items: stretch; + gap: 8px; +} + +/* Appearance cube (figma '.Selector Cube' 276x82 r16, pad 20/32, centered + * icon-over-label column, gap 4). */ +.themeCube { + box-sizing: border-box; + width: 276px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + padding: 20px 32px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 16px; + background: transparent; + font: inherit; + font-size: 14px; + line-height: 22px; + color: var(--dsw-alias-label-primary); + cursor: pointer; +} + +/* Selected cube: #F5F6F7 fill + #ADB2B8 border (static token — the bluish-400 + * step has no alias-layer name). */ +.selected { + background: var(--dsw-alias-bg-module-platform); + border-color: var(--dsw-static-neutral-bluish-400); +} diff --git a/packages/client/ui-theme/src/client/AppearanceRow.tsx b/packages/client/ui-theme/src/client/AppearanceRow.tsx new file mode 100644 index 0000000000..b4aad1725d --- /dev/null +++ b/packages/client/ui-theme/src/client/AppearanceRow.tsx @@ -0,0 +1,63 @@ +/** + * Appearance preference row registered into the General section item slot + * (figma 501:30012 'Frame 2117131228'): title + three preference cubes. + * Registered by this package — the theme feature owns its own settings + * surface. Selection follows the persisted preference, never the resolved + * active theme. + */ +import clsx from 'clsx' +import { + IconDarkOutline16, IconFollowsystemOutline16, IconLightOutline16, +} from '@deepseek-ai/dsh-client-ui-primitives' +import type { PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' +import type { ThemePreference } from './index.ts' +import type {} from './settings-contract.ts' +import type { createAppearanceRowStore } from './settings-store.ts' +import css from './AppearanceRow.module.css' + +/** Injected business face: namespace-bound translate + the preference write. */ +export interface AppearanceRowInjected { + /** Translate a `settings.theme` dictionary key to the active-locale text. */ + t: (key: string) => string + /** Switch the theme preference. */ + setTheme: (id: ThemePreference) => void +} + +/** Full component props: runtime share + store share + injected face. */ +export type AppearanceRowComponentProps = + PropsRuntime<'settings.general.item'> & PropsStore> & AppearanceRowInjected + +/** Cube order and icons (figma 501:30015-30017: Light, Dark, System). */ +const CUBES: readonly { id: ThemePreference; labelKey: string; Icon: typeof IconLightOutline16 }[] = [ + { id: 'light', labelKey: 'appearance.light', Icon: IconLightOutline16 }, + { id: 'dark', labelKey: 'appearance.dark', Icon: IconDarkOutline16 }, + { id: 'system', labelKey: 'appearance.system', Icon: IconFollowsystemOutline16 }, +] + +/** + * Render the Appearance row. + * @param props - composed slot props. + * @returns the row element tree. + */ +export function AppearanceRow({ t, setTheme, useStore }: AppearanceRowComponentProps) { + const preference = useStore(s => s.preference) + return ( +
+
{t('appearance.title')}
+
+ {CUBES.map(({ id, labelKey, Icon }) => ( + + ))} +
+
+ ) +} diff --git a/packages/client/ui-theme/src/client/index.ts b/packages/client/ui-theme/src/client/index.ts index 5c7ca3fe12..ac79913062 100644 --- a/packages/client/ui-theme/src/client/index.ts +++ b/packages/client/ui-theme/src/client/index.ts @@ -2,9 +2,24 @@ * Browser theme registry over the `--dsw-*` token stylesheets. The service * owns the theme preference (light/dark/system), resolves `system` through * `prefers-color-scheme`, and publishes immutable snapshots; it never touches - * the DOM — ui-layout's presenter consumes the resolved snapshot. + * the DOM — ui-layout's presenter consumes the resolved snapshot. The plugin + * also registers the Appearance preference row into the settings General + * section — the theme feature owns its own settings surface. */ import type { Context } from 'cordis' +import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots' +import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +// Type-only: pulls the locale plugin's Context merge (ctx.locale). +import type {} from '@deepseek-ai/dsh-client-locale/client' +import type { AppearanceRowInjected } from './AppearanceRow.tsx' +import { AppearanceRow } from './AppearanceRow.tsx' +import { createAppearanceRowStore } from './settings-store.ts' + +export type { AppearanceRowComponentProps, AppearanceRowInjected } from './AppearanceRow.tsx' +export type { AppearanceRowState } from './settings-store.ts' + +/** Namespace owning this feature's settings-row copy. */ +export const SETTINGS_NS = 'settings.theme' /** Theme token dictionary: --dsw-alias-* overrides keyed by variable name. */ export type ThemeTokens = Record @@ -200,13 +215,75 @@ function persistPreference(preference: ThemePreference): void { } } -/** Required services (none; the loader passes the export surface as an object plugin). */ -export const inject: string[] = [] +/** Required services: slots + locale (the feature registers its own settings row with localized copy). */ +export const inject = ['slots', 'locale'] /** - * Client plugin body: provide the theme service. + * Client plugin body: provide the theme service and register the + * feature-owned Appearance preference row into the General section's item + * slot (a feature owns its settings surface). * @param ctx - client cordis context. */ -export function apply(ctx: Context): void { - ctx.provide('theme', new ThemeService(ctx)) +export function apply(ctx: ClientContext): void { + const theme = new ThemeService(ctx) + ctx.provide('theme', theme) + + ctx.effect(() => { + const disposers = [ + ctx.locale.register(SETTINGS_NS, 'zh', { + 'appearance.title': '外观', + 'appearance.light': '浅色', + 'appearance.dark': '深色', + 'appearance.system': '跟随系统', + }), + ctx.locale.register(SETTINGS_NS, 'en', { + 'appearance.title': 'Appearance', + 'appearance.light': 'Light', + 'appearance.dark': 'Dark', + 'appearance.system': 'System', + }), + ] + return () => { for (const dispose of disposers) dispose() } + }, 'ui-theme: settings row dictionaries') + + const store = createAppearanceRowStore() + let bound: BoundActions | undefined + const sync = (snapshot: ThemeSnapshot): void => { + bound?.sync(snapshot.preference, snapshot.revision) + } + ctx.on('theme/change', sync) + const injected = (actions: BoundActions): AppearanceRowInjected => { + bound = actions + // Re-sync from the getter so no event is lost between registration and + // first render (the store's revision guard drops stale duplicates). + sync(theme.getTheme()) + return { + t: ctx.locale.bind(SETTINGS_NS), + setTheme: (id) => { theme.setTheme(id) }, + } + } + // Declaration-aware registration; the LEDGER is the has-registered judge + // (not a local flag): after an HMR collapse re-declares the slot, the + // cascade already removed our entry, and a stale disposer must not block + // the re-registration. + ctx.effect(() => { + let dispose: (() => void) | undefined + const tryRegister = (): void => { + if (ctx.slots.spec('settings.general.item') === undefined) return + if (ctx.slots.entries('settings.general.item').some(e => e.component === AppearanceRow)) return + dispose = ctx.slots.register({ + name: 'settings.general.item', + id: 'appearance', + order: 10, + store, + inject: injected, + }, AppearanceRow) + } + const unsubscribe = ctx.slots.subscribe('settings.general.item', () => { tryRegister() }) + tryRegister() + return () => { + unsubscribe() + dispose?.() + } + }, 'ui-theme: appearance settings row registration') } diff --git a/packages/client/ui-theme/src/client/settings-contract.ts b/packages/client/ui-theme/src/client/settings-contract.ts new file mode 100644 index 0000000000..4354ca8728 --- /dev/null +++ b/packages/client/ui-theme/src/client/settings-contract.ts @@ -0,0 +1,17 @@ +/** + * Settings-surface slot merge consumed by this package's Appearance row. The + * AUTHORITATIVE home for 'settings.general.item' is the ui-settings contract + * (declaring is claiming: the shell's General entry declares the slot); this + * file repeats the entry verbatim because the settings shell sits above the + * feature layer, so importing its types from here would invert the layering. + * TypeScript declaration merging rejects diverging duplicates, so every + * program that sees both copies enforces identity. + */ +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface SlotMap { + /** One preference row inside the General section (duplicate-identical merge; authority: ui-settings contract). */ + 'settings.general.item': { kind: 'list'; scope: 'root'; owner: { children?: never } } + } +} + +export {} diff --git a/packages/client/ui-theme/src/client/settings-store.ts b/packages/client/ui-theme/src/client/settings-store.ts new file mode 100644 index 0000000000..256b04a299 --- /dev/null +++ b/packages/client/ui-theme/src/client/settings-store.ts @@ -0,0 +1,37 @@ +/** + * Appearance row slot store: a mirror of the theme service snapshot. The + * plugin's apply-world change listener is the only writer; the row component + * reads via props.useStore. + */ +import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-runtime/client' +import type { ThemePreference } from './index.ts' + +/** Store state mirrored from the theme snapshot. */ +export interface AppearanceRowState { + /** Persisted preference (selection state reads this, never the resolved active theme). */ + preference: ThemePreference + /** Service revision; -1 until first sync so revision 0 lands as a change. */ + revision: number +} + +/** Declared action shape giving the exported factory a stable return type. */ +type AppearanceRowActions = { + sync: (draft: AppearanceRowState, preference: ThemePreference, revision: number) => void +} + +/** + * Declares the Appearance row state and write surface. + * @returns the store handle. + */ +export function createAppearanceRowStore(): EngineStoreHandle { + return defineStore({ + init: (): AppearanceRowState => ({ preference: 'system', revision: -1 }), + actions: { + sync: (d, preference: ThemePreference, revision: number) => { + if (revision <= d.revision) return + d.preference = preference + d.revision = revision + }, + }, + }) +} diff --git a/packages/client/ui-theme/src/css-modules.d.ts b/packages/client/ui-theme/src/css-modules.d.ts new file mode 100644 index 0000000000..bc5e482353 --- /dev/null +++ b/packages/client/ui-theme/src/css-modules.d.ts @@ -0,0 +1,6 @@ +declare module '*.module.css' { + const classes: Record + export default classes +} + +declare module '*.css' diff --git a/packages/client/ui-theme/tests/invariant.spec.ts b/packages/client/ui-theme/tests/invariant.spec.ts index 6f4e867c4d..640599ea43 100644 --- a/packages/client/ui-theme/tests/invariant.spec.ts +++ b/packages/client/ui-theme/tests/invariant.spec.ts @@ -4,6 +4,8 @@ import { Context } from 'cordis' import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-theme' import { apply as clientApply, inject, ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client' import * as ThemeInvariant from '@deepseek-ai/dsh-client-ui-theme/invariant' +import { apply as localeApply } from '@deepseek-ai/dsh-client-locale/client' +import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import InvariantService from '@deepseek-ai/dsh-invariants' describe('invariant companion', () => { @@ -18,9 +20,13 @@ describe('invariant companion', () => { expect(true).toBe(true) // reaching here without throw is the contract }) - it('client apply provides ctx.theme with no service prerequisites', async () => { - expect(inject).toEqual([]) + it('client apply provides ctx.theme over the slots/locale edges', async () => { + // The feature registers its own Appearance settings row with localized + // copy, hence the slots + locale edges. + expect(inject).toEqual(['slots', 'locale']) const ctx = new Context() + new SlotsService(ctx) + await ctx.plugin({ inject: ['slots'], apply: localeApply }).await() await ctx.plugin({ inject, apply: clientApply }).await() expect(ctx.get('theme')).toBeInstanceOf(ThemeService) }) diff --git a/packages/client/ui-theme/tsconfig.json b/packages/client/ui-theme/tsconfig.json index 51f9171643..7d5cc6f235 100644 --- a/packages/client/ui-theme/tsconfig.json +++ b/packages/client/ui-theme/tsconfig.json @@ -8,6 +8,18 @@ "src" ], "references": [ + { + "path": "../locale" + }, + { + "path": "../runtime" + }, + { + "path": "../ui-primitives" + }, + { + "path": "../ui-slots" + }, { "path": "../../../vendor/cordis" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2410252eb0..40fee92c16 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -140,18 +140,15 @@ importers: '@deepseek-ai/dsh-client-ui-layout': specifier: workspace:^ version: link:../../packages/client/ui-layout + '@deepseek-ai/dsh-client-ui-models': + specifier: workspace:^ + version: link:../../packages/client/ui-models '@deepseek-ai/dsh-client-ui-question': specifier: workspace:^ version: link:../../packages/client/ui-question '@deepseek-ai/dsh-client-ui-settings': specifier: workspace:^ version: link:../../packages/client/ui-settings - '@deepseek-ai/dsh-client-ui-settings-general': - specifier: workspace:^ - version: link:../../packages/client/ui-settings-general - '@deepseek-ai/dsh-client-ui-settings-models': - specifier: workspace:^ - version: link:../../packages/client/ui-settings-models '@deepseek-ai/dsh-client-ui-sidebar': specifier: workspace:^ version: link:../../packages/client/ui-sidebar @@ -750,13 +747,32 @@ importers: version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) packages/client/locale: + dependencies: + clsx: + specifier: ^2.0.0 + version: 2.1.1 devDependencies: + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../runtime + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 cordis: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + react: + specifier: ^18.2.0 + version: 18.3.1 packages/client/modules: devDependencies: @@ -865,6 +881,33 @@ importers: specifier: ^18.2.0 version: 18.3.1 + packages/client/ui-models: + devDependencies: + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../runtime + '@deepseek-ai/dsh-client-ui-settings': + specifier: workspace:^ + version: link:../ui-settings + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + react: + specifier: ^18.2.0 + version: 18.3.1 + packages/client/ui-primitives: dependencies: clsx: @@ -979,70 +1022,6 @@ importers: specifier: ^18.2.0 version: 18.3.1 - packages/client/ui-settings-general: - dependencies: - clsx: - specifier: ^2.0.0 - version: 2.1.1 - devDependencies: - '@deepseek-ai/dsh-client-locale': - specifier: workspace:^ - version: link:../locale - '@deepseek-ai/dsh-client-runtime': - specifier: workspace:^ - version: link:../runtime - '@deepseek-ai/dsh-client-ui-primitives': - specifier: workspace:^ - version: link:../ui-primitives - '@deepseek-ai/dsh-client-ui-settings': - specifier: workspace:^ - version: link:../ui-settings - '@deepseek-ai/dsh-client-ui-slots': - specifier: workspace:^ - version: link:../ui-slots - '@deepseek-ai/dsh-client-ui-theme': - specifier: workspace:^ - version: link:../ui-theme - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../support/invariants - '@types/react': - specifier: ~18.3.1 - version: 18.3.31 - cordis: - specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) - react: - specifier: ^18.2.0 - version: 18.3.1 - - packages/client/ui-settings-models: - devDependencies: - '@deepseek-ai/dsh-client-locale': - specifier: workspace:^ - version: link:../locale - '@deepseek-ai/dsh-client-runtime': - specifier: workspace:^ - version: link:../runtime - '@deepseek-ai/dsh-client-ui-settings': - specifier: workspace:^ - version: link:../ui-settings - '@deepseek-ai/dsh-client-ui-slots': - specifier: workspace:^ - version: link:../ui-slots - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../support/invariants - '@types/react': - specifier: ~18.3.1 - version: 18.3.31 - cordis: - specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) - react: - specifier: ^18.2.0 - version: 18.3.1 - packages/client/ui-sidebar: dependencies: clsx: @@ -1087,13 +1066,35 @@ importers: version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/client/ui-theme: + dependencies: + clsx: + specifier: ^2.0.0 + version: 2.1.1 devDependencies: + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../runtime + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 cordis: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + react: + specifier: ^18.2.0 + version: 18.3.1 packages/client/ui-trajectory: devDependencies: diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index cb8ebb6a48..91554c5c5c 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -60,8 +60,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/client/ui-workspace': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/ui-theme': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/ui-settings': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, - 'packages/client/ui-settings-general': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, - 'packages/client/ui-settings-models': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, + 'packages/client/ui-models': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/locale': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/web': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/examples/agent-spine-demo': { kind: 'indirect', reason: 'The bundle only mounts model-facing child plugins.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index 81e0fc989c..49a502571e 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -116,8 +116,7 @@ "@deepseek-ai/dsh-client-ui-workspace": ["./packages/client/ui-workspace/src"], "@deepseek-ai/dsh-client-ui-theme": ["./packages/client/ui-theme/src"], "@deepseek-ai/dsh-client-ui-settings": ["./packages/client/ui-settings/src"], - "@deepseek-ai/dsh-client-ui-settings-general": ["./packages/client/ui-settings-general/src"], - "@deepseek-ai/dsh-client-ui-settings-models": ["./packages/client/ui-settings-models/src"], + "@deepseek-ai/dsh-client-ui-models": ["./packages/client/ui-models/src"], "@deepseek-ai/dsh-client-locale": ["./packages/client/locale/src"], "@deepseek-ai/dsh-client-web": ["./packages/client/web/src"], "@deepseek-ai/dsh-*": [ diff --git a/tsconfig.client.json b/tsconfig.client.json index abe80b8b12..55ff2d9a8c 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -39,8 +39,7 @@ { "path": "./packages/client/ui-trajectory" }, { "path": "./packages/client/ui-theme" }, { "path": "./packages/client/ui-settings" }, - { "path": "./packages/client/ui-settings-general" }, - { "path": "./packages/client/ui-settings-models" }, + { "path": "./packages/client/ui-models" }, { "path": "./packages/client/locale" }, { "path": "./packages/client/web" }, { "path": "./apps/web" } From 6ce5628bb140a40f5540eac2cbcc821ed8822cf5 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 02:51:48 +0800 Subject: [PATCH 21/32] docs: tighten PR guidance --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 63957cca16..ad79a04b10 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -111,7 +111,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, - **Testing policy** — [docs/testing.md](docs/testing.md). Every non-trivial model- or product-user-visible behavior change adds or updates a keyless snapshot through a real runnable example in the same PR; package tests, e2e-only assertions, and mock-only fixtures do not substitute for the assembled application transcript. Fixtures must replay on macOS/Linux; fix fixtures, not normalizers. - **A tool's UI render intent is part of its design**, decided up front (`generic`/`terminal`/`diff`, `locations`); presentation methods are pure functions of `args` ([cookbook](docs/cookbook/adding-a-tool.md)). - **Plan unit, e2e, and snapshot coverage** for new seams, lifecycle shapes, and transcript surfaces; missing snapshot-harness support is part of the implementation, not deferred follow-up. -- **Keep PRs coherent and merge with merge commits.** Split an independently meaningful feature or design decision into a separate or stacked PR when combining it obscures ownership, intent, or verification. Never squash/rebase or rewrite pushed branches; put a review fix on its introducing PR, then merge down the stack ([guide](docs/cookbook/responding-to-pr-review-on-a-stack.md)). +- **Keep PRs coherent; use merge commits.** Split independent features or design decisions when combining them obscures ownership, intent, or verification. Never squash, rebase, or rewrite pushed branches; fix the introducing PR, then merge down its stack ([guide](docs/cookbook/responding-to-pr-review-on-a-stack.md)). - **Label PRs:** exactly one kind (`feature`/`bug-fix`/`doc`/`testing`/`cleanup`), all matching areas; the taxonomy remains extensible for recurring distinctions. - TODO markers: `FIXME`/`TODO`/`XXX` by urgency ([semantics](docs/development.md)). - Files end with exactly one trailing newline; `git diff --cached --check` (pre-commit) gates it. From a3ee5dd8a0c2ba5ddc850b485eb07f88cb11fff1 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 26 Jul 2026 02:54:29 +0800 Subject: [PATCH 22/32] docs(gui): regenerate graphs and sync the note's English pair Generated docs follow the ui-models rename and the removed ui-settings-general package; the English note side picks up the feature-owner self-registration doctrine. --- ...-25-client-settings-locale-theme.i18n.yaml | 4 +- ...2026-07-25-client-settings-locale-theme.md | 2 +- docs/config-catalog.md | 3 +- docs/event-producer-consumer.md | 4 +- docs/module-graph.md | 41 +++++----- .../client/locale/tests/language-row.spec.tsx | 82 +++++++++++++++++++ .../locale/tests/settings-store.spec.ts | 30 +++++++ .../client/ui-settings/tests/apply.spec.ts | 6 +- .../tests/general-section.spec.tsx | 4 + .../ui-theme/tests/appearance-row.spec.tsx | 75 +++++++++++++++++ .../ui-theme/tests/settings-store.spec.ts | 28 +++++++ 11 files changed, 250 insertions(+), 29 deletions(-) create mode 100644 packages/client/locale/tests/language-row.spec.tsx create mode 100644 packages/client/locale/tests/settings-store.spec.ts create mode 100644 packages/client/ui-theme/tests/appearance-row.spec.tsx create mode 100644 packages/client/ui-theme/tests/settings-store.spec.ts diff --git a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.i18n.yaml b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.i18n.yaml index 8583642403..39d7377c54 100644 --- a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.i18n.yaml +++ b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.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 -2026-07-25-client-settings-locale-theme.md: e1245a9e1fac82fb0feb84af7a59945b17fa1daf -2026-07-25-client-settings-locale-theme.zh.md: 195b2e5ffa3556dd1b8bf2dd6ec8ae84115fbdb2 +2026-07-25-client-settings-locale-theme.md: 658e6bd3c2da39a98e476c60f10f3f51ad82e5ea +2026-07-25-client-settings-locale-theme.zh.md: dfe93a0ca1f53380c653886c73fe0c32b08d8443 diff --git a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.md b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.md index 0dfcb4ea90..658e6bd3c2 100644 --- a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.md +++ b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.md @@ -119,4 +119,4 @@ Locale ships with 中文 and English built in; `setLocale`/`setTheme` are the on ## Risks -The apply order of slot declarations and contributions is not fixed, so every new section must keep declaration-aware registration and idempotence guards. Service events may fire before a section's first render, so both the General store's init and the controller attach must align to the current snapshot from the getters. Layout must clean up the global attributes it set on unmount, and ThemeService must remove its matchMedia listener on dispose, so nothing lingers after HMR. +The apply order of slot declarations and contributions is not fixed, so every section/item registrant must keep declaration-aware registration and judge presence by the ledger, not by a local disposer. Service events may fire before a row's first render, so both a feature row store's init and the inject attach must align to the current snapshot from the getter. The duplicated merge copies of `settings.general.item` (locale, ui-theme) must stay verbatim-identical to the ui-settings canonical home — any drift means changing all three together. Layout must clean up the global attributes it set on unmount, and ThemeService must remove its matchMedia listener on dispose, so nothing lingers after HMR. diff --git a/docs/config-catalog.md b/docs/config-catalog.md index ede904e1a5..1940811d35 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2022,10 +2022,9 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-client-runtime` ([`packages/client/runtime/src/index.ts`](../packages/client/runtime/src/index.ts)) - `@deepseek-ai/dsh-client-ui-conversation` ([`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts)) - `@deepseek-ai/dsh-client-ui-layout` ([`packages/client/ui-layout/src/index.ts`](../packages/client/ui-layout/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-models` ([`packages/client/ui-models/src/index.ts`](../packages/client/ui-models/src/index.ts)) - `@deepseek-ai/dsh-client-ui-question` — requires `tools` · `userInteraction` ([`packages/client/ui-question/src/index.ts`](../packages/client/ui-question/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings` ([`packages/client/ui-settings/src/index.ts`](../packages/client/ui-settings/src/index.ts)) -- `@deepseek-ai/dsh-client-ui-settings-general` ([`packages/client/ui-settings-general/src/index.ts`](../packages/client/ui-settings-general/src/index.ts)) -- `@deepseek-ai/dsh-client-ui-settings-models` ([`packages/client/ui-settings-models/src/index.ts`](../packages/client/ui-settings-models/src/index.ts)) - `@deepseek-ai/dsh-client-ui-sidebar` ([`packages/client/ui-sidebar/src/index.ts`](../packages/client/ui-sidebar/src/index.ts)) - `@deepseek-ai/dsh-client-ui-theme` ([`packages/client/ui-theme/src/index.ts`](../packages/client/ui-theme/src/index.ts)) - `@deepseek-ai/dsh-client-ui-trajectory` ([`packages/client/ui-trajectory/src/index.ts`](../packages/client/ui-trajectory/src/index.ts)) diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 2c1e85fb3c..53cd56f4cf 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -63,8 +63,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `internal/dispatch` | - | [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`pty-local`](../packages/pty/pty-local), `runtime`, [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`workflow`](../packages/workflow/workflow) | | `internal/plugin` | - | `hmr`, `modules`, `webserver` | | `internal/status` | - | [`agent`](../packages/core/agent) | -| `locale/change` | `locale` (`emit`) | `ui-settings-general`, `ui-settings-models` | +| `locale/change` | `locale` (`emit`) | `locale`, `ui-models`, `ui-settings` | | `slots/changed` | `runtime` (`emit`) | - | -| `theme/change` | `ui-theme` (`emit`) | `ui-layout`, `ui-settings-general` | +| `theme/change` | `ui-theme` (`emit`) | `ui-layout`, `ui-theme` | Maintenance mode: generated: Cordis event declarations and producer/listener edges are resolved from the repository TypeScript Program. diff --git a/docs/module-graph.md b/docs/module-graph.md index c47bd92d83..4903e432cd 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -141,11 +141,10 @@ flowchart TD pkg_client_runtime["client-runtime"] pkg_client_ui_conversation["client-ui-conversation"] pkg_client_ui_layout["client-ui-layout"] + pkg_client_ui_models["client-ui-models"] pkg_client_ui_primitives["client-ui-primitives"] pkg_client_ui_question["client-ui-question"] pkg_client_ui_settings["client-ui-settings"] - pkg_client_ui_settings_general["client-ui-settings-general"] - pkg_client_ui_settings_models["client-ui-settings-models"] pkg_client_ui_sidebar["client-ui-sidebar"] pkg_client_ui_slots["client-ui-slots"] pkg_client_ui_theme["client-ui-theme"] @@ -228,13 +227,11 @@ flowchart TD pkg_subagent_subprocess --> pkg_invariants pkg_acp_snapshot --> pkg_invariants pkg_loader_smoke --> pkg_invariants - pkg_client_locale --> pkg_invariants pkg_client_modules --> pkg_invariants pkg_client_runtime --> pkg_invariants pkg_client_ui_primitives --> pkg_invariants pkg_client_ui_question --> pkg_invariants pkg_client_ui_slots --> pkg_invariants - pkg_client_ui_theme --> pkg_invariants pkg_client_ui_trajectory --> pkg_invariants pkg_client_web --> pkg_invariants pkg_client_web_react --> pkg_invariants @@ -250,25 +247,21 @@ flowchart TD pkg_client_hmr --> pkg_client_modules pkg_client_hmr --> pkg_host_webserver pkg_client_hmr --> pkg_invariants + pkg_client_locale --> pkg_client_runtime + pkg_client_locale --> pkg_client_ui_primitives + pkg_client_locale --> pkg_client_ui_slots + pkg_client_locale --> pkg_invariants pkg_client_ui_conversation --> pkg_client_runtime pkg_client_ui_conversation --> pkg_client_ui_primitives pkg_client_ui_conversation --> pkg_client_ui_slots pkg_client_ui_conversation --> pkg_invariants - pkg_client_ui_layout --> pkg_client_runtime - pkg_client_ui_layout --> pkg_client_ui_slots - pkg_client_ui_layout --> pkg_client_ui_theme - pkg_client_ui_layout --> pkg_invariants + pkg_client_ui_models --> pkg_client_runtime + pkg_client_ui_models --> pkg_client_ui_slots + pkg_client_ui_models --> pkg_invariants pkg_client_ui_settings --> pkg_client_runtime pkg_client_ui_settings --> pkg_client_ui_primitives pkg_client_ui_settings --> pkg_client_ui_slots pkg_client_ui_settings --> pkg_invariants - pkg_client_ui_settings_general --> pkg_client_runtime - pkg_client_ui_settings_general --> pkg_client_ui_primitives - pkg_client_ui_settings_general --> pkg_client_ui_slots - pkg_client_ui_settings_general --> pkg_invariants - pkg_client_ui_settings_models --> pkg_client_runtime - pkg_client_ui_settings_models --> pkg_client_ui_slots - pkg_client_ui_settings_models --> pkg_invariants pkg_client_ui_sidebar --> pkg_client_runtime pkg_client_ui_sidebar --> pkg_client_ui_primitives pkg_client_ui_sidebar --> pkg_client_ui_slots @@ -303,6 +296,11 @@ flowchart TD pkg_system_prompt --> pkg_scope pkg_web --> pkg_invariants pkg_web --> pkg_llm + pkg_client_ui_theme --> pkg_client_locale + pkg_client_ui_theme --> pkg_client_runtime + pkg_client_ui_theme --> pkg_client_ui_primitives + pkg_client_ui_theme --> pkg_client_ui_slots + pkg_client_ui_theme --> pkg_invariants pkg_lsp --> pkg_brand pkg_lsp --> pkg_invariants pkg_lsp --> pkg_llm @@ -355,6 +353,10 @@ flowchart TD pkg_app_boot --> pkg_invariants pkg_app_boot --> pkg_paths pkg_app_boot --> pkg_system_prompt + pkg_client_ui_layout --> pkg_client_runtime + pkg_client_ui_layout --> pkg_client_ui_slots + pkg_client_ui_layout --> pkg_client_ui_theme + pkg_client_ui_layout --> pkg_invariants pkg_code_runtime_worker --> pkg_code_runtime pkg_code_runtime_worker --> pkg_invariants pkg_code_runtime_worker --> pkg_session @@ -813,13 +815,11 @@ flowchart TD | [`subagent-subprocess`](../packages/subagent/subagent-subprocess) | `subagent` | [`invariants`](../packages/support/invariants) | | [`acp-snapshot`](../packages/support/acp-snapshot) | `support` | [`invariants`](../packages/support/invariants) | | [`loader-smoke`](../packages/support/loader-smoke) | `support` | [`invariants`](../packages/support/invariants) | -| [`client-locale`](../packages/client/locale) | `client` | [`invariants`](../packages/support/invariants) | | [`client-modules`](../packages/client/modules) | `client` | [`invariants`](../packages/support/invariants) | | [`client-runtime`](../packages/client/runtime) | `client` | [`invariants`](../packages/support/invariants) | | [`client-ui-primitives`](../packages/client/ui-primitives) | `client` | [`invariants`](../packages/support/invariants) | | [`client-ui-question`](../packages/client/ui-question) | `client` | [`invariants`](../packages/support/invariants) | | [`client-ui-slots`](../packages/client/ui-slots) | `client` | [`invariants`](../packages/support/invariants) | -| [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`invariants`](../packages/support/invariants) | | [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`invariants`](../packages/support/invariants) | | [`client-web`](../packages/client/web) | `client` | [`invariants`](../packages/support/invariants) | | [`client-web-react`](../packages/client/web-react) | `client` | [`invariants`](../packages/support/invariants) | @@ -831,11 +831,10 @@ flowchart TD | [`llm`](../packages/llm/llm) | `llm` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`client-connection`](../packages/client/connection) | `client` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | +| [`client-locale`](../packages/client/locale) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) | +| [`client-ui-models`](../packages/client/ui-models) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-settings-models`](../packages/client/ui-settings-models) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | @@ -848,6 +847,7 @@ flowchart TD | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`web`](../packages/web/web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | +| [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`token-meter`](../packages/llm/token-meter) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | @@ -865,6 +865,7 @@ flowchart TD | [`session-title`](../packages/session-title/session-title) | `session-title` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`llm-replay`](../packages/support/llm-replay) | `support` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`app-boot`](../packages/ui/app-boot) | `ui` | [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`system-prompt`](../packages/core/system-prompt) | +| [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) | | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`timeout`](../packages/util/timeout) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | diff --git a/packages/client/locale/tests/language-row.spec.tsx b/packages/client/locale/tests/language-row.spec.tsx new file mode 100644 index 0000000000..af33038970 --- /dev/null +++ b/packages/client/locale/tests/language-row.spec.tsx @@ -0,0 +1,82 @@ +// @vitest-environment jsdom +/** LanguageRow behavior: selector pill shows the active locale, the menu + * opens/closes, and selection drives setLocale. */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { createSnapshotStore, type SessionListState, type WorkspaceListState } from '@deepseek-ai/dsh-client-runtime/client' +import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' +import { LanguageRow } from '../src/client/LanguageRow.tsx' +import type { LanguageRowComponentProps } from '../src/client/LanguageRow.tsx' +import { createLanguageRowStore } from '../src/client/settings-store.ts' + +afterEach(cleanup) + +const OPTIONS = [{ id: 'zh', label: '中文' }, { id: 'en', label: 'English' }] + +/** Empty global standard-kit hooks (the row reads neither). */ +function emptySessions() { + const store = createSnapshotStore( + { ids: [], byId: {}, current: undefined, intent: undefined, phase: 'ready' }) + return bindSnapshotSelector(store) +} +function emptyWorkspaces() { + const store = createSnapshotStore({ + items: [], intent: undefined, state: 'idle', phase: 'ready', error: null, + baselinesReady: true, recentWorkspaceId: undefined, + }) + return bindSnapshotSelector(store) +} + +function mount(active = 'en') { + // Real store instance — the sanctioned zero-machinery path for tests. + const store = createLanguageRowStore().create() + store.actions.sync(active, OPTIONS, 0) + const setLocale = vi.fn() + const props: LanguageRowComponentProps = { + useSessions: emptySessions(), + useWorkspaces: emptyWorkspaces(), + useStore: bindSnapshotSelector(store), + actions: store.actions, + t: (key: string) => key === 'language.title' ? 'Language' : key, + setLocale, + } + render() + return { store, setLocale } +} + +describe('LanguageRow', () => { + it('shows the title and the active locale label on the selector pill', () => { + mount('en') + expect(screen.getByText('Language')).toBeDefined() + const trigger = screen.getByRole('button', { name: /English/ }) + expect(trigger.getAttribute('aria-expanded')).toBe('false') + }) + + it('opens the menu, selects a locale, and closes', () => { + const b = mount('en') + const trigger = screen.getByRole('button', { name: /English/ }) + fireEvent.click(trigger) + expect(trigger.getAttribute('aria-expanded')).toBe('true') + fireEvent.click(screen.getByRole('menuitem', { name: '中文' })) + expect(b.setLocale).toHaveBeenCalledWith('zh') + expect(trigger.getAttribute('aria-expanded')).toBe('false') + expect(screen.queryByRole('menuitem', { name: '中文' })).toBeNull() + }) + + it('closes on outside pointerdown without selecting', () => { + const b = mount('en') + fireEvent.click(screen.getByRole('button', { name: /English/ })) + expect(screen.getByRole('menuitem', { name: '中文' })).toBeDefined() + fireEvent.pointerDown(document.body) + expect(screen.queryByRole('menuitem', { name: '中文' })).toBeNull() + expect(b.setLocale).not.toHaveBeenCalled() + }) + + it('follows store changes; an unknown active id falls back to the id itself', () => { + const b = mount('en') + act(() => { b.store.actions.sync('zh', OPTIONS, 1) }) + expect(screen.getByRole('button', { name: /中文/ })).toBeDefined() + act(() => { b.store.actions.sync('fr', OPTIONS, 2) }) + expect(screen.getByRole('button', { name: /fr/ })).toBeDefined() + }) +}) diff --git a/packages/client/locale/tests/settings-store.spec.ts b/packages/client/locale/tests/settings-store.spec.ts new file mode 100644 index 0000000000..90e9487a96 --- /dev/null +++ b/packages/client/locale/tests/settings-store.spec.ts @@ -0,0 +1,30 @@ +/** Language row store: snapshot-mirror action and the revision guard. */ +import { describe, expect, it } from 'vitest' +import { createLanguageRowStore } from '../src/client/settings-store.ts' + +const OPTIONS = [{ id: 'zh', label: '中文' }, { id: 'en', label: 'English' }] + +describe('createLanguageRowStore', () => { + it('init shape: empty mirror with revision at -1', () => { + const store = createLanguageRowStore().create() + expect(store.getSnapshot()).toEqual({ active: '', options: [], revision: -1 }) + }) + + it('sync mirrors the snapshot and advances the revision', () => { + const store = createLanguageRowStore().create() + store.actions.sync('zh', OPTIONS, 0) + expect(store.getSnapshot()).toEqual({ active: 'zh', options: OPTIONS, revision: 0 }) + store.actions.sync('en', OPTIONS, 1) + expect(store.getSnapshot().active).toBe('en') + expect(store.getSnapshot().revision).toBe(1) + }) + + it('revision guard drops stale and duplicate writes', () => { + const store = createLanguageRowStore().create() + store.actions.sync('en', OPTIONS, 5) + store.actions.sync('zh', OPTIONS, 4) + store.actions.sync('zh', OPTIONS, 5) + expect(store.getSnapshot().active).toBe('en') + expect(store.getSnapshot().revision).toBe(5) + }) +}) diff --git a/packages/client/ui-settings/tests/apply.spec.ts b/packages/client/ui-settings/tests/apply.spec.ts index 95684404a1..2346012553 100644 --- a/packages/client/ui-settings/tests/apply.spec.ts +++ b/packages/client/ui-settings/tests/apply.spec.ts @@ -82,10 +82,12 @@ describe('ui-settings apply', () => { // empty once apply settles. expect(injected.sections()).toEqual([{ id: 'general', order: 0, label: '通用设置' }]) b.slots.register({ name: 'settings.section', id: 'z', order: 20, label: 'Z' } as never, () => null) - b.slots.register({ name: 'settings.section', id: 'a', order: 5 } as never, () => null) + // No order and no label: both projection defaults apply (order 0 ties + // keep registration sequence, so 'a' lands after the General entry). + b.slots.register({ name: 'settings.section', id: 'a' } as never, () => null) expect(injected.sections()).toEqual([ { id: 'general', order: 0, label: '通用设置' }, - { id: 'a', order: 5, label: '' }, + { id: 'a', order: 0, label: '' }, { id: 'z', order: 20, label: 'Z' }, ]) expect(injected.sectionsVersion()).toBe(b.slots.getVersion('settings.section')) diff --git a/packages/client/ui-settings/tests/general-section.spec.tsx b/packages/client/ui-settings/tests/general-section.spec.tsx index a944dd8b15..ce09aabf93 100644 --- a/packages/client/ui-settings/tests/general-section.spec.tsx +++ b/packages/client/ui-settings/tests/general-section.spec.tsx @@ -11,7 +11,11 @@ function mount() { const renderSlot = vi.fn( ((key: string) =>
) as GeneralSectionComponentProps['renderSlot'], ) + // Global standard kit stubs: the section consumes neither hook. + const unusedHook = (() => { throw new Error('unused by GeneralSection') }) as never const props: GeneralSectionComponentProps = { + useSessions: unusedHook, + useWorkspaces: unusedHook, t: (key) => en[key] ?? key, renderSlot, } diff --git a/packages/client/ui-theme/tests/appearance-row.spec.tsx b/packages/client/ui-theme/tests/appearance-row.spec.tsx new file mode 100644 index 0000000000..4782b674a8 --- /dev/null +++ b/packages/client/ui-theme/tests/appearance-row.spec.tsx @@ -0,0 +1,75 @@ +// @vitest-environment jsdom +/** AppearanceRow behavior: three cubes, selection follows the persisted + * preference, clicks drive setTheme. */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { createSnapshotStore, type SessionListState, type WorkspaceListState } from '@deepseek-ai/dsh-client-runtime/client' +import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' +import { AppearanceRow } from '../src/client/AppearanceRow.tsx' +import type { AppearanceRowComponentProps } from '../src/client/AppearanceRow.tsx' +import { createAppearanceRowStore } from '../src/client/settings-store.ts' +import type { ThemePreference } from '../src/client/index.ts' + +afterEach(cleanup) + +const COPY: Record = { + 'appearance.title': 'Appearance', + 'appearance.light': 'Light', + 'appearance.dark': 'Dark', + 'appearance.system': 'System', +} + +/** Empty global standard-kit hooks (the row reads neither). */ +function emptySessions() { + const store = createSnapshotStore( + { ids: [], byId: {}, current: undefined, intent: undefined, phase: 'ready' }) + return bindSnapshotSelector(store) +} +function emptyWorkspaces() { + const store = createSnapshotStore({ + items: [], intent: undefined, state: 'idle', phase: 'ready', error: null, + baselinesReady: true, recentWorkspaceId: undefined, + }) + return bindSnapshotSelector(store) +} + +function mount(preference: ThemePreference = 'system') { + // Real store instance — the sanctioned zero-machinery path for tests. + const store = createAppearanceRowStore().create() + store.actions.sync(preference, 0) + const setTheme = vi.fn() + const props: AppearanceRowComponentProps = { + useSessions: emptySessions(), + useWorkspaces: emptyWorkspaces(), + useStore: bindSnapshotSelector(store), + actions: store.actions, + t: (key: string) => COPY[key] ?? key, + setTheme, + } + render() + return { store, setTheme } +} + +const pressed = (name: RegExp): string | null => + screen.getByRole('button', { name }).getAttribute('aria-pressed') + +describe('AppearanceRow', () => { + it('renders the title and three cubes with the preference cube selected', () => { + mount('dark') + expect(screen.getByText('Appearance')).toBeDefined() + expect(pressed(/Dark/)).toBe('true') + expect(pressed(/Light/)).toBe('false') + expect(pressed(/System/)).toBe('false') + }) + + it('click drives setTheme; selection follows the store mirror, not the click echo', () => { + const b = mount('dark') + fireEvent.click(screen.getByRole('button', { name: /Light/ })) + expect(b.setTheme).toHaveBeenCalledWith('light') + // No store write yet: selection is unchanged. + expect(pressed(/Dark/)).toBe('true') + act(() => { b.store.actions.sync('light', 1) }) + expect(pressed(/Light/)).toBe('true') + expect(pressed(/Dark/)).toBe('false') + }) +}) diff --git a/packages/client/ui-theme/tests/settings-store.spec.ts b/packages/client/ui-theme/tests/settings-store.spec.ts new file mode 100644 index 0000000000..540d0f5b3b --- /dev/null +++ b/packages/client/ui-theme/tests/settings-store.spec.ts @@ -0,0 +1,28 @@ +/** Appearance row store: snapshot-mirror action and the revision guard. */ +import { describe, expect, it } from 'vitest' +import { createAppearanceRowStore } from '../src/client/settings-store.ts' + +describe('createAppearanceRowStore', () => { + it('init shape: system preference with revision at -1', () => { + const store = createAppearanceRowStore().create() + expect(store.getSnapshot()).toEqual({ preference: 'system', revision: -1 }) + }) + + it('sync mirrors the preference and advances the revision', () => { + const store = createAppearanceRowStore().create() + store.actions.sync('dark', 0) + expect(store.getSnapshot()).toEqual({ preference: 'dark', revision: 0 }) + store.actions.sync('light', 2) + expect(store.getSnapshot().preference).toBe('light') + expect(store.getSnapshot().revision).toBe(2) + }) + + it('revision guard drops stale and duplicate writes', () => { + const store = createAppearanceRowStore().create() + store.actions.sync('dark', 3) + store.actions.sync('system', 2) + store.actions.sync('system', 3) + expect(store.getSnapshot().preference).toBe('dark') + expect(store.getSnapshot().revision).toBe(3) + }) +}) From 0c9f310008160bf8db955b496afa0941e53d3c70 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 26 Jul 2026 02:57:54 +0800 Subject: [PATCH 23/32] test(gui): apply-level suites for the feature-owned settings rows Locale and ui-theme apply coverage on a real Context + SlotCore: dictionary assembly, declaration-aware registration both ways, inject-time getter re-sync, service write-back through the event flow, HMR collapse recovery, and teardown reclamation. Four settings-surface packages sit at full per-file coverage. --- packages/client/locale/tests/apply.spec.ts | 116 ++++++++++++++++++ packages/client/ui-theme/tests/apply.spec.ts | 117 +++++++++++++++++++ 2 files changed, 233 insertions(+) create mode 100644 packages/client/locale/tests/apply.spec.ts create mode 100644 packages/client/ui-theme/tests/apply.spec.ts diff --git a/packages/client/locale/tests/apply.spec.ts b/packages/client/locale/tests/apply.spec.ts new file mode 100644 index 0000000000..25dbdfe239 --- /dev/null +++ b/packages/client/locale/tests/apply.spec.ts @@ -0,0 +1,116 @@ +/** locale apply wiring: service + dictionaries provision, declaration-aware + * Language row registration, snapshot projection into the row store, and + * recovery after an HMR collapse of the declaring entry. */ +import { Context } from 'cordis' +import { describe, expect, it } from 'vitest' +import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' +import { apply, inject, SETTINGS_NS } from '@deepseek-ai/dsh-client-locale/client' +import type { LanguageRowInjected, LocaleService } from '@deepseek-ai/dsh-client-locale/client' +import { LanguageRow } from '../src/client/LanguageRow.tsx' +import type { createLanguageRowStore } from '../src/client/settings-store.ts' + +const SLOT = 'settings.general.item' + +async function bench() { + const ctx = new Context() + await ctx.plugin(SlotsService).await() + return { ctx, slots: ctx.get('slots') as SlotsService } +} + +/** Stand in for the settings shell: declare the General item slot from root. */ +function declareItems(slots: SlotsService): () => void { + return slots.register( + { name: 'root', children: { [SLOT]: { kind: 'list', scope: 'root' } } } as never, + () => null, + ) +} + +/** Mirror the framework's inject choreography: bake a real instance from the + * declared handle and hand its actions to the entry's inject factory. */ +function faceOf(slots: SlotsService) { + const entry = slots.entries(SLOT).find(e => e.component === LanguageRow)! + const handle = entry.store as ReturnType + const instance = handle.create() + const face = (entry.inject as unknown as (a: typeof instance.actions) => LanguageRowInjected)(instance.actions) + return { entry, instance, face } +} + +describe('locale apply', () => { + it('declares the slot service', () => { + expect(inject).toEqual(['slots']) + }) + + it('provides the service with base + settings dictionaries and registers the row (declaration before or after apply)', async () => { + const before = await bench() + declareItems(before.slots) + await before.ctx.plugin({ inject: [...inject], apply }).await() + const locale = before.ctx.get('locale') as LocaleService + // Base dictionaries are registered: the (ns, locale) seats are occupied. + expect(() => locale.register('common', 'zh', {})).toThrow('already has locale') + expect(() => locale.register('common', 'en', {})).toThrow('already has locale') + expect(locale.bind(SETTINGS_NS)('language.title')).toBe('语言') + const entry = before.slots.entries(SLOT).find(e => e.component === LanguageRow)! + expect(entry.options).toMatchObject({ id: 'language', order: 0 }) + + const after = await bench() + const fiber = after.ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + expect(after.slots.entries(SLOT)).toHaveLength(0) + declareItems(after.slots) + await Promise.resolve() + expect(after.slots.entries(SLOT).some(e => e.component === LanguageRow)).toBe(true) + }) + + it('projects service snapshots into the row store and routes face writes back', async () => { + const b = await bench() + declareItems(b.slots) + await b.ctx.plugin({ inject: [...inject], apply }).await() + const locale = b.ctx.get('locale') as LocaleService + // An event ahead of any inject hits the unbound-actions arm. + locale.setLocale('en') + + const { instance, face } = faceOf(b.slots) + // The inject-time re-sync sealed the init window: the mirror is current. + expect(instance.getSnapshot().active).toBe('en') + expect(instance.getSnapshot().options.map(o => o.id)).toEqual(['zh', 'en']) + expect(face.t('language.title')).toBe('Language') + + face.setLocale('zh') + expect(locale.getLocale().active).toBe('zh') + expect(instance.getSnapshot().active).toBe('zh') + expect(face.t('language.title')).toBe('语言') + }) + + it('recovers after an HMR collapse of the declaring entry (stale disposer must not block)', async () => { + const b = await bench() + const host = declareItems(b.slots) + await b.ctx.plugin({ inject: [...inject], apply }).await() + expect(b.slots.entries(SLOT)).toHaveLength(1) + + // Collapse: the declarer dies, the cascade removes our entry while the + // apply closure still holds its (now stale) disposer. + host() + expect(b.slots.entries(SLOT)).toHaveLength(0) + + declareItems(b.slots) + await Promise.resolve() + expect(b.slots.entries(SLOT).some(e => e.component === LanguageRow)).toBe(true) + }) + + it('teardown removes the row; teardown without a declaration is quiet', async () => { + const b = await bench() + declareItems(b.slots) + const fiber = b.ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + expect(b.slots.entries(SLOT)).toHaveLength(1) + await fiber.dispose() + expect(b.slots.entries(SLOT)).toHaveLength(0) + + // Never-declared bench: the effect disposer's dispose arm stays undefined. + const quiet = await bench() + const f2 = quiet.ctx.plugin({ inject: [...inject], apply }) + await f2.await() + await f2.dispose() + expect(quiet.slots.entries(SLOT)).toHaveLength(0) + }) +}) diff --git a/packages/client/ui-theme/tests/apply.spec.ts b/packages/client/ui-theme/tests/apply.spec.ts new file mode 100644 index 0000000000..9852b93e66 --- /dev/null +++ b/packages/client/ui-theme/tests/apply.spec.ts @@ -0,0 +1,117 @@ +/** ui-theme apply wiring: service provision, settings dictionaries riding the + * locale service, declaration-aware Appearance row registration, snapshot + * projection into the row store, and HMR collapse recovery. */ +import { Context } from 'cordis' +import { describe, expect, it } from 'vitest' +import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' +import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' +import { apply, inject, SETTINGS_NS } from '@deepseek-ai/dsh-client-ui-theme/client' +import type { AppearanceRowInjected, ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client' +import { AppearanceRow } from '../src/client/AppearanceRow.tsx' +import type { createAppearanceRowStore } from '../src/client/settings-store.ts' + +const SLOT = 'settings.general.item' + +async function bench() { + const ctx = new Context() + await ctx.plugin(SlotsService).await() + const locale = new LocaleService(ctx) + ctx.provide('locale', locale) + return { ctx, slots: ctx.get('slots') as SlotsService, locale } +} + +/** Stand in for the settings shell: declare the General item slot from root. */ +function declareItems(slots: SlotsService): () => void { + return slots.register( + { name: 'root', children: { [SLOT]: { kind: 'list', scope: 'root' } } } as never, + () => null, + ) +} + +/** Mirror the framework's inject choreography: bake a real instance from the + * declared handle and hand its actions to the entry's inject factory. */ +function faceOf(slots: SlotsService) { + const entry = slots.entries(SLOT).find(e => e.component === AppearanceRow)! + const handle = entry.store as ReturnType + const instance = handle.create() + const face = (entry.inject as unknown as (a: typeof instance.actions) => AppearanceRowInjected)(instance.actions) + return { entry, instance, face } +} + +describe('ui-theme apply', () => { + it('declares the slot and locale services', () => { + expect(inject).toEqual(['slots', 'locale']) + }) + + it('provides the service, registers localized copy, and registers the row (declaration before or after apply)', async () => { + const before = await bench() + declareItems(before.slots) + await before.ctx.plugin({ inject: [...inject], apply }).await() + expect(before.locale.bind(SETTINGS_NS)('appearance.title')).toBe('外观') + before.locale.setLocale('en') + expect(before.locale.bind(SETTINGS_NS)('appearance.title')).toBe('Appearance') + const entry = before.slots.entries(SLOT).find(e => e.component === AppearanceRow)! + expect(entry.options).toMatchObject({ id: 'appearance', order: 10 }) + + const after = await bench() + const fiber = after.ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + expect(after.slots.entries(SLOT)).toHaveLength(0) + declareItems(after.slots) + await Promise.resolve() + expect(after.slots.entries(SLOT).some(e => e.component === AppearanceRow)).toBe(true) + }) + + it('projects service snapshots into the row store and routes face writes back', async () => { + const b = await bench() + declareItems(b.slots) + await b.ctx.plugin({ inject: [...inject], apply }).await() + const theme = b.ctx.get('theme') as ThemeService + // An event ahead of any inject hits the unbound-actions arm. + theme.setTheme('dark') + + const { instance, face } = faceOf(b.slots) + // The inject-time re-sync sealed the init window: the mirror is current. + expect(instance.getSnapshot().preference).toBe('dark') + expect(face.t('appearance.dark')).toBe('深色') + + face.setTheme('system') + expect(theme.getTheme().preference).toBe('system') + expect(instance.getSnapshot().preference).toBe('system') + }) + + it('recovers after an HMR collapse of the declaring entry (stale disposer must not block)', async () => { + const b = await bench() + const host = declareItems(b.slots) + await b.ctx.plugin({ inject: [...inject], apply }).await() + expect(b.slots.entries(SLOT)).toHaveLength(1) + + // Collapse: the declarer dies, the cascade removes our entry while the + // apply closure still holds its (now stale) disposer. + host() + expect(b.slots.entries(SLOT)).toHaveLength(0) + + declareItems(b.slots) + await Promise.resolve() + expect(b.slots.entries(SLOT).some(e => e.component === AppearanceRow)).toBe(true) + }) + + it('teardown removes the row and the dictionaries; teardown without a declaration is quiet', async () => { + const b = await bench() + declareItems(b.slots) + const fiber = b.ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + expect(b.slots.entries(SLOT)).toHaveLength(1) + await fiber.dispose() + expect(b.slots.entries(SLOT)).toHaveLength(0) + // Dictionary disposal: translation falls back to the bare key. + expect(b.locale.bind(SETTINGS_NS)('appearance.title')).toBe('appearance.title') + + // Never-declared bench: the effect disposer's dispose arm stays undefined. + const quiet = await bench() + const f2 = quiet.ctx.plugin({ inject: [...inject], apply }) + await f2.await() + await f2.dispose() + expect(quiet.slots.entries(SLOT)).toHaveLength(0) + }) +}) From c5d323e8e599e63dc0c3eeff4f8c46209e9fc953 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 26 Jul 2026 03:22:35 +0800 Subject: [PATCH 24/32] fix(gui): follow-ups for the feature-owned settings surfaces The ui-layout apply bench provides a real LocaleService before the theme plugin boots (ui-theme now injects slots/locale to register its Appearance row); drop locale's unused clsx dependency. --- packages/client/locale/package.json | 3 --- packages/client/ui-layout/package.json | 1 + packages/client/ui-layout/tests/apply.spec.ts | 4 ++++ packages/client/ui-layout/tsconfig.json | 3 +++ pnpm-lock.yaml | 7 +++---- 5 files changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/client/locale/package.json b/packages/client/locale/package.json index d53d2e4269..4d75496dc8 100644 --- a/packages/client/locale/package.json +++ b/packages/client/locale/package.json @@ -58,8 +58,5 @@ "scripts": { "bundle": "tsdown", "watch": "tsdown --watch" - }, - "dependencies": { - "clsx": "^2.0.0" } } diff --git a/packages/client/ui-layout/package.json b/packages/client/ui-layout/package.json index 17459ee5d2..b816533474 100644 --- a/packages/client/ui-layout/package.json +++ b/packages/client/ui-layout/package.json @@ -43,6 +43,7 @@ "react": "^18.2.0" }, "devDependencies": { + "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-client-ui-theme": "workspace:^", diff --git a/packages/client/ui-layout/tests/apply.spec.ts b/packages/client/ui-layout/tests/apply.spec.ts index 29ad4b3058..910b5a3132 100644 --- a/packages/client/ui-layout/tests/apply.spec.ts +++ b/packages/client/ui-layout/tests/apply.spec.ts @@ -9,6 +9,7 @@ import { Context } from 'cordis' import { describe, expect, it, vi } from 'vitest' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' +import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' import { apply as themeApply, inject as themeInject, ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client' import { apply, inject, LayoutService } from '@deepseek-ai/dsh-client-ui-layout/client' import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-layout' @@ -17,6 +18,9 @@ import * as invariant from '@deepseek-ai/dsh-client-ui-layout/invariant' async function bench() { const ctx = new Context() const slotsFiber = ctx.plugin(SlotsService) + // Theme now injects ['slots', 'locale'] (it registers its Appearance + // settings row); seat a real locale service so the theme fiber activates. + ctx.provide('locale', new LocaleService(ctx)) await ctx.plugin({ inject: themeInject, apply: themeApply }).await() await slotsFiber.await() return { ctx, slots: ctx.get('slots') as SlotsService } diff --git a/packages/client/ui-layout/tsconfig.json b/packages/client/ui-layout/tsconfig.json index 4401e8e4db..731eec8012 100644 --- a/packages/client/ui-layout/tsconfig.json +++ b/packages/client/ui-layout/tsconfig.json @@ -11,6 +11,9 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../locale" + }, { "path": "../ui-slots" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 40fee92c16..612cea101f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -747,10 +747,6 @@ importers: version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) packages/client/locale: - dependencies: - clsx: - specifier: ^2.0.0 - version: 2.1.1 devDependencies: '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ @@ -859,6 +855,9 @@ importers: packages/client/ui-layout: devDependencies: + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ version: link:../runtime From 78e57ab53e9ac175f9c6321ced5fe35a7517163e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 03:28:39 +0800 Subject: [PATCH 25/32] docs: record PR label taxonomy --- ...07-25-semantic-pr-label-taxonomy.i18n.yaml | 6 ++ .../2026-07-25-semantic-pr-label-taxonomy.md | 60 +++++++++++++++++++ ...026-07-25-semantic-pr-label-taxonomy.zh.md | 60 +++++++++++++++++++ AGENTS.md | 2 +- 4 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 .agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md create mode 100644 .agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.zh.md diff --git a/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.i18n.yaml b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.i18n.yaml new file mode 100644 index 0000000000..9be3fb4b13 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.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 +2026-07-25-semantic-pr-label-taxonomy.md: cf25691e55d100cc2cc12246b17f5edbba35feea +2026-07-25-semantic-pr-label-taxonomy.zh.md: 2978bdc80a660f67f84821e473dc229e13b2bc4d diff --git a/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md new file mode 100644 index 0000000000..cf25691e55 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md @@ -0,0 +1,60 @@ +# Agent Note: Semantic pull request label taxonomy + +Status: implemented + +English | [中文](2026-07-25-semantic-pr-label-taxonomy.zh.md) + +## Problem + +Pull requests need two different signals: what kind of change they make and which repository domains they affect. A flat or broadly named label set conflates those questions, hides work in distinct areas such as `session` and `llm`, and gives reviewers and automation weak inputs. + +The repository also gains new domains over time. Treating today's area labels as a closed set would force future work into inaccurate labels or a generic catch-all. + +## Decision + +Every open or merged pull request carries exactly one kind and every materially affected area. Closed pull requests that were never merged are outside the maintained historical corpus. Other operational labels may coexist, but they do not satisfy either dimension. + +### Kinds + +| Kind | Meaning | +|---|---| +| `feature` | Adds or intentionally changes behavior. | +| `bug-fix` | Corrects incorrect behavior. | +| `doc` | Changes documentation only. | +| `testing` | Changes tests or testing infrastructure without changing product behavior. | +| `cleanup` | Preserves behavior while maintaining or simplifying the implementation or repository process. | + +The kind records the change's dominant intent: accompanying tests and documentation do not turn a feature or bug fix into a testing or documentation change. + +Areas record semantic repository domains rather than temporary initiatives, ownership, or every path touched incidentally. A pull request may carry several areas when it changes several domains. + +### Current areas + +The 43 current areas are listed below. The group names organize the list for readability; they are not labels or another taxonomy level. + +| Group | Areas | +|---|---| +| Agent and model | `agent`, `agent-loop`, `session`, `llm`, `model-context`, `compaction`, `tools`, `persistence` | +| Orchestration | `subagent`, `workflow`, `planning`, `tasks`, `telemetry`, `storage`, `workspace` | +| Capabilities | `bash`, `pty`, `filesystem`, `lsp`, `skills`, `web-search`, `code-mode`, `sandbox`, `mcp`, `hooks`, `cordis` | +| Interfaces | `ui`, `web`, `tui`, `acp`, `json-rpc`, `cli`, `python-sdk`, `desktop`, `vscode`, `website` | +| Repository and release | `dev-infra`, `ci`, `build`, `dependencies`, `platform`, `i18n`, `release` | + +### Extensibility + +The area set is intentionally extensible. Add an area when a recurring, meaningful repository domain is missing; do not add a label for one pull request, a temporary project, a status, or a person or team. Rename, split, or retire an area when the domain model changes, and update this list and the affected open and merged pull requests together. + +The kind set stays narrow because kinds are mutually exclusive. A new kind requires a distinct change intent that cannot be represented by the current five; it is not a substitute for an area. + +## Alternatives considered + +- **One undifferentiated label set.** Rejected because kind and area answer different questions; mixing them makes the presence of one label say nothing about whether the other dimension was considered. +- **A fixed, closed area set.** Rejected because repository domains evolve. A closed set would preserve spelling at the cost of semantic accuracy. +- **One broad `core` area or package-derived labels.** Rejected because domains such as `session`, `llm`, and `agent` remain independently meaningful across package boundaries, while incidental file paths are not the scope reviewers or automation need. +- **Exactly one area per pull request.** Rejected because coherent changes can legitimately span several domains, and dropping secondary areas hides affected contracts. + +## Consequences + +- Reviewers and automation receive one stable intent signal plus a complete semantic scope. +- Selecting labels remains a judgment call: paths and title prefixes can suggest areas, but they cannot replace reading the change. +- Taxonomy changes carry maintenance work. Area additions, renames, splits, and removals update this decision record and backfill open and merged pull requests so historical queries keep their meaning. diff --git a/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.zh.md b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.zh.md new file mode 100644 index 0000000000..2978bdc80a --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.zh.md @@ -0,0 +1,60 @@ +# Agent Note: 语义化 PR 标签分类体系 + +Status: implemented + +[English](2026-07-25-semantic-pr-label-taxonomy.md) | 中文 + +## 问题 + +PR(Pull Request)需要传达两个不同的信号:它带来哪一类变更,以及会影响仓库中的哪些领域。一套扁平或命名宽泛的标签会混淆这两个问题,掩盖 `session`、`llm` 等不同领域的工作,也让评审人和自动化流程得到的输入缺乏有效信息。 + +仓库还会随时间发展出新的领域。如果把当前的领域标签视为封闭集合,未来的工作就只能归入不准确的标签或通用兜底标签。 + +## 决策 + +每项开放或已合并的 PR 都带有恰好一个类型标签,以及所有受到实质影响的领域标签。未合并即关闭的 PR 不属于持续维护的历史记录集合。其他管理用途的标签可以并存,但都不能满足这两个维度中的任一个。 + +### 类型 + +| 类型 | 含义 | +|---|---| +| `feature` | 新增行为或有意改变行为。 | +| `bug-fix` | 修正错误行为。 | +| `doc` | 仅修改文档。 | +| `testing` | 修改测试或测试基础设施,但不改变产品行为。 | +| `cleanup` | 在保持行为不变的前提下,维护或简化实现或仓库流程。 | + +类型记录变更的主要意图:配套测试与文档并不会把一项功能或缺陷修复变成测试或文档变更。 + +领域记录仓库中的语义领域,而不是临时项目、归属关系或偶然触及的每条路径。如果一项 PR 修改了多个领域,就可以带有多个领域标签。 + +### 当前领域 + +当前的 43 个领域如下。分组名称仅用于提高列表的可读性;它们既不是标签,也不是分类体系中的另一个层级。 + +| 分组 | 领域 | +|---|---| +| agent(智能体)与模型 | `agent`, `agent-loop`, `session`, `llm`, `model-context`, `compaction`, `tools`, `persistence` | +| 编排 | `subagent`, `workflow`, `planning`, `tasks`, `telemetry`, `storage`, `workspace` | +| 能力 | `bash`, `pty`, `filesystem`, `lsp`, `skills`, `web-search`, `code-mode`, `sandbox`, `mcp`, `hooks`, `cordis` | +| 接口 | `ui`, `web`, `tui`, `acp`, `json-rpc`, `cli`, `python-sdk`, `desktop`, `vscode`, `website` | +| 仓库与发布 | `dev-infra`, `ci`, `build`, `dependencies`, `platform`, `i18n`, `release` | + +### 可扩展性 + +领域集合有意保持可扩展。当分类体系缺少一个会反复涉及且具有实际意义的仓库领域时,就新增领域;不要仅为一项 PR、临时项目、状态、个人或团队新增标签。当领域模型发生变化时,重命名、拆分或退役相应领域,同时更新本列表以及所有受影响的开放和已合并 PR。 + +类型集合保持精简,因为各类型互斥。新增类型的前提是存在一种当前五类无法表达的独立变更意图;类型不能用来替代领域。 + +## 曾考虑的替代方案 + +- **一套不区分维度的标签。** 不予采纳,因为类型与领域回答的是不同问题;两者混在一起时,存在一个维度的标签并不表示另一个维度也经过了考虑。 +- **一套固定、封闭的领域集合。** 不予采纳,因为仓库领域会持续演变。封闭集合会以牺牲语义准确性为代价来维持拼写不变。 +- **一个宽泛的 `core` 领域,或从包(package)结构派生的标签。** 不予采纳,因为 `session`、`llm` 和 `agent` 等领域在跨越包边界时仍各自具有意义,而偶然涉及的文件路径并不是评审人或自动化流程所需的范围信息。 +- **每项 PR 恰好一个领域。** 不予采纳,因为一项内聚的变更可以合理地跨越多个领域;省略次要领域会隐藏受影响的契约。 + +## 后果 + +- 评审人和自动化流程获得一个稳定的意图信号,以及完整的语义范围。 +- 选择标签仍然需要判断:路径和标题前缀可以提示领域,但不能替代阅读变更内容。 +- 变更分类体系会产生维护工作。新增、重命名、拆分或移除领域时,需要更新本决策记录,并回填开放和已合并的 PR,使历史查询保持原有含义。 diff --git a/AGENTS.md b/AGENTS.md index ad79a04b10..a8f811229a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -112,7 +112,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, - **A tool's UI render intent is part of its design**, decided up front (`generic`/`terminal`/`diff`, `locations`); presentation methods are pure functions of `args` ([cookbook](docs/cookbook/adding-a-tool.md)). - **Plan unit, e2e, and snapshot coverage** for new seams, lifecycle shapes, and transcript surfaces; missing snapshot-harness support is part of the implementation, not deferred follow-up. - **Keep PRs coherent; use merge commits.** Split independent features or design decisions when combining them obscures ownership, intent, or verification. Never squash, rebase, or rewrite pushed branches; fix the introducing PR, then merge down its stack ([guide](docs/cookbook/responding-to-pr-review-on-a-stack.md)). -- **Label PRs:** exactly one kind (`feature`/`bug-fix`/`doc`/`testing`/`cleanup`), all matching areas; the taxonomy remains extensible for recurring distinctions. +- **Label PRs:** exactly one kind (`feature`/`bug-fix`/`doc`/`testing`/`cleanup`), all matching areas; the [taxonomy](.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md) remains extensible for recurring distinctions. - TODO markers: `FIXME`/`TODO`/`XXX` by urgency ([semantics](docs/development.md)). - Files end with exactly one trailing newline; `git diff --cached --check` (pre-commit) gates it. From a04ce7afbb6dde19d5e2973794f183667f27c1c4 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 26 Jul 2026 04:15:56 +0800 Subject: [PATCH 26/32] refactor(client): shared declaration-aware registration deferral MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The five settings-surface registrants carried near-identical spec-check/ledger-judge/subscribe scaffolding (three jscpd clones); ui-slots now owns deferRegistration() — ledger-judged presence, refresh for registrant-localized labels, one-call disposal — and every registrant shrinks to its registration body. --- packages/client/locale/src/client/index.ts | 23 ++----- packages/client/ui-models/src/client/index.ts | 32 +++------ .../client/ui-settings/src/client/index.ts | 46 ++++--------- packages/client/ui-slots/src/deferred.ts | 66 +++++++++++++++++++ packages/client/ui-slots/src/index.ts | 1 + packages/client/ui-theme/src/client/index.ts | 23 ++----- 6 files changed, 97 insertions(+), 94 deletions(-) create mode 100644 packages/client/ui-slots/src/deferred.ts diff --git a/packages/client/locale/src/client/index.ts b/packages/client/locale/src/client/index.ts index 444594f9b8..ea6f9d8da9 100644 --- a/packages/client/locale/src/client/index.ts +++ b/packages/client/locale/src/client/index.ts @@ -5,7 +5,7 @@ * its own settings surface. */ import type { Context } from 'cordis' -import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots' +import { deferRegistration, type BoundActions } from '@deepseek-ai/dsh-client-ui-slots' import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' import { en } from '../locales/en.ts' import { zh } from '../locales/zh.ts' @@ -233,28 +233,15 @@ export function apply(ctx: ClientContext): void { setLocale: (id) => { locale.setLocale(id) }, } } - // Declaration-aware registration; the LEDGER is the has-registered judge - // (not a local flag): after an HMR collapse re-declares the slot, the - // cascade already removed our entry, and a stale disposer must not block - // the re-registration. ctx.effect(() => { - let dispose: (() => void) | undefined - const tryRegister = (): void => { - if (ctx.slots.spec('settings.general.item') === undefined) return - if (ctx.slots.entries('settings.general.item').some(e => e.component === LanguageRow)) return - dispose = ctx.slots.register({ + const deferred = deferRegistration(ctx.slots, 'settings.general.item', LanguageRow, () => + ctx.slots.register({ name: 'settings.general.item', id: 'language', order: 0, store, inject: injected, - }, LanguageRow) - } - const unsubscribe = ctx.slots.subscribe('settings.general.item', () => { tryRegister() }) - tryRegister() - return () => { - unsubscribe() - dispose?.() - } + }, LanguageRow)) + return () => { deferred.dispose() } }, 'locale: language settings row registration') } diff --git a/packages/client/ui-models/src/client/index.ts b/packages/client/ui-models/src/client/index.ts index 4f080ec928..5abcb65bcf 100644 --- a/packages/client/ui-models/src/client/index.ts +++ b/packages/client/ui-models/src/client/index.ts @@ -5,6 +5,7 @@ * discipline: packages/client/AGENTS.md. */ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots' // Type-only: pulls the shell's SlotMap merge (the 'settings.section' entry). import type {} from '@deepseek-ai/dsh-client-ui-settings/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). @@ -31,37 +32,20 @@ export function apply(ctx: ClientContext): void { ] return () => { for (const dispose of disposers) dispose() } }, 'ui-models: nav copy dictionaries') - // Declaration-aware registration; the LEDGER is the has-registered judge - // (not a local flag): after an HMR collapse re-declares the slot, the - // cascade already removed our entry, and a stale disposer must not block - // the re-registration. ctx.effect(() => { - let dispose: (() => void) | undefined - const tryRegister = (): void => { - if (ctx.slots.spec('settings.section') === undefined) return - if (ctx.slots.entries('settings.section').some(e => e.component === ModelsSection)) return - dispose = ctx.slots.register({ + const deferred = deferRegistration(ctx.slots, 'settings.section', ModelsSection, () => + ctx.slots.register({ name: 'settings.section', id: 'models', order: 10, label: ctx.locale.bind('settings.models')('nav'), - }, ModelsSection) - } - // Nav labels are registrant-localized: re-register on locale change so - // the ledger carries fresh text (the version bump re-renders the shell). - // Dispose-then-requery: after an HMR collapse the disposer is stale and - // the ledger/spec re-check keeps this path an idempotent no-op. - const offLocale = ctx.on('locale/change', () => { - dispose?.() - dispose = undefined - tryRegister() - }) - const unsubscribe = ctx.slots.subscribe('settings.section', () => { tryRegister() }) - tryRegister() + }, ModelsSection)) + // Nav labels are registrant-localized: refresh on locale change so the + // ledger carries fresh text (the version bump re-renders the shell). + const offLocale = ctx.on('locale/change', () => { deferred.refresh() }) return () => { offLocale() - unsubscribe() - dispose?.() + deferred.dispose() } }, 'ui-models: settings section registration') } diff --git a/packages/client/ui-settings/src/client/index.ts b/packages/client/ui-settings/src/client/index.ts index c360a84201..9d379c301e 100644 --- a/packages/client/ui-settings/src/client/index.ts +++ b/packages/client/ui-settings/src/client/index.ts @@ -7,6 +7,7 @@ * preference rows into. Export discipline: packages/client/AGENTS.md. */ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots' // Type-only: pulls the locale plugin's Context/Events merges (ctx.locale, // 'locale/change') into this program. import type {} from '@deepseek-ai/dsh-client-locale/client' @@ -60,60 +61,37 @@ export function apply(ctx: ClientContext): void { })) .sort((a, b) => a.order - b.order), }) - // Declaration-aware registration; the LEDGER is the has-registered judge - // (not a local flag): after an HMR collapse re-declares the slot, the - // cascade already removed our entry, and a stale disposer must not block - // the re-registration. ctx.effect(() => { - let dispose: (() => void) | undefined - const tryRegister = (): void => { - if (ctx.slots.spec('sidebar.settings') === undefined) return - if (ctx.slots.entries('sidebar.settings').some(e => e.component === SettingsRoot)) return - dispose = ctx.slots.register({ + const deferred = deferRegistration(ctx.slots, 'sidebar.settings', SettingsRoot, () => + ctx.slots.register({ name: 'sidebar.settings', children: { 'settings.section': { kind: 'list', scope: 'root' } }, inject: injected, - }, SettingsRoot) - } - const unsubscribe = ctx.slots.subscribe('sidebar.settings', () => { tryRegister() }) - tryRegister() - return () => { - unsubscribe() - dispose?.() - } + }, SettingsRoot)) + return () => { deferred.dispose() } }, 'ui-settings: shell registration') // The shell's own General section: first page, declares the item slot the // feature plugins (locale, ui-theme, …) contribute preference rows into. - // Same ledger-judged deferral; label re-registers on locale change. const generalInjected = (): GeneralSectionInjected => ({ t: ctx.locale.bind('settings'), }) ctx.effect(() => { - let dispose: (() => void) | undefined - const tryRegister = (): void => { - if (ctx.slots.spec('settings.section') === undefined) return - if (ctx.slots.entries('settings.section').some(e => e.component === GeneralSection)) return - dispose = ctx.slots.register({ + const deferred = deferRegistration(ctx.slots, 'settings.section', GeneralSection, () => + ctx.slots.register({ name: 'settings.section', id: 'general', order: 0, label: ctx.locale.bind('settings')('general.nav'), children: { 'settings.general.item': { kind: 'list', scope: 'root' } }, inject: generalInjected, - }, GeneralSection) - } - const offLocale = ctx.on('locale/change', () => { - dispose?.() - dispose = undefined - tryRegister() - }) - const unsubscribe = ctx.slots.subscribe('settings.section', () => { tryRegister() }) - tryRegister() + }, GeneralSection)) + // Nav labels are registrant-localized: refresh on locale change so the + // ledger carries fresh text (the version bump re-renders the shell). + const offLocale = ctx.on('locale/change', () => { deferred.refresh() }) return () => { offLocale() - unsubscribe() - dispose?.() + deferred.dispose() } }, 'ui-settings: general section registration') } diff --git a/packages/client/ui-slots/src/deferred.ts b/packages/client/ui-slots/src/deferred.ts new file mode 100644 index 0000000000..af85c27f97 --- /dev/null +++ b/packages/client/ui-slots/src/deferred.ts @@ -0,0 +1,66 @@ +/** + * Declaration-aware registration deferral: the shared timing machinery for + * registering into a slot whose declaring entry activates in unconstrained + * order (dshClient.inject edges never sequence apply). Presence is judged on + * the LEDGER, not a local flag — after an HMR collapse re-declares the slot, + * the cascade has already removed the entry while the local disposer went + * stale, and a flag guard would block the re-registration. + */ + +/** Minimal registry face the deferral reads (SlotsService satisfies it). */ +export interface DeferralRegistry { + /** Declared spec lookup (undefined = not declared yet). */ + spec(name: string): unknown + /** Current entries of the slot (component identity is the presence judge). */ + entries(name: string): readonly { component: unknown }[] + /** Subscribe to the slot's ledger changes; returns the unsubscriber. */ + subscribe(name: string, listener: () => void): () => void +} + +/** Handle over one deferred registration. */ +export interface DeferredRegistration { + /** + * Drop the current registration (stale disposers are harmless no-ops) and + * immediately re-attempt — the refresh path for registrants whose options + * carry localized text. + */ + refresh(): void + /** Unsubscribe and unregister (idempotent through the slot core). */ + dispose(): void +} + +/** + * Register into `name` as soon as its declaration is on the ledger, and + * re-register whenever the declaration reappears after a collapse. + * @param registry - the slot registry face. + * @param name - target slot name. + * @param component - the component whose ledger presence marks "registered". + * @param register - performs the actual registration; returns its disposer. + * @returns the deferral handle (dispose in the owning effect's disposer). + */ +export function deferRegistration( + registry: DeferralRegistry, + name: string, + component: unknown, + register: () => () => void, +): DeferredRegistration { + let dispose: (() => void) | undefined + const tryRegister = (): void => { + if (registry.spec(name) === undefined) return + if (registry.entries(name).some(e => e.component === component)) return + dispose = register() + } + const unsubscribe = registry.subscribe(name, () => { tryRegister() }) + tryRegister() + return { + refresh() { + dispose?.() + dispose = undefined + tryRegister() + }, + dispose() { + unsubscribe() + dispose?.() + }, + } +} diff --git a/packages/client/ui-slots/src/index.ts b/packages/client/ui-slots/src/index.ts index 677bc4e0df..e3bc57797d 100644 --- a/packages/client/ui-slots/src/index.ts +++ b/packages/client/ui-slots/src/index.ts @@ -18,6 +18,7 @@ import type { BoundActions, HandleOf, PropsStore, SnapshotSelectorHook, StoreDec export * from './store.ts' export * from './renderer.ts' +export * from './deferred.ts' /** Slot contract table. Owners extend via declaration merging; entries are {@link SlotEntryDef}. */ export interface SlotMap {} diff --git a/packages/client/ui-theme/src/client/index.ts b/packages/client/ui-theme/src/client/index.ts index ac79913062..dd11c98f06 100644 --- a/packages/client/ui-theme/src/client/index.ts +++ b/packages/client/ui-theme/src/client/index.ts @@ -7,7 +7,7 @@ * section — the theme feature owns its own settings surface. */ import type { Context } from 'cordis' -import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots' +import { deferRegistration, type BoundActions } from '@deepseek-ai/dsh-client-ui-slots' import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' @@ -262,28 +262,15 @@ export function apply(ctx: ClientContext): void { setTheme: (id) => { theme.setTheme(id) }, } } - // Declaration-aware registration; the LEDGER is the has-registered judge - // (not a local flag): after an HMR collapse re-declares the slot, the - // cascade already removed our entry, and a stale disposer must not block - // the re-registration. ctx.effect(() => { - let dispose: (() => void) | undefined - const tryRegister = (): void => { - if (ctx.slots.spec('settings.general.item') === undefined) return - if (ctx.slots.entries('settings.general.item').some(e => e.component === AppearanceRow)) return - dispose = ctx.slots.register({ + const deferred = deferRegistration(ctx.slots, 'settings.general.item', AppearanceRow, () => + ctx.slots.register({ name: 'settings.general.item', id: 'appearance', order: 10, store, inject: injected, - }, AppearanceRow) - } - const unsubscribe = ctx.slots.subscribe('settings.general.item', () => { tryRegister() }) - tryRegister() - return () => { - unsubscribe() - dispose?.() - } + }, AppearanceRow)) + return () => { deferred.dispose() } }, 'ui-theme: appearance settings row registration') } From 586ccb677ec0abd4cab2afd98a98965d5f570341 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 04:18:03 +0800 Subject: [PATCH 27/32] docs: refine semantic PR label taxonomy --- ...07-25-semantic-pr-label-taxonomy.i18n.yaml | 4 +-- .../2026-07-25-semantic-pr-label-taxonomy.md | 23 ++++++++++++----- ...026-07-25-semantic-pr-label-taxonomy.zh.md | 25 +++++++++++++------ 3 files changed, 37 insertions(+), 15 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.i18n.yaml b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.i18n.yaml index 9be3fb4b13..8bc3f87435 100644 --- a/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.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 -2026-07-25-semantic-pr-label-taxonomy.md: cf25691e55d100cc2cc12246b17f5edbba35feea -2026-07-25-semantic-pr-label-taxonomy.zh.md: 2978bdc80a660f67f84821e473dc229e13b2bc4d +2026-07-25-semantic-pr-label-taxonomy.md: 61b7a829b8c44836cf9c6d0d8a7df463df309d89 +2026-07-25-semantic-pr-label-taxonomy.zh.md: cc0c5e7a8953bc97de51f90349a0e2542be5b77e diff --git a/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md index cf25691e55..61b7a829b8 100644 --- a/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md +++ b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md @@ -20,26 +20,32 @@ Every open or merged pull request carries exactly one kind and every materially |---|---| | `feature` | Adds or intentionally changes behavior. | | `bug-fix` | Corrects incorrect behavior. | -| `doc` | Changes documentation only. | +| `doc` | Makes documentation the dominant intent. | | `testing` | Changes tests or testing infrastructure without changing product behavior. | | `cleanup` | Preserves behavior while maintaining or simplifying the implementation or repository process. | The kind records the change's dominant intent: accompanying tests and documentation do not turn a feature or bug fix into a testing or documentation change. -Areas record semantic repository domains rather than temporary initiatives, ownership, or every path touched incidentally. A pull request may carry several areas when it changes several domains. +Areas record semantic repository domains rather than temporary initiatives, ownership, or every path touched incidentally. Area labels are not a hierarchy: a pull request may carry several when it changes distinct contracts, but an umbrella and a narrower label do not both describe the same work. ### Current areas -The 43 current areas are listed below. The group names organize the list for readability; they are not labels or another taxonomy level. +The 45 current areas are listed below. The group names organize the list for readability; they are not labels or another taxonomy level. | Group | Areas | |---|---| | Agent and model | `agent`, `agent-loop`, `session`, `llm`, `model-context`, `compaction`, `tools`, `persistence` | -| Orchestration | `subagent`, `workflow`, `planning`, `tasks`, `telemetry`, `storage`, `workspace` | -| Capabilities | `bash`, `pty`, `filesystem`, `lsp`, `skills`, `web-search`, `code-mode`, `sandbox`, `mcp`, `hooks`, `cordis` | -| Interfaces | `ui`, `web`, `tui`, `acp`, `json-rpc`, `cli`, `python-sdk`, `desktop`, `vscode`, `website` | +| Orchestration | `subagent`, `workflow`, `planning`, `tasks`, `schedule`, `telemetry`, `storage`, `workspace` | +| Capabilities | `bash`, `pty`, `filesystem`, `lsp`, `skills`, `web-search`, `code-mode`, `artifact`, `attachment`, `sandbox`, `mcp`, `hooks`, `cordis` | +| Interfaces | `ui`, `gui`, `tui`, `acp`, `json-rpc`, `cli`, `python-sdk`, `vscode`, `website` | | Repository and release | `dev-infra`, `ci`, `build`, `dependencies`, `platform`, `i18n`, `release` | +`gui` covers browser and Electron graphical applications, including standalone graphical developer tools; `vscode` remains the editor extension integration. `ui` covers shared cross-interface commands, approval interaction, presentation, and app boot; it coexists with `gui`, `tui`, or a protocol area only when the pull request also changes that shared contract. + +`tasks` owns background work tied to a running process, while `schedule` owns durable time-triggered jobs. `tools` owns generic registry, schema, and execution contracts; a concrete capability receives `tools` only when it changes one of those contracts. `attachment` owns durable media references and multimodal input delivery, while `artifact` owns model-declared deliverable identity and preview lifecycle; neither borrows `tools` or `ui` for its implementation parts. + +Names follow semantic ownership rather than lexical resemblance. `hooks` means the Claude Code and Codex agent bridges, not local Git hooks; `platform` means product portability, not CI runner selection; and `build` means compilation, bundling, and built package artifacts, not documentation generators. + ### Extensibility The area set is intentionally extensible. Add an area when a recurring, meaningful repository domain is missing; do not add a label for one pull request, a temporary project, a status, or a person or team. Rename, split, or retire an area when the domain model changes, and update this list and the affected open and merged pull requests together. @@ -51,10 +57,15 @@ The kind set stays narrow because kinds are mutually exclusive. A new kind requi - **One undifferentiated label set.** Rejected because kind and area answer different questions; mixing them makes the presence of one label say nothing about whether the other dimension was considered. - **A fixed, closed area set.** Rejected because repository domains evolve. A closed set would preserve spelling at the cost of semantic accuracy. - **One broad `core` area or package-derived labels.** Rejected because domains such as `session`, `llm`, and `agent` remain independently meaningful across package boundaries, while incidental file paths are not the scope reviewers or automation need. +- **Separate browser and desktop areas.** Rejected because browser delivery and Electron packaging expose one graphical client domain; splitting them classifies the delivery shell rather than the semantic work. +- **Broad implementation areas in place of a domain.** Rejected because a durable scheduled job is not a background task, an attachment is not merely its source interface or filesystem implementation, and an artifact is not merely its declaring tool or preview interface. +- **Umbrella and leaf areas for the same contract.** Rejected because duplicate labels inflate scope without adding information. Multiple areas remain correct when a pull request changes genuinely distinct contracts. - **Exactly one area per pull request.** Rejected because coherent changes can legitimately span several domains, and dropping secondary areas hides affected contracts. ## Consequences - Reviewers and automation receive one stable intent signal plus a complete semantic scope. +- `gui` queries cover browser and desktop delivery together, while `ui` queries retain only shared cross-interface contracts. +- `schedule`, `attachment`, and `artifact` queries identify those domains directly instead of approximating them through implementation dependencies. - Selecting labels remains a judgment call: paths and title prefixes can suggest areas, but they cannot replace reading the change. - Taxonomy changes carry maintenance work. Area additions, renames, splits, and removals update this decision record and backfill open and merged pull requests so historical queries keep their meaning. diff --git a/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.zh.md b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.zh.md index 2978bdc80a..cc0c5e7a89 100644 --- a/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.zh.md +++ b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.zh.md @@ -20,26 +20,32 @@ PR(Pull Request)需要传达两个不同的信号:它带来哪一类变更 |---|---| | `feature` | 新增行为或有意改变行为。 | | `bug-fix` | 修正错误行为。 | -| `doc` | 仅修改文档。 | +| `doc` | 以文档变更为主要意图。 | | `testing` | 修改测试或测试基础设施,但不改变产品行为。 | | `cleanup` | 在保持行为不变的前提下,维护或简化实现或仓库流程。 | 类型记录变更的主要意图:配套测试与文档并不会把一项功能或缺陷修复变成测试或文档变更。 -领域记录仓库中的语义领域,而不是临时项目、归属关系或偶然触及的每条路径。如果一项 PR 修改了多个领域,就可以带有多个领域标签。 +领域记录仓库中的语义领域,而不是临时项目、归属关系或偶然触及的每条路径。领域标签不构成层级:一项 PR 修改不同契约时可以带有多个领域标签,但不能用一个总括标签和一个较窄标签重复描述同一项工作。 ### 当前领域 -当前的 43 个领域如下。分组名称仅用于提高列表的可读性;它们既不是标签,也不是分类体系中的另一个层级。 +当前的 45 个领域如下。分组名称仅用于提高列表的可读性;它们既不是标签,也不是分类体系中的另一个层级。 | 分组 | 领域 | |---|---| | agent(智能体)与模型 | `agent`, `agent-loop`, `session`, `llm`, `model-context`, `compaction`, `tools`, `persistence` | -| 编排 | `subagent`, `workflow`, `planning`, `tasks`, `telemetry`, `storage`, `workspace` | -| 能力 | `bash`, `pty`, `filesystem`, `lsp`, `skills`, `web-search`, `code-mode`, `sandbox`, `mcp`, `hooks`, `cordis` | -| 接口 | `ui`, `web`, `tui`, `acp`, `json-rpc`, `cli`, `python-sdk`, `desktop`, `vscode`, `website` | +| 编排 | `subagent`, `workflow`, `planning`, `tasks`, `schedule`, `telemetry`, `storage`, `workspace` | +| 能力 | `bash`, `pty`, `filesystem`, `lsp`, `skills`, `web-search`, `code-mode`, `artifact`, `attachment`, `sandbox`, `mcp`, `hooks`, `cordis` | +| 接口 | `ui`, `gui`, `tui`, `acp`, `json-rpc`, `cli`, `python-sdk`, `vscode`, `website` | | 仓库与发布 | `dev-infra`, `ci`, `build`, `dependencies`, `platform`, `i18n`, `release` | +`gui` 涵盖浏览器和 Electron 图形应用,包括独立的图形化开发者工具;`vscode` 仍表示编辑器扩展集成。`ui` 涵盖共享的跨接口命令、审批交互、呈现和应用启动;只有当 PR 还修改这项共享契约时,它才与 `gui`、`tui` 或某个协议领域并用。 + +`tasks` 负责与运行中进程绑定的后台工作,`schedule` 则负责持久化的定时作业。`tools` 负责通用的注册表契约、schema 契约和执行契约;具体能力只有在修改其中一项契约时才带有 `tools`。`attachment` 负责持久化的媒体引用和多模态输入传递,`artifact` 则负责模型声明的交付物标识和预览生命周期;二者都不会因实现包含工具或界面部分而借用 `tools` 或 `ui`。 + +标签名称以语义归属为准,而不是词面相似性。`hooks` 指 Claude Code 和 Codex 的 agent 桥接,而不是本地 Git 钩子;`platform` 指产品可移植性,而不是 CI 运行器选择;`build` 指编译、打包和已构建的包(package)产物,而不是文档生成器。 + ### 可扩展性 领域集合有意保持可扩展。当分类体系缺少一个会反复涉及且具有实际意义的仓库领域时,就新增领域;不要仅为一项 PR、临时项目、状态、个人或团队新增标签。当领域模型发生变化时,重命名、拆分或退役相应领域,同时更新本列表以及所有受影响的开放和已合并 PR。 @@ -50,11 +56,16 @@ PR(Pull Request)需要传达两个不同的信号:它带来哪一类变更 - **一套不区分维度的标签。** 不予采纳,因为类型与领域回答的是不同问题;两者混在一起时,存在一个维度的标签并不表示另一个维度也经过了考虑。 - **一套固定、封闭的领域集合。** 不予采纳,因为仓库领域会持续演变。封闭集合会以牺牲语义准确性为代价来维持拼写不变。 -- **一个宽泛的 `core` 领域,或从包(package)结构派生的标签。** 不予采纳,因为 `session`、`llm` 和 `agent` 等领域在跨越包边界时仍各自具有意义,而偶然涉及的文件路径并不是评审人或自动化流程所需的范围信息。 +- **一个宽泛的 `core` 领域,或从包结构派生的标签。** 不予采纳,因为 `session`、`llm` 和 `agent` 等领域在跨越包边界时仍各自具有意义,而偶然涉及的文件路径并不是评审人或自动化流程所需的范围信息。 +- **为浏览器和桌面端分别设置领域。** 不予采纳,因为浏览器交付和 Electron 打包共同呈现同一个图形客户端领域;拆开二者将按交付形态而非工作的语义进行分类。 +- **以宽泛的实现领域替代语义领域。** 不予采纳,因为持久化的定时作业不是后台任务,附件不只是其来源接口或文件系统实现,产物也不只是声明它的工具或预览接口。 +- **同一项契约同时使用总括领域与细分领域。** 不予采纳,因为重复标签只会虚增范围,不会增加信息。一项 PR 确实修改不同契约时,多个领域标签仍然合理。 - **每项 PR 恰好一个领域。** 不予采纳,因为一项内聚的变更可以合理地跨越多个领域;省略次要领域会隐藏受影响的契约。 ## 后果 - 评审人和自动化流程获得一个稳定的意图信号,以及完整的语义范围。 +- `gui` 查询会同时覆盖浏览器与桌面端交付,`ui` 查询则只涵盖共享的跨接口契约。 +- `schedule`、`attachment` 与 `artifact` 查询直接对应各自领域,无需通过实现依赖近似归类。 - 选择标签仍然需要判断:路径和标题前缀可以提示领域,但不能替代阅读变更内容。 - 变更分类体系会产生维护工作。新增、重命名、拆分或移除领域时,需要更新本决策记录,并回填开放和已合并的 PR,使历史查询保持原有含义。 From 15c0b6290b26006a162968267733cf0de6953d38 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 26 Jul 2026 11:18:46 +0800 Subject: [PATCH 28/32] docs(notes): state the rejected per-section split without implementation history --- .../2026-07-25-client-settings-locale-theme.i18n.yaml | 4 ++-- .../architecture/2026-07-25-client-settings-locale-theme.md | 2 +- .../2026-07-25-client-settings-locale-theme.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.i18n.yaml b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.i18n.yaml index 39d7377c54..1e820bfc3a 100644 --- a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.i18n.yaml +++ b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.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 -2026-07-25-client-settings-locale-theme.md: 658e6bd3c2da39a98e476c60f10f3f51ad82e5ea -2026-07-25-client-settings-locale-theme.zh.md: dfe93a0ca1f53380c653886c73fe0c32b08d8443 +2026-07-25-client-settings-locale-theme.md: 19a46c5444ea6c56c656b6a17cd024c5897d432c +2026-07-25-client-settings-locale-theme.zh.md: d8e77ad3106780f6e39583e0bb02c2614fe67829 diff --git a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.md b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.md index 658e6bd3c2..19a46c5444 100644 --- a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.md +++ b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.md @@ -102,7 +102,7 @@ Locale ships with 中文 and English built in; `setLocale`/`setTheme` are the on **Settings importing and enumerating the sections.** Adding a page would require modifying the shell plugin, breaking the composition model where each feature occupies a slot from its own plugin. -**One `ui-settings-*` package per section (the first-cut implementation).** It divorces the settings surface from the feature itself: changing Theme behavior touches two packages, the package count grows linearly with settings items, and settings-general depending back on the locale/theme services forms an intermediate layer that exists purely for the package split. After converging on feature-owner self-registration, General belongs to the shell (it belongs to no single feature) and preference rows ship with their feature packages. +**One `ui-settings-*` package per section.** It divorces the settings surface from the feature itself: changing Theme behavior touches two packages, the package count grows linearly with settings items, and settings-general depending back on the locale/theme services forms an intermediate layer that exists purely for the package split. Under feature-owner self-registration that layer does not exist: General belongs to the shell (it belongs to no single feature) and preference rows ship with their feature packages. **Injecting the Locale/Theme snapshots into React directly.** Inject results are cached by entry identity, so volatile values go stale; hand-rolling a React hook per service also bypasses the slot store's unified binding. diff --git a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.zh.md b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.zh.md index dfe93a0ca1..d8e77ad310 100644 --- a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.zh.md +++ b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.zh.md @@ -102,7 +102,7 @@ Locale 内置中文和 English;`setLocale`/`setTheme` 是唯一写入口,未 **Settings import 并枚举各 section。** 新增页面必须修改壳插件,破坏「每个功能由自己的插件占坑」的组合模型。 -**每个 section 单开 `ui-settings-*` 包(首版实现)。** 设置面与功能本体分家:改 Theme 行为要动两个包,包数随设置项线性膨胀,且 settings-general 反向依赖 locale/theme 服务形成纯粹为拆包而生的中间层。收敛为功能属主自注册后,General 归壳(不属任何单一功能),preference 行随功能包交付。 +**每个 section 单开 `ui-settings-*` 包。** 设置面与功能本体分家:改 Theme 行为要动两个包,包数随设置项线性膨胀,且 settings-general 反向依赖 locale/theme 服务,形成纯粹为拆包而生的中间层。功能属主自注册下不存在这层:General 归壳(不属任何单一功能),preference 行随功能包交付。 **把 Locale/Theme snapshot 直接注入 React。** inject 结果按 entry identity 缓存,易变值会陈旧;为每个 service 自造 React hook 也绕开 slot store 的统一绑定。 From 9a4be33899b942d50301ec655a4ba110be64d24f Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 26 Jul 2026 12:09:38 +0800 Subject: [PATCH 29/32] docs(notes): record slot-declaration-as-service as future work deferRegistration() stays the shipped form; the note pins the follow-up direction (bridge declarations into slot: services, migrate registrants to nested ctx.inject, delete the helper) and the boundaries a separate PR must settle. --- .../2026-07-25-client-settings-locale-theme.i18n.yaml | 4 ++-- .../architecture/2026-07-25-client-settings-locale-theme.md | 6 +++++- .../2026-07-25-client-settings-locale-theme.zh.md | 6 +++++- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.i18n.yaml b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.i18n.yaml index 1e820bfc3a..a6d124a3e0 100644 --- a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.i18n.yaml +++ b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.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 -2026-07-25-client-settings-locale-theme.md: 19a46c5444ea6c56c656b6a17cd024c5897d432c -2026-07-25-client-settings-locale-theme.zh.md: d8e77ad3106780f6e39583e0bb02c2614fe67829 +2026-07-25-client-settings-locale-theme.md: 0cfa244f7e9856aede3d01a80404a794374e16be +2026-07-25-client-settings-locale-theme.zh.md: 7caa79d5409b2a11078efb8e52273dc70f89f085 diff --git a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.md b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.md index 19a46c5444..0cfa244f7e 100644 --- a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.md +++ b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.md @@ -51,7 +51,11 @@ root └─ models (order 10) ui-models 注册 ``` -Section and item contributions both use declaration-aware deferral and do not depend on the client manifest's apply order. The `settings.general.item` SlotMap entry's canonical home is the ui-settings contract; locale/ui-theme, because of the reference cycle (the shell consumes ctx.locale), consume that entry as verbatim duplicated merges, with declaration merging guaranteeing the copies agree. +Section and item contributions both use declaration-aware deferral (ui-slots' `deferRegistration()`: ledger-judged presence, `refresh()` for localized labels, one-call disposal) and do not depend on the client manifest's apply order. The `settings.general.item` SlotMap entry's canonical home is the ui-settings contract; locale/ui-theme, because of the reference cycle (the shell consumes ctx.locale), consume that entry as verbatim duplicated merges, with declaration merging guaranteeing the copies agree. + +### Future work: promote slot declarations to first-class injectable waits + +`deferRegistration()` is behaviorally isomorphic to `ctx.inject` — one waits on a ledger declaration, the other on service presence, with matching disappear/reappear lifecycle semantics; the difference is that the fiber form's disposer lifetime naturally equals the declaration's lifetime, so the stale-disposer presence-judging machinery disappears entirely. Direction (a separate PR): SlotsService bridges each slot into a `slot:` service (value = the slot spec) at declaration commit / cascade removal, registrants migrate from `deferRegistration()` to a nested `ctx.inject(['slot:'], cb)`, then `deferRegistration()` is deleted and packages/client/AGENTS.md checklist item 4 is rewritten. Boundaries to pin down: the nested fiber's harmless wait must not be named by the boot fail-loud scan (needs a test); the `slot:` namespace and the silent-wait-on-typo stance; provide keys are flat names (`slot:a.b` is one key, not a property path on `ctx.slots`). This phase keeps the `deferRegistration()` function form. ### Service contracts diff --git a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.zh.md b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.zh.md index d8e77ad310..7caa79d540 100644 --- a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.zh.md +++ b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.zh.md @@ -51,7 +51,11 @@ root └─ models (order 10) ui-models 注册 ``` -section/item contribution 均使用 declaration-aware deferral,不依赖 client manifest 的 apply 顺序。`settings.general.item` 的 SlotMap 条目正家在 ui-settings contract;locale/ui-theme 因引用环(壳消费 ctx.locale)以逐字重复合并的方式消费该条目,declaration merging 保证副本一致。 +section/item contribution 均使用 declaration-aware deferral(ui-slots 的 `deferRegistration()`:ledger 判在位、`refresh()` 换本地化 label、一键 dispose),不依赖 client manifest 的 apply 顺序。`settings.general.item` 的 SlotMap 条目正家在 ui-settings contract;locale/ui-theme 因引用环(壳消费 ctx.locale)以逐字重复合并的方式消费该条目,declaration merging 保证副本一致。 + +### Future work:坑位声明升格为可 inject 的一等等待物 + +`deferRegistration()` 与 `ctx.inject` 行为同构——一个等 ledger 声明、一个等服务在场,消失/重现的生命周期语义一致;差别只在 fiber 版的 disposer 生命周期天然等于声明生命周期,stale-disposer 判在位机器可整体消失。方向(另开 PR):SlotsService 在声明落账/级联拆除处把每个坑位桥接成 `slot:` 服务(value 为坑位 spec),注册方从 `deferRegistration()` 迁为嵌套 `ctx.inject(['slot:'], cb)`,随后删除 `deferRegistration()` 并改写 packages/client/AGENTS.md checklist 第 4 条。待钉死的边界:嵌套 fiber 的无害等待不被 boot fail-loud 扫描点名(需测试);`slot:` 名字空间与 typo 静默等待的口径;provide 键是平面名(`slot:a.b` 是一个键,不是 `ctx.slots` 的属性路径)。本期维持 `deferRegistration()` 函数形式。 ### Service contracts From a04a223babfa8630493534f46fd9d9bd9c6fc0de Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 26 Jul 2026 12:40:17 +0800 Subject: [PATCH 30/32] refactor(gui): copy-free settings shell; ui-settings-general owns ownerless copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shell is now a pure composition face: no dictionaries, no locale dependency, and three new chrome content seats (settings.trigger / settings.header / settings.close) whose slot content also carries the accessible names (trigger text, dialog aria-labelledby, visually hidden close label). ui-settings-general returns as the owner of copy that belongs to no single feature: chrome content, the General section with its item slot, and the settings dictionaries. Slot types split homes — trigger/header/close/section live in the shell contract; the settings.general.item entry moves to the locale package (the common dependency of every item registrant), with ui-theme consuming it through a re-export seam; the verbatim duplicate merges are gone and the dependency graph is a clean DAG. --- ...-25-client-settings-locale-theme.i18n.yaml | 4 +- ...2026-07-25-client-settings-locale-theme.md | 18 ++- ...6-07-25-client-settings-locale-theme.zh.md | 18 ++- apps/cli/cordis.yml | 3 + apps/cli/package.json | 7 +- apps/cli/tsconfig.json | 3 + apps/web/tests/session-title.snapshot.ts | 3 +- apps/web/tests/smoke-real.e2e.ts | 2 +- apps/web/tests/workspace-flow.snapshot.ts | 3 +- packages/client/locale/src/client/index.ts | 1 + .../locale/src/client/settings-contract.ts | 30 ++-- packages/client/ui-settings-general/README.md | 15 ++ .../client/ui-settings-general/package.json | 67 ++++++++ .../src/client/GeneralSection.module.css | 0 .../src/client/GeneralSection.tsx | 22 ++- .../src/client/chrome.module.css | 7 + .../ui-settings-general/src/client/chrome.tsx | 56 +++++++ .../ui-settings-general/src/client/index.ts | 87 ++++++++++ .../src/client/locales.ts | 0 .../ui-settings-general/src/css-modules.d.ts | 6 + .../client/ui-settings-general/src/index.ts | 4 + .../ui-settings-general/src/invariant.ts | 32 ++++ .../ui-settings-general/tests/apply.spec.ts | 150 ++++++++++++++++++ .../tests/components.spec.tsx} | 53 +++++-- .../tests/invariant.spec.ts | 18 +++ .../client/ui-settings-general/tsconfig.json | 33 ++++ .../ui-settings-general/tsdown.config.ts | 3 + packages/client/ui-settings/README.md | 4 +- packages/client/ui-settings/package.json | 4 +- .../src/client/SettingsRoot.module.css | 10 ++ .../ui-settings/src/client/SettingsRoot.tsx | 43 +++-- .../ui-settings/src/client/contract/slots.ts | 97 +++++------ .../client/ui-settings/src/client/index.ts | 76 +++------ .../client/ui-settings/tests/apply.spec.ts | 144 ++++------------- .../ui-settings/tests/settings-root.spec.tsx | 57 +++++-- packages/client/ui-settings/tsconfig.json | 3 - .../ui-theme/src/client/settings-contract.ts | 22 +-- pnpm-lock.yaml | 36 ++++- .../verify-package-readme-model-experience.ts | 1 + tsconfig.base.json | 1 + tsconfig.client.json | 1 + 41 files changed, 808 insertions(+), 336 deletions(-) create mode 100644 packages/client/ui-settings-general/README.md create mode 100644 packages/client/ui-settings-general/package.json rename packages/client/{ui-settings => ui-settings-general}/src/client/GeneralSection.module.css (100%) rename packages/client/{ui-settings => ui-settings-general}/src/client/GeneralSection.tsx (66%) create mode 100644 packages/client/ui-settings-general/src/client/chrome.module.css create mode 100644 packages/client/ui-settings-general/src/client/chrome.tsx create mode 100644 packages/client/ui-settings-general/src/client/index.ts rename packages/client/{ui-settings => ui-settings-general}/src/client/locales.ts (100%) create mode 100644 packages/client/ui-settings-general/src/css-modules.d.ts create mode 100644 packages/client/ui-settings-general/src/index.ts create mode 100644 packages/client/ui-settings-general/src/invariant.ts create mode 100644 packages/client/ui-settings-general/tests/apply.spec.ts rename packages/client/{ui-settings/tests/general-section.spec.tsx => ui-settings-general/tests/components.spec.tsx} (51%) create mode 100644 packages/client/ui-settings-general/tests/invariant.spec.ts create mode 100644 packages/client/ui-settings-general/tsconfig.json create mode 100644 packages/client/ui-settings-general/tsdown.config.ts diff --git a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.i18n.yaml b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.i18n.yaml index a6d124a3e0..15d611d8ce 100644 --- a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.i18n.yaml +++ b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.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 -2026-07-25-client-settings-locale-theme.md: 0cfa244f7e9856aede3d01a80404a794374e16be -2026-07-25-client-settings-locale-theme.zh.md: 7caa79d5409b2a11078efb8e52273dc70f89f085 +2026-07-25-client-settings-locale-theme.md: b6a127037c50066fe9aa501bb73005b9c56869fa +2026-07-25-client-settings-locale-theme.zh.md: a871f06945fb420016df95b23167e72f59c3e5c5 diff --git a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.md b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.md index 0cfa244f7e..b6a127037c 100644 --- a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.md +++ b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.md @@ -10,9 +10,9 @@ The browser client's existing Settings is written directly inside the Sidebar, a ## Proposal -**Collaboration doctrine (how every later module joins Settings): feature owners self-register.** The Settings shell provides only the composition surface (the top-level section list plus the item list inside General) and neither imports nor enumerates any feature; for a feature to appear in Settings, its own plugin registers into the corresponding slot — locale registers the Language row, ui-theme registers the Appearance row, ui-models registers the Models top-level panel. No separate `ui-settings-*` package is created for "a feature's settings page": the settings surface belongs to the feature package itself (shipping the Theme feature means Theme's settings choices ship with ui-theme). The only content the shell carries itself is the first top-level directory, General (skeleton rows plus the item slot declaration), because it belongs to no single feature. +**Collaboration doctrine (how every later module joins Settings): feature owners self-register.** The Settings shell is a pure composition surface: it only declares slots and renders the chrome structure — zero copy, no locale dependency, and neither importing nor enumerating any feature; for a feature to appear in Settings, its own plugin registers into the corresponding slot — locale registers the Language row, ui-theme registers the Appearance row, ui-models registers the Models top-level panel. No separate `ui-settings-*` package is created for "a feature's settings page": the settings surface belongs to the feature package itself (shipping the Theme feature means Theme's settings choices ship with ui-theme). Content that belongs to no single feature (the trigger/title/close chrome copy, the General directory with its skeleton rows, the `settings` dictionary) is owned by `ui-settings-general` — the owner of the ownerless copy, not a feature satellite package. -The Sidebar declares the `sidebar.settings` single slot; `ui-settings` occupies it and declares the `settings.section` list slot. Each section is contributed by a feature plugin; the Settings shell only reads entry metadata from the slot ledger to build the navigation, rendering the current section via `only`. General is registered by the shell itself (order 0) and declares the `settings.general.item` list slot, into which the feature plugins' preference rows slot by order. +The Sidebar declares the `sidebar.settings` single slot; `ui-settings` occupies it and declares four slots: `settings.trigger` / `settings.header` / `settings.close` (chrome content seats, single) and `settings.section` (top-level pages, list). Accessible names all resolve from slot content: the trigger's accessible name is its text content, the dialog points at the header content node via aria-labelledby, and close is a visually hidden text seat. Each section is contributed by a feature plugin; the shell only reads entry metadata from the slot ledger to build the navigation, rendering the current section via `only`. General is registered by `ui-settings-general` (order 0) and declares the `settings.general.item` list slot, into which the feature plugins' preference rows slot by order. The Settings entry is the Settings row in the sidebar Foot; clicking it directly opens a 1080×700 centered overlay (black 24% mask); the close button, a mask click, and ESC all close it. There is no intermediate menu form of any kind. @@ -28,13 +28,14 @@ The theme service never touches the DOM. `ui-layout` reads the Theme getter init | Registration surface | Owning plugin | First-phase content | |---|---|---| -| General section (order 0) | built into the `ui-settings` shell | Permission and Tool Call visual skeletons (no write operations) plus the `settings.general.item` slot declaration | +| chrome content (trigger/header/close) | `ui-settings-general` | Settings entry-row icon and copy, panel title, close hidden text | +| General section (order 0) | `ui-settings-general` | Permission and Tool Call visual skeletons (no write operations) plus the `settings.general.item` slot declaration | | Language row (item order 0) | `locale` | Selector dropdown; 中文/English genuinely switch | | Appearance row (item order 10) | `ui-theme` | Light/Dark/System three cubes genuinely switch (the selected state reflects preference) | | Models section (order 10) | `ui-models` | Navigation item only, with an empty content area; later model-management features land in that package | | Plugin | none | Not built this phase, and the navigation does not show the item (once a later plugin feature package registers the section it appears automatically) | -The first phase localizes only the copy inside the Settings overlay; dictionaries stay close to their owners — shell copy (the chrome plus the General skeletons) lives in the `settings` namespace, and feature-row copy lives in each feature package (`settings.locale`, `settings.theme`, `settings.models`). +The first phase localizes only the copy inside the Settings overlay; dictionaries stay close to their owners — the chrome plus the General skeletons live in `ui-settings-general`'s `settings` namespace, and feature-row copy lives in each feature package (`settings.locale`, `settings.theme`, `settings.models`). ### Slot topology @@ -42,16 +43,19 @@ The first phase localizes only the copy inside the Settings overlay; dictionarie root └─ sidebar └─ sidebar.settings single/root - └─ ui-settings(壳) + └─ ui-settings(壳,零文案) + ├─ settings.trigger single/root ui-settings-general 注册 + ├─ settings.header single/root ui-settings-general 注册 + ├─ settings.close single/root ui-settings-general 注册 └─ settings.section list/root - ├─ general (order 0) ui-settings 壳自带 + ├─ general (order 0) ui-settings-general 注册 │ └─ settings.general.item list/root │ ├─ language (0) locale 注册 │ └─ appearance (10) ui-theme 注册 └─ models (order 10) ui-models 注册 ``` -Section and item contributions both use declaration-aware deferral (ui-slots' `deferRegistration()`: ledger-judged presence, `refresh()` for localized labels, one-call disposal) and do not depend on the client manifest's apply order. The `settings.general.item` SlotMap entry's canonical home is the ui-settings contract; locale/ui-theme, because of the reference cycle (the shell consumes ctx.locale), consume that entry as verbatim duplicated merges, with declaration merging guaranteeing the copies agree. +Section and item contributions both use declaration-aware deferral (ui-slots' `deferRegistration()`: ledger-judged presence, `refresh()` for localized labels, one-call disposal) and do not depend on the client manifest's apply order. The SlotMap types split homes: trigger/header/close/section have their canonical home in the ui-settings contract (the consumers, general and models, both depend on the shell — no cycle); `settings.general.item`'s canonical home is the locale package — it is the lowest common dependency of all item registrants (a settings row always carries copy), while the declarer general's contract is unreachable from locale/ui-theme (it would form a cycle); ui-theme consumes it through a re-export seam. ### Future work: promote slot declarations to first-class injectable waits diff --git a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.zh.md b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.zh.md index 7caa79d540..a871f06945 100644 --- a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.zh.md +++ b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.zh.md @@ -10,9 +10,9 @@ Status: proposed ## Proposal -**协作导向(后续所有模块接入 Settings 的方式):功能属主自注册。** Settings 壳只提供组合面(一级 section 列表 + General 内的 item 列表),不 import 也不枚举任何功能;一个功能要出现在 Settings 里,由它自己的插件向对应坑位注册——locale 注册 Language 行,ui-theme 注册 Appearance 行,ui-models 注册 Models 一级面板。不为「某功能的设置页」单开 `ui-settings-*` 包:设置面属于功能包本身(做 Theme 功能,Theme 的设置选择就随 ui-theme 一起交付)。壳自带的唯一内容是第一个一级目录 General(骨架行 + item 坑位声明),因为它不属于任何单一功能。 +**协作导向(后续所有模块接入 Settings 的方式):功能属主自注册。** Settings 壳是纯组合面:只声明坑位、渲染 chrome 结构,零文案、不依赖 locale、不 import 也不枚举任何功能;一个功能要出现在 Settings 里,由它自己的插件向对应坑位注册——locale 注册 Language 行,ui-theme 注册 Appearance 行,ui-models 注册 Models 一级面板。不为「某功能的设置页」单开 `ui-settings-*` 包:设置面属于功能包本身(做 Theme 功能,Theme 的设置选择就随 ui-theme 一起交付)。不属于任何单一功能的内容(trigger/标题/close 的 chrome 文案、General 目录与骨架行、`settings` 字典)由 `ui-settings-general` 拥有——它是「无主文案」的属主,不是功能卫星包。 -Sidebar 声明 `sidebar.settings` 单坑位,`ui-settings` 占用它并声明 `settings.section` list 坑位。每个 section 由功能插件贡献;Settings 壳只从 slot ledger 读取 entry metadata 生成导航,通过 `only` 渲染当前 section。General 由壳自己注册(order 0)并声明 `settings.general.item` list 坑位,功能插件的偏好行按 order 排入。 +Sidebar 声明 `sidebar.settings` 单坑位,`ui-settings` 占用它并声明四个坑:`settings.trigger` / `settings.header` / `settings.close`(chrome 内容座,single)与 `settings.section`(一级页面,list)。无障碍名全部解析自坑内容:trigger 的可达名即其文本内容,dialog 经 aria-labelledby 指向 header 内容节点,close 是视觉隐藏文本座。每个 section 由功能插件贡献;壳只从 slot ledger 读取 entry metadata 生成导航,通过 `only` 渲染当前 section。General 由 `ui-settings-general` 注册(order 0)并声明 `settings.general.item` list 坑位,功能插件的偏好行按 order 排入。 Settings 入口是 sidebar Foot 的 Settings 行,点击直接打开 1080×700 居中浮层(黑 24% 遮罩);close 按钮、点击遮罩、ESC 均关闭。无任何中间菜单形态。 @@ -28,13 +28,14 @@ Theme service 不操作 DOM。`ui-layout` 初始读取 Theme getter,随后订 | 注册面 | 属主插件 | 首期内容 | |---|---|---| -| General section(order 0)| `ui-settings` 壳自带 | Permission、Tool Call 视觉骨架(无写操作)+ `settings.general.item` 坑位声明 | +| chrome 内容(trigger/header/close)| `ui-settings-general` | 设置入口行图标+文案、面板标题、close 隐藏文本 | +| General section(order 0)| `ui-settings-general` | Permission、Tool Call 视觉骨架(无写操作)+ `settings.general.item` 坑位声明 | | Language 行(item order 0)| `locale` | Selector 下拉,中文/English 真实可切 | | Appearance 行(item order 10)| `ui-theme` | Light/Dark/System 三 cube 真实可切(选中态看 preference) | | Models section(order 10)| `ui-models` | 仅导航项,内容区为空;后续模型管理功能落在该包 | | Plugin | 无 | 首期不做,导航不出现该项(后续插件功能包注册 section 即自动出现) | -首期只翻译 Settings 浮层内文案;字典就近——壳文案(chrome + General 骨架)归 `settings` namespace,功能行文案归各功能包(`settings.locale`、`settings.theme`、`settings.models`)。 +首期只翻译 Settings 浮层内文案;字典就近——chrome + General 骨架归 `ui-settings-general` 的 `settings` namespace,功能行文案归各功能包(`settings.locale`、`settings.theme`、`settings.models`)。 ### Slot topology @@ -42,16 +43,19 @@ Theme service 不操作 DOM。`ui-layout` 初始读取 Theme getter,随后订 root └─ sidebar └─ sidebar.settings single/root - └─ ui-settings(壳) + └─ ui-settings(壳,零文案) + ├─ settings.trigger single/root ui-settings-general 注册 + ├─ settings.header single/root ui-settings-general 注册 + ├─ settings.close single/root ui-settings-general 注册 └─ settings.section list/root - ├─ general (order 0) ui-settings 壳自带 + ├─ general (order 0) ui-settings-general 注册 │ └─ settings.general.item list/root │ ├─ language (0) locale 注册 │ └─ appearance (10) ui-theme 注册 └─ models (order 10) ui-models 注册 ``` -section/item contribution 均使用 declaration-aware deferral(ui-slots 的 `deferRegistration()`:ledger 判在位、`refresh()` 换本地化 label、一键 dispose),不依赖 client manifest 的 apply 顺序。`settings.general.item` 的 SlotMap 条目正家在 ui-settings contract;locale/ui-theme 因引用环(壳消费 ctx.locale)以逐字重复合并的方式消费该条目,declaration merging 保证副本一致。 +section/item contribution 均使用 declaration-aware deferral(ui-slots 的 `deferRegistration()`:ledger 判在位、`refresh()` 换本地化 label、一键 dispose),不依赖 client manifest 的 apply 顺序。SlotMap 类型分家:trigger/header/close/section 正家在 ui-settings contract(消费者 general/models 均依赖壳,无环);`settings.general.item` 正家在 locale 包——它是全部 item 注册方的最低公共依赖(设置行必带文案),而声明方 general 的 contract 对 locale/ui-theme 不可达(会成环);ui-theme 经 re-export seam 消费。 ### Future work:坑位声明升格为可 inject 的一等等待物 diff --git a/apps/cli/cordis.yml b/apps/cli/cordis.yml index 39803aa603..a30d5735f8 100644 --- a/apps/cli/cordis.yml +++ b/apps/cli/cordis.yml @@ -233,6 +233,9 @@ - id: ui-settings name: '@deepseek-ai/dsh-client-ui-settings' +- id: ui-settings-general + name: '@deepseek-ai/dsh-client-ui-settings-general' + - id: ui-models name: '@deepseek-ai/dsh-client-ui-models' diff --git a/apps/cli/package.json b/apps/cli/package.json index ddf8a08a87..810cc85bb9 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -28,10 +28,11 @@ "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-layout": "workspace:^", - "@deepseek-ai/dsh-client-ui-question": "workspace:^", - "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", - "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-client-ui-models": "workspace:^", + "@deepseek-ai/dsh-client-ui-question": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings-general": "workspace:^", + "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", "@deepseek-ai/dsh-client-ui-theme": "workspace:^", "@deepseek-ai/dsh-client-ui-trajectory": "workspace:^", "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index 3000fd1f8d..05947889b2 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -44,6 +44,9 @@ { "path": "../../packages/client/ui-settings" }, + { + "path": "../../packages/client/ui-settings-general" + }, { "path": "../../packages/client/ui-models" }, diff --git a/apps/web/tests/session-title.snapshot.ts b/apps/web/tests/session-title.snapshot.ts index 35356fa0b3..4a11e54e2f 100644 --- a/apps/web/tests/session-title.snapshot.ts +++ b/apps/web/tests/session-title.snapshot.ts @@ -13,7 +13,8 @@ const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [ { id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true }, { id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] }, { id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, - { id: '@deepseek-ai/dsh-client-ui-settings', dir: 'ui-settings', url: '/plugins/ui-settings.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-sidebar', '@deepseek-ai/dsh-client-locale'] }, + { id: '@deepseek-ai/dsh-client-ui-settings', dir: 'ui-settings', url: '/plugins/ui-settings.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-sidebar'] }, + { id: '@deepseek-ai/dsh-client-ui-settings-general', dir: 'ui-settings-general', url: '/plugins/ui-settings-general.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-settings', '@deepseek-ai/dsh-client-locale'] }, { id: '@deepseek-ai/dsh-client-ui-models', dir: 'ui-models', url: '/plugins/ui-models.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-settings'] }, { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, { id: '@deepseek-ai/dsh-client-ui-workspace', dir: 'ui-workspace', url: '/plugins/ui-workspace.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-conversation', '@deepseek-ai/dsh-client-ui-sidebar'] }, diff --git a/apps/web/tests/smoke-real.e2e.ts b/apps/web/tests/smoke-real.e2e.ts index 70d1d81523..a268db107a 100644 --- a/apps/web/tests/smoke-real.e2e.ts +++ b/apps/web/tests/smoke-real.e2e.ts @@ -147,7 +147,7 @@ async function detailsTrack(page: Page): Promise { // Readiness gate: `dsh web` serves ALL nine manifest plugins; until every UI // plugin's client bundle exists and exports apply, the loader fail-louds and // the frame never appears. -const UI_PLUGIN_DIRS = ['connection', 'runtime', 'ui-theme', 'locale', 'ui-layout', 'ui-sidebar', 'ui-conversation', 'ui-question', 'ui-trajectory'] +const UI_PLUGIN_DIRS = ['connection', 'runtime', 'ui-theme', 'locale', 'ui-layout', 'ui-sidebar', 'ui-settings', 'ui-settings-general', 'ui-models', 'ui-conversation', 'ui-question', 'ui-trajectory'] const ROUND_DONE_MARKER = 'WEB_ROUND_DONE' const notReady = UI_PLUGIN_DIRS.filter((dir) => { const bundle = join(REPO_ROOT, 'packages/client', dir, 'lib/client.js') diff --git a/apps/web/tests/workspace-flow.snapshot.ts b/apps/web/tests/workspace-flow.snapshot.ts index 738357a551..2130d27306 100644 --- a/apps/web/tests/workspace-flow.snapshot.ts +++ b/apps/web/tests/workspace-flow.snapshot.ts @@ -13,7 +13,8 @@ const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [ { id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true }, { id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] }, { id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, - { id: '@deepseek-ai/dsh-client-ui-settings', dir: 'ui-settings', url: '/plugins/ui-settings.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-sidebar', '@deepseek-ai/dsh-client-locale'] }, + { id: '@deepseek-ai/dsh-client-ui-settings', dir: 'ui-settings', url: '/plugins/ui-settings.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-sidebar'] }, + { id: '@deepseek-ai/dsh-client-ui-settings-general', dir: 'ui-settings-general', url: '/plugins/ui-settings-general.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-settings', '@deepseek-ai/dsh-client-locale'] }, { id: '@deepseek-ai/dsh-client-ui-models', dir: 'ui-models', url: '/plugins/ui-models.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-settings'] }, { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, { diff --git a/packages/client/locale/src/client/index.ts b/packages/client/locale/src/client/index.ts index ea6f9d8da9..cb6bb6f861 100644 --- a/packages/client/locale/src/client/index.ts +++ b/packages/client/locale/src/client/index.ts @@ -15,6 +15,7 @@ import { createLanguageRowStore } from './settings-store.ts' export type { LanguageRowComponentProps, LanguageRowInjected } from './LanguageRow.tsx' export type { LanguageOptionRow, LanguageRowState } from './settings-store.ts' +export type { SettingsGeneralItemOwnerProps } from './settings-contract.ts' /** Translate a key with optional params. */ export type Translate = (key: string, params?: Record) => string diff --git a/packages/client/locale/src/client/settings-contract.ts b/packages/client/locale/src/client/settings-contract.ts index add314122c..e032707645 100644 --- a/packages/client/locale/src/client/settings-contract.ts +++ b/packages/client/locale/src/client/settings-contract.ts @@ -1,18 +1,26 @@ /** - * Settings-surface slot merge consumed by this package's Language row. The - * AUTHORITATIVE home for 'settings.general.item' is the ui-settings contract - * (declaring is claiming: the shell's General entry declares the slot); this - * file repeats the entry verbatim because the shell consumes ctx.locale - * (project reference ui-settings -> locale), so importing the shell's types - * from here would close a reference cycle. TypeScript declaration merging - * rejects diverging duplicates, so every program that sees both copies (the - * shell's own build, the client aggregate) enforces identity. + * The `settings.general.item` slot type — one preference row inside the + * settings General section, contributed by the feature plugin that owns the + * preference (locale → Language, ui-theme → Appearance). Options: `id` (row + * key), `order` (row position). Rows draw their own internals (row layout, + * separators via CSS); the section column only stacks them. + * + * TYPE HOME RATIONALE: the slot is declared at runtime by + * ui-settings-general's General entry, but its type lives here — this + * package is the common dependency of every item registrant (any settings + * row carries copy, so every registrant already depends on locale), whereas + * the declarer's own contract is unreachable for locale/ui-theme without a + * reference cycle. */ declare module '@deepseek-ai/dsh-client-ui-slots' { interface SlotMap { - /** One preference row inside the General section (duplicate-identical merge; authority: ui-settings contract). */ - 'settings.general.item': { kind: 'list'; scope: 'root'; owner: { children?: never } } + /** One preference row inside the settings General section (see module JSDoc). */ + 'settings.general.item': { kind: 'list'; scope: 'root'; owner: SettingsGeneralItemOwnerProps } } } -export {} +/** Owner share of a General preference row (the section supplies nothing). */ +export interface SettingsGeneralItemOwnerProps { + /** Marker field: item owner props are intentionally empty. */ + children?: never +} diff --git a/packages/client/ui-settings-general/README.md b/packages/client/ui-settings-general/README.md new file mode 100644 index 0000000000..0e0025279b --- /dev/null +++ b/packages/client/ui-settings-general/README.md @@ -0,0 +1,15 @@ +# @deepseek-ai/dsh-client-ui-settings-general + +Settings ownerless-copy plugin: registers everything on the Settings surface that belongs to no single feature — the shell's trigger/header/close chrome content, the General section (Permission/Tool Call skeleton rows + the `settings.general.item` slot declaration), and the `settings` dictionaries. Feature-owned rows (Language, Appearance) and sections (Models) stay with their feature packages. + +## Model Experience + +None, as the plugin renders browser settings UI; nothing here reaches a model request. + +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + +## Known Limitations and Deferred Work + +- **Permission and Tool Call are display skeletons** — the backing host services and RPC methods do not exist yet; the controls are disabled and write nothing. When they gain real backing, each moves to its owning feature plugin per the self-registration doctrine. diff --git a/packages/client/ui-settings-general/package.json b/packages/client/ui-settings-general/package.json new file mode 100644 index 0000000000..798a7710f0 --- /dev/null +++ b/packages/client/ui-settings-general/package.json @@ -0,0 +1,67 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-settings-general", + "description": "Settings ownerless-copy plugin: the General section (skeleton rows + item slot), the shell trigger/header chrome content, and the settings dictionaries", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dshClient": { + "inject": [ + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-ui-settings", + "@deepseek-ai/dsh-client-locale" + ], + "platform": "web" + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-client-locale": "^0.0.1", + "@deepseek-ai/dsh-client-runtime": "^0.0.1", + "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", + "@deepseek-ai/dsh-client-ui-settings": "^0.0.1", + "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.7", + "react": "^18.2.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@types/react": "~18.3.1", + "cordis": "^4.0.0-rc.7", + "react": "^18.2.0" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/client.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ] +} diff --git a/packages/client/ui-settings/src/client/GeneralSection.module.css b/packages/client/ui-settings-general/src/client/GeneralSection.module.css similarity index 100% rename from packages/client/ui-settings/src/client/GeneralSection.module.css rename to packages/client/ui-settings-general/src/client/GeneralSection.module.css diff --git a/packages/client/ui-settings/src/client/GeneralSection.tsx b/packages/client/ui-settings-general/src/client/GeneralSection.tsx similarity index 66% rename from packages/client/ui-settings/src/client/GeneralSection.tsx rename to packages/client/ui-settings-general/src/client/GeneralSection.tsx index 31714a9238..8c75e76022 100644 --- a/packages/client/ui-settings/src/client/GeneralSection.tsx +++ b/packages/client/ui-settings-general/src/client/GeneralSection.tsx @@ -1,14 +1,24 @@ /** - * Shell-owned General section (figma 501:29983 'Options'): Permission and - * Tool Call skeleton rows, then the feature-contributed preference rows from - * the `settings.general.item` slot (locale → Language, ui-theme → - * Appearance). The section column stacks rows; each row draws its own - * internals and separator. + * The General section (figma 501:29983 'Options'): Permission and Tool Call + * skeleton rows, then the feature-contributed preference rows from the + * `settings.general.item` slot (locale → Language, ui-theme → Appearance). + * The section column stacks rows; each row draws its own internals and + * separator. */ import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' -import type { GeneralSectionComponentProps } from './contract/slots.ts' +import type { PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import css from './GeneralSection.module.css' +/** Injected face of the General section: the settings-namespace translate. */ +export interface GeneralSectionInjected { + /** Translate a `settings` dictionary key to the active-locale text. */ + t: (key: string) => string +} + +/** Full component props: section owner share + item render share + inject face. */ +export type GeneralSectionComponentProps = + PropsRuntime<'settings.section'> & PropsRenderSlots<'settings.general.item'> & GeneralSectionInjected + /** * Render the General section content column. * @param props - composed slot props (contract/slots.ts). diff --git a/packages/client/ui-settings-general/src/client/chrome.module.css b/packages/client/ui-settings-general/src/client/chrome.module.css new file mode 100644 index 0000000000..3291b3cd66 --- /dev/null +++ b/packages/client/ui-settings-general/src/client/chrome.module.css @@ -0,0 +1,7 @@ +/* Trigger row label (the shell's button provides layout/colors; the label + * only guards against overflow during the sidebar collapse crossfade). */ + +.triggerLabel { + overflow: hidden; + white-space: nowrap; +} diff --git a/packages/client/ui-settings-general/src/client/chrome.tsx b/packages/client/ui-settings-general/src/client/chrome.tsx new file mode 100644 index 0000000000..dc7ec94a29 --- /dev/null +++ b/packages/client/ui-settings-general/src/client/chrome.tsx @@ -0,0 +1,56 @@ +/** + * Shell chrome content registered into the shell's trigger/header seats: the + * trigger row icon + label (figma sidebar foot) and the panel title text. + * The shell renders the surrounding chrome (button, nav heading row) and + * reads each entry's `label` option for aria text. + */ +import { IconSettingsOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' +import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import css from './chrome.module.css' + +/** Injected face of both chrome seats: the settings-namespace translate. */ +export interface ChromeInjected { + /** Translate a `settings` dictionary key to the active-locale text. */ + t: (key: string) => string +} + +/** Trigger content props: the sidebar column state + translate. */ +export type TriggerContentProps = PropsRuntime<'settings.trigger'> & ChromeInjected + +/** Header content props: translate only. */ +export type HeaderContentProps = PropsRuntime<'settings.header'> & ChromeInjected + +/** + * Render the trigger row content (icon; label only in the wide column). + * @param props - composed slot props. + * @returns the trigger content fragment. + */ +export function TriggerContent({ wide, t }: TriggerContentProps) { + return ( + <> + + {wide && {t('trigger')}} + + ) +} + +/** + * Render the panel title text. + * @param props - composed slot props. + * @returns the title text node. + */ +export function HeaderContent({ t }: HeaderContentProps) { + return <>{t('title')} +} + +/** Close-button label text props: translate only. */ +export type CloseLabelProps = PropsRuntime<'settings.close'> & ChromeInjected + +/** + * Render the close button's visually-hidden label text. + * @param props - composed slot props. + * @returns the label text node. + */ +export function CloseLabel({ t }: CloseLabelProps) { + return <>{t('close')} +} diff --git a/packages/client/ui-settings-general/src/client/index.ts b/packages/client/ui-settings-general/src/client/index.ts new file mode 100644 index 0000000000..afb37d8b92 --- /dev/null +++ b/packages/client/ui-settings-general/src/client/index.ts @@ -0,0 +1,87 @@ +/** + * Settings ownerless-copy plugin, browser half: registers everything on the + * Settings surface that belongs to no single feature — the trigger/header + * chrome content, the General section (skeleton rows + the + * `settings.general.item` slot declaration), and the `settings` + * dictionaries. Feature-owned rows and sections stay with their features. + * Export discipline: packages/client/AGENTS.md. + */ +import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots' +// Type-only: pulls the shell's SlotMap merges (trigger/header/section/item). +import type {} from '@deepseek-ai/dsh-client-ui-settings/client' +import type { ChromeInjected } from './chrome.tsx' +import { CloseLabel, HeaderContent, TriggerContent } from './chrome.tsx' +import type { GeneralSectionInjected } from './GeneralSection.tsx' +import { GeneralSection } from './GeneralSection.tsx' +import { en, zh } from './locales.ts' + +export type { + ChromeInjected, CloseLabelProps, HeaderContentProps, TriggerContentProps, +} from './chrome.tsx' +export type { + GeneralSectionComponentProps, GeneralSectionInjected, +} from './GeneralSection.tsx' + +/** Dictionary namespace owned by this plugin (shell chrome + General copy). */ +const NS = 'settings' + +/** + * Required services (cordis fiber inject). The target slots are declared by + * ui-settings' apply, whose activation order relative to this one is NOT + * constrained; registration goes through declaration-aware deferral. + */ +export const inject = ['slots', 'locale'] + +/** + * Register the `settings` dictionaries, the chrome content, and the General + * section, each once its slot declaration is on the ledger. + * @param ctx - client root context. + */ +export function apply(ctx: ClientContext): void { + ctx.effect(() => { + const disposers = [ + ctx.locale.register(NS, 'zh', zh), + ctx.locale.register(NS, 'en', en), + ] + return () => { for (const dispose of disposers) dispose() } + }, 'ui-settings-general: dictionaries') + + const t = ctx.locale.bind(NS) + const chromeInjected = (): ChromeInjected => ({ t }) + const generalInjected = (): GeneralSectionInjected => ({ t }) + + // All four seats refresh on locale change: re-registration bumps each + // slot's ledger version, which re-renders the outlets through their own + // subscriptions (outlet memoization would swallow a parent-only render). + ctx.effect(() => { + const trigger = deferRegistration(ctx.slots, 'settings.trigger', TriggerContent, () => + ctx.slots.register({ name: 'settings.trigger', inject: chromeInjected }, TriggerContent)) + const header = deferRegistration(ctx.slots, 'settings.header', HeaderContent, () => + ctx.slots.register({ name: 'settings.header', inject: chromeInjected }, HeaderContent)) + const close = deferRegistration(ctx.slots, 'settings.close', CloseLabel, () => + ctx.slots.register({ name: 'settings.close', inject: chromeInjected }, CloseLabel)) + const general = deferRegistration(ctx.slots, 'settings.section', GeneralSection, () => + ctx.slots.register({ + name: 'settings.section', + id: 'general', + order: 0, + label: t('general.nav'), + children: { 'settings.general.item': { kind: 'list', scope: 'root' } }, + inject: generalInjected, + }, GeneralSection)) + const offLocale = ctx.on('locale/change', () => { + trigger.refresh() + header.refresh() + close.refresh() + general.refresh() + }) + return () => { + offLocale() + trigger.dispose() + header.dispose() + close.dispose() + general.dispose() + } + }, 'ui-settings-general: chrome and section registrations') +} diff --git a/packages/client/ui-settings/src/client/locales.ts b/packages/client/ui-settings-general/src/client/locales.ts similarity index 100% rename from packages/client/ui-settings/src/client/locales.ts rename to packages/client/ui-settings-general/src/client/locales.ts diff --git a/packages/client/ui-settings-general/src/css-modules.d.ts b/packages/client/ui-settings-general/src/css-modules.d.ts new file mode 100644 index 0000000000..bc5e482353 --- /dev/null +++ b/packages/client/ui-settings-general/src/css-modules.d.ts @@ -0,0 +1,6 @@ +declare module '*.module.css' { + const classes: Record + export default classes +} + +declare module '*.css' diff --git a/packages/client/ui-settings-general/src/index.ts b/packages/client/ui-settings-general/src/index.ts new file mode 100644 index 0000000000..94b9bdf674 --- /dev/null +++ b/packages/client/ui-settings-general/src/index.ts @@ -0,0 +1,4 @@ +/** Host loader entry for the browser implementation exported from `./client`. */ + +/** Host plugin body — no host-side behavior for the general settings plugin. */ +export function apply(): void {} diff --git a/packages/client/ui-settings-general/src/invariant.ts b/packages/client/ui-settings-general/src/invariant.ts new file mode 100644 index 0000000000..29f762834d --- /dev/null +++ b/packages/client/ui-settings-general/src/invariant.ts @@ -0,0 +1,32 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-settings-general`. + * @module @deepseek-ai/dsh-client-ui-settings-general/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-settings-general' + +/** Cordis companion plugin name. */ +export const name = 'client-ui-settings-general-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: a copy-owning registrant contributing chrome content + * and the General section into shell-declared slots — it emits no cordis + * events and owns no cross-plugin mutable relation; slot conflicts already + * fail loud in the slot core at load time. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/client/ui-settings-general/tests/apply.spec.ts b/packages/client/ui-settings-general/tests/apply.spec.ts new file mode 100644 index 0000000000..d01be576b7 --- /dev/null +++ b/packages/client/ui-settings-general/tests/apply.spec.ts @@ -0,0 +1,150 @@ +/** Ownerless-copy registrations: the four seats, the dictionaries, locale refresh, and HMR recovery. */ +import { Context } from 'cordis' +import { describe, expect, it } from 'vitest' +import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' +import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' +import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings-general/client' +import type { GeneralSectionInjected } from '@deepseek-ai/dsh-client-ui-settings-general/client' +import { CloseLabel, HeaderContent, TriggerContent } from '../src/client/chrome.tsx' +import { GeneralSection } from '../src/client/GeneralSection.tsx' + +/** The four seats this plugin fills (slot name → expected component). */ +const SEATS = [ + ['settings.trigger', TriggerContent], + ['settings.header', HeaderContent], + ['settings.close', CloseLabel], + ['settings.section', GeneralSection], +] as const + +async function bench() { + const ctx = new Context() + await ctx.plugin(SlotsService).await() + const locale = new LocaleService(ctx) + ctx.provide('locale', locale) + return { ctx, slots: ctx.get('slots') as SlotsService, locale } +} + +/** Declare the shell's four child slots the way ui-settings' entry does. */ +function declare(slots: SlotsService): () => void { + return slots.register( + { + name: 'root', + children: { + 'settings.trigger': { kind: 'single', scope: 'root' }, + 'settings.header': { kind: 'single', scope: 'root' }, + 'settings.close': { kind: 'single', scope: 'root' }, + 'settings.section': { kind: 'list', scope: 'root' }, + }, + } as never, + () => null, + ) +} + +function generalEntry(slots: SlotsService) { + return slots.entries('settings.section').find(e => e.component === GeneralSection) +} + +describe('ui-settings-general apply', () => { + it('declares the services it uses', () => { + expect(inject).toEqual(['slots', 'locale']) + }) + + it('fills all four seats for declarations before or after apply', async () => { + const before = await bench() + declare(before.slots) + await before.ctx.plugin({ inject: [...inject], apply }).await() + for (const [name, component] of SEATS) { + expect(before.slots.entries(name)[0]!.component).toBe(component) + } + const entry = generalEntry(before.slots)! + expect(entry.options).toEqual({ id: 'general', order: 0, label: '通用设置' }) + expect(before.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' }) + const injected = (entry.inject as unknown as () => GeneralSectionInjected)() + expect(injected.t('permission.title')).toBe('权限') + // The chrome seats share one inject face: the settings-ns translate. + const chrome = (before.slots.entries('settings.trigger')[0]!.inject as unknown as () => GeneralSectionInjected)() + expect(chrome.t('trigger')).toBe('设置') + + const after = await bench() + await after.ctx.plugin({ inject: [...inject], apply }).await() + for (const [name] of SEATS) expect(after.slots.entries(name)).toHaveLength(0) + declare(after.slots) + await Promise.resolve() + for (const [name, component] of SEATS) { + expect(after.slots.entries(name)[0]!.component).toBe(component) + // The self-inflicted ledger notifications hit the duplicate guard. + expect(after.slots.entries(name)).toHaveLength(1) + } + }) + + it('registers the zh/en settings dictionaries and frees the seats on teardown', async () => { + const b = await bench() + declare(b.slots) + const fiber = b.ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + expect(b.locale.bind('settings')('title')).toBe('设置') + b.locale.setLocale('en') + expect(b.locale.bind('settings')('close')).toBe('Close') + b.locale.setLocale('zh') + await fiber.dispose() + // The (ns, locale) seats are free again — the dictionary disposers ran. + expect(() => b.locale.register('settings', 'zh', {})).not.toThrow() + expect(() => b.locale.register('settings', 'en', {})).not.toThrow() + }) + + it('refreshes all four seats on locale change with fresh General label text', async () => { + const b = await bench() + declare(b.slots) + await b.ctx.plugin({ inject: [...inject], apply }).await() + const zhVersions = SEATS.map(([name]) => b.slots.getVersion(name)) + b.locale.setLocale('en') + // Every seat re-registered (version moved) and the label re-resolved. + SEATS.forEach(([name], i) => { + expect(b.slots.getVersion(name)).toBeGreaterThan(zhVersions[i]!) + expect(b.slots.entries(name)).toHaveLength(1) + }) + expect(generalEntry(b.slots)!.options.label).toBe('General') + b.locale.setLocale('zh') + expect(generalEntry(b.slots)!.options.label).toBe('通用设置') + }) + + it('locale change while the slots are undeclared stays a no-op', async () => { + const b = await bench() + await b.ctx.plugin({ inject: [...inject], apply }).await() + b.locale.setLocale('en') + for (const [name] of SEATS) expect(b.slots.entries(name)).toHaveLength(0) + b.locale.setLocale('zh') + }) + + it('re-registers after an HMR collapse of the declaring chain (stale disposers must not block)', async () => { + const b = await bench() + const redeclare = declare(b.slots) + await b.ctx.plugin({ inject: [...inject], apply }).await() + // Declarer unload: the cascade removes every seat entry and the item + // declaration while our local disposers go stale. + redeclare() + for (const [name] of SEATS) expect(b.slots.entries(name)).toHaveLength(0) + expect(b.slots.spec('settings.general.item')).toBeUndefined() + declare(b.slots) + await Promise.resolve() + for (const [name, component] of SEATS) { + expect(b.slots.entries(name)[0]!.component).toBe(component) + } + expect(b.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' }) + // The recovered registrations still ride the locale path. + b.locale.setLocale('en') + expect(generalEntry(b.slots)!.options.label).toBe('General') + b.locale.setLocale('zh') + }) + + it('removes every seat and the item declaration on teardown', async () => { + const b = await bench() + declare(b.slots) + const fiber = b.ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + expect(b.slots.spec('settings.general.item')).toBeDefined() + await fiber.dispose() + for (const [name] of SEATS) expect(b.slots.entries(name)).toHaveLength(0) + expect(b.slots.spec('settings.general.item')).toBeUndefined() + }) +}) diff --git a/packages/client/ui-settings/tests/general-section.spec.tsx b/packages/client/ui-settings-general/tests/components.spec.tsx similarity index 51% rename from packages/client/ui-settings/tests/general-section.spec.tsx rename to packages/client/ui-settings-general/tests/components.spec.tsx index ce09aabf93..2a041c6cf4 100644 --- a/packages/client/ui-settings/tests/general-section.spec.tsx +++ b/packages/client/ui-settings-general/tests/components.spec.tsx @@ -1,29 +1,50 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, render, screen } from '@testing-library/react' -import type { GeneralSectionComponentProps } from '../src/client/contract/slots.ts' +import type { GeneralSectionComponentProps } from '../src/client/GeneralSection.tsx' import { GeneralSection } from '../src/client/GeneralSection.tsx' +import { CloseLabel, HeaderContent, TriggerContent } from '../src/client/chrome.tsx' import { en } from '../src/client/locales.ts' afterEach(cleanup) -function mount() { - const renderSlot = vi.fn( - ((key: string) =>
) as GeneralSectionComponentProps['renderSlot'], - ) - // Global standard kit stubs: the section consumes neither hook. - const unusedHook = (() => { throw new Error('unused by GeneralSection') }) as never - const props: GeneralSectionComponentProps = { - useSessions: unusedHook, - useWorkspaces: unusedHook, - t: (key) => en[key] ?? key, - renderSlot, - } - const view = render() - return { view, renderSlot } -} +const t = (key: string) => en[key] ?? key + +// Global standard kit stubs: none of these components consume the hooks. +const unusedHook = (() => { throw new Error('unused by settings-general components') }) as never +const kit = { useSessions: unusedHook, useWorkspaces: unusedHook } + +describe('chrome content', () => { + it('TriggerContent renders the icon with the label in the wide column', () => { + const { container } = render() + expect(container.querySelector('svg')).toBeTruthy() + expect(screen.getByText('Settings')).toBeTruthy() + }) + + it('TriggerContent drops the label in the rail state', () => { + const { container } = render() + expect(container.querySelector('svg')).toBeTruthy() + expect(screen.queryByText('Settings')).toBeNull() + }) + + it('HeaderContent and CloseLabel render their translated text', () => { + render() + render() + expect(screen.getByText('Settings')).toBeTruthy() + expect(screen.getByText('Close')).toBeTruthy() + }) +}) describe('GeneralSection', () => { + function mount() { + const renderSlot = vi.fn( + ((key: string) =>
) as GeneralSectionComponentProps['renderSlot'], + ) + const props: GeneralSectionComponentProps = { ...kit, t, renderSlot } + const view = render() + return { view, renderSlot } + } + it('renders the Permission skeleton row with the disabled selector', () => { mount() expect(screen.getByText('Permission')).toBeTruthy() diff --git a/packages/client/ui-settings-general/tests/invariant.spec.ts b/packages/client/ui-settings-general/tests/invariant.spec.ts new file mode 100644 index 0000000000..7b0527c0ff --- /dev/null +++ b/packages/client/ui-settings-general/tests/invariant.spec.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import * as GeneralInvariant from '@deepseek-ai/dsh-client-ui-settings-general/invariant' +import InvariantService from '@deepseek-ai/dsh-invariants' + +describe('invariant companion', () => { + it('registers under the package name with an empty installer', async () => { + const ctx = new Context() + await ctx.plugin(InvariantService, { enabled: true }) + await expect(ctx.plugin(GeneralInvariant).await()).resolves.toBeDefined() + }) + + it('node-half apply is a no-op host placeholder', async () => { + const { apply } = await import('@deepseek-ai/dsh-client-ui-settings-general') + apply() + expect(true).toBe(true) // reaching here without throw is the contract + }) +}) diff --git a/packages/client/ui-settings-general/tsconfig.json b/packages/client/ui-settings-general/tsconfig.json new file mode 100644 index 0000000000..5ef01ba51c --- /dev/null +++ b/packages/client/ui-settings-general/tsconfig.json @@ -0,0 +1,33 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../ui-slots" + }, + { + "path": "../ui-primitives" + }, + { + "path": "../runtime" + }, + { + "path": "../ui-settings" + }, + { + "path": "../locale" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/client/ui-settings-general/tsdown.config.ts b/packages/client/ui-settings-general/tsdown.config.ts new file mode 100644 index 0000000000..bf67c4f10f --- /dev/null +++ b/packages/client/ui-settings-general/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-ui-settings-general', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/client/ui-settings/README.md b/packages/client/ui-settings/README.md index 64250c7917..49a56a8f80 100644 --- a/packages/client/ui-settings/README.md +++ b/packages/client/ui-settings/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-client-ui-settings -Settings shell plugin: the sidebar trigger row and the modal settings panel occupying `sidebar.settings`; declares the `settings.section` list slot that section plugins contribute pages into. The shell projects the section ledger into navigation and renders only the active section (`only` filtering). +Settings shell plugin: a pure composition face. It occupies `sidebar.settings` with the trigger chrome and the modal settings panel, and declares the slots registrants fill: `settings.trigger` / `settings.header` / `settings.close` (chrome content) and `settings.section` (one page per feature). The shell ships no copy and reads no locale state — all text arrives from registrants (ui-settings-general owns chrome and General; features own their sections and rows), so the section ledger bump is its only re-render trigger. ## Model Experience @@ -12,4 +12,4 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **Panel is browser-preference scope only** — host-side settings (permission mode, tool-call mode) render as skeletons in the General section; no RPC surface exists yet. +- **Panel is browser-preference scope only** — host-side settings surfaces (permission mode, tool-call mode) have no RPC backing yet; their skeletons live in ui-settings-general. diff --git a/packages/client/ui-settings/package.json b/packages/client/ui-settings/package.json index 8f71d90c8b..efadf3190f 100644 --- a/packages/client/ui-settings/package.json +++ b/packages/client/ui-settings/package.json @@ -25,8 +25,7 @@ "dshClient": { "inject": [ "@deepseek-ai/dsh-client-runtime", - "@deepseek-ai/dsh-client-ui-sidebar", - "@deepseek-ai/dsh-client-locale" + "@deepseek-ai/dsh-client-ui-sidebar" ], "platform": "web" }, @@ -47,7 +46,6 @@ "react": "^18.2.0" }, "devDependencies": { - "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", diff --git a/packages/client/ui-settings/src/client/SettingsRoot.module.css b/packages/client/ui-settings/src/client/SettingsRoot.module.css index bf557c0a04..e2b2c878df 100644 --- a/packages/client/ui-settings/src/client/SettingsRoot.module.css +++ b/packages/client/ui-settings/src/client/SettingsRoot.module.css @@ -190,3 +190,13 @@ padding: 0 24px 8px; overflow-y: auto; } + +/* Visually-hidden text seat (close button accessible name from slot content). */ +.hiddenLabel { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} diff --git a/packages/client/ui-settings/src/client/SettingsRoot.tsx b/packages/client/ui-settings/src/client/SettingsRoot.tsx index 3a27acdf6c..04fa39a03c 100644 --- a/packages/client/ui-settings/src/client/SettingsRoot.tsx +++ b/packages/client/ui-settings/src/client/SettingsRoot.tsx @@ -1,15 +1,15 @@ /** * Settings shell root: the sidebar-foot trigger row plus the centered modal - * panel (figma 501:29947, 1080x700) with the section nav rail. Modal open - * state and the active section id are component-local viewing state; the - * section ledger arrives through the injected face (nav labels are - * registrant-localized — the shell owns no locale/theme subscription). + * panel (figma 501:29947, 1080x700) with the section nav rail. The shell is + * a pure composition face — every piece of text (trigger label, panel title, + * close label, sections) arrives from registrants through slots; accessible + * names resolve to that content (trigger: its own text; dialog: + * aria-labelledby the title node; close: visually-hidden slot text). Modal + * open state and the active section id are component-local viewing state. */ -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useId, useRef, useState } from 'react' import clsx from 'clsx' -import { - IconCloseOutline16, IconDataOutline16, IconSettingsOutline14, IconSettingsOutline16, -} from '@deepseek-ai/dsh-client-ui-primitives' +import { IconCloseOutline16, IconDataOutline16, IconSettingsOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' import type { SettingsRootComponentProps } from './contract/slots.ts' import css from './SettingsRoot.module.css' @@ -20,7 +20,6 @@ function navIcon(id: string) { } type PanelProps = { - translate: SettingsRootComponentProps['translate'] rows: ReturnType renderSlot: SettingsRootComponentProps['renderSlot'] onClose: () => void @@ -31,11 +30,12 @@ type PanelProps = { * header button, a mask click, and document-level Escape (mounted only while * open, so the listener lifetime is the panel's). */ -function SettingsPanel({ translate, rows, renderSlot, onClose }: PanelProps) { +function SettingsPanel({ rows, renderSlot, onClose }: PanelProps) { // Local selection; entries can unmount underneath it, so the render-time // projection falls back to the first row when the id is gone. const [activeId, setActiveId] = useState(undefined) const active = rows.find((r) => r.id === activeId)?.id ?? rows[0]?.id + const titleId = useId() useEffect(() => { const onKeyDown = (e: KeyboardEvent) => { @@ -52,9 +52,9 @@ function SettingsPanel({ translate, rows, renderSlot, onClose }: PanelProps) { return (