From 311aca3663e9dd38c6dc3b1410cf09935c95b2e6 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 31 Jul 2026 11:14:07 +0800 Subject: [PATCH 01/20] fix(web): improve models settings safety and contrast --- .../2026-07-30-web-config-plane.i18n.yaml | 4 +- .../2026-07-30-web-config-plane.md | 4 +- .../2026-07-30-web-config-plane.zh.md | 4 +- apps/web/tests/models-settings.e2e.ts | 37 +++++++++- .../models-settings/delete.expected.md | 7 ++ packages/client/ui-models/README.i18n.yaml | 4 +- packages/client/ui-models/README.md | 2 +- packages/client/ui-models/README.zh.md | 2 +- .../src/client/ModelsSection.module.css | 74 +++++++++++-------- .../ui-models/src/client/ModelsSection.tsx | 57 ++++++++++++-- .../client/ui-models/src/client/locales.ts | 10 +++ packages/client/ui-models/tests/apply.spec.ts | 4 + .../ui-models/tests/components.spec.tsx | 46 +++++++++++- .../client/ui-models/tests/styles.spec.ts | 13 ++++ 14 files changed, 217 insertions(+), 51 deletions(-) create mode 100644 apps/web/tests/snapshots/models-settings/delete.expected.md create mode 100644 packages/client/ui-models/tests/styles.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml index ac37214ebf..d7f3ce8a17 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-30-web-config-plane.md -2026-07-30-web-config-plane.md: 95ede6264026f7b32e95749d00fe841f57dbf867 -2026-07-30-web-config-plane.zh.md: 6e06b69218a405055621cbd40781f9fbda9f9e6b +2026-07-30-web-config-plane.md: e4c72d1ad555e1d542593b8eb4b7fafc1afb8e0a +2026-07-30-web-config-plane.zh.md: 73dc2b7450c940e893b795cb29c4d2a7752a9bb0 diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md index 95ede62640..e4c72d1ad5 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md @@ -20,7 +20,7 @@ PR1 made LLM adapter configuration restart-free at the seam, but the only writer **A hand-written editor over a schema model layer.** `dsh-client-schema-form` rehydrates the wire's `toJSON()` envelope into live schemastery nodes for validation, path resolution, and immutable draft editing — but no generic rendering: the first cut shipped a full schema-driven form renderer, and the resulting page was an unstyled schema dump (every advanced field flattened onto the card, raw field names as labels, the `retryPolicy` unsupported-fallback in the main flow). The user chose the hand-written direction over adding a hint/grouping system, and a second round removed the reference input entirely: the card's primary field is one **API key** input, a whole-section provider without a configured key opens as its setup card, and the collapsed 自定义设置 fold carries the curated per-family extras (`baseURL` for both families, plus `reasoningEffort` for deepseek / `reasoning` for pi-ai), with every other field owned by `settings.yaml`. Validation still runs the rehydrated schema before writing, so a hand-coded field that drifts from its schema fails loud on save rather than silently. -**The Models page is a three-domain join with seam-shaped apply semantics.** Rows are configured providers; the add card's select is the dormant directory remainder; badges come from route liveness. The key path stays reference-shaped without ever showing a reference: a typed key stores **write-only** through `credentials.set` under the profile's `apiKeyEnv`, deriving `_API_KEY` when none exists (the pi-ai profile records the derivation), so `settings.yaml` never carries a key value and the wholesale `settings.replace` a removal needs can never drop a sibling's secret. An edit without removals lands as a minimal `settings.update` merge patch; clearing a fold field back to inherited or deleting a row replaces the whole user section, because merge semantics cannot express removal. +**The Models page is a three-domain join with seam-shaped apply semantics.** Rows are configured providers; the add card's select is the dormant directory remainder; badges come from route liveness. The key path stays reference-shaped without ever showing a reference: a typed key stores **write-only** through `credentials.set` under the profile's `apiKeyEnv`, deriving `_API_KEY` when none exists (the pi-ai profile records the derivation), so `settings.yaml` never carries a key value. Profile edits and removals land as minimal path-addressed `settings.mutate` operations against the redacted user section, which never names a secret the page did not receive. Removing a user-layer provider first opens a localized model-provider confirmation dialog; cancellation, its close button, and its mask leave the profile untouched, while the destructive confirmation submits the single unset and blocks duplicate submission until it settles. ## Alternatives considered @@ -33,4 +33,4 @@ PR1 made LLM adapter configuration restart-free at the seam, but the only writer ## Consequences -The whole loop is pinned keyless in the browser lane (`apps/web/tests/models-settings.e2e.ts`): the add card offers the dormant pi-ai catalog, adding `minimax-cn` with a typed key writes the reference-only profile into `settings.yaml`, stores the value into the harness home's `.env` under the derived `MINIMAX_CN_API_KEY`, registers the route live on the topology frame, and the customized fold merges `reasoning` beside the reference — zero model calls, ARIA goldens for the add-card and configured states, plus a scaffold `harnessHome` so tests never touch a real `~/.dsh` (the provider under test is one whose derived reference cannot collide with a developer's exported keys). The rename touched 239 files (fixtures, goldens, docs, python) in one commit with no compatibility alias. The renderer replacement cost one commit and no wire change: apply semantics, redaction, and the directory join were renderer-agnostic all along. Deferred: a per-row models preview (the picker already lists models), a page address for live routes that never declared configurability, and the documented reset edge — a `settings.replace` cannot re-supply a stored *literal* secret in the replaced subtree, which the reference-based default makes unreachable. +The whole loop is pinned keyless in the browser lane (`apps/web/tests/models-settings.e2e.ts`): the add card offers the dormant pi-ai catalog, adding `minimax-cn` with a typed key writes the reference-only profile into `settings.yaml`, stores the value into the harness home's `.env` under the derived `MINIMAX_CN_API_KEY`, registers the route live on the topology frame, and the customized fold merges `reasoning` beside the reference — zero model calls, ARIA goldens for the add-card, configured, and delete-confirmation states, plus a scaffold `harnessHome` so tests never touch a real `~/.dsh` (the provider under test is one whose derived reference cannot collide with a developer's exported keys). The removal scenario proves cancellation leaves the profile intact, confirmation removes it, and the intentionally retained credential survives. The rename touched 239 files (fixtures, goldens, docs, python) in one commit with no compatibility alias. The renderer replacement cost one commit and no wire change: apply semantics, redaction, and the directory join were renderer-agnostic all along. Deferred: a per-row models preview (the picker already lists models), a page address for live routes that never declared configurability, and explicit removal of a provider's retained credential. diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md index 6e06b69218..73dc2b7450 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md @@ -20,7 +20,7 @@ PR1 让 LLM(大语言模型)适配器配置在 seam 层面免重启,但唯 **架在 schema 模型层之上的手写编辑器。**`dsh-client-schema-form` 把 wire 的 `toJSON()` 信封还原(rehydrate)为活的 schemastery 节点,用于校验、路径解析与不可变草稿编辑——但不做通用渲染:第一版交付了完整的 schema 驱动表单渲染器,得到的却是一个未加样式、把 schema 原样倾倒出来的页面(每个进阶字段都平铺到卡片上、原始字段名直接充当标签、`retryPolicy` 的「不支持」回退落在主流程里)。用户没有再加一套提示/分组系统,而是选择了手写方向,第二轮又把引用输入框整个移除:卡片的主字段是一个 **API 密钥**输入框,未配置密钥的整分节提供方会以其设置卡片的形式打开,收起的「自定义设置」折叠区承载按家族精选的额外字段(两个家族都有 `baseURL`,另加 deepseek 的 `reasoningEffort`/pi-ai 的 `reasoning`),其余每个字段都归 `settings.yaml` 所有。校验仍会在写入前运行还原出的 schema,因此偏离其 schema 的手写字段会在保存时大声失败,而非静默失败。 -**Models 页是一次三领域联接,应用语义与 seam 同形。**每一行是一个已配置的提供方;「新增」卡片的选择框是可配置提供方目录中剩余的休眠条目;徽标来自路由存活状态。密钥通道保持引用形态,却从不展示任何引用:键入的密钥经 `credentials.set` **只写**存入 profile 的 `apiKeyEnv` 之下,引用不存在时便派生 `_API_KEY`(pi-ai profile 会记录该派生),因此 `settings.yaml` 从不携带密钥值,删除所需的整体 `settings.replace` 也绝不可能丢掉兄弟条目的机密。不含删除的编辑以一次最小的 `settings.update` 合并 patch 落地;把折叠区字段清回继承值或删除整行则经 `settings.replace` 替换整个用户分节,因为合并语义表达不了删除。 +**Models 页是一次三领域联接,应用语义与 seam 同形。**每一行是一个已配置的提供方;「新增」卡片的选择框是可配置提供方目录中剩余的休眠条目;徽标来自路由存活状态。密钥通道保持引用形态,却从不展示任何引用:键入的密钥经 `credentials.set` **只写**存入 profile 的 `apiKeyEnv` 之下,引用不存在时便派生 `_API_KEY`(pi-ai profile 会记录该派生),因此 `settings.yaml` 从不携带密钥值。profile 的编辑和删除会针对脱敏后的用户分节,以按路径寻址的最小 `settings.mutate` 操作落地,绝不会点名页面未收到的机密。删除用户层提供方时,会先打开本地化的模型提供方确认对话框;取消操作、关闭按钮和遮罩均不会改动 profile,而破坏性确认会提交唯一一条 unset,并在其完成前阻止重复提交。 ## 曾考虑的替代方案 @@ -33,4 +33,4 @@ PR1 让 LLM(大语言模型)适配器配置在 seam 层面免重启,但唯 ## 后果 -整条闭环以无密钥方式固定在浏览器测试通道(`apps/web/tests/models-settings.e2e.ts`):「新增」卡片提供休眠的 pi-ai catalog,携键入的密钥添加 `minimax-cn` 会把只含引用的 profile 写入 `settings.yaml`、把密钥值存入 harness 家目录 `.env` 中派生的 `MINIMAX_CN_API_KEY` 之下、路由随拓扑帧注册为存活,「自定义设置」折叠区则把 `reasoning` 合并到引用旁边——全程零模型调用,「新增」卡片态与已配置态各有 ARIA golden,另有脚手架式的 `harnessHome`,测试绝不触碰真实的 `~/.dsh`(受测提供方是派生引用不可能与开发者已导出密钥相撞的那一个)。这次重命名在一次提交中触及 239 个文件(fixture(测试前置数据)、golden、文档、python),未保留兼容别名。替换渲染器只花了一次提交,且没有任何 wire 变更:应用语义、脱敏与目录联接从一开始就与渲染器无关。延后事项:每行的模型预览(选择器已能列出模型)、为从未声明可配置性的存活路由提供页面地址,以及已记录在案的重置边界情形——`settings.replace` 无法在被替换的子树里重新补上已存储的*字面量*机密,而基于引用的默认形态让这种情况根本无从出现。 +整条闭环以无密钥方式固定在浏览器测试通道(`apps/web/tests/models-settings.e2e.ts`):「新增」卡片提供休眠的 pi-ai catalog,携键入的密钥添加 `minimax-cn` 会把只含引用的 profile 写入 `settings.yaml`、把密钥值存入 harness 家目录 `.env` 中派生的 `MINIMAX_CN_API_KEY` 之下、路由随拓扑帧注册为存活,「自定义设置」折叠区则把 `reasoning` 合并到引用旁边——全程零模型调用,「新增」卡片态、已配置态与删除确认态各有 ARIA golden,另有脚手架式的 `harnessHome`,测试绝不触碰真实的 `~/.dsh`(受测提供方是派生引用不可能与开发者已导出密钥相撞的那一个)。删除场景证明:取消后 profile 保持原样,确认后会将其删除,而刻意保留的凭据依然存在。这次重命名在一次提交中触及 239 个文件(fixture(测试前置数据)、golden、文档、python),未保留兼容别名。替换渲染器只花了一次提交,且没有任何 wire 变更:应用语义、脱敏与目录联接从一开始就与渲染器无关。延后事项:每行的模型预览(选择器已能列出模型)、为从未声明可配置性的存活路由提供页面地址,以及显式删除提供方所保留的凭据。 diff --git a/apps/web/tests/models-settings.e2e.ts b/apps/web/tests/models-settings.e2e.ts index 28c423b0a0..a175191b33 100644 --- a/apps/web/tests/models-settings.e2e.ts +++ b/apps/web/tests/models-settings.e2e.ts @@ -8,7 +8,8 @@ // settings/credentials/llm-domain traffic, so there is no fixture and a // stray stream would fail loud on the open seam. The provider under test is // minimax-cn so a developer's real ANTHROPIC/OPENAI environment keys can -// never shadow the derived reference. +// never shadow the derived reference. Removing that row is guarded by the +// localized provider-confirmation dialog before the unset reaches the wire. import { readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import { join } from 'node:path' @@ -24,6 +25,7 @@ import { saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/models-settings', import.meta.url)) const EMPTY_EXPECTED = join(SNAPSHOT_DIR, 'empty.expected.md') const CONFIGURED_EXPECTED = join(SNAPSHOT_DIR, 'configured.expected.md') +const DELETE_EXPECTED = join(SNAPSHOT_DIR, 'delete.expected.md') const MODE = webSnapshotMode() describe('web e2e: Models settings page configures a dormant provider', () => { @@ -109,11 +111,42 @@ describe('web e2e: Models settings page configures a dormant provider', () => { expect(document).toContain('apiKeyEnv: MINIMAX_CN_API_KEY') const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) await compareOrRefreshGolden(CONFIGURED_EXPECTED, snapshot, MODE) + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + + it('confirms provider deletion before removing its settings profile', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-models-delete')) + const settingsDialog = page.getByRole('dialog', { name: '设置' }) + await settingsDialog.getByRole('button', { name: '删除', exact: true }).click() + const deleteDialog = page.getByRole('dialog', { name: '删除模型提供方?' }) + await deleteDialog.waitFor({ timeout: 10_000 }) + const snapshot = await captureStableAria( + page, + '[role="dialog"][aria-label="删除模型提供方?"]', + scaffold.workspaceCwd, + ) + await compareOrRefreshGolden(DELETE_EXPECTED, snapshot, MODE) + + await deleteDialog.getByRole('button', { name: '取消', exact: true }).click() + expect(await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')).toContain('minimax-cn:') + await settingsDialog.getByRole('button', { name: '删除', exact: true }).click() + await page.getByRole('dialog', { name: '删除模型提供方?' }) + .getByRole('button', { name: '删除提供方', exact: true }).click() + await expect.poll( + async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), + { timeout: 10_000 }, + ).not.toContain('minimax-cn:') + expect(await readFile(join(scaffold.harnessHome, '.env'), 'utf8')) + .toContain('MINIMAX_CN_API_KEY=sk-e2e-minimax') + await expect.poll( + async () => page.getByRole('dialog', { name: '删除模型提供方?' }).count(), + { timeout: 10_000 }, + ).toBe(0) await page.keyboard.press('Escape') expect(tripwire.pageErrors).toEqual([]) }, 60_000) it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { - await assertFixtureInventory(SNAPSHOT_DIR, ['configured.expected.md', 'empty.expected.md']) + await assertFixtureInventory(SNAPSHOT_DIR, ['configured.expected.md', 'delete.expected.md', 'empty.expected.md']) }) }) diff --git a/apps/web/tests/snapshots/models-settings/delete.expected.md b/apps/web/tests/snapshots/models-settings/delete.expected.md new file mode 100644 index 0000000000..afb0cb5fd2 --- /dev/null +++ b/apps/web/tests/snapshots/models-settings/delete.expected.md @@ -0,0 +1,7 @@ +- dialog "删除模型提供方?": + - heading "删除模型提供方?" [level=2] + - button "关闭": + - img + - paragraph: 删除此模型提供方会移除其配置。在重新添加前,你将无法继续使用其模型。 + - button "取消" + - button "删除提供方" diff --git a/packages/client/ui-models/README.i18n.yaml b/packages/client/ui-models/README.i18n.yaml index 951b1d04fe..fcb12d5cd5 100644 --- a/packages/client/ui-models/README.i18n.yaml +++ b/packages/client/ui-models/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-models/README.md -README.md: adfbc084e1b0e227d50032cb6c924401b81c6a79 -README.zh.md: 4ee7d4efa729fdccee392ab8e55078b5a4a239ef +README.md: 9dd09faeb515bb8e8336c52418ab5cab5ede1b34 +README.zh.md: 753ee24f6dda647afef42b78cfc97f36dde5d739 diff --git a/packages/client/ui-models/README.md b/packages/client/ui-models/README.md index adfbc084e1..9dd09faeb5 100644 --- a/packages/client/ui-models/README.md +++ b/packages/client/ui-models/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Models settings plugin: the provider configuration page and official-DeepSeek first-run routing overlay. It joins three wire domains into one shared snapshot — `llm.providers` (the configurable-provider directory with each route's live/dormant state), `settings.describe` (serialized schemas, layered redacted values, secret slots), and `credentials.describe` (value-free configured/source/writable badges) — and renders provider rows with one editor card at a time. -Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and the add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), plus `reasoningEffort` (deepseek) or `reasoning` (pi-ai); every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base). +Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and the add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), plus `reasoningEffort` (deepseek) or `reasoning` (pi-ai); every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base), and a localized confirmation dialog must complete before the page submits that destructive unset. The first-run overlay projects `deepseek-official` readiness from that same joined snapshot. It recognizes the official adapter through its `llm-deepseek` configurable-provider declaration, so an undeclared live route with the same provider id is not treated as repairable configuration. A configured literal `apiKey` secret sidecar or configured credential reference suppresses the prompt, including a read-only launch-environment credential. Only a mounted adapter with a missing writable reference shows the action that opens Settings on the Models section, whose existing setup card exclusively owns key input and `credentials.set`; the overlay never holds a secret. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability is skipped so onboarding cannot block the rest of the product; the Models page remains the diagnostic surface. diff --git a/packages/client/ui-models/README.zh.md b/packages/client/ui-models/README.zh.md index 4ee7d4efa7..753ee24f6d 100644 --- a/packages/client/ui-models/README.zh.md +++ b/packages/client/ui-models/README.zh.md @@ -4,7 +4,7 @@ 模型设置插件:提供方配置页和 DeepSeek 官方首次使用跳转浮层。它把三个协议领域汇聚为一个共享快照:`llm.providers`(可配置提供方目录,含每条路由的存活/休眠状态)、`settings.describe`(序列化 schema、分层脱敏值、secret 槽位)与 `credentials.describe`(不含值的 configured/source/writable 徽标);页面据此渲染提供方行,一次只展开一张编辑卡片。 -行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点),另加 `reasoningEffort`(deepseek)或 `reasoning`(pi-ai);其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base)。 +行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点),另加 `reasoningEffort`(deepseek)或 `reasoning`(pi-ai);其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base),而且必须先在本地化对话框中确认,页面才会提交这次破坏性的 unset。 首次使用浮层从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此不会把同一提供方 ID 下没有相应声明的存活路由视为可通过配置修复。若 `apiKey` 字面量对应的 secret 槽位标记为已设置,或凭据引用已配置,浮层就不再显示,其中包括来自启动环境且只读的凭据。只有适配器已挂载、引用可写但尚未配置时,浮层才显示一个操作按钮,用于打开「设置」的 Models 分区;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,浮层绝不持有 secret。适配器缺失、路由未激活、联接失败、部署只读、设置能力不可用或凭据能力不可用时均跳过,以免首次使用引导阻塞产品的其他部分;Models 页仍是诊断界面。 diff --git a/packages/client/ui-models/src/client/ModelsSection.module.css b/packages/client/ui-models/src/client/ModelsSection.module.css index a2be484a63..11e51b66ec 100644 --- a/packages/client/ui-models/src/client/ModelsSection.module.css +++ b/packages/client/ui-models/src/client/ModelsSection.module.css @@ -3,6 +3,7 @@ flex-direction: column; gap: 12px; max-width: 720px; + color: var(--dsw-alias-label-primary); } .title { @@ -14,13 +15,13 @@ .intro { margin: 0; font-size: 13px; - color: var(--text-tertiary, #888); + color: var(--dsw-alias-label-tertiary); } .notice { margin: 0; font-size: 12px; - color: var(--text-warning, #a15c00); + color: var(--dsw-alias-state-warn-label); } .rows { @@ -33,13 +34,13 @@ } .rowCard { - border: 1px solid var(--border, #e2e2e2); + border: 1px solid var(--dsw-alias-border-l2); border-radius: 12px; padding: 12px 14px; display: flex; flex-direction: column; gap: 12px; - background: var(--surface, #fff); + background: var(--dsw-alias-bg-layer-3); } .rowHead { @@ -63,7 +64,7 @@ display: inline-flex; align-items: center; gap: 5px; - color: var(--text-success, #0a7d33); + color: var(--dsw-alias-state-success-primary); font-size: 12px; } @@ -76,12 +77,12 @@ } .badgeMuted { - color: var(--text-tertiary, #999); + color: var(--dsw-alias-label-tertiary); font-size: 12px; } .badgeWarn { - color: var(--text-warning, #a15c00); + color: var(--dsw-alias-state-warn-label); font-size: 12px; } @@ -94,17 +95,17 @@ border: none; border-radius: 999px; padding: 8px 18px; - background: var(--accent-strong, #111); - color: var(--text-inverse, #fff); + background: var(--dsw-alias-button-primary-fill); + color: var(--dsw-alias-label-primary-foreground); font: inherit; cursor: pointer; } .secondaryButton { - border: 1px solid var(--border, #d9d9d9); + border: 1px solid var(--dsw-alias-border-l2); border-radius: 999px; padding: 6px 14px; - background: var(--surface, #fff); + background: var(--dsw-alias-bg-layer-3); color: inherit; font: inherit; cursor: pointer; @@ -113,7 +114,7 @@ .dangerButton { border: none; background: none; - color: var(--text-danger, #c0392b); + color: var(--dsw-alias-state-error-primary); font: inherit; cursor: pointer; } @@ -126,9 +127,9 @@ } .editor { - border: 1px solid var(--border, #e6e6e6); + border: 1px solid var(--dsw-alias-border-l2); border-radius: 12px; - background: var(--surface-secondary, #f7f7f8); + background: var(--dsw-alias-bg-layer-2); padding: 14px 16px; display: flex; flex-direction: column; @@ -148,7 +149,7 @@ .editorRoute { font-size: 12px; - color: var(--text-tertiary, #999); + color: var(--dsw-alias-label-tertiary); } .field { @@ -163,14 +164,14 @@ gap: 10px; font-size: 12px; font-weight: 500; - color: var(--text-secondary, #555); + color: var(--dsw-alias-label-secondary); } .linkButton { border: none; background: none; padding: 0; - color: var(--text-tertiary, #888); + color: var(--dsw-alias-label-tertiary); font: inherit; font-size: 12px; text-decoration: underline; @@ -185,7 +186,7 @@ .advancedHint { margin: 0; font-size: 12px; - color: var(--text-tertiary, #999); + color: var(--dsw-alias-label-tertiary); } .editorActions { @@ -202,12 +203,12 @@ .addButton { align-self: flex-start; - border: 1px solid var(--border, #d9d9d9); + border: 1px solid var(--dsw-alias-border-l2); border-radius: 999px; padding: 8px 16px; font: inherit; font-size: 13px; - background: var(--surface, #fff); + background: var(--dsw-alias-bg-layer-3); color: inherit; cursor: pointer; } @@ -219,9 +220,9 @@ .addCard, .setupCard { - border: 1px solid var(--border, #e6e6e6); + border: 1px solid var(--dsw-alias-border-l2); border-radius: 12px; - background: var(--surface-secondary, #f7f7f8); + background: var(--dsw-alias-bg-layer-3); padding: 14px 16px; display: flex; flex-direction: column; @@ -237,7 +238,7 @@ } .customized { - border-top: 1px solid var(--border, #ececec); + border-top: 1px solid var(--dsw-alias-border-l2); padding-top: 10px; } @@ -245,7 +246,7 @@ cursor: pointer; font-size: 12px; font-weight: 500; - color: var(--text-secondary, #555); + color: var(--dsw-alias-label-secondary); list-style: revert; } @@ -259,25 +260,38 @@ .input { box-sizing: border-box; padding: 9px 12px; - border: 1px solid var(--border, #d9d9d9); + border: 1px solid var(--dsw-alias-border-l2); border-radius: 10px; font: inherit; font-size: 13px; - background: var(--surface, #fff); - color: inherit; + background: var(--dsw-alias-bg-layer-1); + color: var(--dsw-alias-label-primary); } .input:focus { outline: none; - border-color: var(--accent-strong, #111); + border-color: var(--dsw-alias-brand-primary); } .input::placeholder { - color: var(--text-tertiary, #aaa); + color: var(--dsw-alias-label-dimmed); } .error { margin: 0; font-size: 12px; - color: var(--text-danger, #c0392b); + color: var(--dsw-alias-state-error-primary); +} + +.deleteDialog { + width: min(480px, 100%); +} + +.deleteConfirm:not(:disabled) { + border-color: var(--dsw-alias-state-error-primary); + color: var(--dsw-alias-state-error-primary); +} + +.deleteConfirm:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover-danger); } diff --git a/packages/client/ui-models/src/client/ModelsSection.tsx b/packages/client/ui-models/src/client/ModelsSection.tsx index d095acb86c..24f1ab2e4c 100644 --- a/packages/client/ui-models/src/client/ModelsSection.tsx +++ b/packages/client/ui-models/src/client/ModelsSection.tsx @@ -4,13 +4,15 @@ * card at a time. A whole-section provider without a configured key (the * unconfigured DeepSeek posture) renders as its open setup card instead of a * row; the add flow is a card carrying the dormant-provider select. Every - * mutation writes through the wire; the page re-renders from the pushed - * invalidations or the post-apply reload. + * mutation writes through the wire, while a provider removal first requires + * confirmation; the page re-renders from pushed invalidations or the + * post-apply reload. */ import { useState } from 'react' import type { ReactNode } from 'react' import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client' +import { Button, Modal } from '@deepseek-ai/dsh-client-ui-primitives' import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react' import { messageOf } from './store.ts' import type { ModelsSettingsState, ModelsSettingsStore, ProviderRow } from './store.ts' @@ -114,6 +116,8 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode { const state = injected.useSnapshot(snapshot => snapshot) const [editing, setEditing] = useState(undefined) const [adding, setAdding] = useState(false) + const [deleteTarget, setDeleteTarget] = useState(undefined) + const [deleting, setDeleting] = useState(false) const closeEditor = (changed: boolean): void => { setEditing(undefined) @@ -121,6 +125,26 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode { if (changed) void controller.load() } + const closeDelete = (): void => { + if (deleting) return + setDeleteTarget(undefined) + } + + const confirmDelete = (): void => { + /* v8 ignore next -- the action only renders with a target and is disabled while a deletion is pending */ + if (deleteTarget === undefined || deleting) return + setDeleting(true) + void removeProviderProfile(api, controller, deleteTarget) + .then((failure) => { + if (failure !== undefined) { + controller.fail(failure) + return + } + setDeleteTarget(undefined) + }) + .finally(() => { setDeleting(false) }) + } + if (state.status === 'idle') void controller.load() if (state.status === 'error') { /* v8 ignore next -- an error status always carries text; the fallback satisfies the nullable type */ @@ -193,11 +217,7 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode { type="button" className={styles['dangerButton']} disabled={!state.writable} - onClick={() => { - void removeProviderProfile(api, controller, target).then((failure) => { - if (failure !== undefined) controller.fail(failure) - }) - }} + onClick={() => { setDeleteTarget(target) }} > {t('remove')} @@ -276,6 +296,29 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode { )} + + + + + )} + /> ) } diff --git a/packages/client/ui-models/src/client/locales.ts b/packages/client/ui-models/src/client/locales.ts index 48431ddacf..5cc6782dca 100644 --- a/packages/client/ui-models/src/client/locales.ts +++ b/packages/client/ui-models/src/client/locales.ts @@ -9,8 +9,13 @@ export const en = { dormant: 'Inactive', edit: 'Edit', remove: 'Delete', + deleteTitle: 'Delete model provider?', + deleteDescription: 'Deleting this model provider removes its configuration. You will not be able to use its models until you add the provider again.', + deleteConfirm: 'Delete provider', + deleting: 'Deleting provider…', add: 'Add provider', provider: 'Provider', + close: 'Close', cancel: 'Cancel', apply: 'Apply', applying: 'Applying…', @@ -46,8 +51,13 @@ export const zh: typeof en = { dormant: '未启用', edit: '编辑', remove: '删除', + deleteTitle: '删除模型提供方?', + deleteDescription: '删除此模型提供方会移除其配置。在重新添加前,你将无法继续使用其模型。', + deleteConfirm: '删除提供方', + deleting: '正在删除提供方…', add: '添加提供方', provider: '提供方', + close: '关闭', cancel: '取消', apply: '保存', applying: '保存中…', diff --git a/packages/client/ui-models/tests/apply.spec.ts b/packages/client/ui-models/tests/apply.spec.ts index eb34b162a0..6c64a93058 100644 --- a/packages/client/ui-models/tests/apply.spec.ts +++ b/packages/client/ui-models/tests/apply.spec.ts @@ -48,6 +48,7 @@ describe('ui-models apply', () => { expect(resolveSlotLabel(entry.options.label)).toBe('模型') const injected = (entry.inject as unknown as () => import('../src/client/ModelsSection.tsx').ModelsSectionInjected)() expect(injected.t('nav')).toBe('模型') + expect(injected.t('deleteTitle')).toBe('删除模型提供方?') expect(typeof injected.controller.load).toBe('function') expect(typeof injected.useSnapshot).toBe('function') expect(injected.api).toBeDefined() @@ -73,8 +74,11 @@ describe('ui-models apply', () => { await b.ctx.plugin({ inject: [...inject], apply }).await() b.locale.setLocale('en') expect(resolveSlotLabel(b.slots.entries('settings.section')[0]!.options.label)).toBe('Models') + const injected = b.slots.entries('settings.section')[0]!.inject as unknown as () => import('../src/client/ModelsSection.tsx').ModelsSectionInjected + expect(injected().t('deleteTitle')).toBe('Delete model provider?') b.locale.setLocale('zh') expect(resolveSlotLabel(b.slots.entries('settings.section')[0]!.options.label)).toBe('模型') + expect(injected().t('deleteTitle')).toBe('删除模型提供方?') }) it('locale change while the slot is undeclared stays a no-op', async () => { diff --git a/packages/client/ui-models/tests/components.spec.tsx b/packages/client/ui-models/tests/components.spec.tsx index a4f3734fbd..47aec0dd5e 100644 --- a/packages/client/ui-models/tests/components.spec.tsx +++ b/packages/client/ui-models/tests/components.spec.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom /** Section, setup-card, and hand-written editor behavior over a scripted wire face. */ -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import Schema from 'schemastery' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' @@ -471,10 +471,28 @@ describe('ModelsSection', () => { await waitFor(() => { expect(set).toHaveBeenCalledTimes(1) }) }) - it('removes a user-added provider by unsetting its path', async () => { + it('requires confirmation before removing a user-added provider', async () => { const { replace, mutate } = await mountSection() fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement) + const dialog = screen.getByRole('dialog', { name: en.deleteTitle }) + expect(dialog.textContent).toContain(en.deleteDescription) + expect(document.activeElement).toBe(within(dialog).getByRole('button', { name: en.cancel })) + expect(mutate).not.toHaveBeenCalled() + fireEvent.click(within(dialog).getByRole('button', { name: en.cancel })) + expect(screen.queryByRole('dialog', { name: en.deleteTitle })).toBeNull() + expect(mutate).not.toHaveBeenCalled() + + fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement) + fireEvent.click(within(screen.getByRole('dialog', { name: en.deleteTitle })) + .getByRole('button', { name: en.close })) + expect(screen.queryByRole('dialog', { name: en.deleteTitle })).toBeNull() + expect(mutate).not.toHaveBeenCalled() + + fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement) + fireEvent.click(within(screen.getByRole('dialog', { name: en.deleteTitle })) + .getByRole('button', { name: en.deleteConfirm })) await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) }) + expect(screen.queryByRole('dialog', { name: en.deleteTitle })).toBeNull() expect(replace).not.toHaveBeenCalled() expect(mutate.mock.calls[0]?.[0]).toEqual({ ns: 'llm-pi-ai', @@ -482,6 +500,28 @@ describe('ModelsSection', () => { }) }) + it('blocks duplicate deletion while the confirmed removal is pending', async () => { + let resolveRemoval!: (response: RpcResponse) => void + const mutate = vi.fn(() => new Promise>((resolve) => { + resolveRemoval = resolve + })) + await mountSection({ mutate }) + fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement) + const dialog = screen.getByRole('dialog', { name: en.deleteTitle }) + const confirm = within(dialog).getByRole('button', { name: en.deleteConfirm }) + fireEvent.click(confirm) + fireEvent.click(confirm) + expect(mutate).toHaveBeenCalledOnce() + expect(confirm.disabled).toBe(true) + expect(within(dialog).getByRole('button', { name: en.cancel }).disabled).toBe(true) + expect(within(dialog).getByRole('button', { name: en.deleting })).toBe(confirm) + fireEvent.click(within(dialog).getByRole('button', { name: en.close })) + expect(screen.getByRole('dialog', { name: en.deleteTitle })).toBe(dialog) + expect(mutate).toHaveBeenCalledOnce() + await act(async () => { resolveRemoval(ok(wireNamespaces()[2]!)) }) + await waitFor(() => { expect(screen.queryByRole('dialog', { name: en.deleteTitle })).toBeNull() }) + }) + it('renders the load failure with a retry control', async () => { const face = scriptedFace() face.face.llm.providers = vi.fn(() => Promise.resolve(fail('directory down', 'internal'))) as never @@ -589,6 +629,8 @@ describe('ModelsSection', () => { // would appear — rather than the row silently staying put. await mountSection({ mutate: vi.fn(() => Promise.reject(new Error('the host refused'))) }) fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement) + fireEvent.click(within(screen.getByRole('dialog', { name: en.deleteTitle })) + .getByRole('button', { name: en.deleteConfirm })) await screen.findByText(`${en.loadFailed}: the host refused`) }) diff --git a/packages/client/ui-models/tests/styles.spec.ts b/packages/client/ui-models/tests/styles.spec.ts new file mode 100644 index 0000000000..478046454b --- /dev/null +++ b/packages/client/ui-models/tests/styles.spec.ts @@ -0,0 +1,13 @@ +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const css = readFileSync(fileURLToPath(new URL('../src/client/ModelsSection.module.css', import.meta.url)), 'utf8') + +describe('ModelsSection theme styles', () => { + it('uses the shared theme tokens without light-only fallbacks', () => { + expect(css).not.toMatch(/var\(--(?:surface|text-|border|accent-strong)/) + expect(css).toContain('background: var(--dsw-alias-bg-layer-3)') + expect(css).toContain('color: var(--dsw-alias-label-primary)') + }) +}) From 788b9eb9866f714e2144ed1c4d6ff3748943dbe5 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 31 Jul 2026 11:32:10 +0800 Subject: [PATCH 02/20] fix(web): hide provider liveness badges --- .../2026-07-30-web-config-plane.i18n.yaml | 4 +-- .../2026-07-30-web-config-plane.md | 2 +- .../2026-07-30-web-config-plane.zh.md | 2 +- apps/web/tests/models-settings.e2e.ts | 9 +++-- .../models-settings/configured.expected.md | 2 +- packages/client/ui-models/README.i18n.yaml | 4 +-- packages/client/ui-models/README.md | 2 +- packages/client/ui-models/README.zh.md | 2 +- .../src/client/ModelsSection.module.css | 33 +------------------ .../ui-models/src/client/ModelsSection.tsx | 5 --- .../client/ui-models/src/client/locales.ts | 4 --- .../ui-models/tests/components.spec.tsx | 5 ++- 12 files changed, 16 insertions(+), 58 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml index d7f3ce8a17..fedfc7e489 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-30-web-config-plane.md -2026-07-30-web-config-plane.md: e4c72d1ad555e1d542593b8eb4b7fafc1afb8e0a -2026-07-30-web-config-plane.zh.md: 73dc2b7450c940e893b795cb29c4d2a7752a9bb0 +2026-07-30-web-config-plane.md: 6d1a8c242c1888ee4fca9e21ebc814f7a345d633 +2026-07-30-web-config-plane.zh.md: c3255cacfdd1f06d12f7bb2631f95273536b7ef9 diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md index e4c72d1ad5..6d1a8c242c 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md @@ -20,7 +20,7 @@ PR1 made LLM adapter configuration restart-free at the seam, but the only writer **A hand-written editor over a schema model layer.** `dsh-client-schema-form` rehydrates the wire's `toJSON()` envelope into live schemastery nodes for validation, path resolution, and immutable draft editing — but no generic rendering: the first cut shipped a full schema-driven form renderer, and the resulting page was an unstyled schema dump (every advanced field flattened onto the card, raw field names as labels, the `retryPolicy` unsupported-fallback in the main flow). The user chose the hand-written direction over adding a hint/grouping system, and a second round removed the reference input entirely: the card's primary field is one **API key** input, a whole-section provider without a configured key opens as its setup card, and the collapsed 自定义设置 fold carries the curated per-family extras (`baseURL` for both families, plus `reasoningEffort` for deepseek / `reasoning` for pi-ai), with every other field owned by `settings.yaml`. Validation still runs the rehydrated schema before writing, so a hand-coded field that drifts from its schema fails loud on save rather than silently. -**The Models page is a three-domain join with seam-shaped apply semantics.** Rows are configured providers; the add card's select is the dormant directory remainder; badges come from route liveness. The key path stays reference-shaped without ever showing a reference: a typed key stores **write-only** through `credentials.set` under the profile's `apiKeyEnv`, deriving `_API_KEY` when none exists (the pi-ai profile records the derivation), so `settings.yaml` never carries a key value. Profile edits and removals land as minimal path-addressed `settings.mutate` operations against the redacted user section, which never names a secret the page did not receive. Removing a user-layer provider first opens a localized model-provider confirmation dialog; cancellation, its close button, and its mask leave the profile untouched, while the destructive confirmation submits the single unset and blocks duplicate submission until it settles. +**The Models page is a three-domain join with seam-shaped apply semantics.** Rows are configured providers; the add card's select is the dormant directory remainder. Route liveness still gates readiness and invalidates the join, but the page does not render it as provider status because configuration presence and runtime availability are distinct. The key path stays reference-shaped without ever showing a reference: a typed key stores **write-only** through `credentials.set` under the profile's `apiKeyEnv`, deriving `_API_KEY` when none exists (the pi-ai profile records the derivation), so `settings.yaml` never carries a key value. Profile edits and removals land as minimal path-addressed `settings.mutate` operations against the redacted user section, which never names a secret the page did not receive. Removing a user-layer provider first opens a localized model-provider confirmation dialog; cancellation, its close button, and its mask leave the profile untouched, while the destructive confirmation submits the single unset and blocks duplicate submission until it settles. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md index 73dc2b7450..c3255cacfd 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md @@ -20,7 +20,7 @@ PR1 让 LLM(大语言模型)适配器配置在 seam 层面免重启,但唯 **架在 schema 模型层之上的手写编辑器。**`dsh-client-schema-form` 把 wire 的 `toJSON()` 信封还原(rehydrate)为活的 schemastery 节点,用于校验、路径解析与不可变草稿编辑——但不做通用渲染:第一版交付了完整的 schema 驱动表单渲染器,得到的却是一个未加样式、把 schema 原样倾倒出来的页面(每个进阶字段都平铺到卡片上、原始字段名直接充当标签、`retryPolicy` 的「不支持」回退落在主流程里)。用户没有再加一套提示/分组系统,而是选择了手写方向,第二轮又把引用输入框整个移除:卡片的主字段是一个 **API 密钥**输入框,未配置密钥的整分节提供方会以其设置卡片的形式打开,收起的「自定义设置」折叠区承载按家族精选的额外字段(两个家族都有 `baseURL`,另加 deepseek 的 `reasoningEffort`/pi-ai 的 `reasoning`),其余每个字段都归 `settings.yaml` 所有。校验仍会在写入前运行还原出的 schema,因此偏离其 schema 的手写字段会在保存时大声失败,而非静默失败。 -**Models 页是一次三领域联接,应用语义与 seam 同形。**每一行是一个已配置的提供方;「新增」卡片的选择框是可配置提供方目录中剩余的休眠条目;徽标来自路由存活状态。密钥通道保持引用形态,却从不展示任何引用:键入的密钥经 `credentials.set` **只写**存入 profile 的 `apiKeyEnv` 之下,引用不存在时便派生 `_API_KEY`(pi-ai profile 会记录该派生),因此 `settings.yaml` 从不携带密钥值。profile 的编辑和删除会针对脱敏后的用户分节,以按路径寻址的最小 `settings.mutate` 操作落地,绝不会点名页面未收到的机密。删除用户层提供方时,会先打开本地化的模型提供方确认对话框;取消操作、关闭按钮和遮罩均不会改动 profile,而破坏性确认会提交唯一一条 unset,并在其完成前阻止重复提交。 +**Models 页是一次三领域联接,应用语义与 seam 同形。**每一行是一个已配置的提供方;「新增」卡片的选择框是可配置提供方目录中剩余的休眠条目。路由存活状态仍用于就绪判定,并会使该联接失效,但页面不将其渲染为提供方状态,因为配置存在与运行时可用性是两个不同概念。密钥通道保持引用形态,却从不展示任何引用:键入的密钥经 `credentials.set` **只写**存入 profile 的 `apiKeyEnv` 之下,引用不存在时便派生 `_API_KEY`(pi-ai profile 会记录该派生),因此 `settings.yaml` 从不携带密钥值。profile 的编辑和删除会针对脱敏后的用户分节,以按路径寻址的最小 `settings.mutate` 操作落地,绝不会点名页面未收到的机密。删除用户层提供方时,会先打开本地化的模型提供方确认对话框;取消操作、关闭按钮和遮罩均不会改动 profile,而破坏性确认会提交唯一一条 unset,并在其完成前阻止重复提交。 ## 曾考虑的替代方案 diff --git a/apps/web/tests/models-settings.e2e.ts b/apps/web/tests/models-settings.e2e.ts index a175191b33..9c2215ed7f 100644 --- a/apps/web/tests/models-settings.e2e.ts +++ b/apps/web/tests/models-settings.e2e.ts @@ -1,10 +1,10 @@ // Web e2e scenario: the Models settings page end to end through the real // wire — the add card offers the dormant pi-ai catalog, typing an API key // stores it write-only under the derived reference (`MINIMAX_CN_API_KEY`) -// while the settings document records only that reference, and the saved -// route registers live (the row's 已启用 badge is the topology invalidation -// landing). The customized-settings fold writes the curated reasoning field -// as a merge patch. Zero model calls: configuration is pure +// while the settings document records only that reference; the saved row +// appears after the route topology invalidation without presenting liveness +// as provider status. The customized-settings fold writes the curated +// reasoning field as a merge patch. Zero model calls: configuration is pure // settings/credentials/llm-domain traffic, so there is no fixture and a // stray stream would fail loud on the open seam. The provider under test is // minimax-cn so a developer's real ANTHROPIC/OPENAI environment keys can @@ -84,7 +84,6 @@ describe('web e2e: Models settings page configures a dormant provider', () => { // registers, and the topology frame invalidates the page into the row. const row = dialog.getByText('minimax-cn', { exact: true }).first() await row.waitFor({ timeout: 10_000 }) - await dialog.getByText('已启用').waitFor({ timeout: 10_000 }) const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8') expect(document).toContain('minimax-cn:') expect(document).toContain('apiKeyEnv: MINIMAX_CN_API_KEY') diff --git a/apps/web/tests/snapshots/models-settings/configured.expected.md b/apps/web/tests/snapshots/models-settings/configured.expected.md index 8b9c4ad6e1..251352ee00 100644 --- a/apps/web/tests/snapshots/models-settings/configured.expected.md +++ b/apps/web/tests/snapshots/models-settings/configured.expected.md @@ -14,7 +14,7 @@ - paragraph: 填入各提供方的 API 密钥即可使用其模型。 - list: - listitem: - - text: minimax-cn 已启用 + - text: minimax-cn - button "编辑" - button "删除" - button "+ 添加提供方" diff --git a/packages/client/ui-models/README.i18n.yaml b/packages/client/ui-models/README.i18n.yaml index fcb12d5cd5..0cd3fa6269 100644 --- a/packages/client/ui-models/README.i18n.yaml +++ b/packages/client/ui-models/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-models/README.md -README.md: 9dd09faeb515bb8e8336c52418ab5cab5ede1b34 -README.zh.md: 753ee24f6dda647afef42b78cfc97f36dde5d739 +README.md: 4edb34ccbe8f628c04e410a6dd2f002e247623f3 +README.zh.md: 68a1e0ee205d3ba764620bcfeba7c11a88ee8736 diff --git a/packages/client/ui-models/README.md b/packages/client/ui-models/README.md index 9dd09faeb5..4edb34ccbe 100644 --- a/packages/client/ui-models/README.md +++ b/packages/client/ui-models/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Models settings plugin: the provider configuration page and official-DeepSeek first-run routing overlay. It joins three wire domains into one shared snapshot — `llm.providers` (the configurable-provider directory with each route's live/dormant state), `settings.describe` (serialized schemas, layered redacted values, secret slots), and `credentials.describe` (value-free configured/source/writable badges) — and renders provider rows with one editor card at a time. +Models settings plugin: the provider configuration page and official-DeepSeek first-run routing overlay. It joins three wire domains into one shared snapshot — `llm.providers` (the configurable-provider directory with each route's live/dormant state), `settings.describe` (serialized schemas, layered redacted values, secret slots), and `credentials.describe` (value-free configured/source/writable badges) — and renders provider rows with one editor card at a time, without presenting route liveness as provider status. Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and the add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), plus `reasoningEffort` (deepseek) or `reasoning` (pi-ai); every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base), and a localized confirmation dialog must complete before the page submits that destructive unset. diff --git a/packages/client/ui-models/README.zh.md b/packages/client/ui-models/README.zh.md index 753ee24f6d..68a1e0ee20 100644 --- a/packages/client/ui-models/README.zh.md +++ b/packages/client/ui-models/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -模型设置插件:提供方配置页和 DeepSeek 官方首次使用跳转浮层。它把三个协议领域汇聚为一个共享快照:`llm.providers`(可配置提供方目录,含每条路由的存活/休眠状态)、`settings.describe`(序列化 schema、分层脱敏值、secret 槽位)与 `credentials.describe`(不含值的 configured/source/writable 徽标);页面据此渲染提供方行,一次只展开一张编辑卡片。 +模型设置插件:提供方配置页和 DeepSeek 官方首次使用跳转浮层。它把三个协议领域汇聚为一个共享快照:`llm.providers`(可配置提供方目录,含每条路由的存活/休眠状态)、`settings.describe`(序列化 schema、分层脱敏值、secret 槽位)与 `credentials.describe`(不含值的 configured/source/writable 徽标);页面据此渲染提供方行,一次只展开一张编辑卡片,且不把路由存活状态呈现为提供方状态。 行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点),另加 `reasoningEffort`(deepseek)或 `reasoning`(pi-ai);其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base),而且必须先在本地化对话框中确认,页面才会提交这次破坏性的 unset。 diff --git a/packages/client/ui-models/src/client/ModelsSection.module.css b/packages/client/ui-models/src/client/ModelsSection.module.css index 11e51b66ec..a8b28db46c 100644 --- a/packages/client/ui-models/src/client/ModelsSection.module.css +++ b/packages/client/ui-models/src/client/ModelsSection.module.css @@ -54,41 +54,10 @@ font-weight: 600; } -.badges { - display: inline-flex; - gap: 6px; - flex: 1; -} - -.badgeOk { - display: inline-flex; - align-items: center; - gap: 5px; - color: var(--dsw-alias-state-success-primary); - font-size: 12px; -} - -.badgeOk::before { - content: ''; - width: 6px; - height: 6px; - border-radius: 999px; - background: currentcolor; -} - -.badgeMuted { - color: var(--dsw-alias-label-tertiary); - font-size: 12px; -} - -.badgeWarn { - color: var(--dsw-alias-state-warn-label); - font-size: 12px; -} - .rowActions { display: inline-flex; gap: 8px; + margin-left: auto; } .primaryButton { diff --git a/packages/client/ui-models/src/client/ModelsSection.tsx b/packages/client/ui-models/src/client/ModelsSection.tsx index 24f1ab2e4c..c206dbd864 100644 --- a/packages/client/ui-models/src/client/ModelsSection.tsx +++ b/packages/client/ui-models/src/client/ModelsSection.tsx @@ -198,11 +198,6 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
  • {row.entry.displayName} - - {row.entry.active - ? {t('active')} - : {t('dormant')}} - + )} + /> +
    + ) +} + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** Permission row copy. */ + 'settings.permission': PermissionSettingsKey + } +} diff --git a/packages/client/ui-permission/src/client/index.ts b/packages/client/ui-permission/src/client/index.ts index 30fc6d2dd5..65d66c4104 100644 --- a/packages/client/ui-permission/src/client/index.ts +++ b/packages/client/ui-permission/src/client/index.ts @@ -1,46 +1,46 @@ /** - * Permission preset plugin, browser half — a popupSelect DECORATION hung on - * the host `/permission` command: one flat list of presets, current value - * marked active, a pick executes the switch. The decoration owns only the - * bare invocation; the host command keeps its catalog row, the argued path - * (`/permission ` still switches directly), and the lifecycle - * logging. Options and the active mark read the session's `permissions` - * projection (the same host-computed select the composer chip renders); a - * pick submits the `/permission ` command line, so both surfaces - * write through one path and the pushed projection frame is the one - * confirmation. + * Permission plugin, browser half. The General-settings row writes the + * default preset for subsequently created sessions through Settings; the + * `/permission` popup decoration switches the current session through the + * host command and its `permissions` projection. */ import type { ClientContext, SessionFace } from '@deepseek-ai/dsh-client-runtime/client' import type { CommandServiceContract, SelectOption } from '@deepseek-ai/dsh-client-ui-command/client' import type { ClientSessionContext } from '@deepseek-ai/dsh-client-ui-slash/client' import type { PermissionSelect } from '@deepseek-ai/dsh-permission/client' +import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' +import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots' +import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' +// Type-only: pulls the General item slot and locale service contracts. +import type {} from '@deepseek-ai/dsh-client-locale/client' +import { PermissionRow } from './PermissionRow.tsx' +import type { PermissionRowInjected } from './PermissionRow.tsx' +import { en, zh } from './locales.ts' +import { displayPresetName } from './presentation.ts' +import { + PERMISSION_SETTINGS_NS, PermissionSettingsController, refreshPermissionIfLoaded, +} from './settings-store.ts' + +export type { PermissionRowInjected, PermissionRowProps } from './PermissionRow.tsx' +export type { + PermissionDefaultOption, PermissionSettingsState, +} from './settings-store.ts' /** Required services (cordis fiber inject). */ -export const inject = ['command', 'sessions'] +export const inject = ['command', 'sessions', 'slots', 'locale', 'connection'] /** Read one session's current permissions projection value (undefined = capability absent). */ function selectOf(session: SessionFace | undefined): PermissionSelect | undefined { return session?.projections.faceOf('permissions').getSnapshot() as PermissionSelect | undefined } -/** - * Display transform twin of the composer chip's (ui-conversation - * PermissionSelect): kebab-case machine names render as title-case labels - * (`workspace-write` → `Workspace Write`) so both permission surfaces show - * the same text; non-kebab host-configured names pass through. - */ -function displayName(name: string): string { - if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name)) return name - return name.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ') -} - /** Flatten the projection select into popup rows; `custom` is display state, never a target. */ function optionsOf(value: PermissionSelect): SelectOption[] { return value.options .filter(option => option.value !== 'custom') .map(option => ({ id: option.value, - label: displayName(option.name), + label: displayPresetName(option.name), ...(option.description !== undefined ? { detail: option.description } : {}), ...(option.value === value.currentValue ? { active: true } : {}), })) @@ -56,6 +56,41 @@ export function apply(ctx: ClientContext): void { const sessions = ctx.sessions const sessionFor = (session: ClientSessionContext): SessionFace | undefined => sessions.binding(session.sessionId)?.session + + ctx.effect(() => ctx.locale.register('settings.permission', { zh, en }), 'ui-permission: settings row dictionaries') + + const connection = ctx.get('connection') as ConnectionHandle + const controller = new PermissionSettingsController(connection.api) + const useSnapshot = bindSnapshotSelector(controller.store) + const injected = (): PermissionRowInjected => ({ controller, useSnapshot }) + + ctx.effect(() => { + const refresh = (ns?: string): void => { + if (ns !== undefined && ns !== PERMISSION_SETTINGS_NS) return + refreshPermissionIfLoaded(controller) + } + const disposers = [ + ctx.on('settings/changed', refresh), + ctx.on('connection/reset', () => { refresh() }), + ] + return () => { + controller.dispose() + for (const dispose of disposers) dispose() + } + }, 'ui-permission: settings invalidations') + + ctx.effect(() => { + const row = deferRegistration(ctx.slots, 'settings.general.item', PermissionRow, () => + ctx.slots.register({ + name: 'settings.general.item', + id: 'permission', + order: -20, + locale: 'settings.permission', + inject: injected, + }, PermissionRow)) + return () => { row.dispose() } + }, 'ui-permission: General settings row') + ctx.effect(() => command.decorate({ name: 'permission', // The picker exists exactly while the projection does: a permission-less diff --git a/packages/client/ui-permission/src/client/locales.ts b/packages/client/ui-permission/src/client/locales.ts new file mode 100644 index 0000000000..748235c1ee --- /dev/null +++ b/packages/client/ui-permission/src/client/locales.ts @@ -0,0 +1,20 @@ +/** `settings.permission` namespace dictionaries (the Permission row's copy). */ + +/** Simplified Chinese dictionary (the key-set source of truth). */ +export const zh = { + 'title': '权限', + 'description': '选择新会话的默认权限模式', + 'loading': '加载中', + 'unavailable': '不可用', +} satisfies Record + +/** The settings.permission namespace key union. */ +export type PermissionSettingsKey = keyof typeof zh + +/** English dictionary, checked complete against the zh key set. */ +export const en = { + 'title': 'Permission', + 'description': 'Choose the default permission mode for new sessions', + 'loading': 'Loading', + 'unavailable': 'Unavailable', +} satisfies Record diff --git a/packages/client/ui-permission/src/client/presentation.ts b/packages/client/ui-permission/src/client/presentation.ts new file mode 100644 index 0000000000..752daedf11 --- /dev/null +++ b/packages/client/ui-permission/src/client/presentation.ts @@ -0,0 +1,9 @@ +/** + * Convert conventional kebab-case preset names into user-facing title case. + * @param name - host-supplied preset label or key. + * @returns the title-cased conventional key, or a non-kebab label unchanged. + */ +export function displayPresetName(name: string): string { + if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name)) return name + return name.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ') +} diff --git a/packages/client/ui-permission/src/client/settings-store.ts b/packages/client/ui-permission/src/client/settings-store.ts new file mode 100644 index 0000000000..830347aef7 --- /dev/null +++ b/packages/client/ui-permission/src/client/settings-store.ts @@ -0,0 +1,191 @@ +/** + * Permission default-settings controller. The host descriptor supplies the + * current value and the dynamic preset enum; writes target only + * `defaultPreset` and carry the descriptor revision. + */ + +import type { + IApiClient, SettingsNamespaceView, +} from '@deepseek-ai/dsh-client-connection/client' +import { + createSnapshotStore, type SnapshotStore, +} from '@deepseek-ai/dsh-client-runtime/client' +import { + nodeAtPath, rehydrateSchema, type SchemaNode, +} from '@deepseek-ai/dsh-client-schema-form' +import { displayPresetName } from './presentation.ts' + +/** Permission's settings namespace on the host wire. */ +export const PERMISSION_SETTINGS_NS = 'permission' + +/** One selectable new-session default. */ +export interface PermissionDefaultOption { + /** Preset key written to Settings. */ + id: string + /** Host-supplied label or a title-cased preset key. */ + label: string +} + +/** Permission settings-row snapshot. */ +export interface PermissionSettingsState { + status: 'idle' | 'loading' | 'ready' | 'saving' | 'unavailable' | 'error' + error: string | null + writable: boolean + currentValue: string + options: readonly PermissionDefaultOption[] + revision: number +} + +interface ConstChoice { + type: string + value?: unknown + meta?: { description?: unknown } +} + +/** + * Read the dynamic preset enum encoded by the host's `defaultPreset` schema. + * @param view - permission namespace descriptor. + * @returns current value and selectable options. + */ +export function permissionDefaultOf(view: SettingsNamespaceView): { + currentValue: string + options: PermissionDefaultOption[] +} { + const value = (view.value as { defaultPreset?: unknown } | null)?.defaultPreset + if (typeof value !== 'string') throw new Error('permission settings has no defaultPreset value') + const node = nodeAtPath(rehydrateSchema(view.schema), ['defaultPreset']) + if (node === undefined) throw new Error('permission settings schema has no defaultPreset field') + const rawChoices = node.type === 'union' + ? (node.list as SchemaNode[] | undefined) ?? [] + : [node] + const options = rawChoices.flatMap((candidate) => { + const choice = candidate as unknown as ConstChoice + if (choice.type !== 'const' || typeof choice.value !== 'string') return [] + const described = choice.meta?.description + return [{ + id: choice.value, + label: typeof described === 'string' && described.length > 0 + ? displayPresetName(described) + : displayPresetName(choice.value), + }] + }) + if (options.length === 0 || !options.some(option => option.id === value)) { + throw new Error('permission settings schema does not advertise its current preset') + } + return { currentValue: value, options } +} + +/** Controller joining Settings reads, writes, and pushed invalidations. */ +export class PermissionSettingsController { + /** Row snapshot consumed through a bound selector hook. */ + readonly store: SnapshotStore = createSnapshotStore({ + status: 'idle', + error: null, + writable: false, + currentValue: '', + options: [], + revision: 0, + }) + + private generation = 0 + private view: SettingsNamespaceView | undefined + + /** @param api - Settings wire face. */ + constructor(private readonly api: Pick) {} + + /** + * Refresh the permission descriptor. Latest request wins. + * @returns nothing; {@link store} carries success or failure. + */ + async load(): Promise { + const generation = ++this.generation + this.store.update((state) => { + state.status = 'loading' + state.error = null + }) + try { + const response = await this.api.settings.describe({}) + if (!response.result.ok) throw new Error(response.result.error.message) + if (generation !== this.generation) return + const view = response.result.value.namespaces.find(entry => entry.ns === PERMISSION_SETTINGS_NS) + if (view === undefined) { + this.view = undefined + this.store.update((state) => { + state.status = 'unavailable' + state.writable = false + state.currentValue = '' + state.options = [] + }) + return + } + this.accept(view, response.result.value.writable) + } catch (error) { + if (generation !== this.generation) return + this.fail(error) + } + } + + /** + * Persist one preset as the default for subsequently created sessions. + * @param preset - advertised preset key. + * @returns nothing; {@link store} carries success or failure. + */ + async select(preset: string): Promise { + const view = this.view + const state = this.store.getSnapshot() + if (view === undefined || !state.writable) return + const generation = ++this.generation + this.store.update((draft) => { + draft.status = 'saving' + draft.error = null + }) + try { + const response = await this.api.settings.mutate({ + ns: PERMISSION_SETTINGS_NS, + ops: [{ op: 'set', path: ['defaultPreset'], value: preset }], + expectedRevision: view.revision, + }) + if (generation !== this.generation) return + if (!response.result.ok) throw new Error(response.result.error.message) + this.accept(response.result.value, true) + } catch (error) { + if (generation !== this.generation) return + this.fail(error) + } + } + + /** Stop in-flight responses from publishing after plugin disposal. */ + dispose(): void { + this.generation += 1 + this.view = undefined + } + + private accept(view: SettingsNamespaceView, writable: boolean): void { + const resolved = permissionDefaultOf(view) + this.view = view + this.store.update((state) => { + state.status = 'ready' + state.error = null + state.writable = writable + state.currentValue = resolved.currentValue + state.options = resolved.options + state.revision = view.revision + }) + } + + private fail(error: unknown): void { + this.store.update((state) => { + state.status = 'error' + state.error = error instanceof Error ? error.message : String(error) + }) + } +} + +/** + * Refetch only after the row has opened once. + * @param controller - permission settings controller. + */ +export function refreshPermissionIfLoaded(controller: PermissionSettingsController): void { + if (controller.store.getSnapshot().status === 'idle') return + void controller.load() +} diff --git a/packages/client/ui-permission/src/css-modules.d.ts b/packages/client/ui-permission/src/css-modules.d.ts new file mode 100644 index 0000000000..8811db1264 --- /dev/null +++ b/packages/client/ui-permission/src/css-modules.d.ts @@ -0,0 +1,4 @@ +declare module '*.module.css' { + const classes: Record + export default classes +} diff --git a/packages/client/ui-permission/src/index.ts b/packages/client/ui-permission/src/index.ts index 5359562972..5c28cd69b2 100644 --- a/packages/client/ui-permission/src/index.ts +++ b/packages/client/ui-permission/src/index.ts @@ -1,8 +1,8 @@ /** - * Permission preset selection plugin, node half. Pure UI plugin: the empty - * apply exists so the plugin appears in the host cordis.yml / Loader; the - * browser half ships via exports["./client"], discovered through the - * package.json dshClient declaration. + * Permission surfaces plugin, node half. The empty apply exists so the plugin + * appears in the host cordis.yml / Loader; the browser half ships the + * new-session Settings row and current-session command picker through + * exports["./client"], discovered from the package.json dshClient declaration. */ /** Host plugin body — no host-side behavior for this surface plugin. */ diff --git a/packages/client/ui-permission/src/invariant.ts b/packages/client/ui-permission/src/invariant.ts index c0fd33a80b..1c3f7d6500 100644 --- a/packages/client/ui-permission/src/invariant.ts +++ b/packages/client/ui-permission/src/invariant.ts @@ -15,9 +15,9 @@ export const name = 'client-ui-permission-invariant' export const inject = ['invariants'] /** - * No runtime invariant: a single command contribution registration whose disposal is - * proven by the HMR-safety spec — it emits no cordis events and owns no - * cross-plugin mutable state. + * No runtime invariant: the command and slot contribution lifecycles are + * proven by the HMR-safety spec, while the browser-only Settings controller + * owns no host events or cross-plugin mutable state. */ const install: InvariantInstaller = () => {} diff --git a/packages/client/ui-permission/tests/browser-plugin.spec.ts b/packages/client/ui-permission/tests/browser-plugin.spec.ts index 5f9125db53..cbdb30a5fd 100644 --- a/packages/client/ui-permission/tests/browser-plugin.spec.ts +++ b/packages/client/ui-permission/tests/browser-plugin.spec.ts @@ -5,13 +5,16 @@ * the current value active and `custom` excluded; availability follows the * projection key's presence; a pick submits the /permission line through * Session.command and surfaces rejection/unmatched as thrown errors; fiber - * disposal removes the contribution (HMR safety). + * disposal removes the contribution (HMR safety). The same plugin registers + * its Settings row and invalidates that row on host settings changes. */ import { Context } from 'cordis' import { describe, expect, it } from 'vitest' -import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import { SlotsService, type SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' import type { CommandDecoration } from '@deepseek-ai/dsh-client-ui-command/client' import type { PermissionSelect } from '@deepseek-ai/dsh-permission/client' +import { PermissionRow } from '../src/client/PermissionRow.tsx' import { apply, inject } from '../src/client/index.ts' const sid = (k: string): SessionId => k as SessionId @@ -27,6 +30,26 @@ const SELECT: PermissionSelect = { async function bench() { const ctx = new Context() + await ctx.plugin(SlotsService) + const locale = new LocaleService(ctx) + ctx.provide('locale', locale) + ctx.slots.register({ + name: 'root', + children: { + 'settings.general.item': { kind: 'list', scope: 'root' }, + }, + } as never, () => null) + ctx.provide('connection', { + api: { + settings: { + describe: () => Promise.resolve({ + rpcId: 'describe', + result: { ok: true as const, value: { writable: true, namespaces: [] } }, + }), + mutate: () => Promise.reject(new Error('settings mutation is not exercised')), + }, + }, + } as never) let decoration: CommandDecoration | undefined ctx.provide('command', { decorate(c: CommandDecoration) { @@ -60,6 +83,8 @@ async function bench() { ctx, fiber, values, commands, setResult: (r: { ok: boolean; matched?: boolean }) => { commandResult = r }, decoration: () => decoration, + permissionRow: () => ctx.slots.entries('settings.general.item') + .find(entry => entry.component === PermissionRow), } } @@ -69,6 +94,11 @@ describe('ui-permission browser plugin', () => { const c = b.decoration()! expect(c.name).toBe('permission') expect(c.ui.kind).toBe('popupSelect') + const row = b.permissionRow()! + expect(row.options).toEqual({ id: 'permission', order: -20 }) + const injected = row.inject?.() + expect(injected?.controller).toBeDefined() + expect(typeof injected?.useSnapshot).toBe('function') }) it('availability follows the projection key; options mark the current value active and exclude custom', async () => { @@ -114,7 +144,11 @@ describe('ui-permission browser plugin', () => { it('disposal removes the decoration (HMR safety)', async () => { const b = await bench() expect(b.decoration()).toBeDefined() + b.ctx.emit('settings/changed', 'another') + b.ctx.emit('settings/changed', 'permission') + b.ctx.emit('connection/reset') await b.fiber.dispose() expect(b.decoration()).toBeUndefined() + expect(b.permissionRow()).toBeUndefined() }) }) diff --git a/packages/client/ui-permission/tests/permission-row.spec.tsx b/packages/client/ui-permission/tests/permission-row.spec.tsx new file mode 100644 index 0000000000..685df69749 --- /dev/null +++ b/packages/client/ui-permission/tests/permission-row.spec.tsx @@ -0,0 +1,127 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' +import type { SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client' +import { PermissionRow, type PermissionRowProps } from '../src/client/PermissionRow.tsx' +import { en } from '../src/client/locales.ts' +import { PermissionSettingsController } from '../src/client/settings-store.ts' + +afterEach(cleanup) + +const SCHEMA = { + uid: 4, + refs: { + 1: { type: 'const', value: 'read-only' }, + 2: { type: 'const', value: 'workspace-write' }, + 3: { type: 'union', list: [1, 2] }, + 4: { type: 'object', dict: { defaultPreset: 3 } }, + }, +} + +function view(defaultPreset: string, revision = 0): SettingsNamespaceView { + return { + ns: 'permission', + schema: SCHEMA, + value: { defaultPreset }, + base: { defaultPreset: 'read-only' }, + applies: 'live', + secrets: [], + revision, + } +} + +function ok(value: T) { + return { rpcId: 'test', result: { ok: true as const, value } } +} + +const dictionary: Record = en +const t: PermissionRowProps['t'] = key => dictionary[key] ?? key +const runtime = { + useSessions: (() => { throw new Error('unused') }) as never, + useWorkspaces: (() => { throw new Error('unused') }) as never, +} + +function mount(controller: PermissionSettingsController) { + return render( + , + ) +} + +describe('PermissionRow', () => { + it('loads the descriptor, opens the menu, and selects a new default', async () => { + const mutate = vi.fn(() => Promise.resolve(ok(view('workspace-write', 1)))) + const controller = new PermissionSettingsController({ + settings: { + describe: () => Promise.resolve(ok({ writable: true, namespaces: [view('read-only')] })), + mutate, + } as never, + }) + mount(controller) + const button = await screen.findByRole('button', { name: 'Read Only' }) + expect(button.getAttribute('aria-expanded')).toBe('false') + fireEvent.click(button) + expect(button.getAttribute('aria-expanded')).toBe('true') + fireEvent.keyDown(document, { key: 'Escape' }) + await waitFor(() => { expect(button.getAttribute('aria-expanded')).toBe('false') }) + fireEvent.click(button) + fireEvent.click(button) + expect(button.getAttribute('aria-expanded')).toBe('false') + fireEvent.click(button) + fireEvent.click(screen.getByRole('menuitem', { name: 'Workspace Write' })) + await screen.findByRole('button', { name: 'Workspace Write' }) + expect(mutate).toHaveBeenCalledOnce() + }) + + it('hides an unavailable namespace and disables a read-only provider', async () => { + const absent = new PermissionSettingsController({ + settings: { + describe: () => Promise.resolve(ok({ writable: true, namespaces: [] })), + mutate: vi.fn(), + } as never, + }) + const rendered = mount(absent) + await waitFor(() => { expect(rendered.container.textContent).toBe('') }) + rendered.unmount() + + const readonly = new PermissionSettingsController({ + settings: { + describe: () => Promise.resolve(ok({ writable: false, namespaces: [view('read-only')] })), + mutate: vi.fn(), + } as never, + }) + mount(readonly) + expect((await screen.findByRole('button', { name: 'Read Only' })).hasAttribute('disabled')).toBe(true) + }) + + it('shows loading and a contained write error', async () => { + const describe = Promise.withResolvers>>() + const controller = new PermissionSettingsController({ + settings: { + describe: () => describe.promise, + mutate: () => Promise.resolve({ + rpcId: 'test', + result: { + ok: false as const, + error: { code: 'settings-conflict', message: 'changed elsewhere', details: {} }, + }, + }), + } as never, + }) + mount(controller) + expect((await screen.findByRole('button', { name: 'Loading' })).hasAttribute('disabled')).toBe(true) + describe.resolve(ok({ writable: true, namespaces: [view('read-only')] })) + const button = await screen.findByRole('button', { name: 'Read Only' }) + fireEvent.click(button) + fireEvent.click(screen.getByRole('menuitem', { name: 'Workspace Write' })) + expect((await screen.findByRole('alert')).textContent).toBe('changed elsewhere') + }) +}) diff --git a/packages/client/ui-permission/tests/settings-store.spec.ts b/packages/client/ui-permission/tests/settings-store.spec.ts new file mode 100644 index 0000000000..74edb838b0 --- /dev/null +++ b/packages/client/ui-permission/tests/settings-store.spec.ts @@ -0,0 +1,254 @@ +import { describe, expect, it, vi } from 'vitest' +import type { SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client' +import { + PermissionSettingsController, permissionDefaultOf, refreshPermissionIfLoaded, +} from '../src/client/settings-store.ts' + +const SCHEMA = { + uid: 6, + refs: { + 1: { type: 'const', value: 'read-only' }, + 2: { type: 'const', meta: { description: 'Workspace' }, value: 'workspace-write' }, + 3: { type: 'union', list: [1, 2] }, + 6: { type: 'object', dict: { defaultPreset: 3 } }, + }, +} + +function view(defaultPreset: string, revision = 0, schema: SettingsNamespaceView['schema'] = SCHEMA): SettingsNamespaceView { + return { + ns: 'permission', + schema, + value: { defaultPreset }, + base: { defaultPreset: 'read-only' }, + applies: 'live', + secrets: [], + revision, + } +} + +function ok(value: T) { + return { rpcId: 'test', result: { ok: true as const, value } } +} + +describe('permission settings store', () => { + it('derives dynamic options and host labels from the descriptor schema', () => { + expect(permissionDefaultOf(view('read-only'))).toEqual({ + currentValue: 'read-only', + options: [ + { id: 'read-only', label: 'Read Only' }, + { id: 'workspace-write', label: 'Workspace' }, + ], + }) + const single = { + uid: 2, + refs: { + 1: { type: 'const', meta: { description: '' }, value: 'read-only' }, + 2: { type: 'object', dict: { defaultPreset: 1 } }, + }, + } + expect(permissionDefaultOf(view('read-only', 0, single))).toEqual({ + currentValue: 'read-only', + options: [{ id: 'read-only', label: 'Read Only' }], + }) + const undescribed = { + uid: 2, + refs: { + 1: { type: 'const', meta: { description: 7 }, value: 'read-only' }, + 2: { type: 'object', dict: { defaultPreset: 1 } }, + }, + } + expect(permissionDefaultOf(view('read-only', 0, undescribed)).options) + .toEqual([{ id: 'read-only', label: 'Read Only' }]) + }) + + it('rejects malformed values and dynamic enums at the wire boundary', () => { + expect(() => permissionDefaultOf({ ...view('read-only'), value: {} })).toThrow(/no defaultPreset value/) + expect(() => permissionDefaultOf(view('read-only', 0, { + uid: 1, refs: { 1: { type: 'object', dict: {} } }, + }))).toThrow(/no defaultPreset field/) + expect(() => permissionDefaultOf(view('read-only', 0, { + uid: 2, + refs: { + 1: { type: 'union' }, + 2: { type: 'object', dict: { defaultPreset: 1 } }, + }, + }))).toThrow(/does not advertise/) + expect(() => permissionDefaultOf(view('read-only', 0, { + uid: 4, + refs: { + 1: { type: 'string' }, + 2: { type: 'const', value: 1 }, + 3: { type: 'union', list: [1, 2] }, + 4: { type: 'object', dict: { defaultPreset: 3 } }, + }, + }))).toThrow(/does not advertise/) + expect(() => permissionDefaultOf(view('missing'))).toThrow(/does not advertise/) + }) + + it('loads and writes defaultPreset with optimistic concurrency', async () => { + const describe = vi.fn(() => Promise.resolve(ok({ + writable: true, + namespaces: [view('read-only', 4)], + }))) + const mutate = vi.fn(() => Promise.resolve(ok(view('workspace-write', 5)))) + const controller = new PermissionSettingsController({ + settings: { describe, mutate } as never, + }) + await controller.load() + expect(controller.store.getSnapshot()).toMatchObject({ + status: 'ready', + writable: true, + currentValue: 'read-only', + revision: 4, + }) + await controller.select('workspace-write') + expect(mutate).toHaveBeenCalledWith({ + ns: 'permission', + ops: [{ op: 'set', path: ['defaultPreset'], value: 'workspace-write' }], + expectedRevision: 4, + }) + expect(controller.store.getSnapshot()).toMatchObject({ + status: 'ready', + currentValue: 'workspace-write', + revision: 5, + }) + }) + + it('hides the row when the namespace is absent and contains write failures', async () => { + const describe = vi.fn(() => Promise.resolve(ok({ writable: true, namespaces: [] }))) + const controller = new PermissionSettingsController({ + settings: { describe, mutate: vi.fn() } as never, + }) + await controller.load() + expect(controller.store.getSnapshot().status).toBe('unavailable') + + const failing = new PermissionSettingsController({ + settings: { + describe: () => Promise.resolve(ok({ writable: true, namespaces: [view('read-only')] })), + mutate: () => Promise.resolve({ + rpcId: 'test', + result: { + ok: false as const, + error: { code: 'settings-conflict', message: 'stale', details: {} }, + }, + }), + } as never, + }) + await failing.load() + await failing.select('workspace-write') + expect(failing.store.getSnapshot()).toMatchObject({ status: 'error', error: 'stale' }) + }) + + it('contains read failures, no-ops without a writable view, and ignores stale responses', async () => { + const first = Promise.withResolvers>>() + const describe = vi.fn() + .mockImplementationOnce(() => first.promise) + .mockResolvedValueOnce(ok({ writable: false, namespaces: [view('read-only', 2)] })) + const mutate = vi.fn() + const controller = new PermissionSettingsController({ + settings: { describe, mutate } as never, + }) + const stale = controller.load() + await controller.load() + first.resolve(ok({ writable: true, namespaces: [view('workspace-write', 1)] })) + await stale + expect(controller.store.getSnapshot()).toMatchObject({ + currentValue: 'read-only', + writable: false, + revision: 2, + }) + await controller.select('workspace-write') + expect(mutate).not.toHaveBeenCalled() + + const rejected = new PermissionSettingsController({ + settings: { + describe: () => Promise.resolve({ + rpcId: 'test', + result: { ok: false as const, error: { code: 'internal', message: 'offline', details: {} } }, + }), + mutate, + } as never, + }) + await rejected.select('workspace-write') + await rejected.load() + expect(rejected.store.getSnapshot()).toMatchObject({ status: 'error', error: 'offline' }) + + const thrown = new PermissionSettingsController({ + settings: { + // Promise consumers must contain unknown rejection values from a + // transport implementation, including non-Error legacy clients. + // oxlint-disable-next-line typescript/prefer-promise-reject-errors + describe: () => Promise.reject('disconnected'), + mutate, + } as never, + }) + await thrown.load() + expect(thrown.store.getSnapshot()).toMatchObject({ status: 'error', error: 'disconnected' }) + }) + + it('disposal suppresses in-flight reads and writes, and loaded invalidations refetch', async () => { + const read = Promise.withResolvers>>() + const describe = vi.fn(() => read.promise) + const idle = new PermissionSettingsController({ settings: { describe, mutate: vi.fn() } as never }) + refreshPermissionIfLoaded(idle) + expect(describe).not.toHaveBeenCalled() + const loading = idle.load() + idle.dispose() + read.resolve(ok({ writable: true, namespaces: [view('read-only')] })) + await loading + expect(idle.store.getSnapshot().status).toBe('loading') + + const rejectedRead = Promise.withResolvers>>() + const disposedRead = new PermissionSettingsController({ + settings: { describe: () => rejectedRead.promise, mutate: vi.fn() } as never, + }) + const reading = disposedRead.load() + disposedRead.dispose() + rejectedRead.reject(new Error('late read')) + await reading + expect(disposedRead.store.getSnapshot().status).toBe('loading') + + const mutation = Promise.withResolvers>>() + const activeDescribe = vi.fn(() => Promise.resolve(ok({ + writable: true, + namespaces: [view('read-only')], + }))) + const active = new PermissionSettingsController({ + settings: { + describe: activeDescribe, + mutate: () => mutation.promise, + } as never, + }) + await active.load() + refreshPermissionIfLoaded(active) + await vi.waitFor(() => { expect(activeDescribe).toHaveBeenCalledTimes(2) }) + const saving = active.select('workspace-write') + active.dispose() + mutation.resolve(ok(view('workspace-write', 1))) + await saving + expect(active.store.getSnapshot().status).toBe('saving') + + const rejectedMutation = Promise.withResolvers>>() + const disposedWrite = new PermissionSettingsController({ + settings: { + describe: () => Promise.resolve(ok({ writable: true, namespaces: [view('read-only')] })), + mutate: () => rejectedMutation.promise, + } as never, + }) + await disposedWrite.load() + const writing = disposedWrite.select('workspace-write') + disposedWrite.dispose() + rejectedMutation.reject(new Error('late write')) + await writing + expect(disposedWrite.store.getSnapshot().status).toBe('saving') + }) +}) diff --git a/packages/client/ui-permission/tsconfig.json b/packages/client/ui-permission/tsconfig.json index b66ce746b2..32f84d6a2d 100644 --- a/packages/client/ui-permission/tsconfig.json +++ b/packages/client/ui-permission/tsconfig.json @@ -8,18 +8,36 @@ "src" ], "references": [ + { + "path": "../connection" + }, + { + "path": "../locale" + }, { "path": "../../../vendor/cordis" }, { "path": "../runtime" }, + { + "path": "../schema-form" + }, { "path": "../ui-command" }, + { + "path": "../ui-primitives" + }, { "path": "../ui-slash" }, + { + "path": "../ui-slots" + }, + { + "path": "../web-react" + }, { "path": "../../ui/permission" }, diff --git a/packages/client/ui-settings-general/README.i18n.yaml b/packages/client/ui-settings-general/README.i18n.yaml index 9377fc73b8..8beb4c9578 100644 --- a/packages/client/ui-settings-general/README.i18n.yaml +++ b/packages/client/ui-settings-general/README.i18n.yaml @@ -1,6 +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 -README.md: c392d745021c0fc6a752cf71dd0506a435106c50 -README.zh.md: 83ab81e01eae435a74b50fa363a4de203c483002 +# pnpm run verify-translation-pairing --write packages/client/ui-settings-general/README.md +README.md: 9e12f02fc1e767fb807be4fdd3f506c189662bc7 +README.zh.md: 225e27f5705f33bc6199615b0fe96e04eaa6a04c diff --git a/packages/client/ui-settings-general/README.md b/packages/client/ui-settings-general/README.md index c392d74502..9e12f02fc1 100644 --- a/packages/client/ui-settings-general/README.md +++ b/packages/client/ui-settings-general/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -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. +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 (`settings.general.item` slot plus the Tool Call skeleton), and the `settings` dictionaries. Feature-owned rows (Permission, Language, Appearance) and sections (Models) stay with their feature packages. ## Model Experience @@ -14,4 +14,4 @@ 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. +- **Tool Call is a display skeleton** — its backing host setting does not exist yet, so the cubes write nothing. When it gains real backing, the row moves to its owning feature plugin per the self-registration doctrine. diff --git a/packages/client/ui-settings-general/README.zh.md b/packages/client/ui-settings-general/README.zh.md index 83ab81e01e..225e27f570 100644 --- a/packages/client/ui-settings-general/README.zh.md +++ b/packages/client/ui-settings-general/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -设置界面文案插件:在设置界面注册所有不属于单一功能的内容,包括外壳的触发器、标题栏与关闭控件内容,「通用」分区(「权限」/「工具调用」骨架行和 `settings.general.item` slot 声明),以及 `settings` 字典。归具体功能所有的行(「语言」、「外观」)和分区(「模型」)仍由各自的功能包提供。 +设置界面无归属文案插件:在设置界面注册所有不属于单一功能的内容,包括外壳的触发器、标题栏与关闭控件内容,「通用」分区(`settings.general.item` slot 加上「工具调用」骨架行),以及 `settings` 字典。归具体功能所有的行(「权限」、「语言」、「外观」)和分区(「模型」)仍由各自的功能包提供。 ## 模型体验 @@ -14,4 +14,4 @@ ## 已知限制与暂缓事项 -- **「权限」与「工具调用」只是展示骨架**:对应的宿主服务和 RPC 方法尚不存在;这些控件已禁用,不会写入任何内容。一旦获得实际支撑,按照自注册原则,每一项都会移至拥有它的功能插件。 +- **「工具调用」只是展示骨架**:其宿主设置尚不存在,因此控件不会写入任何内容。一旦获得实际支撑,按照自注册原则,该行会移至拥有它的功能插件。 diff --git a/packages/client/ui-settings-general/package.json b/packages/client/ui-settings-general/package.json index 798a7710f0..6d0ee01404 100644 --- a/packages/client/ui-settings-general/package.json +++ b/packages/client/ui-settings-general/package.json @@ -1,6 +1,6 @@ { "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", + "description": "Settings ownerless-copy plugin: the General section and Tool Call skeleton, shell trigger/header chrome content, and settings dictionaries", "version": "0.0.1", "private": true, "type": "module", diff --git a/packages/client/ui-settings-general/src/client/GeneralSection.module.css b/packages/client/ui-settings-general/src/client/GeneralSection.module.css index aced3b2962..cb1d137ec8 100644 --- a/packages/client/ui-settings-general/src/client/GeneralSection.module.css +++ b/packages/client/ui-settings-general/src/client/GeneralSection.module.css @@ -13,15 +13,6 @@ border-bottom: none; } -/* 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; @@ -31,16 +22,6 @@ border-bottom: 1px solid var(--dsw-alias-border-l2); } -/* 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; @@ -55,35 +36,6 @@ 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:hover:not(:disabled) { - background: var(--dsw-alias-interactive-bg-hover); -} - -.selector:disabled { - cursor: default; -} - -.chevron { - flex: none; -} - /* Tool Call mode cubes share an 8px gap and wrap to one per row when the panel is too narrow. */ .cubeRow { diff --git a/packages/client/ui-settings-general/src/client/GeneralSection.tsx b/packages/client/ui-settings-general/src/client/GeneralSection.tsx index 62c3a0b134..861f4e23d4 100644 --- a/packages/client/ui-settings-general/src/client/GeneralSection.tsx +++ b/packages/client/ui-settings-general/src/client/GeneralSection.tsx @@ -1,55 +1,51 @@ /** - * 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. + * The General section (figma 501:29983 'Options'): one column rendering the + * `settings.general.item` contributions. Features own their rows; this + * package contributes only the ownerless Tool Call skeleton. */ -import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import css from './GeneralSection.module.css' -/** Full component props: section owner share + item render share + the standard locale seat. */ +/** Full component props: section owner share plus item render share. */ export type GeneralSectionComponentProps = - PropsRuntime<'settings.section'> & PropsRenderSlots<'settings.general.item'> & PropsLocale<'settings'> + PropsRuntime<'settings.section'> & PropsRenderSlots<'settings.general.item'> /** * 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) { +export function GeneralSection({ 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', {})}
    ) } + +/** Props of the ownerless Tool Call item contribution. */ +export type ToolCallSkeletonProps = + PropsRuntime<'settings.general.item'> & PropsLocale<'settings'> + +/** + * Render the static Tool Call mode choice until its host setting exists. + * @param props - item runtime and translated copy. + * @returns the skeleton row. + */ +export function ToolCallSkeleton({ t }: ToolCallSkeletonProps) { + return ( +
    +
    {t('toolcall.title')}
    +
    +
    +
    {t('toolcall.schema.title')}
    +
    {t('toolcall.schema.desc')}
    +
    +
    +
    {t('toolcall.code.title')}
    +
    {t('toolcall.code.desc')}
    +
    +
    +
    + ) +} diff --git a/packages/client/ui-settings-general/src/client/index.ts b/packages/client/ui-settings-general/src/client/index.ts index 2683c20608..894b3a50e9 100644 --- a/packages/client/ui-settings-general/src/client/index.ts +++ b/packages/client/ui-settings-general/src/client/index.ts @@ -1,9 +1,9 @@ /** * 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. + * chrome content, the General section (`settings.general.item` slot plus the + * ownerless Tool Call skeleton), 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' @@ -13,13 +13,15 @@ import type {} from '@deepseek-ai/dsh-client-ui-settings/client' // Type-only: pulls ctx.locale and the 'settings.general.item' SlotMap merge. import type {} from '@deepseek-ai/dsh-client-locale/client' import { CloseLabel, HeaderContent, TriggerContent } from './chrome.tsx' -import { GeneralSection } from './GeneralSection.tsx' +import { GeneralSection, ToolCallSkeleton } from './GeneralSection.tsx' import { en, zh, type SettingsKey } from './locales.ts' export type { CloseLabelProps, HeaderContentProps, TriggerContentProps, } from './chrome.tsx' -export type { GeneralSectionComponentProps } from './GeneralSection.tsx' +export type { + GeneralSectionComponentProps, ToolCallSkeletonProps, +} from './GeneralSection.tsx' export type { SettingsKey } from './locales.ts' declare module '@deepseek-ai/dsh-client-ui-slots' { @@ -67,11 +69,19 @@ export function apply(ctx: ClientContext): void { locale: NS, children: { 'settings.general.item': { kind: 'list', scope: 'root' } }, }, GeneralSection)) + const toolCall = deferRegistration(ctx.slots, 'settings.general.item', ToolCallSkeleton, () => + ctx.slots.register({ + name: 'settings.general.item', + id: 'tool-call', + order: -10, + locale: NS, + }, ToolCallSkeleton)) return () => { trigger.dispose() header.dispose() close.dispose() general.dispose() + toolCall.dispose() } }, 'ui-settings-general: chrome and section registrations') } diff --git a/packages/client/ui-settings-general/src/client/locales.ts b/packages/client/ui-settings-general/src/client/locales.ts index d49dfecf96..1e3ff3b883 100644 --- a/packages/client/ui-settings-general/src/client/locales.ts +++ b/packages/client/ui-settings-general/src/client/locales.ts @@ -1,12 +1,10 @@ /** * `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. Feature-owned rows - * (Language, Appearance) ship their copy in their own packages. + * General section (nav label and ownerless Tool Call skeleton). Technical + * mode copy is shared verbatim across locales per the Figma design. + * Feature-owned rows ship their copy in their own packages. */ 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', @@ -20,8 +18,6 @@ export const zh = { 'title': '设置', 'close': '关闭', 'general.nav': '通用设置', - 'permission.title': '权限', - 'permission.desc': '选择默认权限模式', 'toolcall.title': '工具调用', } satisfies Record @@ -35,7 +31,5 @@ export const en = { 'title': 'Settings', 'close': 'Close', 'general.nav': 'General', - 'permission.title': 'Permission', - 'permission.desc': 'Choose default permission mode', 'toolcall.title': 'Tool Call', } satisfies Record diff --git a/packages/client/ui-settings-general/tests/apply.spec.ts b/packages/client/ui-settings-general/tests/apply.spec.ts index 726efe0be8..dfbaa49c6e 100644 --- a/packages/client/ui-settings-general/tests/apply.spec.ts +++ b/packages/client/ui-settings-general/tests/apply.spec.ts @@ -1,12 +1,12 @@ /** Ownerless-copy registrations: the four seats, the dictionaries, thunked labels, and HMR recovery. */ import { Context } from 'cordis' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' 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 { CloseLabel, HeaderContent, TriggerContent } from '../src/client/chrome.tsx' -import { GeneralSection } from '../src/client/GeneralSection.tsx' +import { GeneralSection, ToolCallSkeleton } from '../src/client/GeneralSection.tsx' /** The four seats this plugin fills (slot name → expected component). */ const SEATS = [ @@ -61,10 +61,16 @@ describe('ui-settings-general apply', () => { // The nav label is a locale-following thunk; owners resolve at read time. expect(resolveSlotLabel(entry.options.label)).toBe('通用设置') expect(before.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' }) + const toolEntry = before.slots.entries('settings.general.item')[0]! + expect(toolEntry).toMatchObject({ + component: ToolCallSkeleton, + options: { id: 'tool-call', order: -10 }, + }) // Copy rides the standard locale seat: every seat declares the namespace. for (const [name] of SEATS) { expect(before.slots.entries(name)[0]!.locale).toBe('settings') } + expect(toolEntry.locale).toBe('settings') const after = await bench() await after.ctx.plugin({ inject: [...inject], apply }).await() @@ -76,6 +82,9 @@ describe('ui-settings-general apply', () => { // The self-inflicted ledger notifications hit the duplicate guard. expect(after.slots.entries(name)).toHaveLength(1) } + await vi.waitFor(() => { + expect(after.slots.entries('settings.general.item')[0]!.component).toBe(ToolCallSkeleton) + }) }) it('registers the zh/en settings dictionaries and frees the seats on teardown', async () => { @@ -124,6 +133,7 @@ describe('ui-settings-general apply', () => { for (const [name, component] of SEATS) { expect(b.slots.entries(name)[0]!.component).toBe(component) } + expect(b.slots.entries('settings.general.item')[0]!.component).toBe(ToolCallSkeleton) expect(b.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' }) // The recovered registrations still ride the locale path. b.locale.setLocale('en') diff --git a/packages/client/ui-settings-general/tests/components.spec.tsx b/packages/client/ui-settings-general/tests/components.spec.tsx index 9af2fb825b..2538335581 100644 --- a/packages/client/ui-settings-general/tests/components.spec.tsx +++ b/packages/client/ui-settings-general/tests/components.spec.tsx @@ -1,8 +1,10 @@ // @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/GeneralSection.tsx' -import { GeneralSection } from '../src/client/GeneralSection.tsx' +import type { + GeneralSectionComponentProps, ToolCallSkeletonProps, +} from '../src/client/GeneralSection.tsx' +import { GeneralSection, ToolCallSkeleton } from '../src/client/GeneralSection.tsx' import { CloseLabel, HeaderContent, TriggerContent } from '../src/client/chrome.tsx' import { en } from '../src/client/locales.ts' @@ -10,7 +12,7 @@ afterEach(cleanup) // The seat's key domain is settings ∪ common; the stub answers from the // package dictionary and falls back to the key like the real chain. -const t: GeneralSectionComponentProps['t'] = key => (en as Record)[key] ?? key +const t: ToolCallSkeletonProps['t'] = key => (en as Record)[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 @@ -42,21 +44,21 @@ describe('GeneralSection', () => { const renderSlot = vi.fn( ((key: string) =>
    ) as GeneralSectionComponentProps['renderSlot'], ) - const props: GeneralSectionComponentProps = { ...kit, t, renderSlot } + const props: GeneralSectionComponentProps = { ...kit, renderSlot } const view = render() return { view, renderSlot } } - 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/ }) - expect(selector.disabled).toBe(true) + it('renders the item slot as the section body', () => { + const { renderSlot } = mount() + expect(renderSlot).toHaveBeenCalledWith('settings.general.item', {}) + expect(screen.getByTestId('slot-settings.general.item')).toBeTruthy() }) +}) - it('renders the Tool Call skeleton cubes with schema pinned selected', () => { - mount() +describe('ToolCallSkeleton', () => { + it('renders the mode cubes with schema pinned selected', () => { + render() expect(screen.getByText('Tool Call')).toBeTruthy() const schema = screen.getByText('Schema mode') const code = screen.getByText('Code mode') @@ -65,10 +67,4 @@ describe('GeneralSection', () => { 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/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 02cf787a27..3d9399cc5c 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md -README.md: 73d8afb32f868ca82dfa2d350df089a5d0b9b358 -README.zh.md: 47af18f76302e261e18f682e0d3cf0ee903933db +README.md: eba4ad8406a4b3426c04d8da37f68a1306b5382d +README.zh.md: 4fdbc889d297d810053836566010bac35839b8da diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 73d8afb32f..eba4ad8406 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -34,7 +34,7 @@ Directory picking delegates to the composed `ctx.directoryPicker` backend ([the The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the browser's user-selected model-reference path, so it returns only skills that are both model-invocable and user-invocable; this domain has no direct skill-loading RPC. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream; the carrier's request signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing. -The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves exactly the namespaces a registered configurable provider addresses (`ctx.llm.listConfigurableProviders()`): the seam is general, but this plane is the model-provider surface, so a namespace nothing in the directory names is neither described nor writable here and answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, and the section's `revision`. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired both by `llm/adapters-updated` and by a change to an exposed provider namespace, whose settings carry that provider's catalog and endpoint. The browser carrier restricts the whole configuration plane, reads included (`settings.describe`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin. +The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus a small explicit Web-preference allowlist, currently only `permission`; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, and the section's `revision`. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired by `llm/adapters-updated` and by a change to a configurable-provider namespace, whose settings carry that provider's catalog and endpoint; a `permission` change emits only its settings invalidation. The browser carrier restricts the whole configuration plane, reads included (`settings.describe`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin. ## Carrier layer (`/client` + root) diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 47af18f763..4fdbc889d2 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -34,7 +34,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr `command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于浏览器中由用户选择的模型引用路径,因此仅返回模型和用户均可调用的 skill;该领域没有直接加载 skill 的 RPC。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载;载体的请求信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。 -`settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域只服务于已注册可配置提供方所指向的那些 namespace(`ctx.llm.listConfigurableProviders()`):seam 本身是通用的,但这个面是模型提供方表层,因此目录中无人点名的 namespace 在这里既不会被描述也不可写入,只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表,以及该分节的 `revision`。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;过期的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}`(`settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它既由 `llm/adapters-updated` 触发,也由某个已暴露提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点。浏览器载体把整个配置面(含读取:`settings.describe`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。 +`settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace(`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 Web 偏好 allowlist,目前仅包含 `permission`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表,以及该分节的 `revision`。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;过期的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}`(`settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它由 `llm/adapters-updated` 和可配置提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点;`permission` 变更只会发出自身的 settings 失效通知。浏览器载体把整个配置面(含读取:`settings.describe`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。 ## 载体层(`/client` + 根路径) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index e224608c72..205c9912f5 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -72,6 +72,9 @@ import { openNativePath } from './native-path-opener.ts' /** Page size when history is called without maxMessages. */ const DEFAULT_MAX_MESSAGES = 50 +/** Non-model settings namespaces intentionally served to the Web client. */ +const WEB_SETTINGS_NAMESPACES = ['permission'] as const + /** Provider work budget: at most 100 calls and 2,000 inspected hits. */ const SESSION_SEARCH_PROVIDER_CALL_LIMIT = 100 @@ -1098,31 +1101,35 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } } - /** - * The settings namespaces this proxy serves: exactly those a registered - * configurable provider addresses. The settings seam itself is general — - * any plugin may register a namespace for its own configuration — but the - * Web configuration plane is scoped to model providers, and that boundary - * has to be enforced here rather than assumed from the current plugin set. - * Without it, every future `settings.register()` would silently become - * remotely readable and writable configuration. - */ - function exposedNamespaces(): Set { + /** Settings namespaces whose changes can invalidate the model catalog. */ + function modelProviderNamespaces(): Set { return new Set(ctx.llm.listConfigurableProviders().map(entry => entry.settingsNs)) } - /** Refuse a namespace outside the model-provider boundary, naming why. */ + /** + * The settings namespaces this proxy serves: configurable model providers + * plus the small explicit Web preference allowlist. The settings seam + * remains general; a future registration does not become remotely readable + * or writable by default. + */ + function exposedNamespaces(): Set { + const exposed = modelProviderNamespaces() + for (const ns of WEB_SETTINGS_NAMESPACES) exposed.add(ns) + return exposed + } + + /** Refuse a namespace outside the explicit configuration-client boundary. */ function notExposed(request: RpcRequest, ns: string): RpcResponse { return err(request, { code: 'settings-not-exposed', - message: `settings namespace "${ns}" is not exposed to configuration clients; only a namespace a registered model provider addresses is`, + message: `settings namespace "${ns}" is not exposed to configuration clients`, details: { ns }, }) } /** * Run one settings write (merge or wholesale replace) and acknowledge with - * the namespace's new redacted view. A namespace outside the model-provider + * the namespace's new redacted view. A namespace outside the configuration * boundary is refused before the seam is touched; every seam refusal — * unknown or invalid namespace, read-only provider, schema validation, * storage — becomes one `settings-rejected` carrying the seam's own message. @@ -2165,11 +2172,12 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro // inherited to overridden leaves the resolved value equal, and a // configuration client still has to re-read (its held revision is // stale, and the field's meaning changed). - queue.push(frame({ type: 'host/settings-changed', ns: String(ns) })) + const name = String(ns) + queue.push(frame({ type: 'host/settings-changed', ns: name })) // A provider's own settings carry its model catalog and endpoint, // so a change there invalidates the model list even when the route // set is untouched — `llm/adapters-updated` alone misses it. - if (exposedNamespaces().has(String(ns))) queue.push(frame({ type: 'host/models-changed' })) + if (modelProviderNamespaces().has(name)) queue.push(frame({ type: 'host/models-changed' })) }), ctx.on('credentials/updated', (ref) => { queue.push(frame({ type: 'host/credentials-changed', ref: String(ref) })) diff --git a/packages/host/apiproxy/tests/api-proxy-config.spec.ts b/packages/host/apiproxy/tests/api-proxy-config.spec.ts index a505f72018..08d2dee2f3 100644 --- a/packages/host/apiproxy/tests/api-proxy-config.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-config.spec.ts @@ -160,8 +160,8 @@ async function harness(options?: { await ctx.plugin(LlmService) if (options?.settings !== false) await ctx.plugin(MemorySettings, options?.settings) if (options?.credentials !== false) await ctx.plugin(MemoryCredentials, options?.credentials) - // The proxy serves only namespaces a configurable provider addresses, which - // is what the real LLM plugins declare at load; the tests mirror that. + // Model-provider namespaces and the explicit Web preference allowlist are + // the proxy's complete settings surface. if (options?.configurableProviders !== false) { ctx.llm.registerConfigurableProviders([ { provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [] }, @@ -222,19 +222,29 @@ describe('settings domain', () => { expect(JSON.stringify(value)).not.toContain('user-secret') }) - it('serves only namespaces a registered model provider addresses', async () => { + it('serves model-provider and explicitly allowlisted Web namespaces only', async () => { // The settings seam is general: any plugin may register a namespace for - // its own configuration. The Web configuration plane is not — it is the - // model-provider surface, and a namespace nothing in the provider - // directory addresses must be invisible and unwritable here, so a future - // plugin cannot become remotely configurable just by registering. + // its own configuration. The Web configuration plane remains opt-in, so a + // future internal plugin cannot become remotely configurable just by + // registering; permission is the one non-model namespace intentionally + // admitted by this surface. const ctx = await harness() ctx.settings.register(NS, AdapterConfig) ctx.settings.register(settingsNamespace('some-other-plugin'), z.object({ secretPath: z.string() })) + ctx.settings.register(settingsNamespace('permission'), z.object({ + defaultPreset: z.union(['read-only', 'workspace-write']).required(), + }), { + base: { defaultPreset: 'read-only' }, + }) const api = createApiProxy(ctx, DEFAULTS) const value = expectOk(await api.settings.describe(request({}))) - expect(value.namespaces.map(view => view.ns)).toEqual(['llm-deepseek']) + expect(value.namespaces.map(view => view.ns)).toEqual(['llm-deepseek', 'permission']) + const permission = expectOk(await api.settings.mutate(request({ + ns: 'permission', + ops: [{ op: 'set', path: ['defaultPreset'], value: 'workspace-write' }], + }))) + expect(permission.value).toEqual({ defaultPreset: 'workspace-write' }) for (const response of [ await api.settings.update(request({ ns: 'some-other-plugin', patch: { secretPath: '/etc/shadow' } })), @@ -277,6 +287,20 @@ describe('settings domain', () => { .toEqual({ apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: 'https://base' }) }) + it('broadcasts a permission change without invalidating the model catalog', async () => { + const ctx = await harness() + const permission = ctx.settings.register(settingsNamespace('permission'), z.object({ + defaultPreset: z.union(['read-only', 'workspace-write']).required(), + }), { + base: { defaultPreset: 'read-only' }, + }) + const api = createApiProxy(ctx, DEFAULTS) + const frames = await collectHost(api, ['host/settings-changed', 'host/models-changed'], 1, async () => { + await permission.update({ defaultPreset: 'workspace-write' }) + }) + expect(frames).toEqual([{ type: 'host/settings-changed', ns: 'permission' }]) + }) + it('maps a stale expectedRevision to settings-conflict carrying both revisions', async () => { const ctx = await harness() ctx.settings.register(NS, AdapterConfig) diff --git a/packages/ui/permission/README.i18n.yaml b/packages/ui/permission/README.i18n.yaml index f6f5f49a14..e23eed90a4 100644 --- a/packages/ui/permission/README.i18n.yaml +++ b/packages/ui/permission/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/ui/permission/README.md -README.md: 814085ed6f2c9650854f377e1c97e442fc4211a4 -README.zh.md: 36880d6b8c3f0b39b88db1abb02534f30e3355fa +README.md: 576555b56c82e4041f2862bcb41ce137cb2d4f77 +README.zh.md: 894289314a4fb2fc22216462d3f3e0544d7d17bb diff --git a/packages/ui/permission/README.md b/packages/ui/permission/README.md index 814085ed6f..576555b56c 100644 --- a/packages/ui/permission/README.md +++ b/packages/ui/permission/README.md @@ -6,7 +6,9 @@ User-facing permission presets through `ctx.permission` ([`PermissionService`](s `set(session, name)` records a changed selection in a log-only `permission/preset` event, then calls each knob's setter only when its effective value changes. The selection event precedes the knob events and preserves user intent when presets share a bundle; a net-zero selection appends nothing. `current(events)` prefers a still-matching recorded selection, then the first matching table entry, and otherwise returns `custom`. Clients may display `custom` as the current value, but cannot select it. -The service requires a confining `ctx.bash` executor and `ctx.approval`. A table entry named `custom` throws at load; composition defaults outside the table instead make a zero-event session derive `custom`. See the [sandbox switching design](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md). +The service owns the `permission` Settings namespace. Its `defaultPreset` is the default for future sessions: the composition entry uses `Config.defaultPreset`, or infers the preset matching the composed sandbox and approval defaults when omitted. A committed Settings change is read when the next session is created; creation pins `permission/preset`, `sandbox/mode`, and `approval/policy` into that session, so later changes never alter an existing session. A resumed seed preserves its effective permission and receives only missing durable facts rather than the latest user default. + +The service requires a confining `ctx.bash` executor and `ctx.approval`. A table entry named `custom` throws at load. When composition defaults match no preset, the plugin requires an explicit `defaultPreset`; an independently constructed zero-event session may still derive `custom`. See the [sandbox switching design](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md). Two optional children ship the product surfaces over the same service: a `permissions` session-projection unit (`src/types.ts` declares the key; the unit folds the three whole-value knob events and views the select — table options plus a current-only `custom` — over the composition defaults) and the `/permission` command (bare invocation reports the current preset and the table; a preset argument switches through `set`). Each child activates only when its registry (`ctx.sessionProjections` / `ctx.commands`) is composed. diff --git a/packages/ui/permission/README.zh.md b/packages/ui/permission/README.zh.md index 36880d6b8c..894289314a 100644 --- a/packages/ui/permission/README.zh.md +++ b/packages/ui/permission/README.zh.md @@ -6,7 +6,9 @@ `set(session, name)` 会先在仅写日志的 `permission/preset` 事件中记录已变更的选择,再仅对实际值发生变化的调节项调用 setter。选择事件先于调节项事件,并在多个 preset 共享同一组取值时保留用户意图;净变化为零的选择不会追加任何内容。`current(events)` 优先返回仍与当前调节项匹配的已记录选择,其次返回表中第一个匹配项,否则返回 `custom`。客户端可以把 `custom` 显示为当前值,但不能选择它。 -该服务要求存在具有约束能力的 `ctx.bash` 执行器和 `ctx.approval`。表中名为 `custom` 的条目会在加载时抛出异常;如果组合在表外指定默认值,则零事件会话会推导出 `custom`。详见[沙箱切换设计](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)。 +该服务拥有 `permission` Settings namespace。其 `defaultPreset` 是未来会话的默认值:组合项使用 `Config.defaultPreset`;省略时,则推断与组合后的沙箱和审批默认值匹配的 preset。已提交的 Settings 变更会在下一个会话创建时读取;创建过程将 `permission/preset`、`sandbox/mode` 和 `approval/policy` 固定到该会话中,因此后续变更绝不会改变现有会话。恢复的 seed 会保留其有效权限,只补齐缺失的持久事实,而不会采用最新的用户默认值。 + +该服务要求存在具有约束能力的 `ctx.bash` 执行器和 `ctx.approval`。表中名为 `custom` 的条目会在加载时抛出异常。当组合默认值与任何 preset 都不匹配时,插件要求显式配置 `defaultPreset`;独立构造的零事件会话仍可能推导出 `custom`。详见[沙箱切换设计](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)。 两个可选子件在同一服务之上交付产品界面:`permissions` 会话投影单元(`src/types.ts` 声明该 key;单元折叠三个全量值旋钮事件,在组合默认值之上视图出 select——表内选项加仅作当前值的 `custom`)与 `/permission` 命令(裸调用报告当前预设与表;预设参数经 `set` 切换)。每个子件仅在其注册表(`ctx.sessionProjections` / `ctx.commands`)被组合时激活。 diff --git a/packages/ui/permission/package.json b/packages/ui/permission/package.json index c5021af37b..c3554e3c4d 100644 --- a/packages/ui/permission/package.json +++ b/packages/ui/permission/package.json @@ -43,6 +43,7 @@ "@deepseek-ai/dsh-sandbox-policy": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-session-projection": "^0.0.1", + "@deepseek-ai/dsh-settings": "^0.0.1", "@deepseek-ai/dsh-user-approval": "^0.0.1", "cordis": "^4.0.0-rc.7" }, @@ -58,6 +59,7 @@ "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^", + "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/ui/permission/src/index.ts b/packages/ui/permission/src/index.ts index 5c18a2b560..3919db66f6 100644 --- a/packages/ui/permission/src/index.ts +++ b/packages/ui/permission/src/index.ts @@ -21,6 +21,7 @@ import { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from '@deepseek-a import type {} from '@deepseek-ai/dsh-bash' import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval' import { APPROVAL_POLICIES, effectiveApprovalPolicy, setApprovalPolicy } from '@deepseek-ai/dsh-user-approval' +import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings' // Type-only: resolves ctx.sessionProjections / ctx.commands for the optional children. import type {} from '@deepseek-ai/dsh-session-projection' import type {} from '@deepseek-ai/dsh-commands' @@ -68,6 +69,9 @@ export interface PresetSpec { */ export const CUSTOM_PRESET = 'custom' +/** Settings namespace carrying the default for future sessions. */ +export const PERMISSION_SETTINGS_NAMESPACE = settingsNamespace('permission') + /** * Fold the last selected preset from the durable log; replay needs no catch-up * state. @@ -126,7 +130,13 @@ function foldKnobs(events: readonly SessionEvent[]): KnobState { return state } -/** The {@link PermissionService} config: the deployment's preset table. */ +/** User setting resolved when a new session receives its initial permission. */ +export interface PermissionSettings { + /** Preset pinned into a newly created session. */ + defaultPreset: string +} + +/** The {@link PermissionService} config: preset table and composition default. */ export interface Config { /** * The preset table: name → knob bundle. Defaults to `workspace-write` @@ -134,6 +144,11 @@ export interface Config { * never). The name `custom` is reserved for the derived not-a-preset state. */ presets?: Record + /** + * Default for new sessions. When omitted, the preset matching the composed + * sandbox and approval defaults is used. + */ + defaultPreset?: string } /** @@ -159,11 +174,13 @@ export class PermissionService extends Service { name: 'danger-full-access', description: 'Full file access without approval prompts.', }, }), + defaultPreset: z.string(), }) static inject = ['bash', 'approval'] private readonly presets: Record + private defaultSettings: () => PermissionSettings constructor(ctx: Context, config: Config) { super(ctx, 'permission') @@ -175,6 +192,34 @@ export class PermissionService extends Service { if (ctx.bash.sandboxMode === undefined) { throw new Error('permission: the mounted bash executor does not confine (no sandboxMode) — presets bundle a sandbox mode, so composing this plugin over an unconfined executor is a misconfiguration') } + const inferredDefault = this.derive(EMPTY_KNOBS) + const defaultPreset = config.defaultPreset ?? inferredDefault + if (defaultPreset === CUSTOM_PRESET) { + throw new Error('permission: composed sandbox and approval defaults match no preset; configure defaultPreset explicitly') + } + this.resolve(defaultPreset) + const baseSettings: PermissionSettings = { defaultPreset } + this.defaultSettings = () => baseSettings + const presetChoices = this.names.map((name) => { + const choice = z.const(name) + const label = this.presets[name]?.name + return label === undefined ? choice : choice.description(label) + }) + const settingsSchema: z = z.object({ + defaultPreset: z.union(presetChoices).required(), + }) + installSettingsSection(ctx, PERMISSION_SETTINGS_NAMESPACE, settingsSchema, baseSettings, { + setSource: (current) => { + this.defaultSettings = current + }, + // The source thunk reads the latest scope snapshot at session creation; + // no process-level registration needs replacement on change. + onChange: () => {}, + }) + + ctx.on('session/created', (session) => { + this.pinInitialPermission(session) + }) // The permissions projection unit: fold the three whole-value knob // events; view derives the select over the composition defaults this @@ -237,6 +282,15 @@ export class PermissionService extends Service { return Object.keys(this.presets) } + /** + * The preset currently selected as the default for future sessions. + * @returns the resolved settings value, or the composition default without + * a mounted settings provider. + */ + get defaultPreset(): string { + return this.defaultSettings().defaultPreset + } + /** * Resolve the preset matching the effective knob values. A still-matching * last selection wins shared-bundle ties; otherwise the first table match @@ -328,6 +382,44 @@ export class PermissionService extends Service { setApprovalPolicy(session, spec.approval) } } + + /** + * Fill every missing permission fact before a session is published. A + * genuinely fresh session uses the current user default; seeded or partially + * initialized sessions preserve their effective knob values and only gain + * the missing durable facts. + */ + private pinInitialPermission(session: Session): void { + const events = session.events + const selected = effectivePermissionPreset(events) + const sandbox = effectiveSandboxMode(events) + const approval = effectiveApprovalPolicy(events) + const seeded = events.some(event => event.type === 'session/end-seed') + if (selected === undefined && sandbox === undefined && approval === undefined && !seeded) { + const name = this.defaultPreset + const spec = this.resolve(name) + session.append('permission/preset', { preset: name }) + setSandboxMode(session, spec.sandbox) + setApprovalPolicy(session, spec.approval) + return + } + + const state: KnobState = { + preset: selected ?? null, + sandbox: sandbox ?? null, + approval: approval ?? null, + } + const effective = this.derive(state) + if (selected === undefined && effective !== CUSTOM_PRESET) { + session.append('permission/preset', { preset: effective }) + } + if (sandbox === undefined) { + setSandboxMode(session, this.ctx.bash.sandboxMode as SandboxMode) + } + if (approval === undefined) { + setApprovalPolicy(session, this.ctx.approval.config.policy ?? 'ask') + } + } } export default PermissionService diff --git a/packages/ui/permission/tests/permission.spec.ts b/packages/ui/permission/tests/permission.spec.ts index 05a747de8d..b7a4203745 100644 --- a/packages/ui/permission/tests/permission.spec.ts +++ b/packages/ui/permission/tests/permission.spec.ts @@ -1,10 +1,29 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import { Session, SessionId } from '@deepseek-ai/dsh-session' +import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval' -import PermissionService, { CUSTOM_PRESET, effectivePermissionPreset } from '@deepseek-ai/dsh-permission' +import PermissionService, { + CUSTOM_PRESET, effectivePermissionPreset, PERMISSION_SETTINGS_NAMESPACE, +} from '@deepseek-ai/dsh-permission' import type { Config } from '@deepseek-ai/dsh-permission' +import { Settings } from '@deepseek-ai/dsh-settings' +import type { SettingsNamespace } from '@deepseek-ai/dsh-settings' + +/** Writable memory provider for the permission/settings lifecycle specs. */ +class MemorySettings extends Settings { + readonly doc: Record = {} + readonly writable = true + + protected load(): Promise> { + return Promise.resolve(structuredClone(this.doc)) + } + + protected persist(ns: SettingsNamespace, section: Record): Promise { + this.doc[ns] = structuredClone(section) + return Promise.resolve() + } +} async function mounted(options: { config?: Config @@ -27,6 +46,23 @@ function freshSession(id: string): Session { return new Session(SessionId(id)) } +async function mountedStore(options: { approvalDefault?: ApprovalPolicy | undefined } = {}): Promise { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(MemorySettings) + ctx.provide('bash', { + sandboxMode: 'workspace-write', + resolve() { throw new Error('permission tests do not execute bash') }, + run() { throw new Error('permission tests do not execute bash') }, + start() { throw new Error('permission tests do not execute bash') }, + }) + ctx.provide('approval', { + config: { policy: 'approvalDefault' in options ? options.approvalDefault : 'ask' }, + }) + await ctx.plugin(PermissionService, {}) + return ctx +} + describe('effectivePermissionPreset', () => { it('folds to the last event, or undefined without one', () => { const session = freshSession('sess-fold') @@ -66,8 +102,11 @@ describe('PermissionService', () => { expect(() => ctx.permission.resolve(CUSTOM_PRESET)).toThrow(/unknown preset/) }) - it('composition defaults outside the table derive custom at zero events', async () => { - const ctx = await mounted({ approvalDefault: 'never' }) + it('composition defaults outside the table still derive custom when an explicit new-session default is configured', async () => { + const ctx = await mounted({ + approvalDefault: 'never', + config: { defaultPreset: 'workspace-write' }, + }) const session = freshSession('sess-defaults-custom') expect(ctx.permission.current(session.events)).toBe(CUSTOM_PRESET) }) @@ -138,6 +177,11 @@ describe('PermissionService', () => { .rejects.toThrow(/reserved for the derived not-a-preset state/) }) + it('requires an explicit default when composition defaults match no preset', async () => { + await expect(mounted({ approvalDefault: 'never' })) + .rejects.toThrow(/configure defaultPreset explicitly/) + }) + it('reads a schema-less approval stand-in as the ask default', async () => { const ctx = await mounted({ approvalDefault: undefined }) const session = freshSession('sess-standin') @@ -146,3 +190,79 @@ describe('PermissionService', () => { expect(ctx.permission.current(session.events)).toBe('workspace-write') }) }) + +describe('new-session default', () => { + it('pins the current setting into each new session without changing earlier sessions', async () => { + const ctx = await mountedStore() + const first = ctx.sessions.create(SessionId('first')) + expect(first.events.map(event => [event.type, event.data])).toEqual([ + ['permission/preset', { preset: 'workspace-write' }], + ['sandbox/mode', { mode: 'workspace-write' }], + ['approval/policy', { policy: 'ask' }], + ]) + + await ctx.settings.update(PERMISSION_SETTINGS_NAMESPACE, { + defaultPreset: 'danger-full-access', + }) + expect(ctx.permission.defaultPreset).toBe('danger-full-access') + const second = ctx.sessions.create(SessionId('second')) + expect(ctx.permission.current(first.events)).toBe('workspace-write') + expect(ctx.permission.current(second.events)).toBe('danger-full-access') + expect(second.events.map(event => event.type)).toEqual([ + 'permission/preset', 'sandbox/mode', 'approval/policy', + ]) + }) + + it('preserves a seeded legacy session instead of applying the latest user default', async () => { + const ctx = await mountedStore() + await ctx.settings.update(PERMISSION_SETTINGS_NAMESPACE, { + defaultPreset: 'danger-full-access', + }) + const legacy = freshSession('legacy-source') + legacy.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + legacy.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + const resumed = ctx.sessions.create(SessionId('legacy-resumed'), { seed: legacy.events }) + expect(ctx.permission.current(resumed.events)).toBe('workspace-write') + expect(resumed.events.slice(-3).map(event => event.type)).toEqual([ + 'permission/preset', 'sandbox/mode', 'approval/policy', + ]) + }) + + it('fills only missing legacy facts and preserves an unmatched seeded combination', async () => { + const ctx = await mountedStore() + const partial = freshSession('partial-source') + partial.append('sandbox/mode', { mode: 'workspace-write' }) + partial.append('approval/policy', { policy: 'ask' }) + const resumed = ctx.sessions.create(SessionId('partial-resumed'), { seed: partial.events }) + expect(resumed.events.at(-1)).toMatchObject({ + type: 'permission/preset', + data: { preset: 'workspace-write' }, + }) + + const custom = freshSession('custom-source') + custom.append('sandbox/mode', { mode: 'read-only' }) + custom.append('approval/policy', { policy: 'never' }) + const unmatched = ctx.sessions.create(SessionId('custom-resumed'), { seed: custom.events }) + expect(ctx.permission.current(unmatched.events)).toBe(CUSTOM_PRESET) + expect(unmatched.events.at(-1)?.type).toBe('session/end-seed') + }) + + it('materializes ask when a legacy seed and approval stand-in omit the policy', async () => { + const ctx = await mountedStore({ approvalDefault: undefined }) + const partial = freshSession('approval-fallback-source') + partial.append('sandbox/mode', { mode: 'workspace-write' }) + const resumed = ctx.sessions.create(SessionId('approval-fallback-resumed'), { seed: partial.events }) + expect(resumed.events.at(-1)).toMatchObject({ + type: 'approval/policy', + data: { policy: 'ask' }, + }) + }) + + it('rejects a stored default outside the configured preset table', async () => { + const ctx = await mountedStore() + await expect(ctx.settings.update(PERMISSION_SETTINGS_NAMESPACE, { + defaultPreset: 'missing', + })).rejects.toThrow() + expect(ctx.permission.defaultPreset).toBe('workspace-write') + }) +}) diff --git a/packages/ui/permission/tests/projection.spec.ts b/packages/ui/permission/tests/projection.spec.ts index 1649fe7077..a50c17a399 100644 --- a/packages/ui/permission/tests/projection.spec.ts +++ b/packages/ui/permission/tests/projection.spec.ts @@ -44,7 +44,7 @@ async function agentFor(ctx: Context, session: Session): Promise { } describe('permissions projection unit', () => { - it('serves the composition-default select at zero events', async () => { + it('serves the pinned new-session default select', async () => { const { ctx, session } = await harness() const value = ctx.sessionProjections.snapshot(session).values.permissions expect(value).toMatchObject({ currentValue: 'workspace-write' }) @@ -103,12 +103,14 @@ describe('/permission command', () => { kind: 'success', text: 'current preset workspace-write (available: workspace-write, danger-full-access)', }) - expect(session.events.filter(event => event.type === 'permission/preset')).toHaveLength(0) + expect(session.events.filter(event => event.type === 'permission/preset')).toHaveLength(1) }) it('rejects an unknown preset without touching the log', async () => { const { ctx, session } = await harness() const agent = await agentFor(ctx, session) + const before = session.events.filter(event => + event.type !== 'command/run' && event.type !== 'command/done') const execution = await ctx.commands.execute(agent, '/permission yolo', new AbortController().signal) // The error text carries the same no-self-labelling rule as the success // texts: `permission · unknown preset "yolo" (…)`, not `unknown permission @@ -117,6 +119,7 @@ describe('/permission command', () => { kind: 'error', text: 'unknown preset "yolo" (available: workspace-write, danger-full-access)', }) - expect(session.events.filter(event => event.type !== 'command/run' && event.type !== 'command/done')).toHaveLength(0) + expect(session.events.filter(event => + event.type !== 'command/run' && event.type !== 'command/done')).toEqual(before) }) }) diff --git a/packages/ui/permission/tsconfig.json b/packages/ui/permission/tsconfig.json index 493fbf358e..9dc4afcd9a 100644 --- a/packages/ui/permission/tsconfig.json +++ b/packages/ui/permission/tsconfig.json @@ -38,6 +38,9 @@ { "path": "../../session-projection/session-projection" }, + { + "path": "../../settings/settings" + }, { "path": "../commands" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 545d5c7eae..96a8df6f8f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1413,24 +1413,48 @@ importers: packages/client/ui-permission: devDependencies: + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../connection + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ version: link:../runtime + '@deepseek-ai/dsh-client-schema-form': + specifier: workspace:^ + version: link:../schema-form '@deepseek-ai/dsh-client-ui-command': specifier: workspace:^ version: link:../ui-command + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives '@deepseek-ai/dsh-client-ui-slash': specifier: workspace:^ version: link:../ui-slash + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-client-web-react': + specifier: workspace:^ + version: link:../web-react '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants '@deepseek-ai/dsh-permission': specifier: workspace:^ version: link:../../ui/permission + '@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-plan: devDependencies: @@ -5431,6 +5455,9 @@ importers: '@deepseek-ai/dsh-session-projection': specifier: workspace:^ version: link:../../session-projection/session-projection + '@deepseek-ai/dsh-settings': + specifier: workspace:^ + version: link:../../settings/settings '@deepseek-ai/dsh-user-approval': specifier: workspace:^ version: link:../user-approval From f45b6f76a5fcd4198f49c572f4dd57a6fda9d1cf Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 31 Jul 2026 12:48:39 +0800 Subject: [PATCH 04/20] fix(web): remove tool-call settings placeholder --- ...mission-default-for-new-sessions.i18n.yaml | 4 +- ...-31-permission-default-for-new-sessions.md | 2 +- ...-permission-default-for-new-sessions.zh.md | 2 +- apps/web/tests/settings-chrome.e2e.ts | 5 +- .../settings-chrome/dialog.expected.md | 2 +- .../ui-settings-general/README.i18n.yaml | 4 +- packages/client/ui-settings-general/README.md | 4 +- .../client/ui-settings-general/README.zh.md | 4 +- .../client/ui-settings-general/package.json | 2 +- .../src/client/GeneralSection.module.css | 67 +------------------ .../src/client/GeneralSection.tsx | 35 +--------- .../ui-settings-general/src/client/index.ts | 15 +---- .../ui-settings-general/src/client/locales.ts | 17 +---- .../ui-settings-general/tests/apply.spec.ts | 14 ++-- .../tests/components.spec.tsx | 22 ++---- 15 files changed, 30 insertions(+), 169 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.i18n.yaml index a76d46c1ce..b29ee4ec4b 100644 --- a/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.md -2026-07-31-permission-default-for-new-sessions.md: 236e0eedd2b3a6ba64a837fa1838d63545f13fb1 -2026-07-31-permission-default-for-new-sessions.zh.md: 8cdb5e6a0b6ca8a9a878351474728b325fb92528 +2026-07-31-permission-default-for-new-sessions.md: 78ec7a9b7c690c7b29fa10c1518fb7466971f0ac +2026-07-31-permission-default-for-new-sessions.zh.md: f6b29b879112de8b8f1c1f7ab466b7d90f9a142c diff --git a/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.md b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.md index 236e0eedd2..78ec7a9b7c 100644 --- a/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.md +++ b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.md @@ -14,7 +14,7 @@ The Web General-settings page displayed Permission as a disabled skeleton even t The service reads the current Settings value synchronously at `session/created`. A genuinely fresh session receives three explicit events: `permission/preset`, `sandbox/mode`, and `approval/policy`. Those facts pin the permission selected at creation, so a later Settings change affects only later sessions. A seeded or partially initialized session preserves its effective knobs and receives only missing facts; it never adopts the latest user default while resuming. -The existing `/permission` command and `permissions` projection remain the current-session path. The browser plugin now contributes the Permission row to `settings.general.item`, reads the dynamic enum from the redacted Settings descriptor, and writes only `defaultPreset` through a revision-checked `settings.mutate`. The ownerless General-settings package retains only the Tool Call skeleton. +The existing `/permission` command and `permissions` projection remain the current-session path. The browser plugin now contributes the Permission row to `settings.general.item`, reads the dynamic enum from the redacted Settings descriptor, and writes only `defaultPreset` through a revision-checked `settings.mutate`. The ownerless General-settings package contributes no placeholder rows. ApiProxy explicitly adds `permission` to its Web settings allowlist beside the configurable-provider namespaces. This is a local boundary decision, not a general registration flag or a `local-client` access model: registering another Settings namespace still does not expose it. Permission changes emit `host/settings-changed` but not `host/models-changed`. diff --git a/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.zh.md b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.zh.md index 8cdb5e6a0b..f6b29b8791 100644 --- a/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.zh.md @@ -14,7 +14,7 @@ Web「通用」设置页将「权限」显示为禁用的骨架控件,尽管 ` 服务会在 `session/created` 时同步读取当前 Settings 值。真正的新会话会收到三个显式事件:`permission/preset`、`sandbox/mode` 和 `approval/policy`。这些事实将创建时选中的权限固定下来,因此后续 Settings 变更只影响之后的会话。带 seed 或只完成部分初始化的会话会保留其有效调节项,只补齐缺失的事实;恢复时绝不会采用最新的用户默认值。 -现有 `/permission` 命令和 `permissions` 投影仍是当前会话的操作路径。浏览器插件现在向 `settings.general.item` 贡献「权限」行,从脱敏后的 Settings 描述符读取动态 enum,并只通过经过 revision 校验的 `settings.mutate` 写入 `defaultPreset`。无归属的「通用」设置包只保留「工具调用」骨架。 +现有 `/permission` 命令和 `permissions` 投影仍是当前会话的操作路径。浏览器插件现在向 `settings.general.item` 贡献「权限」行,从脱敏后的 Settings 描述符读取动态 enum,并只通过经过 revision 校验的 `settings.mutate` 写入 `defaultPreset`。无归属的「通用」设置包不贡献任何占位行。 ApiProxy 在可配置提供方 namespace 之外,将 `permission` 显式加入 Web Settings allowlist。这是局部的边界决策,而不是通用注册标志或 `local-client` 访问模型:注册其他 Settings namespace 仍不会将其暴露。权限变更会发出 `host/settings-changed`,但不会发出 `host/models-changed`。 diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index e81b8ab6fd..e3a016dd68 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -53,8 +53,7 @@ describe('web e2e: settings modal and General preferences', () => { const dialog = page.getByRole('dialog', { name: '设置' }) await dialog.waitFor({ timeout: 10_000 }) expect(await trigger.getAttribute('aria-expanded')).toBe('true') - // General is active by default; Permission, Language and Appearance are - // functional, while Tool Call remains a skeleton. + // General is active by default; Permission, Language and Appearance are functional. expect(await dialog.getByRole('button', { name: '通用设置' }).getAttribute('aria-current')).toBe('true') await dialog.getByRole('button', { name: 'Danger Full Access' }).waitFor({ timeout: 10_000 }) await expect.poll(() => dialog.getByText('语言', { exact: true }).count(), { timeout: 5_000 }).toBe(1) @@ -88,7 +87,7 @@ describe('web e2e: settings modal and General preferences', () => { await dialog.waitFor({ timeout: 10_000 }) const selector = dialog.getByRole('button', { name: 'Danger Full Access' }) await selector.waitFor({ timeout: 10_000 }) - expect(await selector.isEnabled()).toBe(true) + await expect.poll(() => selector.isEnabled(), { timeout: 5_000 }).toBe(true) await selector.click() await page.getByRole('menuitem', { name: 'Read Only' }).click() await dialog.getByRole('button', { name: 'Read Only' }).waitFor({ timeout: 10_000 }) diff --git a/apps/web/tests/snapshots/settings-chrome/dialog.expected.md b/apps/web/tests/snapshots/settings-chrome/dialog.expected.md index ff65e630f0..9234aa4948 100644 --- a/apps/web/tests/snapshots/settings-chrome/dialog.expected.md +++ b/apps/web/tests/snapshots/settings-chrome/dialog.expected.md @@ -14,7 +14,7 @@ - button "Danger Full Access": - text: Danger Full Access - img - - text: 工具调用 Schema mode Traditional function calling — invoke tools one at a time Code mode Chain multiple tools with code — multi-step orchestration 语言 + - text: 语言 - button "中文": - text: 中文 - img diff --git a/packages/client/ui-settings-general/README.i18n.yaml b/packages/client/ui-settings-general/README.i18n.yaml index 8beb4c9578..b1fd862af4 100644 --- a/packages/client/ui-settings-general/README.i18n.yaml +++ b/packages/client/ui-settings-general/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-settings-general/README.md -README.md: 9e12f02fc1e767fb807be4fdd3f506c189662bc7 -README.zh.md: 225e27f5705f33bc6199615b0fe96e04eaa6a04c +README.md: 241678567c4dbc7411ab9e76f595f2f696cc02d6 +README.zh.md: da4568d109c20bf1860fb8841942d42078b9443a diff --git a/packages/client/ui-settings-general/README.md b/packages/client/ui-settings-general/README.md index 9e12f02fc1..241678567c 100644 --- a/packages/client/ui-settings-general/README.md +++ b/packages/client/ui-settings-general/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -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 (`settings.general.item` slot plus the Tool Call skeleton), and the `settings` dictionaries. Feature-owned rows (Permission, Language, Appearance) and sections (Models) stay with their feature packages. +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 and its `settings.general.item` slot, and the `settings` dictionaries. Feature-owned rows (Permission, Language, Appearance) and sections (Models) stay with their feature packages. ## Model Experience @@ -14,4 +14,4 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **Tool Call is a display skeleton** — its backing host setting does not exist yet, so the cubes write nothing. When it gains real backing, the row moves to its owning feature plugin per the self-registration doctrine. +- The General section has no built-in rows; each row appears only when its owning feature plugin is mounted. diff --git a/packages/client/ui-settings-general/README.zh.md b/packages/client/ui-settings-general/README.zh.md index 225e27f570..da4568d109 100644 --- a/packages/client/ui-settings-general/README.zh.md +++ b/packages/client/ui-settings-general/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -设置界面无归属文案插件:在设置界面注册所有不属于单一功能的内容,包括外壳的触发器、标题栏与关闭控件内容,「通用」分区(`settings.general.item` slot 加上「工具调用」骨架行),以及 `settings` 字典。归具体功能所有的行(「权限」、「语言」、「外观」)和分区(「模型」)仍由各自的功能包提供。 +设置界面无归属文案插件:在设置界面注册所有不属于单一功能的内容,包括外壳的触发器、标题栏与关闭控件内容,「通用」分区及其 `settings.general.item` slot,以及 `settings` 字典。归具体功能所有的行(「权限」、「语言」、「外观」)和分区(「模型」)仍由各自的功能包提供。 ## 模型体验 @@ -14,4 +14,4 @@ ## 已知限制与暂缓事项 -- **「工具调用」只是展示骨架**:其宿主设置尚不存在,因此控件不会写入任何内容。一旦获得实际支撑,按照自注册原则,该行会移至拥有它的功能插件。 +- 「通用」分区没有内置行;每一行仅在其所属功能插件挂载时出现。 diff --git a/packages/client/ui-settings-general/package.json b/packages/client/ui-settings-general/package.json index 6d0ee01404..af85a9954f 100644 --- a/packages/client/ui-settings-general/package.json +++ b/packages/client/ui-settings-general/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-client-ui-settings-general", - "description": "Settings ownerless-copy plugin: the General section and Tool Call skeleton, shell trigger/header chrome content, and settings dictionaries", + "description": "Settings ownerless-copy plugin: the General section, shell trigger/header chrome content, and settings dictionaries", "version": "0.0.1", "private": true, "type": "module", diff --git a/packages/client/ui-settings-general/src/client/GeneralSection.module.css b/packages/client/ui-settings-general/src/client/GeneralSection.module.css index cb1d137ec8..efa367bc52 100644 --- a/packages/client/ui-settings-general/src/client/GeneralSection.module.css +++ b/packages/client/ui-settings-general/src/client/GeneralSection.module.css @@ -1,7 +1,5 @@ -/* 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). */ +/* Feature-contributed rows own their chrome and separators; the section + * strips the trailing separator wherever the column ends. */ .section { display: flex; @@ -12,64 +10,3 @@ .section > :last-child { border-bottom: none; } - -/* 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); -} - -.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); -} - -/* Tool Call mode cubes share an 8px gap and wrap to one per row when the - panel is too narrow. */ -.cubeRow { - display: flex; - align-items: stretch; - gap: 8px; - flex-wrap: wrap; -} - -/* Tool Call mode cube (figma '.Selector Cube' 418w r16, flexed to fit the - * 800 panel; horizontal inset = outer pad 4 + inner .Menu_cell pad 10, - * vertical = inner pad 8). */ -.modeCube { - box-sizing: border-box; - flex: 1 1 276px; - 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; - cursor: pointer; -} - -.modeCube:hover:not(.selected) { - background: var(--dsw-alias-interactive-bg-hover); -} - -/* 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 index 861f4e23d4..1217b36f96 100644 --- a/packages/client/ui-settings-general/src/client/GeneralSection.tsx +++ b/packages/client/ui-settings-general/src/client/GeneralSection.tsx @@ -1,9 +1,5 @@ -/** - * The General section (figma 501:29983 'Options'): one column rendering the - * `settings.general.item` contributions. Features own their rows; this - * package contributes only the ownerless Tool Call skeleton. - */ -import type { PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +/** The General section: one column rendering feature-owned item contributions. */ +import type { PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import css from './GeneralSection.module.css' /** Full component props: section owner share plus item render share. */ @@ -22,30 +18,3 @@ export function GeneralSection({ renderSlot }: GeneralSectionComponentProps) {
    ) } - -/** Props of the ownerless Tool Call item contribution. */ -export type ToolCallSkeletonProps = - PropsRuntime<'settings.general.item'> & PropsLocale<'settings'> - -/** - * Render the static Tool Call mode choice until its host setting exists. - * @param props - item runtime and translated copy. - * @returns the skeleton row. - */ -export function ToolCallSkeleton({ t }: ToolCallSkeletonProps) { - return ( -
    -
    {t('toolcall.title')}
    -
    -
    -
    {t('toolcall.schema.title')}
    -
    {t('toolcall.schema.desc')}
    -
    -
    -
    {t('toolcall.code.title')}
    -
    {t('toolcall.code.desc')}
    -
    -
    -
    - ) -} diff --git a/packages/client/ui-settings-general/src/client/index.ts b/packages/client/ui-settings-general/src/client/index.ts index 894b3a50e9..0a0d84ed80 100644 --- a/packages/client/ui-settings-general/src/client/index.ts +++ b/packages/client/ui-settings-general/src/client/index.ts @@ -1,8 +1,7 @@ /** * 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 (`settings.general.item` slot plus the - * ownerless Tool Call skeleton), and the `settings` dictionaries. + * chrome content, the General section, and the `settings` dictionaries. * Feature-owned rows and sections stay with their features. * Export discipline: packages/client/AGENTS.md. */ @@ -13,14 +12,14 @@ import type {} from '@deepseek-ai/dsh-client-ui-settings/client' // Type-only: pulls ctx.locale and the 'settings.general.item' SlotMap merge. import type {} from '@deepseek-ai/dsh-client-locale/client' import { CloseLabel, HeaderContent, TriggerContent } from './chrome.tsx' -import { GeneralSection, ToolCallSkeleton } from './GeneralSection.tsx' +import { GeneralSection } from './GeneralSection.tsx' import { en, zh, type SettingsKey } from './locales.ts' export type { CloseLabelProps, HeaderContentProps, TriggerContentProps, } from './chrome.tsx' export type { - GeneralSectionComponentProps, ToolCallSkeletonProps, + GeneralSectionComponentProps, } from './GeneralSection.tsx' export type { SettingsKey } from './locales.ts' @@ -69,19 +68,11 @@ export function apply(ctx: ClientContext): void { locale: NS, children: { 'settings.general.item': { kind: 'list', scope: 'root' } }, }, GeneralSection)) - const toolCall = deferRegistration(ctx.slots, 'settings.general.item', ToolCallSkeleton, () => - ctx.slots.register({ - name: 'settings.general.item', - id: 'tool-call', - order: -10, - locale: NS, - }, ToolCallSkeleton)) return () => { trigger.dispose() header.dispose() close.dispose() general.dispose() - toolCall.dispose() } }, 'ui-settings-general: chrome and section registrations') } diff --git a/packages/client/ui-settings-general/src/client/locales.ts b/packages/client/ui-settings-general/src/client/locales.ts index 1e3ff3b883..b71fc683b9 100644 --- a/packages/client/ui-settings-general/src/client/locales.ts +++ b/packages/client/ui-settings-general/src/client/locales.ts @@ -1,24 +1,11 @@ -/** - * `settings` namespace dictionaries: shell chrome plus the shell-owned - * General section (nav label and ownerless Tool Call skeleton). Technical - * mode copy is shared verbatim across locales per the Figma design. - * Feature-owned rows ship their copy in their own packages. - */ -const SHARED = { - '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 Record +/** Shell chrome and General-nav dictionaries; feature rows own their copy. */ /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { - ...SHARED, 'trigger': '设置', 'title': '设置', 'close': '关闭', 'general.nav': '通用设置', - 'toolcall.title': '工具调用', } satisfies Record /** The settings namespace key union. */ @@ -26,10 +13,8 @@ export type SettingsKey = keyof typeof zh /** English dictionary, checked complete against the zh key set. */ export const en = { - ...SHARED, 'trigger': 'Settings', 'title': 'Settings', 'close': 'Close', 'general.nav': 'General', - 'toolcall.title': 'Tool Call', } satisfies Record diff --git a/packages/client/ui-settings-general/tests/apply.spec.ts b/packages/client/ui-settings-general/tests/apply.spec.ts index dfbaa49c6e..506a69699a 100644 --- a/packages/client/ui-settings-general/tests/apply.spec.ts +++ b/packages/client/ui-settings-general/tests/apply.spec.ts @@ -6,7 +6,7 @@ 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 { CloseLabel, HeaderContent, TriggerContent } from '../src/client/chrome.tsx' -import { GeneralSection, ToolCallSkeleton } from '../src/client/GeneralSection.tsx' +import { GeneralSection } from '../src/client/GeneralSection.tsx' /** The four seats this plugin fills (slot name → expected component). */ const SEATS = [ @@ -61,17 +61,11 @@ describe('ui-settings-general apply', () => { // The nav label is a locale-following thunk; owners resolve at read time. expect(resolveSlotLabel(entry.options.label)).toBe('通用设置') expect(before.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' }) - const toolEntry = before.slots.entries('settings.general.item')[0]! - expect(toolEntry).toMatchObject({ - component: ToolCallSkeleton, - options: { id: 'tool-call', order: -10 }, - }) + expect(before.slots.entries('settings.general.item')).toEqual([]) // Copy rides the standard locale seat: every seat declares the namespace. for (const [name] of SEATS) { expect(before.slots.entries(name)[0]!.locale).toBe('settings') } - expect(toolEntry.locale).toBe('settings') - const after = await bench() await after.ctx.plugin({ inject: [...inject], apply }).await() for (const [name] of SEATS) expect(after.slots.entries(name)).toHaveLength(0) @@ -83,7 +77,7 @@ describe('ui-settings-general apply', () => { expect(after.slots.entries(name)).toHaveLength(1) } await vi.waitFor(() => { - expect(after.slots.entries('settings.general.item')[0]!.component).toBe(ToolCallSkeleton) + expect(after.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' }) }) }) @@ -133,7 +127,7 @@ describe('ui-settings-general apply', () => { for (const [name, component] of SEATS) { expect(b.slots.entries(name)[0]!.component).toBe(component) } - expect(b.slots.entries('settings.general.item')[0]!.component).toBe(ToolCallSkeleton) + expect(b.slots.entries('settings.general.item')).toEqual([]) expect(b.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' }) // The recovered registrations still ride the locale path. b.locale.setLocale('en') diff --git a/packages/client/ui-settings-general/tests/components.spec.tsx b/packages/client/ui-settings-general/tests/components.spec.tsx index 2538335581..db6be78ccd 100644 --- a/packages/client/ui-settings-general/tests/components.spec.tsx +++ b/packages/client/ui-settings-general/tests/components.spec.tsx @@ -1,18 +1,17 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, render, screen } from '@testing-library/react' -import type { - GeneralSectionComponentProps, ToolCallSkeletonProps, -} from '../src/client/GeneralSection.tsx' -import { GeneralSection, ToolCallSkeleton } from '../src/client/GeneralSection.tsx' +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 type { TriggerContentProps } from '../src/client/chrome.tsx' import { en } from '../src/client/locales.ts' afterEach(cleanup) // The seat's key domain is settings ∪ common; the stub answers from the // package dictionary and falls back to the key like the real chain. -const t: ToolCallSkeletonProps['t'] = key => (en as Record)[key] ?? key +const t: TriggerContentProps['t'] = key => (en as Record)[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 @@ -55,16 +54,3 @@ describe('GeneralSection', () => { expect(screen.getByTestId('slot-settings.general.item')).toBeTruthy() }) }) - -describe('ToolCallSkeleton', () => { - it('renders the mode cubes with schema pinned selected', () => { - render() - 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() - }) -}) From 62ad4880206cbbad9c11bfbbc71f9e0bc244b1a5 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 31 Jul 2026 12:51:23 +0800 Subject: [PATCH 05/20] ci: refresh pull request mergeability From 7aa126e0d57062764e100e0cd64c5754388d5a76 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 31 Jul 2026 13:36:40 +0800 Subject: [PATCH 06/20] test(session): expect empty fork seed marker --- packages/core/session/tests/fork.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/session/tests/fork.spec.ts b/packages/core/session/tests/fork.spec.ts index b04d08e9ab..b0381f3c3f 100644 --- a/packages/core/session/tests/fork.spec.ts +++ b/packages/core/session/tests/fork.spec.ts @@ -67,7 +67,7 @@ describe('SessionStore.fork', () => { const child = sessions.fork(source, undefined, SessionId('empty-child')) - expect(child.events).toEqual([]) + expect(inherited(child)).toEqual([]) expect(child.header).toMatchObject({ id: SessionId('empty-child'), cwd: '/workspace', From 8ab647366087b6228b39342e24f1ae8f8cc6b8f4 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 31 Jul 2026 13:45:22 +0800 Subject: [PATCH 07/20] test(permission): cover settings confirmation paths --- .../ui-permission/src/client/PermissionRow.tsx | 18 ++++++++---------- .../ui-permission/tests/browser-plugin.spec.ts | 2 ++ .../tests/permission-row.spec.tsx | 7 +++++++ 3 files changed, 17 insertions(+), 10 deletions(-) diff --git a/packages/client/ui-permission/src/client/PermissionRow.tsx b/packages/client/ui-permission/src/client/PermissionRow.tsx index b27c5fc584..ae8c8bafe7 100644 --- a/packages/client/ui-permission/src/client/PermissionRow.tsx +++ b/packages/client/ui-permission/src/client/PermissionRow.tsx @@ -41,7 +41,7 @@ export type PermissionRowProps = export function PermissionRow({ load, select, usePermission, t }: PermissionRowProps) { const state = usePermission(snapshot => snapshot) const [open, setOpen] = useState(false) - const [confirmation, setConfirmation] = useState(null) + const [confirmingFullAccess, setConfirmingFullAccess] = useState(false) const [acknowledged, setAcknowledged] = useState(false) useEffect(() => { @@ -52,12 +52,12 @@ export function PermissionRow({ load, select, usePermission, t }: PermissionRowP if (state.writable && state.status !== 'unavailable') return setOpen(false) setAcknowledged(false) - setConfirmation(null) + setConfirmingFullAccess(false) }, [state.status, state.writable]) if (state.status === 'unavailable') return null const selected = state.options.find(option => option.id === state.currentValue) - const busy = state.status === 'loading' || state.status === 'saving' || confirmation !== null + const busy = state.status === 'loading' || state.status === 'saving' || confirmingFullAccess const label = selected?.label ?? (busy ? t('loading') : t('unavailable')) const description: string = state.error ?? t('description') @@ -79,7 +79,7 @@ export function PermissionRow({ load, select, usePermission, t }: PermissionRowP if (id === state.currentValue) return if (id === FULL_ACCESS_PRESET) { setAcknowledged(false) - setConfirmation(id) + setConfirmingFullAccess(true) return } void select(id) @@ -102,7 +102,7 @@ export function PermissionRow({ load, select, usePermission, t }: PermissionRowP /> { setAcknowledged(false) - setConfirmation(null) + setConfirmingFullAccess(false) }} onConfirm={() => { - if (!acknowledged || confirmation === null) return - const preset = confirmation setAcknowledged(false) - setConfirmation(null) - void select(preset) + setConfirmingFullAccess(false) + void select(FULL_ACCESS_PRESET) }} /> diff --git a/packages/client/ui-permission/tests/browser-plugin.spec.ts b/packages/client/ui-permission/tests/browser-plugin.spec.ts index 399f5e8306..fea56a413a 100644 --- a/packages/client/ui-permission/tests/browser-plugin.spec.ts +++ b/packages/client/ui-permission/tests/browser-plugin.spec.ts @@ -104,6 +104,8 @@ describe('ui-permission browser plugin', () => { expect(injected?.hooks.permission).toBeDefined() expect(typeof injected?.load).toBe('function') expect(typeof injected?.select).toBe('function') + await injected!.load() + await injected!.select('read-only') }) it('availability follows the projection key; options mark the current value active and exclude custom', async () => { diff --git a/packages/client/ui-permission/tests/permission-row.spec.tsx b/packages/client/ui-permission/tests/permission-row.spec.tsx index 81a4dc4b70..f74e6ae2ad 100644 --- a/packages/client/ui-permission/tests/permission-row.spec.tsx +++ b/packages/client/ui-permission/tests/permission-row.spec.tsx @@ -75,6 +75,9 @@ describe('PermissionRow', () => { fireEvent.click(button) expect(button.getAttribute('aria-expanded')).toBe('false') fireEvent.click(button) + fireEvent.click(screen.getByRole('menuitem', { name: 'Read Only' })) + expect(mutate).not.toHaveBeenCalled() + fireEvent.click(button) fireEvent.click(screen.getByRole('menuitem', { name: 'Workspace Write' })) await screen.findByRole('button', { name: 'Workspace Write' }) expect(mutate).toHaveBeenCalledOnce() @@ -92,6 +95,10 @@ describe('PermissionRow', () => { fireEvent.click(await screen.findByRole('button', { name: 'Read Only' })) fireEvent.click(screen.getByRole('menuitem', { name: 'Full access' })) expect(mutate).not.toHaveBeenCalled() + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + expect(screen.queryByRole('dialog', { name: 'Enable Full access?' })).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Read Only' })) + fireEvent.click(screen.getByRole('menuitem', { name: 'Full access' })) const dialog = screen.getByRole('dialog', { name: 'Enable Full access?' }) const enable = screen.getByRole('button', { name: 'Enable Full access' }) expect((enable as HTMLButtonElement).disabled).toBe(true) From b00cd0cd8cc75517262b3f1eba93ffd818f7b05c Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 31 Jul 2026 02:39:26 +0800 Subject: [PATCH 08/20] feat(workspace): add registry-global archivedSessionIds set The workspace domain global singleton gains archivedSessionIds (schema default [], version unchanged): a display-layer archive set layered over workspace accounting. archiveSession() rides the registry operation chain, validates the session against live/persisted headers, and is idempotent; archived sessions keep their sessionIds slot so a future unarchive restores position. --- packages/workspace/workspace/README.i18n.yaml | 4 +- packages/workspace/workspace/README.md | 1 + packages/workspace/workspace/README.zh.md | 1 + packages/workspace/workspace/src/index.ts | 61 +++++++++++++- packages/workspace/workspace/src/spec.ts | 9 +- .../workspace/tests/workspace.spec.ts | 83 +++++++++++++++++-- 6 files changed, 145 insertions(+), 14 deletions(-) diff --git a/packages/workspace/workspace/README.i18n.yaml b/packages/workspace/workspace/README.i18n.yaml index a24e81d977..0805eb6ddc 100644 --- a/packages/workspace/workspace/README.i18n.yaml +++ b/packages/workspace/workspace/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/workspace/workspace/README.md -README.md: bee3e4fcb5dded273f30942ee2e42ee93b839e62 -README.zh.md: 9a052f796fb7cc8756999bdc9b2ce905805730ab +README.md: 2d69074d42c28ba5e340cef19c8007bcba94e755 +README.zh.md: f95246a0c970aede1944868489d122d7f309d8e7 diff --git a/packages/workspace/workspace/README.md b/packages/workspace/workspace/README.md index bee3e4fcb5..2d69074d42 100644 --- a/packages/workspace/workspace/README.md +++ b/packages/workspace/workspace/README.md @@ -13,6 +13,7 @@ The entity/storage rationale lives in the [domain Agent Note](../../../.agents/n - `ctx.workspace.delete(id)` — removes only the Workspace registration, its durable order entry, and its session account. Unknown ids return `false`; a removed record returns `true`. The directory, user files, live Sessions, and persisted session logs are never touched, so those Sessions become Ungrouped. A table-write failure restores the prior order and published entity. - `Workspace.attachSession(id)` — validates a live or persisted session header cwd against the workspace path and prepends a new id. Unknown sessions, absent/unresolvable/non-directory cwd values, and mismatches reject without writing. `detachSession` removes only the candidate index entry. - `ctx.workspace.touchSession(id)` — moves only that validated, accounted session to the front. Ungrouped or filtered sessions are no-ops, and workspace order never changes. +- `ctx.workspace.archiveSession(id)` / `archivedSessionIds` — the registry-global archive set, layered over workspace accounting: an archived session disappears from grouping surfaces but keeps its session log and its `sessionIds` slot, so a future unarchive restores its position. Archiving accepts any live or persisted session (accounted or Ungrouped), resolves without writing for an already archived id, and rejects an unknown id. State written before the field existed parses with an empty set. - `Workspace.sessionIds` — synchronous id-plus-canonical-cwd membership projection in durable candidate order. Missing headers, invalid cwd values, and mismatches are filtered; the next workspace mutation prunes them. A medium indexing one session under two workspaces, claiming one path from two records, or diverging from durable workspace order rejects at startup. - `Workspace.status()` — uncached directory check, `'ok' | 'missing-dir'`; a missing directory never mutates the record. diff --git a/packages/workspace/workspace/README.zh.md b/packages/workspace/workspace/README.zh.md index 9a052f796f..f95246a0c9 100644 --- a/packages/workspace/workspace/README.zh.md +++ b/packages/workspace/workspace/README.zh.md @@ -13,6 +13,7 @@ DeepSeek Harness 的 Workspace 实体注册表(`ctx.workspace`):通过领 - `ctx.workspace.delete(id)`:只移除 Workspace 注册记录、对应的持久顺序条目及会话归属记录。未知 id 返回 `false`,成功移除记录则返回 `true`。目录、用户文件、活跃会话和持久化会话日志绝不受影响,因此相关会话会进入 Ungrouped。表写入失败时会恢复原顺序和此前发布的实体。 - `Workspace.attachSession(id)`:对照 workspace 路径验证实时或已持久化的会话头 cwd,并将新 id 前置。未知会话、缺失/无法解析/非目录的 cwd 值和不匹配情况都会在不写入的前提下被拒绝。`detachSession` 只移除候选索引条目。 - `ctx.workspace.touchSession(id)`:仅将已验证、已记账的会话移到最前。未分组或被过滤的会话不会触发任何操作,workspace 顺序绝不改变。 +- `ctx.workspace.archiveSession(id)`/`archivedSessionIds`:覆盖在 workspace 记账之上的注册表级全局归档集合:被归档的会话从各分组视图中消失,但其会话日志和 `sessionIds` 席位保持不变,未来取消归档时可恢复原位置。归档接受任何实时或已持久化的会话(无论已记账还是 Ungrouped),对已归档的 id 直接完成而不写入,并拒绝未知 id。在该字段出现之前写入的状态解析为一个空集合。 - `Workspace.sessionIds`:按持久候选顺序提供同步 id 加规范 cwd 成员投影。缺失头部、无效 cwd 值和不匹配情况都被过滤;下一次 workspace 变更会剪除它们。如果同一存储介质将一个会话索引到两个 workspace 下、用两条记录声明同一路径,或偏离持久 workspace 顺序,启动会被拒绝。 - `Workspace.status()`:未缓存的目录检查,返回 `'ok' | 'missing-dir'`;目录缺失绝不会改动记录。 diff --git a/packages/workspace/workspace/src/index.ts b/packages/workspace/workspace/src/index.ts index 5172c63805..742c328e29 100644 --- a/packages/workspace/workspace/src/index.ts +++ b/packages/workspace/workspace/src/index.ts @@ -49,6 +49,18 @@ export class WorkspaceNameConflictError extends Error { } } +/** An archiveSession request named a session neither live nor in session persistence. */ +export class WorkspaceUnknownSessionError extends Error { + /** + * @param sessionId - The unknown session id. + * @param options - Standard error options (the header-read failure as `cause`). + */ + constructor(readonly sessionId: SessionId, options?: ErrorOptions) { + super(`cannot archive session '${sessionId}': live sessions and session persistence hold no such session`, options) + this.name = 'WorkspaceUnknownSessionError' + } +} + declare module 'cordis' { interface Context { @@ -181,6 +193,38 @@ export class WorkspaceRegistry extends Service { return this.enqueueOperation(() => this.deleteKnown(id)) } + /** + * The registry-global archive set: sessions hidden from every grouping + * surface. Archiving never touches workspace accounting — an archived + * session keeps its `sessionIds` slot so unarchiving restores its position. + * @returns the archived session ids in archive order. + */ + get archivedSessionIds(): readonly SessionId[] { + return this.requireState().archivedSessionIds + } + + /** + * Archive one session durably. The session must exist (live or in session + * persistence); its workspace accounting — or lack of one — is irrelevant. + * An already archived id resolves without writing. + * @param sessionId - The session to archive. + * @returns resolution after durability. + */ + archiveSession(sessionId: SessionId): Promise { + return this.enqueueOperation(async () => { + // The chain slot serializes against every other registry write, so this + // check-then-write pair cannot interleave with another archive. + if (this.requireState().archivedSessionIds.includes(sessionId)) return + try { + await this.readSessionHeader(sessionId) + } catch (error) { + throw new WorkspaceUnknownSessionError(sessionId, { cause: error }) + } + const state = this.requireState() + await this.setState({ ...state, archivedSessionIds: [...state.archivedSessionIds, sessionId] }) + }) + } + /** * Resolve by canonical directory path without creating or mutating a * workspace. A missing path rejects during `realpath`; an existing unowned @@ -245,7 +289,11 @@ export class WorkspaceRegistry extends Service { } try { - await this.setState({ initialized: true, workspaceIds: [id, ...state.workspaceIds] }) + await this.setState({ + initialized: true, + workspaceIds: [id, ...state.workspaceIds], + archivedSessionIds: state.archivedSessionIds, + }) } catch (error) { this.entities.delete(id) try { @@ -276,6 +324,7 @@ export class WorkspaceRegistry extends Service { const nextState = { initialized: true, workspaceIds: state.workspaceIds.filter(workspaceId => workspaceId !== id), + archivedSessionIds: state.archivedSessionIds, } await this.setState({ ...nextState, @@ -329,7 +378,11 @@ export class WorkspaceRegistry extends Service { ) } await this.requireTable().delete(pending.workspaceId) - await this.setState({ initialized: state.initialized, workspaceIds: state.workspaceIds }) + await this.setState({ + initialized: state.initialized, + workspaceIds: state.workspaceIds, + archivedSessionIds: state.archivedSessionIds, + }) } private async bootstrap(headers: readonly SessionHeader[]): Promise { @@ -411,9 +464,9 @@ export class WorkspaceRegistry extends Service { .map(([id]) => id) if (!sameIds(state.workspaceIds, workspaceIds)) { - await this.setState({ initialized: false, workspaceIds }) + await this.setState({ initialized: false, workspaceIds, archivedSessionIds: state.archivedSessionIds }) } - await this.setState({ initialized: true, workspaceIds }) + await this.setState({ initialized: true, workspaceIds, archivedSessionIds: state.archivedSessionIds }) } private validateStoredState(state: WorkspaceDomainState): void { diff --git a/packages/workspace/workspace/src/spec.ts b/packages/workspace/workspace/src/spec.ts index 7b1a6a41d0..ba1b89e39d 100644 --- a/packages/workspace/workspace/src/spec.ts +++ b/packages/workspace/workspace/src/spec.ts @@ -42,11 +42,16 @@ const workspacePendingMutation = z.discriminatedUnion('operation', [ /** * Durable registry state. `initialized` distinguishes a valid empty registry * from one that still needs the header-only history bootstrap; - * `workspaceIds` is the authoritative display order. + * `workspaceIds` is the authoritative display order. `archivedSessionIds` is + * the registry-global archive set layered over workspace accounting: an + * archived session keeps its `sessionIds` slot (unarchiving must restore the + * position), so the set never participates in the one-owner accounting + * invariant. Defaulted so records written before the field parse unchanged. */ export const workspaceDomainState = z.object({ initialized: z.boolean(), workspaceIds: z.array(workspaceId), + archivedSessionIds: z.array(z.string().transform(SessionId)).default([]), pendingMutation: workspacePendingMutation.optional(), }) @@ -64,7 +69,7 @@ export const workspaceDomainSpec = defineDomain({ version: 2, global: { schema: workspaceDomainState, - initial: { initialized: false, workspaceIds: [] }, + initial: { initialized: false, workspaceIds: [], archivedSessionIds: [] }, }, tables: { workspaces: domainTable(workspaceRecord) }, }) diff --git a/packages/workspace/workspace/tests/workspace.spec.ts b/packages/workspace/workspace/tests/workspace.spec.ts index 4576155f3b..56910f67c8 100644 --- a/packages/workspace/workspace/tests/workspace.spec.ts +++ b/packages/workspace/workspace/tests/workspace.spec.ts @@ -135,9 +135,16 @@ function record(path: string, sessionIds: string[], createdAt = '2026-07-24T00:0 } } +/** + * Media written before archivedSessionIds existed omit the field; keeping the + * fixtures in that shape continuously proves the schema default upgrades them. + */ +type StoredDomainState = Omit + & Partial> + function storedPool( entries: Array<[string, WorkspaceRecord]>, - state: WorkspaceDomainState, + state: StoredDomainState, ): MemoryMediaPool { const pool = new MemoryMediaPool() pool.versions.set('workspace', DOMAIN_VERSION) @@ -185,7 +192,7 @@ describe('WorkspaceRegistry lifecycle and bootstrap', () => { await fiber.await() expect(ctx.workspace.list()).toEqual([]) expect(list).toHaveBeenCalledTimes(1) - expect(storedState(pool)).toEqual({ initialized: true, workspaceIds: [] }) + expect(storedState(pool)).toEqual({ initialized: true, workspaceIds: [], archivedSessionIds: [] }) }) it('bootstraps once from list headers only, in workspace/session createdAt order', async () => { @@ -218,6 +225,7 @@ describe('WorkspaceRegistry lifecycle and bootstrap', () => { expect(storedState(result.pool)).toEqual({ initialized: true, workspaceIds: result.registry.list().map(workspace => workspace.id), + archivedSessionIds: [], }) }) @@ -246,7 +254,7 @@ describe('WorkspaceRegistry lifecycle and bootstrap', () => { const second = await harness({ pool, sessions: [header('late', late, 100)] }) expect(second.list).not.toHaveBeenCalled() expect(second.registry.list()).toEqual([]) - expect(storedState(pool)).toEqual({ initialized: true, workspaceIds: [] }) + expect(storedState(pool)).toEqual({ initialized: true, workspaceIds: [], archivedSessionIds: [] }) }) it('reuses partial records after a bootstrap record write fails', async () => { @@ -476,7 +484,7 @@ describe('WorkspaceRegistry create and lookup', () => { await expect(result.registry.delete(workspace.id)).resolves.toBe(false) expect(result.registry.get(workspace.id)).toBeUndefined() expect(result.registry.list()).toEqual([]) - expect(storedState(result.pool)).toEqual({ initialized: true, workspaceIds: [] }) + expect(storedState(result.pool)).toEqual({ initialized: true, workspaceIds: [], archivedSessionIds: [] }) expect(result.pool.media.get('workspace')!.tables.get('workspaces')!.has(workspace.id)).toBe(false) await expect(realpath(dir)).resolves.toBe(dir) expect(result.list).toHaveBeenCalledTimes(1) @@ -519,6 +527,7 @@ describe('WorkspaceRegistry create and lookup', () => { expect(storedState(pool)).toEqual({ initialized: true, workspaceIds: [], + archivedSessionIds: [], pendingMutation: { operation: 'delete', workspaceId: workspace.id }, }) const reregistered = await first.registry.create(dir) @@ -526,6 +535,7 @@ describe('WorkspaceRegistry create and lookup', () => { expect(storedState(pool)).toEqual({ initialized: true, workspaceIds: [reregistered.id], + archivedSessionIds: [], }) await first.fiber.dispose() @@ -762,7 +772,7 @@ describe('header-validated membership projection', () => { const createRecovery = await harness({ pool: interruptedCreate }) expect(createRecovery.registry.list()).toEqual([]) expect(interruptedCreate.media.get('workspace')!.tables.get('workspaces')!.has(createId)).toBe(false) - expect(storedState(interruptedCreate)).toEqual({ initialized: true, workspaceIds: [] }) + expect(storedState(interruptedCreate)).toEqual({ initialized: true, workspaceIds: [], archivedSessionIds: [] }) const interruptedDelete = storedPool( [[deleteId, record(deleteDir, [])]], @@ -775,7 +785,7 @@ describe('header-validated membership projection', () => { const deleteRecovery = await harness({ pool: interruptedDelete }) expect(deleteRecovery.registry.list()).toEqual([]) expect(interruptedDelete.media.get('workspace')!.tables.get('workspaces')!.has(deleteId)).toBe(false) - expect(storedState(interruptedDelete)).toEqual({ initialized: true, workspaceIds: [] }) + expect(storedState(interruptedDelete)).toEqual({ initialized: true, workspaceIds: [], archivedSessionIds: [] }) const corruptPending = storedPool( [[deleteId, record(deleteDir, [])]], @@ -816,3 +826,64 @@ describe('workspace mutation and status', () => { expect(registry.get(workspace.id)).toBe(workspace) }) }) + +describe('registry-global session archive', () => { + it('archives durably in order, idempotently skips repeats, and leaves accounting untouched', async () => { + const dir = await makeDir('archive-home') + const result = await harness({ sessions: [header('kept', dir, 100), header('gone', dir, 200)] }) + const workspace = result.registry.list()[0]! + expect(result.registry.archivedSessionIds).toEqual([]) + + await result.registry.archiveSession(SessionId('gone')) + expect(result.registry.archivedSessionIds).toEqual(['gone']) + // Archiving is a display-set write: the workspace account keeps the id. + expect(workspace.sessionIds).toContain('gone') + expect(storedState(result.pool).archivedSessionIds).toEqual(['gone']) + const changesAfterFirst = result.changes.filter(change => change.table === '').length + + await result.registry.archiveSession(SessionId('gone')) + expect(result.registry.archivedSessionIds).toEqual(['gone']) + // The idempotent repeat neither rewrites the medium nor emits a change. + expect(result.changes.filter(change => change.table === '').length).toBe(changesAfterFirst) + + await result.registry.archiveSession(SessionId('kept')) + expect(result.registry.archivedSessionIds).toEqual(['gone', 'kept']) + }) + + it('accepts unaccounted and live sessions but rejects unknown ids without writing', async () => { + const dir = await makeDir('archive-strays') + const live = await makeDir('archive-live') + const result = await harness({ + sessions: [header('stray', dir, 100)], + liveSessions: [header('live-only', live, 200)], + }) + await result.registry.archiveSession(SessionId('stray')) + await result.registry.archiveSession(SessionId('live-only')) + expect(result.registry.archivedSessionIds).toEqual(['stray', 'live-only']) + + await expect(result.registry.archiveSession(SessionId('ghost'))) + .rejects.toThrow(/cannot archive session 'ghost'/) + expect(storedState(result.pool).archivedSessionIds).toEqual(['stray', 'live-only']) + }) + + it('restores the archive set across restarts and defaults it for pre-field media', async () => { + const dir = await makeDir('archive-restart') + const pool = new MemoryMediaPool() + const first = await harness({ pool, sessions: [header('s1', dir, 100)] }) + await first.registry.archiveSession(SessionId('s1')) + await first.fiber.dispose() + + const second = await harness({ pool, sessions: [header('s1', dir, 100)] }) + expect(second.registry.archivedSessionIds).toEqual(['s1']) + await second.fiber.dispose() + + // A medium written before the field existed parses through the schema default. + const legacyId = WorkspaceId('00000000-0000-4000-8000-00000000000a') + const legacy = storedPool( + [[legacyId, record(dir, [])]], + { initialized: true, workspaceIds: [legacyId] }, + ) + const upgraded = await harness({ pool: legacy }) + expect(upgraded.registry.archivedSessionIds).toEqual([]) + }) +}) From 53ceab53b82fac05251653d8bd6de2191c9c75c9 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 31 Jul 2026 02:40:03 +0800 Subject: [PATCH 09/20] feat(apiproxy): workspace.archiveSession RPC and archived-sessions frame workspace.archiveSession answers the full updated archive set; workspace.list carries the set as the reconnect baseline; the host stream pushes host/archived-sessions-changed full snapshots from the domain/changed global-put branch (same posture as workspace-changed). Unknown sessions map to the existing session-not-found code. --- packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/README.zh.md | 2 +- packages/host/apiproxy/src/api-proxy.ts | 36 ++++++++++++++++- .../host/apiproxy/src/api/events.schema.ts | 1 + packages/host/apiproxy/src/api/events.ts | 5 ++- packages/host/apiproxy/src/api/rpc-map.ts | 1 + .../host/apiproxy/src/api/workspace.schema.ts | 11 +++++ packages/host/apiproxy/src/api/workspace.ts | 20 +++++++++- packages/host/apiproxy/src/fetch/client.ts | 4 ++ packages/host/apiproxy/src/fetch/handler.ts | 2 + .../tests/api-proxy-workspace.spec.ts | 40 +++++++++++++++++++ .../apiproxy/tests/client-handler.spec.ts | 7 +++- .../host/apiproxy/tests/fetch-carrier.spec.ts | 5 ++- .../host/apiproxy/tests/rpc-schemas.spec.ts | 12 +++++- 15 files changed, 139 insertions(+), 13 deletions(-) diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 573790009f..1bd0f19d0b 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md -README.md: b12c1179a7b64b4b67efa01598e19bffc9f4198c -README.zh.md: 740c01bf46a0df56470fd2b4655e3c21317199bb +README.md: 8f08d90f8a91afc2ff022761d2d83de055df18a0 +README.zh.md: febeba0e601d75cbc69490d29135c206189efe29 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index b12c1179a7..8f08d90f8a 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -22,7 +22,7 @@ Session model routing is a session-domain contract. `session.models` returns the Pending queued input is a live control-plane contract, not session history. The gateway mirrors queued `InboxItem` occurrences from `agent/inbox/*` and broadcasts authoritative `session/queue` snapshots on every queued change and reconnect; pending steering stays outside this Web projection. `session.updateQueue` addresses one `InboxItemId`: edit replaces pending content and remove discards it. A driver claim wins races by retiring the address before admission; a later operation returns `queue-item-not-found`. The operation queries only an attached Agent and never resumes a cold session because process-local inbox identities do not survive restart or disposal. The client never infers retirement from turn or status events. -Workspace and Session lists are separate reconnect baselines. `workspace.create` creates a unique name or adopts an existing directory, `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`. +Workspace and Session lists are separate reconnect baselines. `workspace.create` creates a unique name or adopts an existing directory, `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. `workspace.archiveSession` adds one session to the registry-global archive set and answers the full updated set; `workspace.list` carries that set as the reconnect baseline and `host/archived-sessions-changed` pushes the full snapshot after every durable change. Archiving hides the session from grouping surfaces without touching its log or its workspace account; a session neither live nor persisted fails with `session-not-found`. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`. `session.search` is a bounded content-search projection over the sessions visible through `session.list`. The gateway asks the optional `ctx.sessionQuery` service for globally ranked current-surface user, assistant, and steering matches, consumes that stream until it has at most 20 visible session/snippet pairs plus one lookahead, and revalidates every hit against the list-derived authorization set before returning it. Provider pages start at 20 hits; when a first-page request rejects that limit, the gateway probes 10, 5, 2, then 1 and retains the learned size for continuation and stale-generation restarts. Returned snippets contain at most 240 Unicode code points, and the response schema independently enforces that bound at each client boundary. Keeping the authorization set in Host memory avoids SQLite's variable ceiling for large valid corpora without weakening visibility or ranking. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 740c01bf46..febeba0e60 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -22,7 +22,7 @@ 待处理的 queued 输入属于实时控制平面契约,而非会话历史。网关镜像来自 `agent/inbox/*` 的 queued `InboxItem` 入队项,并在每次 queued 变更和重连时广播权威的 `session/queue` 快照;待处理 steering(中途引导)不进入此 Web 投影。`session.updateQueue` 通过 `InboxItemId` 寻址单个项:编辑会替换待处理内容,移除会将其丢弃。驱动器在接纳前退役寻址标识,因此认领会赢得竞态;之后的操作返回 `queue-item-not-found`。该操作只查询当前已挂载的 Agent,绝不恢复冷会话,因为进程本地 inbox 标识无法在重启或资源释放后存活。客户端绝不根据轮次或状态事件推断项已退役。 -Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create` 会创建唯一名称或接纳现有目录,`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。 +Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create` 会创建唯一名称或接纳现有目录,`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。`workspace.archiveSession` 向注册表级全局归档集合添加一个会话,并应答完整的更新后集合;`workspace.list` 携带该集合作为重连基线,`host/archived-sessions-changed` 在每次持久变更后推送完整快照。归档只把会话从各分组视图中隐藏,不触碰其日志和 workspace 记账;既非实时也未持久化的会话以 `session-not-found` 失败。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。 `session.search` 是以 `session.list` 所列会话为范围的有界内容搜索投影。网关向可选的 `ctx.sessionQuery` 服务请求全局排序后的当前 surface user、assistant 和 steering(中途引导)匹配项,并持续消费该结果流,直到获得至多 20 个可见会话/snippet 对及一个前瞻项;返回前仍会依据从列表推导的授权集合重新校验每个命中。提供方分页初始请求 20 个命中;如果第一页请求因这一上限被拒绝,网关会依次探测 10、5、2、1,并在续传和陈旧世代重启中沿用探测所得的页面大小。返回的 snippet 最多包含 240 个 Unicode 码点,响应 schema 则会在每个客户端边界独立强制执行该上限。将授权集合保留在宿主内存中,可在不削弱可见性或排序的前提下避开有效大型语料库的 SQLite 变量上限。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 205c9912f5..b6365a4a52 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -21,7 +21,7 @@ import { SessionQueryError, type SessionSearchCursor } from '@deepseek-ai/dsh-se import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace' import { workspaceDomainState, workspaceRecord, WorkspaceId as brandWorkspaceId, - WorkspaceMoveInvalidError, WorkspaceNameConflictError, + WorkspaceMoveInvalidError, WorkspaceNameConflictError, WorkspaceUnknownSessionError, } 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' @@ -1582,7 +1582,10 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro workspace: { list(request) { - return Promise.resolve(ok(request, { items: ctx.workspace.list().map(workspaceView) })) + return Promise.resolve(ok(request, { + items: ctx.workspace.list().map(workspaceView), + archivedSessionIds: [...ctx.workspace.archivedSessionIds], + })) }, // Exactly one of path/name arrives (schema refine). Existing-folder @@ -1698,6 +1701,23 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } return ok(request, { workspace: workspaceView(workspace) }) }, + + async archiveSession(request) { + const { sessionId } = request.payload + try { + await ctx.workspace.archiveSession(sessionId) + } catch (error: unknown) { + // Only the registry's unknown-session rejection is the business + // code; storage/durability failures propagate as internal errors. + if (!(error instanceof WorkspaceUnknownSessionError)) throw error + return err(request, { + code: 'session-not-found', + message: error.message, + details: { sessionId }, + }) + } + return ok(request, { archivedSessionIds: [...ctx.workspace.archivedSessionIds] }) + }, }, host: { @@ -2109,6 +2129,10 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro const committedWorkspaceIds = new Set( ctx.workspace.list().map(workspace => String(workspace.id)), ) + // Frame-dedup baseline, same posture as committedWorkspaceIds: the + // stream opens against the current set; workspace.list re-baselines + // reconnecting clients, so only later changes need frames. + let archivedSessionIds = ctx.workspace.archivedSessionIds const disposers = [ ctx.on('session/created', (session: Session) => { queue.push(frame({ @@ -2145,6 +2169,14 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro committedWorkspaceIds.add(workspaceId) queue.push(frame({ type: 'host/workspace-changed', workspace: workspaceView(workspace) })) } + if (state.archivedSessionIds.length !== archivedSessionIds.length + || state.archivedSessionIds.some((id, index) => id !== archivedSessionIds[index])) { + archivedSessionIds = state.archivedSessionIds + queue.push(frame({ + type: 'host/archived-sessions-changed', + archivedSessionIds: [...state.archivedSessionIds], + })) + } return } if (change.table !== 'workspaces') return diff --git a/packages/host/apiproxy/src/api/events.schema.ts b/packages/host/apiproxy/src/api/events.schema.ts index 186c189879..ea4b6c892f 100644 --- a/packages/host/apiproxy/src/api/events.schema.ts +++ b/packages/host/apiproxy/src/api/events.schema.ts @@ -71,6 +71,7 @@ export const hostFrameSchema = z.discriminatedUnion('type', [ z.object({ type: z.literal('host/agent-error'), sessionId: sessionIdSchema, message: z.string() }), z.object({ type: z.literal('host/workspace-changed'), workspace: workspaceViewSchema }), z.object({ type: z.literal('host/workspace-removed'), workspaceId: workspaceIdSchema }), + z.object({ type: z.literal('host/archived-sessions-changed'), archivedSessionIds: z.array(sessionIdSchema) }), z.object({ type: z.literal('host/commands-changed') }), z.object({ type: z.literal('host/settings-changed'), ns: z.string() }), z.object({ type: z.literal('host/credentials-changed'), ref: z.string() }), diff --git a/packages/host/apiproxy/src/api/events.ts b/packages/host/apiproxy/src/api/events.ts index d678f2f911..d8ee3f6bff 100644 --- a/packages/host/apiproxy/src/api/events.ts +++ b/packages/host/apiproxy/src/api/events.ts @@ -101,7 +101,9 @@ export type MuxFrame = * workspace mutation (create/attach/order change — the client upserts, while * `workspace.list` provides the reconnect baseline); workspace-removed is the * committed registration-deletion increment and never implies directory or - * session-log deletion. + * session-log deletion; archived-sessions-changed pushes the full registry + * archive set after every durable change (same full-snapshot posture as + * workspace-changed — `workspace.list` re-baselines it on reconnect). */ export type HostFrame = | { type: 'host/session-added'; sessionId: SessionId; blank: boolean; parentSessionId?: SessionId; cwd?: string } @@ -110,6 +112,7 @@ export type HostFrame = | { type: 'host/agent-error'; sessionId: SessionId; message: string } | { type: 'host/workspace-changed'; workspace: WorkspaceView } | { type: 'host/workspace-removed'; workspaceId: WorkspaceView['workspaceId'] } + | { type: 'host/archived-sessions-changed'; archivedSessionIds: SessionId[] } /** * The command registry changed (`commands/change` passthrough). Pure * invalidation signal, no payload: clients refetch `command.list` in the diff --git a/packages/host/apiproxy/src/api/rpc-map.ts b/packages/host/apiproxy/src/api/rpc-map.ts index f2f112f7cd..88e2c05575 100644 --- a/packages/host/apiproxy/src/api/rpc-map.ts +++ b/packages/host/apiproxy/src/api/rpc-map.ts @@ -42,6 +42,7 @@ export interface RpcMethodMap { 'workspace.rename': WorkspaceApi['rename'] 'workspace.delete': WorkspaceApi['delete'] 'workspace.insertSessionBefore': WorkspaceApi['insertSessionBefore'] + 'workspace.archiveSession': WorkspaceApi['archiveSession'] 'command.list': CommandsApi['list'] 'command.execute': CommandsApi['execute'] 'skill.list': SkillsApi['list'] diff --git a/packages/host/apiproxy/src/api/workspace.schema.ts b/packages/host/apiproxy/src/api/workspace.schema.ts index e16e5339da..20b3038301 100644 --- a/packages/host/apiproxy/src/api/workspace.schema.ts +++ b/packages/host/apiproxy/src/api/workspace.schema.ts @@ -28,6 +28,7 @@ export const workspaceListRequestSchema = z.object({}) satisfies z.ZodType>> /** workspace.create request payload: exactly one of path/name (the contract's create spellings). */ @@ -80,3 +81,13 @@ export const workspaceInsertSessionBeforeRequestSchema = z.object({ export const workspaceInsertSessionBeforeValueSchema = z.object({ workspace: workspaceViewSchema, }) satisfies z.ZodType>> + +/** workspace.archiveSession request payload. */ +export const workspaceArchiveSessionRequestSchema = z.object({ + sessionId: sessionIdSchema, +}) satisfies z.ZodType>> + +/** workspace.archiveSession response value: the full updated archive set. */ +export const workspaceArchiveSessionValueSchema = z.object({ + archivedSessionIds: z.array(sessionIdSchema), +}) satisfies z.ZodType>> diff --git a/packages/host/apiproxy/src/api/workspace.ts b/packages/host/apiproxy/src/api/workspace.ts index ff22d845fb..957c566bbd 100644 --- a/packages/host/apiproxy/src/api/workspace.ts +++ b/packages/host/apiproxy/src/api/workspace.ts @@ -37,8 +37,13 @@ export interface WorkspaceView { /** Workspace-domain unary methods (the map keys workspace.* of RpcMethodMap). */ export interface WorkspaceApi { - /** Lists all workspaces in the registry's durable display order. */ - list(request: RpcRequest<{}>): Promise> + /** + * Lists all workspaces in the registry's durable display order, plus the + * registry-global archive set (the reconnect baseline of + * `host/archived-sessions-changed`). Archived sessions stay in their + * workspace's `sessionIds` account; grouping surfaces hide them. + */ + list(request: RpcRequest<{}>): Promise> /** * Creates (or idempotently resolves) a workspace. Exactly one of `path` / @@ -86,4 +91,15 @@ export interface WorkspaceApi { sessionId: SessionId beforeSessionId?: SessionId }>): Promise> + + /** + * Adds one session to the registry-global archive set: the session + * disappears from every grouping surface but keeps its session log and its + * workspace accounting slot (a future unarchive restores its position). + * Idempotent for an already archived id. A session neither live nor in + * session persistence fails with `session-not-found`. Returns the full + * updated set (same snapshot the changed frame carries). + */ + archiveSession(request: RpcRequest<{ sessionId: SessionId }>): + Promise> } diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts index 5261658ee0..7b5c1545de 100644 --- a/packages/host/apiproxy/src/fetch/client.ts +++ b/packages/host/apiproxy/src/fetch/client.ts @@ -31,6 +31,7 @@ import { sessionUpdateQueueValueSchema, } from '../api/sessions.schema.ts' import { + workspaceArchiveSessionValueSchema, workspaceCreateValueSchema, workspaceDeleteValueSchema, workspaceInsertSessionBeforeValueSchema, @@ -98,6 +99,7 @@ export interface IApiClient { rename(payload: RequestPayload<'workspace.rename'>, signal?: AbortSignal): Promise>> delete(payload: RequestPayload<'workspace.delete'>, signal?: AbortSignal): Promise>> insertSessionBefore(payload: RequestPayload<'workspace.insertSessionBefore'>, signal?: AbortSignal): Promise>> + archiveSession(payload: RequestPayload<'workspace.archiveSession'>, signal?: AbortSignal): Promise>> } commands: { list(payload: RequestPayload<'command.list'>, signal?: AbortSignal): Promise>> @@ -163,6 +165,7 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType this.callUnary('workspace.rename', payload, signal), delete: (payload, signal) => this.callUnary('workspace.delete', payload, signal), insertSessionBefore: (payload, signal) => this.callUnary('workspace.insertSessionBefore', payload, signal), + archiveSession: (payload, signal) => this.callUnary('workspace.archiveSession', payload, signal), } readonly commands: IApiClient['commands'] = { diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index 0cf7625a65..6bc060e969 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -33,6 +33,7 @@ import { hostPickDirectoryRequestSchema, } from '../api/host.schema.ts' import { + workspaceArchiveSessionRequestSchema, workspaceCreateRequestSchema, workspaceDeleteRequestSchema, workspaceInsertSessionBeforeRequestSchema, @@ -96,6 +97,7 @@ const UNARY_ROUTES: UnaryRoutes = { 'workspace.rename': { schema: workspaceRenameRequestSchema, invoke: (api, r) => api.workspace.rename(r) }, 'workspace.delete': { schema: workspaceDeleteRequestSchema, invoke: (api, r) => api.workspace.delete(r) }, 'workspace.insertSessionBefore': { schema: workspaceInsertSessionBeforeRequestSchema, invoke: (api, r) => api.workspace.insertSessionBefore(r) }, + 'workspace.archiveSession': { schema: workspaceArchiveSessionRequestSchema, invoke: (api, r) => api.workspace.archiveSession(r) }, 'command.list': { schema: commandListRequestSchema, invoke: (api, r) => api.commands.list(r) }, 'command.execute': { schema: commandExecuteRequestSchema, invoke: (api, r, signal) => api.commands.execute(r, signal) }, 'skill.list': { schema: skillListRequestSchema, invoke: (api, r) => api.skills.list(r) }, diff --git a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts index f520f8766a..d9211dde6c 100644 --- a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts @@ -441,4 +441,44 @@ describe('Host Workspace increments', () => { expect(expectOk(await api.sessions.list(request({}))).items.map(item => item.sessionId)).toContain(sessionId) abort.abort() }) + + it('archives a session into the global set, keeps its accounting, and streams the set once', async () => { + const { api } = await harness() + const workspace = expectOk(await api.workspace.create(request({ name: 'archive-home' }))).workspace + const sessionId = SessionId('session-to-archive') + expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId }))) + expect(expectOk(await api.workspace.list(request({}))).archivedSessionIds).toEqual([]) + + const abort = new AbortController() + const stream: AsyncIterator> = + api.events.host(request({}), abort.signal)[Symbol.asyncIterator]() + const changed = nextHostFrame(stream) + expect(expectOk(await api.workspace.archiveSession(request({ sessionId }))).archivedSessionIds) + .toEqual([sessionId]) + expect(await changed).toMatchObject({ + payload: { type: 'host/archived-sessions-changed', archivedSessionIds: [sessionId] }, + }) + + // Accounting and the session itself are untouched; list re-baselines the set. + const listed = expectOk(await api.workspace.list(request({}))) + expect(listed.archivedSessionIds).toEqual([sessionId]) + expect(listed.items[0]?.sessionIds).toEqual([sessionId]) + expect(expectOk(await api.sessions.list(request({}))).items.map(item => item.sessionId)).toContain(sessionId) + + // The idempotent repeat emits no second frame: the next observed frame is + // the workspace-changed of a later attach, not another archive snapshot. + const after = nextHostFrame(stream) + expect(expectOk(await api.workspace.archiveSession(request({ sessionId }))).archivedSessionIds) + .toEqual([sessionId]) + const otherSession = SessionId('session-after-archive') + expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId: otherSession }))) + expect((await after).payload.type).not.toBe('host/archived-sessions-changed') + + const missing = await api.workspace.archiveSession(request({ sessionId: SessionId('session-ghost') })) + expect(missing.result).toMatchObject({ + ok: false, + error: { code: 'session-not-found', details: { sessionId: 'session-ghost' } }, + }) + abort.abort() + }) }) diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index 1a667bf4a6..6307dfe8f9 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -66,11 +66,12 @@ function scriptedApi(overrides: { ...overrides.host, }, workspace: { - list: r => ok(r, { items: [] }), + list: r => ok(r, { items: [], archivedSessionIds: [] }), 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' } }), delete: r => ok(r, { deleted: true as const }), insertSessionBefore: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' } }), + archiveSession: r => ok(r, { archivedSessionIds: [r.payload.sessionId] }), }, commands: { list: r => ok(r, { commands: [] }), @@ -360,10 +361,12 @@ describe('workspace domain round trip', () => { it('routes both workspace methods through their handler rows and value schemas', async () => { const c = client(scriptedApi()) const list = await c.workspace.list({}) - expect(list.result).toEqual({ ok: true, value: { items: [] } }) + expect(list.result).toEqual({ ok: true, value: { items: [], archivedSessionIds: [] } }) const created = await c.workspace.create({ path: '/t' }) expect(created.result.ok).toBe(true) if (created.result.ok) expect(created.result.value.created).toBe(true) + const archivedResponse = await c.workspace.archiveSession({ sessionId: 's-arch' as never }) + expect(archivedResponse.result).toEqual({ ok: true, value: { archivedSessionIds: ['s-arch'] } }) }) it('rejects a create payload violating the exactly-one refine at the handler', async () => { diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index 3a21d6d1ce..ef111afe12 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -122,7 +122,7 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra }, workspace: { async list(request) { - return { rpcId: request.rpcId, result: { ok: true, value: { items: [] } } } + return { rpcId: request.rpcId, result: { ok: true, value: { items: [], archivedSessionIds: [] } } } }, async create(request) { return { @@ -145,6 +145,9 @@ 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' } } }, } }, + async archiveSession(request) { + return { rpcId: request.rpcId, result: { ok: true, value: { archivedSessionIds: [request.payload.sessionId] } } } + }, }, commands: { async list(request) { diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index aa9c46d9ae..f6bd093178 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -20,6 +20,7 @@ import { hostListDirectoryRequestSchema, hostListDirectoryValueSchema, } from '../src/api/host.schema.ts' import { + workspaceArchiveSessionRequestSchema, workspaceArchiveSessionValueSchema, workspaceCreateRequestSchema, workspaceCreateValueSchema, workspaceIdSchema, workspaceDeleteRequestSchema, workspaceDeleteValueSchema, workspaceInsertSessionBeforeRequestSchema, workspaceInsertSessionBeforeValueSchema, @@ -312,7 +313,16 @@ describe('workspace domain schemas', () => { expect(workspaceViewSchema.parse(view).sessionIds).toEqual(['s1']) expect(() => workspaceViewSchema.parse({ ...view, sessionIds: 's1' })).toThrow() expect(workspaceListRequestSchema.parse({})).toEqual({}) - expect(workspaceListValueSchema.parse({ items: [view] }).items).toHaveLength(1) + expect(workspaceListValueSchema.parse({ items: [view], archivedSessionIds: ['s1'] }).items).toHaveLength(1) + expect(() => workspaceListValueSchema.parse({ items: [view] })).toThrow() + }) + + it('archiveSession request/value carry the id and the full updated set', () => { + expect(workspaceArchiveSessionRequestSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1') + expect(() => workspaceArchiveSessionRequestSchema.parse({})).toThrow() + expect(workspaceArchiveSessionValueSchema.parse({ archivedSessionIds: ['s1', 's2'] }).archivedSessionIds) + .toEqual(['s1', 's2']) + expect(() => workspaceArchiveSessionValueSchema.parse({ archivedSessionIds: 's1' })).toThrow() }) it('create requires exactly one of path/name (both refine arms)', () => { From c764ed7e640a1c4efa67ba9063e37d12618c1603 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 31 Jul 2026 02:40:14 +0800 Subject: [PATCH 10/20] feat(client-runtime): project the archive set and the archiveSession action WorkspaceListState gains archivedSessionIds (ReadonlySet, replaced only on membership change), installed as full snapshots from the list baseline, the unary echo, and the changed frame. Archiving the current session clears the selection into the New Session view state. Test doubles (test-runtime, fake APIs, fixture client) follow the widened IWorkspaces/IApiClient faces. --- .../client/connection/src/client/fixture.ts | 19 +++++++- packages/client/connection/tests/fake-api.ts | 5 +- .../client/locale/tests/language-row.spec.tsx | 2 +- packages/client/runtime/README.i18n.yaml | 4 +- packages/client/runtime/README.md | 2 + packages/client/runtime/README.zh.md | 2 + .../runtime/src/client/contract/workspaces.ts | 7 +++ .../runtime/src/client/workspaces/manager.ts | 30 ++++++++++++ .../runtime/src/client/workspaces/service.ts | 21 ++++++++- packages/client/runtime/tests/fake-api.ts | 16 ++++++- .../runtime/tests/workspaces-service.spec.ts | 46 +++++++++++++++++++ packages/client/test-runtime/src/fixtures.ts | 1 + .../client/test-runtime/src/workspaces.ts | 17 +++++++ .../tests/chat-code-subcalls.spec.tsx | 2 +- .../ui-conversation/tests/chat-view.spec.tsx | 2 +- .../tests/gate-branch-tails.spec.tsx | 4 +- .../ui-conversation/tests/input-bar.spec.tsx | 2 +- .../tests/input-matrix.spec.tsx | 2 +- .../tests/input-scenarios.spec.tsx | 2 +- .../ui-conversation/tests/skeleton.spec.tsx | 2 +- .../tests/terminal-card.spec.tsx | 4 +- .../client/ui-layout/tests/app-frame.spec.tsx | 2 +- .../ui-theme/tests/appearance-row.spec.tsx | 2 +- .../client/ui-trajectory/tests/views.spec.tsx | 2 +- 24 files changed, 177 insertions(+), 21 deletions(-) diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 45445cc9cf..49f0aaf9dc 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -969,6 +969,9 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { updatedAt: fixtureEpoch, }] let nextWorkspace = 1 + // Registry-global archive set mirroring the host: archived sessions keep + // their workspace accounting slot and only grouping surfaces hide them. + const archivedSessionIds: SessionId[] = [] // In-memory browse tree behind the fixture's `browse` picker capability — // deterministic content mirroring the design mock so assembled Web tests @@ -1623,7 +1626,10 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { openPath: request => ok(request, { opened: true as const }), }, workspace: { - list: request => ok(request, { items: workspaces.map(w => ({ ...w })) }), + list: request => ok(request, { + items: workspaces.map(w => ({ ...w })), + archivedSessionIds: [...archivedSessionIds], + }), create: (request) => { const { path, name } = request.payload const target = path ?? `/tmp/fixture-workspaces/${name ?? ''}` @@ -1709,6 +1715,16 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { } return ok(request, { workspace: { ...workspace } }) }, + archiveSession: (request) => { + const missing = requireSession(request) + if (missing !== undefined) return missing + const { sessionId } = request.payload + if (!archivedSessionIds.includes(sessionId)) { + archivedSessionIds.push(sessionId) + emitHost({ type: 'host/archived-sessions-changed', archivedSessionIds: [...archivedSessionIds] }) + } + return ok(request, { archivedSessionIds: [...archivedSessionIds] }) + }, }, commands: { // The catalog mirrors one session's effective view (every fixture @@ -2089,6 +2105,7 @@ export class FixtureApiClient extends AbstractApiClient { case 'workspace.rename': return this.api.workspace.rename(request) case 'workspace.delete': return this.api.workspace.delete(request) case 'workspace.insertSessionBefore': return this.api.workspace.insertSessionBefore(request) + case 'workspace.archiveSession': return this.api.workspace.archiveSession(request) case 'command.list': return this.api.commands.list(request) case 'command.execute': return this.api.commands.execute(request, signal) case 'skill.list': return this.api.skills.list(request) diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index 0d58800279..d599de1be7 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -122,7 +122,7 @@ export class FakeApiClient implements IApiClient { } readonly workspace: IApiClient['workspace'] = { - list: (payload: unknown) => this.record('workspace.list', payload, Promise.resolve(ok({ items: [] }))), + list: (payload: unknown) => this.record('workspace.list', payload, Promise.resolve(ok({ items: [], archivedSessionIds: [] }))), create: (payload: unknown) => this.record('workspace.create', payload, Promise.resolve(ok({ workspace: { workspaceId: 'fk-ws' as never, path: '/f/ws', title: 'ws', sessionIds: [], createdAt: '0', updatedAt: '0' }, created: true, @@ -134,6 +134,9 @@ export class FakeApiClient implements IApiClient { 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' }, }))), + archiveSession: (payload: unknown) => this.record('workspace.archiveSession', payload, Promise.resolve(ok({ + archivedSessionIds: [(payload as { sessionId: SessionId }).sessionId], + }))), } // Payloads stay `unknown` (lint-lane note above); response rows are the real diff --git a/packages/client/locale/tests/language-row.spec.tsx b/packages/client/locale/tests/language-row.spec.tsx index 2fdc5d5f45..223b9761b2 100644 --- a/packages/client/locale/tests/language-row.spec.tsx +++ b/packages/client/locale/tests/language-row.spec.tsx @@ -21,7 +21,7 @@ function emptySessions() { } function emptyWorkspaces() { const store = createSnapshotStore({ - items: [], state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: new Set(), state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, }) return bindSnapshotSelector(store) diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index f42e4f09d0..60fcf831b4 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/runtime/README.md -README.md: 9f2b165f1a98dcecfa3ab82386da9b094cfd2f54 -README.zh.md: 3ed047e65d3bddc14c3b6b84f327bbeebf805d4b +README.md: 4c9122d87bfb0ea2d66478de03c69975b0577ab7 +README.zh.md: 1ee4731964ea75de29da47137933852581fc45b3 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 9f2b165f1a..4c9122d87b 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -10,6 +10,8 @@ Workspace and Session lists have independent monotone `pending` → `ready` base `WorkspacesService.delete(workspaceId)` removes the registration from the client projection after the successful unary response; the matching `host/workspace-removed` frame is idempotent and synchronizes other tabs. Session state and the current Session selection are independent, so accounted Sessions immediately project under Ungrouped after their Workspace disappears. +`WorkspaceListState.archivedSessionIds` mirrors the Host's registry-global archive set (a `ReadonlySet` replaced only when membership changes). It is full-snapshot state: the `workspace.list` baseline, the `archiveSession` unary echo, and the `host/archived-sessions-changed` frame each install the complete set. `WorkspacesService.archiveSession(sessionId)` archives over the wire and, when the archived session is the current selection, clears it into the New Session view state; grouping surfaces hide members everywhere while the session rows stay in the list store. + SlotsService gives the renderer separate bare observables for `useSessions` and `useWorkspaces`; web-react creates the hooks. Workspace business state does not enter `SessionListState` or an entry store. `SessionsService.search(query, signal)` is a stateless one-shot action over the `session.search` RPC. It returns ranked session/snippet pairs without putting query, loading, or error state into the shared Session list, so each UI owner controls debounce, cancellation, stale-response suppression, and fallback presentation. `searchResultLimit` re-exposes `SESSION_SEARCH_RESULT_LIMIT` — the bound the response schema itself enforces — as injected presentation data, so client plugins do not duplicate it. It is a protocol constant rather than per-connection state, so the connection handle does not carry it. diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index 3ed047e65d..1ee4731964 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -10,6 +10,8 @@ Workspace 和 Session 列表各自具有单调的 `pending` → `ready` 基线 `WorkspacesService.delete(workspaceId)` 在一元响应成功后从客户端投影中移除注册记录;对应的 `host/workspace-removed` 帧具有幂等性,并负责同步其他标签页。Session 状态与当前 Session selection 相互独立,因此 Workspace 消失后,其已纳入客户端投影的 Session 会立即投影到 Ungrouped 下。 +`WorkspaceListState.archivedSessionIds` 镜像 Host 的注册表级全局归档集合(一个 `ReadonlySet`,仅在成员变化时才替换)。它是全快照状态:`workspace.list` 基线、`archiveSession` 一元回声和 `host/archived-sessions-changed` 帧各自安装完整集合。`WorkspacesService.archiveSession(sessionId)` 通过 wire 归档;当被归档的会话正是当前 selection 时,将其清空为 New Session 视图状态。各分组视图在所有位置隐藏集合成员,而会话行本身仍留在列表 store 中。 + SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 observable;web-react 创建钩子。Workspace 业务状态不会进入 `SessionListState` 或配置项 store。 `SessionsService.search(query, signal)` 是基于 `session.search` RPC 的无状态单次操作。它返回经过排序的会话/snippet 对,但不会将查询条件、加载状态或错误状态写入共享 Session 列表,因此每个 UI 所有者都自行负责防抖、取消、抑制陈旧响应和回退呈现。`searchResultLimit` 将 `SESSION_SEARCH_RESULT_LIMIT`——即响应 schema 自身强制执行的上限——作为注入的呈现数据重新公开,使客户端插件无需复制该值。它是协议常量而非逐连接状态,因此连接 handle 不携带它。 diff --git a/packages/client/runtime/src/client/contract/workspaces.ts b/packages/client/runtime/src/client/contract/workspaces.ts index 9238ea5fd0..3e64ef3717 100644 --- a/packages/client/runtime/src/client/contract/workspaces.ts +++ b/packages/client/runtime/src/client/contract/workspaces.ts @@ -76,4 +76,11 @@ export interface IWorkspaces { * @returns the updated Workspace view. */ insertSessionBefore(workspaceId: WorkspaceId, sessionId: SessionId, beforeSessionId?: SessionId): Promise + /** + * Archive a session into the registry-global set (hidden from grouping + * surfaces; session log and accounting slot remain). Archiving the current + * session clears the selection into the New Session view state. + * @param sessionId - session to archive. + */ + archiveSession(sessionId: SessionId): Promise } diff --git a/packages/client/runtime/src/client/workspaces/manager.ts b/packages/client/runtime/src/client/workspaces/manager.ts index ce4198cd01..0eddae8445 100644 --- a/packages/client/runtime/src/client/workspaces/manager.ts +++ b/packages/client/runtime/src/client/workspaces/manager.ts @@ -14,6 +14,8 @@ export type WorkspaceListPhase = 'pending' | 'ready' /** Immutable workspace-list snapshot. */ export interface WorkspaceListSnapshot { items: readonly WorkspaceView[] + /** Registry-global archive set (hidden from grouping surfaces; accounting slots retained). */ + archivedSessionIds: ReadonlySet state: 'idle' | 'loading' | 'error' phase: WorkspaceListPhase error: RpcError | null @@ -28,6 +30,9 @@ export class WorkspaceManager { private items: Workspace[] = [] private itemViewsSource: readonly Workspace[] | null = null private itemViewsCache: readonly WorkspaceView[] = [] + // Full-snapshot state (list response / unary response / changed frame all + // carry the complete set), so deltas never merge — installs replace. + private archivedSessionIds: ReadonlySet = new Set() private state: WorkspaceListSnapshot['state'] = 'idle' private phase: WorkspaceListPhase = 'pending' private error: RpcError | null = null @@ -77,6 +82,7 @@ export class WorkspaceManager { items = items.filter(workspace => !this.removedIds.has(workspace.workspaceId)) for (const delta of frames) items = applyWorkspaceDelta(items, delta) this.installViews(items) + this.installArchived(result.value.archivedSessionIds) this.state = 'idle' this.phase = 'ready' } else { @@ -158,6 +164,18 @@ export class WorkspaceManager { return result } + /** + * Archive one session in the registry-global set, then install the + * returned full set without waiting for the changed frame. + * @param sessionId - session to archive. + * @returns the wire result. + */ + async archiveSession(sessionId: SessionId): Promise> { + const { result } = await this.api.workspace.archiveSession({ sessionId }) + if (result.ok) this.installArchived(result.value.archivedSessionIds) + return result + } + /** * Host-frame entry. Non-workspace frames are ignored so the runtime can * fan one host stream out to both object managers. @@ -166,6 +184,9 @@ export class WorkspaceManager { handleHostEnvelope(envelope: RpcRequest): void { if (envelope.payload.type === 'host/workspace-changed') this.upsert(envelope.payload.workspace) else if (envelope.payload.type === 'host/workspace-removed') this.remove(envelope.payload.workspaceId) + else if (envelope.payload.type === 'host/archived-sessions-changed') { + this.installArchived(envelope.payload.archivedSessionIds) + } } /** Re-pull the baseline after each connection generation. */ @@ -194,12 +215,21 @@ export class WorkspaceManager { private buildSnapshot(): WorkspaceListSnapshot { return { items: this.itemViews(), + archivedSessionIds: this.archivedSessionIds, state: this.state, phase: this.phase, error: this.error, } } + /** Replace the archive set when membership actually changed (set identity backs Object.is short-circuits). */ + private installArchived(archivedSessionIds: readonly SessionId[]): void { + if (archivedSessionIds.length === this.archivedSessionIds.size + && archivedSessionIds.every(id => this.archivedSessionIds.has(id))) return + this.archivedSessionIds = new Set(archivedSessionIds) + this.notifier.markDirty() + } + /** Upsert one Host view, optionally retaining the local object that materialized it. */ private upsert(view: WorkspaceView, identity?: Workspace): void { if (this.removedIds.has(view.workspaceId)) return diff --git a/packages/client/runtime/src/client/workspaces/service.ts b/packages/client/runtime/src/client/workspaces/service.ts index 1dd3319e79..f3125e6494 100644 --- a/packages/client/runtime/src/client/workspaces/service.ts +++ b/packages/client/runtime/src/client/workspaces/service.ts @@ -14,6 +14,12 @@ import { WorkspaceManager, type WorkspaceListPhase } from './manager.ts' /** Workspace list plus the two-baseline readiness and default-target projection. */ export interface WorkspaceListState { items: readonly WorkspaceView[] + /** + * Registry-global archive set: grouping surfaces hide these sessions + * everywhere (workspace groups and the ungrouped bucket) while their + * session logs and workspace accounting slots remain. + */ + archivedSessionIds: ReadonlySet state: 'idle' | 'loading' | 'error' phase: WorkspaceListPhase error: RpcError | null @@ -58,7 +64,7 @@ export class WorkspacesService implements IWorkspaces { constructor(ctx: Context, private readonly api: IApiClient, private readonly sessions: SessionsPort) { this.manager = new WorkspaceManager(api) this.list = createSnapshotStore({ - items: [], state: 'idle', phase: 'pending', error: null, + items: [], archivedSessionIds: new Set(), state: 'idle', phase: 'pending', error: null, baselinesReady: false, recentWorkspaceId: undefined, }) this.manager.subscribe(() => { this.project() }) @@ -249,6 +255,18 @@ export class WorkspacesService implements IWorkspaces { if (!result.ok) throw new Error(`workspace delete failed: ${result.error.code}: ${result.error.message}`) } + /** + * Archive a session into the registry-global set. When the archived + * session is the current one, the selection is cleared into the New + * Session view state — a hidden row must not stay open behind the list. + * @param sessionId - session to archive. + */ + async archiveSession(sessionId: SessionId): Promise { + const result = await this.manager.archiveSession(sessionId) + if (!result.ok) throw new Error(`session archive failed: ${result.error.code}: ${result.error.message}`) + if (this.sessions.list.getSnapshot().current === sessionId) this.sessions.clear() + } + /** * Move a session within its Workspace's manual order (DOM-insertBefore-like). * @param workspaceId - owning workspace. @@ -293,6 +311,7 @@ export class WorkspacesService implements IWorkspaces { const baselinesReady = workspace.phase === 'ready' && sessions.phase === 'ready' this.list.set({ items: workspace.items, + archivedSessionIds: workspace.archivedSessionIds, state: workspace.state, phase: workspace.phase, error: workspace.error, diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index 06d948ae83..9cf7971049 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -140,7 +140,10 @@ export class FakeApiClient implements IApiClient { openPath: (payload: unknown) => this.record('host.openPath', payload, this.onOpenPath(payload)), } - onWorkspaceList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ items: [] })) + // The archive-set field defaults at the binding below so list stubs keep + // the pre-archive `{ items }` shape; a stub carrying the field wins. + onWorkspaceList: (payload: unknown) => Promise> = + () => Promise.resolve(ok({ items: [] })) onWorkspaceCreate: (payload: unknown) => Promise> = () => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws'), created: true })) @@ -153,13 +156,22 @@ export class FakeApiClient implements IApiClient { onWorkspaceInsertSessionBefore: (payload: unknown) => Promise> = () => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws') })) + onWorkspaceArchiveSession: (payload: unknown) => Promise> = + payload => Promise.resolve(ok({ archivedSessionIds: [(payload as { sessionId: SessionId }).sessionId] })) + readonly workspace: IApiClient['workspace'] = { - list: (payload: unknown) => this.record('workspace.list', payload, this.onWorkspaceList(payload)), + list: (payload: unknown) => this.record('workspace.list', payload, this.onWorkspaceList(payload).then(response => ( + response.result.ok + ? { ...response, result: { ok: true as const, value: { archivedSessionIds: [] as never[], ...response.result.value } } } + : response + )) as ReturnType), create: (payload: unknown) => this.record('workspace.create', payload, this.onWorkspaceCreate(payload)), rename: (payload: unknown) => this.record('workspace.rename', payload, this.onWorkspaceRename(payload)), delete: (payload: unknown) => this.record('workspace.delete', payload, this.onWorkspaceDelete(payload)), insertSessionBefore: (payload: unknown) => this.record('workspace.insertSessionBefore', payload, this.onWorkspaceInsertSessionBefore(payload)), + archiveSession: (payload: unknown) => + this.record('workspace.archiveSession', payload, this.onWorkspaceArchiveSession(payload)), } // Payloads stay `unknown` (lint-lane note above); response rows are the real diff --git a/packages/client/runtime/tests/workspaces-service.spec.ts b/packages/client/runtime/tests/workspaces-service.spec.ts index 7fd6934827..2bd462e5ee 100644 --- a/packages/client/runtime/tests/workspaces-service.spec.ts +++ b/packages/client/runtime/tests/workspaces-service.spec.ts @@ -285,6 +285,52 @@ describe('WorkspacesService', () => { })) await expect(workspaces.delete(wid('ghost'))).rejects.toThrow(/workspace-not-found: gone/) }) + + it('archives a session, projects the set from the response, list, and frame, and clears only the current one', async () => { + const ctx = new Context() + const api = new FakeApiClient() + const sessions = new SessionsService(ctx, api) + const workspaces = new WorkspacesService(ctx, api, sessions) + api.onList = () => Promise.resolve(ok({ + items: [ + { sessionId: sid('s-open'), updatedAt: 2, running: false, blank: false }, + { sessionId: sid('s-idle'), updatedAt: 1, running: false, blank: false }, + ], + }) as never) + await sessions.refresh() + sessions.open(sid('s-open')) + + // Archiving a non-current session installs the unary echo and keeps the selection. + await expect(workspaces.archiveSession(sid('s-idle'))).resolves.toBeUndefined() + expect(api.callsOf('workspace.archiveSession')).toEqual([{ sessionId: 's-idle' }]) + expect([...workspaces.list.getSnapshot().archivedSessionIds]).toEqual(['s-idle']) + expect(sessions.list.getSnapshot().current).toBe('s-open') + + // Archiving the current session clears it into the New Session view state. + api.onWorkspaceArchiveSession = () => Promise.resolve(ok({ archivedSessionIds: [sid('s-idle'), sid('s-open')] })) + await workspaces.archiveSession(sid('s-open')) + expect([...workspaces.list.getSnapshot().archivedSessionIds]).toEqual(['s-idle', 's-open']) + expect(sessions.list.getSnapshot().current).toBeUndefined() + + // A Host failure leaves the set and the selection untouched. + api.onWorkspaceArchiveSession = () => Promise.resolve(err({ + code: 'session-not-found', message: 'no session ghost', details: { sessionId: sid('ghost') }, + })) + await expect(workspaces.archiveSession(sid('ghost'))).rejects.toThrow(/session-not-found/) + expect([...workspaces.list.getSnapshot().archivedSessionIds]).toEqual(['s-idle', 's-open']) + + // The changed frame and the list baseline both re-install the full set. + workspaces.handleHostEnvelope({ + rpcId: 'frame' as never, + payload: { type: 'host/archived-sessions-changed', archivedSessionIds: [sid('s-idle')] }, + } as never) + // Frame installs ride the notifier's microtask batch before projecting. + await new Promise(resolve => setTimeout(resolve, 0)) + expect([...workspaces.list.getSnapshot().archivedSessionIds]).toEqual(['s-idle']) + api.onWorkspaceList = () => Promise.resolve(ok({ items: [], archivedSessionIds: [sid('s-open')] }) as never) + await workspaces.refresh() + expect([...workspaces.list.getSnapshot().archivedSessionIds]).toEqual(['s-open']) + }) }) describe('startInitialSelection', () => { diff --git a/packages/client/test-runtime/src/fixtures.ts b/packages/client/test-runtime/src/fixtures.ts index 4219d233e2..d81adf7698 100644 --- a/packages/client/test-runtime/src/fixtures.ts +++ b/packages/client/test-runtime/src/fixtures.ts @@ -73,6 +73,7 @@ export function conversationSnapshot(sessionId: SessionId): ConversationSnapshot export function workspaceListState(): WorkspaceListState { return { items: [], + archivedSessionIds: new Set(), state: 'idle', phase: 'ready', error: null, diff --git a/packages/client/test-runtime/src/workspaces.ts b/packages/client/test-runtime/src/workspaces.ts index 6c1a9d0aad..fab209c96c 100644 --- a/packages/client/test-runtime/src/workspaces.ts +++ b/packages/client/test-runtime/src/workspaces.ts @@ -186,4 +186,21 @@ export class TestWorkspaces implements IWorkspaces { if (stub !== undefined) return await (stub(workspaceId, sessionId, beforeSessionId) as Promise) return { workspaceId, title: '', path: '', sessionIds: [sessionId] } as unknown as WorkspaceView } + + /** + * Archive a session (recorded). The default mirrors the production face's + * observable effect: the id joins the list state's archive set. + * @param sessionId - session to archive. + */ + async archiveSession(sessionId: SessionId): Promise { + this.calls.push({ method: 'archiveSession', args: [sessionId] }) + const stub = this.stubs.get('archiveSession') + if (stub !== undefined) { + await (stub(sessionId) as Promise) + return + } + await this.update((draft) => { + draft.archivedSessionIds = new Set([...draft.archivedSessionIds, sessionId]) + }) + } } diff --git a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx index 49eeec29fd..16d070b04d 100644 --- a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx @@ -128,7 +128,7 @@ async function bench(snapshot: ConversationSnapshot) { ctx.provide('sessions', sessionsFake) const workspaces = { list: createSnapshotStore({ - items: [], state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: new Set(), state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, }), startSession: vi.fn(), diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index bfaab93375..b59335fa3a 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -94,7 +94,7 @@ function emptySessions() { function emptyWorkspaces() { const store = createSnapshotStore({ - items: [], state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: new Set(), state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, }) return bindSnapshotSelector(store) diff --git a/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx index 354b94ecdd..192470abfc 100644 --- a/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx @@ -74,7 +74,7 @@ describe('render branch tails', () => { const emptyList = createSnapshotStore( { ids: [], byId: {}, current: undefined, phase: 'ready' }) const emptyWorkspaces = createSnapshotStore({ - items: [], state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: new Set(), state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, }) const view = render( @@ -111,7 +111,7 @@ describe('render branch tails', () => { const emptyList = createSnapshotStore( { ids: [], byId: {}, current: undefined, phase: 'ready' }) const emptyWorkspaces = createSnapshotStore({ - items: [], state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: new Set(), state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, }) const view = render( diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index 6831b1a5a0..1974881357 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -96,7 +96,7 @@ function bench(over?: BenchOptions) { ids: [], byId: {}, current: undefined, phase: 'ready', })), useWorkspaces: bindSnapshotSelector(createSnapshotStore({ - items: [], state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: new Set(), state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, })), useProjection: ((key: string, selector?: (v: unknown) => unknown) => diff --git a/packages/client/ui-conversation/tests/input-matrix.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.spec.tsx index f619d380a0..4901ec2f9f 100644 --- a/packages/client/ui-conversation/tests/input-matrix.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.spec.tsx @@ -39,7 +39,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled ids: [], byId: {}, current: undefined, phase: 'ready', })), useWorkspaces: bindSnapshotSelector(createSnapshotStore({ - items: [], state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: new Set(), state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, })), useProjection: (() => undefined), diff --git a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx index 60b02795fe..167b9c96af 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx @@ -125,7 +125,7 @@ async function scopedBench(register?: (slash: SlashService) => void) { ids: [], byId: {}, current: undefined, phase: 'ready', })), useWorkspaces: bindSnapshotSelector(createSnapshotStore({ - items: [], state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: new Set(), state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, })), useProjection: (() => undefined), diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index a37074bd07..2669cbb8ab 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -62,7 +62,7 @@ function workspace(id = 'w1'): WorkspaceView { } const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState => ({ - items, state: 'idle', phase: 'ready', error: null, + items, archivedSessionIds: new Set(), state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, }) diff --git a/packages/client/ui-conversation/tests/terminal-card.spec.tsx b/packages/client/ui-conversation/tests/terminal-card.spec.tsx index 213897e0f5..29f246d603 100644 --- a/packages/client/ui-conversation/tests/terminal-card.spec.tsx +++ b/packages/client/ui-conversation/tests/terminal-card.spec.tsx @@ -429,7 +429,7 @@ describe('DetailsPanel Output section', () => { phase: 'ready', }) const workspaces = createSnapshotStore({ - items: [], state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: new Set(), state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, }) return render( @@ -607,7 +607,7 @@ describe('DetailsPanel Output section', () => { useSessions={bindSnapshotSelector(createSnapshotStore( { ids: [], byId: {}, current: undefined, phase: 'ready' }))} useWorkspaces={bindSnapshotSelector(createSnapshotStore({ - items: [], state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: new Set(), state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, }))} useInput={(() => { throw new Error('unused') })} diff --git a/packages/client/ui-layout/tests/app-frame.spec.tsx b/packages/client/ui-layout/tests/app-frame.spec.tsx index ab8a11706d..d30cd17877 100644 --- a/packages/client/ui-layout/tests/app-frame.spec.tsx +++ b/packages/client/ui-layout/tests/app-frame.spec.tsx @@ -77,7 +77,7 @@ function mountFrame() { return sel(sessionState) }) as never const workspaceState: WorkspaceListState = { - items: [], state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: new Set(), state: 'idle', phase: 'ready', error: null, baselinesReady: baselinesReady.current, recentWorkspaceId: undefined, } const element = () => ( diff --git a/packages/client/ui-theme/tests/appearance-row.spec.tsx b/packages/client/ui-theme/tests/appearance-row.spec.tsx index f21fb26bdd..00cc0e3052 100644 --- a/packages/client/ui-theme/tests/appearance-row.spec.tsx +++ b/packages/client/ui-theme/tests/appearance-row.spec.tsx @@ -27,7 +27,7 @@ function emptySessions() { } function emptyWorkspaces() { const store = createSnapshotStore({ - items: [], state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: new Set(), state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, }) return bindSnapshotSelector(store) diff --git a/packages/client/ui-trajectory/tests/views.spec.tsx b/packages/client/ui-trajectory/tests/views.spec.tsx index 5c1e6c691d..d31b43d3e6 100644 --- a/packages/client/ui-trajectory/tests/views.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.spec.tsx @@ -113,7 +113,7 @@ function emptySessions() { function emptyWorkspaces() { const store = createSnapshotStore({ - items: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, + items: [], archivedSessionIds: new Set(), state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, }) return bindSnapshotSelector(store) From e235be2bac56f290832e6a67d4b7c4c985a697ab Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 31 Jul 2026 02:40:39 +0800 Subject: [PATCH 11/20] feat(ui-workspace): archive session from the row menu The visual-only Delete session placeholder becomes a wired Archive session action: no confirmation dialog (non-destructive), failures stay console diagnostics. tree.ts hides archived sessions in every derivation (workspace groups, Ungrouped, search, flat list) through the sessionVisible predicate. The workspace-management e2e pins the archive round trip across reload. --- apps/web/tests/workspace-management.e2e.ts | 52 ++++++++++++-- packages/client/ui-workspace/README.i18n.yaml | 4 +- packages/client/ui-workspace/README.md | 4 +- packages/client/ui-workspace/README.zh.md | 4 +- .../src/client/WorkspaceBrowser.tsx | 44 +++++++++--- .../ui-workspace/src/client/contract/slots.ts | 6 ++ .../client/ui-workspace/src/client/index.ts | 1 + .../client/ui-workspace/src/client/locales.ts | 4 +- .../ui-workspace/src/client/rows/Rows.tsx | 20 ++++-- .../client/ui-workspace/src/client/tree.ts | 41 +++++++---- .../client/ui-workspace/tests/rows.spec.tsx | 26 ++++--- .../client/ui-workspace/tests/tree.spec.ts | 70 +++++++++++++++---- .../tests/workspace-browser.spec.tsx | 49 ++++++++++++- .../tests/workspace-picker.spec.tsx | 2 +- 14 files changed, 261 insertions(+), 66 deletions(-) diff --git a/apps/web/tests/workspace-management.e2e.ts b/apps/web/tests/workspace-management.e2e.ts index e6a7f31f18..86c9056d66 100644 --- a/apps/web/tests/workspace-management.e2e.ts +++ b/apps/web/tests/workspace-management.e2e.ts @@ -1,10 +1,13 @@ // Web e2e scenarios: workspace management — the create-by-name dialog, the // rename round trip over the real wire (workspace.rename RPC + durable // registry), duplicate-name pre-check, the flat "In one list" view with its -// persisted group-by preference, and the session hover card. Zero model -// calls: workspace.create/rename are host RPCs with no model involvement, -// and the one session row the flat/hover scenarios need comes from a seeded -// fixture (the seeded-history seed reused verbatim — no new recording). +// persisted group-by preference, the session hover card, and the session +// archive round trip (row menu → workspace.archiveSession RPC → durable +// global set → row hidden across reload). Zero model calls: +// workspace.create/rename/archiveSession are host RPCs with no model +// involvement, and the one session row the flat/hover/archive scenarios need +// comes from a seeded fixture (the seeded-history seed reused verbatim — no +// new recording). import { mkdir, readFile, stat, writeFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import { join } from 'node:path' @@ -413,6 +416,47 @@ describe('web e2e: workspace management (create / rename / flat view / hover car expect(tripwire.pageErrors).toEqual([]) }, 60_000) + it('archives the seeded session from its row menu, hiding it durably across reload', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-archive')) + // The seeded session lives under Ungrouped (expanded by the hover-card + // test's gesture; converge again for order independence). + const ungroupedRow = page.getByText('Ungrouped', { exact: true }).locator('..').locator('..') + const ungroupedSection = ungroupedRow.locator('..') + await expect.poll(async () => { + if (await ungroupedRow.getAttribute('aria-expanded') !== 'true') { + await page.getByText('Ungrouped', { exact: true }).click() + await page.waitForTimeout(50) + } + return await ungroupedRow.getAttribute('aria-expanded') + }, { timeout: 5_000 }).toBe('true') + const sessionRow = ungroupedSection.locator('[role="treeitem"]').nth(1) + const rowTitle = await sessionRow.locator('[class*="title"]').innerText() + // Row menu: hover reveals the actions button; Archive session commits + // without a confirmation dialog (non-destructive: log + accounting stay). + await sessionRow.hover() + await sessionRow.getByRole('button', { name: `Session actions for ${rowTitle}` }).click() + await page.getByRole('menuitem', { name: 'Archive session' }).click() + // The row disappears on the archive-set echo; with no other visible + // stray, the whole Ungrouped bucket withdraws. + await expect.poll(() => page.getByText(rowTitle, { exact: true }).count(), { timeout: 10_000 }).toBe(0) + await expect.poll(() => page.getByText('Ungrouped', { exact: true }).count(), { timeout: 10_000 }).toBe(0) + // Durable on the host: the registry-global set carries the id while the + // session log itself stays in persistence untouched. + expect([...scaffold.ctx.workspace.archivedSessionIds]).toEqual([SessionId(SEED_ID)]) + expect((await scaffold.ctx.sessionPersistence.list()).map(header => header.id)).toContain(SessionId(SEED_ID)) + // Reload: the hidden state is rebuilt from the workspace.list baseline. + const warningStart = tripwire.warnings.length + await page.reload({ waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + acknowledgeReloadConnectionLoss(tripwire, warningStart) + await expect.poll(() => page.getByText('Workspaces', { exact: true }).count(), { timeout: 15_000 }).toBe(1) + // The archived row must not resurface (the Ungrouped bucket itself may + // reappear if selection restore lands on another stray — not this test's + // concern). + expect(await page.getByText(rowTitle, { exact: true }).count()).toBe(0) + expect(tripwire.pageErrors).toEqual([]) + }, 90_000) + it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', async () => { expect(tripwire.warnings).toEqual([]) // The directory-browser aria golden is this spec's one owned artifact; diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index 3c61535e06..a21fd21697 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-workspace/README.md -README.md: f71bfa09c795bd69e1f49c8f6dffffd5959dbe47 -README.zh.md: 80b53d85eb210b0e7a7ace1699d6bbfc9a836606 +README.md: cc73214a281c6950acf8846f0bae3214c8726934 +README.zh.md: c0b6472c7db74dbfd4b0afd19a258e0132a7c534 diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index f71bfa09c7..cc73214a28 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -6,7 +6,7 @@ Shared Workspace browser and picker plugin. `WorkspaceBrowser` fills the sidebar The browser renders grouped or flat Session rows from the global runtime hooks and owns the Workspace create/rename and in-Workspace reorder flows. A non-blank search query replaces either browsing mode with one flat result list: case-insensitive title and Workspace substring matches appear immediately, while a 250 ms debounced Host request adds ranked current-conversation content matches and snippets. The English search input and its defensive request path remove NUL, cap the query at the wire schema's 500 UTF-16 code units without splitting a surrogate pair, and preserve the existing debounce and cancellation behavior. Each new query aborts the preceding request; a failed content search leaves metadata matches visible with a warning. The list is capped at 20, asks the user to narrow broader queries, and opens the selected Session without clearing the query or jumping to a specific event. -The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. Each registration declares a **directory-flow child hole** (`single` kind: `conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`) that the composed picker package's client half fills with its picking interaction — the [`-native`](../../host/directory-picker-native/README.md) backend's renderless OS-chooser driver today, an in-app browsing dialog under a `-browse` composition. The flat **Open local folder...** action renders only while the surface's hole is occupied (occupancy read per menu render; an empty hole means the composition has no picking affordance — the seam's documented no-flow default). This package owns the trigger and the adoption: the occupant reports one picked path per open through the hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`), and the owner adopts it through the object layer, selecting the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors land in the retryable folder dialog whose **Choose again** reopens the flow. **Create a new workspace** retains the name dialog and disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped. The Session row's Rename action opens the same browser-owned dialog pattern prefilled with the row's display title: no client-side conflict rule exists (the host normalizes and may reject with `title-invalid`, rendered in the dialog alert), and confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration. +The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. Each registration declares a **directory-flow child hole** (`single` kind: `conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`) that the composed picker package's client half fills with its picking interaction — the [`-native`](../../host/directory-picker-native/README.md) backend's renderless OS-chooser driver today, an in-app browsing dialog under a `-browse` composition. The flat **Open local folder...** action renders only while the surface's hole is occupied (occupancy read per menu render; an empty hole means the composition has no picking affordance — the seam's documented no-flow default). This package owns the trigger and the adoption: the occupant reports one picked path per open through the hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`), and the owner adopts it through the object layer, selecting the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors land in the retryable folder dialog whose **Choose again** reopens the flow. **Create a new workspace** retains the name dialog and disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped. The Session row's Rename action opens the same browser-owned dialog pattern prefilled with the row's display title: no client-side conflict rule exists (the host normalizes and may reject with `title-invalid`, rendered in the dialog alert), and confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration. The Session row's Archive action commits without a confirmation dialog (non-destructive: the log and the workspace accounting slot remain) through `ctx.workspaces.archiveSession`; the row disappears from every grouping surface — workspace groups, Ungrouped, content search, and the flat list — when the archive-set echo lands, and failures are console diagnostics that leave the tree unchanged. A blank New Session row is a pure placeholder: it renders no row menu and no time label (nothing has happened in it yet), so rename, fork, and archive first apply once the first prompt lands. The Session row's Fork action forks at the source's last completed turn, increments the inherited persisted title on the client, and then opens the child; a trailing ASCII or fullwidth parenthesized number is incremented in the same style, while an unnumbered title gets ` (1)` appended. The source and child always appear as peer rows within a workspace group, with lineage retained only as session data. A fork or rename failure leaves the current selection unchanged; after a rename failure, the created child remains in the list. @@ -23,5 +23,5 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work - **No fuzzy content search or event deep links** — the content backend uses literal token/phrase matching, and selecting a result opens the Session rather than the matching event. -- **No Session deletion control** — the Session menu's Delete row remains visual-only; Workspace registration deletion does not delete Sessions. +- **No Session deletion or unarchive control** — archiving replaces the former Delete placeholder; archived sessions have no viewing or unarchive surface yet, and Workspace registration deletion does not delete Sessions. - **Native folder selection depends on the local Host carrier** — under the `-native` composition, fixture-only or remote browser deployments cannot open a local operating-system dialog; platform failures are shown in a retryable modal. Remote-capable picking is the `-browse` composition's in-app flow. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index 80b53d85eb..c0b6472c7d 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -6,7 +6,7 @@ 该浏览器通过全局运行时钩子将 Session 行渲染为分组或扁平形式,并负责 Workspace 创建/重命名和 Workspace 内的重排序流程。非空白查询会以单一扁平结果列表替代任一浏览模式:不区分大小写的标题和 Workspace 子串匹配项会立即显示,经 250 ms 防抖的 Host 请求则会加入经过排序的当前对话内容匹配项及其摘要片段。英文搜索输入框及其防御性请求路径会移除 NUL,将查询限制在传输 schema 规定的 500 个 UTF-16 code unit 内且不会拆分 surrogate pair,并保留现有的防抖与取消行为。每次新查询都会中止前一个请求;内容搜索失败时,元数据匹配项仍会显示,同时给出警告。列表最多显示 20 条结果,并会在查询过宽时提示用户缩小范围;打开所选 Session 时既不会清除查询,也不会跳转至特定事件。 -该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。每个注册各自声明一个**目录流子洞**(`single` kind:`conversation.hero.workspace.directoryFlow`/`sidebar.workspaces.directoryFlow`),由组合的选择器包 client half 填入其选取交互——今天是 [`-native`](../../host/directory-picker-native/README.md) 后端的无渲染 OS 选择器驱动,`-browse` 组合下则是应用内浏览对话框。平铺显示的 **打开本地文件夹…** 操作仅在本表层的洞被占用时渲染(每次菜单渲染读取占用状态;洞为空意味着该组合没有选目录能力——seam 文档化的无流程默认行为)。本包持有触发与接纳:占用者经洞的 owner 会话(`open`/`busy`/`onPicked`/`onCancel`/`onError`)每次打开上报一个所选路径,owner 通过对象层接纳它,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,错误落入可重试的文件夹对话框,其 **重新选择** 会重新打开流程。**创建新工作区** 操作保留名称对话框,并禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。Session 行内的 Rename 操作打开同款浏览器持有的对话框,并以该行的显示标题预填:客户端不设名称冲突规则(host 负责规范化,可能以 `title-invalid` 拒绝,错误渲染在对话框告警区);确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。 +该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。每个注册各自声明一个**目录流子洞**(`single` kind:`conversation.hero.workspace.directoryFlow`/`sidebar.workspaces.directoryFlow`),由组合的选择器包 client half 填入其选取交互——今天是 [`-native`](../../host/directory-picker-native/README.md) 后端的无渲染 OS 选择器驱动,`-browse` 组合下则是应用内浏览对话框。平铺显示的 **打开本地文件夹…** 操作仅在本表层的洞被占用时渲染(每次菜单渲染读取占用状态;洞为空意味着该组合没有选目录能力——seam 文档化的无流程默认行为)。本包持有触发与接纳:占用者经洞的 owner 会话(`open`/`busy`/`onPicked`/`onCancel`/`onError`)每次打开上报一个所选路径,owner 通过对象层接纳它,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,错误落入可重试的文件夹对话框,其 **重新选择** 会重新打开流程。**创建新工作区** 操作保留名称对话框,并禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。Session 行内的 Rename 操作打开同款浏览器持有的对话框,并以该行的显示标题预填:客户端不设名称冲突规则(host 负责规范化,可能以 `title-invalid` 拒绝,错误渲染在对话框告警区);确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。Session 行内的 Archive 操作不经确认对话框直接提交(非破坏性:日志和 workspace 记账席位保持不变),通过 `ctx.workspaces.archiveSession` 归档;归档集合回声落地后,该行从所有分组视图——workspace 分组、Ungrouped、内容搜索和平铺列表——中消失,失败只作为控制台诊断输出,树保持不变。blank「新会话」行是纯占位:不渲染行菜单和时间标签(其中还没有发生任何事),rename/fork/归档都从首条 prompt 落地后才可用。 Session 行内的 Fork 操作在源会话最后一个已完成轮次处 fork,在 client 端递增继承的持久化标题后再打开子会话;尾部半角或全角括号编号会原样式递增,无编号标题追加 ` (1)`。源会话与子会话在 workspace 组内始终作为同级行展示,谱系只保留为 session 数据。Fork 或改名失败都不会改变当前选中项,改名失败时已创建的子会话仍会留在列表中。 @@ -23,5 +23,5 @@ Session 行内的 Fork 操作在源会话最后一个已完成轮次处 fork, ## 已知限制与暂缓事项 - **没有模糊内容搜索或事件深链接**:内容后端采用字面 token/短语匹配,选择结果会打开 Session,而不是匹配的事件。 -- **没有 Session 删除控件**:Session 菜单的 Delete 行仍仅提供视觉效果;删除 Workspace 注册记录不会删除 Session。 +- **没有 Session 删除与取消归档控件**:归档取代了原先的 Delete 占位;已归档会话尚无查看或取消归档入口;删除 Workspace 注册记录不会删除 Session。 - **原生文件夹选择依赖本地 Host 载体**:在 `-native` 组合下,仅使用 fixture(测试前置数据)的部署或远程浏览器部署无法打开本地操作系统对话框;模态框会显示平台故障,并允许重试。可远程的选取是 `-browse` 组合的应用内流程。 diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx index f5b636ab1b..97cc2116a4 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx @@ -102,18 +102,22 @@ type SessionTreeProps = Pick< 'useSessions' | 'startSession' | 'open' | 'forkSession' | 'insertSessionBefore' | 't' > & { workspaces: readonly WorkspaceView[] + /** Registry-global archive set (hidden rows). */ + archivedSessionIds: readonly SessionNode['id'][] /** Open the browser-owned rename dialog for a real Workspace group. */ onRenameRequest: (workspaceId: WorkspaceId, currentTitle: string) => void /** Open the browser-owned delete-confirmation dialog for a real Workspace group. */ onDeleteRequest: (workspaceId: WorkspaceId, currentTitle: string) => void /** Open the browser-owned session rename dialog. */ onSessionRename: (sessionId: SessionNode['id'], currentTitle: string) => void + /** Archive a session (row menu action; the row disappears on the state echo). */ + onSessionArchive: (sessionId: SessionNode['id']) => void } /** The scrolling session tree; unmounting at collapse settle drops the sessions subscription and expansion state. */ function SessionTree({ - useSessions, startSession, open, forkSession, workspaces, - onRenameRequest, onDeleteRequest, onSessionRename, insertSessionBefore, t, + useSessions, startSession, open, forkSession, workspaces, archivedSessionIds, + onRenameRequest, onDeleteRequest, onSessionRename, onSessionArchive, insertSessionBefore, t, }: SessionTreeProps) { const list = useSessions(s => s) const current = list.current @@ -129,8 +133,8 @@ function SessionTree({ setExpandedProjects(l => (l.includes(currentGroup) ? l : [...l, currentGroup])) }, [current, currentGroup]) const groups = useMemo( - () => deriveGroups(list, workspaces, { expandedProjects }), - [list, workspaces, expandedProjects], + () => deriveGroups(list, workspaces, archivedSessionIds, { expandedProjects }), + [list, workspaces, archivedSessionIds, expandedProjects], ) const now = Date.now() @@ -209,6 +213,7 @@ function SessionTree({ onOpen={open} onRename={onSessionRename} onFork={forkSession} + onArchive={onSessionArchive} drag={dragProps} t={t} /> @@ -223,9 +228,11 @@ function SessionTree({ } /** The flat "In one list" body: every session a top-level row, newest-first. */ -function FlatList({ useSessions, open, forkSession, onSessionRename, t }: Pick) { +function FlatList({ useSessions, open, forkSession, onSessionRename, onSessionArchive, archivedSessionIds, t }: Pick< + SessionTreeProps, 'useSessions' | 'open' | 'forkSession' | 'onSessionRename' | 'onSessionArchive' | 'archivedSessionIds' | 't' +>) { const list = useSessions(s => s) - const rows = useMemo(() => deriveFlat(list), [list]) + const rows = useMemo(() => deriveFlat(list, archivedSessionIds), [list, archivedSessionIds]) const now = Date.now() return (
    @@ -242,6 +249,7 @@ function FlatList({ useSessions, open, forkSession, onSessionRename, t }: Pick ))} @@ -263,12 +271,14 @@ function SearchResults({ useSessions, open, workspaces, + archivedSessionIds, query, remote, resultLimit, t, }: Pick & { workspaces: readonly WorkspaceView[] + archivedSessionIds: readonly SessionNode['id'][] query: string remote: RemoteSearchState resultLimit: number @@ -278,8 +288,8 @@ function SearchResults({ ? remote : { query, status: 'loading' as const, items: [], hasMore: false } const results = useMemo( - () => deriveSearchResults(list, workspaces, query, currentRemote, resultLimit), - [list, workspaces, query, currentRemote, resultLimit], + () => deriveSearchResults(list, workspaces, query, archivedSessionIds, currentRemote, resultLimit), + [list, workspaces, query, archivedSessionIds, currentRemote, resultLimit], ) const pending = currentRemote.status === 'loading' const failed = currentRemote.status === 'error' @@ -337,6 +347,7 @@ export function WorkspaceBrowser({ forkSession, renameWorkspace, deleteWorkspace, + archiveSession, insertSessionBefore, createWorkspace, searchSessions, @@ -346,6 +357,7 @@ export function WorkspaceBrowser({ t, }: WorkspaceBrowserProps) { const workspaces = useWorkspaces(state => state.items) + const archivedSessionIds = useWorkspaces(state => state.archivedSessionIds) 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. @@ -475,6 +487,16 @@ export function WorkspaceBrowser({ setSessionRenameError(null) } + // Archive is dialog-free: not destructive (the log and the accounting slot + // remain), so the menu action commits directly; the row disappears when the + // archive-set echo lands. Failures are non-fatal console diagnostics, the + // same posture as reorder rejections. + const onSessionArchive = (sessionId: SessionNode['id']) => { + archiveSession(sessionId).catch((reason: unknown) => { + console.warn('session archive rejected:', reason) + }) + } + // Delete dialog is separate from the row so a successful removal can // unmount that row without tearing down the in-flight confirmation state. const [deleteTarget, setDeleteTarget] = useState<{ workspaceId: WorkspaceId; title: string } | null>(null) @@ -597,6 +619,7 @@ export function WorkspaceBrowser({ useSessions={useSessions} open={open} workspaces={workspaces} + archivedSessionIds={archivedSessionIds} query={normalizedQuery} remote={remoteSearch} resultLimit={searchResultLimit} @@ -607,15 +630,18 @@ export function WorkspaceBrowser({ ? ( ) : ( Promise /** Delete only a Host Workspace registration; directory and Session logs remain. */ deleteWorkspace: (workspaceId: WorkspaceId) => Promise + /** + * Archive a Session into the registry-global set: hidden from grouping + * surfaces, log and accounting slot retained. Archiving the current + * session clears the selection into the New Session view state. + */ + archiveSession: (sessionId: SessionId) => Promise /** * Reorder a session inside its Workspace account (DOM-insertBefore * semantics: omitted anchor appends to the end). The view refreshes from diff --git a/packages/client/ui-workspace/src/client/index.ts b/packages/client/ui-workspace/src/client/index.ts index 7dd8db5419..59574beed3 100644 --- a/packages/client/ui-workspace/src/client/index.ts +++ b/packages/client/ui-workspace/src/client/index.ts @@ -92,6 +92,7 @@ export function apply(ctx: ClientContext): void { }, renameWorkspace: async (workspaceId, title) => { await ctx.workspaces.rename(workspaceId, title) }, deleteWorkspace: async (workspaceId) => { await ctx.workspaces.delete(workspaceId) }, + archiveSession: async (sessionId) => { await ctx.workspaces.archiveSession(sessionId) }, insertSessionBefore: async (workspaceId, sessionId, beforeSessionId) => { await ctx.workspaces.insertSessionBefore(workspaceId, sessionId, beforeSessionId) }, diff --git a/packages/client/ui-workspace/src/client/locales.ts b/packages/client/ui-workspace/src/client/locales.ts index 3d8a5d657f..1ecc244329 100644 --- a/packages/client/ui-workspace/src/client/locales.ts +++ b/packages/client/ui-workspace/src/client/locales.ts @@ -45,7 +45,7 @@ export const zh = { 'delete.desc': '将把“{name}”从工作区列表中移除。文件夹与会话记录会保留,其会话将显示在“未分组”下。', 'delete.pending': '正在删除工作区…', 'menu.fork': '分叉会话', - 'menu.deleteSession': '删除会话', + 'menu.archiveSession': '归档会话', 'sessions.count.one': '{n} 个会话', 'sessions.count.other': '{n} 个会话', 'actions.workspace.aria': '工作区“{name}”的操作', @@ -108,7 +108,7 @@ export const en = { 'delete.desc': 'This removes “{name}” from the workspace list. The folder and session logs will be kept. Its sessions will appear under Ungrouped.', 'delete.pending': 'Deleting workspace…', 'menu.fork': 'Fork session', - 'menu.deleteSession': 'Delete session', + 'menu.archiveSession': 'Archive session', 'sessions.count.one': '{n} session', 'sessions.count.other': '{n} sessions', 'actions.workspace.aria': 'Workspace actions for {name}', diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index 03634dbe1a..a8b28f17e4 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -2,14 +2,14 @@ * 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/Delete and session Rename/Fork; the session and - * workspace hover cards are suppressed while a menu is open. + * except workspace Rename/Delete and session Rename/Fork/Archive; the session + * and workspace hover cards are suppressed while a menu is open. */ import { useState } from 'react' import clsx from 'clsx' import { - HoverCard, IconBranchOutline16, IconEditOutline16, IconEllipsisOutline16, - IconFolderClose16, IconFolderOpen16, IconPlusOutline16, + HoverCard, IconBranchOutline16, IconDownloadOutline16, IconEditOutline16, + IconEllipsisOutline16, IconFolderClose16, IconFolderOpen16, IconPlusOutline16, IconTrashOutline16, IconTriangleRightFill14, Menu, StateDot, } from '@deepseek-ai/dsh-client-ui-primitives' import type { WorkspaceBrowserProps } from '../contract/slots.ts' @@ -243,7 +243,7 @@ function rowHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' | return e.clientY < rect.top + rect.height / 2 ? 'before' : 'after' } -export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork, drag, t }: { +export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork, onArchive, drag, t }: { node: SessionNode currentId: string | undefined now: number @@ -252,6 +252,8 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork onRename: (id: SessionNode['id'], currentTitle: string) => void /** Fork a session at its last completed turn (row menu action). */ onFork: (id: SessionNode['id']) => void + /** Archive this session (row menu action; commits without a dialog). */ + onArchive: (id: SessionNode['id']) => void /** Present only on draggable rows (workspace-group sessions outside search). */ drag?: RowDragProps | undefined t: RowTranslate @@ -260,10 +262,13 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork const title = displayTitle(node, t) const selected = node.id === currentId const [menuOpen, setMenuOpen] = useState(false) + // Archive replaces the former Delete placeholder: it hides the row through + // the registry-global archive set and never touches the session log, so it + // is not styled as destructive and needs no confirmation dialog. const sessionMenuItems = [ { id: 'rename', label: t('rename'), icon: }, { id: 'fork', label: t('menu.fork'), icon: }, - { id: 'delete', label: t('menu.deleteSession'), icon: , danger: true }, + { id: 'archive', label: t('menu.archiveSession'), icon: }, ] // Figma session cell: pad 8, status slot 16, then a 4px title gap. const ownRow = ( @@ -310,7 +315,8 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork onSelect={(id) => { setMenuOpen(false) if (id === 'rename') onRename(node.id, row.title) - if (id === 'fork') onFork(node.id) // delete stays visual-only. + if (id === 'fork') onFork(node.id) + if (id === 'archive') onArchive(node.id) }} portal closeOnPointerLeave diff --git a/packages/client/ui-workspace/src/client/tree.ts b/packages/client/ui-workspace/src/client/tree.ts index aed01c21cb..7ed7e684d1 100644 --- a/packages/client/ui-workspace/src/client/tree.ts +++ b/packages/client/ui-workspace/src/client/tree.ts @@ -90,9 +90,13 @@ function byRecency(a: SessionSummary, b: SessionSummary): number { return a.id < b.id ? -1 : 1 } -/** Ordinary sessions are visible; among blank sessions, only the current one is visible. */ -function sessionVisible(session: SessionSummary, current: SessionId | undefined): boolean { - return !session.blank || session.id === current +/** + * Ordinary sessions are visible; among blank sessions, only the current one + * is visible; archived sessions are visible nowhere (their accounting slots + * remain, so unarchiving restores position). + */ +function sessionVisible(session: SessionSummary, current: SessionId | undefined, archived: ReadonlySet): boolean { + return !archived.has(session.id) && (!session.blank || session.id === current) } /** @@ -126,7 +130,11 @@ function buildGroup( * order, with members resolved from sessionIds in their stored order. Sessions * outside every Workspace trail in the recency-ordered Ungrouped bucket. */ -function groupByWorkspace(list: SessionListState, workspaces: readonly WorkspaceView[]): Group[] { +function groupByWorkspace( + list: SessionListState, + workspaces: readonly WorkspaceView[], + archived: ReadonlySet, +): Group[] { const groups: Group[] = [] const accounted = new Set() for (const workspace of workspaces) { @@ -135,7 +143,7 @@ function groupByWorkspace(list: SessionListState, workspaces: readonly Workspace const summary = list.byId[id] if (summary === undefined) continue // account may lead the list pull; the row appears when the summary lands accounted.add(id) - if (!sessionVisible(summary, list.current)) continue + if (!sessionVisible(summary, list.current, archived)) continue members.push(summary) } groups.push(buildGroup( @@ -146,7 +154,7 @@ function groupByWorkspace(list: SessionListState, workspaces: readonly Workspace const stray = list.ids .map(id => list.byId[id]) .filter((s): s is SessionSummary => - s !== undefined && !accounted.has(s.id) && sessionVisible(s, list.current)) + s !== undefined && !accounted.has(s.id) && sessionVisible(s, list.current, archived)) if (stray.length > 0) { groups.push(buildGroup(UNGROUPED_KEY, undefined, undefined, undefined, UNGROUPED_LABEL, stray, 'recency')) } @@ -168,25 +176,29 @@ function sessionNode(s: SessionSummary): SessionNode { * * Every group shows; sessions populate under expanded groups, preserving * Host account order. Blank sessions are excluded except for the selected - * provisional New Session row. Content search lives outside this derivation + * provisional New Session row; archived sessions are excluded everywhere. + * Content search lives outside this derivation * (see {@link deriveSearchResults}). * @param list - sessions list snapshot (`current` feeds containsCurrent). * @param workspaces - real workspaces in stable Host order. + * @param archivedSessionIds - registry-global archive set. * @param view - local expansion arrays. * @returns group sections in render order. */ export function deriveGroups( list: SessionListState, workspaces: readonly WorkspaceView[], + archivedSessionIds: readonly SessionId[], view: TreeView, ): GroupNode[] { + const archived = new Set(archivedSessionIds) const expandedProjects = new Set(view.expandedProjects) const currentGroup = list.current === undefined ? undefined : (workspaces.find(w => w.sessionIds.includes(list.current as SessionId))?.workspaceId as string | undefined) ?? UNGROUPED_KEY const groups: GroupNode[] = [] - for (const g of groupByWorkspace(list, workspaces)) { + for (const g of groupByWorkspace(list, workspaces, archived)) { const expanded = expandedProjects.has(g.key) groups.push({ key: g.key, @@ -209,13 +221,15 @@ export function deriveGroups( * no parent/child adjacency. Content search lives outside this derivation * (see {@link deriveSearchResults}). * @param list - sessions list snapshot. + * @param archivedSessionIds - registry-global archive set. * @returns flat rows in render order. */ -export function deriveFlat(list: SessionListState): SessionNode[] { +export function deriveFlat(list: SessionListState, archivedSessionIds: readonly SessionId[]): SessionNode[] { + const archived = new Set(archivedSessionIds) const rows: SessionSummary[] = [] for (const id of list.ids) { const s = list.byId[id] - if (s === undefined || !sessionVisible(s, list.current)) continue + if (s === undefined || !sessionVisible(s, list.current, archived)) continue rows.push(s) } rows.sort(byRecency) @@ -238,6 +252,7 @@ export interface RelativeTime { * @param list - session metadata authority. * @param workspaces - Workspace membership and display labels. * @param query - caller text; surrounding whitespace is ignored. + * @param archivedSessionIds - registry-global archive set (members never match). * @param content - ranked Host content-search page. * @param limit - protocol-owned maximum merged row count. * @returns bounded deduplicated flat rows and a refine-query hint bit. @@ -246,11 +261,13 @@ export function deriveSearchResults( list: SessionListState, workspaces: readonly WorkspaceView[], query: string, + archivedSessionIds: readonly SessionId[], content: { items: readonly SessionSearchResultItem[]; hasMore: boolean }, limit: number, ): SearchResultSet { const q = query.trim().toLowerCase() if (q === '') return { items: [], hasMore: false } + const archived = new Set(archivedSessionIds) const workspaceBySession = new Map() for (const workspace of workspaces) { @@ -270,7 +287,7 @@ export function deriveSearchResults( const summary = list.byId[id] // Blank placeholders never match a query (their canonical title displays // localized, so matching it would tie search to one language). - if (summary === undefined || summary.blank || !sessionVisible(summary, list.current)) continue + if (summary === undefined || summary.blank || !sessionVisible(summary, list.current, archived)) continue if ( sessionTitle(summary).toLowerCase().includes(q) || labelOf(summary).toLowerCase().includes(q) @@ -290,7 +307,7 @@ export function deriveSearchResults( for (const summary of local) include(summary) for (const item of content.items) { const summary = list.byId[item.sessionId] - if (summary !== undefined && !summary.blank && sessionVisible(summary, list.current)) include(summary) + if (summary !== undefined && !summary.blank && sessionVisible(summary, list.current, archived)) include(summary) } return { diff --git a/packages/client/ui-workspace/tests/rows.spec.tsx b/packages/client/ui-workspace/tests/rows.spec.tsx index 59fe95fe0c..ee45b8628d 100644 --- a/packages/client/ui-workspace/tests/rows.spec.tsx +++ b/packages/client/ui-workspace/tests/rows.spec.tsx @@ -88,7 +88,7 @@ describe('workspace browser rows', () => { const onOpen = vi.fn() render( , + onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />, ) const row = screen.getByRole('treeitem') @@ -157,18 +157,20 @@ describe('workspace browser rows', () => { expect(screen.queryByRole('button', { name: /工作区/ })).toBeNull() }) - it('session row menu opens without opening the session and dispatches rename and fork', () => { + it('session row menu opens without opening the session and dispatches rename, fork, and archive', () => { const onOpen = vi.fn() const onRename = vi.fn() const onFork = vi.fn() + const onArchive = vi.fn() const node: SessionNode = { id: sid('s1'), title: 'One', blank: false, running: false, updatedAt: 0, } render() + onRename={onRename} onFork={onFork} onArchive={onArchive} t={t} />) fireEvent.click(screen.getByRole('button', { name: '会话“One”的操作' })) expect(onOpen).not.toHaveBeenCalled() - expect(screen.getByRole('menuitem', { name: '删除会话' }).className).toMatch(/danger/) + // Archive is not destructive (log and accounting slot remain): no danger styling. + expect(screen.getByRole('menuitem', { name: '归档会话' }).className).not.toMatch(/danger/) // Rename dispatches with the current display title (dialog prefill). fireEvent.click(screen.getByRole('menuitem', { name: '重命名' })) expect(screen.queryByRole('menu')).toBeNull() @@ -177,10 +179,12 @@ describe('workspace browser rows', () => { fireEvent.click(screen.getByRole('button', { name: '会话“One”的操作' })) fireEvent.click(screen.getByRole('menuitem', { name: '分叉会话' })) expect(onFork).toHaveBeenCalledWith(node.id) - // Delete stays visual-only. + // Archive dispatches without opening the session. fireEvent.click(screen.getByRole('button', { name: '会话“One”的操作' })) - fireEvent.click(screen.getByRole('menuitem', { name: '删除会话' })) + fireEvent.click(screen.getByRole('menuitem', { name: '归档会话' })) + expect(onArchive).toHaveBeenCalledWith(node.id) expect(onRename).toHaveBeenCalledOnce() + expect(onOpen).not.toHaveBeenCalled() // Escape closes without selecting (Menu onClose path). fireEvent.click(screen.getByRole('button', { name: '会话“One”的操作' })) fireEvent.keyDown(document, { key: 'Escape' }) @@ -194,7 +198,7 @@ describe('workspace browser rows', () => { id: sid('s1'), title: 'Hovered', blank: false, running: true, updatedAt: 0, } render() + onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />) const wrapper = screen.getByRole('treeitem').parentElement as HTMLElement fireEvent.pointerEnter(wrapper) act(() => { vi.advanceTimersByTime(500) }) @@ -220,7 +224,7 @@ describe('workspace browser rows', () => { id: sid('s1'), title: 'Quiet', blank: false, running: false, updatedAt: 0, } render() + onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />) fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement) act(() => { vi.advanceTimersByTime(500) }) expect(screen.getByText('空闲')).toBeTruthy() @@ -237,7 +241,7 @@ describe('workspace browser rows', () => { const inactive = dragProps() const { rerender } = render( , + onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} drag={inactive} t={t} />, ) const row = screen.getByRole('treeitem') stubRect(row) @@ -255,7 +259,7 @@ describe('workspace browser rows', () => { const active = dragProps({ active: true, marker: 'before' }) rerender( , + onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} drag={active} t={t} />, ) stubRect(screen.getByRole('treeitem')) // Top half hovers/drops 'before'; bottom half 'after' (row mid = 117). @@ -269,7 +273,7 @@ describe('workspace browser rows', () => { const after = dragProps({ active: true, marker: 'after' }) rerender( , + onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} drag={after} t={t} />, ) 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 8fb750a4ad..7476de29f5 100644 --- a/packages/client/ui-workspace/tests/tree.spec.ts +++ b/packages/client/ui-workspace/tests/tree.spec.ts @@ -26,19 +26,21 @@ const workspace = (id: string, sessionIds: string[], title = id): WorkspaceView const view = (expandedProjects: readonly string[] = []) => ({ expandedProjects, }) +const noArchive: readonly SessionId[] = [] +const archived = (...ids: string[]): readonly SessionId[] => ids.map(sid) describe('deriveGroups', () => { it('keeps Host Workspace and sessionIds order without Client recency sorting', () => { const sessions = list(summary('newer', 20), summary('older', 10)) const workspaces = [workspace('first', ['older', 'newer']), workspace('empty', [])] - const groups = deriveGroups(sessions, workspaces, view(['first'])) + const groups = deriveGroups(sessions, workspaces, noArchive, view(['first'])) expect(groups.map(group => group.key)).toEqual(['first', 'empty']) expect(groups[0]!.sessions.map(session => session.id)).toEqual([sid('older'), sid('newer')]) }) it('puts only real unaccounted Sessions in the trailing Ungrouped group', () => { const sessions = list(summary('owned', 1, '/projects/first'), summary('loose', 9, '/other')) - const groups = deriveGroups(sessions, [workspace('first', ['owned'])], view([UNGROUPED_KEY])) + const groups = deriveGroups(sessions, [workspace('first', ['owned'])], noArchive, view([UNGROUPED_KEY])) expect(groups.map(group => group.key)).toEqual(['first', UNGROUPED_KEY]) expect(groups[1]!.sessions.map(session => session.id)).toEqual([sid('loose')]) }) @@ -52,7 +54,7 @@ describe('deriveGroups', () => { current: currentBlank.id, } const groups = deriveGroups( - sessions, [workspace('first', ['shown', 'current-blank', 'stale-blank'])], view(['first']), + sessions, [workspace('first', ['shown', 'current-blank', 'stale-blank'])], noArchive, view(['first']), ) expect(groups[0]!.sessions.map(session => session.id)).toEqual([real.id, currentBlank.id]) const blankNode = groups[0]!.sessions.find(session => session.id === currentBlank.id)! @@ -63,7 +65,7 @@ describe('deriveGroups', () => { expect(groups[0]!.sessions.find(session => session.id === real.id)!.blank).toBe(false) expect(groups[0]!.sessionCount).toBe(2) // A non-current blank stray never surfaces an Ungrouped bucket either. - const strayGroups = deriveGroups(list({ ...summary('stray', 2), blank: true }), [workspace('first', [])], view()) + const strayGroups = deriveGroups(list({ ...summary('stray', 2), blank: true }), [workspace('first', [])], noArchive, view()) expect(strayGroups.map(group => group.key)).toEqual(['first']) }) @@ -80,6 +82,7 @@ describe('deriveGroups', () => { const groups = deriveGroups( list(parent, oldChild, newChild, tieB, tieA, self, orphan, cycleA, cycleB), [], + noArchive, { expandedProjects: [UNGROUPED_KEY] }, ) @@ -90,7 +93,7 @@ describe('deriveGroups', () => { ]) // Equal timestamps use ids as a deterministic tiebreak in either input order. - expect(deriveGroups(list(summary('tie-a', 1), summary('tie-b', 1)), [], view([UNGROUPED_KEY]))[0]! + expect(deriveGroups(list(summary('tie-a', 1), summary('tie-b', 1)), [], noArchive, view([UNGROUPED_KEY]))[0]! .sessions.map(node => node.id)).toEqual([sid('tie-a'), sid('tie-b')]) }) @@ -100,17 +103,32 @@ describe('deriveGroups', () => { ids: [sid('present')], byId: { [sid('present')]: summary('present', 1) }, } - const groups = deriveGroups(partial, [workspace('project', ['missing', 'present'])], view(['project'])) + const groups = deriveGroups(partial, [workspace('project', ['missing', 'present'])], noArchive, view(['project'])) expect(groups[0]!.sessions.map(node => node.id)).toEqual([sid('present')]) }) + it('hides archived sessions from workspace groups and Ungrouped', () => { + const kept = summary('kept', 1, '/projects/first') + const gone = summary('gone', 2, '/projects/first') + const looseGone = summary('loose-gone', 3, '/other') + const sessions = list(kept, gone, looseGone) + const groups = deriveGroups( + sessions, [workspace('first', ['kept', 'gone'])], archived('gone', 'loose-gone'), view(['first', UNGROUPED_KEY]), + ) + // The archived member drops from its group AND the archived stray never + // surfaces an Ungrouped bucket; counts follow the visible rows. + expect(groups.map(group => group.key)).toEqual(['first']) + expect(groups[0]!.sessions.map(node => node.id)).toEqual([kept.id]) + expect(groups[0]!.sessionCount).toBe(1) + }) + it('marks selected Workspace and Ungrouped sessions without relying on an Intent', () => { const owned = summary('owned', 1) const loose = summary('loose', 2) const ws = workspace('project', ['owned']) - const ownedGroups = deriveGroups({ ...list(owned, loose), current: owned.id }, [ws], view()) + const ownedGroups = deriveGroups({ ...list(owned, loose), current: owned.id }, [ws], noArchive, view()) expect(ownedGroups.find(group => group.key === 'project')!.containsCurrent).toBe(true) - const looseGroups = deriveGroups({ ...list(owned, loose), current: loose.id }, [ws], view()) + const looseGroups = deriveGroups({ ...list(owned, loose), current: loose.id }, [ws], noArchive, view()) expect(looseGroups.find(group => group.key === UNGROUPED_KEY)!.containsCurrent).toBe(true) }) }) @@ -121,13 +139,13 @@ describe('deriveFlat', () => { 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)) + const rows = deriveFlat(list(parent, child, tieB, tieA), noArchive) expect(rows.map(row => row.id)).toEqual([sid('child'), sid('tie-a'), sid('tie-b'), sid('parent')]) }) it('tolerates ids whose summary has not landed yet', () => { const partial: SessionListState = { ...list(summary('present', 1)), ids: [sid('ghost'), sid('present')] } - expect(deriveFlat(partial).map(row => row.id)).toEqual([sid('present')]) + expect(deriveFlat(partial, noArchive).map(row => row.id)).toEqual([sid('present')]) }) it('shows only the current blank session and excludes blanks from search', () => { @@ -137,11 +155,35 @@ describe('deriveFlat', () => { ...list(summary('real', 1), currentBlank, staleBlank), current: currentBlank.id, } - const rows = deriveFlat(sessions) + const rows = deriveFlat(sessions, noArchive) expect(rows.map(row => row.id)).toEqual([currentBlank.id, sid('real')]) expect(rows.map(row => row.title)).toEqual(['New Session', 'real']) expect(rows.map(row => row.blank)).toEqual([true, false]) }) + + it('hides archived sessions in flat mode', () => { + const kept = summary('kept', 1) + const gone = summary('gone', 2) + expect(deriveFlat(list(kept, gone), archived('gone')).map(row => row.id)).toEqual([kept.id]) + }) +}) + +describe('deriveSearchResults archive filtering', () => { + it('archived sessions never match — not by title and not via a backend content hit', () => { + const hit = summary('hit', 2) + hit.displayTitle = 'Needle row' + const gone = summary('gone', 1) + gone.displayTitle = 'Needle archived' + const result = deriveSearchResults( + list(hit, gone), + [], + 'needle', + archived('gone'), + { items: [{ sessionId: gone.id, snippet: 'needle body' }], hasMore: false }, + 10, + ) + expect(result.items.map(item => item.id)).toEqual([hit.id]) + }) }) describe('deriveSearchResults', () => { @@ -160,6 +202,7 @@ describe('deriveSearchResults', () => { workspace('duplicate-owner', ['title-hit'], 'Ignored duplicate owner'), ], ' NEEDLE ', + noArchive, { items: [ { sessionId: contentHit.id, snippet: 'body needle excerpt' }, @@ -212,6 +255,7 @@ describe('deriveSearchResults', () => { sessions, [workspace('first', ['opaque-current', 'new session stale'])], 'new session', + noArchive, { items: [ { sessionId: staleBlank.id, snippet: 'stale body' }, @@ -234,6 +278,7 @@ describe('deriveSearchResults', () => { list(...rows), [], 'needle', + noArchive, { items: [], hasMore: false }, 3, ) @@ -244,12 +289,13 @@ describe('deriveSearchResults', () => { list(summary('body', 1)), [], 'needle', + noArchive, { items: [{ sessionId: sid('body'), snippet: 'needle' }], hasMore: true }, 3, ) expect(backendMore.items).toHaveLength(1) expect(backendMore.hasMore).toBe(true) - expect(deriveSearchResults(list(), [], ' ', { items: [], hasMore: true }, 3)) + expect(deriveSearchResults(list(), [], ' ', noArchive, { items: [], hasMore: true }, 3)) .toEqual({ items: [], hasMore: false }) }) }) diff --git a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx index 7dc3f7239b..5b12646a27 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx @@ -35,8 +35,8 @@ 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, state: 'idle', phase: 'ready', error: null, baselinesReady: true, +const workspaceState = (items: readonly WorkspaceView[], archivedSessionIds: ReadonlySet = new Set()): WorkspaceListState => ({ + items, archivedSessionIds, state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: items[0]?.workspaceId, }) function hook(snapshot: T) { @@ -68,6 +68,7 @@ function mount(overrides: Partial = {}) { forkSession: vi.fn(), renameWorkspace: vi.fn(async () => {}), deleteWorkspace: vi.fn(async () => {}), + archiveSession: vi.fn(async () => {}), insertSessionBefore: vi.fn(async () => {}), createWorkspace: vi.fn(async () => workspace('created', [])), useDirectoryFlow: bindSnapshotSelector({ getSnapshot: () => true, subscribe: () => () => {} }), @@ -135,6 +136,50 @@ describe('WorkspaceBrowser', () => { expect(screen.queryByText('alpha-s')).toBeNull() }) + it('archives a session from the row menu and hides archived rows in both modes', async () => { + const archiveSession = vi.fn(async () => {}) + const b = mount({ + useSessions: hook(sessionState([summary('kept-s', 2), summary('gone-s', 1)])), + useWorkspaces: hook(workspaceState([workspace('alpha', ['kept-s', 'gone-s'])])), + archiveSession, + }) + fireEvent.click(screen.getByText('alpha')) + fireEvent.click(screen.getByRole('button', { name: '会话“gone-s”的操作' })) + fireEvent.click(screen.getByRole('menuitem', { name: '归档会话' })) + expect(archiveSession).toHaveBeenCalledWith(sid('gone-s')) + + // The archive-set echo hides the row in grouped mode (count included) and flat mode. + rerender(b, { useWorkspaces: hook(workspaceState([workspace('alpha', ['kept-s', 'gone-s'])], new Set([sid('gone-s')]))) }) + expect(screen.queryByText('gone-s')).toBeNull() + expect(screen.getByText('1 个会话')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: '分组方式' })) + fireEvent.click(screen.getByRole('menuitem', { name: '单列表' })) + expect(screen.getByText('kept-s')).toBeTruthy() + expect(screen.queryByText('gone-s')).toBeNull() + }) + + it('logs and keeps the tree when the archive call rejects', async () => { + const rejection = new Error('archive exploded') + const archiveSession = vi.fn(async () => { throw rejection }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + mount({ + useSessions: hook(sessionState([summary('alpha-s', 1)])), + useWorkspaces: hook(workspaceState([workspace('alpha', ['alpha-s'])])), + archiveSession, + }) + fireEvent.click(screen.getByText('alpha')) + fireEvent.click(screen.getByRole('button', { name: '会话“alpha-s”的操作' })) + fireEvent.click(screen.getByRole('menuitem', { name: '归档会话' })) + await Promise.resolve() + await Promise.resolve() + expect(warn).toHaveBeenCalledWith('session archive rejected:', rejection) + expect(screen.getByText('alpha-s')).toBeTruthy() + } finally { + warn.mockRestore() + } + }) + it('renders a fork child as a top-level row without a session twist', () => { const parent = summary('parent-s', 2) const child = { ...summary('child-s', 1), parentId: parent.id } diff --git a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx index 3a1bb8e2b2..334020b37f 100644 --- a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx @@ -32,7 +32,7 @@ const sessions: SessionListState = { ids: [], byId: {}, current: undefined, phase: 'ready', } const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState => ({ - items, state: 'idle', phase: 'ready', error: null, baselinesReady: true, + items, archivedSessionIds: new Set(), state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: items[0]?.workspaceId, }) function anchor(): { current: HTMLElement } { From 3b86c288d62c3a307660035e756a8b6bde90c976 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 31 Jul 2026 02:40:49 +0800 Subject: [PATCH 12/20] docs: catalogs and Agent Note for the session archive set --- ...07-31-session-archive-global-set.i18n.yaml | 6 ++++ .../2026-07-31-session-archive-global-set.md | 33 +++++++++++++++++++ ...026-07-31-session-archive-global-set.zh.md | 33 +++++++++++++++++++ docs/cordis-catalog/services.md | 13 +++++++- .../cordis/tool-cordis/src/api-catalog.ts | 4 +++ 5 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 .agents/notes/implemented/feature/2026-07-31-session-archive-global-set.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-31-session-archive-global-set.md create mode 100644 .agents/notes/implemented/feature/2026-07-31-session-archive-global-set.zh.md diff --git a/.agents/notes/implemented/feature/2026-07-31-session-archive-global-set.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-session-archive-global-set.i18n.yaml new file mode 100644 index 0000000000..6ed5f6ed3b --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-31-session-archive-global-set.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-session-archive-global-set.md +2026-07-31-session-archive-global-set.md: 0fe354ef0ed50ee60a69b0633522323124c80da7 +2026-07-31-session-archive-global-set.zh.md: 2b13f0eddc9aa62600a95ac5f412c51f3a38af7b diff --git a/.agents/notes/implemented/feature/2026-07-31-session-archive-global-set.md b/.agents/notes/implemented/feature/2026-07-31-session-archive-global-set.md new file mode 100644 index 0000000000..0fe354ef0e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-31-session-archive-global-set.md @@ -0,0 +1,33 @@ +# Agent Note: Session archive (registry-global set) + +Status: implemented + +English | [中文](2026-07-31-session-archive-global-set.zh.md) + +## Problem + +The session row menu in the sidebar workspace browser carried a purely visual "Delete session" placeholder (no handler). The product decision is **archive**, not delete: the session log and its workspace accounting stay untouched; the session merely disappears from every grouping surface (workspace groups, Ungrouped, search, the flat list). The archive record needs a home: an Ungrouped session belongs to no workspace entity, so a per-workspace field cannot carry it. + +## Decision + +**The archive set is a new field on the workspace domain's global singleton (`workspaceDomainState.archivedSessionIds`), layered over workspace accounting; display filtering converges entirely in the client's `tree.ts` derivation layer; the wire surface uses the full-snapshot posture.** + +- Storage: `archivedSessionIds: z.array(sessionId).default([])`, domain version stays 2 — a purely additive field; pre-field media parse to an empty set through the schema default, no migration code. An archived session keeps its `sessionIds` slot (a future unarchive restores its position), so the set never touches the one-owner accounting invariant. +- Registry: `ctx.workspace.archiveSession(id)` rides `enqueueOperation`, serialized with create/delete; a session neither live nor persisted throws `WorkspaceUnknownSessionError`; an already archived id neither writes nor emits. The `archivedSessionIds` getter exposes the read-only set. +- RPC: `workspace.archiveSession({sessionId}) → {archivedSessionIds}` (answers the full updated set); the `workspace.list` response carries the set as the reconnect baseline; a new host frame `host/archived-sessions-changed` pushes the full snapshot after every durable change (same posture as `host/workspace-changed`, emitted from the `domain/changed` global-put branch by set comparison). Unknown sessions reuse the `session-not-found` error code. +- Client runtime: `WorkspaceListState.archivedSessionIds` (a `ReadonlySet`, reference replaced only on membership change); the list baseline, the unary echo, and the changed frame each install the complete set. `WorkspacesService.archiveSession` calls `sessions.clear()` when the archived session is the current selection, returning to the New Session view (user decision: archiving the open session sends the main view back to the hero). +- UI: the `delete` menu row (visual-only) becomes `archive` (label "Archive session", non-danger styling, no confirmation dialog — a non-destructive action whose worst misfire is list hiding); filtering is one extra arm in `tree.ts`'s `sessionVisible` predicate, with `deriveGroups`/`deriveFlat` taking an `archived` set parameter so all four surfaces (group loop, stray bucket, search, flat) share one source. + +## Alternatives considered + +**Per-workspace archivedSessionIds (the original phrasing).** Rejected: Ungrouped sessions have no home; the user switched to global. + +**An archived flag on SessionSummary (session.list layer).** Rejected: it joins a workspace-domain fact into the sessions-domain projection, summaries have no incremental frame so a separate notification would still be needed — cross-domain coupling outweighs the saving. + +**Host-side filtering in `workspaceView`/the `sessionIds` getter.** Rejected: archiving ≠ changing accounting, and filtering the projection muddles the two concepts; a future restore surface also needs the client to see full accounting. + +**Incremental frames (single archived/removed rows).** Rejected: the set is tiny and changes rarely; full snapshots spare the client merge logic and dedup state and match the existing workspace-changed posture. + +## Consequences + +Archived sessions have no viewing or unarchive surface yet (this iteration's scope; recorded as a README Known Limitation); data and accounting slots stay intact, so a future restore is one UI surface plus one inverse RPC. The `workspace.list` response shape change is a pre-release direct edit (no compatibility layer). The workspace-management e2e pins the full chain (archive → row disappears → still hidden after reload, log still present); domain tests pin idempotence, unknown-id rejection, restart recovery, and the pre-field media default upgrade. diff --git a/.agents/notes/implemented/feature/2026-07-31-session-archive-global-set.zh.md b/.agents/notes/implemented/feature/2026-07-31-session-archive-global-set.zh.md new file mode 100644 index 0000000000..2b13f0eddc --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-31-session-archive-global-set.zh.md @@ -0,0 +1,33 @@ +# Agent Note: Session 归档(注册表级全局集合) + +状态:implemented + +[English](2026-07-31-session-archive-global-set.md) | 中文 + +## 问题 + +Sidebar workspace 浏览区的 session 行菜单里,「Delete session」一直是纯视觉占位(无 handler)。产品口径定为**归档**而非删除:session 日志与 workspace 记账都不动,只把该 session 从所有分组视图(workspace 分组、Ungrouped、搜索、平铺列表)里隐藏。归档记录需要一个落点:Ungrouped 的 session 不属于任何 workspace 实体,per-workspace 字段放不下它。 + +## 决策 + +**归档集合是 workspace domain 全局单例(`workspaceDomainState.archivedSessionIds`)上的一个新字段,覆盖在 workspace 记账之上;显示过滤全部收敛在 client 的 `tree.ts` 派生层;wire 面走全快照姿态。** + +- 存储:`archivedSessionIds: z.array(sessionId).default([])`,domain version 保持 2——纯增量字段,旧介质经 schema default 解析为空集合,无迁移代码。被归档的 session 保留其 `sessionIds` 席位(未来取消归档恢复原位置),因此与「一个 session 只被一个 workspace 记账」不变式零纠缠。 +- Registry:`ctx.workspace.archiveSession(id)` 走 `enqueueOperation` 与 create/delete 串行;未知 session(实时与持久化都查不到)抛 `WorkspaceUnknownSessionError`;已归档 id 不写盘不发事件。`archivedSessionIds` getter 暴露只读集合。 +- RPC:`workspace.archiveSession({sessionId}) → {archivedSessionIds}`(应答完整更新后集合);`workspace.list` 响应携带集合作为重连基线;新 host 帧 `host/archived-sessions-changed` 在每次持久变更后推完整快照(与 `host/workspace-changed` 同姿态,从 `domain/changed` 的 global put 分支比对推帧)。未知 session 复用错误码 `session-not-found`。 +- client runtime:`WorkspaceListState.archivedSessionIds`(`ReadonlySet`,成员不变不换引用);list 基线、unary 回声、changed 帧三路都整体替换安装。`WorkspacesService.archiveSession` 在归档对象恰为当前 selection 时调 `sessions.clear()` 回 New Session 视图(用户拍板:归档当前打开的 session 主视图回 hero)。 +- UI:菜单项 `delete`(visual-only)改为 `archive`(label「Archive session」,非 danger 样式,无确认对话框——非破坏性操作,误触后果只是列表隐藏);过滤实现为 `tree.ts` 的 `sessionVisible` 判据加一档,`deriveGroups`/`deriveFlat` 增加 `archived` 集合入参,四个视图(分组循环、stray 兜底、搜索、平铺)同源生效。 + +## 已考虑的替代方案 + +**per-workspace archivedSessionIds(最初表述)。** 否决:Ungrouped session 无落点;用户改口全局。 + +**SessionSummary 打 archived 标(session.list 层)。** 否决:要把 workspace domain 事实 join 进 sessions domain 投影,summary 无增量帧还得另发通知,跨域耦合大于收益。 + +**host 侧在 `workspaceView`/`sessionIds` getter 过滤。** 否决:归档 ≠ 改记账,投影过滤会把两个概念搅浑;未来恢复入口也需要 client 拿到全量记账。 + +**增量帧(archived/removed 单条)。** 否决:集合极小、变更频率低,全快照免去 client 侧合并逻辑与去重状态,与 workspace-changed 现有姿态一致。 + +## 后果 + +归档后 UI 无查看/取消归档入口(本期口径,README Known Limitation 记账);数据与席位完好,后续加恢复面只是 UI + 一个逆向 RPC。`workspace.list` 响应形状变化是 pre-release 直改(无兼容层)。e2e(workspace-management)钉住了「归档→行消失→reload 后仍隐藏、日志仍在」的全链路;domain 层测试钉住幂等、未知 id 拒绝、跨重启恢复与旧介质默认升级。 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 0adc848ea5..958dcc10cd 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -2544,6 +2544,15 @@ list(): Workspace[] */ delete(id: WorkspaceId): Promise +/** + * Archive one session durably. The session must exist (live or in session + * persistence); its workspace accounting — or lack of one — is irrelevant. + * An already archived id resolves without writing. + * @param sessionId - The session to archive. + * @returns resolution after durability. + */ +archiveSession(sessionId: SessionId): Promise + /** * Resolve by canonical directory path without creating or mutating a * workspace. A missing path rejects during `realpath`; an existing unowned @@ -2554,7 +2563,9 @@ delete(id: WorkspaceId): Promise async resolveByPath(path: string): Promise ``` -Source: [`packages/workspace/workspace/src/index.ts:78`](../../packages/workspace/workspace/src/index.ts) +Types: [SessionId](../core-data-structures/core.md) + +Source: [`packages/workspace/workspace/src/index.ts:90`](../../packages/workspace/workspace/src/index.ts) ## Inherited `ctx` members (cordis core + loader/hmr/timer) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 4d986582d4..66ac086b49 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1162,6 +1162,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'delete(id: WorkspaceId): Promise', jsDoc: '/**\n * Delete one workspace registration while retaining its directory and every\n * session log. The durable order is updated before the table deletion; a\n * failed table write restores the prior order and keeps the entity\n * published. Unknown ids are an idempotent no-op for domain callers.\n * @param id - Workspace registration to remove.\n * @returns `true` when a record was deleted, `false` when it was unknown.\n */', }, + { + signature: 'archiveSession(sessionId: SessionId): Promise', + jsDoc: '/**\n * Archive one session durably. The session must exist (live or in session\n * persistence); its workspace accounting — or lack of one — is irrelevant.\n * An already archived id resolves without writing.\n * @param sessionId - The session to archive.\n * @returns resolution after durability.\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 */', From fc0042e47e3bdc7375ac9d1b5356ef5ed7bab554 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 31 Jul 2026 03:07:53 +0800 Subject: [PATCH 13/20] fix(client-runtime): never reuse an archived blank session in connectWorkspace Reusing one would open a session no grouping surface can show; the New Session flow mints a fresh blank instead. --- packages/client/runtime/src/client/workspaces/service.ts | 6 +++++- packages/client/runtime/tests/workspaces-service.spec.ts | 6 ++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/client/runtime/src/client/workspaces/service.ts b/packages/client/runtime/src/client/workspaces/service.ts index f3125e6494..3204407e20 100644 --- a/packages/client/runtime/src/client/workspaces/service.ts +++ b/packages/client/runtime/src/client/workspaces/service.ts @@ -94,10 +94,14 @@ export class WorkspacesService implements IWorkspaces { if (inflight !== undefined) return inflight // Reuse: blank && same canonical cwd (workspace.path is the host realpath // canon; summary cwd is the session header passthrough of the same canon). + // An archived blank is never reused: reuse would open a session no + // grouping surface can show, so New Session mints a fresh one instead. + const archived = this.list.getSnapshot().archivedSessionIds const sessions = this.sessions.list.getSnapshot() for (const id of sessions.ids) { const summary = sessions.byId[id] - if (summary !== undefined && summary.blank && summary.cwd === workspace.path) return summary.id + if (summary !== undefined && summary.blank && summary.cwd === workspace.path + && !archived.has(summary.id)) return summary.id } const attempt = this.sessions.create({ workspaceId }) .finally(() => { this.connecting.delete(workspaceId) }) diff --git a/packages/client/runtime/tests/workspaces-service.spec.ts b/packages/client/runtime/tests/workspaces-service.spec.ts index 2bd462e5ee..e280f843d1 100644 --- a/packages/client/runtime/tests/workspaces-service.spec.ts +++ b/packages/client/runtime/tests/workspaces-service.spec.ts @@ -183,6 +183,12 @@ describe('WorkspacesService', () => { // Unknown workspace fails loud instead of silently creating in nowhere. await expect(workspaces.connectWorkspace(wid('ghost'))).rejects.toThrow(/unknown workspace ghost/) + + // An archived blank is never reused: no surface can show it, so New + // Session mints a fresh one for alpha instead. + await workspaces.archiveSession(sid('s-blank')) + api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s-fresh-2') })) + await expect(workspaces.connectWorkspace(wid('alpha'))).resolves.toBe('s-fresh-2') }) it('a rejected first prompt keeps the blank session eligible for connectWorkspace reuse', async () => { From c00f0dccda7f26372ed65f0651eac30a22d63c61 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 31 Jul 2026 03:23:26 +0800 Subject: [PATCH 14/20] test(test-runtime): cover the archiveSession double's default and stub arms The default arm builds the next Set outside the immer draft: reading a Set through a draft requires the MapSet plugin, assigning a fresh Set does not. --- packages/client/test-runtime/src/workspaces.ts | 5 ++++- packages/client/test-runtime/tests/runtime.spec.tsx | 10 +++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/client/test-runtime/src/workspaces.ts b/packages/client/test-runtime/src/workspaces.ts index fab209c96c..d27139cd48 100644 --- a/packages/client/test-runtime/src/workspaces.ts +++ b/packages/client/test-runtime/src/workspaces.ts @@ -199,8 +199,11 @@ export class TestWorkspaces implements IWorkspaces { await (stub(sessionId) as Promise) return } + // Built outside the draft: reading a Set through an immer draft needs + // the MapSet plugin, while assigning a fresh Set does not. + const next = new Set([...this.list.getSnapshot().archivedSessionIds, sessionId]) await this.update((draft) => { - draft.archivedSessionIds = new Set([...draft.archivedSessionIds, sessionId]) + draft.archivedSessionIds = next }) } } diff --git a/packages/client/test-runtime/tests/runtime.spec.tsx b/packages/client/test-runtime/tests/runtime.spec.tsx index 8909f88162..f48e96b832 100644 --- a/packages/client/test-runtime/tests/runtime.spec.tsx +++ b/packages/client/test-runtime/tests/runtime.spec.tsx @@ -551,8 +551,12 @@ describe('workspaces action face', () => { await ws.openPath('/proj/file.ts') const moved = await ws.insertSessionBefore('w1' as WorkspaceId, 's1' as SessionId, 's2' as SessionId) expect(moved.sessionIds).toEqual(['s1']) + // Default archive mirrors the production effect: the id joins the list + // state's archive set (features render against the same snapshot). + await ws.archiveSession('s1' as SessionId) + expect([...ws.list.getSnapshot().archivedSessionIds]).toEqual(['s1']) expect(ws.calls.map(c => c.method)).toEqual( - ['create', 'create', 'pickDirectory', 'rename', 'delete', 'openPath', 'insertSessionBefore']) + ['create', 'create', 'pickDirectory', 'rename', 'delete', 'openPath', 'insertSessionBefore', 'archiveSession']) ws.stub('create', () => Promise.resolve({ workspaceId: 'ws-x', title: 'X', path: '/x', sessionIds: [] } as never)) ws.stub('pickDirectory', () => Promise.resolve('/picked')) @@ -560,12 +564,16 @@ describe('workspaces action face', () => { ws.stub('delete', () => Promise.resolve()) ws.stub('openPath', () => Promise.resolve()) ws.stub('insertSessionBefore', () => Promise.resolve({ workspaceId: 'w1', title: '', path: '', sessionIds: [] } as never)) + ws.stub('archiveSession', () => Promise.resolve()) expect((await ws.create({ name: 'y' })).title).toBe('X') await expect(ws.pickDirectory()).resolves.toBe('/picked') expect((await ws.rename('w1' as WorkspaceId, 'z')).title).toBe('S') await ws.delete('w1' as WorkspaceId) await ws.openPath('/other') expect((await ws.insertSessionBefore('w1' as WorkspaceId, 's1' as SessionId)).sessionIds).toEqual([]) + // The stub replaces the default set mutation: the set stays as-is. + await ws.archiveSession('s2' as SessionId) + expect([...ws.list.getSnapshot().archivedSessionIds]).toEqual(['s1']) await runtime.dispose() }) }) From d3e8f17a54e93c0d090537ae0afc0f8c936e5bed Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 31 Jul 2026 03:44:35 +0800 Subject: [PATCH 15/20] fix: review follow-ups for the session archive set - WorkspaceRegistry.archiveSession no longer wraps persistence-listing failures as WorkspaceUnknownSessionError: only a definite miss (live lookup, header index, then a fresh list) maps to session-not-found; storage faults propagate as internal errors, with a negative test. - The archived-current sweep moves from the unary path into the projection: any install path (local echo, another tab's frame, a reconnect baseline) clears a selection that landed in the archive set. - An archive set installed while workspace.list is in flight supersedes the stale baseline's set instead of being rolled back by it. - The workspace-management e2e anchors the archived row by its session actions button and asserts the single-stray fixture assumption loudly. - Drop the stale touchSession rows from the workspace READMEs (the method was removed with its Agent Note). --- ...07-31-session-archive-global-set.i18n.yaml | 4 +-- .../2026-07-31-session-archive-global-set.md | 2 +- ...026-07-31-session-archive-global-set.zh.md | 2 +- apps/web/tests/workspace-management.e2e.ts | 10 +++++- packages/client/runtime/README.i18n.yaml | 4 +-- packages/client/runtime/README.md | 2 +- packages/client/runtime/README.zh.md | 2 +- .../runtime/src/client/workspaces/manager.ts | 11 ++++++- .../runtime/src/client/workspaces/service.ts | 15 ++++++--- .../runtime/tests/workspaces-service.spec.ts | 32 +++++++++++++++++++ packages/workspace/workspace/README.i18n.yaml | 4 +-- packages/workspace/workspace/README.md | 1 - packages/workspace/workspace/README.zh.md | 1 - packages/workspace/workspace/src/index.ts | 29 ++++++++++++----- .../workspace/tests/workspace.spec.ts | 10 ++++++ 15 files changed, 103 insertions(+), 26 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-31-session-archive-global-set.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-session-archive-global-set.i18n.yaml index 6ed5f6ed3b..ace89283b1 100644 --- a/.agents/notes/implemented/feature/2026-07-31-session-archive-global-set.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-session-archive-global-set.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-session-archive-global-set.md -2026-07-31-session-archive-global-set.md: 0fe354ef0ed50ee60a69b0633522323124c80da7 -2026-07-31-session-archive-global-set.zh.md: 2b13f0eddc9aa62600a95ac5f412c51f3a38af7b +2026-07-31-session-archive-global-set.md: b318be9985b197171a046a5abeab3c93b879c710 +2026-07-31-session-archive-global-set.zh.md: 8e3157f4c55c54c3fdd3132034b28567b9b25013 diff --git a/.agents/notes/implemented/feature/2026-07-31-session-archive-global-set.md b/.agents/notes/implemented/feature/2026-07-31-session-archive-global-set.md index 0fe354ef0e..b318be9985 100644 --- a/.agents/notes/implemented/feature/2026-07-31-session-archive-global-set.md +++ b/.agents/notes/implemented/feature/2026-07-31-session-archive-global-set.md @@ -15,7 +15,7 @@ The session row menu in the sidebar workspace browser carried a purely visual "D - Storage: `archivedSessionIds: z.array(sessionId).default([])`, domain version stays 2 — a purely additive field; pre-field media parse to an empty set through the schema default, no migration code. An archived session keeps its `sessionIds` slot (a future unarchive restores its position), so the set never touches the one-owner accounting invariant. - Registry: `ctx.workspace.archiveSession(id)` rides `enqueueOperation`, serialized with create/delete; a session neither live nor persisted throws `WorkspaceUnknownSessionError`; an already archived id neither writes nor emits. The `archivedSessionIds` getter exposes the read-only set. - RPC: `workspace.archiveSession({sessionId}) → {archivedSessionIds}` (answers the full updated set); the `workspace.list` response carries the set as the reconnect baseline; a new host frame `host/archived-sessions-changed` pushes the full snapshot after every durable change (same posture as `host/workspace-changed`, emitted from the `domain/changed` global-put branch by set comparison). Unknown sessions reuse the `session-not-found` error code. -- Client runtime: `WorkspaceListState.archivedSessionIds` (a `ReadonlySet`, reference replaced only on membership change); the list baseline, the unary echo, and the changed frame each install the complete set. `WorkspacesService.archiveSession` calls `sessions.clear()` when the archived session is the current selection, returning to the New Session view (user decision: archiving the open session sends the main view back to the hero). +- Client runtime: `WorkspaceListState.archivedSessionIds` (a `ReadonlySet`, reference replaced only on membership change); the list baseline, the unary echo, and the changed frame each install the complete set. the projection sweep clears the current selection whenever it lands in the archive set, returning to the New Session view (user decision: archiving the open session sends the main view back to the hero) — one rule covering the local unary echo, another tab's changed frame, and a reconnect baseline restoring a selection archived while this client was away; a frame or echo landing during an in-flight `workspace.list` also shields the newer set from the stale baseline. - UI: the `delete` menu row (visual-only) becomes `archive` (label "Archive session", non-danger styling, no confirmation dialog — a non-destructive action whose worst misfire is list hiding); filtering is one extra arm in `tree.ts`'s `sessionVisible` predicate, with `deriveGroups`/`deriveFlat` taking an `archived` set parameter so all four surfaces (group loop, stray bucket, search, flat) share one source. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-31-session-archive-global-set.zh.md b/.agents/notes/implemented/feature/2026-07-31-session-archive-global-set.zh.md index 2b13f0eddc..8e3157f4c5 100644 --- a/.agents/notes/implemented/feature/2026-07-31-session-archive-global-set.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-session-archive-global-set.zh.md @@ -15,7 +15,7 @@ Sidebar workspace 浏览区的 session 行菜单里,「Delete session」一直 - 存储:`archivedSessionIds: z.array(sessionId).default([])`,domain version 保持 2——纯增量字段,旧介质经 schema default 解析为空集合,无迁移代码。被归档的 session 保留其 `sessionIds` 席位(未来取消归档恢复原位置),因此与「一个 session 只被一个 workspace 记账」不变式零纠缠。 - Registry:`ctx.workspace.archiveSession(id)` 走 `enqueueOperation` 与 create/delete 串行;未知 session(实时与持久化都查不到)抛 `WorkspaceUnknownSessionError`;已归档 id 不写盘不发事件。`archivedSessionIds` getter 暴露只读集合。 - RPC:`workspace.archiveSession({sessionId}) → {archivedSessionIds}`(应答完整更新后集合);`workspace.list` 响应携带集合作为重连基线;新 host 帧 `host/archived-sessions-changed` 在每次持久变更后推完整快照(与 `host/workspace-changed` 同姿态,从 `domain/changed` 的 global put 分支比对推帧)。未知 session 复用错误码 `session-not-found`。 -- client runtime:`WorkspaceListState.archivedSessionIds`(`ReadonlySet`,成员不变不换引用);list 基线、unary 回声、changed 帧三路都整体替换安装。`WorkspacesService.archiveSession` 在归档对象恰为当前 selection 时调 `sessions.clear()` 回 New Session 视图(用户拍板:归档当前打开的 session 主视图回 hero)。 +- client runtime:`WorkspaceListState.archivedSessionIds`(`ReadonlySet`,成员不变不换引用);list 基线、unary 回声、changed 帧三路都整体替换安装。投影层在当前 selection 落入归档集合时统一清空回 New Session 视图(用户拍板:归档当前打开的 session 主视图回 hero)——一条规则同时覆盖本地 unary 回声、其他标签页的 changed 帧、以及重连基线恢复出一个离线期间被归档的 selection;帧/回声落在 in-flight `workspace.list` 期间时还会屏蔽旧基线对新集合的回滚。 - UI:菜单项 `delete`(visual-only)改为 `archive`(label「Archive session」,非 danger 样式,无确认对话框——非破坏性操作,误触后果只是列表隐藏);过滤实现为 `tree.ts` 的 `sessionVisible` 判据加一档,`deriveGroups`/`deriveFlat` 增加 `archived` 集合入参,四个视图(分组循环、stray 兜底、搜索、平铺)同源生效。 ## 已考虑的替代方案 diff --git a/apps/web/tests/workspace-management.e2e.ts b/apps/web/tests/workspace-management.e2e.ts index 86c9056d66..48483e512d 100644 --- a/apps/web/tests/workspace-management.e2e.ts +++ b/apps/web/tests/workspace-management.e2e.ts @@ -429,7 +429,15 @@ describe('web e2e: workspace management (create / rename / flat view / hover car } return await ungroupedRow.getAttribute('aria-expanded') }, { timeout: 5_000 }).toBe('true') - const sessionRow = ungroupedSection.locator('[role="treeitem"]').nth(1) + // Anchor on session rows (the rows carrying a session actions button), + // not a positional index, and assert the single-stray assumption loudly + // so a fixture gaining a second stray fails here instead of archiving + // the wrong row. CSS attribute match, not getByRole: the button is + // display:none until its row hovers, and role queries skip hidden nodes. + const sessionRows = ungroupedSection.locator('[role="treeitem"]') + .filter({ has: page.locator('button[aria-label^="Session actions for "]') }) + await expect.poll(() => sessionRows.count(), { timeout: 10_000 }).toBe(1) + const sessionRow = sessionRows.first() const rowTitle = await sessionRow.locator('[class*="title"]').innerText() // Row menu: hover reveals the actions button; Archive session commits // without a confirmation dialog (non-destructive: log + accounting stay). diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index 60fcf831b4..34f1a33cc5 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/runtime/README.md -README.md: 4c9122d87bfb0ea2d66478de03c69975b0577ab7 -README.zh.md: 1ee4731964ea75de29da47137933852581fc45b3 +README.md: 52d2caf51d2fdaf48a06e6d56292fe087568eba5 +README.zh.md: d7e807cd7dffdd81a106488884031fccbb8a6d65 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 4c9122d87b..52d2caf51d 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -10,7 +10,7 @@ Workspace and Session lists have independent monotone `pending` → `ready` base `WorkspacesService.delete(workspaceId)` removes the registration from the client projection after the successful unary response; the matching `host/workspace-removed` frame is idempotent and synchronizes other tabs. Session state and the current Session selection are independent, so accounted Sessions immediately project under Ungrouped after their Workspace disappears. -`WorkspaceListState.archivedSessionIds` mirrors the Host's registry-global archive set (a `ReadonlySet` replaced only when membership changes). It is full-snapshot state: the `workspace.list` baseline, the `archiveSession` unary echo, and the `host/archived-sessions-changed` frame each install the complete set. `WorkspacesService.archiveSession(sessionId)` archives over the wire and, when the archived session is the current selection, clears it into the New Session view state; grouping surfaces hide members everywhere while the session rows stay in the list store. +`WorkspaceListState.archivedSessionIds` mirrors the Host's registry-global archive set (a `ReadonlySet` replaced only when membership changes). It is full-snapshot state: the `workspace.list` baseline, the `archiveSession` unary echo, and the `host/archived-sessions-changed` frame each install the complete set. `WorkspacesService.archiveSession(sessionId)` archives over the wire; the projection sweep clears the current selection into the New Session view state whenever it lands in the archive set — one rule covering the local echo, another tab's frame, and a reconnect baseline restoring a selection archived while this client was away. A set installed while a `workspace.list` request is in flight also supersedes that stale baseline's set. Grouping surfaces hide members everywhere while the session rows stay in the list store. SlotsService gives the renderer separate bare observables for `useSessions` and `useWorkspaces`; web-react creates the hooks. Workspace business state does not enter `SessionListState` or an entry store. diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index 1ee4731964..d7e807cd7d 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -10,7 +10,7 @@ Workspace 和 Session 列表各自具有单调的 `pending` → `ready` 基线 `WorkspacesService.delete(workspaceId)` 在一元响应成功后从客户端投影中移除注册记录;对应的 `host/workspace-removed` 帧具有幂等性,并负责同步其他标签页。Session 状态与当前 Session selection 相互独立,因此 Workspace 消失后,其已纳入客户端投影的 Session 会立即投影到 Ungrouped 下。 -`WorkspaceListState.archivedSessionIds` 镜像 Host 的注册表级全局归档集合(一个 `ReadonlySet`,仅在成员变化时才替换)。它是全快照状态:`workspace.list` 基线、`archiveSession` 一元回声和 `host/archived-sessions-changed` 帧各自安装完整集合。`WorkspacesService.archiveSession(sessionId)` 通过 wire 归档;当被归档的会话正是当前 selection 时,将其清空为 New Session 视图状态。各分组视图在所有位置隐藏集合成员,而会话行本身仍留在列表 store 中。 +`WorkspaceListState.archivedSessionIds` 镜像 Host 的注册表级全局归档集合(一个 `ReadonlySet`,仅在成员变化时才替换)。它是全快照状态:`workspace.list` 基线、`archiveSession` 一元回声和 `host/archived-sessions-changed` 帧各自安装完整集合。`WorkspacesService.archiveSession(sessionId)` 通过 wire 归档;投影层在当前 selection 落入归档集合时统一清空为 New Session 视图状态——一条规则同时覆盖本地回声、其他标签页的帧、以及重连基线恢复出一个离线期间被归档的 selection。在 `workspace.list` 请求进行中安装的集合还会取代该过期基线携带的集合。各分组视图在所有位置隐藏集合成员,而会话行本身仍留在列表 store 中。 SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 observable;web-react 创建钩子。Workspace 业务状态不会进入 `SessionListState` 或配置项 store。 diff --git a/packages/client/runtime/src/client/workspaces/manager.ts b/packages/client/runtime/src/client/workspaces/manager.ts index 0eddae8445..ba8344533a 100644 --- a/packages/client/runtime/src/client/workspaces/manager.ts +++ b/packages/client/runtime/src/client/workspaces/manager.ts @@ -38,6 +38,13 @@ export class WorkspaceManager { private error: RpcError | null = null private inflight: Promise | null = null private refreshFrames: WorkspaceDelta[] | null = null + /** + * True once a frame or unary echo installed the archive set while a list + * request was in flight: that install is newer than the pending baseline, + * so the baseline's (older) set must not roll it back — the archive + * mirror of replaying refreshFrames over the item baseline. + */ + private archivedSupersedesRefresh = false /** * Ids this process has seen removed, kept for the connection's lifetime so * a late changed frame or a stale baseline row cannot resurrect a deleted @@ -82,7 +89,7 @@ export class WorkspaceManager { items = items.filter(workspace => !this.removedIds.has(workspace.workspaceId)) for (const delta of frames) items = applyWorkspaceDelta(items, delta) this.installViews(items) - this.installArchived(result.value.archivedSessionIds) + if (!this.archivedSupersedesRefresh) this.installArchived(result.value.archivedSessionIds) this.state = 'idle' this.phase = 'ready' } else { @@ -96,6 +103,7 @@ export class WorkspaceManager { this.error = folded.ok ? null : folded.error } finally { this.refreshFrames = null + this.archivedSupersedesRefresh = false this.inflight = null this.notifier.markDirty() } @@ -224,6 +232,7 @@ export class WorkspaceManager { /** Replace the archive set when membership actually changed (set identity backs Object.is short-circuits). */ private installArchived(archivedSessionIds: readonly SessionId[]): void { + if (this.refreshFrames !== null) this.archivedSupersedesRefresh = true if (archivedSessionIds.length === this.archivedSessionIds.size && archivedSessionIds.every(id => this.archivedSessionIds.has(id))) return this.archivedSessionIds = new Set(archivedSessionIds) diff --git a/packages/client/runtime/src/client/workspaces/service.ts b/packages/client/runtime/src/client/workspaces/service.ts index 3204407e20..2ef6f77867 100644 --- a/packages/client/runtime/src/client/workspaces/service.ts +++ b/packages/client/runtime/src/client/workspaces/service.ts @@ -260,15 +260,14 @@ export class WorkspacesService implements IWorkspaces { } /** - * Archive a session into the registry-global set. When the archived - * session is the current one, the selection is cleared into the New - * Session view state — a hidden row must not stay open behind the list. + * Archive a session into the registry-global set. Clearing an archived + * current selection is the projection sweep's job (one rule for the local + * echo and a remote tab's frame alike). * @param sessionId - session to archive. */ async archiveSession(sessionId: SessionId): Promise { const result = await this.manager.archiveSession(sessionId) if (!result.ok) throw new Error(`session archive failed: ${result.error.code}: ${result.error.message}`) - if (this.sessions.list.getSnapshot().current === sessionId) this.sessions.clear() } /** @@ -313,6 +312,14 @@ export class WorkspacesService implements IWorkspaces { const workspace = this.manager.getSnapshot() const sessions = this.sessions.list.getSnapshot() const baselinesReady = workspace.phase === 'ready' && sessions.phase === 'ready' + // An archived current selection clears into the New Session view state — + // a hidden row must not stay open behind the list. Sweeping here covers + // every install path with one rule: the local unary echo, another tab's + // changed frame, and a reconnect baseline restoring a persisted + // selection that was archived while this client was away. + if (sessions.current !== undefined && workspace.archivedSessionIds.has(sessions.current)) { + this.sessions.clear() + } this.list.set({ items: workspace.items, archivedSessionIds: workspace.archivedSessionIds, diff --git a/packages/client/runtime/tests/workspaces-service.spec.ts b/packages/client/runtime/tests/workspaces-service.spec.ts index e280f843d1..71769a8e79 100644 --- a/packages/client/runtime/tests/workspaces-service.spec.ts +++ b/packages/client/runtime/tests/workspaces-service.spec.ts @@ -337,6 +337,38 @@ describe('WorkspacesService', () => { await workspaces.refresh() expect([...workspaces.list.getSnapshot().archivedSessionIds]).toEqual(['s-open']) }) + + it('clears a current archived by a remote frame and shields the set from a stale in-flight baseline', async () => { + const ctx = new Context() + const api = new FakeApiClient() + const sessions = new SessionsService(ctx, api) + const workspaces = new WorkspacesService(ctx, api, sessions) + api.onList = () => Promise.resolve(ok({ + items: [{ sessionId: sid('s-open'), updatedAt: 1, running: false, blank: false }], + }) as never) + await sessions.refresh() + sessions.open(sid('s-open')) + + // A stale baseline is in flight (older, empty set) when another tab's + // archive frame lands: the frame clears the current selection and its + // set survives the baseline's later resolution. + const gate = deferred>>() + api.onWorkspaceList = () => gate.promise + const hydration = workspaces.refresh() + workspaces.handleHostEnvelope({ + rpcId: 'frame' as never, + payload: { type: 'host/archived-sessions-changed', archivedSessionIds: [sid('s-open')] }, + } as never) + await new Promise(resolve => setTimeout(resolve, 0)) + expect(sessions.list.getSnapshot().current).toBeUndefined() + gate.resolve(ok({ items: [], archivedSessionIds: [] })) + await hydration + expect([...workspaces.list.getSnapshot().archivedSessionIds]).toEqual(['s-open']) + // The next (fresh) baseline is authoritative again. + api.onWorkspaceList = () => Promise.resolve(ok({ items: [], archivedSessionIds: [] }) as never) + await workspaces.refresh() + expect([...workspaces.list.getSnapshot().archivedSessionIds]).toEqual([]) + }) }) describe('startInitialSelection', () => { diff --git a/packages/workspace/workspace/README.i18n.yaml b/packages/workspace/workspace/README.i18n.yaml index 0805eb6ddc..33f0029093 100644 --- a/packages/workspace/workspace/README.i18n.yaml +++ b/packages/workspace/workspace/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/workspace/workspace/README.md -README.md: 2d69074d42c28ba5e340cef19c8007bcba94e755 -README.zh.md: f95246a0c970aede1944868489d122d7f309d8e7 +README.md: 11dc8172392e530ab4ea16f1b60473e5befb8089 +README.zh.md: 5c6e3cefe31759df27b8008861505527648ce3c4 diff --git a/packages/workspace/workspace/README.md b/packages/workspace/workspace/README.md index 2d69074d42..11dc817239 100644 --- a/packages/workspace/workspace/README.md +++ b/packages/workspace/workspace/README.md @@ -12,7 +12,6 @@ The entity/storage rationale lives in the [domain Agent Note](../../../.agents/n - `ctx.workspace.get(id)` / `list()` / `resolveByPath(path)` — cache-served lookups. `list()` is synchronous and follows durable registry order; `resolveByPath` is async because it applies the same `realpath` canon and rejects a missing path rather than creating it. - `ctx.workspace.delete(id)` — removes only the Workspace registration, its durable order entry, and its session account. Unknown ids return `false`; a removed record returns `true`. The directory, user files, live Sessions, and persisted session logs are never touched, so those Sessions become Ungrouped. A table-write failure restores the prior order and published entity. - `Workspace.attachSession(id)` — validates a live or persisted session header cwd against the workspace path and prepends a new id. Unknown sessions, absent/unresolvable/non-directory cwd values, and mismatches reject without writing. `detachSession` removes only the candidate index entry. -- `ctx.workspace.touchSession(id)` — moves only that validated, accounted session to the front. Ungrouped or filtered sessions are no-ops, and workspace order never changes. - `ctx.workspace.archiveSession(id)` / `archivedSessionIds` — the registry-global archive set, layered over workspace accounting: an archived session disappears from grouping surfaces but keeps its session log and its `sessionIds` slot, so a future unarchive restores its position. Archiving accepts any live or persisted session (accounted or Ungrouped), resolves without writing for an already archived id, and rejects an unknown id. State written before the field existed parses with an empty set. - `Workspace.sessionIds` — synchronous id-plus-canonical-cwd membership projection in durable candidate order. Missing headers, invalid cwd values, and mismatches are filtered; the next workspace mutation prunes them. A medium indexing one session under two workspaces, claiming one path from two records, or diverging from durable workspace order rejects at startup. - `Workspace.status()` — uncached directory check, `'ok' | 'missing-dir'`; a missing directory never mutates the record. diff --git a/packages/workspace/workspace/README.zh.md b/packages/workspace/workspace/README.zh.md index f95246a0c9..5c6e3cefe3 100644 --- a/packages/workspace/workspace/README.zh.md +++ b/packages/workspace/workspace/README.zh.md @@ -12,7 +12,6 @@ DeepSeek Harness 的 Workspace 实体注册表(`ctx.workspace`):通过领 - `ctx.workspace.get(id)`/`list()`/`resolveByPath(path)`:由缓存提供的查找。`list()` 为同步操作,并遵循持久注册表顺序;`resolveByPath` 为异步操作,因为它采用相同的 `realpath` 规范化方式,并会拒绝缺失路径,而不是创建路径。 - `ctx.workspace.delete(id)`:只移除 Workspace 注册记录、对应的持久顺序条目及会话归属记录。未知 id 返回 `false`,成功移除记录则返回 `true`。目录、用户文件、活跃会话和持久化会话日志绝不受影响,因此相关会话会进入 Ungrouped。表写入失败时会恢复原顺序和此前发布的实体。 - `Workspace.attachSession(id)`:对照 workspace 路径验证实时或已持久化的会话头 cwd,并将新 id 前置。未知会话、缺失/无法解析/非目录的 cwd 值和不匹配情况都会在不写入的前提下被拒绝。`detachSession` 只移除候选索引条目。 -- `ctx.workspace.touchSession(id)`:仅将已验证、已记账的会话移到最前。未分组或被过滤的会话不会触发任何操作,workspace 顺序绝不改变。 - `ctx.workspace.archiveSession(id)`/`archivedSessionIds`:覆盖在 workspace 记账之上的注册表级全局归档集合:被归档的会话从各分组视图中消失,但其会话日志和 `sessionIds` 席位保持不变,未来取消归档时可恢复原位置。归档接受任何实时或已持久化的会话(无论已记账还是 Ungrouped),对已归档的 id 直接完成而不写入,并拒绝未知 id。在该字段出现之前写入的状态解析为一个空集合。 - `Workspace.sessionIds`:按持久候选顺序提供同步 id 加规范 cwd 成员投影。缺失头部、无效 cwd 值和不匹配情况都被过滤;下一次 workspace 变更会剪除它们。如果同一存储介质将一个会话索引到两个 workspace 下、用两条记录声明同一路径,或偏离持久 workspace 顺序,启动会被拒绝。 - `Workspace.status()`:未缓存的目录检查,返回 `'ok' | 'missing-dir'`;目录缺失绝不会改动记录。 diff --git a/packages/workspace/workspace/src/index.ts b/packages/workspace/workspace/src/index.ts index 742c328e29..5262043b03 100644 --- a/packages/workspace/workspace/src/index.ts +++ b/packages/workspace/workspace/src/index.ts @@ -49,14 +49,16 @@ export class WorkspaceNameConflictError extends Error { } } -/** An archiveSession request named a session neither live nor in session persistence. */ +/** + * An archiveSession request named a session neither live nor in session + * persistence — a definite miss only; storage faults propagate as themselves. + */ export class WorkspaceUnknownSessionError extends Error { /** * @param sessionId - The unknown session id. - * @param options - Standard error options (the header-read failure as `cause`). */ - constructor(readonly sessionId: SessionId, options?: ErrorOptions) { - super(`cannot archive session '${sessionId}': live sessions and session persistence hold no such session`, options) + constructor(readonly sessionId: SessionId) { + super(`cannot archive session '${sessionId}': live sessions and session persistence hold no such session`) this.name = 'WorkspaceUnknownSessionError' } } @@ -215,16 +217,27 @@ export class WorkspaceRegistry extends Service { // The chain slot serializes against every other registry write, so this // check-then-write pair cannot interleave with another archive. if (this.requireState().archivedSessionIds.includes(sessionId)) return - try { - await this.readSessionHeader(sessionId) - } catch (error) { - throw new WorkspaceUnknownSessionError(sessionId, { cause: error }) + if (!(await this.sessionKnown(sessionId))) { + throw new WorkspaceUnknownSessionError(sessionId) } const state = this.requireState() await this.setState({ ...state, archivedSessionIds: [...state.archivedSessionIds, sessionId] }) }) } + /** + * Whether a session is live, header-indexed, or present in a fresh + * persistence listing. Only a definite miss returns false — a failing + * `sessionPersistence.list()` propagates so storage faults never + * masquerade as an unknown session. + */ + private async sessionKnown(id: SessionId): Promise { + if (this.ctx.get('sessions')?.get(id) !== undefined) return true + if (this.headers.has(id)) return true + await this.indexHeaders(await this.ctx.sessionPersistence.list()) + return this.headers.has(id) + } + /** * 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/tests/workspace.spec.ts b/packages/workspace/workspace/tests/workspace.spec.ts index 56910f67c8..ae4567b27e 100644 --- a/packages/workspace/workspace/tests/workspace.spec.ts +++ b/packages/workspace/workspace/tests/workspace.spec.ts @@ -866,6 +866,16 @@ describe('registry-global session archive', () => { expect(storedState(result.pool).archivedSessionIds).toEqual(['stray', 'live-only']) }) + it('propagates a persistence-listing failure instead of reporting an unknown session', async () => { + const result = await harness({ sessions: [] }) + result.list.mockRejectedValueOnce(new Error('persistence backend down')) + // The storage fault is the error — never WorkspaceUnknownSessionError, + // which the API layer would misreport as session-not-found. + await expect(result.registry.archiveSession(SessionId('unlisted'))) + .rejects.toThrow(/persistence backend down/) + expect(storedState(result.pool).archivedSessionIds).toEqual([]) + }) + it('restores the archive set across restarts and defaults it for pre-field media', async () => { const dir = await makeDir('archive-restart') const pool = new MemoryMediaPool() From 5fc2645afc706f67283ac68d031ec4588f2fd490 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 31 Jul 2026 03:45:51 +0800 Subject: [PATCH 16/20] docs: regenerate the cordis catalog for the archiveSession contract change --- docs/cordis-catalog/services.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 958dcc10cd..fdd1712705 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -2565,7 +2565,7 @@ async resolveByPath(path: string): Promise Types: [SessionId](../core-data-structures/core.md) -Source: [`packages/workspace/workspace/src/index.ts:90`](../../packages/workspace/workspace/src/index.ts) +Source: [`packages/workspace/workspace/src/index.ts:92`](../../packages/workspace/workspace/src/index.ts) ## Inherited `ctx` members (cordis core + loader/hmr/timer) From 9ed87a6dbaf6e8ecf3e04c987b921d7dd5f202b2 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 31 Jul 2026 10:40:09 +0800 Subject: [PATCH 17/20] refactor(client): archivedSessionIds public face becomes a plain array MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Public snapshot state stays in the store engine's plain-data vocabulary (immer drafts reject Sets without the MapSet plugin, which stays off): manager/service/contract carry readonly SessionId[] in Host order, and the tree derivations build their own transient Set — the expandedProjects pattern. Membership-unchanged installs still keep the array reference for Object.is short-circuits. --- ...07-31-session-archive-global-set.i18n.yaml | 4 ++-- .../2026-07-31-session-archive-global-set.md | 2 +- ...026-07-31-session-archive-global-set.zh.md | 2 +- .../client/locale/tests/language-row.spec.tsx | 2 +- packages/client/runtime/README.i18n.yaml | 4 ++-- packages/client/runtime/README.md | 2 +- packages/client/runtime/README.zh.md | 2 +- .../runtime/src/client/workspaces/manager.ts | 24 +++++++++++++------ .../runtime/src/client/workspaces/service.ts | 16 +++++++------ .../runtime/tests/workspaces-service.spec.ts | 14 +++++------ packages/client/test-runtime/src/fixtures.ts | 2 +- .../client/test-runtime/src/workspaces.ts | 5 +--- .../test-runtime/tests/runtime.spec.tsx | 4 ++-- .../tests/chat-code-subcalls.spec.tsx | 2 +- .../ui-conversation/tests/chat-view.spec.tsx | 2 +- .../tests/gate-branch-tails.spec.tsx | 4 ++-- .../ui-conversation/tests/input-bar.spec.tsx | 2 +- .../tests/input-matrix.spec.tsx | 2 +- .../tests/input-scenarios.spec.tsx | 2 +- .../ui-conversation/tests/skeleton.spec.tsx | 2 +- .../tests/terminal-card.spec.tsx | 4 ++-- .../client/ui-layout/tests/app-frame.spec.tsx | 2 +- .../ui-theme/tests/appearance-row.spec.tsx | 2 +- .../client/ui-trajectory/tests/views.spec.tsx | 2 +- .../tests/workspace-browser.spec.tsx | 4 ++-- .../tests/workspace-picker.spec.tsx | 2 +- 26 files changed, 62 insertions(+), 53 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-31-session-archive-global-set.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-session-archive-global-set.i18n.yaml index ace89283b1..cca8f8a597 100644 --- a/.agents/notes/implemented/feature/2026-07-31-session-archive-global-set.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-session-archive-global-set.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-session-archive-global-set.md -2026-07-31-session-archive-global-set.md: b318be9985b197171a046a5abeab3c93b879c710 -2026-07-31-session-archive-global-set.zh.md: 8e3157f4c55c54c3fdd3132034b28567b9b25013 +2026-07-31-session-archive-global-set.md: fab99a405a6f8264c36453473327e32905bac9c8 +2026-07-31-session-archive-global-set.zh.md: e33f3b5272a6d8fc90cfad247ba21d4d10afb045 diff --git a/.agents/notes/implemented/feature/2026-07-31-session-archive-global-set.md b/.agents/notes/implemented/feature/2026-07-31-session-archive-global-set.md index b318be9985..fab99a405a 100644 --- a/.agents/notes/implemented/feature/2026-07-31-session-archive-global-set.md +++ b/.agents/notes/implemented/feature/2026-07-31-session-archive-global-set.md @@ -15,7 +15,7 @@ The session row menu in the sidebar workspace browser carried a purely visual "D - Storage: `archivedSessionIds: z.array(sessionId).default([])`, domain version stays 2 — a purely additive field; pre-field media parse to an empty set through the schema default, no migration code. An archived session keeps its `sessionIds` slot (a future unarchive restores its position), so the set never touches the one-owner accounting invariant. - Registry: `ctx.workspace.archiveSession(id)` rides `enqueueOperation`, serialized with create/delete; a session neither live nor persisted throws `WorkspaceUnknownSessionError`; an already archived id neither writes nor emits. The `archivedSessionIds` getter exposes the read-only set. - RPC: `workspace.archiveSession({sessionId}) → {archivedSessionIds}` (answers the full updated set); the `workspace.list` response carries the set as the reconnect baseline; a new host frame `host/archived-sessions-changed` pushes the full snapshot after every durable change (same posture as `host/workspace-changed`, emitted from the `domain/changed` global-put branch by set comparison). Unknown sessions reuse the `session-not-found` error code. -- Client runtime: `WorkspaceListState.archivedSessionIds` (a `ReadonlySet`, reference replaced only on membership change); the list baseline, the unary echo, and the changed frame each install the complete set. the projection sweep clears the current selection whenever it lands in the archive set, returning to the New Session view (user decision: archiving the open session sends the main view back to the hero) — one rule covering the local unary echo, another tab's changed frame, and a reconnect baseline restoring a selection archived while this client was away; a frame or echo landing during an in-flight `workspace.list` also shields the newer set from the stale baseline. +- Client runtime: `WorkspaceListState.archivedSessionIds` (a `readonly SessionId[]` in Host order, reference replaced only on membership change — public snapshot state stays in the store engine's plain-data vocabulary since immer drafts reject Sets without the MapSet plugin; membership lookups build a transient Set in the derivation, the expandedProjects pattern); the list baseline, the unary echo, and the changed frame each install the complete set. the projection sweep clears the current selection whenever it lands in the archive set, returning to the New Session view (user decision: archiving the open session sends the main view back to the hero) — one rule covering the local unary echo, another tab's changed frame, and a reconnect baseline restoring a selection archived while this client was away; a frame or echo landing during an in-flight `workspace.list` also shields the newer set from the stale baseline. - UI: the `delete` menu row (visual-only) becomes `archive` (label "Archive session", non-danger styling, no confirmation dialog — a non-destructive action whose worst misfire is list hiding); filtering is one extra arm in `tree.ts`'s `sessionVisible` predicate, with `deriveGroups`/`deriveFlat` taking an `archived` set parameter so all four surfaces (group loop, stray bucket, search, flat) share one source. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-31-session-archive-global-set.zh.md b/.agents/notes/implemented/feature/2026-07-31-session-archive-global-set.zh.md index 8e3157f4c5..e33f3b5272 100644 --- a/.agents/notes/implemented/feature/2026-07-31-session-archive-global-set.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-session-archive-global-set.zh.md @@ -15,7 +15,7 @@ Sidebar workspace 浏览区的 session 行菜单里,「Delete session」一直 - 存储:`archivedSessionIds: z.array(sessionId).default([])`,domain version 保持 2——纯增量字段,旧介质经 schema default 解析为空集合,无迁移代码。被归档的 session 保留其 `sessionIds` 席位(未来取消归档恢复原位置),因此与「一个 session 只被一个 workspace 记账」不变式零纠缠。 - Registry:`ctx.workspace.archiveSession(id)` 走 `enqueueOperation` 与 create/delete 串行;未知 session(实时与持久化都查不到)抛 `WorkspaceUnknownSessionError`;已归档 id 不写盘不发事件。`archivedSessionIds` getter 暴露只读集合。 - RPC:`workspace.archiveSession({sessionId}) → {archivedSessionIds}`(应答完整更新后集合);`workspace.list` 响应携带集合作为重连基线;新 host 帧 `host/archived-sessions-changed` 在每次持久变更后推完整快照(与 `host/workspace-changed` 同姿态,从 `domain/changed` 的 global put 分支比对推帧)。未知 session 复用错误码 `session-not-found`。 -- client runtime:`WorkspaceListState.archivedSessionIds`(`ReadonlySet`,成员不变不换引用);list 基线、unary 回声、changed 帧三路都整体替换安装。投影层在当前 selection 落入归档集合时统一清空回 New Session 视图(用户拍板:归档当前打开的 session 主视图回 hero)——一条规则同时覆盖本地 unary 回声、其他标签页的 changed 帧、以及重连基线恢复出一个离线期间被归档的 selection;帧/回声落在 in-flight `workspace.list` 期间时还会屏蔽旧基线对新集合的回滚。 +- client runtime:`WorkspaceListState.archivedSessionIds`(按 Host 顺序的 `readonly SessionId[]`,成员不变不换引用——公有快照状态保持 store 引擎的纯数据词汇:immer draft 不开 MapSet 插件就不接受 Set;membership 查询在派生函数内自建临时 Set,与 expandedProjects 同款);list 基线、unary 回声、changed 帧三路都整体替换安装。投影层在当前 selection 落入归档集合时统一清空回 New Session 视图(用户拍板:归档当前打开的 session 主视图回 hero)——一条规则同时覆盖本地 unary 回声、其他标签页的 changed 帧、以及重连基线恢复出一个离线期间被归档的 selection;帧/回声落在 in-flight `workspace.list` 期间时还会屏蔽旧基线对新集合的回滚。 - UI:菜单项 `delete`(visual-only)改为 `archive`(label「Archive session」,非 danger 样式,无确认对话框——非破坏性操作,误触后果只是列表隐藏);过滤实现为 `tree.ts` 的 `sessionVisible` 判据加一档,`deriveGroups`/`deriveFlat` 增加 `archived` 集合入参,四个视图(分组循环、stray 兜底、搜索、平铺)同源生效。 ## 已考虑的替代方案 diff --git a/packages/client/locale/tests/language-row.spec.tsx b/packages/client/locale/tests/language-row.spec.tsx index 223b9761b2..2908583191 100644 --- a/packages/client/locale/tests/language-row.spec.tsx +++ b/packages/client/locale/tests/language-row.spec.tsx @@ -21,7 +21,7 @@ function emptySessions() { } function emptyWorkspaces() { const store = createSnapshotStore({ - items: [], archivedSessionIds: new Set(), state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, }) return bindSnapshotSelector(store) diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index 34f1a33cc5..60d011aeb1 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/runtime/README.md -README.md: 52d2caf51d2fdaf48a06e6d56292fe087568eba5 -README.zh.md: d7e807cd7dffdd81a106488884031fccbb8a6d65 +README.md: 022dc6f82ea7aa1490144449ea61a84a512906a2 +README.zh.md: 4d0f74f573a5e03b05755cfcfea930e69ef386e2 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 52d2caf51d..022dc6f82e 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -10,7 +10,7 @@ Workspace and Session lists have independent monotone `pending` → `ready` base `WorkspacesService.delete(workspaceId)` removes the registration from the client projection after the successful unary response; the matching `host/workspace-removed` frame is idempotent and synchronizes other tabs. Session state and the current Session selection are independent, so accounted Sessions immediately project under Ungrouped after their Workspace disappears. -`WorkspaceListState.archivedSessionIds` mirrors the Host's registry-global archive set (a `ReadonlySet` replaced only when membership changes). It is full-snapshot state: the `workspace.list` baseline, the `archiveSession` unary echo, and the `host/archived-sessions-changed` frame each install the complete set. `WorkspacesService.archiveSession(sessionId)` archives over the wire; the projection sweep clears the current selection into the New Session view state whenever it lands in the archive set — one rule covering the local echo, another tab's frame, and a reconnect baseline restoring a selection archived while this client was away. A set installed while a `workspace.list` request is in flight also supersedes that stale baseline's set. Grouping surfaces hide members everywhere while the session rows stay in the list store. +`WorkspaceListState.archivedSessionIds` mirrors the Host's registry-global archive set (a `readonly SessionId[]` in Host order, replaced only when membership changes; consumers needing O(1) lookups build a transient Set). It is full-snapshot state: the `workspace.list` baseline, the `archiveSession` unary echo, and the `host/archived-sessions-changed` frame each install the complete set. `WorkspacesService.archiveSession(sessionId)` archives over the wire; the projection sweep clears the current selection into the New Session view state whenever it lands in the archive set — one rule covering the local echo, another tab's frame, and a reconnect baseline restoring a selection archived while this client was away. A set installed while a `workspace.list` request is in flight also supersedes that stale baseline's set. Grouping surfaces hide members everywhere while the session rows stay in the list store. SlotsService gives the renderer separate bare observables for `useSessions` and `useWorkspaces`; web-react creates the hooks. Workspace business state does not enter `SessionListState` or an entry store. diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index d7e807cd7d..4d0f74f573 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -10,7 +10,7 @@ Workspace 和 Session 列表各自具有单调的 `pending` → `ready` 基线 `WorkspacesService.delete(workspaceId)` 在一元响应成功后从客户端投影中移除注册记录;对应的 `host/workspace-removed` 帧具有幂等性,并负责同步其他标签页。Session 状态与当前 Session selection 相互独立,因此 Workspace 消失后,其已纳入客户端投影的 Session 会立即投影到 Ungrouped 下。 -`WorkspaceListState.archivedSessionIds` 镜像 Host 的注册表级全局归档集合(一个 `ReadonlySet`,仅在成员变化时才替换)。它是全快照状态:`workspace.list` 基线、`archiveSession` 一元回声和 `host/archived-sessions-changed` 帧各自安装完整集合。`WorkspacesService.archiveSession(sessionId)` 通过 wire 归档;投影层在当前 selection 落入归档集合时统一清空为 New Session 视图状态——一条规则同时覆盖本地回声、其他标签页的帧、以及重连基线恢复出一个离线期间被归档的 selection。在 `workspace.list` 请求进行中安装的集合还会取代该过期基线携带的集合。各分组视图在所有位置隐藏集合成员,而会话行本身仍留在列表 store 中。 +`WorkspaceListState.archivedSessionIds` 镜像 Host 的注册表级全局归档集合(一个按 Host 顺序的 `readonly SessionId[]`,仅在成员变化时才替换;需要 O(1) 查询的消费方自建临时 Set)。它是全快照状态:`workspace.list` 基线、`archiveSession` 一元回声和 `host/archived-sessions-changed` 帧各自安装完整集合。`WorkspacesService.archiveSession(sessionId)` 通过 wire 归档;投影层在当前 selection 落入归档集合时统一清空为 New Session 视图状态——一条规则同时覆盖本地回声、其他标签页的帧、以及重连基线恢复出一个离线期间被归档的 selection。在 `workspace.list` 请求进行中安装的集合还会取代该过期基线携带的集合。各分组视图在所有位置隐藏集合成员,而会话行本身仍留在列表 store 中。 SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 observable;web-react 创建钩子。Workspace 业务状态不会进入 `SessionListState` 或配置项 store。 diff --git a/packages/client/runtime/src/client/workspaces/manager.ts b/packages/client/runtime/src/client/workspaces/manager.ts index ba8344533a..ccf0c46fe1 100644 --- a/packages/client/runtime/src/client/workspaces/manager.ts +++ b/packages/client/runtime/src/client/workspaces/manager.ts @@ -14,8 +14,14 @@ export type WorkspaceListPhase = 'pending' | 'ready' /** Immutable workspace-list snapshot. */ export interface WorkspaceListSnapshot { items: readonly WorkspaceView[] - /** Registry-global archive set (hidden from grouping surfaces; accounting slots retained). */ - archivedSessionIds: ReadonlySet + /** + * Registry-global archive set in Host order (hidden from grouping + * surfaces; accounting slots retained). A plain array, not a Set: public + * snapshot state stays in the store engine's plain-data vocabulary + * (immer drafts reject Sets without the MapSet plugin); membership + * lookups build their own transient Set where they need one. + */ + archivedSessionIds: readonly SessionId[] state: 'idle' | 'loading' | 'error' phase: WorkspaceListPhase error: RpcError | null @@ -32,7 +38,7 @@ export class WorkspaceManager { private itemViewsCache: readonly WorkspaceView[] = [] // Full-snapshot state (list response / unary response / changed frame all // carry the complete set), so deltas never merge — installs replace. - private archivedSessionIds: ReadonlySet = new Set() + private archivedSessionIds: readonly SessionId[] = [] private state: WorkspaceListSnapshot['state'] = 'idle' private phase: WorkspaceListPhase = 'pending' private error: RpcError | null = null @@ -230,12 +236,16 @@ export class WorkspaceManager { } } - /** Replace the archive set when membership actually changed (set identity backs Object.is short-circuits). */ + /** + * Replace the archive set when membership actually changed (array identity + * backs Object.is short-circuits). Host snapshots are append-ordered, so + * positional comparison is exact, not merely heuristic. + */ private installArchived(archivedSessionIds: readonly SessionId[]): void { if (this.refreshFrames !== null) this.archivedSupersedesRefresh = true - if (archivedSessionIds.length === this.archivedSessionIds.size - && archivedSessionIds.every(id => this.archivedSessionIds.has(id))) return - this.archivedSessionIds = new Set(archivedSessionIds) + if (archivedSessionIds.length === this.archivedSessionIds.length + && archivedSessionIds.every((id, index) => id === this.archivedSessionIds[index])) return + this.archivedSessionIds = [...archivedSessionIds] this.notifier.markDirty() } diff --git a/packages/client/runtime/src/client/workspaces/service.ts b/packages/client/runtime/src/client/workspaces/service.ts index 2ef6f77867..a0a76670f2 100644 --- a/packages/client/runtime/src/client/workspaces/service.ts +++ b/packages/client/runtime/src/client/workspaces/service.ts @@ -15,11 +15,13 @@ import { WorkspaceManager, type WorkspaceListPhase } from './manager.ts' export interface WorkspaceListState { items: readonly WorkspaceView[] /** - * Registry-global archive set: grouping surfaces hide these sessions - * everywhere (workspace groups and the ungrouped bucket) while their - * session logs and workspace accounting slots remain. + * Registry-global archive set in Host order: grouping surfaces hide these + * sessions everywhere (workspace groups and the ungrouped bucket) while + * their session logs and workspace accounting slots remain. A plain array + * (store-engine vocabulary; immer drafts reject Sets) — membership lookups + * build their own transient Set. */ - archivedSessionIds: ReadonlySet + archivedSessionIds: readonly SessionId[] state: 'idle' | 'loading' | 'error' phase: WorkspaceListPhase error: RpcError | null @@ -64,7 +66,7 @@ export class WorkspacesService implements IWorkspaces { constructor(ctx: Context, private readonly api: IApiClient, private readonly sessions: SessionsPort) { this.manager = new WorkspaceManager(api) this.list = createSnapshotStore({ - items: [], archivedSessionIds: new Set(), state: 'idle', phase: 'pending', error: null, + items: [], archivedSessionIds: [], state: 'idle', phase: 'pending', error: null, baselinesReady: false, recentWorkspaceId: undefined, }) this.manager.subscribe(() => { this.project() }) @@ -101,7 +103,7 @@ export class WorkspacesService implements IWorkspaces { for (const id of sessions.ids) { const summary = sessions.byId[id] if (summary !== undefined && summary.blank && summary.cwd === workspace.path - && !archived.has(summary.id)) return summary.id + && !archived.includes(summary.id)) return summary.id } const attempt = this.sessions.create({ workspaceId }) .finally(() => { this.connecting.delete(workspaceId) }) @@ -317,7 +319,7 @@ export class WorkspacesService implements IWorkspaces { // every install path with one rule: the local unary echo, another tab's // changed frame, and a reconnect baseline restoring a persisted // selection that was archived while this client was away. - if (sessions.current !== undefined && workspace.archivedSessionIds.has(sessions.current)) { + if (sessions.current !== undefined && workspace.archivedSessionIds.includes(sessions.current)) { this.sessions.clear() } this.list.set({ diff --git a/packages/client/runtime/tests/workspaces-service.spec.ts b/packages/client/runtime/tests/workspaces-service.spec.ts index 71769a8e79..4323d7ffce 100644 --- a/packages/client/runtime/tests/workspaces-service.spec.ts +++ b/packages/client/runtime/tests/workspaces-service.spec.ts @@ -309,13 +309,13 @@ describe('WorkspacesService', () => { // Archiving a non-current session installs the unary echo and keeps the selection. await expect(workspaces.archiveSession(sid('s-idle'))).resolves.toBeUndefined() expect(api.callsOf('workspace.archiveSession')).toEqual([{ sessionId: 's-idle' }]) - expect([...workspaces.list.getSnapshot().archivedSessionIds]).toEqual(['s-idle']) + expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual(['s-idle']) expect(sessions.list.getSnapshot().current).toBe('s-open') // Archiving the current session clears it into the New Session view state. api.onWorkspaceArchiveSession = () => Promise.resolve(ok({ archivedSessionIds: [sid('s-idle'), sid('s-open')] })) await workspaces.archiveSession(sid('s-open')) - expect([...workspaces.list.getSnapshot().archivedSessionIds]).toEqual(['s-idle', 's-open']) + expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual(['s-idle', 's-open']) expect(sessions.list.getSnapshot().current).toBeUndefined() // A Host failure leaves the set and the selection untouched. @@ -323,7 +323,7 @@ describe('WorkspacesService', () => { code: 'session-not-found', message: 'no session ghost', details: { sessionId: sid('ghost') }, })) await expect(workspaces.archiveSession(sid('ghost'))).rejects.toThrow(/session-not-found/) - expect([...workspaces.list.getSnapshot().archivedSessionIds]).toEqual(['s-idle', 's-open']) + expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual(['s-idle', 's-open']) // The changed frame and the list baseline both re-install the full set. workspaces.handleHostEnvelope({ @@ -332,10 +332,10 @@ describe('WorkspacesService', () => { } as never) // Frame installs ride the notifier's microtask batch before projecting. await new Promise(resolve => setTimeout(resolve, 0)) - expect([...workspaces.list.getSnapshot().archivedSessionIds]).toEqual(['s-idle']) + expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual(['s-idle']) api.onWorkspaceList = () => Promise.resolve(ok({ items: [], archivedSessionIds: [sid('s-open')] }) as never) await workspaces.refresh() - expect([...workspaces.list.getSnapshot().archivedSessionIds]).toEqual(['s-open']) + expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual(['s-open']) }) it('clears a current archived by a remote frame and shields the set from a stale in-flight baseline', async () => { @@ -363,11 +363,11 @@ describe('WorkspacesService', () => { expect(sessions.list.getSnapshot().current).toBeUndefined() gate.resolve(ok({ items: [], archivedSessionIds: [] })) await hydration - expect([...workspaces.list.getSnapshot().archivedSessionIds]).toEqual(['s-open']) + expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual(['s-open']) // The next (fresh) baseline is authoritative again. api.onWorkspaceList = () => Promise.resolve(ok({ items: [], archivedSessionIds: [] }) as never) await workspaces.refresh() - expect([...workspaces.list.getSnapshot().archivedSessionIds]).toEqual([]) + expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual([]) }) }) diff --git a/packages/client/test-runtime/src/fixtures.ts b/packages/client/test-runtime/src/fixtures.ts index d81adf7698..e4c9ef946d 100644 --- a/packages/client/test-runtime/src/fixtures.ts +++ b/packages/client/test-runtime/src/fixtures.ts @@ -73,7 +73,7 @@ export function conversationSnapshot(sessionId: SessionId): ConversationSnapshot export function workspaceListState(): WorkspaceListState { return { items: [], - archivedSessionIds: new Set(), + archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, diff --git a/packages/client/test-runtime/src/workspaces.ts b/packages/client/test-runtime/src/workspaces.ts index d27139cd48..7e626a3660 100644 --- a/packages/client/test-runtime/src/workspaces.ts +++ b/packages/client/test-runtime/src/workspaces.ts @@ -199,11 +199,8 @@ export class TestWorkspaces implements IWorkspaces { await (stub(sessionId) as Promise) return } - // Built outside the draft: reading a Set through an immer draft needs - // the MapSet plugin, while assigning a fresh Set does not. - const next = new Set([...this.list.getSnapshot().archivedSessionIds, sessionId]) await this.update((draft) => { - draft.archivedSessionIds = next + draft.archivedSessionIds = [...draft.archivedSessionIds, sessionId] }) } } diff --git a/packages/client/test-runtime/tests/runtime.spec.tsx b/packages/client/test-runtime/tests/runtime.spec.tsx index f48e96b832..3675671f26 100644 --- a/packages/client/test-runtime/tests/runtime.spec.tsx +++ b/packages/client/test-runtime/tests/runtime.spec.tsx @@ -554,7 +554,7 @@ describe('workspaces action face', () => { // Default archive mirrors the production effect: the id joins the list // state's archive set (features render against the same snapshot). await ws.archiveSession('s1' as SessionId) - expect([...ws.list.getSnapshot().archivedSessionIds]).toEqual(['s1']) + expect(ws.list.getSnapshot().archivedSessionIds).toEqual(['s1']) expect(ws.calls.map(c => c.method)).toEqual( ['create', 'create', 'pickDirectory', 'rename', 'delete', 'openPath', 'insertSessionBefore', 'archiveSession']) @@ -573,7 +573,7 @@ describe('workspaces action face', () => { expect((await ws.insertSessionBefore('w1' as WorkspaceId, 's1' as SessionId)).sessionIds).toEqual([]) // The stub replaces the default set mutation: the set stays as-is. await ws.archiveSession('s2' as SessionId) - expect([...ws.list.getSnapshot().archivedSessionIds]).toEqual(['s1']) + expect(ws.list.getSnapshot().archivedSessionIds).toEqual(['s1']) await runtime.dispose() }) }) diff --git a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx index 16d070b04d..1f9b8b7356 100644 --- a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx @@ -128,7 +128,7 @@ async function bench(snapshot: ConversationSnapshot) { ctx.provide('sessions', sessionsFake) const workspaces = { list: createSnapshotStore({ - items: [], archivedSessionIds: new Set(), state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, }), startSession: vi.fn(), diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index b59335fa3a..ca81d225a9 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -94,7 +94,7 @@ function emptySessions() { function emptyWorkspaces() { const store = createSnapshotStore({ - items: [], archivedSessionIds: new Set(), state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, }) return bindSnapshotSelector(store) diff --git a/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx index 192470abfc..4de44d367c 100644 --- a/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx @@ -74,7 +74,7 @@ describe('render branch tails', () => { const emptyList = createSnapshotStore( { ids: [], byId: {}, current: undefined, phase: 'ready' }) const emptyWorkspaces = createSnapshotStore({ - items: [], archivedSessionIds: new Set(), state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, }) const view = render( @@ -111,7 +111,7 @@ describe('render branch tails', () => { const emptyList = createSnapshotStore( { ids: [], byId: {}, current: undefined, phase: 'ready' }) const emptyWorkspaces = createSnapshotStore({ - items: [], archivedSessionIds: new Set(), state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, }) const view = render( diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index 1974881357..e8154ca957 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -96,7 +96,7 @@ function bench(over?: BenchOptions) { ids: [], byId: {}, current: undefined, phase: 'ready', })), useWorkspaces: bindSnapshotSelector(createSnapshotStore({ - items: [], archivedSessionIds: new Set(), state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, })), useProjection: ((key: string, selector?: (v: unknown) => unknown) => diff --git a/packages/client/ui-conversation/tests/input-matrix.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.spec.tsx index 4901ec2f9f..04f20d56c6 100644 --- a/packages/client/ui-conversation/tests/input-matrix.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.spec.tsx @@ -39,7 +39,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled ids: [], byId: {}, current: undefined, phase: 'ready', })), useWorkspaces: bindSnapshotSelector(createSnapshotStore({ - items: [], archivedSessionIds: new Set(), state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, })), useProjection: (() => undefined), diff --git a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx index 167b9c96af..11ee8fa4e4 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx @@ -125,7 +125,7 @@ async function scopedBench(register?: (slash: SlashService) => void) { ids: [], byId: {}, current: undefined, phase: 'ready', })), useWorkspaces: bindSnapshotSelector(createSnapshotStore({ - items: [], archivedSessionIds: new Set(), state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, })), useProjection: (() => undefined), diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index 2669cbb8ab..3841930d2c 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -62,7 +62,7 @@ function workspace(id = 'w1'): WorkspaceView { } const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState => ({ - items, archivedSessionIds: new Set(), state: 'idle', phase: 'ready', error: null, + items, archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, }) diff --git a/packages/client/ui-conversation/tests/terminal-card.spec.tsx b/packages/client/ui-conversation/tests/terminal-card.spec.tsx index 29f246d603..71de19b152 100644 --- a/packages/client/ui-conversation/tests/terminal-card.spec.tsx +++ b/packages/client/ui-conversation/tests/terminal-card.spec.tsx @@ -429,7 +429,7 @@ describe('DetailsPanel Output section', () => { phase: 'ready', }) const workspaces = createSnapshotStore({ - items: [], archivedSessionIds: new Set(), state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, }) return render( @@ -607,7 +607,7 @@ describe('DetailsPanel Output section', () => { useSessions={bindSnapshotSelector(createSnapshotStore( { ids: [], byId: {}, current: undefined, phase: 'ready' }))} useWorkspaces={bindSnapshotSelector(createSnapshotStore({ - items: [], archivedSessionIds: new Set(), state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, }))} useInput={(() => { throw new Error('unused') })} diff --git a/packages/client/ui-layout/tests/app-frame.spec.tsx b/packages/client/ui-layout/tests/app-frame.spec.tsx index d30cd17877..11b5e48e0a 100644 --- a/packages/client/ui-layout/tests/app-frame.spec.tsx +++ b/packages/client/ui-layout/tests/app-frame.spec.tsx @@ -77,7 +77,7 @@ function mountFrame() { return sel(sessionState) }) as never const workspaceState: WorkspaceListState = { - items: [], archivedSessionIds: new Set(), state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: baselinesReady.current, recentWorkspaceId: undefined, } const element = () => ( diff --git a/packages/client/ui-theme/tests/appearance-row.spec.tsx b/packages/client/ui-theme/tests/appearance-row.spec.tsx index 00cc0e3052..d028affd69 100644 --- a/packages/client/ui-theme/tests/appearance-row.spec.tsx +++ b/packages/client/ui-theme/tests/appearance-row.spec.tsx @@ -27,7 +27,7 @@ function emptySessions() { } function emptyWorkspaces() { const store = createSnapshotStore({ - items: [], archivedSessionIds: new Set(), state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, }) return bindSnapshotSelector(store) diff --git a/packages/client/ui-trajectory/tests/views.spec.tsx b/packages/client/ui-trajectory/tests/views.spec.tsx index d31b43d3e6..1ebe8d2f61 100644 --- a/packages/client/ui-trajectory/tests/views.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.spec.tsx @@ -113,7 +113,7 @@ function emptySessions() { function emptyWorkspaces() { const store = createSnapshotStore({ - items: [], archivedSessionIds: new Set(), state: 'idle', phase: 'ready', error: null, baselinesReady: true, + items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, }) return bindSnapshotSelector(store) diff --git a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx index 5b12646a27..e5c89ce0de 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx @@ -35,7 +35,7 @@ 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[], archivedSessionIds: ReadonlySet = new Set()): WorkspaceListState => ({ +const workspaceState = (items: readonly WorkspaceView[], archivedSessionIds: readonly SessionId[] = []): WorkspaceListState => ({ items, archivedSessionIds, state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: items[0]?.workspaceId, }) @@ -149,7 +149,7 @@ describe('WorkspaceBrowser', () => { expect(archiveSession).toHaveBeenCalledWith(sid('gone-s')) // The archive-set echo hides the row in grouped mode (count included) and flat mode. - rerender(b, { useWorkspaces: hook(workspaceState([workspace('alpha', ['kept-s', 'gone-s'])], new Set([sid('gone-s')]))) }) + rerender(b, { useWorkspaces: hook(workspaceState([workspace('alpha', ['kept-s', 'gone-s'])], [sid('gone-s')])) }) expect(screen.queryByText('gone-s')).toBeNull() expect(screen.getByText('1 个会话')).toBeTruthy() fireEvent.click(screen.getByRole('button', { name: '分组方式' })) diff --git a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx index 334020b37f..b2d1479d17 100644 --- a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx @@ -32,7 +32,7 @@ const sessions: SessionListState = { ids: [], byId: {}, current: undefined, phase: 'ready', } const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState => ({ - items, archivedSessionIds: new Set(), state: 'idle', phase: 'ready', error: null, baselinesReady: true, + items, archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: items[0]?.workspaceId, }) function anchor(): { current: HTMLElement } { From d2dff405606704a9f8a6ed268b71aa15f90a661c Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 31 Jul 2026 10:48:39 +0800 Subject: [PATCH 18/20] feat(ui-workspace): blank New Session rows render no menu and no time A blank row is a provisional placeholder: nothing has happened in it, so the row verbs (rename/fork/archive) and a 'now' stamp would act on content that does not exist. The trailing cells and the hover card's time line stay off until the first prompt lands. --- .../ui-workspace/src/client/rows/Rows.tsx | 62 +++++++++++-------- .../client/ui-workspace/tests/rows.spec.tsx | 23 +++++++ 2 files changed, 58 insertions(+), 27 deletions(-) diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index a8b28f17e4..77583140c1 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -175,7 +175,9 @@ function SessionHoverContent({ node, now, t }: { node: SessionNode; now: number; return (
    {displayTitle(node, t)}
    -
    {hoverTimeLabel(node.updatedAt, now, t)}
    + {/* Same placeholder rule as the row's trailing cell: no timestamp + before the first prompt. */} + {!node.blank &&
    {hoverTimeLabel(node.updatedAt, now, t)}
    }
    {node.running ? t('status.running') : t('status.idle')} @@ -306,32 +308,38 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork > {row.running && } {title} - {timeLabel(row.updatedAt, now, t)} - - { setMenuOpen(false) }} - items={sessionMenuItems} - onSelect={(id) => { - setMenuOpen(false) - if (id === 'rename') onRename(node.id, row.title) - if (id === 'fork') onFork(node.id) - if (id === 'archive') onArchive(node.id) - }} - portal - closeOnPointerLeave - anchor={( - - )} - /> - + {/* A blank New Session row is a provisional placeholder: nothing has + happened in it yet, so a "now" timestamp and the row verbs + (rename/fork/archive) would all act on content that does not + exist — both trailing cells stay off until the first prompt. */} + {!row.blank && {timeLabel(row.updatedAt, now, t)}} + {!row.blank && ( + + { setMenuOpen(false) }} + items={sessionMenuItems} + onSelect={(id) => { + setMenuOpen(false) + if (id === 'rename') onRename(node.id, row.title) + if (id === 'fork') onFork(node.id) + if (id === 'archive') onArchive(node.id) + }} + portal + closeOnPointerLeave + anchor={( + + )} + /> + + )}
    ) return ( diff --git a/packages/client/ui-workspace/tests/rows.spec.tsx b/packages/client/ui-workspace/tests/rows.spec.tsx index ee45b8628d..c0b4959b17 100644 --- a/packages/client/ui-workspace/tests/rows.spec.tsx +++ b/packages/client/ui-workspace/tests/rows.spec.tsx @@ -157,6 +157,29 @@ describe('workspace browser rows', () => { expect(screen.queryByRole('button', { name: /工作区/ })).toBeNull() }) + it('blank New Session rows carry no menu, no time label, and no hover-card time', () => { + vi.useFakeTimers() + try { + const node: SessionNode = { + id: sid('s-blank'), title: 'ignored', blank: true, running: false, updatedAt: 0, + } + render() + // The placeholder has no content yet: no row verbs, no "now" stamp. + expect(screen.queryByRole('button', { name: /会话.*的操作/ })).toBeNull() + expect(screen.queryByText('刚刚')).toBeNull() + // The hover card keeps title + status but drops the timestamp line. + const wrapper = screen.getByRole('treeitem').parentElement as HTMLElement + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + expect(screen.getAllByText('新会话').length).toBeGreaterThanOrEqual(2) + expect(screen.getByText('空闲')).toBeTruthy() + expect(screen.queryByText('刚刚')).toBeNull() + } finally { + vi.useRealTimers() + } + }) + it('session row menu opens without opening the session and dispatches rename, fork, and archive', () => { const onOpen = vi.fn() const onRename = vi.fn() From b8b5403f0abaed29cad110eaa288c22c39232666 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 31 Jul 2026 11:59:10 +0800 Subject: [PATCH 19/20] fix: ci --- apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md | 2 +- .../tests/snapshots/lifecycle-chrome/plan-active.expected.md | 2 +- package.json | 1 + packages/client/ui-conversation/tests/web-card.spec.tsx | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md index 7024719a3a..dc4cbca241 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md @@ -16,7 +16,7 @@ - treeitem "workspace 1 session" [expanded]: - img - text: workspace 1 session - - treeitem "New Session now" [selected] + - treeitem "New Session" [selected] - button "Settings": - img - text: Settings diff --git a/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md index 15bee7afe4..7f62e80503 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md @@ -16,7 +16,7 @@ - treeitem "workspace 1 session" [expanded]: - img - text: workspace 1 session - - treeitem "New Session now" [selected] + - treeitem "New Session" [selected] - button "Settings": - img - text: Settings diff --git a/package.json b/package.json index fc315cb146..96f72d1c1c 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "test:snapshot:refresh": "DSH_SNAPSHOT=refresh vitest run --config vitest.snapshot.config.ts", "migrate:packed-session-fixtures": "tsx scripts/migrate-packed-session-fixtures.ts", "test:web": "npm run build && npm run test:web:built", + "test:web:refresh": "npm run build && DSH_SNAPSHOT=refresh vitest run --config vitest.web.config.ts", "test:web:built": "vitest run --config vitest.web.config.ts", "test:gui": "vitest run packages/client packages/host", "check:all": "tsx scripts/run-gates.ts check-all", diff --git a/packages/client/ui-conversation/tests/web-card.spec.tsx b/packages/client/ui-conversation/tests/web-card.spec.tsx index cea95e61f6..fc4a27c78b 100644 --- a/packages/client/ui-conversation/tests/web-card.spec.tsx +++ b/packages/client/ui-conversation/tests/web-card.spec.tsx @@ -190,7 +190,7 @@ describe('DetailsPanel web Output section', () => { if (selection !== null) chat.actions.select(selection) const sessions = createSnapshotStore({ ids: [], byId: {}, current: undefined, phase: 'ready' }) const workspaces = createSnapshotStore({ - items: [], state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, }) return render( From f5680ffabec9b461deb5e4b66bff14a55a2bc7af Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 31 Jul 2026 14:02:34 +0800 Subject: [PATCH 20/20] fix: ci --- packages/client/ui-conversation/tests/diff-card.spec.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/client/ui-conversation/tests/diff-card.spec.tsx b/packages/client/ui-conversation/tests/diff-card.spec.tsx index 10af914bed..e25045c9f0 100644 --- a/packages/client/ui-conversation/tests/diff-card.spec.tsx +++ b/packages/client/ui-conversation/tests/diff-card.spec.tsx @@ -287,7 +287,7 @@ describe('DetailsPanel diff Output section', () => { phase: 'ready', }) const workspaces = createSnapshotStore({ - items: [], state: 'idle', phase: 'ready', error: null, + items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, }) return render(