From 6fb226ea247f71dc867d26d42a082ef772894e27 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Tue, 11 Aug 2026 11:33:53 +0800 Subject: [PATCH 01/81] feat(loader): interpolate the entry disabled field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Windows platform layer disables tool-bash and inserts the pwsh stack, but the shipped presets each mount a tool-bash row that re-enabled the tool on win32 — the session had both a PowerShell-backed bash tool and tool-pwsh, silently, because no spec pinned the composed preset layer. The Loader now evaluates a disabled: !!js expression against the loader context at every mount decision; disabled is the only interpolated metadata field, and the raw node stays in the options so write-back keeps the !!js form. The standard/code/cordis presets gate tool-bash with process.platform === 'win32', verify-cordis-config allows expressions in disabled only, and the windows-shell spec pins the preset-level invariant. --- ...der-entry-disabled-interpolation.i18n.yaml | 6 ++ ...-11-loader-entry-disabled-interpolation.md | 23 ++++++ ...-loader-entry-disabled-interpolation.zh.md | 23 ++++++ AGENTS.md | 2 +- .../agent-presets/code/agent.cordis.yml | 6 +- .../agent-presets/cordis/agent.cordis.yml | 6 +- .../agent-presets/standard/agent.cordis.yml | 6 +- apps/cli/tests/windows-shell.spec.ts | 40 +++++++++- docs/cordis-primer.i18n.yaml | 4 +- docs/cordis-primer.md | 2 +- docs/cordis-primer.zh.md | 2 +- .../boot/app-boot/tests/user-patches.spec.ts | 68 +++++++++++++++++ scripts/verify-cordis-config.spec.ts | 23 ++++++ scripts/verify-cordis-config.ts | 76 ++++++++++++------- vendor/README.md | 1 + vendor/loader/src/config/entry.ts | 16 +++- 16 files changed, 261 insertions(+), 43 deletions(-) create mode 100644 .agents/notes/implemented/process/2026-08-11-loader-entry-disabled-interpolation.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-08-11-loader-entry-disabled-interpolation.md create mode 100644 .agents/notes/implemented/process/2026-08-11-loader-entry-disabled-interpolation.zh.md create mode 100644 scripts/verify-cordis-config.spec.ts diff --git a/.agents/notes/implemented/process/2026-08-11-loader-entry-disabled-interpolation.i18n.yaml b/.agents/notes/implemented/process/2026-08-11-loader-entry-disabled-interpolation.i18n.yaml new file mode 100644 index 0000000000..1d51763b47 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-11-loader-entry-disabled-interpolation.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/process/2026-08-11-loader-entry-disabled-interpolation.md +2026-08-11-loader-entry-disabled-interpolation.md: d2ddbc7d00c8f3e493912f826ee573f355b3b71d +2026-08-11-loader-entry-disabled-interpolation.zh.md: cc139202e4ea79dfbae1c89cef5d7eda4ab2011f diff --git a/.agents/notes/implemented/process/2026-08-11-loader-entry-disabled-interpolation.md b/.agents/notes/implemented/process/2026-08-11-loader-entry-disabled-interpolation.md new file mode 100644 index 0000000000..d2ddbc7d00 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-11-loader-entry-disabled-interpolation.md @@ -0,0 +1,23 @@ +# Agent Note: Loader interpolates the entry `disabled` field + +Status: implemented + +English | [中文](2026-08-11-loader-entry-disabled-interpolation.zh.md) + +## Problem + +The Windows platform layer (`packages/bundle/base/windows.cordis.patch.yml`) disables `tool-bash` on win32, but the shipped presets each mount a `tool-bash` row. Preset rows compose last, so the same-id row re-enabled the tool on Windows — the session had both `tool-bash` (PowerShell-backed) and `tool-pwsh`, silently, because no spec pinned the composed preset layer. Entry metadata had no conditional mechanism: `!!js` interpolates only under plugin `config`, and [postmortem 0002](../../../../docs/postmortem/0002-js-expression-disabled-filesystem-tools.md) documents that `disabled: !!js ...` stays a truthy expression object, disabling the row everywhere. + +## Decision + +The Loader interpolates the entry `disabled` field (`vendor/loader/src/config/entry.ts`): a `!!js` expression evaluates against the loader context at every mount decision. `disabled` is the only interpolated metadata field; `id`, `name`, `group`, and `inject` stay static. The raw node stays in the options, so write-back keeps the `!!js` form. The shipped presets (standard, code, cordis) gate `tool-bash` with `disabled: !!js process.platform === 'win32'`, and `verify-cordis-config` now allows expressions in `disabled` only. + +## Alternatives considered + +**A declarative `platform` field on the row.** Static and gate-checkable, but a second composition mechanism beside `!!js`, and platform is only today's condition. + +**Preset-level platform overlays.** Rejected: the condition belongs on the row it governs. + +## Consequences + +A row can gate itself on platform or environment; a bad expression fails loud at boot. Every other metadata field remains literal and the gate keeps rejecting expressions there — the postmortem-0002 hazard is closed for `disabled` by evaluation, not prohibition. The `minimal` preset's missing win32 PTY stack is a preset-metadata follow-up. diff --git a/.agents/notes/implemented/process/2026-08-11-loader-entry-disabled-interpolation.zh.md b/.agents/notes/implemented/process/2026-08-11-loader-entry-disabled-interpolation.zh.md new file mode 100644 index 0000000000..cc139202e4 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-11-loader-entry-disabled-interpolation.zh.md @@ -0,0 +1,23 @@ +# Agent Note:Loader 插值条目 `disabled` 字段 + +Status: implemented + +[English](2026-08-11-loader-entry-disabled-interpolation.md) | 中文 + +## 问题 + +Windows 平台层(`packages/bundle/base/windows.cordis.patch.yml`)在 win32 上禁用 `tool-bash`,但 shipped 预设各自挂载了一行 `tool-bash`。预设行最后组合,同名行在 Windows 上重新启用了该工具——会话同时拥有 `tool-bash`(PowerShell 后端)与 `tool-pwsh`,且是静默的,因为没有 spec pin 组合后的预设层。条目元数据没有条件机制:`!!js` 只在插件 `config` 下插值,[postmortem 0002](../../../../docs/postmortem/0002-js-expression-disabled-filesystem-tools.md) 记录了 `disabled: !!js ...` 保持真值表达式对象、在所有平台上禁用该行的事故。 + +## 决策 + +Loader 插值条目 `disabled` 字段(`vendor/loader/src/config/entry.ts`):`!!js` 表达式在每次挂载决策时基于 loader 上下文求值。`disabled` 是唯一被插值的元数据字段;`id`、`name`、`group`、`inject` 保持静态。原始节点保留在 options 中,写回保持 `!!js` 形式。shipped 预设(standard、code、cordis)用 `disabled: !!js process.platform === 'win32'` 门控 `tool-bash`,`verify-cordis-config` 现在只允许 `disabled` 中的表达式。 + +## 备选方案 + +**行上的声明式 `platform` 字段。** 静态且可被门禁检查,但它是 `!!js` 之外的第二种组合机制,且平台只是今天的条件。 + +**预设级平台 overlay。** 被否:条件应当属于它所治理的行。 + +## 后果 + +行可以按平台或环境门控自身;错误的表达式在启动时响亮失败。其余元数据字段保持字面值,门禁继续拒绝那里的表达式——`disabled` 上的 postmortem-0002 隐患以「求值」而非「禁止」关闭。`minimal` 预设缺失的 win32 PTY 栈是预设元数据的后续工作。 diff --git a/AGENTS.md b/AGENTS.md index adc14858d7..7452c1df83 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,7 +92,7 @@ Run checks before pushes via [dsh-pre-push-checks](.agents/skills/dsh-pre-push-c ## Secrets / .env -Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, and root `.env`. cordis.yml allows `!!js` (never `!js`) only under plugin `config`; Loader metadata is static, so conditional composition uses overlays ([primer](docs/cordis-primer.md#loader-configuration)). Never commit credentials. CI e2e skips without a key; [testing.md](docs/testing.md) owns key policy. +Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, and root `.env`. cordis.yml allows `!!js` (never `!js`) under plugin `config` and entry `disabled`; other metadata stays literal, so conditional composition also uses overlays ([primer](docs/cordis-primer.md#loader-configuration)). Never commit credentials. CI e2e skips without a key; [testing.md](docs/testing.md) owns key policy. ## Conventions diff --git a/apps/cli/config/agent-presets/code/agent.cordis.yml b/apps/cli/config/agent-presets/code/agent.cordis.yml index a4b01eddb7..ffcad54e59 100644 --- a/apps/cli/config/agent-presets/code/agent.cordis.yml +++ b/apps/cli/config/agent-presets/code/agent.cordis.yml @@ -46,10 +46,12 @@ # the criterion for host-plane ownership — injection resolves before any session # exists, so there is no agent to key by. Behind a preset realm those variables # never reached the model's shell at all. `tool-bash` consumes the host registry -# from here; the executor behind it (`bash-sandbox`) is host-plane too, where the -# sandbox policy owns it. +# from here; the executor behind it is host-plane too, where the sandbox policy +# owns it. - id: tool-bash name: '@deepseek-ai/dsh-tool-bash' + # POSIX-only: the Windows platform layer swaps the bash stack for the pwsh stack. + disabled: !!js process.platform === 'win32' # ── filesystem ────────────────────────────────────────────────────────────── diff --git a/apps/cli/config/agent-presets/cordis/agent.cordis.yml b/apps/cli/config/agent-presets/cordis/agent.cordis.yml index 4c296659af..4afda44ad1 100644 --- a/apps/cli/config/agent-presets/cordis/agent.cordis.yml +++ b/apps/cli/config/agent-presets/cordis/agent.cordis.yml @@ -40,10 +40,12 @@ # the criterion for host-plane ownership — injection resolves before any session # exists, so there is no agent to key by. Behind a preset realm those variables # never reached the model's shell at all. `tool-bash` consumes the host registry -# from here; the executor behind it (`bash-sandbox`) is host-plane too, where the -# sandbox policy owns it. +# from here; the executor behind it is host-plane too, where the sandbox policy +# owns it. - id: tool-bash name: '@deepseek-ai/dsh-tool-bash' + # POSIX-only: the Windows platform layer swaps the bash stack for the pwsh stack. + disabled: !!js process.platform === 'win32' # ── filesystem ────────────────────────────────────────────────────────────── diff --git a/apps/cli/config/agent-presets/standard/agent.cordis.yml b/apps/cli/config/agent-presets/standard/agent.cordis.yml index 5e22f5da11..86c8a2b724 100644 --- a/apps/cli/config/agent-presets/standard/agent.cordis.yml +++ b/apps/cli/config/agent-presets/standard/agent.cordis.yml @@ -39,10 +39,12 @@ # the criterion for host-plane ownership — injection resolves before any session # exists, so there is no agent to key by. Behind a preset realm those variables # never reached the model's shell at all. `tool-bash` consumes the host registry -# from here; the executor behind it (`bash-sandbox`) is host-plane too, where the -# sandbox policy owns it. +# from here; the executor behind it is host-plane too, where the sandbox policy +# owns it. - id: tool-bash name: '@deepseek-ai/dsh-tool-bash' + # POSIX-only: the Windows platform layer swaps the bash stack for the pwsh stack. + disabled: !!js process.platform === 'win32' # ── filesystem ────────────────────────────────────────────────────────────── diff --git a/apps/cli/tests/windows-shell.spec.ts b/apps/cli/tests/windows-shell.spec.ts index 569ba91b34..2dcd34cf91 100644 --- a/apps/cli/tests/windows-shell.spec.ts +++ b/apps/cli/tests/windows-shell.spec.ts @@ -1,8 +1,11 @@ import { afterEach, describe, expect, it } from 'vitest' import { mkdtempSync, writeFileSync, rmSync, mkdirSync, readFileSync } from 'node:fs' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' +import yaml from 'js-yaml' +import { entryListSchema } from '@deepseek-ai/cordis-plugin-include' +import { evaluate } from '@deepseek-ai/cordis-plugin-loader' import type { ProfileLayer } from '@deepseek-ai/dsh-app-boot' import { composeEntries, initProfile, loadProfile, PROFILES_DIR } from '@deepseek-ai/dsh-app-boot' import { @@ -137,3 +140,38 @@ describe('the shipped Windows composition (real bundle layers)', () => { expect(baseWarnings).toEqual([]) }) }) + +describe('shipped agent presets keep tool-bash off the win32 roster', () => { + const presetRoot = resolve(fileURLToPath(new URL('../package.json', import.meta.url)), '..', 'config', 'agent-presets') + + it.each(['standard', 'code', 'cordis'])('preset %s gates its tool-bash row by platform', (preset) => { + const entries: unknown = yaml.load( + readFileSync(join(presetRoot, preset, 'agent.cordis.yml'), 'utf8'), + { schema: entryListSchema }, + ) + if (!Array.isArray(entries)) throw new TypeError(`preset ${preset} must parse to an entry array`) + const row = entries.find((entry): entry is Record => ( + typeof entry === 'object' && entry !== null && (entry as Record).id === 'tool-bash' + )) + if (row === undefined) throw new TypeError(`preset ${preset} must mount tool-bash`) + expect(row.disabled).toMatchObject({ __jsExpr: expect.any(String) as string }) + // The platform patch disables the host's tool-bash row on win32; the + // preset row must not re-enable it there. Evaluate the shipped expression + // with a platform-scoped context (the `with` scope shadows the global + // `process`) so both outcomes pin on every host. + const expression = (row.disabled as { __jsExpr: string }).__jsExpr + expect(Boolean(evaluate({ process: { platform: 'win32' } }, expression))).toBe(true) + expect(Boolean(evaluate({ process: { platform: 'linux' } }, expression))).toBe(false) + }) + + it('minimal mounts no tool-bash row at all (its shell is the PTY stack)', () => { + const entries: unknown = yaml.load( + readFileSync(join(presetRoot, 'minimal', 'agent.cordis.yml'), 'utf8'), + { schema: entryListSchema }, + ) + if (!Array.isArray(entries)) throw new TypeError('minimal preset must parse to an entry array') + expect(entries.some(entry => ( + typeof entry === 'object' && entry !== null && (entry as Record).id === 'tool-bash' + ))).toBe(false) + }) +}) diff --git a/docs/cordis-primer.i18n.yaml b/docs/cordis-primer.i18n.yaml index 180ba85c01..be2ce7bbe8 100644 --- a/docs/cordis-primer.i18n.yaml +++ b/docs/cordis-primer.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cordis-primer.md -cordis-primer.md: d1e7c5fd8eaaa89fe448d238359389d945cd6346 -cordis-primer.zh.md: d6ce0f2024f65b006c9505daffaa06a08bb56875 +cordis-primer.md: c57055e9657ebc8a0c3f537825ddcbdda1ced68a +cordis-primer.zh.md: 45cce2abb2117aef44028ab53a9836d24fab91d6 diff --git a/docs/cordis-primer.md b/docs/cordis-primer.md index d1e7c5fd8e..c57055e965 100644 --- a/docs/cordis-primer.md +++ b/docs/cordis-primer.md @@ -35,7 +35,7 @@ For single-decision events, short-circuiting is the design. A policy listener ca ## Loader Configuration -`@deepseek-ai/cordis-plugin-include` parses `!!js` into expression nodes. Loader interpolates only an entry's `config`, after declared injections activate, against that plugin context (`ctx.serviceName`); Include preserves nested row expressions until target activation. Entry metadata (`id`, `name`, `group`, `disabled`, `inject`, `intercept`, `isolate`) stays literal, so `disabled: !!js ...` always disables the entry. Use overlays when the environment selects plugins. +`@deepseek-ai/cordis-plugin-include` parses `!!js` into expression nodes. Loader interpolates an entry's `config` (after declared injections activate, against that plugin context — `ctx.serviceName`) and its `disabled` field (at every mount decision, against the loader context); Include preserves nested row expressions until target activation. Other entry metadata stays literal. Use overlays when the environment selects plugins. ## Practical Rules diff --git a/docs/cordis-primer.zh.md b/docs/cordis-primer.zh.md index d6ce0f2024..45cce2abb2 100644 --- a/docs/cordis-primer.zh.md +++ b/docs/cordis-primer.zh.md @@ -39,7 +39,7 @@ Cordis 是 DeepSeek Harness SDK 底层以 vendor 方式引入的插件框架。 ## Loader 配置 -`@deepseek-ai/cordis-plugin-include` 将 `!!js` 解析为表达式节点。Loader 只在声明的注入激活后,基于该插件上下文(`ctx.serviceName`)插值条目的 `config`;Include 会保留嵌套行表达式,直到目标行激活。条目元数据(`id`、`name`、`group`、`disabled`、`inject`、`intercept`、`isolate`)保持字面值,因此 `disabled: !!js ...` 始终禁用该条目。由环境选择插件时,请使用 overlay。 +`@deepseek-ai/cordis-plugin-include` 将 `!!js` 解析为表达式节点。Loader 在声明的注入激活后,基于该插件上下文(`ctx.serviceName`)插值条目的 `config`,并在每次挂载决策时基于 loader 上下文插值其 `disabled` 字段;Include 会保留嵌套行表达式,直到目标行激活。其余条目元数据保持字面值。由环境选择插件时,请使用 overlay。 ## 实践规则 diff --git a/packages/boot/app-boot/tests/user-patches.spec.ts b/packages/boot/app-boot/tests/user-patches.spec.ts index da58524e1d..61a2544f7e 100644 --- a/packages/boot/app-boot/tests/user-patches.spec.ts +++ b/packages/boot/app-boot/tests/user-patches.spec.ts @@ -187,6 +187,74 @@ describe('Loader config interpolation', () => { }) }) +describe('Loader entry disabled interpolation', () => { + it('evaluates a !!js disabled expression against the loader context', async () => { + const dir = tmp() + writeFileSync(join(dir, 'noop.mjs'), 'export function apply() {}\n') + writeFileSync(join(dir, 'cordis.yml'), [ + '- id: expr-off', + ' name: ./noop.mjs', + ' disabled: !!js process.version.length > 0', + '- id: expr-on', + ' name: ./noop.mjs', + ' disabled: !!js process.version.length === 0', + '', + ].join('\n')) + const ctx = await boot(NAME, join(dir, 'cordis.yml')) + try { + const off = [...ctx.loader.entries()].find(entry => entry.options.id === 'expr-off') + const on = [...ctx.loader.entries()].find(entry => entry.options.id === 'expr-on') + expect(off?.disabled).toBe(true) + expect(off?.fiber).toBeUndefined() + expect(on?.disabled).toBe(false) + expect(on?.fiber).toBeDefined() + } finally { + await ctx.fiber.dispose() + } + }) + + it('keeps the raw expression in the options so write-back preserves the !!js form', async () => { + const dir = tmp() + writeFileSync(join(dir, 'noop.mjs'), 'export function apply() {}\n') + writeFileSync(join(dir, 'cordis.yml'), '- id: expr\n name: ./noop.mjs\n disabled: !!js process.platform === "win32"\n') + const ctx = await boot(NAME, join(dir, 'cordis.yml')) + try { + const entry = [...ctx.loader.entries()].find(item => item.options.id === 'expr') + // The evaluated boolean drives the mount decision; the serialized + // expression node stays in the options for the file-backed tree. + expect(entry?.options.disabled).toEqual({ __jsExpr: 'process.platform === "win32"' }) + expect(entry?.disabled).toBe(process.platform === 'win32') + } finally { + await ctx.fiber.dispose() + } + }) + + it('re-evaluates when update() replaces the expression, mounting and unmounting', async () => { + const dir = tmp() + writeFileSync(join(dir, 'noop.mjs'), 'export function apply() {}\n') + writeFileSync(join(dir, 'cordis.yml'), '- id: expr\n name: ./noop.mjs\n disabled: !!js process.version.length === 0\n') + const ctx = await boot(NAME, join(dir, 'cordis.yml')) + try { + const entry = [...ctx.loader.entries()].find(item => item.options.id === 'expr') + expect(entry?.disabled).toBe(false) + expect(entry?.fiber).toBeDefined() + // The expression form is the file dialect; the typed programmatic API + // carries booleans. Include reapplication feeds the raw node through + // the untyped file path — simulated here with the serialized shape. + const disabledTrue = { __jsExpr: 'process.version.length > 0' } as unknown as boolean + const disabledFalse = { __jsExpr: 'process.version.length === 0' } as unknown as boolean + await entry?.update({ disabled: disabledTrue }) + expect(entry?.disabled).toBe(true) + expect(entry?.fiber).toBeUndefined() + await entry?.update({ disabled: disabledFalse }) + expect(entry?.disabled).toBe(false) + expect(entry?.fiber).toBeDefined() + } finally { + await ctx.fiber.dispose() + } + }) +}) + describe('boot with user patches', () => { it('applies id-targeted overrides, inserts, and interpolates !!js from the environment', async () => { const dir = tmp() diff --git a/scripts/verify-cordis-config.spec.ts b/scripts/verify-cordis-config.spec.ts new file mode 100644 index 0000000000..bfc4f10169 --- /dev/null +++ b/scripts/verify-cordis-config.spec.ts @@ -0,0 +1,23 @@ +/** + * The verify-cordis-config metadata contract: `disabled` is the one entry + * metadata field whose `!!js` expression the Loader interpolates; every other + * metadata field must stay static, and a disabled expression must parse. + */ + +import { describe, expect, it } from 'vitest' +import { metadataExpressionErrors } from './verify-cordis-config.ts' + +describe('verify-cordis-config metadata expressions', () => { + it('accepts a disabled !!js expression', () => { + const problems = metadataExpressionErrors( + { id: 'tool-bash', name: '@deepseek-ai/dsh-tool-bash', disabled: { __jsExpr: "process.platform === 'win32'" } }, + '[0]', + ) + expect(problems).toEqual([]) + }) + + it('rejects an expression in a static metadata field', () => { + const problems = metadataExpressionErrors({ id: { __jsExpr: 'process.platform' }, name: 'pkg' }, '[0]') + expect(problems).toContain('[0].id: !!js is not interpolated here') + }) +}) diff --git a/scripts/verify-cordis-config.ts b/scripts/verify-cordis-config.ts index a70dae4d1c..0eb2c3571a 100644 --- a/scripts/verify-cordis-config.ts +++ b/scripts/verify-cordis-config.ts @@ -1,11 +1,13 @@ /** * Validate Cordis Loader entry metadata and package resolution. * - * The Loader interpolates only a plugin entry's `config`; expression objects in - * fields such as `disabled` remain truthy data and silently change composition. - * Example configs and the dsh Web composition resolve named plugins from their - * owning workspace manifests. Local example packages must also be in the root - * TypeScript project graph. + * The Loader interpolates a plugin entry's `config` and the entry `disabled` + * field (both evaluate against the loader context; `disabled` at tree build). + * Every other entry metadata field stays static, so an expression there + * remains truthy data and silently changes composition. Example configs and + * the dsh Web composition resolve named plugins from their owning workspace + * manifests. Local example packages must also be in the root TypeScript + * project graph. */ import { globSync, readFileSync } from 'node:fs' @@ -35,7 +37,7 @@ const appOverlayFiles = new Set([ 'examples/web-cordis/cordis.yml', ...globSync('examples/mcp-memory/*.cordis.yml', { cwd: root }), ]) -const metadataFields = ['id', 'name', 'group', 'disabled', 'inject', 'intercept', 'isolate'] as const +const metadataFields = ['id', 'name', 'group', 'inject', 'intercept', 'isolate'] as const /** The adaptive directory-picker chooser package (mounts a backend row at boot). */ const CHOOSER_PACKAGE = '@deepseek-ai/dsh-host-directory-picker-auto' @@ -60,32 +62,35 @@ const jsExprType = new yaml.Type('tag:yaml.org,2002:js', { }) const schema = yaml.JSON_SCHEMA.extend(jsExprType) -const files = cordisConfigFiles(root) const errors: string[] = [] const pluginReferences: PluginReference[] = [] -for (const file of files) { - const document: unknown = yaml.load(readFileSync(resolve(root, file), 'utf8'), { schema }) - if (!isUnknownArray(document)) { - errors.push(`${file}: root must be a Loader entry array`) - continue - } - for (let index = 0; index < document.length; index++) { - validateEntry(document[index], file, `[${index}]`) - } -} +if (import.meta.main) { + const files = cordisConfigFiles(root) -errors.push(...validateExampleResolution()) -errors.push(...validateAppResolution()) -errors.push(...validateSourcePlaneResolution()) -errors.push(...validatePresetPlaneSeparation()) + for (const file of files) { + const document: unknown = yaml.load(readFileSync(resolve(root, file), 'utf8'), { schema }) + if (!isUnknownArray(document)) { + errors.push(`${file}: root must be a Loader entry array`) + continue + } + for (let index = 0; index < document.length; index++) { + validateEntry(document[index], file, `[${index}]`) + } + } -if (errors.length > 0) { - console.error('verify-cordis-config: invalid Loader metadata or plugin package resolution:') - for (const error of errors) console.error(`- ${error}`) - process.exitCode = 1 -} else { - console.log(`verify-cordis-config: ${files.length} config files passed.`) + errors.push(...validateExampleResolution()) + errors.push(...validateAppResolution()) + errors.push(...validateSourcePlaneResolution()) + errors.push(...validatePresetPlaneSeparation()) + + if (errors.length > 0) { + console.error('verify-cordis-config: invalid Loader metadata or plugin package resolution:') + for (const error of errors) console.error(`- ${error}`) + process.exitCode = 1 + } else { + console.log(`verify-cordis-config: ${files.length} config files passed.`) + } } /** @@ -375,12 +380,27 @@ function packageNameFromSpecifier(specifier: string): string | undefined { } function validateMetadata(entry: Record, file: string, path: string): void { + for (const problem of metadataExpressionErrors(entry, path)) { + errors.push(`${file}${problem}`) + } +} + +/** + * Expression-node diagnostics for one entry. `disabled` is the single + * interpolated metadata field; every other metadata field must stay static. + * @param entry - one loader entry (or patch row). + * @param path - the entry's diagnostic path prefix. + * @returns one diagnostic per offending expression. + */ +export function metadataExpressionErrors(entry: Record, path: string): string[] { + const problems: string[] = [] for (const field of metadataFields) { if (!(field in entry)) continue const expressionPaths: string[] = [] collectExpressionPaths(entry[field], `${path}.${field}`, expressionPaths) - for (const expressionPath of expressionPaths) errors.push(`${file}${expressionPath}: !!js is not interpolated here`) + for (const expressionPath of expressionPaths) problems.push(`${expressionPath}: !!js is not interpolated here`) } + return problems } function collectExpressionPaths(value: unknown, path: string, output: string[]): void { diff --git a/vendor/README.md b/vendor/README.md index 0d889e87fd..7a792d4eaa 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -48,6 +48,7 @@ Keep this log exhaustive — every divergence from upstream must be listed. 16. **In-memory Loader entry activation in `loader/src/config/entry.ts`**: an invocation can activate a row shipped with `disabled: true` without mutating its serialized options. The override belongs to the mounted entry object, survives Include config reapplication, respects disabled ancestors, and disappears with the entry. Covered by `packages/boot/cmdline/tests/cmdline.spec.ts` and `apps/web/tests/hmr-live.e2e.ts`. 17. **`@deepseek-ai` rescope**: every vendored manifest `name`, every internal dependency entry among the vendored set, and every module specifier that reaches them use the scoped names in the manifest table's `npm name` column. Directory names, version numbers, and dependency ranges are unchanged, and no upstream runtime identifier is renamed — `Symbol.for('schemastery')` and Schemastery's `vendor:` metadata field keep their upstream values. Re-apply with `pnpm run rescope-vendor --apply` after a sync; the table's two name columns are the mapping, restated for consumers in [docs/rescope.md](../docs/rescope.md). 18. **`cordis/package.json` publishes `src`**: added `src` to the `files` list, joining the other eight vendored packages. Cordis declares `"./src/*": "./src/*"` in its exports, so a tarball without `src` publishes an export map pointing at absent files; the release change judgement also reads `files` to decide whether a diff reaches the payload, and a package whose only published paths are build output has no tracked path to match. +19. **Entry `disabled` interpolation in `loader/src/config/entry.ts`**: a `disabled: !!js` expression evaluates against the loader context at every mount decision; the raw node stays in the options, so write-back keeps the `!!js` form. `disabled` is the only interpolated metadata field. Covered by `packages/boot/app-boot/tests/user-patches.spec.ts` and `apps/cli/tests/windows-shell.spec.ts`. ## Sync procedure diff --git a/vendor/loader/src/config/entry.ts b/vendor/loader/src/config/entry.ts index 3fc74177f9..13b7d46766 100644 --- a/vendor/loader/src/config/entry.ts +++ b/vendor/loader/src/config/entry.ts @@ -3,7 +3,7 @@ import { deepEqual, isNullable } from '@deepseek-ai/cosmokit' import { Loader } from '../index.ts' import { EntryGroup } from './group.ts' import { EntryTree } from './tree.ts' -import { evaluate } from './utils.ts' +import { evaluate, isJsExpr } from './utils.ts' /** Static plugin hook for resolving a container config while preserving nested entry configs. */ export const EntryConfigResolver = Symbol.for('cordis.loader.entry-config-resolver') @@ -101,15 +101,25 @@ export class Entry { private _disabled(options: EntryOptions) { // group is always enabled if (options.group) return false - if (options.disabled && !this.runtimeEnabled) return true + if (this.disabledOf(options) && !this.runtimeEnabled) return true let entry = this.parent.ctx.fiber.entry while (entry) { - if (entry.options.disabled && !entry.runtimeEnabled) return true + if (this.disabledOf(entry.options) && !entry.runtimeEnabled) return true entry = entry.parent.ctx.fiber.entry } return false } + /** + * Effective disabled state: a `!!js` expression evaluates against the loader + * context. The raw node stays in the options, so write-back keeps the form. + */ + private disabledOf(options: EntryOptions): boolean { + return isJsExpr(options.disabled) + ? Boolean(this.evaluate(options.disabled.__jsExpr)) + : Boolean(options.disabled) + } + /** * Enable this in-memory entry without rewriting its configured `disabled` * value; the override survives config reapplication for this entry object. From 4308f91e8894a4b279a0905abf4b7f4cbbd2b16e Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Tue, 11 Aug 2026 15:11:45 +0800 Subject: [PATCH 02/81] refactor(bundle): fold the Windows shell platform layer into the base rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Entry \disabled\ interpolation makes the launcher's separate platform layer unnecessary: the base bundle's cordis.patch.yml now gates both shell stacks on its own rows — bash-sandbox/tool-bash disable on win32, and their twins pwsh-sandbox/tool-pwsh mount only there with the inverted expression — so exactly one shell stack mounts per host from one shared patch file. windows.cordis.patch.yml and the launcher's windows-shell.ts injection (boot, live recomposition, config dumps) are deleted, with the workspace-constraints entry and the dsh-base exports/files entries following. The windows-shell spec pins the effective per-platform roster through the real bundle layers, and base.spec pins the four symmetric gates. The superseded active notes are updated and cross-linked; the loader note records the fold itself. --- ...26-07-20-dsh-cli-personal-config.i18n.yaml | 4 +- .../2026-07-20-dsh-cli-personal-config.md | 2 +- .../2026-07-20-dsh-cli-personal-config.zh.md | 2 +- ...7-26-code-dispatch-ui-foundation.i18n.yaml | 4 +- .../2026-07-26-code-dispatch-ui-foundation.md | 2 +- ...26-07-26-code-dispatch-ui-foundation.zh.md | 2 +- .../2026-08-01-windows-pwsh-default.i18n.yaml | 4 +- .../2026-08-01-windows-pwsh-default.md | 16 +- .../2026-08-01-windows-pwsh-default.zh.md | 16 +- ...der-entry-disabled-interpolation.i18n.yaml | 4 +- ...-11-loader-entry-disabled-interpolation.md | 8 +- ...-loader-entry-disabled-interpolation.zh.md | 8 +- apps/cli/composition.md | 6 + .../agent-presets/code/agent.cordis.yml | 6 +- .../agent-presets/cordis/agent.cordis.yml | 6 +- .../agent-presets/standard/agent.cordis.yml | 6 +- apps/cli/src/dump-config.ts | 7 - apps/cli/src/profile-boot.ts | 18 +- apps/cli/src/windows-shell.ts | 52 ------ apps/cli/tests/windows-shell.spec.ts | 166 +++++++----------- packages/bundle/base/README.md | 2 +- packages/bundle/base/README.zh.md | 2 +- packages/bundle/base/cordis.patch.yml | 17 ++ packages/bundle/base/package.json | 2 - packages/bundle/base/tests/base.spec.ts | 58 +++--- packages/bundle/base/windows.cordis.patch.yml | 31 ---- scripts/check-workspace-constraints.ts | 5 +- 27 files changed, 173 insertions(+), 283 deletions(-) delete mode 100644 apps/cli/src/windows-shell.ts delete mode 100644 packages/bundle/base/windows.cordis.patch.yml diff --git a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml index 9d13851a0d..4ec2811550 100644 --- a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.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-20-dsh-cli-personal-config.md -2026-07-20-dsh-cli-personal-config.md: ed04725e92848bbab550a27ef2f4c021536f765e -2026-07-20-dsh-cli-personal-config.zh.md: cc97987f803f7fb513e94ce0ce079558f5e3dc75 +2026-07-20-dsh-cli-personal-config.md: c910d28e2c616b1348e95d0a8731a9b47fc03edf +2026-07-20-dsh-cli-personal-config.zh.md: 161169af11c2c054ac5b1ae3df74fe963e57aec5 diff --git a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md index ed04725e92..c910d28e2c 100644 --- a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md +++ b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md @@ -6,7 +6,7 @@ English | [中文](2026-07-20-dsh-cli-personal-config.zh.md) ## Problem -A developer's own preferences — which provider and model the TUI uses, personal credentials, a private adapter route — had nowhere to live except edits to committed files. Pointing the TUI demo at a personal Anthropic-proxy Opus route meant patching `examples/tui-agent/cordis.yml` and `.env` in the working tree, which risks committing secrets and repeats per checkout. There was also no installable command: running the agent in an arbitrary project directory required invoking the repo's demo script from the repo root. Loader metadata is static, so "conditional composition uses overlays" (AGENTS.md) — but overlays only existed as committed sibling files, not as a machine-level layer. +A developer's own preferences — which provider and model the TUI uses, personal credentials, a private adapter route — had nowhere to live except edits to committed files. Pointing the TUI demo at a personal Anthropic-proxy Opus route meant patching `examples/tui-agent/cordis.yml` and `.env` in the working tree, which risks committing secrets and repeats per checkout. There was also no installable command: running the agent in an arbitrary project directory required invoking the repo's demo script from the repo root. Loader metadata is static except the entry `disabled` field (see the [loader `disabled` interpolation decision](../process/2026-08-11-loader-entry-disabled-interpolation.md)), so "conditional composition uses overlays" (AGENTS.md) — but overlays only existed as committed sibling files, not as a machine-level layer. ## Decision diff --git a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md index cc97987f80..161169af11 100644 --- a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md +++ b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md @@ -6,7 +6,7 @@ Status: implemented ## Problem -开发者自己的偏好——TUI 使用哪个提供方和模型、个人凭证、私有的适配器路由——除了改动已提交的文件之外无处安放。要把 TUI 示例指向个人的 Anthropic 代理 Opus 路由,只能在工作区里改 `examples/tui-agent/cordis.yml` 和 `.env`,既有提交密钥的风险,又要在每个 checkout 里重复一遍。也没有可安装的命令:想在任意项目目录里运行这个 agent,必须回到仓库根目录调用示例脚本。Loader 元数据是静态的,所以「条件组合使用 overlay」(AGENTS.md)——但 overlay 此前只以已提交的同级文件形式存在,没有机器级的层。 +开发者自己的偏好——TUI 使用哪个提供方和模型、个人凭证、私有的适配器路由——除了改动已提交的文件之外无处安放。要把 TUI 示例指向个人的 Anthropic 代理 Opus 路由,只能在工作区里改 `examples/tui-agent/cordis.yml` 和 `.env`,既有提交密钥的风险,又要在每个 checkout 里重复一遍。也没有可安装的命令:想在任意项目目录里运行这个 agent,必须回到仓库根目录调用示例脚本。Loader 元数据是静态的——条目 `disabled` 字段除外(见 [loader `disabled` 插值决策](../process/2026-08-11-loader-entry-disabled-interpolation.md))——所以「条件组合使用 overlay」(AGENTS.md);但 overlay 此前只以已提交的同级文件形式存在,没有机器级的层。 ## Decision diff --git a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.i18n.yaml b/.agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.i18n.yaml index 4e2d35175f..49952e51cf 100644 --- a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.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-26-code-dispatch-ui-foundation.md -2026-07-26-code-dispatch-ui-foundation.md: 4115a1898de7d2cce01346c3f005fcd19c325f4c -2026-07-26-code-dispatch-ui-foundation.zh.md: aeb57b93d781163dd0a4747ac03053c65deda1db +2026-07-26-code-dispatch-ui-foundation.md: f98e919c2459327c8a42f31bea1fd115ce78b361 +2026-07-26-code-dispatch-ui-foundation.zh.md: a35864b3e2e2e2facb8d464d175a1e8f4d160ec1 diff --git a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.md b/.agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.md index 4115a1898d..f98e919c24 100644 --- a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.md +++ b/.agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.md @@ -16,7 +16,7 @@ Three changes, one per obstacle: 1. **`run_code` gains a required `description` parameter** (bash's exact contract: active voice, 5-10 words, shown in the UI; whitespace-only rejected at execute). `presentCall` now titles the card with the description and moves the program to `rawInput`. The prompt-side cost is a few tokens per call; the return is that every surface — TUI card, ACP title, web row — gets a human-readable label without parsing TypeScript. 2. **`tool/code-dispatch` logs the sub-call's complete model-facing outcome** — `content: ContentBlock[]` + `isError`, the `tool/result` vocabulary — replacing `resultSummary` and deleting the summarize/cwd-normalization machinery outright. A UI renders a sub-call through the identical code path as a native result, including error text and non-text blocks. The event stays log-only (`deriveMessages()` ignores it): nothing about model context changes. -3. **`DSH_TOOLS_MODE` env var on the `dsh` config tree** (`native`|`code`|`both`; unset keeps the schema default): the `tools` row reads it via `!!js`, and the worker code runtime is mounted unconditionally (Loader metadata is static, so no conditional row exists; a native boot only registers the service — workers spawn per run). This is an explicitly temporary configuration hook: per-session tool-mode selection owned by the web UI is the design goal, and the env var dies when that lands. +3. **`DSH_TOOLS_MODE` env var on the `dsh` config tree** (`native`|`code`|`both`; unset keeps the schema default): the `tools` row reads it via `!!js`, and the worker code runtime is mounted unconditionally (Loader metadata was static when this shipped — no conditional row existed; the later [`disabled` interpolation decision](../process/2026-08-11-loader-entry-disabled-interpolation.md) makes one possible but changes nothing here — a native boot only registers the service, workers spawn per run). This is an explicitly temporary configuration hook: per-session tool-mode selection owned by the web UI is the design goal, and the env var dies when that lands. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.zh.md b/.agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.zh.md index aeb57b93d7..a35864b3e2 100644 --- a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.zh.md +++ b/.agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.zh.md @@ -16,7 +16,7 @@ Status: implemented 1. **`run_code` 新增必填的 `description` 参数**(与 bash 完全相同的约定:主动语态、5-10 个词、展示在 UI 中;仅含空白的取值在执行时被拒绝)。`presentCall` 现在以该 description 作为卡片标题,并把程序文本移入 `rawInput`。提示词侧的成本是每次调用多出几个 token;换来的是每个表面——TUI 卡片、ACP(Agent Client Protocol)标题、Web 行——都无需解析 TypeScript 就能获得可供人阅读的标签。 2. **`tool/code-dispatch` 记录子调用面向模型的完整结果**(`content: ContentBlock[]` 加 `isError`,即 `tool/result` 的词汇),取代 `resultSummary`,并把摘要与 cwd 归一化机制彻底删除。UI 渲染子调用走的代码路径与渲染原生结果完全相同,包括错误文本和非文本块。该事件保持仅日志(`deriveMessages()` 忽略它):模型上下文没有任何变化。 -3. **`dsh` 配置树上的 `DSH_TOOLS_MODE` 环境变量**(`native`|`code`|`both`;未设置时保持 schema 默认值):`tools` 行通过 `!!js` 读取它,worker 代码运行时则无条件挂载(loader 元数据是静态的,因此不存在条件行;native 启动只是注册该服务,worker 要到每次运行时才 spawn)。这是一个明确标注为临时的配置钩子:设计目标是让 Web UI 拥有按会话的工具模式选择,该目标落地后,这个环境变量随即退役。 +3. **`dsh` 配置树上的 `DSH_TOOLS_MODE` 环境变量**(`native`|`code`|`both`;未设置时保持 schema 默认值):`tools` 行通过 `!!js` 读取它,worker 代码运行时则无条件挂载(本项交付时 loader 元数据仍是静态的,因此不存在条件行;后来的 [`disabled` 插值决策](../process/2026-08-11-loader-entry-disabled-interpolation.md) 让条件行成为可能,但此处不变——native 启动只是注册该服务,worker 要到每次运行时才 spawn)。这是一个明确标注为临时的配置钩子:设计目标是让 Web UI 拥有按会话的工具模式选择,该目标落地后,这个环境变量随即退役。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.i18n.yaml b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.i18n.yaml index 49b713f534..49b47465e0 100644 --- a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md -2026-08-01-windows-pwsh-default.md: f0da86e52bcdd53a10b60164d7cc12261cfc5c49 -2026-08-01-windows-pwsh-default.zh.md: 41a6429eab8f86a8960ac4aa372aeacfda4661c4 +2026-08-01-windows-pwsh-default.md: 5f1b1e4046bdbd1cc433080c5f8814a3ae9f9a3d +2026-08-01-windows-pwsh-default.zh.md: 0bbbc815813ea208b73441ae4911a94e4fde7a91 diff --git a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md index f0da86e52b..5f1b1e4046 100644 --- a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md +++ b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md @@ -12,9 +12,8 @@ The harness's shipped execution profile is bash-first on every platform. Windows Windows hosts booting a shipped profile (`dsh web`, `dsh --profile headless`, one-shot tasks) get the PowerShell stack by default; POSIX hosts are unchanged. -- **The platform layer is a data file, not a roster rewrite.** `@deepseek-ai/dsh-base` ships [`windows.cordis.patch.yml`](../../../../packages/bundle/base/windows.cordis.patch.yml) alongside its universal `cordis.patch.yml`: it disables `bash-sandbox`/`tool-bash` (the POSIX-only executor and its dialect tool) and inserts `pwsh-local`/`tool-pwsh`. Windows has no OS sandbox runner (landlock/bwrap/seatbelt are POSIX-only), so the layer drops the sandbox stack entirely — `sandbox`, `sandbox-policy`, and `fs-sandbox` are disabled and the unconfined `dsh-fs-local` provides `ctx.fs` — and degrades to danger-full-access: `permission`/`ui-permission` leave the roster (dsh-permission requires a confining executor — presets bundle a sandbox mode the unconfined executor cannot honor; see its constructor guard — and the client knob would advertise a boundary that does not exist), and the `approval` service is disabled — nothing in the Windows roster asks for approval, so the model is never told approval exists or that asks are auto-rejected. Keeping fs-only path rules would be theater: the unconfined shell can bypass them with one command, so the honest Windows posture is full access rather than a boundary only the fs tools pretend to enforce. -- **The launcher injects the layer by platform.** `apps/cli/src/windows-shell.ts` resolves it from the base bundle layer's `packageDir` between the bundle layers and the user layers on `win32` hosts, in every composition path (boot, config-only HMR recomposition, config dumps). Overriding the shipped default is a composition decision: a Windows host that prefers the bash stack — or confinement — re-enables the bash rows through its profile or home `cordis.patch.yml`. Custom profiles without the base bundle are skipped (they own their shell stack); a base bundle that ships no Windows shell patch fails loud. -- **Module resolution is restored for cold starts.** The profiles-rework CLI dropped the pwsh packages from `apps/cli`'s dependency closure, so `healProfilesModuleFallback` never linked them into `$DSH_HOME/profiles/node_modules` and a fresh Windows host could not resolve the inserted rows. `apps/cli` and `dsh-base` re-declare `dsh-pwsh-local`/`dsh-tool-pwsh`, and `dsh-base` also declares `dsh-fs-local`; the base bundle lists every row plugin as a dependency by house style. +- **The base patch gates both shell stacks on its own rows** (the [loader `disabled` interpolation](../process/2026-08-11-loader-entry-disabled-interpolation.md) note records the mechanism and the platform-layer fold): `bash-sandbox`/`tool-bash` carry `disabled: !!js process.platform === 'win32'` (bash has no Windows runner), and their twins `pwsh-sandbox`/`tool-pwsh` mount only on win32 with the inverted expression — one shared patch file, exactly one shell stack per host. The confined pwsh stack runs over the ACL restricted-token runner, and the permission surface stays exactly as on POSIX (the [Windows ACL restricted-token sandbox](2026-08-08-windows-acl-restricted-token-sandbox.md) note owns that roster). Overriding the shipped default is a composition decision: a Windows host that prefers the bash stack or an unconfined pwsh executor overrides these rows through its profile or home `cordis.patch.yml` (the bash-restore recipe must be complete: disable `pwsh-sandbox`/`tool-pwsh` AND re-enable `bash-sandbox`/`tool-bash` — both executor families register the same `bash` service, so an incomplete recipe fails loud at load) — composition config is the one override channel. The separate `windows.cordis.patch.yml` layer and the launcher's `apps/cli/src/windows-shell.ts` injection are deleted; the layer existed only because entry metadata was static. +- **Module resolution is restored for cold starts.** The profiles-rework CLI dropped the pwsh packages from `apps/cli`'s dependency closure, so `healProfilesModuleFallback` never linked them into `$DSH_HOME/profiles/node_modules` and a fresh Windows host could not resolve the pwsh rows. `apps/cli` and `dsh-base` declare `dsh-pwsh-sandbox`/`dsh-tool-pwsh`; the base bundle lists every row plugin as a dependency by house style. The pwsh GUI rendering shipped earlier with the [pwsh UI presentation matches bash decision](2026-08-05-pwsh-ui-bash-parity.md); the [pwsh tool bash parity decision](2026-08-02-pwsh-tool-bash-parity.md) ships the tool's surface. Nothing in this decision changes POSIX behavior. @@ -32,13 +31,12 @@ The pwsh GUI rendering shipped earlier with the [pwsh UI presentation matches ba ## Consequences -- A Windows host running a shipped `dsh` surface gets `pwsh` as its shell tool and PowerShell as the `ctx.bash` executor without configuration; `bash` is absent from the model-visible roster there (its tool row is disabled). -- Windows has no sandbox at all: the fs tools run unconfined (`dsh-fs-local`), the approval service is absent (nothing asks for approval, and the model is never told approval exists), and the permission switcher is gone. The model-visible posture is honest full access rather than a boundary the shell can bypass. -- POSIX hosts are unchanged: the platform layer never applies, and the bash stack remains the universal `cordis.patch.yml` rows. -- Windows hosts that prefer the bash stack (e.g. with WSL/Git-Bash on PATH) override the shipped default through their profile or home `cordis.patch.yml` — disabling `pwsh-local`/`tool-pwsh` and re-enabling `bash-sandbox`/`tool-bash` (both executors register the same `bash` service, so an incomplete recipe fails loud at load) — composition config is the one override channel. +- A Windows host running a shipped `dsh` surface gets the confined `pwsh` as its shell tool and PowerShell as the `ctx.bash` executor without configuration; `bash` is absent from the model-visible roster there (its tool row is disabled). +- POSIX hosts mount the bash stack as before; the pwsh rows sit disabled in their composition, because the one shared patch file lists both stacks and each row gates itself. +- A Windows host that prefers the bash stack (e.g. with WSL/Git-Bash on PATH) overrides the shipped rows through its profile or home `cordis.patch.yml` — disabling `pwsh-sandbox`/`tool-pwsh` and re-enabling `bash-sandbox`/`tool-bash` (both executors register the same `bash` service, so an incomplete recipe fails loud at load) — composition config is the one override channel. ## Verification -- Unit: `apps/cli/tests/windows-shell.spec.ts` pins the win32 default, the custom-profile skip, and the missing-patch failure with the platform injected, and composes the REAL shipped bundle layers (dsh-base + dsh-web-app resolved from the app installation) through the boot's patch algorithm to assert the win32 danger-full-access roster and the base-only-profile warning; `packages/bundle/base/tests/base.spec.ts` pins the shipped Windows patch file shape (disables, inserts, and the absent approval service). -- Keyless: a win32 `dsh --profile --dump-config` shows the pwsh rows with `windows.cordis.patch.yml` provenance and the bash rows disabled; the POSIX dump (CI Linux) is unchanged. +- Unit: `apps/cli/tests/windows-shell.spec.ts` composes the REAL shipped bundle layers (dsh-base + dsh-web-app resolved from the app installation) through the boot's patch algorithm and pins the effective per-platform roster — the win32 pwsh roster, the POSIX bash roster, and the base-only profile — plus the preset-level tool-bash gate and the cold-start resolution closure; `packages/bundle/base/tests/base.spec.ts` pins the four shell rows' symmetric `!!js` platform gates and that no separate platform patch ships. +- Keyless: a `dsh --profile --dump-config` shows both stacks in the one shared patch layer, with each row's own `disabled` expression deciding the roster at mount. - The real-composition smoke boots the web profile on win32 with the pwsh stack mounted (the exact roster this note describes). diff --git a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.zh.md b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.zh.md index 41a6429eab..0bbbc81581 100644 --- a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.zh.md +++ b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.zh.md @@ -12,9 +12,8 @@ harness 交付的执行画像在每个平台都是 bash 优先。Windows 主机 启动交付 profile(`dsh web`、`dsh --profile headless`、一次性任务)的 Windows 主机默认获得 PowerShell 栈;POSIX 主机不变。 -- **平台层是数据文件,不是清单重写。** `@deepseek-ai/dsh-base` 随通用 `cordis.patch.yml` 一起交付 [`windows.cordis.patch.yml`](../../../../packages/bundle/base/windows.cordis.patch.yml):它禁用 `bash-sandbox`/`tool-bash`(仅 POSIX 的执行器及其方言工具)并插入 `pwsh-local`/`tool-pwsh`。Windows 上没有 OS 级 sandbox runner(landlock/bwrap/seatbelt 均为 POSIX 专属),因此该层整体移除 sandbox 栈——`sandbox`、`sandbox-policy`、`fs-sandbox` 被禁用,由不限权的 `dsh-fs-local` 提供 `ctx.fs`——并完全退化为 danger-full-access:`permission`/`ui-permission` 离开清单(dsh-permission 要求有限权能力的执行器——preset 捆绑的是无限制执行器无法兑现的 sandbox 模式;见其构造函数守卫——客户端旋钮会宣传一个并不存在的边界),`approval` 服务也被禁用——Windows 清单里没有任何动作需要审批,模型也不会被告知"审批存在"或"请求会被自动拒绝"。保留仅限 fs 的路径规则是摆设:不限权的 shell 一条命令即可绕过,因此诚实的 Windows 姿态是全权访问,而不是一个只有 fs 工具假装执行的边界。 -- **启动器按平台注入该层。** `apps/cli/src/windows-shell.ts` 在 `win32` 主机上从 base bundle 层的 `packageDir` 解析它,置于 bundle 层与用户层之间,覆盖所有组合路径(启动、config-only HMR 重组合、配置转储)。覆盖交付默认是组合决策:偏好 bash 栈(或偏好有限权)的 Windows 主机通过其 profile 或 home 的 `cordis.patch.yml` 重新启用 bash 行。未挂 base bundle 的自定义 profile 被跳过(它们自己拥有 shell 栈);base bundle 缺 `windows.cordis.patch.yml` 时 fail loud。 -- **冷启动的模块解析已恢复。** profiles 重构把 pwsh 包从 `apps/cli` 的依赖闭包中删掉了,`healProfilesModuleFallback` 因此从未把它们链接进 `$DSH_HOME/profiles/node_modules`,新 Windows 主机解析不到插入的行。`apps/cli` 与 `dsh-base` 重新声明 `dsh-pwsh-local`/`dsh-tool-pwsh`,`dsh-base` 还声明 `dsh-fs-local`;按仓库惯例,base bundle 把每个行插件都列为依赖。 +- **base patch 在自身行上按平台门控两个 shell 栈**([loader `disabled` 插值](../process/2026-08-11-loader-entry-disabled-interpolation.md) note 记录了该机制与平台层折叠):`bash-sandbox`/`tool-bash` 携带 `disabled: !!js process.platform === 'win32'`(bash 没有 Windows runner),它们的孪生行 `pwsh-sandbox`/`tool-pwsh` 以取反的表达式仅在 win32 挂载——同一份 patch 文件,每个宿主恰好挂载一个 shell 栈。受限 pwsh 栈运行在 ACL 受限令牌 runner 之上,权限面与 POSIX 完全一致([Windows ACL 受限令牌沙箱](2026-08-08-windows-acl-restricted-token-sandbox.md) note 拥有该清单)。覆盖交付默认是组合决策:偏好 bash 栈或不限权 pwsh 执行器的 Windows 主机通过其 profile 或 home 的 `cordis.patch.yml` 覆盖这些行(bash 恢复配方必须完整:禁用 `pwsh-sandbox`/`tool-pwsh` 并重新启用 `bash-sandbox`/`tool-bash`——两个执行器家族注册同一个 `bash` 服务,配方不完整会在加载时 fail loud)——组合配置是唯一的覆盖通道。独立的 `windows.cordis.patch.yml` 层与启动器的 `apps/cli/src/windows-shell.ts` 注入已删除;该层只因条目元数据是静态的而存在。 +- **冷启动的模块解析已恢复。** profiles 重构把 pwsh 包从 `apps/cli` 的依赖闭包中删掉了,`healProfilesModuleFallback` 因此从未把它们链接进 `$DSH_HOME/profiles/node_modules`,新 Windows 主机解析不到 pwsh 行。`apps/cli` 与 `dsh-base` 声明 `dsh-pwsh-sandbox`/`dsh-tool-pwsh`;按仓库惯例,base bundle 把每个行插件都列为依赖。 pwsh GUI 渲染已随 [pwsh UI 呈现与 bash 对齐决策](2026-08-05-pwsh-ui-bash-parity.md) 先行交付;[pwsh 工具与 bash 对齐决策](2026-08-02-pwsh-tool-bash-parity.md) 交付了工具表面。本决策不改变任何 POSIX 行为。 @@ -32,13 +31,12 @@ pwsh GUI 渲染已随 [pwsh UI 呈现与 bash 对齐决策](2026-08-05-pwsh-ui-b ## 后果 -- 运行交付版 `dsh` 表面的 Windows 主机无需配置即获得 `pwsh` 作为 shell 工具、PowerShell 作为 `ctx.bash` 执行器;那里的模型可见清单中没有 `bash`(其工具行被禁用)。 -- Windows 上没有任何沙箱:fs 工具不限权运行(`dsh-fs-local`)、`approval` 服务不存在(没有任何动作需要审批,模型也不会被告知审批存在)、权限切换器消失。模型可见的姿态是诚实的全权访问,而不是一个 shell 可以绕过的边界。 -- POSIX 主机不变:平台层永不生效,bash 栈仍是通用 `cordis.patch.yml` 的行。 -- 偏好 bash 栈的 Windows 主机(例如 PATH 上有 WSL/Git-Bash 时)通过其 profile 或 home 的 `cordis.patch.yml` 覆盖交付默认——禁用 `pwsh-local`/`tool-pwsh` 并重新启用 `bash-sandbox`/`tool-bash`(两个执行器注册同一个 `bash` 服务,配方不完整会在加载时 fail loud)——组合配置是唯一的覆盖通道。 +- 运行交付版 `dsh` 表面的 Windows 主机无需配置即获得受限 `pwsh` 作为 shell 工具、PowerShell 作为 `ctx.bash` 执行器;那里的模型可见清单中没有 `bash`(其工具行被禁用)。 +- POSIX 主机如常挂载 bash 栈;pwsh 行以其自身的门控表达式处于禁用状态——同一份共享 patch 文件列出两个栈,每个行自己决定挂载。 +- 偏好 bash 栈的 Windows 主机(例如 PATH 上有 WSL/Git-Bash 时)通过其 profile 或 home 的 `cordis.patch.yml` 覆盖交付行——禁用 `pwsh-sandbox`/`tool-pwsh` 并重新启用 `bash-sandbox`/`tool-bash`(两个执行器注册同一个 `bash` 服务,配方不完整会在加载时 fail loud)——组合配置是唯一的覆盖通道。 ## 验证 -- 单元:`apps/cli/tests/windows-shell.spec.ts` 以平台注入固定 win32 默认、自定义 profile 跳过与缺文件失败,并通过启动所用的 patch 算法组合真实交付的 bundle 层(从应用安装解析的 dsh-base + dsh-web-app)断言 win32 danger-full-access 清单与 base-only profile 警告;`packages/bundle/base/tests/base.spec.ts` 固定交付的 Windows patch 文件形状(禁用、插入与缺席的 approval 服务)。 -- Keyless:win32 上的 `dsh --profile --dump-config` 显示带 `windows.cordis.patch.yml` 出处的 pwsh 行、被禁用的 bash 行;POSIX 转储(CI Linux)不变。 +- 单元:`apps/cli/tests/windows-shell.spec.ts` 通过启动所用的 patch 算法组合真实交付的 bundle 层(从应用安装解析的 dsh-base + dsh-web-app),固定每个平台的有效清单——win32 pwsh 清单、POSIX bash 清单与 base-only profile——外加预设级 tool-bash 门控与冷启动解析闭包;`packages/bundle/base/tests/base.spec.ts` 固定四个 shell 行的对称 `!!js` 平台门控,并断言不再交付独立的平台 patch。 +- Keyless:`dsh --profile --dump-config` 在同一份共享 patch 层中显示两个栈,每个行以自己的 `disabled` 表达式在挂载时决定清单。 - 真实组合冒烟在 win32 上启动 web profile,pwsh 栈挂载成功(即本笔记描述的确切清单)。 diff --git a/.agents/notes/implemented/process/2026-08-11-loader-entry-disabled-interpolation.i18n.yaml b/.agents/notes/implemented/process/2026-08-11-loader-entry-disabled-interpolation.i18n.yaml index 1d51763b47..0a40f082d8 100644 --- a/.agents/notes/implemented/process/2026-08-11-loader-entry-disabled-interpolation.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-11-loader-entry-disabled-interpolation.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/process/2026-08-11-loader-entry-disabled-interpolation.md -2026-08-11-loader-entry-disabled-interpolation.md: d2ddbc7d00c8f3e493912f826ee573f355b3b71d -2026-08-11-loader-entry-disabled-interpolation.zh.md: cc139202e4ea79dfbae1c89cef5d7eda4ab2011f +2026-08-11-loader-entry-disabled-interpolation.md: c916c6b667fc85f68e79e33edf5a1a63921b2d71 +2026-08-11-loader-entry-disabled-interpolation.zh.md: b5f5a527dcdfe2a906001e31aa4a32884e67f3ab diff --git a/.agents/notes/implemented/process/2026-08-11-loader-entry-disabled-interpolation.md b/.agents/notes/implemented/process/2026-08-11-loader-entry-disabled-interpolation.md index d2ddbc7d00..c916c6b667 100644 --- a/.agents/notes/implemented/process/2026-08-11-loader-entry-disabled-interpolation.md +++ b/.agents/notes/implemented/process/2026-08-11-loader-entry-disabled-interpolation.md @@ -6,18 +6,20 @@ English | [中文](2026-08-11-loader-entry-disabled-interpolation.zh.md) ## Problem -The Windows platform layer (`packages/bundle/base/windows.cordis.patch.yml`) disables `tool-bash` on win32, but the shipped presets each mount a `tool-bash` row. Preset rows compose last, so the same-id row re-enabled the tool on Windows — the session had both `tool-bash` (PowerShell-backed) and `tool-pwsh`, silently, because no spec pinned the composed preset layer. Entry metadata had no conditional mechanism: `!!js` interpolates only under plugin `config`, and [postmortem 0002](../../../../docs/postmortem/0002-js-expression-disabled-filesystem-tools.md) documents that `disabled: !!js ...` stays a truthy expression object, disabling the row everywhere. +The Windows platform layer (then a separate `packages/bundle/base/windows.cordis.patch.yml`, since folded into the base patch — see Decision) disabled `tool-bash` on win32, but the shipped presets each mount a `tool-bash` row. Preset rows compose last, so the same-id row re-enabled the tool on Windows — the session had both `tool-bash` (PowerShell-backed) and `tool-pwsh`, silently, because no spec pinned the composed preset layer. Entry metadata had no conditional mechanism: `!!js` interpolates only under plugin `config`, and [postmortem 0002](../../../../docs/postmortem/0002-js-expression-disabled-filesystem-tools.md) documents that `disabled: !!js ...` stays a truthy expression object, disabling the row everywhere. ## Decision The Loader interpolates the entry `disabled` field (`vendor/loader/src/config/entry.ts`): a `!!js` expression evaluates against the loader context at every mount decision. `disabled` is the only interpolated metadata field; `id`, `name`, `group`, and `inject` stay static. The raw node stays in the options, so write-back keeps the `!!js` form. The shipped presets (standard, code, cordis) gate `tool-bash` with `disabled: !!js process.platform === 'win32'`, and `verify-cordis-config` now allows expressions in `disabled` only. +The mechanism completes the platform-layer fold: the base bundle's `cordis.patch.yml` gates both shell stacks on its own rows — `bash-sandbox`/`tool-bash` carry `disabled: !!js process.platform === 'win32'`, and their twins `pwsh-sandbox`/`tool-pwsh` mount only on win32 with the inverted expression. The launcher's separate Windows platform layer (`windows.cordis.patch.yml` plus `apps/cli/src/windows-shell.ts` and its injection into boot, live recomposition, and config dumps) is deleted — the layer existed only because entry metadata was static, and with `disabled` interpolated the condition lives on the row it governs. + ## Alternatives considered **A declarative `platform` field on the row.** Static and gate-checkable, but a second composition mechanism beside `!!js`, and platform is only today's condition. -**Preset-level platform overlays.** Rejected: the condition belongs on the row it governs. +**Preset-level platform overlays.** Rejected: the condition belongs on the row it governs — the same principle folds the launcher's separate Windows platform layer into the base rows. ## Consequences -A row can gate itself on platform or environment; a bad expression fails loud at boot. Every other metadata field remains literal and the gate keeps rejecting expressions there — the postmortem-0002 hazard is closed for `disabled` by evaluation, not prohibition. The `minimal` preset's missing win32 PTY stack is a preset-metadata follow-up. +A row can gate itself on platform or environment; a bad expression fails loud at boot. Every other metadata field remains literal and the gate keeps rejecting expressions there — the postmortem-0002 hazard is closed for `disabled` by evaluation, not prohibition. The Windows shell swap moved from a launcher-injected patch layer to the base bundle's own rows: win32 mounts the confined pwsh stack, POSIX carries the pwsh rows disabled, and one shared patch file serves both rosters — the [Windows pwsh default](../feature/2026-08-01-windows-pwsh-default.md) note's layer mechanism is superseded. The `minimal` preset's missing win32 PTY stack is a preset-metadata follow-up. diff --git a/.agents/notes/implemented/process/2026-08-11-loader-entry-disabled-interpolation.zh.md b/.agents/notes/implemented/process/2026-08-11-loader-entry-disabled-interpolation.zh.md index cc139202e4..b5f5a527dc 100644 --- a/.agents/notes/implemented/process/2026-08-11-loader-entry-disabled-interpolation.zh.md +++ b/.agents/notes/implemented/process/2026-08-11-loader-entry-disabled-interpolation.zh.md @@ -6,18 +6,20 @@ Status: implemented ## 问题 -Windows 平台层(`packages/bundle/base/windows.cordis.patch.yml`)在 win32 上禁用 `tool-bash`,但 shipped 预设各自挂载了一行 `tool-bash`。预设行最后组合,同名行在 Windows 上重新启用了该工具——会话同时拥有 `tool-bash`(PowerShell 后端)与 `tool-pwsh`,且是静默的,因为没有 spec pin 组合后的预设层。条目元数据没有条件机制:`!!js` 只在插件 `config` 下插值,[postmortem 0002](../../../../docs/postmortem/0002-js-expression-disabled-filesystem-tools.md) 记录了 `disabled: !!js ...` 保持真值表达式对象、在所有平台上禁用该行的事故。 +Windows 平台层(当时是独立的 `packages/bundle/base/windows.cordis.patch.yml`,现已折入 base patch——见「决策」)在 win32 上禁用 `tool-bash`,但 shipped 预设各自挂载了一行 `tool-bash`。预设行最后组合,同名行在 Windows 上重新启用了该工具——会话同时拥有 `tool-bash`(PowerShell 后端)与 `tool-pwsh`,且是静默的,因为没有 spec pin 组合后的预设层。条目元数据没有条件机制:`!!js` 只在插件 `config` 下插值,[postmortem 0002](../../../../docs/postmortem/0002-js-expression-disabled-filesystem-tools.md) 记录了 `disabled: !!js ...` 保持真值表达式对象、在所有平台上禁用该行的事故。 ## 决策 Loader 插值条目 `disabled` 字段(`vendor/loader/src/config/entry.ts`):`!!js` 表达式在每次挂载决策时基于 loader 上下文求值。`disabled` 是唯一被插值的元数据字段;`id`、`name`、`group`、`inject` 保持静态。原始节点保留在 options 中,写回保持 `!!js` 形式。shipped 预设(standard、code、cordis)用 `disabled: !!js process.platform === 'win32'` 门控 `tool-bash`,`verify-cordis-config` 现在只允许 `disabled` 中的表达式。 +该机制补全了平台层折叠:base bundle 的 `cordis.patch.yml` 在自身行上按平台门控两个 shell 栈——`bash-sandbox`/`tool-bash` 携带 `disabled: !!js process.platform === 'win32'`,它们的孪生行 `pwsh-sandbox`/`tool-pwsh` 以取反的表达式仅在 win32 挂载。启动器的独立 Windows 平台层(`windows.cordis.patch.yml` 以及 `apps/cli/src/windows-shell.ts` 及其注入到 boot、live 重组合、config dump 的逻辑)被删除——该层只因条目元数据是静态的而存在,`disabled` 可插值后条件就落在它所治理的行上。 + ## 备选方案 **行上的声明式 `platform` 字段。** 静态且可被门禁检查,但它是 `!!js` 之外的第二种组合机制,且平台只是今天的条件。 -**预设级平台 overlay。** 被否:条件应当属于它所治理的行。 +**预设级平台 overlay。** 被否:条件应当属于它所治理的行——同一原则把启动器独立的 Windows 平台层折入 base 行。 ## 后果 -行可以按平台或环境门控自身;错误的表达式在启动时响亮失败。其余元数据字段保持字面值,门禁继续拒绝那里的表达式——`disabled` 上的 postmortem-0002 隐患以「求值」而非「禁止」关闭。`minimal` 预设缺失的 win32 PTY 栈是预设元数据的后续工作。 +行可以按平台或环境门控自身;错误的表达式在启动时响亮失败。其余元数据字段保持字面值,门禁继续拒绝那里的表达式——`disabled` 上的 postmortem-0002 隐患以「求值」而非「禁止」关闭。Windows shell 栈的切换从启动器注入的 patch 层移到 base bundle 自身的行上:win32 挂载受限 pwsh 栈,POSIX 携带被禁用的 pwsh 行,同一份 patch 文件服务两种阵容——[Windows 默认 pwsh](../feature/2026-08-01-windows-pwsh-default.md) note 的层机制已被取代。`minimal` 预设缺失的 win32 PTY 栈是预设元数据的后续工作。 diff --git a/apps/cli/composition.md b/apps/cli/composition.md index 303903566b..3e08e97b37 100644 --- a/apps/cli/composition.md +++ b/apps/cli/composition.md @@ -60,6 +60,8 @@ flowchart LR cfg --> plugin_dsh_base_sandbox_policy plugin_dsh_base_bash_sandbox["bash-sandbox
@deepseek-ai/dsh-bash-sandbox"] cfg --> plugin_dsh_base_bash_sandbox + plugin_dsh_base_pwsh_sandbox["pwsh-sandbox
@deepseek-ai/dsh-pwsh-sandbox"] + cfg --> plugin_dsh_base_pwsh_sandbox plugin_dsh_base_approval["approval
@deepseek-ai/dsh-user-approval"] cfg --> plugin_dsh_base_approval plugin_dsh_base_permission["permission
@deepseek-ai/dsh-permission"] @@ -68,6 +70,8 @@ flowchart LR cfg --> plugin_dsh_base_bash_env plugin_dsh_base_tool_bash["tool-bash
@deepseek-ai/dsh-tool-bash"] cfg --> plugin_dsh_base_tool_bash + plugin_dsh_base_tool_pwsh["tool-pwsh
@deepseek-ai/dsh-tool-pwsh"] + cfg --> plugin_dsh_base_tool_pwsh plugin_dsh_base_tool_tasks["tool-tasks
@deepseek-ai/dsh-tool-tasks"] cfg --> plugin_dsh_base_tool_tasks plugin_dsh_base_fs_policy["fs-policy
@deepseek-ai/dsh-fs-policy"] @@ -194,10 +198,12 @@ flowchart LR | `sandbox` | `@deepseek-ai/dsh-sandbox-local` | | `sandbox-policy` | `@deepseek-ai/dsh-sandbox-policy` | | `bash-sandbox` | `@deepseek-ai/dsh-bash-sandbox` | +| `pwsh-sandbox` | `@deepseek-ai/dsh-pwsh-sandbox` | | `approval` | `@deepseek-ai/dsh-user-approval` | | `permission` | `@deepseek-ai/dsh-permission` | | `bash-env` | `@deepseek-ai/dsh-bash-env` | | `tool-bash` | `@deepseek-ai/dsh-tool-bash` | +| `tool-pwsh` | `@deepseek-ai/dsh-tool-pwsh` | | `tool-tasks` | `@deepseek-ai/dsh-tool-tasks` | | `fs-policy` | `@deepseek-ai/dsh-fs-policy` | | `tool-fs` | `@deepseek-ai/dsh-tool-fs` | diff --git a/apps/cli/config/agent-presets/code/agent.cordis.yml b/apps/cli/config/agent-presets/code/agent.cordis.yml index ffcad54e59..6a7750de11 100644 --- a/apps/cli/config/agent-presets/code/agent.cordis.yml +++ b/apps/cli/config/agent-presets/code/agent.cordis.yml @@ -46,11 +46,11 @@ # the criterion for host-plane ownership — injection resolves before any session # exists, so there is no agent to key by. Behind a preset realm those variables # never reached the model's shell at all. `tool-bash` consumes the host registry -# from here; the executor behind it is host-plane too, where the sandbox policy -# owns it. +# from here; the executor behind it (`bash-sandbox`) is host-plane too, where the +# sandbox policy owns it. - id: tool-bash name: '@deepseek-ai/dsh-tool-bash' - # POSIX-only: the Windows platform layer swaps the bash stack for the pwsh stack. + # POSIX-only: the base composition swaps the bash stack for the pwsh stack on win32. disabled: !!js process.platform === 'win32' # ── filesystem ────────────────────────────────────────────────────────────── diff --git a/apps/cli/config/agent-presets/cordis/agent.cordis.yml b/apps/cli/config/agent-presets/cordis/agent.cordis.yml index 4afda44ad1..a2e7be3fc7 100644 --- a/apps/cli/config/agent-presets/cordis/agent.cordis.yml +++ b/apps/cli/config/agent-presets/cordis/agent.cordis.yml @@ -40,11 +40,11 @@ # the criterion for host-plane ownership — injection resolves before any session # exists, so there is no agent to key by. Behind a preset realm those variables # never reached the model's shell at all. `tool-bash` consumes the host registry -# from here; the executor behind it is host-plane too, where the sandbox policy -# owns it. +# from here; the executor behind it (`bash-sandbox`) is host-plane too, where the +# sandbox policy owns it. - id: tool-bash name: '@deepseek-ai/dsh-tool-bash' - # POSIX-only: the Windows platform layer swaps the bash stack for the pwsh stack. + # POSIX-only: the base composition swaps the bash stack for the pwsh stack on win32. disabled: !!js process.platform === 'win32' # ── filesystem ────────────────────────────────────────────────────────────── diff --git a/apps/cli/config/agent-presets/standard/agent.cordis.yml b/apps/cli/config/agent-presets/standard/agent.cordis.yml index 86c8a2b724..12fdeaadeb 100644 --- a/apps/cli/config/agent-presets/standard/agent.cordis.yml +++ b/apps/cli/config/agent-presets/standard/agent.cordis.yml @@ -39,11 +39,11 @@ # the criterion for host-plane ownership — injection resolves before any session # exists, so there is no agent to key by. Behind a preset realm those variables # never reached the model's shell at all. `tool-bash` consumes the host registry -# from here; the executor behind it is host-plane too, where the sandbox policy -# owns it. +# from here; the executor behind it (`bash-sandbox`) is host-plane too, where the +# sandbox policy owns it. - id: tool-bash name: '@deepseek-ai/dsh-tool-bash' - # POSIX-only: the Windows platform layer swaps the bash stack for the pwsh stack. + # POSIX-only: the base composition swaps the bash stack for the pwsh stack on win32. disabled: !!js process.platform === 'win32' # ── filesystem ────────────────────────────────────────────────────────────── diff --git a/apps/cli/src/dump-config.ts b/apps/cli/src/dump-config.ts index d06bddbc6f..1754eb4efd 100644 --- a/apps/cli/src/dump-config.ts +++ b/apps/cli/src/dump-config.ts @@ -15,7 +15,6 @@ import { type ConfigDumpLayer, } from '@deepseek-ai/dsh-app-boot' import { homePatchPath, prepareProfile, PROFILE_ROOT_FILENAME } from './profile-boot.ts' -import { resolveWindowsShellLayer } from './windows-shell.ts' const NAME = 'dsh' @@ -34,12 +33,6 @@ export function runDumpConfig(profile: string, defaultOnly: boolean, patches: re label: layer.packageName, patches: layer.patches, })) - // The win32 shell platform layer rides between bundles and user layers, - // exactly where the boot applies it. - const windowsShellLayer = resolveWindowsShellLayer(process.platform, loaded.layers, NAME) - if (windowsShellLayer !== undefined) { - layers.push({ label: windowsShellLayer.label, patches: windowsShellLayer.patches }) - } if (!defaultOnly) { if (existsSync(loaded.patchPath)) { layers.push({ label: loaded.patchPath, patches: loaded.patches }) diff --git a/apps/cli/src/profile-boot.ts b/apps/cli/src/profile-boot.ts index f3c356f199..510d5ef7b5 100644 --- a/apps/cli/src/profile-boot.ts +++ b/apps/cli/src/profile-boot.ts @@ -40,7 +40,6 @@ import { DSH_ENVIRONMENT_KEY, type EnvironmentSnapshot } from '@deepseek-ai/dsh- import { provideCmdline } from '@deepseek-ai/dsh-cmdline' import type { HeadlessIo } from '@deepseek-ai/dsh-headless' import { createProcessShutdown, type ProcessShutdown } from './process-shutdown.ts' -import { resolveWindowsShellLayer } from './windows-shell.ts' const NAME = 'dsh' @@ -114,8 +113,6 @@ interface ComposedProfile { profile: Profile /** Bundle layers concatenated — the part below the user layers on a live reload. */ bundlePatches: PatchOptions[] - /** The win32 shell platform layer (the base bundle's `windows.cordis.patch.yml`), between bundles and user layers. */ - windowsShellPatches: PatchOptions[] /** The home-level user layer (`$DSH_HOME/cordis.patch.yml`), applied after the profile's own. */ homePatches: PatchOptions[] /** Layers above the user layers on a live reload: `--patch` overlays and the telemetry switch. */ @@ -131,7 +128,6 @@ interface ComposedProfile { function allPatches(composed: ComposedProfile): PatchOptions[] { return [ ...composed.bundlePatches, - ...composed.windowsShellPatches, ...composed.profile.patches, ...composed.homePatches, ...composed.overlays, @@ -140,10 +136,10 @@ function allPatches(composed: ComposedProfile): PatchOptions[] { /** * Load `name` and compose its effective patch stack: bundle layers in - * `dsh.profile.bundles` order, the win32 shell platform layer (when the host - * is Windows), the profile's user layer, the home-level user layer - * (`$DSH_HOME/cordis.patch.yml` — machine-local preferences that apply to - * every profile, so it outranks the per-profile layer), `--patch` overlays, + * `dsh.profile.bundles` order (the base bundle gates the shell stacks by + * platform on its own rows), the profile's user layer, the home-level user + * layer (`$DSH_HOME/cordis.patch.yml` — machine-local preferences that apply + * to every profile, so it outranks the per-profile layer), `--patch` overlays, * then the telemetry switch. * @param name - the profile name. * @param patchFiles - `--patch` overlay paths, in argv order. @@ -157,9 +153,8 @@ function composeProfile( const homePatches = loadOptionalPatches(NAME, homePatchPath()) ?? [] const overlays = patchFiles.flatMap(file => loadOverlayPatches(NAME, resolve(file))) const bundlePatches = profile.layers.flatMap(layer => layer.patches) - const windowsShellPatches = resolveWindowsShellLayer(process.platform, profile.layers, NAME)?.patches ?? [] const rows = new Map() - for (const row of composeEntries([bundlePatches, windowsShellPatches, profile.patches, homePatches, overlays])) { + for (const row of composeEntries([bundlePatches, profile.patches, homePatches, overlays])) { if (typeof row.id === 'string') rows.set(row.id, row) } const composedOverlays = [...overlays] @@ -178,7 +173,7 @@ function composeProfile( } const telemetryPatch = resolveTelemetryPatch(process.env.DSH_TELEMETRY_DISABLED, rows.has(TELEMETRY_ROW_ID)) if (telemetryPatch !== undefined) composedOverlays.push(telemetryPatch) - return { profile, bundlePatches, windowsShellPatches, homePatches, overlays: composedOverlays, rows } + return { profile, bundlePatches, homePatches, overlays: composedOverlays, rows } } /** Options for {@link runProfile}. */ @@ -241,7 +236,6 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con // removing the override could never revert the row to the bundle default. const composeLive = (): PatchOptions[] => structuredClone([ ...composed.bundlePatches, - ...composed.windowsShellPatches, ...loadOptionalPatches(NAME, composed.profile.patchPath) ?? [], ...loadOptionalPatches(NAME, homePatchPath()) ?? [], ...composed.overlays, diff --git a/apps/cli/src/windows-shell.ts b/apps/cli/src/windows-shell.ts deleted file mode 100644 index 1a9ca719f8..0000000000 --- a/apps/cli/src/windows-shell.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * The Windows shell platform layer: on win32 hosts the shipped profile - * compositions swap the POSIX-only bash stack for the sandbox-confined - * PowerShell stack (`@deepseek-ai/dsh-pwsh-sandbox` + - * `@deepseek-ai/dsh-tool-pwsh`). The layer is the base bundle's - * `windows.cordis.patch.yml`, injected by the launcher between the bundle - * layers and the user layers so a user patch can still override it — the - * only override channel is composition config, like every other roster - * decision. POSIX hosts never receive the layer. - * @module @deepseek-ai/dsh/windows-shell - */ - -import { join } from 'node:path' -import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' -import { loadOverlayPatches, type ProfileLayer } from '@deepseek-ai/dsh-app-boot' - -/** The base bundle whose package carries the Windows shell patch. */ -export const BASE_BUNDLE = '@deepseek-ai/dsh-base' - -/** The Windows shell patch filename inside the base bundle package. */ -export const WINDOWS_SHELL_PATCH_FILENAME = 'windows.cordis.patch.yml' - -/** One Windows shell platform layer: its patch file and parsed patches. */ -export interface WindowsShellLayer { - /** The patch file path, used as the config-dump provenance label. */ - label: string - /** The parsed patch entries, applied after the bundle layers. */ - patches: PatchOptions[] -} - -/** - * Resolve the Windows shell platform layer for a profile composition. - * @param platform - the host platform (`process.platform` at call sites). - * @param layers - the profile's bundle layers, in application order. - * @param binName - the diagnostic prefix on thrown errors (`dsh`). - * @returns the pwsh layer on win32, else `undefined`. A custom profile that - * mounts no base bundle is skipped (it owns its shell stack); a base - * bundle whose Windows shell patch is missing fails loud in - * {@link loadOverlayPatches} — the shipped package always carries it, so - * a miss is a broken installation. - */ -export function resolveWindowsShellLayer( - platform: NodeJS.Platform, - layers: readonly ProfileLayer[], - binName: string, -): WindowsShellLayer | undefined { - if (platform !== 'win32') return undefined - const base = layers.find(layer => layer.packageName === BASE_BUNDLE) - if (base === undefined) return undefined - const label = join(base.packageDir, WINDOWS_SHELL_PATCH_FILENAME) - return { label, patches: loadOverlayPatches(binName, label) } -} diff --git a/apps/cli/tests/windows-shell.spec.ts b/apps/cli/tests/windows-shell.spec.ts index 2dcd34cf91..4109cab68e 100644 --- a/apps/cli/tests/windows-shell.spec.ts +++ b/apps/cli/tests/windows-shell.spec.ts @@ -1,76 +1,40 @@ +/** + * The shipped shell composition: the base bundle gates both shell stacks by + * platform on its own rows (`disabled: !!js process.platform`), so exactly + * one shell stack mounts per host and no separate platform layer exists — + * the launcher applies nothing beyond the bundle layers. The spec composes + * the REAL shipped bundle layers (dsh-base + dsh-web-app resolved from the + * app installation anchor) through the boot's patch algorithm and pins the + * effective per-platform roster, the preset-level gate that keeps tool-bash + * out of win32 sessions, and the cold-start resolution closure for the pwsh + * rows' bare plugin names. + */ + import { afterEach, describe, expect, it } from 'vitest' -import { mkdtempSync, writeFileSync, rmSync, mkdirSync, readFileSync } from 'node:fs' +import { mkdtempSync, rmSync, readFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import yaml from 'js-yaml' import { entryListSchema } from '@deepseek-ai/cordis-plugin-include' import { evaluate } from '@deepseek-ai/cordis-plugin-loader' -import type { ProfileLayer } from '@deepseek-ai/dsh-app-boot' import { composeEntries, initProfile, loadProfile, PROFILES_DIR } from '@deepseek-ai/dsh-app-boot' -import { - BASE_BUNDLE, - resolveWindowsShellLayer, - WINDOWS_SHELL_PATCH_FILENAME, -} from '../src/windows-shell.ts' -const WINDOWS_PATCH = `- id: bash-sandbox - disabled: true -- insert: - - id: pwsh-sandbox - name: '@deepseek-ai/dsh-pwsh-sandbox' -` - -/** One fake bundle layer rooted in a temp directory. */ -function fakeLayer(packageName: string, dir: string): ProfileLayer { - return { packageName, packageDir: dir, patchPath: join(dir, 'cordis.patch.yml'), patches: [] } -} - -/** A base bundle layer whose package carries the Windows shell patch. */ -function baseLayerWithPatch(dir: string): ProfileLayer { - writeFileSync(join(dir, WINDOWS_SHELL_PATCH_FILENAME), WINDOWS_PATCH) - return fakeLayer(BASE_BUNDLE, dir) -} - -describe('resolveWindowsShellLayer', () => { - let base: string - afterEach(() => { if (base !== undefined) rmSync(base, { recursive: true, force: true }) }) - const tempBase = (): string => { - base = mkdtempSync(join(tmpdir(), 'dsh-windows-shell-')) - return base +/** + * The effective disabled state of one composed row on one platform: a `!!js` + * expression evaluates with a platform-scoped context (the `with` scope + * shadows the global `process`) so both outcomes pin on every host; a plain + * boolean is the value itself. + */ +function disabledOn(row: { disabled?: unknown }, platform: 'win32' | 'linux'): boolean { + const value = row.disabled + if (value !== null && typeof value === 'object' && '__jsExpr' in value) { + return Boolean(evaluate({ process: { platform } }, (value as { __jsExpr: string }).__jsExpr)) } + return value === true +} - it('never applies on POSIX hosts', () => { - expect(resolveWindowsShellLayer('linux', [baseLayerWithPatch(tempBase())], 'dsh')).toBeUndefined() - expect(resolveWindowsShellLayer('darwin', [baseLayerWithPatch(tempBase())], 'dsh')).toBeUndefined() - }) - - it('defaults Windows hosts to the pwsh platform layer', () => { - const layer = resolveWindowsShellLayer('win32', [baseLayerWithPatch(tempBase())], 'dsh') - expect(layer).toBeDefined() - expect(layer?.label.endsWith(WINDOWS_SHELL_PATCH_FILENAME)).toBe(true) - expect(layer?.patches).toEqual([ - { id: 'bash-sandbox', disabled: true }, - { insert: [{ id: 'pwsh-sandbox', name: '@deepseek-ai/dsh-pwsh-sandbox' }] }, - ]) - }) - - it('skips custom profiles without a base bundle', () => { - const other = fakeLayer('@deepseek-ai/dsh-custom', tempBase()) - expect(resolveWindowsShellLayer('win32', [other], 'dsh')).toBeUndefined() - }) - - it('fails loud when the base bundle ships no Windows shell patch', () => { - const base = tempBase() - mkdirSync(base, { recursive: true }) - // The overlay loader owns the fail-loud contract: the caller named this - // file, so its absence is a misconfiguration, not "no overlay". - expect(() => resolveWindowsShellLayer('win32', [fakeLayer(BASE_BUNDLE, base)], 'dsh')) - .toThrow(/dsh: failed to read overlay .*windows\.cordis\.patch\.yml/) - }) -}) - -describe('the shipped Windows composition (real bundle layers)', () => { +describe('the shipped shell composition (real bundle layers)', () => { let home: string afterEach(() => { if (home !== undefined) rmSync(home, { recursive: true, force: true }) }) // The app installation anchor, mirroring profile-boot.ts: the bundle layers @@ -78,66 +42,64 @@ describe('the shipped Windows composition (real bundle layers)', () => { // suite composes the shipped patch files, not test fixtures. const anchor = fileURLToPath(new URL('../package.json', import.meta.url)) - it('composes the win32 confined roster through the real patch layers', () => { + it('composes the confined pwsh roster on win32 and the bash roster on POSIX from the same rows', () => { home = mkdtempSync(join(tmpdir(), 'dsh-windows-home-')) initProfile(join(home, PROFILES_DIR, 'web'), ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app']) const profile = loadProfile('dsh', 'web', anchor, home) const warnings: string[] = [] - const win32 = resolveWindowsShellLayer('win32', profile.layers, 'dsh') - expect(win32).toBeDefined() const rows = composeEntries( - [...profile.layers.map(layer => layer.patches), win32!.patches], + profile.layers.map(layer => layer.patches), message => warnings.push(message), ) const byId = new Map(rows.map(row => [row.id, row])) - // Only the POSIX bash stack leaves the roster: the permission surface - // (sandbox/sandbox-policy/fs-sandbox, permission, approval) stays enabled - // exactly as on POSIX — the confined pwsh executor is what changes. - for (const id of ['bash-sandbox', 'tool-bash']) { - expect(byId.get(id)?.disabled, `row ${id}`).toBe(true) + // One shared patch set, two rosters: the shell stacks gate themselves. + for (const id of ['bash-sandbox', 'pwsh-sandbox', 'tool-pwsh']) { + expect(byId.has(id), `row ${id}`).toBe(true) } + expect(disabledOn(byId.get('bash-sandbox')!, 'win32'), 'bash-sandbox on win32').toBe(true) + expect(disabledOn(byId.get('bash-sandbox')!, 'linux'), 'bash-sandbox on linux').toBe(false) + expect(disabledOn(byId.get('pwsh-sandbox')!, 'win32'), 'pwsh-sandbox on win32').toBe(false) + expect(disabledOn(byId.get('pwsh-sandbox')!, 'linux'), 'pwsh-sandbox on linux').toBe(true) + expect(disabledOn(byId.get('tool-pwsh')!, 'win32'), 'tool-pwsh on win32').toBe(false) + expect(disabledOn(byId.get('tool-pwsh')!, 'linux'), 'tool-pwsh on linux').toBe(true) + // The Web surface owns the host tool-bash row on every platform: sessions + // mount their own preset rows instead. + expect(byId.get('tool-bash')?.disabled).toBe(true) + // The permission surface never moves: the sandbox/policy rows, the + // permission switcher, fs-sandbox, and the approval service stay enabled + // exactly as on POSIX — the confined pwsh executor is what changes. for (const id of ['permission', 'ui-permission', 'sandbox', 'sandbox-policy', 'fs-sandbox', 'approval']) { expect(byId.get(id)?.disabled, `row ${id}`).not.toBe(true) } - for (const id of ['pwsh-sandbox', 'tool-pwsh']) { - expect(byId.has(id), `inserted row ${id}`).toBe(true) - } // The launcher's cold-start module fallback BFS-links the apps/cli - // dependency closure into the profile's node_modules (the pwsh-local - // precedent), so every inserted bare plugin must resolve from there. + // dependency closure into the profile's node_modules, so every bare + // plugin name in the base patch must resolve from there. const cliManifest = JSON.parse(readFileSync(anchor, 'utf8')) as { dependencies?: Record } for (const name of ['@deepseek-ai/dsh-pwsh-sandbox', '@deepseek-ai/dsh-tool-pwsh']) { expect(cliManifest.dependencies?.[name], `cold-start closure must reach ${name}`).toBeDefined() } - // The patch touches only base-owned rows plus inserts, so the full web - // profile composes without any no-match warning. expect(warnings).toEqual([]) }) - it('leaves POSIX untouched and base-only profiles compose without warnings', () => { + it('base-only profiles carry both stacks with the same platform gating', () => { home = mkdtempSync(join(tmpdir(), 'dsh-windows-home-')) - initProfile(join(home, PROFILES_DIR, 'web'), ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app']) - const profile = loadProfile('dsh', 'web', anchor, home) - // POSIX: no platform layer, the bash stack stays enabled. - const posixRows = composeEntries(profile.layers.map(layer => layer.patches)) - const posixById = new Map(posixRows.map(row => [row.id, row])) - expect(posixById.get('bash-sandbox')?.disabled).not.toBe(true) - expect(posixById.has('pwsh-local')).toBe(false) - expect(posixById.has('pwsh-sandbox')).toBe(false) - - // A base-only custom profile (the DEFAULT_PROFILE_BUNDLES template): the - // patch touches only base-owned rows (bash-sandbox/tool-bash) plus its - // inserts, so the composition produces no no-match warning. initProfile(join(home, PROFILES_DIR, 'base-only'), ['@deepseek-ai/dsh-base']) - const baseOnly = loadProfile('dsh', 'base-only', anchor, home) - const baseWarnings: string[] = [] - const win32 = resolveWindowsShellLayer('win32', baseOnly.layers, 'dsh') - expect(win32).toBeDefined() - composeEntries( - [...baseOnly.layers.map(layer => layer.patches), win32!.patches], - message => baseWarnings.push(message), + const profile = loadProfile('dsh', 'base-only', anchor, home) + const warnings: string[] = [] + const rows = composeEntries( + profile.layers.map(layer => layer.patches), + message => warnings.push(message), ) - expect(baseWarnings).toEqual([]) + const byId = new Map(rows.map(row => [row.id, row])) + for (const id of ['bash-sandbox', 'tool-bash', 'pwsh-sandbox', 'tool-pwsh']) { + expect(byId.has(id), `row ${id}`).toBe(true) + } + // No web overlay: the tool rows keep their own gating too. + expect(disabledOn(byId.get('tool-bash')!, 'win32'), 'tool-bash on win32').toBe(true) + expect(disabledOn(byId.get('tool-bash')!, 'linux'), 'tool-bash on linux').toBe(false) + expect(disabledOn(byId.get('tool-pwsh')!, 'win32'), 'tool-pwsh on win32').toBe(false) + expect(disabledOn(byId.get('tool-pwsh')!, 'linux'), 'tool-pwsh on linux').toBe(true) + expect(warnings).toEqual([]) }) }) @@ -155,9 +117,9 @@ describe('shipped agent presets keep tool-bash off the win32 roster', () => { )) if (row === undefined) throw new TypeError(`preset ${preset} must mount tool-bash`) expect(row.disabled).toMatchObject({ __jsExpr: expect.any(String) as string }) - // The platform patch disables the host's tool-bash row on win32; the - // preset row must not re-enable it there. Evaluate the shipped expression - // with a platform-scoped context (the `with` scope shadows the global + // The base patch gates the host tool-bash row on win32; the preset row + // must not re-enable it there. Evaluate the shipped expression with a + // platform-scoped context (the `with` scope shadows the global // `process`) so both outcomes pin on every host. const expression = (row.disabled as { __jsExpr: string }).__jsExpr expect(Boolean(evaluate({ process: { platform: 'win32' } }, expression))).toBe(true) diff --git a/packages/bundle/base/README.md b/packages/bundle/base/README.md index 8b0db20274..bd38f39f58 100644 --- a/packages/bundle/base/README.md +++ b/packages/bundle/base/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) The shared dsh core as a profile bundle: [`cordis.patch.yml`](cordis.patch.yml) inserts every base plugin row — model adapters, the shared [`agent-default-model`](../../core/agent-default-model/README.md) selection, tools, persistence, policy, settings/credentials, telemetry, and host-level subagent providers — over the empty profile root, as the first layer of every profile's `dsh.profile.bundles` list. Codex and Claude Code providers load dormant; Agent Presets independently decide whether their agent contributes either model-facing delegation tool. Later bundle layers (e.g. [`dsh-web-app`](../web-app/README.md)) and the user's profile `cordis.patch.yml` override these rows by id; a patch replaces a row's whole `config`, so mode-specific values live in mode bundles, not here. The package has no runtime API; the profile composer resolves the patch through the `dsh.bundle.patch` manifest field, never through code. -Windows hosts booting a shipped profile additionally receive [`windows.cordis.patch.yml`](windows.cordis.patch.yml): it disables the POSIX-only bash stack (`bash-sandbox`/`tool-bash`) and inserts the sandbox-confined PowerShell stack (`@deepseek-ai/dsh-pwsh-sandbox`, `@deepseek-ai/dsh-tool-pwsh`). The permission surface stays exactly as on POSIX: `sandbox`/`sandbox-policy` enforce the file-effect policy through the Windows ACL restricted-token runner (the win32 chain of `dsh-sandbox-local` → `@deepseek-ai/dsh-sandbox-windows-acl`), the permission switcher and the approval service run unchanged, and `fs-sandbox` keeps fencing `ctx.fs` writes — mounting `dsh-fs-local` alongside it would double-register `ctx.fs` and fail the load. The launcher applies the layer between the bundle layers and the user layers on win32 hosts; a Windows host that prefers the unconfined local pwsh executor or full access overrides these rows through its profile or home `cordis.patch.yml` (the bash-restore recipe must be complete: disable `pwsh-sandbox`/`tool-pwsh` AND re-enable `bash-sandbox`/`tool-bash` — both executor families register the same `bash` service, so an incomplete recipe fails loud at load). POSIX hosts never receive it. +The patch gates both shell stacks by platform on its own rows: `bash-sandbox`/`tool-bash` carry `disabled: !!js process.platform === 'win32'` (bash has no Windows runner), and their twins `pwsh-sandbox`/`tool-pwsh` mount on win32 only with the inverted expression — one shared patch file, exactly one shell stack per host. The permission surface stays exactly as on POSIX: `sandbox`/`sandbox-policy` enforce the file-effect policy through the Windows ACL restricted-token runner (the win32 chain of `dsh-sandbox-local` → `@deepseek-ai/dsh-sandbox-windows-acl`), the permission switcher and the approval service run unchanged, and `fs-sandbox` keeps fencing `ctx.fs` writes — mounting `dsh-fs-local` alongside it would double-register `ctx.fs` and fail the load. A Windows host that prefers the unconfined local pwsh executor or full access overrides these rows through its profile or home `cordis.patch.yml` (the bash-restore recipe must be complete: disable `pwsh-sandbox`/`tool-pwsh` AND re-enable `bash-sandbox`/`tool-bash` — both executor families register the same `bash` service, so an incomplete recipe fails loud at load). POSIX hosts see the pwsh rows disabled. The row set and its rationale are documented inline in the patch file; the [generated composition graph](../../../apps/cli/composition.md) renders it. diff --git a/packages/bundle/base/README.zh.md b/packages/bundle/base/README.zh.md index ac5ab10a52..2c6ff8513b 100644 --- a/packages/bundle/base/README.zh.md +++ b/packages/bundle/base/README.zh.md @@ -4,7 +4,7 @@ 以 profile 组合包形式交付的共享 dsh 核心:[`cordis.patch.yml`](cordis.patch.yml) 在空的 profile 根之上插入全部基础插件行——模型适配器、共享的 [`agent-default-model`](../../core/agent-default-model/README.md) 选择、工具、持久化、策略、settings/credentials、遥测与宿主级 subagent provider——作为每个 profile 的 `dsh.profile.bundles` 列表中的第一层。Codex 与 Claude Code provider 以休眠状态加载;Agent Preset 分别决定自己的 agent 是否贡献任一面向模型的委派工具。后续的组合包层(例如 [`dsh-web-app`](../web-app/README.md))和用户 profile 的 `cordis.patch.yml` 按 id 覆盖这些行;patch 会替换目标行的整个 `config`,因此模式专属的值放在各模式组合包中,而不是这里。该包没有运行时 API;profile 组合器通过 manifest(元数据清单)的 `dsh.bundle.patch` 字段解析 patch,绝不通过代码。 -启动交付 profile 的 Windows 主机还会额外收到 [`windows.cordis.patch.yml`](windows.cordis.patch.yml):它禁用仅 POSIX 的 bash 栈(`bash-sandbox`/`tool-bash`),并插入沙盒受限的 PowerShell 栈(`@deepseek-ai/dsh-pwsh-sandbox`、`@deepseek-ai/dsh-tool-pwsh`)。权限面与 POSIX 完全一致:`sandbox`/`sandbox-policy` 通过 Windows ACL 受限令牌 runner(`dsh-sandbox-local` 的 win32 链 → `@deepseek-ai/dsh-sandbox-windows-acl`)执行文件效果策略,权限切换器与 approval 服务原样运行,`fs-sandbox` 继续围栏 `ctx.fs` 写入——在其旁再挂载 `dsh-fs-local` 会重复注册 `ctx.fs` 并在加载时失败。启动器在 win32 主机上把该层应用于 bundle 层与用户层之间;偏好不限权本地 pwsh 执行器或完整访问的 Windows 主机通过其 profile 或 home 的 `cordis.patch.yml` 覆盖这些行(bash 恢复配方必须完整:禁用 `pwsh-sandbox`/`tool-pwsh` 并重新启用 `bash-sandbox`/`tool-bash`——两个执行器家族注册同一个 `bash` 服务,配方不完整会在加载时 fail loud)。POSIX 主机永远不会收到它。 +patch 在自身上按平台门控两个 shell 栈:`bash-sandbox`/`tool-bash` 携带 `disabled: !!js process.platform === 'win32'`(bash 没有 Windows runner),它们的孪生行 `pwsh-sandbox`/`tool-pwsh` 以取反的表达式仅在 win32 挂载——同一份 patch 文件,每个宿主恰好挂载一个 shell 栈。权限面与 POSIX 完全一致:`sandbox`/`sandbox-policy` 通过 Windows ACL 受限令牌 runner(`dsh-sandbox-local` 的 win32 链 → `@deepseek-ai/dsh-sandbox-windows-acl`)执行文件效果策略,权限切换器与 approval 服务原样运行,`fs-sandbox` 继续围栏 `ctx.fs` 写入——在其旁再挂载 `dsh-fs-local` 会重复注册 `ctx.fs` 并在加载时失败。偏好不限权本地 pwsh 执行器或完整访问的 Windows 主机通过其 profile 或 home 的 `cordis.patch.yml` 覆盖这些行(bash 恢复配方必须完整:禁用 `pwsh-sandbox`/`tool-pwsh` 并重新启用 `bash-sandbox`/`tool-bash`——两个执行器家族注册同一个 `bash` 服务,配方不完整会在加载时 fail loud)。POSIX 主机看到的是被禁用的 pwsh 行。 行集合及其设计依据以行内注释写在 patch 文件里;[生成的组合图](../../../apps/cli/composition.md)负责渲染它。 diff --git a/packages/bundle/base/cordis.patch.yml b/packages/bundle/base/cordis.patch.yml index 2fb5ff10d3..4b8696f506 100644 --- a/packages/bundle/base/cordis.patch.yml +++ b/packages/bundle/base/cordis.patch.yml @@ -170,11 +170,22 @@ mode: !!js process.env.DSH_PERMISSION_MODE ?? 'workspace-write' workspaceRoot: !!js process.cwd() + # POSIX shell executor and its win32 twin: bash has no Windows runner, so + # each row gates itself on platform and exactly one shell stack mounts per + # host (both executors register the same 'bash' service). - id: bash-sandbox name: '@deepseek-ai/dsh-bash-sandbox' + disabled: !!js process.platform === 'win32' config: timeoutMs: 60000 + # The confined PowerShell executor, mounted on win32 only. Its config keeps + # the schema defaults (a 120s timeout); the 60s pin above is the bash + # executor's own knob, not a shared shell policy. + - id: pwsh-sandbox + name: '@deepseek-ai/dsh-pwsh-sandbox' + disabled: !!js process.platform !== 'win32' + - id: approval name: '@deepseek-ai/dsh-user-approval' config: @@ -197,8 +208,14 @@ - id: bash-env name: '@deepseek-ai/dsh-bash-env' + # The dialect tools gate with their executors: one shell tool per host. - id: tool-bash name: '@deepseek-ai/dsh-tool-bash' + disabled: !!js process.platform === 'win32' + + - id: tool-pwsh + name: '@deepseek-ai/dsh-tool-pwsh' + disabled: !!js process.platform !== 'win32' - id: tool-tasks name: '@deepseek-ai/dsh-tool-tasks' diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json index 655de66ec0..e6251833b2 100644 --- a/packages/bundle/base/package.json +++ b/packages/bundle/base/package.json @@ -23,7 +23,6 @@ "default": "./lib/invariant.js" }, "./cordis.patch.yml": "./cordis.patch.yml", - "./windows.cordis.patch.yml": "./windows.cordis.patch.yml", "./src/*": "./src/*", "./package.json": "./package.json" }, @@ -31,7 +30,6 @@ "lib/index.js", "lib/invariant.js", "cordis.patch.yml", - "windows.cordis.patch.yml", "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", diff --git a/packages/bundle/base/tests/base.spec.ts b/packages/bundle/base/tests/base.spec.ts index ba93c37a3c..5026039a42 100644 --- a/packages/bundle/base/tests/base.spec.ts +++ b/packages/bundle/base/tests/base.spec.ts @@ -3,12 +3,13 @@ * field must name a real, parseable patch list. */ -import { readFileSync } from 'node:fs' +import { existsSync, readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' import { resolve } from 'node:path' import { describe, expect, it } from 'vitest' import * as yaml from 'js-yaml' import { entryListSchema } from '@deepseek-ai/cordis-plugin-include' +import { evaluate } from '@deepseek-ai/cordis-plugin-loader' describe('dsh-base bundle', () => { it('declares a parseable patch list through the dsh.bundle.patch manifest field', () => { @@ -39,34 +40,37 @@ describe('dsh-base bundle', () => { }) }) - it('ships the Windows platform layer as the confined pwsh roster over the ACL runner chain', () => { + it('gates each shell stack by platform with a symmetric disabled expression', () => { const root = fileURLToPath(new URL('..', import.meta.url)) const parsed = yaml.load( - readFileSync(resolve(root, 'windows.cordis.patch.yml'), 'utf8'), + readFileSync(resolve(root, 'cordis.patch.yml'), 'utf8'), { schema: entryListSchema }, - ) as { - id?: string - disabled?: boolean - insert?: { id?: string; name?: string }[] - config?: { policy?: string } - }[] - const disables = parsed - .filter(patch => patch.disabled === true) - .map(patch => patch.id) - // Only the POSIX bash stack is disabled: the Windows roster confines the - // pwsh executor through the ACL runner chain, so the sandbox/policy rows, - // the permission switcher, fs-sandbox, and the approval service all stay - // enabled exactly as on POSIX — only the shell is swapped. - expect(disables).toEqual(['bash-sandbox', 'tool-bash']) - const inserted = parsed - .flatMap(patch => patch.insert ?? []) - .map(row => row.id) - expect(inserted).toEqual(['pwsh-sandbox', 'tool-pwsh']) - // The patch no longer touches the permission/approval surface at all. - expect(parsed.find(patch => patch.id === 'approval')).toBeUndefined() - expect(parsed.find(patch => patch.id === 'permission')).toBeUndefined() - expect(parsed.find(patch => patch.id === 'sandbox')).toBeUndefined() - expect(parsed.find(patch => patch.id === 'sandbox-policy')).toBeUndefined() - expect(parsed.find(patch => patch.id === 'fs-sandbox')).toBeUndefined() + ) + if (!Array.isArray(parsed)) throw new TypeError('base patch must parse to a patch list') + const rows = parsed.flatMap((patch): Record[] => + typeof patch === 'object' && patch !== null + ? (patch as { insert?: Record[] }).insert ?? [] + : [], + ) + // Symmetric gating: each stack's executor and tool rows carry the same + // platform fact, inverted between the bash and pwsh twins, so exactly one + // shell stack mounts per host. Evaluate with a platform-scoped context + // (the `with` scope shadows the global `process`) so both outcomes pin on + // every host. + for (const [id, win32, linux] of [ + ['bash-sandbox', true, false], + ['tool-bash', true, false], + ['pwsh-sandbox', false, true], + ['tool-pwsh', false, true], + ] as const) { + const row = rows.find(candidate => candidate.id === id) + if (row === undefined) throw new Error(`base patch must mount ${id}`) + const expression = (row.disabled as { __jsExpr?: string } | undefined)?.__jsExpr + if (expression === undefined) throw new Error(`${id} must gate on a !!js disabled expression`) + expect(Boolean(evaluate({ process: { platform: 'win32' } }, expression)), `${id} on win32`).toBe(win32) + expect(Boolean(evaluate({ process: { platform: 'linux' } }, expression)), `${id} on linux`).toBe(linux) + } + // The platform layer folded into these rows: no separate patch file ships. + expect(existsSync(resolve(root, 'windows.cordis.patch.yml'))).toBe(false) }) }) diff --git a/packages/bundle/base/windows.cordis.patch.yml b/packages/bundle/base/windows.cordis.patch.yml deleted file mode 100644 index 6db6a57098..0000000000 --- a/packages/bundle/base/windows.cordis.patch.yml +++ /dev/null @@ -1,31 +0,0 @@ -# The dsh-base Windows platform layer: applied by the dsh launcher on win32 -# hosts, between the bundle layers and the user layers. Windows confines -# through the ACL restricted-token runner (the win32 chain of -# dsh-sandbox-local → @deepseek-ai/dsh-sandbox-windows-acl), so the shipped -# stack is the SANDBOXED PowerShell executor plus the full permission -# surface: sandbox/sandbox-policy enforce the file-effect policy, the -# permission switcher and the approval service run exactly as on POSIX, and -# the fs row stays the base's sandboxed provider (fs-sandbox) — mounting -# dsh-fs-local alongside it would double-register ctx.fs and fail the load. -# Only the POSIX bash -# stack (bash-sandbox/tool-bash) is disabled — bash has no Windows runner. -# A Windows host that prefers the unconfined local pwsh executor or full -# access overrides these rows through its profile or home cordis.patch.yml. -# The bash-restore recipe must be complete: disable pwsh-sandbox and -# tool-pwsh AND re-enable bash-sandbox and tool-bash — both executor -# families register the same 'bash' service, so re-enabling the bash rows -# while pwsh-sandbox stays inserted fails loud at load on a duplicate -# registration. - -- id: bash-sandbox - disabled: true - -- id: tool-bash - disabled: true - -- insert: - - id: pwsh-sandbox - name: '@deepseek-ai/dsh-pwsh-sandbox' - - - id: tool-pwsh - name: '@deepseek-ai/dsh-tool-pwsh' diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index cfc10fda49..54890cf2be 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -129,9 +129,8 @@ function workspaceManifests(): WorkspaceManifest[] { } const packageFileExtras: Readonly> = { - // Profile bundles publish their dsh.bundle.patch layer beside the lib; - // dsh-base also ships the win32 shell platform layer the launcher reads. - '@deepseek-ai/dsh-base': ['cordis.patch.yml', 'windows.cordis.patch.yml'], + // Profile bundles publish their dsh.bundle.patch layer beside the lib. + '@deepseek-ai/dsh-base': ['cordis.patch.yml'], '@deepseek-ai/dsh-web-app': ['cordis.patch.yml'], '@deepseek-ai/dsh-headless': ['cordis.patch.yml'], '@deepseek-ai/dsh-client-ui-theme': ['lib/styles'], From 1b2a5c55dda469acebe109f5eb646491acb4ba8c Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Tue, 11 Aug 2026 15:23:54 +0800 Subject: [PATCH 03/81] chore: retrigger CI after a lost synchronize dispatch From 8e0cb2bdba97994a690c42f76d1456529e109963 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 11 Aug 2026 13:20:16 +0800 Subject: [PATCH 04/81] feat(client): improve workspace session browsing --- .../client/connection/src/client/fixture.ts | 17 ++-- .../src/client/contract/sessions-port.ts | 1 + .../runtime/src/client/sessions/lineage.ts | 2 + .../runtime/src/client/sessions/manager.ts | 20 +++-- .../runtime/src/client/sessions/service.ts | 4 + .../client/ui-primitives/src/Menu.module.css | 9 +++ packages/client/ui-primitives/src/Menu.tsx | 13 ++- .../src/client/WorkspaceBrowser.module.css | 26 +++++- .../src/client/WorkspaceBrowser.tsx | 81 ++++++++++++++----- .../client/ui-workspace/src/client/locales.ts | 12 +++ .../src/client/rows/Rows.module.css | 10 +-- .../ui-workspace/src/client/rows/Rows.tsx | 4 +- .../client/ui-workspace/src/client/stores.ts | 13 ++- .../client/ui-workspace/src/client/tree.ts | 40 ++++++--- packages/host/apiproxy/src/api-proxy.ts | 3 + .../host/apiproxy/src/api/events.schema.ts | 1 + packages/host/apiproxy/src/api/events.ts | 1 + .../host/apiproxy/src/api/sessions.schema.ts | 1 + packages/host/apiproxy/src/api/sessions.ts | 2 + 19 files changed, 198 insertions(+), 62 deletions(-) diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 08837db9c5..07e437ecf1 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -1387,10 +1387,11 @@ interface FixtureWorld { /** Build the fixture's legacy API and Remote RPC faces over one state graph. */ function createFixtureWorld(options: FixtureOptions): FixtureWorld { // The resident fixture sessions all carry history, so none of them is blank. + const fixtureSessionsNow = Date.now() const sessions: SessionSummary[] = options.empty ? [] : [ - { sessionId: sid('fx-alpha'), updatedAt: Date.now(), running: true, blank: false, cwd: '/tmp/fixture' }, - { sessionId: sid('fx-beta'), updatedAt: Date.now() - 60_000, running: false, blank: false, parentSessionId: sid('fx-alpha'), cwd: '/tmp/fixture' }, - { sessionId: sid('fx-gamma'), updatedAt: Date.now() - 120_000, running: false, blank: false, cwd: '/tmp/fixture' }, + { sessionId: sid('fx-alpha'), createdAt: fixtureSessionsNow - 180_000, updatedAt: fixtureSessionsNow, running: true, blank: false, cwd: '/tmp/fixture' }, + { sessionId: sid('fx-beta'), createdAt: fixtureSessionsNow - 120_000, updatedAt: fixtureSessionsNow - 60_000, running: false, blank: false, parentSessionId: sid('fx-alpha'), cwd: '/tmp/fixture' }, + { sessionId: sid('fx-gamma'), createdAt: fixtureSessionsNow - 60_000, updatedAt: fixtureSessionsNow - 120_000, running: false, blank: false, cwd: '/tmp/fixture' }, ] const logs = new Map([[sid('fx-alpha'), buildAlphaLog()]]) const modelSelections = new Map(sessions.map(session => [ @@ -2046,15 +2047,16 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { return ok(request, { sessionId: requestedId }) } } + const createdAt = Date.now() const created: SessionSummary = { - sessionId: requestedId ?? sid(`fx-${nextSession++}`), updatedAt: Date.now(), running: false, blank: true, cwd, + sessionId: requestedId ?? sid(`fx-${nextSession++}`), createdAt, updatedAt: createdAt, running: false, blank: true, cwd, } sessions.push(created) modelSelections.set(created.sessionId, { provider: 'deepseek-official', model: 'deepseek-v4-flash' }) attachedSessions += 1 const emitSession = (): void => { // Mirrors the host: the frame fires at creation, so blank is constantly true. - emitHost({ type: 'host/session-added', sessionId: created.sessionId, blank: true, cwd }) + emitHost({ type: 'host/session-added', sessionId: created.sessionId, createdAt, blank: true, cwd }) } if (workspace !== undefined && options.failWorkspaceAttach) { emitSession() @@ -2121,15 +2123,16 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { } let cut = boundary.seq + 1 while (cut < log.length && log[cut]?.type !== 'turn/start') cut++ + const createdAt = Date.now() const child: SessionSummary = { - sessionId: sid(`fx-${nextSession++}`), updatedAt: Date.now(), running: false, blank: false, + sessionId: sid(`fx-${nextSession++}`), createdAt, updatedAt: createdAt, running: false, blank: false, parentSessionId: sessionId, ...source.cwd === undefined ? {} : { cwd: source.cwd }, } logs.set(child.sessionId, log.slice(0, cut)) sessions.push(child) emitHost({ - type: 'host/session-added', sessionId: child.sessionId, blank: false, + type: 'host/session-added', sessionId: child.sessionId, createdAt, blank: false, parentSessionId: sessionId, ...source.cwd === undefined ? {} : { cwd: source.cwd }, }) diff --git a/packages/client/runtime/src/client/contract/sessions-port.ts b/packages/client/runtime/src/client/contract/sessions-port.ts index 466e26fe12..5f694921b1 100644 --- a/packages/client/runtime/src/client/contract/sessions-port.ts +++ b/packages/client/runtime/src/client/contract/sessions-port.ts @@ -16,6 +16,7 @@ export interface SessionsPortSummary { /** Empty-log bit (blank sessions are reused by New Session instead of minting another). */ blank: boolean cwd?: string + createdAt: number updatedAt: number } diff --git a/packages/client/runtime/src/client/sessions/lineage.ts b/packages/client/runtime/src/client/sessions/lineage.ts index cf8fa0834d..5e54cb59fd 100644 --- a/packages/client/runtime/src/client/sessions/lineage.ts +++ b/packages/client/runtime/src/client/sessions/lineage.ts @@ -17,6 +17,7 @@ export interface TitledSessionSummary extends SessionSummary { export interface SessionListEntry { sessionId: SessionId title?: string + createdAt: number updatedAt: number running: boolean /** Empty-log bit mirrored from the summary; lists hide blank sessions (filtering stays with the consumer). */ @@ -77,6 +78,7 @@ export function flattenLineage( const pendingInteraction = pendingInteractions?.get(s.sessionId) out.push({ ...s, + createdAt: s.createdAt ?? s.updatedAt, ...(pendingInteraction === undefined ? {} : { pendingInteraction }), completed: completed?.has(s.sessionId) ?? false, depth, diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index 741b3d36b2..a6e7bf867c 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -540,8 +540,9 @@ export class SessionManager { : { ...(opts.cwd === undefined ? {} : { cwd: opts.cwd }), ...shared } const { result } = await this.api.sessions.create(payload) if (result.ok) { + const createdAt = Date.now() this.recordMutation({ kind: 'upsert', summary: { - sessionId: result.value.sessionId, updatedAt: Date.now(), running: false, blank: true, + sessionId: result.value.sessionId, createdAt, updatedAt: createdAt, running: false, blank: true, ...(opts.cwd !== undefined ? { cwd: opts.cwd } : {}), ...(result.value.agentPreset !== undefined ? { agentPreset: result.value.agentPreset } : {}), } }) @@ -551,9 +552,11 @@ export class SessionManager { // so expose it immediately as Ungrouped while the caller keeps the // prompt buffer and decides whether to retry attachment. if (publishedSessionId !== undefined) { + const createdAt = Date.now() this.recordMutation({ kind: 'upsert', summary: { sessionId: publishedSessionId, - updatedAt: Date.now(), + createdAt, + updatedAt: createdAt, running: false, blank: true, } }) @@ -587,8 +590,9 @@ export class SessionManager { ? result.value.sessionId : workspaceAttachSessionId(result.error) if (childId !== undefined) { + const createdAt = Date.now() this.recordMutation({ kind: 'upsert', summary: { - sessionId: childId, updatedAt: Date.now(), running: false, blank: false, + sessionId: childId, createdAt, updatedAt: createdAt, running: false, blank: false, parentSessionId: opts.sessionId, ...(source?.cwd !== undefined ? { cwd: source.cwd } : {}), } }) @@ -615,8 +619,9 @@ export class SessionManager { * @param agentPreset - the preset id the host confirmed. */ noteAgentPreset(sessionId: SessionId, agentPreset: string): void { + const createdAt = Date.now() this.recordMutation({ kind: 'upsert', summary: { - sessionId, updatedAt: Date.now(), running: false, blank: true, agentPreset, + sessionId, createdAt, updatedAt: createdAt, running: false, blank: true, agentPreset, } }) } @@ -783,8 +788,10 @@ export class SessionManager { const frame = envelope.payload switch (frame.type) { case 'host/session-added': { + const createdAt = frame.createdAt ?? Date.now() this.mergeSummary({ - sessionId: frame.sessionId, updatedAt: Date.now(), running: false, blank: frame.blank, + sessionId: frame.sessionId, createdAt, updatedAt: createdAt, + running: false, blank: frame.blank, ...(frame.parentSessionId !== undefined ? { parentSessionId: frame.parentSessionId } : {}), ...(frame.origin !== undefined ? { origin: frame.origin } : {}), ...(frame.cwd !== undefined ? { cwd: frame.cwd } : {}), @@ -1037,7 +1044,8 @@ export class SessionManager { const items = fresh.map((entry) => { const prev = this.entryCache.get(entry.sessionId) if ( - prev !== undefined && prev.updatedAt === entry.updatedAt && prev.running === entry.running + prev !== undefined && prev.createdAt === entry.createdAt + && prev.updatedAt === entry.updatedAt && prev.running === entry.running && prev.blank === entry.blank && prev.agentPreset === entry.agentPreset && prev.parentSessionId === entry.parentSessionId && prev.cwd === entry.cwd && prev.origin === entry.origin && prev.title === entry.title && prev.depth === entry.depth diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index 2b7267402e..c8e5412696 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -66,6 +66,8 @@ export interface SessionSummary { * selected blank entry. */ blank: boolean + /** Durable session creation time. */ + createdAt: number updatedAt: number /** Current host-computed projection values retained by the object layer. */ projectionValues?: Readonly> @@ -667,6 +669,7 @@ export class SessionsService implements ISessions { running: entry.running, ...(entry.completed ? { completed: true } : {}), blank: entry.blank, + createdAt: entry.createdAt, updatedAt: entry.updatedAt, ...(entry.pendingInteraction === undefined ? {} @@ -700,6 +703,7 @@ export class SessionsService implements ISessions { origin: 'subagent', running: child.activity === 'running', blank: false, + createdAt: 0, updatedAt: 0, } } else if (summary.displayTitle !== displayTitle) { diff --git a/packages/client/ui-primitives/src/Menu.module.css b/packages/client/ui-primitives/src/Menu.module.css index 20c1584c1a..7bc0c5aced 100644 --- a/packages/client/ui-primitives/src/Menu.module.css +++ b/packages/client/ui-primitives/src/Menu.module.css @@ -115,6 +115,15 @@ background: var(--dsw-alias-interactive-bg-hover); } +.denseList .item { + min-height: 34px; + padding-block: 5px; +} + +.denseList .label { + padding-block: 4px; +} + .list.compactList, .submenu.compactList { min-width: 164px; diff --git a/packages/client/ui-primitives/src/Menu.tsx b/packages/client/ui-primitives/src/Menu.tsx index ea7e51b478..46c30b8afb 100644 --- a/packages/client/ui-primitives/src/Menu.tsx +++ b/packages/client/ui-primitives/src/Menu.tsx @@ -62,6 +62,7 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 } * @param props.anchor - the trigger element (rendered in place). * @param props.items - selectable rows and optional separators. * @param props.selectedId - row shown as selected. + * @param props.selectedIds - rows shown as selected when a menu contains independent option groups. * @param props.onSelect - row click callback (not called for disabled rows or submenu parents that only open children). * @param props.onClose - invoked on outside click or Escape. * @param props.align - list alignment against the anchor (default 'start'). @@ -74,6 +75,7 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 } * both trigger and list for the pointer grace (default false keeps it open * until outside click/Escape/selection). The grace makes the 4px trigger->list * gap and a brief overshoot survivable; coming back cancels the close. + * @param props.dense - reduce vertical row spacing without changing the standard typography or card width. * @param props.compact - use reduced menu typography and spacing. * @param props.getAnchorRect - portal mode only: supply the anchor rect * directly (e.g. from a host-owned trigger button) instead of measuring the @@ -85,18 +87,20 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 } * by a hairline; they stay visible while the items above scroll. * @returns anchor wrapper with the conditional list. */ -export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, compact = false, getAnchorRect, footer, className }: { +export function Menu({ open, anchor, items, selectedId, selectedIds, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, dense = false, compact = false, getAnchorRect, footer, className }: { open: boolean anchor: ReactNode items: readonly MenuEntry[] footer?: readonly MenuEntry[] selectedId?: string | undefined + selectedIds?: readonly string[] | undefined onSelect: (id: string) => void onClose: () => void align?: 'start' | 'end' side?: 'bottom' | 'top' | 'right' portal?: boolean closeOnPointerLeave?: boolean + dense?: boolean compact?: boolean getAnchorRect?: () => DOMRect | null className?: string @@ -204,6 +208,7 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align } const hasSub = entry.submenu !== undefined && entry.submenu.length > 0 const subOpen = hasSub && openSubmenuId === entry.id + const selected = entry.id === selectedId || selectedIds?.includes(entry.id) === true return (
{entry.icon}} {entry.label} {/* Selection marker is a trailing check (figma .Menu_cell), not a fill. */} - {entry.id === selectedId && } + {selected && } {subOpen && entry.submenu !== undefined && (
@@ -260,7 +265,7 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align const list = open && (
sidebar fill so it tracks the theme. */ .fade { position: absolute; left: 0; right: var(--dsh-session-list-edge-inset); bottom: 0; - height: 72px; + height: 24px; background: linear-gradient(to bottom, transparent, var(--dsw-specific-sidebar-fill)); pointer-events: none; } @@ -229,9 +229,9 @@ - var(--dsh-session-list-scrollbar-width) - var(--dsh-session-list-scrollbar-offset) ); - /* Clears the 72px bottom fade overlay: at scroll end the last row sits + /* Clears the compact bottom fade overlay: at scroll end the last row sits above the gradient instead of under it. */ - padding-bottom: 48px; + padding-bottom: 16px; scrollbar-gutter: stable; } @@ -258,6 +258,24 @@ margin-top: 4px; } +.sessionOverflowButton { + width: 100%; + height: 30px; + border: none; + border-radius: 8px; + padding: 0 12px 0 28px; + background: transparent; + cursor: pointer; + text-align: left; + font-size: 12px; + color: var(--dsw-alias-label-tertiary); +} + +.sessionOverflowButton:hover { + background: var(--dsw-alias-interactive-bg-hover); + color: var(--dsw-alias-label-secondary); +} + .empty { padding: 16px 12px; color: var(--dsw-alias-label-tertiary); diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx index a049b3f3d1..4faa479e70 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx @@ -1,6 +1,6 @@ /** * The workspace/session browsing region filling the sidebar shell's - * `sidebar.workspaces` hole: section header (title + group-by + add + * `sidebar.workspaces` hole: section header (title + view options + add * workspace), search, the grouped tree or flat list, and the workspace * dialogs. Wide state renders the full browser; rail state renders the two * region icons (search / add workspace), each requesting shell expansion @@ -19,7 +19,7 @@ import type { SessionSearchResultItem, WorkspaceId, WorkspaceView, } from '@deepseek-ai/dsh-client-runtime/client' import type { WorkspaceBrowserProps } from './contract/slots.ts' -import type { SessionNode } from './tree.ts' +import type { SessionNode, SessionOrderBy } from './tree.ts' import { deriveFlat, deriveGroups, deriveSearchResults, UNGROUPED_KEY } from './tree.ts' import { ProjectRowItem, SearchResultItem, SessionNodeItem } from './rows/Rows.tsx' import { WorkspacePickFlow } from './WorkspacePicker.tsx' @@ -34,6 +34,8 @@ const EXPAND_SLIDE_MS = 300 const SEARCH_DEBOUNCE_MS = 250 /** `session.search` wire bound, measured in JavaScript UTF-16 code units. */ const SEARCH_QUERY_MAX_CODE_UNITS = 500 +/** Session rows visible per Workspace before the local overflow control. */ +const COLLAPSED_SESSION_LIMIT = 6 /** Keep controlled input and RPC payload inside the session.search wire contract. */ function sanitizeSearchQuery(value: string): string { @@ -51,10 +53,12 @@ function toggled(list: readonly string[], key: string): string[] { return list.includes(key) ? list.filter(k => k !== key) : [...list, key] } -/** Group-by strategy menu; own open state so it resets with the wide chrome. */ -function GroupByMenu({ groupBy, onPick, t }: { +/** Grouping and ordering menu; own open state so it resets with the wide chrome. */ +function ViewOptionsMenu({ groupBy, orderBy, onGroupPick, onOrderPick, t }: { groupBy: 'workspace' | 'flat' - onPick: (mode: 'workspace' | 'flat') => void + orderBy: SessionOrderBy + onGroupPick: (mode: 'workspace' | 'flat') => void + onOrderPick: (mode: SessionOrderBy) => void t: WorkspaceBrowserProps['t'] }) { const [open, setOpen] = useState(false) @@ -66,14 +70,19 @@ function GroupByMenu({ groupBy, onPick, t }: { { type: 'label' as const, id: 'group-by', text: t('groupBy.label') }, { id: 'workspace', label: t('groupBy.workspace') }, { id: 'flat', label: t('groupBy.flat') }, + { type: 'label' as const, id: 'order-by', text: t('orderBy.label') }, + { id: 'manual', label: t('orderBy.manual'), disabled: groupBy !== 'workspace' }, + { id: 'created', label: t('orderBy.created') }, + { id: 'updated', label: t('orderBy.updated') }, ]} - selectedId={groupBy} + selectedIds={[groupBy, orderBy]} onSelect={(id) => { - /* v8 ignore next -- narrowing guard: the heading label is not selectable, so the only arriving ids are the two modes. */ - if (id === 'workspace' || id === 'flat') onPick(id) + if (id === 'workspace' || id === 'flat') onGroupPick(id) + else if (id === 'manual' || id === 'created' || id === 'updated') onOrderPick(id) setOpen(false) }} align="end" + dense // Portal: the section header clips overflow, so an in-place list would // be cut off at the header's bounds. portal @@ -116,16 +125,19 @@ type SessionTreeProps = Pick< 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 + /** Visual order; only manual mode exposes durable Workspace dragging. */ + orderBy: SessionOrderBy } /** The scrolling session tree; unmounting at collapse settle drops the sessions subscription and expansion state. */ function SessionTree({ useSessions, startSession, open, forkSession, workspaces, archivedSessionIds, - onRenameRequest, onDeleteRequest, onSessionRename, onSessionArchive, insertSessionBefore, t, + onRenameRequest, onDeleteRequest, onSessionRename, onSessionArchive, insertSessionBefore, orderBy, t, }: SessionTreeProps) { const list = useSessions(s => s) const current = list.current const [expandedProjects, setExpandedProjects] = useState([]) + const [expandedSessionGroups, setExpandedSessionGroups] = useState([]) // Transient drag viewing state (never store-bound; order truth stays Host-side). const [drag, setDrag] = useState(null) const currentGroup = current === undefined @@ -137,8 +149,8 @@ function SessionTree({ setExpandedProjects(l => (l.includes(currentGroup) ? l : [...l, currentGroup])) }, [current, currentGroup]) const groups = useMemo( - () => deriveGroups(list, workspaces, archivedSessionIds, { expandedProjects }), - [list, workspaces, archivedSessionIds, expandedProjects], + () => deriveGroups(list, workspaces, archivedSessionIds, { expandedProjects }, orderBy), + [list, workspaces, archivedSessionIds, expandedProjects, orderBy], ) const now = Date.now() @@ -173,11 +185,14 @@ function SessionTree({ }, }} /> - {group.sessions.map((node, index) => { + {(expandedSessionGroups.includes(group.key) + ? group.sessions + : group.sessions.slice(0, COLLAPSED_SESSION_LIMIT) + ).map((node, index) => { // Draggable: real-workspace session rows. The drag // never leaves its group — rows of other groups show no markers // and reject drops (visual movement confined to this section). - const draggable = group.workspaceId !== undefined + const draggable = group.workspaceId !== undefined && orderBy === 'manual' const sameGroupDrag = drag !== null && drag.workspaceId === group.workspaceId const dragProps = !draggable || group.workspaceId === undefined ? undefined : { start: () => { @@ -223,6 +238,18 @@ function SessionTree({ /> ) })} + {group.sessions.length > COLLAPSED_SESSION_LIMIT && ( + + )}
))}
@@ -232,11 +259,14 @@ function SessionTree({ } /** The flat "In one list" body: every session a top-level row, newest-first. */ -function FlatList({ useSessions, open, forkSession, onSessionRename, onSessionArchive, archivedSessionIds, t }: Pick< - SessionTreeProps, 'useSessions' | 'open' | 'forkSession' | 'onSessionRename' | 'onSessionArchive' | 'archivedSessionIds' | 't' +function FlatList({ useSessions, open, forkSession, onSessionRename, onSessionArchive, archivedSessionIds, orderBy, t }: Pick< + SessionTreeProps, 'useSessions' | 'open' | 'forkSession' | 'onSessionRename' | 'onSessionArchive' | 'archivedSessionIds' | 'orderBy' | 't' >) { const list = useSessions(s => s) - const rows = useMemo(() => deriveFlat(list, archivedSessionIds), [list, archivedSessionIds]) + const rows = useMemo( + () => deriveFlat(list, archivedSessionIds, orderBy), + [list, archivedSessionIds, orderBy], + ) const now = Date.now() return (
@@ -367,6 +397,12 @@ export function WorkspaceBrowser({ // flow reads): a composition without a picking affordance can add nothing. const directoryFlowAvailable = useDirectoryFlow(occupied => occupied) const groupBy = useStore(s => s.groupBy) + // A live HMR handoff can retain the pre-ordering store instance until the + // slot is remounted; manual is the established Workspace order. + const orderBy = useStore(s => s.orderBy ?? 'manual') + // A flat list has no single Workspace account to drag. Keep the stored + // grouped preference intact while presenting the flat list by recency. + const effectiveOrderBy = groupBy === 'flat' && orderBy === 'manual' ? 'updated' : orderBy // The query outlives the tree and the input (both wide-only) so collapsing // does not silently drop an in-progress filter. const [query, setQuery] = useState('') @@ -548,7 +584,15 @@ export function WorkspaceBrowser({ {groupBy === 'flat' ? t('section.sessions') : t('section.workspaces')} )} - {wide && { actions.setGroupBy(mode) }} t={t} />} + {wide && ( + { actions.setGroupBy(mode) }} + onOrderPick={(mode) => { actions.setOrderBy(mode) }} + t={t} + /> + )} {/* Adding is the button's one action, so a composition with no picking affordance has nothing to offer here: the region hides the button rather than leaving a dead one in the header. */} @@ -644,7 +688,7 @@ export function WorkspaceBrowser({ ) : ( @@ -658,6 +702,7 @@ export function WorkspaceBrowser({ startSession={startSession} open={open} insertSessionBefore={insertSessionBefore} + orderBy={orderBy} t={t} onRenameRequest={(workspaceId, currentTitle) => { setRenameTarget({ workspaceId, currentTitle }) diff --git a/packages/client/ui-workspace/src/client/locales.ts b/packages/client/ui-workspace/src/client/locales.ts index 30fe6bfcc0..c816326157 100644 --- a/packages/client/ui-workspace/src/client/locales.ts +++ b/packages/client/ui-workspace/src/client/locales.ts @@ -13,6 +13,12 @@ export const zh = { 'groupBy.label': '分组方式', 'groupBy.workspace': '按工作区', 'groupBy.flat': '单列表', + 'orderBy.label': '排序方式', + 'orderBy.manual': '手动排序', + 'orderBy.created': '创建时间', + 'orderBy.updated': '最近更新', + 'sessions.expand': '展开其余 {n} 个会话', + 'sessions.collapse': '收起', 'empty.none': '暂无会话', 'empty.noMatches': '无匹配结果', 'workspace.add': '添加工作区', @@ -76,6 +82,12 @@ export const en = { 'groupBy.label': 'Group by', 'groupBy.workspace': 'WorkSpace', 'groupBy.flat': 'In one list', + 'orderBy.label': 'Order by', + 'orderBy.manual': 'Manual', + 'orderBy.created': 'Date created', + 'orderBy.updated': 'Last updated', + 'sessions.expand': 'Show {n} more sessions', + 'sessions.collapse': 'Show less', 'empty.none': 'No sessions yet', 'empty.noMatches': 'No matches', 'workspace.add': 'Add workspace', diff --git a/packages/client/ui-workspace/src/client/rows/Rows.module.css b/packages/client/ui-workspace/src/client/rows/Rows.module.css index 612eb3e306..71be79d081 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.module.css +++ b/packages/client/ui-workspace/src/client/rows/Rows.module.css @@ -82,14 +82,10 @@ color: var(--dsw-alias-label-secondary); } -/* Two-line row: the leading slot (folder/chevron), title, and trailing - actions all top-align on the 20px first text line (figma cell) — content - is 42px (20 + 2 + 20), so 6px vertical padding centers the block. */ +/* Compact one-line Workspace row after removing the session-count subtitle. */ .projectRow { - height: 54px; - align-items: flex-start; - padding-top: 6px; - padding-bottom: 6px; + height: 36px; + align-items: center; box-sizing: border-box; } diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index 71c0b05af5..5ed290f20d 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -67,7 +67,7 @@ function WorkspaceHoverContent({ label, cwd, createdAt, t }: { } /** - * Project (workspace) header row: 54px, folder + title + session count; + * Project (workspace) header row: folder + title; * hover reveals the chevron and create button, and dwelling on a real * Workspace shows its hover card (the ungrouped bucket has none). * `containsCurrent` arrives on the node (derivation fact, no renderer scan). @@ -89,7 +89,6 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions, t }: { // The ungrouped bucket has no workspace title: its label is dictionary copy. const label = row.workspaceId === undefined ? t('group.ungrouped') : row.label const active = group.expanded && group.containsCurrent - const count = t(row.sessionCount === 1 ? 'sessions.count.one' : 'sessions.count.other', { n: row.sessionCount }) const [menuOpen, setMenuOpen] = useState(false) const workspaceMenuItems = [ { id: 'rename', label: t('rename'), icon: }, @@ -110,7 +109,6 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions, t }: { {label} - {count} {actions !== undefined && ( diff --git a/packages/client/ui-workspace/src/client/stores.ts b/packages/client/ui-workspace/src/client/stores.ts index ed89d80d9e..91abedccfa 100644 --- a/packages/client/ui-workspace/src/client/stores.ts +++ b/packages/client/ui-workspace/src/client/stores.ts @@ -9,9 +9,11 @@ import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-run /** Session-list grouping mode: workspace sections or one flat recency list. */ export type WorkspaceGroupBy = 'workspace' | 'flat' +/** Session order: durable Workspace order or a derived timestamp order. */ +export type WorkspaceOrderBy = 'manual' | 'created' | 'updated' -/** Workspace browser viewing state (grouping mode only; transient UI facts stay component-local). */ -type WorkspaceViewState = { groupBy: WorkspaceGroupBy } +/** Workspace browser viewing state; transient expansion facts stay component-local. */ +type WorkspaceViewState = { groupBy: WorkspaceGroupBy; orderBy: WorkspaceOrderBy } /** * Annotation twin of the actions literal below (the export needs a declared @@ -19,6 +21,7 @@ type WorkspaceViewState = { groupBy: WorkspaceGroupBy } */ type WorkspaceViewActions = { setGroupBy: (draft: WorkspaceViewState, mode: WorkspaceGroupBy) => void + setOrderBy: (draft: WorkspaceViewState, mode: WorkspaceOrderBy) => void } /** @@ -27,10 +30,12 @@ type WorkspaceViewActions = { */ export function createWorkspaceViewStore(): EngineStoreHandle { return defineStore({ - init: (): WorkspaceViewState => ({ groupBy: 'workspace' }), - persist: 'dsh.workspace.view', + init: (): WorkspaceViewState => ({ groupBy: 'workspace', orderBy: 'manual' }), + // The added order field changes the whole-value persistence format. + persist: 'dsh.workspace.view.v2', actions: { setGroupBy: (d, mode: WorkspaceGroupBy) => { d.groupBy = mode }, + setOrderBy: (d, mode: WorkspaceOrderBy) => { d.orderBy = mode }, }, }) } diff --git a/packages/client/ui-workspace/src/client/tree.ts b/packages/client/ui-workspace/src/client/tree.ts index 008ab687f7..5120e31931 100644 --- a/packages/client/ui-workspace/src/client/tree.ts +++ b/packages/client/ui-workspace/src/client/tree.ts @@ -29,9 +29,13 @@ export interface SessionNode { runningSubagentCount: number /** Finished running while not selected and not yet opened (the green "done" reminder dot). */ completed: boolean + createdAt: number updatedAt: number } +/** Session order selected by the Workspace browser. */ +export type SessionOrderBy = 'manual' | 'created' | 'updated' + /** One workspace group section: header row facts + visible top-level session rows. */ export interface GroupNode { /** Group key: the workspace id or {@link UNGROUPED_KEY}. */ @@ -104,6 +108,16 @@ function byRecency(a: SessionSummary, b: SessionSummary): number { return a.id < b.id ? -1 : 1 } +/** Newest-created first, id as the deterministic tiebreak. */ +function byCreation(a: SessionSummary, b: SessionSummary): number { + if (b.createdAt !== a.createdAt) return b.createdAt - a.createdAt + return a.id < b.id ? -1 : 1 +} + +function sortSessions(sessions: SessionSummary[], orderBy: Exclude): void { + sessions.sort(orderBy === 'created' ? byCreation : byRecency) +} + /** * Ordinary sessions are visible; among blank sessions, only the current one * is visible. Subagent children use their parent header catalog; archived @@ -133,12 +147,10 @@ function buildGroup( createdAt: number | undefined, label: string, members: readonly SessionSummary[], - order: 'account' | 'recency', + orderBy: SessionOrderBy, ): Group { const sessions = [...members] - // Workspace order is workspace.sessionIds; only Ungrouped lacks an account - // order and therefore falls back to recency. - if (order === 'recency') sessions.sort(byRecency) + if (orderBy !== 'manual') sortSessions(sessions, orderBy) return { key, workspaceId, cwd, createdAt, label, sessions } } @@ -151,6 +163,7 @@ function groupByWorkspace( list: SessionListState, workspaces: readonly WorkspaceView[], archived: ReadonlySet, + orderBy: SessionOrderBy, ): Group[] { const groups: Group[] = [] const accounted = new Set() @@ -165,7 +178,7 @@ function groupByWorkspace( } groups.push(buildGroup( workspace.workspaceId, workspace.workspaceId, workspace.path, - Date.parse(workspace.createdAt), workspace.title, members, 'account', + Date.parse(workspace.createdAt), workspace.title, members, orderBy, )) } const stray = list.ids @@ -173,7 +186,10 @@ function groupByWorkspace( .filter((s): s is SessionSummary => 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')) + groups.push(buildGroup( + UNGROUPED_KEY, undefined, undefined, undefined, UNGROUPED_LABEL, stray, + orderBy === 'manual' ? 'updated' : orderBy, + )) } return groups } @@ -189,6 +205,7 @@ function sessionNode( running: s.running, runningSubagentCount: descendants.get(s.id)?.runningCount ?? 0, completed: s.completed === true, + createdAt: s.createdAt, updatedAt: s.updatedAt, ...(s.pendingInteraction === undefined ? {} : { pendingInteraction: s.pendingInteraction }), } @@ -213,6 +230,7 @@ export function deriveGroups( workspaces: readonly WorkspaceView[], archivedSessionIds: readonly SessionId[], view: TreeView, + orderBy: SessionOrderBy = 'manual', ): GroupNode[] { const archived = new Set(archivedSessionIds) const expandedProjects = new Set(view.expandedProjects) @@ -222,7 +240,7 @@ export function deriveGroups( : (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, archived)) { + for (const g of groupByWorkspace(list, workspaces, archived, orderBy)) { const expanded = expandedProjects.has(g.key) groups.push({ key: g.key, @@ -248,7 +266,11 @@ export function deriveGroups( * @param archivedSessionIds - registry-global archive set. * @returns flat rows in render order. */ -export function deriveFlat(list: SessionListState, archivedSessionIds: readonly SessionId[]): SessionNode[] { +export function deriveFlat( + list: SessionListState, + archivedSessionIds: readonly SessionId[], + orderBy: SessionOrderBy = 'updated', +): SessionNode[] { const archived = new Set(archivedSessionIds) const descendants = indexSubagentDescendants(list.byId) const rows: SessionSummary[] = [] @@ -257,7 +279,7 @@ export function deriveFlat(list: SessionListState, archivedSessionIds: readonly if (s === undefined || !sessionVisible(s, list.current, archived)) continue rows.push(s) } - rows.sort(byRecency) + sortSessions(rows, orderBy === 'manual' ? 'updated' : orderBy) return rows.map(session => sessionNode(session, descendants)) } diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index c4e0a16756..b01ef9d636 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -466,6 +466,7 @@ function sessionListFields(header: SessionHeader, events: readonly SessionEvent[ function summarize(session: Session, running: boolean): SessionSummary { return { sessionId: session.id, + createdAt: session.header.createdAt, // Excludes end-seed: a resumed-but-untouched session // must not sort as freshly worked in. updatedAt: lastActivityTime(session.events) ?? session.header.createdAt, @@ -499,6 +500,7 @@ async function summarizeCold( } return { sessionId: meta.id, + createdAt: meta.createdAt, updatedAt, running: false, // Lazy persistence keeps never-appended sessions out of list(); reading @@ -3377,6 +3379,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro queue.push(frame({ type: 'host/session-added', sessionId: session.id, + createdAt: session.header.createdAt, // Derived at frame time like summarize(); a just-created session // has run no turn yet, so this is constantly true in practice. blank: sessionBlank(session), diff --git a/packages/host/apiproxy/src/api/events.schema.ts b/packages/host/apiproxy/src/api/events.schema.ts index 02516c13bb..baa4b6101a 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/session-added'), sessionId: sessionIdSchema, + createdAt: z.number().optional(), blank: z.boolean(), parentSessionId: sessionIdSchema.optional(), origin: z.literal('subagent').optional(), diff --git a/packages/host/apiproxy/src/api/events.ts b/packages/host/apiproxy/src/api/events.ts index 73bb9d8bc2..7069efecf3 100644 --- a/packages/host/apiproxy/src/api/events.ts +++ b/packages/host/apiproxy/src/api/events.ts @@ -127,6 +127,7 @@ export type HostFrame = | { type: 'host/session-added' sessionId: SessionId + createdAt?: number blank: boolean parentSessionId?: SessionId origin?: 'subagent' diff --git a/packages/host/apiproxy/src/api/sessions.schema.ts b/packages/host/apiproxy/src/api/sessions.schema.ts index 5c4647769a..18d162a95c 100644 --- a/packages/host/apiproxy/src/api/sessions.schema.ts +++ b/packages/host/apiproxy/src/api/sessions.schema.ts @@ -51,6 +51,7 @@ export const sessionEventSchema = z.object({ /** SessionSummary row of session.list (`projections` reuses the history block's shape and schema). */ export const sessionSummarySchema = z.object({ sessionId: sessionIdSchema, + createdAt: z.number().optional(), updatedAt: z.number(), running: z.boolean(), blank: z.boolean(), diff --git a/packages/host/apiproxy/src/api/sessions.ts b/packages/host/apiproxy/src/api/sessions.ts index d1f0317e8f..24272dcf07 100644 --- a/packages/host/apiproxy/src/api/sessions.ts +++ b/packages/host/apiproxy/src/api/sessions.ts @@ -147,6 +147,8 @@ export type QueueAction = /** Session list entry (v1 builds no index: list does readdir+stat). */ export interface SessionSummary { sessionId: SessionId + /** Session creation time from the durable session header when supplied by the Host. */ + createdAt?: number /** * Last activity. Attached: the last non-`session/end-seed` event, since a * pickup is not activity. Cold: the log's mtime, or `createdAt` for a backend From 1d4ab4492e95efa2f6d0168f33869f9b40c0b3be Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 11 Aug 2026 13:20:19 +0800 Subject: [PATCH 05/81] style(client): tighten sidebar layout --- packages/client/ui-layout/src/client/columns.ts | 4 ++-- .../src/client/SettingsRoot.module.css | 16 +++++++++------- .../ui-sidebar/src/client/SidebarRoot.module.css | 2 +- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/packages/client/ui-layout/src/client/columns.ts b/packages/client/ui-layout/src/client/columns.ts index 51a944ef2a..374ce64703 100644 --- a/packages/client/ui-layout/src/client/columns.ts +++ b/packages/client/ui-layout/src/client/columns.ts @@ -20,10 +20,10 @@ export interface Columns { sidebar: number; center: number; details: number } /** Center column floor; only the final fallback may go below it. */ export const CENTER_MIN = 640 /** Sidebar drag clamp floor. */ -export const SIDEBAR_MIN = 280 +export const SIDEBAR_MIN = 264 /** Sidebar drag clamp ceiling. */ export const SIDEBAR_MAX = 420 -/** Sidebar width before any user drag (= the drag floor). */ +/** Sidebar width before any user drag. */ export const SIDEBAR_DEFAULT = 280 /** Closed-sidebar rail: a 24px icon column between 16px horizontal paddings. */ export const SIDEBAR_COLLAPSED = 56 diff --git a/packages/client/ui-settings/src/client/SettingsRoot.module.css b/packages/client/ui-settings/src/client/SettingsRoot.module.css index f1bd87e9af..711387fbb4 100644 --- a/packages/client/ui-settings/src/client/SettingsRoot.module.css +++ b/packages/client/ui-settings/src/client/SettingsRoot.module.css @@ -1,19 +1,20 @@ /* Settings shell (figma 501:29904 mask context / 501:29947 panel): sidebar - foot trigger row + centered 1080x700 modal panel. The trigger reproduces - the former sidebar foot geometry (49px wide row / 36px rail circle); the + foot trigger row + centered 1080x700 modal panel. The trigger uses the + sidebar's 38px wide row / 36px rail circle rhythm; the panel is a two-column layout — 188px nav rail + content column with a 54px header and the 24px-padded options area. */ -/* Trigger row (former sidebar foot, figma 133:7668): 49px hover pill. */ +/* Trigger row: match the other wide sidebar controls' compact vertical rhythm. */ .trigger { flex: none; display: flex; align-items: center; gap: 8px; width: 100%; - height: 49px; - margin: 8px 0 0; - padding: 0 2px 0 6px; + height: 38px; + margin: 4px 0 0; + padding: 8px 2px 8px 6px; + box-sizing: border-box; border: none; border-radius: 12px; background: transparent; @@ -22,6 +23,7 @@ color: var(--dsw-alias-label-primary); font-family: inherit; font-size: 14px; + line-height: 22px; } .trigger:hover { @@ -32,7 +34,7 @@ .trigger.rail { width: 36px; height: 36px; - margin: 18px 0 10px; + margin: 8px 0 10px; justify-content: center; gap: 0; padding: 0; diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css index 67310853a2..47a7a40967 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css @@ -224,7 +224,7 @@ } /* Foot seat: a pure layout socket pinned under the region; the ui-settings - trigger row inside owns its own geometry (49px wide row / 36px rail + trigger row inside owns its own geometry (38px wide row / 36px rail circle) and hover chrome. */ .footArea { flex: none; From b3e843056e2d71db38e909a646836d558750186f Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 11 Aug 2026 13:43:47 +0800 Subject: [PATCH 06/81] feat(workspace): support persistent workspace ordering --- .../client/connection/src/client/fixture.ts | 33 +++++++ .../runtime/src/client/contract/workspaces.ts | 6 ++ .../runtime/src/client/workspaces/manager.ts | 90 ++++++++++++++++--- .../runtime/src/client/workspaces/service.ts | 10 +++ .../client/test-runtime/src/workspaces.ts | 10 +++ packages/host/apiproxy/src/api-proxy.ts | 27 +++++- .../host/apiproxy/src/api/events.schema.ts | 1 + packages/host/apiproxy/src/api/events.ts | 4 +- packages/host/apiproxy/src/api/rpc-map.ts | 1 + .../host/apiproxy/src/api/workspace.schema.ts | 11 +++ packages/host/apiproxy/src/api/workspace.ts | 9 ++ packages/host/apiproxy/src/fetch/client.ts | 4 + packages/host/apiproxy/src/fetch/handler.ts | 2 + packages/workspace/workspace/src/index.ts | 35 ++++++++ 14 files changed, 230 insertions(+), 13 deletions(-) diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 07e437ecf1..2a75d77e29 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -2408,6 +2408,38 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { emitHost({ type: 'host/workspace-removed', workspaceId }) return ok(request, { deleted: true as const }) }, + insertBefore: (request) => { + const { workspaceId, beforeWorkspaceId } = request.payload + const source = workspaces.findIndex(workspace => workspace.workspaceId === workspaceId) + const anchor = beforeWorkspaceId === undefined + ? workspaces.length + : workspaces.findIndex(workspace => workspace.workspaceId === beforeWorkspaceId) + const missing = source === -1 ? workspaceId : anchor === -1 ? beforeWorkspaceId : undefined + if (missing !== undefined) { + return err(request, { + code: 'workspace-not-found', + message: `no workspace ${missing}`, + details: { workspaceId: missing }, + }) + } + if (beforeWorkspaceId !== workspaceId) { + const previousOrder = workspaces.map(candidate => candidate.workspaceId) + const [workspace] = workspaces.splice(source, 1) + /* v8 ignore next -- source was resolved from the same array immediately above. */ + if (workspace === undefined) throw new Error(`fixture lost workspace ${workspaceId}`) + const at = beforeWorkspaceId === undefined + ? workspaces.length + : workspaces.findIndex(candidate => candidate.workspaceId === beforeWorkspaceId) + workspaces.splice(at, 0, workspace) + if (workspaces.some((candidate, index) => candidate.workspaceId !== previousOrder[index])) { + emitHost({ + type: 'host/workspace-order-changed', + workspaceIds: workspaces.map(candidate => candidate.workspaceId), + }) + } + } + return ok(request, { workspaceIds: workspaces.map(candidate => candidate.workspaceId) }) + }, insertSessionBefore: (request) => { const { workspaceId, sessionId, beforeSessionId } = request.payload const workspace = workspaces.find(w => w.workspaceId === workspaceId) @@ -2949,6 +2981,7 @@ export class FixtureApiClient extends AbstractApiClient { case 'workspace.create': return this.api.workspace.create(request) case 'workspace.rename': return this.api.workspace.rename(request) case 'workspace.delete': return this.api.workspace.delete(request) + case 'workspace.insertBefore': return this.api.workspace.insertBefore(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) diff --git a/packages/client/runtime/src/client/contract/workspaces.ts b/packages/client/runtime/src/client/contract/workspaces.ts index ad896bbdaf..a541887df0 100644 --- a/packages/client/runtime/src/client/contract/workspaces.ts +++ b/packages/client/runtime/src/client/contract/workspaces.ts @@ -68,6 +68,12 @@ export interface IWorkspaces { * @param workspaceId - target workspace. */ delete(workspaceId: WorkspaceId): Promise + /** + * Move a Workspace within the registry display order. + * @param workspaceId - Workspace to move. + * @param beforeWorkspaceId - Anchor workspace; omitted appends. + */ + insertBefore(workspaceId: WorkspaceId, beforeWorkspaceId?: WorkspaceId): Promise /** * Move an accounted session within/into a Workspace's ordered list. * @param workspaceId - target workspace. diff --git a/packages/client/runtime/src/client/workspaces/manager.ts b/packages/client/runtime/src/client/workspaces/manager.ts index df3aa8fe28..89f96295ef 100644 --- a/packages/client/runtime/src/client/workspaces/manager.ts +++ b/packages/client/runtime/src/client/workspaces/manager.ts @@ -4,7 +4,6 @@ import type { HostFrame, IApiClient, RpcError, RpcRequest, RpcResult, SessionId, WorkspaceId, WorkspaceView, } from '@deepseek-ai/dsh-client-connection/client' import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api' -import { mergeOrderedBaseline } from '../ordered-baseline.ts' import { Notifier } from '../sessions/notifier.ts' import { Workspace, type WorkspaceCreateInput } from './workspace.ts' @@ -30,6 +29,7 @@ export interface WorkspaceListSnapshot { type WorkspaceDelta = | { type: 'upsert'; workspace: WorkspaceView } | { type: 'remove'; workspaceId: WorkspaceId } + | { type: 'order'; workspaceIds: readonly WorkspaceId[] } /** Workspace object cluster driven by one list baseline and changed-frame upserts. */ export class WorkspaceManager { @@ -51,6 +51,10 @@ export class WorkspaceManager { * mirror of replaying refreshFrames over the item baseline. */ private archivedSupersedesRefresh = false + /** Latest local reorder request; only its unary echo may install order. */ + private orderRequestGeneration = 0 + /** Increments on order frames so a later remote commit outranks an older unary echo. */ + private orderFrameGeneration = 0 /** * 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 @@ -72,16 +76,15 @@ export class WorkspaceManager { /** * Refresh from workspace.list. The first successful response establishes - * Host order; later responses update membership and values without moving - * identities already visible to the client. Frames arriving during the RPC - * are replayed over its response. + * Host order; later responses re-establish the durable order so reconnects + * adopt reorders committed while this client was offline. Frames arriving + * during the RPC are replayed over its response. * @returns the shared in-flight refresh. */ refresh(): Promise { if (this.inflight !== null) return this.inflight this.state = 'loading' this.error = null - const established = this.itemViews() const frames: WorkspaceDelta[] = [] this.refreshFrames = frames this.notifier.markDirty() @@ -89,9 +92,7 @@ export class WorkspaceManager { try { const { result } = await this.api.workspace.list({}) if (result.ok) { - let items = this.phase === 'pending' - ? result.value.items - : mergeOrderedBaseline(established, result.value.items, workspace => workspace.workspaceId) + let items = result.value.items items = items.filter(workspace => !this.removedIds.has(workspace.workspaceId)) for (const delta of frames) items = applyWorkspaceDelta(items, delta) this.installViews(items) @@ -157,6 +158,35 @@ export class WorkspaceManager { return result } + /** + * Move a Workspace within the registry display order and install the full + * returned order without waiting for the Host frame. + * @param workspaceId - Workspace to move. + * @param beforeWorkspaceId - Anchor workspace; omitted appends. + * @returns the wire result. + */ + async insertBefore( + workspaceId: WorkspaceId, + beforeWorkspaceId?: WorkspaceId, + ): Promise> { + const requestGeneration = ++this.orderRequestGeneration + const frameGeneration = this.orderFrameGeneration + const previousOrder = this.itemViews().map(workspace => workspace.workspaceId) + this.installOrder(insertIdBefore(previousOrder, workspaceId, beforeWorkspaceId)) + const { result } = await this.api.workspace.insertBefore({ + workspaceId, + ...beforeWorkspaceId === undefined ? {} : { beforeWorkspaceId }, + }) + if (result.ok && requestGeneration === this.orderRequestGeneration + && frameGeneration === this.orderFrameGeneration) { + this.installOrder(result.value.workspaceIds) + } else if (!result.ok && requestGeneration === this.orderRequestGeneration + && frameGeneration === this.orderFrameGeneration) { + this.installOrder(previousOrder) + } + return result + } + /** * Move a session within its Workspace's manual order, then publish the * returned snapshot without waiting for the changed frame. @@ -198,6 +228,10 @@ 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/workspace-order-changed') { + this.orderFrameGeneration++ + this.installOrder(envelope.payload.workspaceIds) + } else if (envelope.payload.type === 'host/archived-sessions-changed') { this.installArchived(envelope.payload.archivedSessionIds) } @@ -249,6 +283,21 @@ export class WorkspaceManager { this.notifier.markDirty() } + /** Reorder known Workspace objects by a complete Host id sequence. */ + private installOrder(workspaceIds: readonly WorkspaceId[]): void { + this.refreshFrames?.push({ type: 'order', workspaceIds }) + const rank = new Map(workspaceIds.map((id, index) => [id, index])) + const items = [...this.items].sort((left, right) => { + const leftId = left.getSnapshot().view?.workspaceId + const rightId = right.getSnapshot().view?.workspaceId + return (leftId === undefined ? Number.MAX_SAFE_INTEGER : rank.get(leftId) ?? Number.MAX_SAFE_INTEGER) + - (rightId === undefined ? Number.MAX_SAFE_INTEGER : rank.get(rightId) ?? Number.MAX_SAFE_INTEGER) + }) + if (items.every((item, index) => item === this.items[index])) return + this.items = items + 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 @@ -332,7 +381,26 @@ function upsertWorkspace(items: readonly WorkspaceView[], workspace: WorkspaceVi /** Replay one ordered delta over a baseline: upsert in place, or drop the removed id. */ function applyWorkspaceDelta(items: readonly WorkspaceView[], delta: WorkspaceDelta): WorkspaceView[] { - return delta.type === 'upsert' - ? upsertWorkspace(items, delta.workspace) - : items.filter(workspace => workspace.workspaceId !== delta.workspaceId) + if (delta.type === 'upsert') return upsertWorkspace(items, delta.workspace) + if (delta.type === 'remove') { + return items.filter(workspace => workspace.workspaceId !== delta.workspaceId) + } + const rank = new Map(delta.workspaceIds.map((id, index) => [id, index])) + return [...items].sort((left, right) => + (rank.get(left.workspaceId) ?? Number.MAX_SAFE_INTEGER) + - (rank.get(right.workspaceId) ?? Number.MAX_SAFE_INTEGER)) +} + +/** Move one known id before an optional anchor; unknown ids leave the order unchanged. */ +function insertIdBefore( + ids: readonly WorkspaceId[], + id: WorkspaceId, + beforeId?: WorkspaceId, +): WorkspaceId[] { + if (!ids.includes(id) || (beforeId !== undefined && !ids.includes(beforeId)) || beforeId === id) { + return [...ids] + } + const without = ids.filter(candidate => candidate !== id) + const at = beforeId === undefined ? without.length : without.indexOf(beforeId) + return [...without.slice(0, at), id, ...without.slice(at)] } diff --git a/packages/client/runtime/src/client/workspaces/service.ts b/packages/client/runtime/src/client/workspaces/service.ts index 468ae95a19..8b26d0f1b0 100644 --- a/packages/client/runtime/src/client/workspaces/service.ts +++ b/packages/client/runtime/src/client/workspaces/service.ts @@ -265,6 +265,16 @@ export class WorkspacesService implements IWorkspaces { if (!result.ok) throw new Error(`workspace delete failed: ${result.error.code}: ${result.error.message}`) } + /** + * Move a Workspace within the durable registry display order. + * @param workspaceId - Workspace to move. + * @param beforeWorkspaceId - Anchor workspace; omitted appends. + */ + async insertBefore(workspaceId: WorkspaceId, beforeWorkspaceId?: WorkspaceId): Promise { + const result = await this.manager.insertBefore(workspaceId, beforeWorkspaceId) + if (!result.ok) throw new Error(`workspace reorder failed: ${result.error.code}: ${result.error.message}`) + } + /** * Archive a session into the registry-global set. Clearing an archived * current selection is the projection sweep's job (one rule for the local diff --git a/packages/client/test-runtime/src/workspaces.ts b/packages/client/test-runtime/src/workspaces.ts index 9e1061ec8c..4f6b2122cb 100644 --- a/packages/client/test-runtime/src/workspaces.ts +++ b/packages/client/test-runtime/src/workspaces.ts @@ -172,6 +172,16 @@ export class TestWorkspaces implements IWorkspaces { await (this.stubs.get('delete')?.(workspaceId) as Promise | undefined) } + /** + * Move a Workspace in display order (recorded; default no-op). + * @param workspaceId - Workspace to move. + * @param beforeWorkspaceId - Anchor; omitted appends. + */ + async insertBefore(workspaceId: WorkspaceId, beforeWorkspaceId?: WorkspaceId): Promise { + this.calls.push({ method: 'insertBefore', args: [workspaceId, beforeWorkspaceId] }) + await (this.stubs.get('insertBefore')?.(workspaceId, beforeWorkspaceId) as Promise | undefined) + } + /** * Move an accounted session (recorded). The default echoes a minimal view. * @param workspaceId - target workspace. diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index b01ef9d636..f21d3e9a86 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -25,7 +25,7 @@ import { isUserInvocable } from '@deepseek-ai/dsh-skill' import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace' import { workspaceDomainState, workspaceRecord, WorkspaceId as brandWorkspaceId, - WorkspaceMoveInvalidError, WorkspaceUnknownSessionError, + WorkspaceMoveInvalidError, WorkspaceOrderInvalidError, WorkspaceUnknownSessionError, } from '@deepseek-ai/dsh-workspace' // Type-only: brings the `ctx.tools` Context merge into this program (viewFor reads presenters). import { @@ -2671,6 +2671,20 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro return ok(request, { deleted: true as const }) }, + async insertBefore(request) { + const { workspaceId, beforeWorkspaceId } = request.payload + try { + const workspaceIds = await ctx.workspace.insertBefore( + brandWorkspaceId(workspaceId), + beforeWorkspaceId === undefined ? undefined : brandWorkspaceId(beforeWorkspaceId), + ) + return ok(request, { workspaceIds: [...workspaceIds] }) + } catch (error: unknown) { + if (!(error instanceof WorkspaceOrderInvalidError)) throw error + return workspaceNotFound(request, error.workspaceId) + } + }, + async insertSessionBefore(request) { const { payload } = request const workspace = ctx.workspace.get(brandWorkspaceId(payload.workspaceId)) @@ -3370,6 +3384,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro const committedWorkspaceIds = new Set( ctx.workspace.list().map(workspace => String(workspace.id)), ) + let committedWorkspaceOrder = ctx.workspace.list().map(workspace => workspaceView(workspace).workspaceId) // 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. @@ -3401,6 +3416,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro if (change.table === '') { if (change.operation !== 'put') return const state = workspaceDomainState.parse(change.value) + const orderChanged = state.workspaceIds.length === committedWorkspaceOrder.length + && state.workspaceIds.every(workspaceId => committedWorkspaceIds.has(String(workspaceId))) + && state.workspaceIds.some((workspaceId, index) => workspaceId !== committedWorkspaceOrder[index]) for (const workspaceId of state.workspaceIds) { if (committedWorkspaceIds.has(workspaceId)) continue const workspace = ctx.workspace.get(workspaceId) @@ -3410,6 +3428,13 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro committedWorkspaceIds.add(workspaceId) queue.push(frame({ type: 'host/workspace-changed', workspace: workspaceView(workspace) })) } + committedWorkspaceOrder = [...state.workspaceIds] + if (orderChanged) { + queue.push(frame({ + type: 'host/workspace-order-changed', + workspaceIds: [...state.workspaceIds], + })) + } if (state.archivedSessionIds.length !== archivedSessionIds.length || state.archivedSessionIds.some((id, index) => id !== archivedSessionIds[index])) { archivedSessionIds = state.archivedSessionIds diff --git a/packages/host/apiproxy/src/api/events.schema.ts b/packages/host/apiproxy/src/api/events.schema.ts index baa4b6101a..4bdd8d3a24 100644 --- a/packages/host/apiproxy/src/api/events.schema.ts +++ b/packages/host/apiproxy/src/api/events.schema.ts @@ -83,6 +83,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/workspace-order-changed'), workspaceIds: z.array(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/session-preset-changed'), sessionId: sessionIdSchema, agentPreset: z.string() }), diff --git a/packages/host/apiproxy/src/api/events.ts b/packages/host/apiproxy/src/api/events.ts index 7069efecf3..5a116a1f9d 100644 --- a/packages/host/apiproxy/src/api/events.ts +++ b/packages/host/apiproxy/src/api/events.ts @@ -119,7 +119,8 @@ 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; archived-sessions-changed pushes the full registry + * session-log deletion; workspace-order-changed pushes the complete durable + * registry order after a reorder; 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). */ @@ -139,6 +140,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/workspace-order-changed'; workspaceIds: WorkspaceView['workspaceId'][] } | { type: 'host/archived-sessions-changed'; archivedSessionIds: SessionId[] } /** * The command registry changed (`commands/change` passthrough). Pure diff --git a/packages/host/apiproxy/src/api/rpc-map.ts b/packages/host/apiproxy/src/api/rpc-map.ts index ca7231e774..3c06f1e9d5 100644 --- a/packages/host/apiproxy/src/api/rpc-map.ts +++ b/packages/host/apiproxy/src/api/rpc-map.ts @@ -48,6 +48,7 @@ export interface RpcMethodMap { 'workspace.create': WorkspaceApi['create'] 'workspace.rename': WorkspaceApi['rename'] 'workspace.delete': WorkspaceApi['delete'] + 'workspace.insertBefore': WorkspaceApi['insertBefore'] 'workspace.insertSessionBefore': WorkspaceApi['insertSessionBefore'] 'workspace.archiveSession': WorkspaceApi['archiveSession'] 'command.list': CommandsApi['list'] diff --git a/packages/host/apiproxy/src/api/workspace.schema.ts b/packages/host/apiproxy/src/api/workspace.schema.ts index 5ad5a0b96b..b57305141c 100644 --- a/packages/host/apiproxy/src/api/workspace.schema.ts +++ b/packages/host/apiproxy/src/api/workspace.schema.ts @@ -66,6 +66,17 @@ export const workspaceDeleteValueSchema = z.object({ deleted: z.literal(true), }) satisfies z.ZodType>> +/** workspace.insertBefore request payload (anchor omitted = append to end). */ +export const workspaceInsertBeforeRequestSchema = z.object({ + workspaceId: workspaceIdSchema, + beforeWorkspaceId: workspaceIdSchema.optional(), +}) satisfies z.ZodType>> + +/** workspace.insertBefore response value: the complete durable display order. */ +export const workspaceInsertBeforeValueSchema = z.object({ + workspaceIds: z.array(workspaceIdSchema), +}) satisfies z.ZodType>> + /** workspace.insertSessionBefore request payload (anchor omitted = append to end). */ export const workspaceInsertSessionBeforeRequestSchema = z.object({ workspaceId: workspaceIdSchema, diff --git a/packages/host/apiproxy/src/api/workspace.ts b/packages/host/apiproxy/src/api/workspace.ts index 64feb27f80..d36d0c406e 100644 --- a/packages/host/apiproxy/src/api/workspace.ts +++ b/packages/host/apiproxy/src/api/workspace.ts @@ -73,6 +73,15 @@ export interface WorkspaceApi { delete(request: RpcRequest<{ workspaceId: WorkspaceId }>): Promise> + /** + * Moves one Workspace within the registry display order, + * DOM-insertBefore-like. An omitted anchor appends to the end. + */ + insertBefore(request: RpcRequest<{ + workspaceId: WorkspaceId + beforeWorkspaceId?: WorkspaceId + }>): Promise> + /** * Moves an accounted session within its workspace's manual order, * DOM-insertBefore-like: with `beforeSessionId` the session is inserted diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts index 6060875a6f..49e5fce67f 100644 --- a/packages/host/apiproxy/src/fetch/client.ts +++ b/packages/host/apiproxy/src/fetch/client.ts @@ -35,6 +35,7 @@ import { workspaceArchiveSessionValueSchema, workspaceCreateValueSchema, workspaceDeleteValueSchema, + workspaceInsertBeforeValueSchema, workspaceInsertSessionBeforeValueSchema, workspaceListValueSchema, workspaceRenameValueSchema, @@ -117,6 +118,7 @@ export interface IApiClient { create(payload: RequestPayload<'workspace.create'>, signal?: AbortSignal): Promise>> rename(payload: RequestPayload<'workspace.rename'>, signal?: AbortSignal): Promise>> delete(payload: RequestPayload<'workspace.delete'>, signal?: AbortSignal): Promise>> + insertBefore(payload: RequestPayload<'workspace.insertBefore'>, signal?: AbortSignal): Promise>> insertSessionBefore(payload: RequestPayload<'workspace.insertSessionBefore'>, signal?: AbortSignal): Promise>> archiveSession(payload: RequestPayload<'workspace.archiveSession'>, signal?: AbortSignal): Promise>> } @@ -198,6 +200,7 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType this.callUnary('workspace.create', payload, signal), rename: (payload, signal) => this.callUnary('workspace.rename', payload, signal), delete: (payload, signal) => this.callUnary('workspace.delete', payload, signal), + insertBefore: (payload, signal) => this.callUnary('workspace.insertBefore', payload, signal), insertSessionBefore: (payload, signal) => this.callUnary('workspace.insertSessionBefore', payload, signal), archiveSession: (payload, signal) => this.callUnary('workspace.archiveSession', payload, signal), } diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index 1e902f059e..f356c9f7bd 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -38,6 +38,7 @@ import { workspaceArchiveSessionRequestSchema, workspaceCreateRequestSchema, workspaceDeleteRequestSchema, + workspaceInsertBeforeRequestSchema, workspaceInsertSessionBeforeRequestSchema, workspaceListRequestSchema, workspaceRenameRequestSchema, @@ -113,6 +114,7 @@ const UNARY_ROUTES: UnaryRoutes = { 'workspace.create': { schema: workspaceCreateRequestSchema, invoke: (api, r) => api.workspace.create(r) }, 'workspace.rename': { schema: workspaceRenameRequestSchema, invoke: (api, r) => api.workspace.rename(r) }, 'workspace.delete': { schema: workspaceDeleteRequestSchema, invoke: (api, r) => api.workspace.delete(r) }, + 'workspace.insertBefore': { schema: workspaceInsertBeforeRequestSchema, invoke: (api, r) => api.workspace.insertBefore(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) }, diff --git a/packages/workspace/workspace/src/index.ts b/packages/workspace/workspace/src/index.ts index d972085939..5d1f3296d8 100644 --- a/packages/workspace/workspace/src/index.ts +++ b/packages/workspace/workspace/src/index.ts @@ -52,6 +52,17 @@ export class WorkspaceUnknownSessionError extends Error { } } +/** A workspace reorder named a source or anchor absent from the durable registry order. */ +export class WorkspaceOrderInvalidError extends Error { + /** + * @param workspaceId - Missing source or anchor id. + */ + constructor(readonly workspaceId: WorkspaceId) { + super(`cannot reorder unknown workspace '${workspaceId}'`) + this.name = 'WorkspaceOrderInvalidError' + } +} + declare module '@deepseek-ai/cordis' { interface Context { @@ -189,6 +200,30 @@ export class WorkspaceRegistry extends Service { return this.enqueueOperation(() => this.deleteKnown(id)) } + /** + * Move one workspace within the durable display order, DOM-insertBefore-like. + * With an anchor it lands before that workspace; without one it appends. + * @param id - Workspace to move. + * @param beforeId - Workspace anchor; omitted appends. + * @returns the complete committed workspace order. + */ + insertBefore(id: WorkspaceId, beforeId?: WorkspaceId): Promise { + return this.enqueueOperation(async () => { + const state = this.requireState() + if (!state.workspaceIds.includes(id)) throw new WorkspaceOrderInvalidError(id) + if (beforeId !== undefined && !state.workspaceIds.includes(beforeId)) { + throw new WorkspaceOrderInvalidError(beforeId) + } + if (beforeId === id) return state.workspaceIds + const without = state.workspaceIds.filter(workspaceId => workspaceId !== id) + const at = beforeId === undefined ? without.length : without.indexOf(beforeId) + const workspaceIds = [...without.slice(0, at), id, ...without.slice(at)] + if (sameIds(workspaceIds, state.workspaceIds)) return state.workspaceIds + await this.setState({ ...state, workspaceIds }) + return workspaceIds + }) + } + /** * The registry-global archive set: sessions hidden from every grouping * surface. Archiving never touches workspace accounting — an archived From 1a1729a138f274163372bd1fd402e51b73938978 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 11 Aug 2026 13:43:51 +0800 Subject: [PATCH 07/81] feat(client): refine workspace sidebar interactions --- .../src/client/WorkspaceBrowser.module.css | 163 +++++++++--- .../src/client/WorkspaceBrowser.tsx | 231 +++++++++++++----- .../ui-workspace/src/client/contract/slots.ts | 5 + .../client/ui-workspace/src/client/index.ts | 3 + .../src/client/rows/Rows.module.css | 34 ++- .../ui-workspace/src/client/rows/Rows.tsx | 82 +++++-- 6 files changed, 394 insertions(+), 124 deletions(-) diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css b/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css index f44287ed8a..270b8902d6 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css @@ -38,9 +38,8 @@ background: var(--dsw-alias-interactive-bg-hover); } -/* Section header: 36px, "Workspaces/Sessions" label + group-by / - new-workspace buttons; the right-anchored new-workspace button is the - row's rail survivor. */ +/* Section header: title, an inline search control, and the two trailing + actions. Expanding search collapses the action cluster and takes its room. */ .sectionHeader { flex: none; display: flex; @@ -48,7 +47,7 @@ justify-content: flex-end; gap: 4px; height: 36px; - padding-left: 12px; + padding-left: 4px; margin-bottom: 4px; box-sizing: border-box; border-radius: 12px; @@ -56,64 +55,126 @@ color: var(--dsw-alias-label-tertiary); } +.root:not(.rail) .sectionHeader { + margin-right: -4px; +} + .sectionLabel { - flex: 1; + flex: none; + max-width: 45%; min-width: 0; overflow: hidden; white-space: nowrap; line-height: 20px; } -/* Search input: 38px bar, 12px radius (figma 133:7649 geometry, squared-off - corners); rail state renders it as the - region's search control. Upstream binds a dedicated design-system variable - (light #F1F3F5 / dark #1B1B1C) matching no shipped alias — a component - token pinned to the static scale mirrors it. */ -.search { - --dsh-search-input-fill: var(--dsw-static-neutral-bluish-75); +.searchSlot { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + padding-left: 4px; + box-sizing: border-box; +} + +.headerActions { flex: none; display: flex; align-items: center; - gap: 8px; - height: 38px; - margin: 0 2px 12px; - padding: 0 14px; + gap: 4px; + max-width: 60px; + opacity: 1; + overflow: hidden; + visibility: visible; + transition: + max-width 180ms var(--ds-ease-in-out), + opacity 120ms var(--ds-ease-in-out), + transform 180ms var(--ds-ease-in-out), + visibility 0s linear; +} + +.headerActionsHidden { + max-width: 0; + opacity: 0; + transform: translateX(4px); + visibility: hidden; + pointer-events: none; + transition-delay: 0s, 0s, 0s, 180ms; +} + +/* Inline search always fills the room between the title and trailing actions; + it grows farther right when the action cluster collapses. */ +.search { + flex: none; + display: flex; + align-items: center; + gap: 0; + width: 100%; + height: 26px; + margin: 0; + padding: 0; box-sizing: border-box; - border: 1px solid var(--dsw-alias-border-l2); - border-radius: 12px; - background: var(--dsh-search-input-fill); + border: 1px solid var(--dsw-alias-border-l1); + border-radius: 10px; + background: transparent; + cursor: text; color: var(--dsw-alias-label-caption); overflow: hidden; + transition: + width 180ms var(--ds-ease-in-out), + padding 180ms var(--ds-ease-in-out), + border-color 180ms var(--ds-ease-in-out), + background-color 180ms var(--ds-ease-in-out); } -:global(body[data-ds-dark-theme]) .search { - --dsh-search-input-fill: var(--dsw-static-neutral-bluish-900); +.searchExpanded { + padding: 0 4px 0 0; + border-color: var(--dsw-alias-border-l2); + background: transparent; } -/* The capsule's leading icon: decorative while wide (pointer-events off so - clicks reach the input), the hit target in rail state. */ .searchButton { flex: none; display: inline-flex; align-items: center; justify-content: center; + width: 26px; + height: 26px; border: none; border-radius: 50%; padding: 0; background: transparent; - pointer-events: none; + cursor: pointer; color: inherit; } +.searchButton:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + +.searchExpanded .searchButton:hover { + background: transparent; +} + .searchInput { flex: 1; + width: 0; min-width: 0; border: none; outline: none; background: transparent; - font-size: 14px; - line-height: 20px; + opacity: 0; + pointer-events: none; + font-size: 13px; + line-height: 18px; color: var(--dsw-alias-label-primary); + transition: opacity 120ms var(--ds-ease-in-out); +} + +.searchExpanded .searchInput { + margin-left: -2px; + opacity: 1; + pointer-events: auto; } .searchInput::placeholder { @@ -125,8 +186,8 @@ display: inline-flex; align-items: center; justify-content: center; - width: 28px; - height: 28px; + width: 18px; + height: 18px; border: none; border-radius: 50%; padding: 0; @@ -144,6 +205,10 @@ margin-bottom: 12px; } +.rail .headerActions { + max-width: none; +} + .rail .iconButton { width: 36px; height: 36px; @@ -151,6 +216,7 @@ } .rail .search { + width: 36px; height: 36px; padding: 0; margin: 0 0 12px; @@ -162,8 +228,6 @@ .rail .searchButton { width: 36px; height: 36px; - pointer-events: auto; - cursor: pointer; color: var(--dsw-alias-label-primary); } @@ -254,10 +318,35 @@ } /* One workspace section: header row + a compact expanded session run. */ +.groupSection { + position: relative; +} + .groupSection + .groupSection { margin-top: 4px; } +.workspaceDropBefore::before, +.workspaceDropAfter::after { + content: ''; + position: absolute; + z-index: 1; + left: 4px; + right: 4px; + height: 2px; + border-radius: 999px; + background: var(--dsw-alias-state-business-primary); + pointer-events: none; +} + +.workspaceDropBefore::before { + top: -3px; +} + +.workspaceDropAfter::after { + bottom: -3px; +} + .sessionOverflowButton { width: 100%; height: 30px; @@ -271,9 +360,15 @@ color: var(--dsw-alias-label-tertiary); } +.groupSection > .sessionOverflowButton { + margin-top: 0; +} + .sessionOverflowButton:hover { - background: var(--dsw-alias-interactive-bg-hover); + background: transparent; color: var(--dsw-alias-label-secondary); + text-decoration: underline; + text-underline-offset: 2px; } .empty { @@ -323,4 +418,10 @@ .wide { animation: none; } + + .search, + .searchInput, + .headerActions { + transition: none; + } } diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx index 4faa479e70..294b39b59a 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx @@ -35,7 +35,7 @@ const SEARCH_DEBOUNCE_MS = 250 /** `session.search` wire bound, measured in JavaScript UTF-16 code units. */ const SEARCH_QUERY_MAX_CODE_UNITS = 500 /** Session rows visible per Workspace before the local overflow control. */ -const COLLAPSED_SESSION_LIMIT = 6 +const COLLAPSED_SESSION_LIMIT = 5 /** Keep controlled input and RPC payload inside the session.search wire contract. */ function sanitizeSearchQuery(value: string): string { @@ -110,9 +110,16 @@ interface DragState { over: { id: SessionNode['id']; half: 'before' | 'after' } | null } +/** In-flight Workspace-row drag: source identity plus the current marker. */ +interface WorkspaceDragState { + workspaceId: WorkspaceId + over: { id: WorkspaceId; half: 'before' | 'after' } | null +} + type SessionTreeProps = Pick< WorkspaceBrowserProps, - 'useSessions' | 'startSession' | 'open' | 'forkSession' | 'insertSessionBefore' | 't' + 'useSessions' | 'startSession' | 'open' | 'forkSession' + | 'insertWorkspaceBefore' | 'insertSessionBefore' | 't' > & { workspaces: readonly WorkspaceView[] /** Registry-global archive set (hidden rows). */ @@ -125,14 +132,15 @@ type SessionTreeProps = Pick< 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 - /** Visual order; only manual mode exposes durable Workspace dragging. */ + /** Session visual order; only manual mode exposes durable Session dragging. */ orderBy: SessionOrderBy } /** The scrolling session tree; unmounting at collapse settle drops the sessions subscription and expansion state. */ function SessionTree({ useSessions, startSession, open, forkSession, workspaces, archivedSessionIds, - onRenameRequest, onDeleteRequest, onSessionRename, onSessionArchive, insertSessionBefore, orderBy, t, + onRenameRequest, onDeleteRequest, onSessionRename, onSessionArchive, + insertWorkspaceBefore, insertSessionBefore, orderBy, t, }: SessionTreeProps) { const list = useSessions(s => s) const current = list.current @@ -140,6 +148,7 @@ function SessionTree({ const [expandedSessionGroups, setExpandedSessionGroups] = useState([]) // Transient drag viewing state (never store-bound; order truth stays Host-side). const [drag, setDrag] = useState(null) + const [workspaceDrag, setWorkspaceDrag] = useState(null) const currentGroup = current === undefined ? undefined : (workspaces.find(w => w.sessionIds.includes(current))?.workspaceId as string | undefined) @@ -160,18 +169,62 @@ function SessionTree({ {groups.length === 0 && (
{t('empty.none')}
)} - {groups.map(group => ( + {groups.map((group) => { + const workspaceId = group.workspaceId + const workspaceMarker = workspaceId !== undefined && workspaceDrag?.over?.id === workspaceId + ? workspaceDrag.over.half + : null + const workspaceDragProps = workspaceId === undefined ? undefined : { + start: () => { setWorkspaceDrag({ workspaceId, over: null }) }, + active: workspaceDrag !== null, + marker: null, + hover: (half: 'before' | 'after') => { + setWorkspaceDrag(active => active === null + ? active + : { ...active, over: { id: workspaceId, half } }) + }, + drop: (half: 'before' | 'after') => { + if (workspaceDrag === null) return + const rowIndex = workspaces.findIndex(workspace => workspace.workspaceId === workspaceId) + const anchor = half === 'before' ? workspaceId : workspaces[rowIndex + 1]?.workspaceId + setWorkspaceDrag(null) + if (anchor === workspaceDrag.workspaceId) return + const sourceIndex = workspaces.findIndex(workspace => workspace.workspaceId === workspaceDrag.workspaceId) + const anchorIndex = anchor === undefined + ? workspaces.length + : workspaces.findIndex(workspace => workspace.workspaceId === anchor) + if (sourceIndex !== -1 && (anchorIndex === sourceIndex || anchorIndex === sourceIndex + 1)) return + insertWorkspaceBefore(workspaceDrag.workspaceId, anchor).catch((reason: unknown) => { + console.warn('workspace reorder rejected:', reason) + }) + }, + end: () => { setWorkspaceDrag(null) }, + } + return ( // Group section: header row + expanded top-level session rows. The // inter-group breathing room is the section's own margin // (WorkspaceBrowser.module.css). -
+
{ setExpandedProjects(l => toggled(l, group.key)) }} + onToggle={() => { + if (group.expanded) { + setExpandedSessionGroups(keys => keys.filter(key => key !== group.key)) + } + setExpandedProjects(l => toggled(l, group.key)) + }} onCreate={() => { if (group.workspaceId !== undefined) startSession(group.workspaceId) }} + drag={workspaceDragProps} actions={group.workspaceId === undefined ? undefined : { @@ -251,7 +304,8 @@ function SessionTree({ )}
- ))} + ) + })}
@@ -382,6 +436,7 @@ export function WorkspaceBrowser({ forkSession, renameWorkspace, deleteWorkspace, + insertWorkspaceBefore, archiveSession, insertSessionBefore, createWorkspace, @@ -406,6 +461,7 @@ export function WorkspaceBrowser({ // The query outlives the tree and the input (both wide-only) so collapsing // does not silently drop an in-progress filter. const [query, setQuery] = useState('') + const [searchExpanded, setSearchExpanded] = useState(false) const normalizedQuery = sanitizeSearchQuery(query).trim() const [remoteSearch, setRemoteSearch] = useState({ query: '', @@ -413,6 +469,7 @@ export function WorkspaceBrowser({ items: [], hasMore: false, }) + const searchRoot = useRef(null) const searchInput = useRef(null) // Section-header + opens the picker menu (same popover in wide and rail // states; the menu anchors on this button). @@ -433,6 +490,21 @@ export function WorkspaceBrowser({ } }, [wide, searchOnExpand]) + useEffect(() => { + if (!wide || !searchExpanded || searchOnExpand) return + searchInput.current?.focus({ preventScroll: true }) + }, [wide, searchExpanded, searchOnExpand]) + + useEffect(() => { + if (!wide || !searchExpanded) return + const onPointerDown = (event: PointerEvent): void => { + if (!(event.target instanceof Node) || searchRoot.current?.contains(event.target) === true) return + searchInput.current?.blur() + } + document.addEventListener('pointerdown', onPointerDown) + return () => { document.removeEventListener('pointerdown', onPointerDown) } + }, [wide, searchExpanded]) + useEffect(() => { if (normalizedQuery === '') { setRemoteSearch({ query: '', status: 'idle', items: [], hasMore: false }) @@ -585,32 +657,91 @@ export function WorkspaceBrowser({ )} {wide && ( - { actions.setGroupBy(mode) }} - onOrderPick={(mode) => { actions.setOrderBy(mode) }} - t={t} - /> - )} - {/* Adding is the button's one action, so a composition with no - picking affordance has nothing to offer here: the region hides the - button rather than leaving a dead one in the header. */} - {directoryFlowAvailable && ( - - - + + + + { setQuery(sanitizeSearchQuery(e.target.value)) }} + onKeyDown={(e) => { + if (e.key !== 'Escape') return + setQuery('') + setSearchExpanded(false) + }} + /> + {searchExpanded && ( + + )} +
+ )} +
+ {wide && ( + { actions.setGroupBy(mode) }} + onOrderPick={(mode) => { actions.setOrderBy(mode) }} + t={t} + /> + )} + {/* Adding is the button's one action, so a composition with no + picking affordance has nothing to offer here: the region hides the + button rather than leaving a dead one in the header. */} + {directoryFlowAvailable && ( + + + + )} +
{/* Add flow + its error dialog (same package — direct composition). */} - {/* Expanded: the row is a click-to-focus field (the leading icon is - decorative). Rail: the icon is the region's search control. */} -
{ if (wide) searchInput.current?.focus() }}> - + {/* The collapsed rail keeps search as its own 36px control. */} + {!wide &&
+ - {wide && ( - { setQuery(sanitizeSearchQuery(e.target.value)) }} - /> - )} - {wide && query !== '' && ( - - )} -
+
} {/* Always-mounted seat keeps the region's flex slot while the list itself is wide-only. */} @@ -701,6 +813,7 @@ export function WorkspaceBrowser({ archivedSessionIds={archivedSessionIds} startSession={startSession} open={open} + insertWorkspaceBefore={insertWorkspaceBefore} insertSessionBefore={insertSessionBefore} orderBy={orderBy} t={t} diff --git a/packages/client/ui-workspace/src/client/contract/slots.ts b/packages/client/ui-workspace/src/client/contract/slots.ts index e1c41c9c17..e5487d2657 100644 --- a/packages/client/ui-workspace/src/client/contract/slots.ts +++ b/packages/client/ui-workspace/src/client/contract/slots.ts @@ -116,6 +116,11 @@ export type WorkspaceBrowserInjected = DirectoryPickingInjected & { renameWorkspace: (workspaceId: WorkspaceId, title: string) => Promise /** Delete only a Host Workspace registration; directory and Session logs remain. */ deleteWorkspace: (workspaceId: WorkspaceId) => Promise + /** + * Reorder a Workspace in the durable registry display order. + * Omitted anchor appends to the end. + */ + insertWorkspaceBefore: (workspaceId: WorkspaceId, beforeWorkspaceId?: WorkspaceId) => Promise /** * Archive a Session into the registry-global set: hidden from grouping * surfaces, log and accounting slot retained. Archiving the current diff --git a/packages/client/ui-workspace/src/client/index.ts b/packages/client/ui-workspace/src/client/index.ts index 5f499c4336..41e88116fc 100644 --- a/packages/client/ui-workspace/src/client/index.ts +++ b/packages/client/ui-workspace/src/client/index.ts @@ -91,6 +91,9 @@ export function apply(ctx: ClientContext): void { }, renameWorkspace: async (workspaceId, title) => { await ctx.workspaces.rename(workspaceId, title) }, deleteWorkspace: async (workspaceId) => { await ctx.workspaces.delete(workspaceId) }, + insertWorkspaceBefore: async (workspaceId, beforeWorkspaceId) => { + await ctx.workspaces.insertBefore(workspaceId, beforeWorkspaceId) + }, 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/rows/Rows.module.css b/packages/client/ui-workspace/src/client/rows/Rows.module.css index 71be79d081..0880820e4a 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.module.css +++ b/packages/client/ui-workspace/src/client/rows/Rows.module.css @@ -21,7 +21,7 @@ } .sessionRow.selected { - background: var(--dsw-alias-interactive-bg-active); + background: var(--dsw-alias-interactive-bg-hover); } .searchResultRow { @@ -45,7 +45,7 @@ } .searchResultRow.selected { - background: var(--dsw-alias-interactive-bg-active); + background: var(--dsw-alias-interactive-bg-hover); } .searchResultHeading { @@ -229,14 +229,32 @@ background: var(--dsw-alias-interactive-bg-hover); } -/* Drag reorder insert line (workspace-group session rows): 2px accent above or - below the hovered row, drawn with box-shadow so no layout shift. */ -.sessionRow.dropBefore { - box-shadow: 0 -2px 0 0 var(--dsw-alias-state-business-primary); +/* Session drag insert line: an independent 2px rule between rows, absolutely + positioned so it neither resembles a row border nor changes layout. */ +.sessionRow.dropBefore, +.sessionRow.dropAfter { + position: relative; } -.sessionRow.dropAfter { - box-shadow: 0 2px 0 0 var(--dsw-alias-state-business-primary); +.sessionRow.dropBefore::before, +.sessionRow.dropAfter::after { + content: ''; + position: absolute; + z-index: 1; + left: 4px; + right: 4px; + height: 2px; + border-radius: 999px; + background: var(--dsw-alias-state-business-primary); + pointer-events: none; +} + +.sessionRow.dropBefore::before { + top: -2px; +} + +.sessionRow.dropAfter::after { + bottom: -2px; } /* Hover-card body (figma 169:16903): dark surface, fixed colors both themes. */ diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index 5ed290f20d..0903bbb600 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -66,6 +66,29 @@ function WorkspaceHoverContent({ label, cwd, createdAt, t }: { ) } +/** + * Row drag wiring supplied by the tree owner. `drop` reports the half of the + * row where the pointer released so the owner can resolve an insert anchor. + */ +export interface RowDragProps { + /** Start dragging this row. */ + start: () => void + /** A compatible row drag is in flight. */ + active: boolean + /** Current marker on this row: insert line above, below, or none. */ + marker: 'before' | 'after' | null + /** Report the hovered half while a compatible drag passes over this row. */ + hover: (half: 'before' | 'after') => void + drop: (half: 'before' | 'after') => void + end: () => void +} + +/** Pointer-position half of a row (insert line above or below). */ +function rowHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' | 'after' { + const rect = e.currentTarget.getBoundingClientRect() + return e.clientY < rect.top + rect.height / 2 ? 'before' : 'after' +} + /** * Project (workspace) header row: folder + title; * hover reveals the chevron and create button, and dwelling on a real @@ -74,15 +97,18 @@ function WorkspaceHoverContent({ label, cwd, createdAt, t }: { * @param props.group - derived group node. * @param props.onToggle - expand/collapse the group. * @param props.onCreate - start a frontend Session inside this Workspace. + * @param props.drag - optional workspace-row drag wiring. * @param props.t - the browser root's locale seat. * @returns the row element. */ -export function ProjectRowItem({ group, onToggle, onCreate, actions, t }: { +export function ProjectRowItem({ group, onToggle, onCreate, actions, drag, t }: { group: GroupNode onToggle: () => void onCreate: () => void /** Real-Workspace actions; absent for the ungrouped bucket (no menu shown). */ actions?: { rename: () => void; delete: () => void } | undefined + /** Present only for real Workspace rows in the grouped view. */ + drag?: RowDragProps | undefined t: RowTranslate }) { const row = group @@ -96,10 +122,37 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions, t }: { ] const ownRow = (
{ + e.dataTransfer.effectAllowed = 'move' + e.dataTransfer.setData('text/plain', row.key) + drag.start() + }} + onDragEnd={drag?.end} + onDragOver={drag === undefined + ? undefined + : (e) => { + if (!drag.active) return + e.preventDefault() + e.dataTransfer.dropEffect = 'move' + drag.hover(rowHalf(e)) + }} + onDrop={drag === undefined + ? undefined + : (e) => { + if (!drag.active) return + e.preventDefault() + drag.drop(rowHalf(e)) + }} > {row.expanded ? : } @@ -237,24 +290,6 @@ function SessionHoverContent({ node, now, t }: { node: SessionNode; now: number; ) } -/** - * Session-row drag wiring supplied by the group owner (workspace groups only). - * `drop` reports the half of the row the pointer released on: 'before' - * inserts above this row, 'after' below it (the owner resolves the anchor). - */ -export interface RowDragProps { - /** Start dragging this row. */ - start: () => void - /** A drag from the same group is in flight (rows show insert markers). */ - active: boolean - /** Current marker on this row: insert line above, below, or none. */ - marker: 'before' | 'after' | null - /** Report the hovered half while a same-group drag passes over this row. */ - hover: (half: 'before' | 'after') => void - drop: (half: 'before' | 'after') => void - end: () => void -} - /** * One flat search result: title, Workspace context, and optional content * excerpt. Search navigation opens the session only; it does not address an @@ -303,12 +338,6 @@ export function SearchResultItem({ result, currentId, onOpen, t }: { ) } -/** Pointer-position half of a row (insert line above or below). */ -function rowHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' | 'after' { - const rect = e.currentTarget.getBoundingClientRect() - return e.clientY < rect.top + rect.height / 2 ? 'before' : 'after' -} - /** * One top-level 34px session row: status dot (pending user interaction outranks * own or descendant activity), title, relative time, and the row actions menu. @@ -368,6 +397,7 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork ? undefined : (e) => { e.dataTransfer.effectAllowed = 'move' + e.dataTransfer.setData('text/plain', node.id) drag.start() }} onDragEnd={drag?.end} From e713cc820dd89823bd435c989a6a7e271753b7e5 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 11 Aug 2026 13:43:54 +0800 Subject: [PATCH 08/81] style(client): polish sidebar spacing --- .../client/ui-settings/src/client/SettingsRoot.module.css | 6 +++--- .../client/ui-sidebar/src/client/SidebarRoot.module.css | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/client/ui-settings/src/client/SettingsRoot.module.css b/packages/client/ui-settings/src/client/SettingsRoot.module.css index 711387fbb4..dd3cd9661c 100644 --- a/packages/client/ui-settings/src/client/SettingsRoot.module.css +++ b/packages/client/ui-settings/src/client/SettingsRoot.module.css @@ -10,10 +10,10 @@ display: flex; align-items: center; gap: 8px; - width: 100%; + width: calc(100% - 8px); height: 38px; - margin: 4px 0 0; - padding: 8px 2px 8px 6px; + margin: 4px 4px 4px; + padding: 8px 2px 8px 10px; box-sizing: border-box; border: none; border-radius: 12px; diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css index 47a7a40967..2cc99e3159 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css @@ -167,7 +167,7 @@ gap: 6px; height: 38px; padding: 8px 16px; - margin: 0 2px 20px; /* bottom: former headerBlock padBottom 12 + root gap 8 */ + margin: 0 2px 8px; box-sizing: border-box; border: 1px solid var(--dsw-alias-border-l2); border-radius: 12px; From 325da4dab1a08aeb08c0f1e70823e8c7cc93f499 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 11 Aug 2026 14:10:10 +0800 Subject: [PATCH 09/81] fix(client): preserve workspace browser interactions --- .../src/client/WorkspaceBrowser.tsx | 74 ++++++++++++++----- .../ui-workspace/src/client/rows/Rows.tsx | 28 ++----- .../client/ui-workspace/src/client/stores.ts | 16 ++-- 3 files changed, 74 insertions(+), 44 deletions(-) diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx index 294b39b59a..016682c0c3 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx @@ -36,6 +36,7 @@ const SEARCH_DEBOUNCE_MS = 250 const SEARCH_QUERY_MAX_CODE_UNITS = 500 /** Session rows visible per Workspace before the local overflow control. */ const COLLAPSED_SESSION_LIMIT = 5 +const EMPTY_WORKSPACE_EXPANSION: Readonly> = Object.freeze({}) /** Keep controlled input and RPC payload inside the session.search wire contract. */ function sanitizeSearchQuery(value: string): string { @@ -48,7 +49,7 @@ function sanitizeSearchQuery(value: string): string { return withoutNul.slice(0, end) } -/** Immutable membership toggle for the local expansion arrays. */ +/** Immutable membership toggle for the local expand-all array. */ function toggled(list: readonly string[], key: string): string[] { return list.includes(key) ? list.filter(k => k !== key) : [...list, key] } @@ -116,12 +117,22 @@ interface WorkspaceDragState { over: { id: WorkspaceId; half: 'before' | 'after' } | null } +/** Resolve an insertion side from the full rendered workspace group. */ +function workspaceGroupHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' | 'after' { + const rect = e.currentTarget.getBoundingClientRect() + return e.clientY < rect.top + rect.height / 2 ? 'before' : 'after' +} + type SessionTreeProps = Pick< WorkspaceBrowserProps, 'useSessions' | 'startSession' | 'open' | 'forkSession' | 'insertWorkspaceBefore' | 'insertSessionBefore' | 't' > & { workspaces: readonly WorkspaceView[] + /** Explicit persisted zero-or-five-session state by Workspace group. */ + workspaceExpansion: Readonly> + /** Persist one Workspace group's zero-or-five-session state. */ + setWorkspaceExpanded: (key: string, expanded: boolean) => void /** Registry-global archive set (hidden rows). */ archivedSessionIds: readonly SessionNode['id'][] /** Open the browser-owned rename dialog for a real Workspace group. */ @@ -136,15 +147,15 @@ type SessionTreeProps = Pick< orderBy: SessionOrderBy } -/** The scrolling session tree; unmounting at collapse settle drops the sessions subscription and expansion state. */ +/** The scrolling session tree; unmounting drops the sessions subscription and expand-all state. */ function SessionTree({ useSessions, startSession, open, forkSession, workspaces, archivedSessionIds, onRenameRequest, onDeleteRequest, onSessionRename, onSessionArchive, - insertWorkspaceBefore, insertSessionBefore, orderBy, t, + insertWorkspaceBefore, insertSessionBefore, orderBy, + workspaceExpansion, setWorkspaceExpanded, t, }: SessionTreeProps) { const list = useSessions(s => s) const current = list.current - const [expandedProjects, setExpandedProjects] = useState([]) const [expandedSessionGroups, setExpandedSessionGroups] = useState([]) // Transient drag viewing state (never store-bound; order truth stays Host-side). const [drag, setDrag] = useState(null) @@ -154,9 +165,13 @@ function SessionTree({ : (workspaces.find(w => w.sessionIds.includes(current))?.workspaceId as string | undefined) ?? UNGROUPED_KEY useEffect(() => { - if (current === undefined || currentGroup === undefined) return - setExpandedProjects(l => (l.includes(currentGroup) ? l : [...l, currentGroup])) - }, [current, currentGroup]) + if (current === undefined || currentGroup === undefined || Object.hasOwn(workspaceExpansion, currentGroup)) return + setWorkspaceExpanded(currentGroup, true) + }, [current, currentGroup, setWorkspaceExpanded, workspaceExpansion]) + const expandedProjects = useMemo( + () => Object.entries(workspaceExpansion).filter(([, expanded]) => expanded).map(([key]) => key), + [workspaceExpansion], + ) const groups = useMemo( () => deriveGroups(list, workspaces, archivedSessionIds, { expandedProjects }, orderBy), [list, workspaces, archivedSessionIds, expandedProjects, orderBy], @@ -176,14 +191,18 @@ function SessionTree({ : null const workspaceDragProps = workspaceId === undefined ? undefined : { start: () => { setWorkspaceDrag({ workspaceId, over: null }) }, - active: workspaceDrag !== null, - marker: null, - hover: (half: 'before' | 'after') => { + end: () => { setWorkspaceDrag(null) }, + } + const hoverWorkspace = workspaceId === undefined + ? undefined + : (half: 'before' | 'after') => { setWorkspaceDrag(active => active === null ? active : { ...active, over: { id: workspaceId, half } }) - }, - drop: (half: 'before' | 'after') => { + } + const dropWorkspace = workspaceId === undefined + ? undefined + : (half: 'before' | 'after') => { if (workspaceDrag === null) return const rowIndex = workspaces.findIndex(workspace => workspace.workspaceId === workspaceId) const anchor = half === 'before' ? workspaceId : workspaces[rowIndex + 1]?.workspaceId @@ -197,9 +216,7 @@ function SessionTree({ insertWorkspaceBefore(workspaceDrag.workspaceId, anchor).catch((reason: unknown) => { console.warn('workspace reorder rejected:', reason) }) - }, - end: () => { setWorkspaceDrag(null) }, - } + } return ( // Group section: header row + expanded top-level session rows. The // inter-group breathing room is the section's own margin @@ -211,6 +228,19 @@ function SessionTree({ workspaceMarker === 'before' && css.workspaceDropBefore, workspaceMarker === 'after' && css.workspaceDropAfter, )} + onDragOver={workspaceDrag === null || hoverWorkspace === undefined + ? undefined + : (e) => { + e.preventDefault() + e.dataTransfer.dropEffect = 'move' + hoverWorkspace(workspaceGroupHalf(e)) + }} + onDrop={workspaceDrag === null || dropWorkspace === undefined + ? undefined + : (e) => { + e.preventDefault() + dropWorkspace(workspaceGroupHalf(e)) + }} > keys.filter(key => key !== group.key)) } - setExpandedProjects(l => toggled(l, group.key)) + setWorkspaceExpanded(group.key, !group.expanded) }} onCreate={() => { if (group.workspaceId !== undefined) startSession(group.workspaceId) @@ -458,6 +488,8 @@ export function WorkspaceBrowser({ // A flat list has no single Workspace account to drag. Keep the stored // grouped preference intact while presenting the flat list by recency. const effectiveOrderBy = groupBy === 'flat' && orderBy === 'manual' ? 'updated' : orderBy + // HMR can retain the preceding view-store instance until the slot remounts. + const workspaceExpansion = useStore(s => s.workspaceExpansion ?? EMPTY_WORKSPACE_EXPANSION) // The query outlives the tree and the input (both wide-only) so collapsing // does not silently drop an in-progress filter. const [query, setQuery] = useState('') @@ -497,12 +529,14 @@ export function WorkspaceBrowser({ useEffect(() => { if (!wide || !searchExpanded) return - const onPointerDown = (event: PointerEvent): void => { + const onClick = (event: MouseEvent): void => { if (!(event.target instanceof Node) || searchRoot.current?.contains(event.target) === true) return searchInput.current?.blur() + setQuery('') + setSearchExpanded(false) } - document.addEventListener('pointerdown', onPointerDown) - return () => { document.removeEventListener('pointerdown', onPointerDown) } + document.addEventListener('click', onClick) + return () => { document.removeEventListener('click', onClick) } }, [wide, searchExpanded]) useEffect(() => { @@ -810,6 +844,8 @@ export function WorkspaceBrowser({ onSessionArchive={onSessionArchive} forkSession={forkSession} workspaces={workspaces} + workspaceExpansion={workspaceExpansion} + setWorkspaceExpanded={actions.setWorkspaceExpanded} archivedSessionIds={archivedSessionIds} startSession={startSession} open={open} diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index 0903bbb600..9f56cda9f9 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -83,6 +83,12 @@ export interface RowDragProps { end: () => void } +/** Drag lifecycle owned by a workspace row; its enclosing group owns hit testing. */ +interface WorkspaceRowDragProps { + start: () => void + end: () => void +} + /** Pointer-position half of a row (insert line above or below). */ function rowHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' | 'after' { const rect = e.currentTarget.getBoundingClientRect() @@ -108,7 +114,7 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions, drag, t }: /** Real-Workspace actions; absent for the ungrouped bucket (no menu shown). */ actions?: { rename: () => void; delete: () => void } | undefined /** Present only for real Workspace rows in the grouped view. */ - drag?: RowDragProps | undefined + drag?: WorkspaceRowDragProps | undefined t: RowTranslate }) { const row = group @@ -122,10 +128,7 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions, drag, t }: ] const ownRow = (
{ - if (!drag.active) return - e.preventDefault() - e.dataTransfer.dropEffect = 'move' - drag.hover(rowHalf(e)) - }} - onDrop={drag === undefined - ? undefined - : (e) => { - if (!drag.active) return - e.preventDefault() - drag.drop(rowHalf(e)) - }} > {row.expanded ? : } diff --git a/packages/client/ui-workspace/src/client/stores.ts b/packages/client/ui-workspace/src/client/stores.ts index 91abedccfa..9a17ba0c15 100644 --- a/packages/client/ui-workspace/src/client/stores.ts +++ b/packages/client/ui-workspace/src/client/stores.ts @@ -12,8 +12,13 @@ export type WorkspaceGroupBy = 'workspace' | 'flat' /** Session order: durable Workspace order or a derived timestamp order. */ export type WorkspaceOrderBy = 'manual' | 'created' | 'updated' -/** Workspace browser viewing state; transient expansion facts stay component-local. */ -type WorkspaceViewState = { groupBy: WorkspaceGroupBy; orderBy: WorkspaceOrderBy } +/** Workspace browser viewing state persisted across surface remounts and reloads. */ +type WorkspaceViewState = { + groupBy: WorkspaceGroupBy + orderBy: WorkspaceOrderBy + /** Explicit zero-or-five-session state keyed by Workspace group identity. */ + workspaceExpansion: Record +} /** * Annotation twin of the actions literal below (the export needs a declared @@ -22,6 +27,7 @@ type WorkspaceViewState = { groupBy: WorkspaceGroupBy; orderBy: WorkspaceOrderBy type WorkspaceViewActions = { setGroupBy: (draft: WorkspaceViewState, mode: WorkspaceGroupBy) => void setOrderBy: (draft: WorkspaceViewState, mode: WorkspaceOrderBy) => void + setWorkspaceExpanded: (draft: WorkspaceViewState, key: string, expanded: boolean) => void } /** @@ -30,12 +36,12 @@ type WorkspaceViewActions = { */ export function createWorkspaceViewStore(): EngineStoreHandle { return defineStore({ - init: (): WorkspaceViewState => ({ groupBy: 'workspace', orderBy: 'manual' }), - // The added order field changes the whole-value persistence format. - persist: 'dsh.workspace.view.v2', + init: (): WorkspaceViewState => ({ groupBy: 'workspace', orderBy: 'manual', workspaceExpansion: {} }), + persist: 'dsh.workspace.view.v3', actions: { setGroupBy: (d, mode: WorkspaceGroupBy) => { d.groupBy = mode }, setOrderBy: (d, mode: WorkspaceOrderBy) => { d.orderBy = mode }, + setWorkspaceExpanded: (d, key: string, expanded: boolean) => { d.workspaceExpansion[key] = expanded }, }, }) } From a46a7bf912f371e29801fa88551457ea84baea3f Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 11 Aug 2026 14:10:21 +0800 Subject: [PATCH 10/81] style(client): simplify expanded workspace icon --- .../client/ui-workspace/src/client/rows/Rows.module.css | 4 ---- packages/client/ui-workspace/src/client/rows/Rows.tsx | 7 +++---- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/packages/client/ui-workspace/src/client/rows/Rows.module.css b/packages/client/ui-workspace/src/client/rows/Rows.module.css index 0880820e4a..902d8e0327 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.module.css +++ b/packages/client/ui-workspace/src/client/rows/Rows.module.css @@ -130,10 +130,6 @@ } -.folderActive { - color: var(--dsw-alias-state-business-primary); -} - /* Project leading slot: folder by default, expand arrow on row hover. */ .projectRow .chevron { display: none; } .projectRow:hover .chevron { display: inline-flex; } diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index 9f56cda9f9..05f36cf959 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -9,7 +9,7 @@ import { useState } from 'react' import clsx from 'clsx' import { HoverCard, IconArchiveOutline20, IconBranchOutline16, IconEditOutline16, - IconEllipsisOutline16, IconFolderClose16, IconFolderOpen16, IconPlusOutline16, + IconEllipsisOutline16, IconFolderClose16, IconFolderOpenOutline16, IconPlusOutline16, IconTrashOutline16, IconTriangleRightFill14, Menu, StateDot, } from '@deepseek-ai/dsh-client-ui-primitives' import type { StateDotState } from '@deepseek-ai/dsh-client-ui-primitives' @@ -120,7 +120,6 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions, drag, t }: const row = group // The ungrouped bucket has no workspace title: its label is dictionary copy. const label = row.workspaceId === undefined ? t('group.ungrouped') : row.label - const active = group.expanded && group.containsCurrent const [menuOpen, setMenuOpen] = useState(false) const workspaceMenuItems = [ { id: 'rename', label: t('rename'), icon: }, @@ -142,8 +141,8 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions, drag, t }: }} onDragEnd={drag?.end} > - - {row.expanded ? : } + + {row.expanded ? : } From 4ce51888be60a65bad565e3cfda15a1cb848471b Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 11 Aug 2026 14:19:51 +0800 Subject: [PATCH 11/81] fix(client): inherit current workspace for new sessions --- .../runtime/src/client/contract/workspaces.ts | 8 +++++--- .../runtime/src/client/workspaces/service.ts | 16 +++++++++++----- .../ui-sidebar/src/client/contract/slots.ts | 4 ++-- packages/client/ui-sidebar/src/client/index.ts | 2 +- .../ui-workspace/src/client/contract/slots.ts | 6 +++--- packages/client/ui-workspace/src/client/index.ts | 4 ++-- 6 files changed, 24 insertions(+), 16 deletions(-) diff --git a/packages/client/runtime/src/client/contract/workspaces.ts b/packages/client/runtime/src/client/contract/workspaces.ts index a541887df0..ff530ab800 100644 --- a/packages/client/runtime/src/client/contract/workspaces.ts +++ b/packages/client/runtime/src/client/contract/workspaces.ts @@ -21,9 +21,11 @@ export interface IWorkspaces { */ connectWorkspace(workspaceId: WorkspaceId): Promise /** - * The New Session flow: connect the target (or recent) Workspace and open - * the resulting session; failures surface on the session list state. - * @param workspaceId - explicit target; omitted uses the recency projection. + * The New Session flow: connect the explicit, current-Session, or recent + * Workspace and open the resulting session; failures surface on the session + * list state. + * @param workspaceId - explicit target; omitted inherits the current + * Session's Workspace before falling back to the recency projection. */ startSession(workspaceId?: WorkspaceId): void /** diff --git a/packages/client/runtime/src/client/workspaces/service.ts b/packages/client/runtime/src/client/workspaces/service.ts index 8b26d0f1b0..527f5ec0ab 100644 --- a/packages/client/runtime/src/client/workspaces/service.ts +++ b/packages/client/runtime/src/client/workspaces/service.ts @@ -167,14 +167,20 @@ export class WorkspacesService implements IWorkspaces { /** * The shared New Session action behind the shell entry points (sidebar * button, workspace browser): resolve the target Workspace — explicit wins, - * else the recent-Workspace projection — connect its blank session and - * navigate there; with no Workspace at all, clear the selection into the - * New Session view state. Connect failures are non-fatal (console - * diagnostics; the current view stays usable). + * then the current Session's Workspace, then the recent-Workspace + * projection — connect its blank session and navigate there; with no + * Workspace at all, clear the selection into the New Session view state. + * Connect failures are non-fatal (console diagnostics; the current view + * stays usable). * @param workspaceId - explicit target Workspace for scoped actions. */ startSession(workspaceId?: WorkspaceId): void { - const target = workspaceId ?? this.list.getSnapshot().recentWorkspaceId + const workspace = this.list.getSnapshot() + const current = this.sessions.list.getSnapshot().current + const currentWorkspaceId = current === undefined + ? undefined + : workspace.items.find(item => item.sessionIds.includes(current))?.workspaceId + const target = workspaceId ?? currentWorkspaceId ?? workspace.recentWorkspaceId if (target === undefined) { this.sessions.clear() return diff --git a/packages/client/ui-sidebar/src/client/contract/slots.ts b/packages/client/ui-sidebar/src/client/contract/slots.ts index 7b30e4232e..4da4d14eed 100644 --- a/packages/client/ui-sidebar/src/client/contract/slots.ts +++ b/packages/client/ui-sidebar/src/client/contract/slots.ts @@ -58,8 +58,8 @@ export interface SidebarSettingsOwnerProps { export type SidebarRootInjected = { /** * Start a New Session: with a workspace, reuse-or-create its blank session - * and open it; without one, clear the selection into the New Session pure - * view state (the conversation.empty seat). + * and open it; without one, inherit the current Session Workspace, then the + * recent Workspace, or clear into the New Session pure view when none exist. */ startSession: (workspaceId?: WorkspaceId) => void /** Toggle the sidebar column through the layout service. */ diff --git a/packages/client/ui-sidebar/src/client/index.ts b/packages/client/ui-sidebar/src/client/index.ts index 3d7ed23aa4..a9706c3e99 100644 --- a/packages/client/ui-sidebar/src/client/index.ts +++ b/packages/client/ui-sidebar/src/client/index.ts @@ -30,7 +30,7 @@ export function apply(ctx: ClientContext): void { const injectProps = (): SidebarRootInjected => ({ // The shell's New Session button rides the runtime's shared action - // (recent-Workspace targeting; explicit Workspace wins for scoped actions). + // (current Session Workspace, then recent Workspace). startSession: (workspaceId) => { ctx.workspaces.startSession(workspaceId) }, toggleSidebar: () => { ctx.layout.toggleSidebar() }, }) diff --git a/packages/client/ui-workspace/src/client/contract/slots.ts b/packages/client/ui-workspace/src/client/contract/slots.ts index e5487d2657..8027a3623a 100644 --- a/packages/client/ui-workspace/src/client/contract/slots.ts +++ b/packages/client/ui-workspace/src/client/contract/slots.ts @@ -91,9 +91,9 @@ export type DirectoryPickingHooks = { */ export type WorkspaceBrowserInjected = DirectoryPickingInjected & { /** - * Start a New Session in a Workspace: reuse-or-create its blank session - * and open it; with no workspace, clear the selection into the New Session - * pure view state (the conversation.empty seat). + * Start a New Session in a Workspace: reuse-or-create its blank session and + * open it; without an explicit workspace, inherit the current Session + * Workspace, then the recent Workspace, or clear into the New Session view. */ startSession: (workspaceId?: WorkspaceId) => void /** Open a real Session. */ diff --git a/packages/client/ui-workspace/src/client/index.ts b/packages/client/ui-workspace/src/client/index.ts index 41e88116fc..6b14243ecf 100644 --- a/packages/client/ui-workspace/src/client/index.ts +++ b/packages/client/ui-workspace/src/client/index.ts @@ -68,8 +68,8 @@ export function apply(ctx: ClientContext): void { const browserFlowSource = flowSource('sidebar.workspaces.directoryFlow') const pickerFlowSource = flowSource('conversation.hero.workspace.directoryFlow') const browserInjected = (): WorkspaceBrowserInjected => ({ - // Explicit group actions keep their target; unscoped New Session rides - // the runtime's shared action (recent-Workspace projection inside). + // Explicit group actions keep their target; unscoped New Session inherits + // the current Session Workspace before the recent-Workspace fallback. startSession: (workspaceId) => { ctx.workspaces.startSession(workspaceId) }, open: (sessionId) => { ctx.sessions.open(sessionId) }, searchSessions, From d86ecf7b29b71a60c1e3dfe0f8586a4dd2a821eb Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 11 Aug 2026 14:19:55 +0800 Subject: [PATCH 12/81] style(client): collapse search into header action --- .../src/client/WorkspaceBrowser.module.css | 35 ++++++++++++++----- .../src/client/WorkspaceBrowser.tsx | 4 +-- 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css b/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css index 270b8902d6..944c6917d1 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css @@ -70,11 +70,21 @@ .searchSlot { flex: 1; + max-width: 28px; min-width: 0; display: flex; align-items: center; - padding-left: 4px; + margin-left: auto; + padding-left: 0; box-sizing: border-box; + transition: + max-width 180ms var(--ds-ease-in-out), + padding-left 180ms var(--ds-ease-in-out); +} + +.searchSlotExpanded { + max-width: 100%; + padding-left: 4px; } .headerActions { @@ -110,15 +120,15 @@ align-items: center; gap: 0; width: 100%; - height: 26px; + height: 28px; margin: 0; padding: 0; box-sizing: border-box; - border: 1px solid var(--dsw-alias-border-l1); - border-radius: 10px; + border: none; + border-radius: 50%; background: transparent; cursor: text; - color: var(--dsw-alias-label-caption); + color: var(--dsw-alias-label-secondary); overflow: hidden; transition: width 180ms var(--ds-ease-in-out), @@ -128,9 +138,12 @@ } .searchExpanded { + height: 26px; padding: 0 4px 0 0; - border-color: var(--dsw-alias-border-l2); + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 10px; background: transparent; + color: var(--dsw-alias-label-caption); } .searchButton { @@ -138,8 +151,8 @@ display: inline-flex; align-items: center; justify-content: center; - width: 26px; - height: 26px; + width: 28px; + height: 28px; border: none; border-radius: 50%; padding: 0; @@ -148,6 +161,11 @@ color: inherit; } +.searchExpanded .searchButton { + width: 26px; + height: 26px; +} + .searchButton:hover { background: var(--dsw-alias-interactive-bg-hover); } @@ -420,6 +438,7 @@ } .search, + .searchSlot, .searchInput, .headerActions { transition: none; diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx index 016682c0c3..5c27af999f 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx @@ -691,7 +691,7 @@ export function WorkspaceBrowser({ )} {wide && ( -
+
- + Date: Tue, 11 Aug 2026 14:50:50 +0800 Subject: [PATCH 13/81] feat(client): refine workspace sidebar interactions --- .../src/client/SettingsRoot.module.css | 10 +- .../src/client/WorkspaceBrowser.module.css | 54 ++++- .../src/client/WorkspaceBrowser.tsx | 194 +++++++++++++++--- .../client/ui-workspace/src/client/locales.ts | 2 - .../src/client/rows/Rows.module.css | 25 ++- .../ui-workspace/src/client/rows/Rows.tsx | 10 +- .../client/ui-workspace/src/client/stores.ts | 32 ++- .../client/ui-workspace/src/client/tree.ts | 14 +- 8 files changed, 267 insertions(+), 74 deletions(-) diff --git a/packages/client/ui-settings/src/client/SettingsRoot.module.css b/packages/client/ui-settings/src/client/SettingsRoot.module.css index dd3cd9661c..060e8d115b 100644 --- a/packages/client/ui-settings/src/client/SettingsRoot.module.css +++ b/packages/client/ui-settings/src/client/SettingsRoot.module.css @@ -1,6 +1,6 @@ /* Settings shell (figma 501:29904 mask context / 501:29947 panel): sidebar foot trigger row + centered 1080x700 modal panel. The trigger uses the - sidebar's 38px wide row / 36px rail circle rhythm; the + sidebar's 34px compact row / 36px rail circle rhythm; the panel is a two-column layout — 188px nav rail + content column with a 54px header and the 24px-padded options area. */ @@ -10,10 +10,10 @@ display: flex; align-items: center; gap: 8px; - width: calc(100% - 8px); - height: 38px; - margin: 4px 4px 4px; - padding: 8px 2px 8px 10px; + width: calc(100% + 8px); + height: 34px; + margin: 4px -4px 4px; + padding: 6px 2px 6px 10px; box-sizing: border-box; border: none; border-radius: 12px; diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css b/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css index 944c6917d1..1431096afe 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css @@ -38,6 +38,19 @@ background: var(--dsw-alias-interactive-bg-hover); } +.viewOptionLabel { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + width: 100%; +} + +.viewOptionCheck { + flex: none; + color: var(--dsw-alias-label-primary); +} + /* Section header: title, an inline search control, and the two trailing actions. Expanding search collapses the action cluster and takes its room. */ .sectionHeader { @@ -56,6 +69,7 @@ } .root:not(.rail) .sectionHeader { + margin-top: 2px; margin-right: -4px; } @@ -66,6 +80,23 @@ overflow: hidden; white-space: nowrap; line-height: 20px; + opacity: 1; + visibility: visible; + transition: + max-width 180ms var(--ds-ease-in-out), + margin-right 180ms var(--ds-ease-in-out), + opacity 120ms var(--ds-ease-in-out), + transform 180ms var(--ds-ease-in-out), + visibility 0s linear; +} + +.sectionLabelHidden { + max-width: 0; + margin-right: -4px; + opacity: 0; + transform: translateX(-4px); + visibility: hidden; + transition-delay: 0s, 0s, 0s, 0s, 180ms; } .searchSlot { @@ -84,7 +115,7 @@ .searchSlotExpanded { max-width: 100%; - padding-left: 4px; + padding-left: 0; } .headerActions { @@ -138,7 +169,9 @@ } .searchExpanded { - height: 26px; + width: calc(100% + 4px); + height: 34px; + margin-inline: -2px; padding: 0 4px 0 0; border: 1px solid var(--dsw-alias-border-l2); border-radius: 10px; @@ -162,8 +195,8 @@ } .searchExpanded .searchButton { - width: 26px; - height: 26px; + width: 28px; + height: 34px; } .searchButton:hover { @@ -204,8 +237,8 @@ display: inline-flex; align-items: center; justify-content: center; - width: 18px; - height: 18px; + width: 24px; + height: 24px; border: none; border-radius: 50%; padding: 0; @@ -214,6 +247,10 @@ color: var(--dsw-alias-label-secondary); } +.clearButton:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + /* Rail variant (own .rail class from the wide owner prop — the region never reads the shell's class names): the two icon controls stack as 36x36 circles matching the shell's rail rhythm. */ @@ -367,7 +404,7 @@ .sessionOverflowButton { width: 100%; - height: 30px; + height: 28px; border: none; border-radius: 8px; padding: 0 12px 0 28px; @@ -385,8 +422,6 @@ .sessionOverflowButton:hover { background: transparent; color: var(--dsw-alias-label-secondary); - text-decoration: underline; - text-underline-offset: 2px; } .empty { @@ -438,6 +473,7 @@ } .search, + .sectionLabel, .searchSlot, .searchInput, .headerActions { diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx index 5c27af999f..f1dd5dd8aa 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx @@ -12,11 +12,11 @@ import { useEffect, useMemo, useRef, useState } from 'react' import clsx from 'clsx' import { - Button, IconCloseFill14, IconPersonalizationOutline16, + Button, IconCheckOutline16, IconCloseFill14, IconPersonalizationOutline16, IconProjectAddOutline16, IconSearchOutline16, Menu, Modal, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' import type { - SessionSearchResultItem, WorkspaceId, WorkspaceView, + SessionId, SessionListState, SessionSearchResultItem, WorkspaceId, WorkspaceView, } from '@deepseek-ai/dsh-client-runtime/client' import type { WorkspaceBrowserProps } from './contract/slots.ts' import type { SessionNode, SessionOrderBy } from './tree.ts' @@ -37,6 +37,8 @@ const SEARCH_QUERY_MAX_CODE_UNITS = 500 /** Session rows visible per Workspace before the local overflow control. */ const COLLAPSED_SESSION_LIMIT = 5 const EMPTY_WORKSPACE_EXPANSION: Readonly> = Object.freeze({}) +const EMPTY_RECENT_SESSION_ORDER: Readonly> = Object.freeze({}) +const EMPTY_RECENT_SESSION_UPDATED_AT: Readonly>>> = Object.freeze({}) /** Keep controlled input and RPC payload inside the session.search wire contract. */ function sanitizeSearchQuery(value: string): string { @@ -54,6 +56,33 @@ function toggled(list: readonly string[], key: string): string[] { return list.includes(key) ? list.filter(k => k !== key) : [...list, key] } +/** Reconcile a stored view order with the Workspace's current session account. */ +function reconciledSessionOrder(sessionIds: readonly SessionId[], stored: readonly string[] | undefined): SessionId[] { + if (stored === undefined) return [...sessionIds] + const byId = new Map(sessionIds.map(id => [id as string, id])) + const ordered: SessionId[] = [] + const included = new Set() + for (const key of stored) { + const id = byId.get(key) + if (id === undefined || included.has(key)) continue + ordered.push(id) + included.add(key) + } + for (const id of sessionIds) { + if (included.has(id)) continue + ordered.push(id) + } + return ordered +} + +/** Newest update first with stable Session identity as the tie-break. */ +function compareSessionRecency(a: SessionId, b: SessionId, byId: SessionListState['byId']): number { + const aUpdatedAt = byId[a]?.updatedAt ?? Number.NEGATIVE_INFINITY + const bUpdatedAt = byId[b]?.updatedAt ?? Number.NEGATIVE_INFINITY + if (aUpdatedAt !== bUpdatedAt) return bUpdatedAt - aUpdatedAt + return a < b ? -1 : 1 +} + /** Grouping and ordering menu; own open state so it resets with the wide chrome. */ function ViewOptionsMenu({ groupBy, orderBy, onGroupPick, onOrderPick, t }: { groupBy: 'workspace' | 'flat' @@ -63,23 +92,27 @@ function ViewOptionsMenu({ groupBy, orderBy, onGroupPick, onOrderPick, t }: { t: WorkspaceBrowserProps['t'] }) { const [open, setOpen] = useState(false) + const optionLabel = (label: string, selected: boolean) => ( + + {label} + {selected && } + + ) return ( { setOpen(false) }} items={[ { type: 'label' as const, id: 'group-by', text: t('groupBy.label') }, - { id: 'workspace', label: t('groupBy.workspace') }, - { id: 'flat', label: t('groupBy.flat') }, + { id: 'workspace', label: optionLabel(t('groupBy.workspace'), groupBy === 'workspace') }, + { id: 'flat', label: optionLabel(t('groupBy.flat'), groupBy === 'flat') }, { type: 'label' as const, id: 'order-by', text: t('orderBy.label') }, - { id: 'manual', label: t('orderBy.manual'), disabled: groupBy !== 'workspace' }, - { id: 'created', label: t('orderBy.created') }, - { id: 'updated', label: t('orderBy.updated') }, + { id: 'manual', label: optionLabel(t('orderBy.manual'), orderBy === 'manual'), disabled: groupBy !== 'workspace' }, + { id: 'updated', label: optionLabel(t('orderBy.updated'), orderBy === 'updated') }, ]} - selectedIds={[groupBy, orderBy]} onSelect={(id) => { if (id === 'workspace' || id === 'flat') onGroupPick(id) - else if (id === 'manual' || id === 'created' || id === 'updated') onOrderPick(id) + else if (id === 'manual' || id === 'updated') onOrderPick(id) setOpen(false) }} align="end" @@ -133,6 +166,14 @@ type SessionTreeProps = Pick< workspaceExpansion: Readonly> /** Persist one Workspace group's zero-or-five-session state. */ setWorkspaceExpanded: (key: string, expanded: boolean) => void + /** Editable orders used by recent-update mode. */ + recentSessionOrder: Readonly> + /** Last update timestamps observed by recent-update mode. */ + recentSessionUpdatedAt: Readonly>>> + /** Replace one recent-mode order and its observed timestamps. */ + syncRecentSessions: (workspaceKey: string, order: string[], updatedAt: Record) => void + /** Apply a manual drag inside one recent-mode order. */ + setRecentSessionOrder: (workspaceKey: string, order: string[]) => void /** Registry-global archive set (hidden rows). */ archivedSessionIds: readonly SessionNode['id'][] /** Open the browser-owned rename dialog for a real Workspace group. */ @@ -143,7 +184,7 @@ type SessionTreeProps = Pick< 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 - /** Session visual order; only manual mode exposes durable Session dragging. */ + /** Session visual order; manual mode drags durable order, updated mode drags its view order. */ orderBy: SessionOrderBy } @@ -152,14 +193,34 @@ function SessionTree({ useSessions, startSession, open, forkSession, workspaces, archivedSessionIds, onRenameRequest, onDeleteRequest, onSessionRename, onSessionArchive, insertWorkspaceBefore, insertSessionBefore, orderBy, - workspaceExpansion, setWorkspaceExpanded, t, + workspaceExpansion, setWorkspaceExpanded, + recentSessionOrder, recentSessionUpdatedAt, syncRecentSessions, setRecentSessionOrder, t, }: SessionTreeProps) { const list = useSessions(s => s) const current = list.current const [expandedSessionGroups, setExpandedSessionGroups] = useState([]) - // Transient drag viewing state (never store-bound; order truth stays Host-side). + // Transient drag marker state; the selected mode owns the resulting order. const [drag, setDrag] = useState(null) + const sessionDropCommitted = useRef(false) const [workspaceDrag, setWorkspaceDrag] = useState(null) + const sessionDragging = drag !== null + useEffect(() => { + if (!sessionDragging) return + // Row hover still owns the insertion marker. Accept the native drag at + // document level so releasing outside the list is not rendered as a + // rejected drop before dragend commits that last marker. + const acceptDrag = (event: DragEvent): void => { + event.preventDefault() + if (event.dataTransfer !== null) event.dataTransfer.dropEffect = 'move' + } + const acceptDrop = (event: DragEvent): void => { event.preventDefault() } + document.addEventListener('dragover', acceptDrag) + document.addEventListener('drop', acceptDrop) + return () => { + document.removeEventListener('dragover', acceptDrag) + document.removeEventListener('drop', acceptDrop) + } + }, [sessionDragging]) const currentGroup = current === undefined ? undefined : (workspaces.find(w => w.sessionIds.includes(current))?.workspaceId as string | undefined) @@ -172,11 +233,79 @@ function SessionTree({ () => Object.entries(workspaceExpansion).filter(([, expanded]) => expanded).map(([key]) => key), [workspaceExpansion], ) + useEffect(() => { + if (orderBy !== 'updated' || list.phase !== 'ready') return + for (const workspace of workspaces) { + const key = workspace.workspaceId as string + const sessionIds = workspace.sessionIds.filter(id => list.byId[id] !== undefined) + const previousOrder = recentSessionOrder[key] + const previousUpdatedAt = recentSessionUpdatedAt[key] ?? {} + let nextOrder = reconciledSessionOrder(sessionIds, previousOrder) + if (previousOrder === undefined) { + nextOrder.sort((a, b) => compareSessionRecency(a, b, list.byId)) + } else { + const promoted = sessionIds + .filter((id) => previousUpdatedAt[id] === undefined || list.byId[id]!.updatedAt > previousUpdatedAt[id]!) + .sort((a, b) => compareSessionRecency(a, b, list.byId)) + if (promoted.length > 0) { + const promotedIds = new Set(promoted) + nextOrder = [...promoted, ...nextOrder.filter(id => !promotedIds.has(id))] + } + } + const nextUpdatedAt: Record = {} + for (const id of sessionIds) nextUpdatedAt[id] = list.byId[id]!.updatedAt + const orderChanged = previousOrder === undefined + || nextOrder.length !== previousOrder.length + || nextOrder.some((id, index) => id !== previousOrder[index]) + const timestampsChanged = Object.keys(nextUpdatedAt).length !== Object.keys(previousUpdatedAt).length + || Object.entries(nextUpdatedAt).some(([id, updatedAt]) => previousUpdatedAt[id] !== updatedAt) + if (orderChanged || timestampsChanged) { + syncRecentSessions(key, nextOrder.map(id => id as string), nextUpdatedAt) + } + } + }, [list, orderBy, recentSessionOrder, recentSessionUpdatedAt, syncRecentSessions, workspaces]) + const orderedWorkspaces = useMemo(() => { + if (orderBy !== 'updated') return workspaces + return workspaces.map((workspace) => { + const stored = recentSessionOrder[workspace.workspaceId as string] + const sessionIds = reconciledSessionOrder(workspace.sessionIds, stored) + if (stored === undefined) sessionIds.sort((a, b) => compareSessionRecency(a, b, list.byId)) + return { ...workspace, sessionIds } + }) + }, [list.byId, orderBy, recentSessionOrder, workspaces]) const groups = useMemo( - () => deriveGroups(list, workspaces, archivedSessionIds, { expandedProjects }, orderBy), - [list, workspaces, archivedSessionIds, expandedProjects, orderBy], + () => deriveGroups(list, orderedWorkspaces, archivedSessionIds, { expandedProjects }, 'manual'), + [list, orderedWorkspaces, archivedSessionIds, expandedProjects], ) const now = Date.now() + const commitSessionDrag = (activeDrag: DragState, over: NonNullable): void => { + if (sessionDropCommitted.current) return + sessionDropCommitted.current = true + setDrag(null) + const group = groups.find(candidate => candidate.workspaceId === activeDrag.workspaceId) + if (group === undefined) return + const targetIndex = group.sessions.findIndex(session => session.id === over.id) + if (targetIndex === -1) return + const anchor = over.half === 'before' ? over.id : group.sessions[targetIndex + 1]?.id + if (anchor === activeDrag.sessionId) return + const sourceIndex = group.sessions.findIndex(session => session.id === activeDrag.sessionId) + const anchorIndex = anchor === undefined + ? group.sessions.length + : group.sessions.findIndex(session => session.id === anchor) + if (sourceIndex !== -1 && (anchorIndex === sourceIndex || anchorIndex === sourceIndex + 1)) return + if (orderBy === 'updated') { + const account = orderedWorkspaces.find(workspace => workspace.workspaceId === activeDrag.workspaceId) + if (account === undefined) return + const nextOrder = account.sessionIds.filter(id => id !== activeDrag.sessionId) + const insertAt = anchor === undefined ? nextOrder.length : nextOrder.indexOf(anchor) + nextOrder.splice(insertAt === -1 ? nextOrder.length : insertAt, 0, activeDrag.sessionId) + setRecentSessionOrder(activeDrag.workspaceId as string, nextOrder.map(id => id as string)) + return + } + insertSessionBefore(activeDrag.workspaceId, activeDrag.sessionId, anchor).catch((reason: unknown) => { + console.warn('session reorder rejected:', reason) + }) + } return (
@@ -271,14 +400,15 @@ function SessionTree({ {(expandedSessionGroups.includes(group.key) ? group.sessions : group.sessions.slice(0, COLLAPSED_SESSION_LIMIT) - ).map((node, index) => { + ).map((node) => { // Draggable: real-workspace session rows. The drag // never leaves its group — rows of other groups show no markers // and reject drops (visual movement confined to this section). - const draggable = group.workspaceId !== undefined && orderBy === 'manual' + const draggable = group.workspaceId !== undefined const sameGroupDrag = drag !== null && drag.workspaceId === group.workspaceId const dragProps = !draggable || group.workspaceId === undefined ? undefined : { start: () => { + sessionDropCommitted.current = false setDrag({ workspaceId: group.workspaceId as WorkspaceId, sessionId: node.id, over: null }) }, active: sameGroupDrag, @@ -290,21 +420,13 @@ function SessionTree({ drop: (half: 'before' | 'after') => { /* v8 ignore next -- narrowing guard: Rows gates drop on `active`, which is false while the drag state is null. */ if (drag === null) return - const sessions = group.sessions - // Anchor = the row the insert line points at ('after' means - // the next root; end-of-list omits the anchor → append). - const anchor = half === 'before' ? node.id : sessions[index + 1]?.id - setDrag(null) - if (anchor === drag.sessionId) return - // No-op when the drop lands back on the source position. - const sourceIndex = sessions.findIndex(r => r.id === drag.sessionId) - const anchorIndex = anchor === undefined ? sessions.length : sessions.findIndex(r => r.id === anchor) - if (sourceIndex !== -1 && (anchorIndex === sourceIndex || anchorIndex === sourceIndex + 1)) return - insertSessionBefore(drag.workspaceId, drag.sessionId, anchor).catch((reason: unknown) => { - console.warn('session reorder rejected:', reason) - }) + commitSessionDrag(drag, { id: node.id, half }) + }, + end: () => { + if (drag?.over !== null && drag?.over !== undefined) commitSessionDrag(drag, drag.over) + else setDrag(null) + sessionDropCommitted.current = false }, - end: () => { setDrag(null) }, } return ( s.workspaceExpansion ?? EMPTY_WORKSPACE_EXPANSION) + const recentSessionOrder = useStore(s => s.recentSessionOrder ?? EMPTY_RECENT_SESSION_ORDER) + const recentSessionUpdatedAt = useStore(s => s.recentSessionUpdatedAt ?? EMPTY_RECENT_SESSION_UPDATED_AT) // The query outlives the tree and the input (both wide-only) so collapsing // does not silently drop an in-progress filter. const [query, setQuery] = useState('') @@ -532,12 +656,12 @@ export function WorkspaceBrowser({ const onClick = (event: MouseEvent): void => { if (!(event.target instanceof Node) || searchRoot.current?.contains(event.target) === true) return searchInput.current?.blur() - setQuery('') + if (query !== '') return setSearchExpanded(false) } document.addEventListener('click', onClick) return () => { document.removeEventListener('click', onClick) } - }, [wide, searchExpanded]) + }, [query, wide, searchExpanded]) useEffect(() => { if (normalizedQuery === '') { @@ -686,7 +810,7 @@ export function WorkspaceBrowser({
{wide && ( - + {groupBy === 'flat' ? t('section.sessions') : t('section.workspaces')} )} @@ -846,6 +970,10 @@ export function WorkspaceBrowser({ workspaces={workspaces} workspaceExpansion={workspaceExpansion} setWorkspaceExpanded={actions.setWorkspaceExpanded} + recentSessionOrder={recentSessionOrder} + recentSessionUpdatedAt={recentSessionUpdatedAt} + syncRecentSessions={actions.syncRecentSessions} + setRecentSessionOrder={actions.setRecentSessionOrder} archivedSessionIds={archivedSessionIds} startSession={startSession} open={open} diff --git a/packages/client/ui-workspace/src/client/locales.ts b/packages/client/ui-workspace/src/client/locales.ts index c816326157..e43c977a40 100644 --- a/packages/client/ui-workspace/src/client/locales.ts +++ b/packages/client/ui-workspace/src/client/locales.ts @@ -15,7 +15,6 @@ export const zh = { 'groupBy.flat': '单列表', 'orderBy.label': '排序方式', 'orderBy.manual': '手动排序', - 'orderBy.created': '创建时间', 'orderBy.updated': '最近更新', 'sessions.expand': '展开其余 {n} 个会话', 'sessions.collapse': '收起', @@ -84,7 +83,6 @@ export const en = { 'groupBy.flat': 'In one list', 'orderBy.label': 'Order by', 'orderBy.manual': 'Manual', - 'orderBy.created': 'Date created', 'orderBy.updated': 'Last updated', 'sessions.expand': 'Show {n} more sessions', 'sessions.collapse': 'Show less', diff --git a/packages/client/ui-workspace/src/client/rows/Rows.module.css b/packages/client/ui-workspace/src/client/rows/Rows.module.css index 902d8e0327..548407fd40 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.module.css +++ b/packages/client/ui-workspace/src/client/rows/Rows.module.css @@ -1,5 +1,5 @@ -/* Tree rows (figma Cell set 14:3080): project 54px two-line, session 34px - single-line, radius 8, indent step 22px (16px slot + 6px gap). Hover swaps +/* Tree rows: project 34px, session 32px, radius 8, indent step 22px + (16px slot + 6px gap). Hover swaps are pure CSS: project folder -> chevron + action buttons; session time -> ellipsis button. */ @@ -29,11 +29,11 @@ flex-direction: column; align-items: stretch; width: 100%; - min-height: 62px; + min-height: 48px; box-sizing: border-box; border: none; border-radius: 8px; - padding: 7px 8px; + padding: 4px 8px; background: transparent; cursor: pointer; text-align: left; @@ -64,9 +64,16 @@ line-height: 20px; } +.searchResultMeta { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + margin-left: 20px; +} + .searchResultWorkspace, .searchResultSnippet { - margin-left: 20px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -75,16 +82,20 @@ } .searchResultWorkspace { + flex: none; + max-width: 40%; color: var(--dsw-alias-label-tertiary); } .searchResultSnippet { + flex: 1; + min-width: 0; color: var(--dsw-alias-label-secondary); } /* Compact one-line Workspace row after removing the session-count subtitle. */ .projectRow { - height: 36px; + height: 34px; align-items: center; box-sizing: border-box; } @@ -95,7 +106,7 @@ /* Session cell (figma): pad 8, a 16px status slot, then a 4px title gap. */ .sessionRow { - height: 34px; + height: 32px; gap: 0; /* Mount fade: session rows appear by unfolding a group (or the tree mounting). Stable row keys keep already-visible rows from replaying it. */ diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index 05f36cf959..71998f2b6b 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -317,10 +317,12 @@ export function SearchResultItem({ result, currentId, onOpen, t }: { {result.title} - {result.workspace} - {result.snippet !== undefined && ( - {result.snippet} - )} + + {result.workspace} + {result.snippet !== undefined && ( + {result.snippet} + )} + ) } diff --git a/packages/client/ui-workspace/src/client/stores.ts b/packages/client/ui-workspace/src/client/stores.ts index 9a17ba0c15..e5334b0e4f 100644 --- a/packages/client/ui-workspace/src/client/stores.ts +++ b/packages/client/ui-workspace/src/client/stores.ts @@ -9,8 +9,8 @@ import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-run /** Session-list grouping mode: workspace sections or one flat recency list. */ export type WorkspaceGroupBy = 'workspace' | 'flat' -/** Session order: durable Workspace order or a derived timestamp order. */ -export type WorkspaceOrderBy = 'manual' | 'created' | 'updated' +/** Session order: durable Workspace order or an activity-promoted editable order. */ +export type WorkspaceOrderBy = 'manual' | 'updated' /** Workspace browser viewing state persisted across surface remounts and reloads. */ type WorkspaceViewState = { @@ -18,6 +18,10 @@ type WorkspaceViewState = { orderBy: WorkspaceOrderBy /** Explicit zero-or-five-session state keyed by Workspace group identity. */ workspaceExpansion: Record + /** Editable per-Workspace order used by recent-update mode. */ + recentSessionOrder: Record + /** Last observed update timestamps used to detect promotion events. */ + recentSessionUpdatedAt: Record> } /** @@ -28,6 +32,13 @@ type WorkspaceViewActions = { setGroupBy: (draft: WorkspaceViewState, mode: WorkspaceGroupBy) => void setOrderBy: (draft: WorkspaceViewState, mode: WorkspaceOrderBy) => void setWorkspaceExpanded: (draft: WorkspaceViewState, key: string, expanded: boolean) => void + syncRecentSessions: ( + draft: WorkspaceViewState, + workspaceKey: string, + order: string[], + updatedAt: Record, + ) => void + setRecentSessionOrder: (draft: WorkspaceViewState, workspaceKey: string, order: string[]) => void } /** @@ -36,12 +47,25 @@ type WorkspaceViewActions = { */ export function createWorkspaceViewStore(): EngineStoreHandle { return defineStore({ - init: (): WorkspaceViewState => ({ groupBy: 'workspace', orderBy: 'manual', workspaceExpansion: {} }), - persist: 'dsh.workspace.view.v3', + init: (): WorkspaceViewState => ({ + groupBy: 'workspace', + orderBy: 'manual', + workspaceExpansion: {}, + recentSessionOrder: {}, + recentSessionUpdatedAt: {}, + }), + persist: 'dsh.workspace.view.v4', actions: { setGroupBy: (d, mode: WorkspaceGroupBy) => { d.groupBy = mode }, setOrderBy: (d, mode: WorkspaceOrderBy) => { d.orderBy = mode }, setWorkspaceExpanded: (d, key: string, expanded: boolean) => { d.workspaceExpansion[key] = expanded }, + syncRecentSessions: (d, workspaceKey: string, order: string[], updatedAt: Record) => { + d.recentSessionOrder[workspaceKey] = order + d.recentSessionUpdatedAt[workspaceKey] = updatedAt + }, + setRecentSessionOrder: (d, workspaceKey: string, order: string[]) => { + d.recentSessionOrder[workspaceKey] = order + }, }, }) } diff --git a/packages/client/ui-workspace/src/client/tree.ts b/packages/client/ui-workspace/src/client/tree.ts index 5120e31931..553742ef90 100644 --- a/packages/client/ui-workspace/src/client/tree.ts +++ b/packages/client/ui-workspace/src/client/tree.ts @@ -34,7 +34,7 @@ export interface SessionNode { } /** Session order selected by the Workspace browser. */ -export type SessionOrderBy = 'manual' | 'created' | 'updated' +export type SessionOrderBy = 'manual' | 'updated' /** One workspace group section: header row facts + visible top-level session rows. */ export interface GroupNode { @@ -108,14 +108,8 @@ function byRecency(a: SessionSummary, b: SessionSummary): number { return a.id < b.id ? -1 : 1 } -/** Newest-created first, id as the deterministic tiebreak. */ -function byCreation(a: SessionSummary, b: SessionSummary): number { - if (b.createdAt !== a.createdAt) return b.createdAt - a.createdAt - return a.id < b.id ? -1 : 1 -} - -function sortSessions(sessions: SessionSummary[], orderBy: Exclude): void { - sessions.sort(orderBy === 'created' ? byCreation : byRecency) +function sortSessions(sessions: SessionSummary[]): void { + sessions.sort(byRecency) } /** @@ -150,7 +144,7 @@ function buildGroup( orderBy: SessionOrderBy, ): Group { const sessions = [...members] - if (orderBy !== 'manual') sortSessions(sessions, orderBy) + if (orderBy !== 'manual') sortSessions(sessions) return { key, workspaceId, cwd, createdAt, label, sessions } } From 23889483cf90104fac0b71c47b5c12349702b3eb Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 11 Aug 2026 14:50:57 +0800 Subject: [PATCH 14/81] style(client): refine conversation tab indicator --- .../skeleton/ConversationRoot.module.css | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css index 971661cd48..0a69ec6e72 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -26,9 +26,22 @@ } .header { + position: relative; flex: none; padding: 12px 28px 0 20px; - border-bottom: 1px solid var(--dsw-alias-border-l2); + border-bottom: 1px solid transparent; +} + +.header::after { + content: ''; + position: absolute; + right: 0; + bottom: 1px; + left: 0; + z-index: 0; + height: 1px; + background: var(--dsw-alias-border-l2); + pointer-events: none; } /* Blank hero/settling: keep the strict Session header mounted without taking @@ -100,13 +113,15 @@ /* figma Tab_Group 34:11441: 35px strip, gap 36, pad-left 8, tabs bottom-aligned. */ .tabs { + position: relative; + z-index: 1; display: flex; gap: 36px; margin-top: 4px; padding-left: 8px; } -/* figma .Tab 34:11442: 13/16 text (figma wt510, rendered 500), gap 8 to the 3px bar (no bottom rounding). */ +/* figma .Tab 34:11442: 13/16 text (figma wt510, rendered 500), gap 8 to the 3px bar. */ .tab { position: relative; padding: 0 0 11px; @@ -126,6 +141,7 @@ bottom: 0; left: 0; height: 3px; + border-radius: 2px; background: transparent; } From c4affa852c2bed4f5bf3d79291d143a74a9d3cfb Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 11 Aug 2026 14:55:20 +0800 Subject: [PATCH 15/81] style(client): reduce conversation tab indicator --- .../src/client/skeleton/ConversationRoot.module.css | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css index 0a69ec6e72..473e2a1fd7 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -1,7 +1,7 @@ /* Conversation column skeleton: header (breadcrumb row only for subagents not fork + tabs) over the view area, composer InputBar at the bottom. Column width/squeeze is layout's; this fills its cell. Figma: Header 39:27730 (83px two-row), tabs 13px with - a 3px active bar. */ + a 2px active bar. */ .root { display: flex; @@ -121,7 +121,7 @@ padding-left: 8px; } -/* figma .Tab 34:11442: 13/16 text (figma wt510, rendered 500), gap 8 to the 3px bar. */ +/* figma .Tab 34:11442: 13/16 text (figma wt510, rendered 500), gap 8 to the 2px bar. */ .tab { position: relative; padding: 0 0 11px; @@ -138,9 +138,9 @@ content: ''; position: absolute; right: 0; - bottom: 0; + bottom: 1px; left: 0; - height: 3px; + height: 2px; border-radius: 2px; background: transparent; } From 8005ab78bd3bf2243e82c243b720389e367d76a7 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 11 Aug 2026 15:24:32 +0800 Subject: [PATCH 16/81] fix(client): keep flat sessions ordered by recency --- .../src/client/WorkspaceBrowser.tsx | 235 +++++++++--------- .../client/ui-workspace/src/client/tree.ts | 8 +- 2 files changed, 122 insertions(+), 121 deletions(-) diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx index f1dd5dd8aa..637b89c162 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx @@ -36,9 +36,6 @@ const SEARCH_DEBOUNCE_MS = 250 const SEARCH_QUERY_MAX_CODE_UNITS = 500 /** Session rows visible per Workspace before the local overflow control. */ const COLLAPSED_SESSION_LIMIT = 5 -const EMPTY_WORKSPACE_EXPANSION: Readonly> = Object.freeze({}) -const EMPTY_RECENT_SESSION_ORDER: Readonly> = Object.freeze({}) -const EMPTY_RECENT_SESSION_UPDATED_AT: Readonly>>> = Object.freeze({}) /** Keep controlled input and RPC payload inside the session.search wire contract. */ function sanitizeSearchQuery(value: string): string { @@ -245,7 +242,11 @@ function SessionTree({ nextOrder.sort((a, b) => compareSessionRecency(a, b, list.byId)) } else { const promoted = sessionIds - .filter((id) => previousUpdatedAt[id] === undefined || list.byId[id]!.updatedAt > previousUpdatedAt[id]!) + .filter((id) => { + const session = list.byId[id] + return session !== undefined + && (previousUpdatedAt[id] === undefined || session.updatedAt > previousUpdatedAt[id]) + }) .sort((a, b) => compareSessionRecency(a, b, list.byId)) if (promoted.length > 0) { const promotedIds = new Set(promoted) @@ -253,7 +254,10 @@ function SessionTree({ } } const nextUpdatedAt: Record = {} - for (const id of sessionIds) nextUpdatedAt[id] = list.byId[id]!.updatedAt + for (const id of sessionIds) { + const session = list.byId[id] + if (session !== undefined) nextUpdatedAt[id] = session.updatedAt + } const orderChanged = previousOrder === undefined || nextOrder.length !== previousOrder.length || nextOrder.some((id, index) => id !== previousOrder[index]) @@ -299,7 +303,7 @@ function SessionTree({ const nextOrder = account.sessionIds.filter(id => id !== activeDrag.sessionId) const insertAt = anchor === undefined ? nextOrder.length : nextOrder.indexOf(anchor) nextOrder.splice(insertAt === -1 ? nextOrder.length : insertAt, 0, activeDrag.sessionId) - setRecentSessionOrder(activeDrag.workspaceId as string, nextOrder.map(id => id as string)) + setRecentSessionOrder(activeDrag.workspaceId, nextOrder.map(id => id as string)) return } insertSessionBefore(activeDrag.workspaceId, activeDrag.sessionId, anchor).catch((reason: unknown) => { @@ -350,112 +354,112 @@ function SessionTree({ // Group section: header row + expanded top-level session rows. The // inter-group breathing room is the section's own margin // (WorkspaceBrowser.module.css). -
{ - e.preventDefault() - e.dataTransfer.dropEffect = 'move' - hoverWorkspace(workspaceGroupHalf(e)) - }} - onDrop={workspaceDrag === null || dropWorkspace === undefined - ? undefined - : (e) => { - e.preventDefault() - dropWorkspace(workspaceGroupHalf(e)) - }} - > - { - if (group.expanded) { - setExpandedSessionGroups(keys => keys.filter(key => key !== group.key)) - } - setWorkspaceExpanded(group.key, !group.expanded) - }} - onCreate={() => { - if (group.workspaceId !== undefined) startSession(group.workspaceId) - }} - drag={workspaceDragProps} - actions={group.workspaceId === undefined +
{ - /* v8 ignore next -- narrowing guard: the actions object exists only for real-workspace groups. */ - if (group.workspaceId !== undefined) onRenameRequest(group.workspaceId, group.label) - }, - delete: () => { - /* v8 ignore next -- narrowing guard: the actions object exists only for real-workspace groups. */ - if (group.workspaceId !== undefined) onDeleteRequest(group.workspaceId, group.label) - }, + : (e) => { + e.preventDefault() + e.dataTransfer.dropEffect = 'move' + hoverWorkspace(workspaceGroupHalf(e)) }} - /> - {(expandedSessionGroups.includes(group.key) - ? group.sessions - : group.sessions.slice(0, COLLAPSED_SESSION_LIMIT) - ).map((node) => { + onDrop={workspaceDrag === null || dropWorkspace === undefined + ? undefined + : (e) => { + e.preventDefault() + dropWorkspace(workspaceGroupHalf(e)) + }} + > + { + if (group.expanded) { + setExpandedSessionGroups(keys => keys.filter(key => key !== group.key)) + } + setWorkspaceExpanded(group.key, !group.expanded) + }} + onCreate={() => { + if (group.workspaceId !== undefined) startSession(group.workspaceId) + }} + drag={workspaceDragProps} + actions={group.workspaceId === undefined + ? undefined + : { + rename: () => { + /* v8 ignore next -- narrowing guard: the actions object exists only for real-workspace groups. */ + if (group.workspaceId !== undefined) onRenameRequest(group.workspaceId, group.label) + }, + delete: () => { + /* v8 ignore next -- narrowing guard: the actions object exists only for real-workspace groups. */ + if (group.workspaceId !== undefined) onDeleteRequest(group.workspaceId, group.label) + }, + }} + /> + {(expandedSessionGroups.includes(group.key) + ? group.sessions + : group.sessions.slice(0, COLLAPSED_SESSION_LIMIT) + ).map((node) => { // Draggable: real-workspace session rows. The drag // never leaves its group — rows of other groups show no markers // and reject drops (visual movement confined to this section). - const draggable = group.workspaceId !== undefined - const sameGroupDrag = drag !== null && drag.workspaceId === group.workspaceId - const dragProps = !draggable || group.workspaceId === undefined ? undefined : { - start: () => { - sessionDropCommitted.current = false - setDrag({ workspaceId: group.workspaceId as WorkspaceId, sessionId: node.id, over: null }) - }, - active: sameGroupDrag, - marker: sameGroupDrag && drag.over?.id === node.id ? drag.over.half : null, - hover: (half: 'before' | 'after') => { + const draggable = group.workspaceId !== undefined + const sameGroupDrag = drag !== null && drag.workspaceId === group.workspaceId + const dragProps = !draggable || group.workspaceId === undefined ? undefined : { + start: () => { + sessionDropCommitted.current = false + setDrag({ workspaceId: group.workspaceId as WorkspaceId, sessionId: node.id, over: null }) + }, + active: sameGroupDrag, + marker: sameGroupDrag && drag.over?.id === node.id ? drag.over.half : null, + hover: (half: 'before' | 'after') => { /* v8 ignore next -- narrowing guard: Rows gates hover on `active`, which is false while the drag state is null. */ - setDrag(d => (d === null ? d : { ...d, over: { id: node.id, half } })) - }, - drop: (half: 'before' | 'after') => { + setDrag(d => (d === null ? d : { ...d, over: { id: node.id, half } })) + }, + drop: (half: 'before' | 'after') => { /* v8 ignore next -- narrowing guard: Rows gates drop on `active`, which is false while the drag state is null. */ - if (drag === null) return - commitSessionDrag(drag, { id: node.id, half }) - }, - end: () => { - if (drag?.over !== null && drag?.over !== undefined) commitSessionDrag(drag, drag.over) - else setDrag(null) - sessionDropCommitted.current = false - }, - } - return ( - - ) - })} - {group.sessions.length > COLLAPSED_SESSION_LIMIT && ( - - )} -
+ if (drag === null) return + commitSessionDrag(drag, { id: node.id, half }) + }, + end: () => { + if (drag?.over !== null && drag?.over !== undefined) commitSessionDrag(drag, drag.over) + else setDrag(null) + sessionDropCommitted.current = false + }, + } + return ( + + ) + })} + {group.sessions.length > COLLAPSED_SESSION_LIMIT && ( + + )} +
) })}
@@ -465,13 +469,13 @@ function SessionTree({ } /** The flat "In one list" body: every session a top-level row, newest-first. */ -function FlatList({ useSessions, open, forkSession, onSessionRename, onSessionArchive, archivedSessionIds, orderBy, t }: Pick< - SessionTreeProps, 'useSessions' | 'open' | 'forkSession' | 'onSessionRename' | 'onSessionArchive' | 'archivedSessionIds' | 'orderBy' | 't' +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, archivedSessionIds, orderBy), - [list, archivedSessionIds, orderBy], + () => deriveFlat(list, archivedSessionIds), + [list, archivedSessionIds], ) const now = Date.now() return ( @@ -604,16 +608,13 @@ export function WorkspaceBrowser({ // flow reads): a composition without a picking affordance can add nothing. const directoryFlowAvailable = useDirectoryFlow(occupied => occupied) const groupBy = useStore(s => s.groupBy) - // A live HMR handoff can retain the pre-ordering store instance until the - // slot is remounted; manual is the established Workspace order. - const orderBy = useStore(s => s.orderBy ?? 'manual') + const orderBy = useStore(s => s.orderBy) // A flat list has no single Workspace account to drag. Keep the stored // grouped preference intact while presenting the flat list by recency. const effectiveOrderBy = groupBy === 'flat' && orderBy === 'manual' ? 'updated' : orderBy - // HMR can retain the preceding view-store instance until the slot remounts. - const workspaceExpansion = useStore(s => s.workspaceExpansion ?? EMPTY_WORKSPACE_EXPANSION) - const recentSessionOrder = useStore(s => s.recentSessionOrder ?? EMPTY_RECENT_SESSION_ORDER) - const recentSessionUpdatedAt = useStore(s => s.recentSessionUpdatedAt ?? EMPTY_RECENT_SESSION_UPDATED_AT) + const workspaceExpansion = useStore(s => s.workspaceExpansion) + const recentSessionOrder = useStore(s => s.recentSessionOrder) + const recentSessionUpdatedAt = useStore(s => s.recentSessionUpdatedAt) // The query outlives the tree and the input (both wide-only) so collapsing // does not silently drop an in-progress filter. const [query, setQuery] = useState('') @@ -958,7 +959,7 @@ export function WorkspaceBrowser({ ) : ( diff --git a/packages/client/ui-workspace/src/client/tree.ts b/packages/client/ui-workspace/src/client/tree.ts index 553742ef90..292b574b2f 100644 --- a/packages/client/ui-workspace/src/client/tree.ts +++ b/packages/client/ui-workspace/src/client/tree.ts @@ -208,8 +208,8 @@ function sessionNode( /** * Derive the workspace browser groups with every session as a top-level row. * - * Every group shows; sessions populate under expanded groups, preserving - * Host account order. Blank sessions are excluded except for the selected + * Every group shows; sessions populate under expanded groups in the selected + * local order. Blank sessions are excluded except for the selected * provisional New Session row; archived sessions are excluded everywhere. * Content search lives outside this derivation * (see {@link deriveSearchResults}). @@ -217,6 +217,7 @@ function sessionNode( * @param workspaces - real workspaces in stable Host order. * @param archivedSessionIds - registry-global archive set. * @param view - local expansion arrays. + * @param orderBy - local session ordering mode. * @returns group sections in render order. */ export function deriveGroups( @@ -263,7 +264,6 @@ export function deriveGroups( export function deriveFlat( list: SessionListState, archivedSessionIds: readonly SessionId[], - orderBy: SessionOrderBy = 'updated', ): SessionNode[] { const archived = new Set(archivedSessionIds) const descendants = indexSubagentDescendants(list.byId) @@ -273,7 +273,7 @@ export function deriveFlat( if (s === undefined || !sessionVisible(s, list.current, archived)) continue rows.push(s) } - sortSessions(rows, orderBy === 'manual' ? 'updated' : orderBy) + sortSessions(rows) return rows.map(session => sessionNode(session, descendants)) } From 6e303ac0b72036c385f66bab835d828f47d27201 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 11 Aug 2026 15:24:40 +0800 Subject: [PATCH 17/81] test(web): cover workspace sidebar behavior --- apps/web/tests/sidebar-scrollbar.e2e.ts | 12 +- packages/client/connection/tests/fake-api.ts | 5 +- packages/client/runtime/tests/fake-api.ts | 5 + .../runtime/tests/subagent-lineage.spec.ts | 2 +- .../runtime/tests/workspaces-service.spec.ts | 104 ++++++++++++- packages/client/test-runtime/src/sessions.ts | 1 + .../ui-conversation/tests/skeleton.spec.tsx | 4 +- .../tests/conversation-ui.spec.tsx | 2 + .../ui-tool/tests/chat-code-subcalls.spec.tsx | 2 +- .../ui-tool/tests/coverage-tails.spec.tsx | 2 +- .../client/ui-tool/tests/diff-card.spec.tsx | 4 +- .../client/ui-tool/tests/read-card.spec.tsx | 4 +- .../ui-tool/tests/terminal-card.spec.tsx | 4 +- .../ui-workspace/tests/browser-styles.spec.ts | 24 ++- .../client/ui-workspace/tests/rows.spec.tsx | 27 ++-- .../client/ui-workspace/tests/tree.spec.ts | 16 +- .../tests/workspace-browser.spec.tsx | 143 +++++++++++++++++- .../tests/api-proxy-workspace.spec.ts | 42 +++++ .../apiproxy/tests/client-handler.spec.ts | 1 + .../host/apiproxy/tests/fetch-carrier.spec.ts | 3 + .../host/apiproxy/tests/rpc-schemas.spec.ts | 12 ++ .../workspace/tests/workspace.spec.ts | 49 +++++- 22 files changed, 420 insertions(+), 48 deletions(-) diff --git a/apps/web/tests/sidebar-scrollbar.e2e.ts b/apps/web/tests/sidebar-scrollbar.e2e.ts index d5afd925a3..16e98a356e 100644 --- a/apps/web/tests/sidebar-scrollbar.e2e.ts +++ b/apps/web/tests/sidebar-scrollbar.e2e.ts @@ -349,9 +349,9 @@ async function pointAt(page: Page, where: 'list' | 'away'): Promise { /** * Reveal the seeded rows: every seeded session is unattached, so they all sit - * in the collapsed Ungrouped bucket. Converges on expanded rather than - * clicking once — startup auto-selection can expand the bucket first, and a - * second click would collapse it again. Hand-rolled polling because + * in the collapsed Ungrouped bucket. Open the bucket, then use its transient + * Show-more control because an open group intentionally renders only five + * rows by default. Hand-rolled polling because * `expect.poll` is test-scoped and this runs in `beforeAll`. * @param page - the page under test. */ @@ -364,6 +364,12 @@ async function expandSeededSessions(page: Page): Promise { if (await bucket.getAttribute('aria-expanded') !== 'true') { await page.getByText('Ungrouped', { exact: true }).click() } + const showMore = page.getByRole('button', { name: /Show \d+ more sessions/ }) + if (await bucket.getAttribute('aria-expanded') === 'true' + && await rows.count() <= SEED_COUNT / 2 + && await showMore.count() > 0) { + await showMore.click() + } if (await bucket.getAttribute('aria-expanded') === 'true' && await rows.count() > SEED_COUNT / 2) return if (Date.now() > deadline) { throw new Error(`Ungrouped bucket never revealed more than ${SEED_COUNT / 2} rows`) diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index f1e62c618a..be821168eb 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -4,7 +4,7 @@ import type { CommandId } from '@deepseek-ai/dsh-commands/brand' import type { CommandDescriptor, HostFrame, IApiClient, ModelSelection, MuxFrame, - RpcRequest, RpcResponse, SessionId, SessionModels, SessionSearchItem, SkillEntry, + RpcRequest, RpcResponse, SessionId, SessionModels, SessionSearchItem, SkillEntry, WorkspaceId, } from '../src/client/api.ts' import { RpcId } from '../src/client/api.ts' @@ -152,6 +152,9 @@ export class FakeApiClient implements IApiClient { workspace: { workspaceId: 'fk-ws' as never, path: '/f/ws', title: 'ws', sessionIds: [], createdAt: '0', updatedAt: '0' }, }))), delete: (payload: unknown) => this.record('workspace.delete', payload, Promise.resolve(ok({ deleted: true as const }))), + insertBefore: (payload: unknown) => this.record('workspace.insertBefore', payload, Promise.resolve(ok({ + workspaceIds: [(payload as { workspaceId: WorkspaceId }).workspaceId], + }))), 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' }, }))), diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index da3c1d3025..839b5c957c 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -174,6 +174,9 @@ export class FakeApiClient implements IApiClient { onWorkspaceDelete: (payload: unknown) => Promise> = () => Promise.resolve(ok({ deleted: true })) + onWorkspaceInsertBefore: (payload: unknown) => Promise> = + () => Promise.resolve(ok({ workspaceIds: [] })) + onWorkspaceInsertSessionBefore: (payload: unknown) => Promise> = () => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws') })) @@ -189,6 +192,8 @@ export class FakeApiClient implements IApiClient { 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)), + insertBefore: (payload: unknown) => + this.record('workspace.insertBefore', payload, this.onWorkspaceInsertBefore(payload)), insertSessionBefore: (payload: unknown) => this.record('workspace.insertSessionBefore', payload, this.onWorkspaceInsertSessionBefore(payload)), archiveSession: (payload: unknown) => diff --git a/packages/client/runtime/tests/subagent-lineage.spec.ts b/packages/client/runtime/tests/subagent-lineage.spec.ts index 05881576bf..9fe99a073f 100644 --- a/packages/client/runtime/tests/subagent-lineage.spec.ts +++ b/packages/client/runtime/tests/subagent-lineage.spec.ts @@ -11,7 +11,7 @@ function summary( running = false, ): SessionSummary { return { - id: sid(id), displayTitle: id, running, blank: false, updatedAt: 0, + id: sid(id), displayTitle: id, running, blank: false, createdAt: 0, updatedAt: 0, ...(parentId === undefined ? {} : { parentId }), ...(origin === undefined ? {} : { origin }), } diff --git a/packages/client/runtime/tests/workspaces-service.spec.ts b/packages/client/runtime/tests/workspaces-service.spec.ts index aa0f404da6..2c4ed551d8 100644 --- a/packages/client/runtime/tests/workspaces-service.spec.ts +++ b/packages/client/runtime/tests/workspaces-service.spec.ts @@ -1,5 +1,5 @@ import { Context } from '@deepseek-ai/cordis' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client' import { SessionsService } from '../src/client/sessions/service.ts' import { WorkspaceManager } from '../src/client/workspaces/manager.ts' @@ -17,7 +17,7 @@ function workspace(id: string, sessionIds: SessionId[] = [], createdAt = '2026-0 } describe('WorkspaceManager', () => { - it('replays changed frames over hydration and keeps established order on refresh', async () => { + it('replays changed frames over hydration and adopts the durable order on refresh', async () => { const api = new FakeApiClient() const gate = deferred>>() api.onWorkspaceList = () => gate.promise @@ -36,7 +36,7 @@ describe('WorkspaceManager', () => { items: [workspace('old'), workspace('new')] as never[], })) await manager.refresh() - expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['new', 'old']) + expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['old', 'new']) }) it('single-flights refreshes and exposes result and transport failures independently of readiness', async () => { @@ -77,6 +77,38 @@ describe('WorkspaceManager', () => { }) }) + it('reorders optimistically while newer Host frames outrank unary echoes and failures roll back', async () => { + const api = new FakeApiClient() + api.onWorkspaceList = () => Promise.resolve(ok({ + items: [workspace('one'), workspace('two'), workspace('three')] as never[], + })) + const manager = new WorkspaceManager(api) + await manager.refresh() + + const gate = deferred>>() + api.onWorkspaceInsertBefore = () => gate.promise + const pending = manager.insertBefore(wid('three'), wid('one')) + expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['three', 'one', 'two']) + manager.handleHostEnvelope({ + rpcId: 'newer-order' as never, + payload: { + type: 'host/workspace-order-changed', + workspaceIds: [wid('one'), wid('three'), wid('two')], + }, + }) + gate.resolve(ok({ workspaceIds: [wid('three'), wid('one'), wid('two')] })) + await pending + expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'three', 'two']) + + api.onWorkspaceInsertBefore = () => Promise.resolve(err({ + code: 'workspace-not-found', message: 'gone', details: { workspaceId: 'three' }, + })) + const rejected = manager.insertBefore(wid('three')) + expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'two', 'three']) + await expect(rejected).resolves.toMatchObject({ ok: false }) + expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'three', 'two']) + }) + it('replays removal over an in-flight baseline and ignores duplicate or late updates', async () => { const api = new FakeApiClient() const gate = deferred>>() @@ -309,6 +341,72 @@ describe('WorkspacesService', () => { await expect(workspaces.delete(wid('ghost'))).rejects.toThrow(/workspace-not-found: gone/) }) + it('moves a Workspace through the durable order RPC and surfaces Host rejection', async () => { + const ctx = new Context() + const api = new FakeApiClient() + const workspaces = new WorkspacesService(ctx, api, new SessionsService(ctx, api)) + api.onWorkspaceList = () => Promise.resolve(ok({ + items: [workspace('one'), workspace('two')] as never[], + })) + await workspaces.refresh() + api.onWorkspaceInsertBefore = () => Promise.resolve(ok({ + workspaceIds: [wid('two'), wid('one')], + })) + await expect(workspaces.insertBefore(wid('two'), wid('one'))).resolves.toBeUndefined() + expect(api.callsOf('workspace.insertBefore')).toEqual([{ + workspaceId: 'two', beforeWorkspaceId: 'one', + }]) + expect(workspaces.list.getSnapshot().items.map(item => item.workspaceId)).toEqual(['two', 'one']) + + api.onWorkspaceInsertBefore = () => Promise.resolve(err({ + code: 'workspace-not-found', message: 'gone', details: { workspaceId: 'ghost' }, + })) + await expect(workspaces.insertBefore(wid('ghost'))).rejects.toThrow(/workspace-not-found: gone/) + }) + + it('targets New Session at explicit, current-session, then recent Workspaces and clears with none', async () => { + const ctx = new Context() + const api = new FakeApiClient() + const sessions = new SessionsService(ctx, api) + const workspaces = new WorkspacesService(ctx, api, sessions) + api.onWorkspaceList = () => Promise.resolve(ok({ + items: [ + workspace('current-home', [sid('current')]), + workspace('recent-home', [sid('recent')]), + ] as never[], + })) + api.onList = () => Promise.resolve(ok({ items: [ + { sessionId: sid('current'), updatedAt: 1, running: false, blank: false }, + { sessionId: sid('recent'), updatedAt: 2, running: false, blank: false }, + ] as never[] })) + await Promise.all([workspaces.refresh(), sessions.refresh()]) + await Promise.resolve() + sessions.open(sid('current')) + const unresolved = new Promise(() => {}) + const connect = vi.spyOn(workspaces, 'connectWorkspace').mockReturnValue(unresolved) + + workspaces.startSession(wid('recent-home')) + await Promise.resolve() + expect(connect).toHaveBeenLastCalledWith(wid('recent-home')) + + workspaces.startSession() + await Promise.resolve() + expect(connect).toHaveBeenLastCalledWith(wid('current-home')) + + sessions.clear() + workspaces.startSession() + await Promise.resolve() + expect(connect).toHaveBeenLastCalledWith(wid('recent-home')) + + const emptyCtx = new Context() + const emptyApi = new FakeApiClient() + const emptySessions = new SessionsService(emptyCtx, emptyApi) + const emptyWorkspaces = new WorkspacesService(emptyCtx, emptyApi, emptySessions) + const clear = vi.spyOn(emptySessions, 'clear') + emptyWorkspaces.startSession() + expect(clear).toHaveBeenCalledOnce() + }) + 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() diff --git a/packages/client/test-runtime/src/sessions.ts b/packages/client/test-runtime/src/sessions.ts index fc41c83975..ece67e1acd 100644 --- a/packages/client/test-runtime/src/sessions.ts +++ b/packages/client/test-runtime/src/sessions.ts @@ -233,6 +233,7 @@ export class TestSessions implements ISessions { displayTitle: fixture.id, running: false, blank: false, + createdAt: this.records.size + 1, updatedAt: this.records.size + 1, ...fixture.summary, } diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index be5cd3be28..9858fdaeb9 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -99,10 +99,10 @@ function mount( } = {}, ) { const root = sid('root') - const rootRow = { id: root, displayTitle: 'Root', running: false, blank: false, updatedAt: 1 } + const rootRow = { id: root, displayTitle: 'Root', running: false, blank: false, createdAt: 1, updatedAt: 1 } const childRow = { id: SID, displayTitle: 'Child', parentId: root, cwd: '/projects/one', - running: false, blank: options.summaryBlank ?? false, updatedAt: 2, + running: false, blank: options.summaryBlank ?? false, createdAt: 2, updatedAt: 2, ...(options.summaryOrigin === undefined ? {} : { origin: options.summaryOrigin }), } const listed = options.omitSummaryRow !== true diff --git a/packages/client/ui-subagent/tests/conversation-ui.spec.tsx b/packages/client/ui-subagent/tests/conversation-ui.spec.tsx index ebc140405e..50ec3a6df1 100644 --- a/packages/client/ui-subagent/tests/conversation-ui.spec.tsx +++ b/packages/client/ui-subagent/tests/conversation-ui.spec.tsx @@ -56,6 +56,7 @@ function props( displayTitle: 'worker', running: true, blank: false, + createdAt: Date.now(), updatedAt: Date.now(), }, }, @@ -83,6 +84,7 @@ function summary(id: SessionId, updatedAt: number): SessionSummary { displayTitle: id, running: false, blank: false, + createdAt: updatedAt, updatedAt, } } diff --git a/packages/client/ui-tool/tests/chat-code-subcalls.spec.tsx b/packages/client/ui-tool/tests/chat-code-subcalls.spec.tsx index 5939655118..4376694492 100644 --- a/packages/client/ui-tool/tests/chat-code-subcalls.spec.tsx +++ b/packages/client/ui-tool/tests/chat-code-subcalls.spec.tsx @@ -110,7 +110,7 @@ async function bench(snapshot: ConversationSnapshot) { const session = createSnapshotStore(snapshot) const list = createSnapshotStore({ ids: [SID], - byId: { [SID]: { id: SID, title: 'S', displayTitle: 'S', running: false, blank: false, updatedAt: 1 } }, + byId: { [SID]: { id: SID, title: 'S', displayTitle: 'S', running: false, blank: false, createdAt: 1, updatedAt: 1 } }, current: SID, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined, }) diff --git a/packages/client/ui-tool/tests/coverage-tails.spec.tsx b/packages/client/ui-tool/tests/coverage-tails.spec.tsx index 8010fb8763..06935e0e26 100644 --- a/packages/client/ui-tool/tests/coverage-tails.spec.tsx +++ b/packages/client/ui-tool/tests/coverage-tails.spec.tsx @@ -26,7 +26,7 @@ function listStore() { return createSnapshotStore({ ids: [SID], byId: { - [SID]: { id: SID, title: 'r', displayTitle: 'r', running: false, blank: false, updatedAt: 0 }, + [SID]: { id: SID, title: 'r', displayTitle: 'r', running: false, blank: false, createdAt: 0, updatedAt: 0 }, }, current: undefined, phase: 'ready', diff --git a/packages/client/ui-tool/tests/diff-card.spec.tsx b/packages/client/ui-tool/tests/diff-card.spec.tsx index 1726c81136..92aab13d8c 100644 --- a/packages/client/ui-tool/tests/diff-card.spec.tsx +++ b/packages/client/ui-tool/tests/diff-card.spec.tsx @@ -156,7 +156,7 @@ describe('chat row diff body', () => { describe('FileMutationRow diff card', () => { const list = () => createSnapshotStore({ ids: [SID], - byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd: '/w/app' } }, + byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, createdAt: 0, updatedAt: 0, cwd: '/w/app' } }, current: SID, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, @@ -314,7 +314,7 @@ describe('DetailsPanel diff Output section', () => { ? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined } : { ids: [SID], - byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd } }, + byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, createdAt: 0, updatedAt: 0, cwd } }, current: SID, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, diff --git a/packages/client/ui-tool/tests/read-card.spec.tsx b/packages/client/ui-tool/tests/read-card.spec.tsx index ae719173d5..bf43507bb3 100644 --- a/packages/client/ui-tool/tests/read-card.spec.tsx +++ b/packages/client/ui-tool/tests/read-card.spec.tsx @@ -170,7 +170,7 @@ describe('GenericToolCard read body', () => { describe('ReadRow keyed toolview', () => { const list = () => createSnapshotStore({ ids: [SID], - byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd: '/w/app' } }, + byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, createdAt: 0, updatedAt: 0, cwd: '/w/app' } }, current: SID, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, @@ -260,7 +260,7 @@ describe('DetailsPanel Output section (read)', () => { ? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined } : { ids: [SID], - byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd } }, + byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, createdAt: 0, updatedAt: 0, cwd } }, current: SID, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, diff --git a/packages/client/ui-tool/tests/terminal-card.spec.tsx b/packages/client/ui-tool/tests/terminal-card.spec.tsx index dd39b8bc88..334da7f607 100644 --- a/packages/client/ui-tool/tests/terminal-card.spec.tsx +++ b/packages/client/ui-tool/tests/terminal-card.spec.tsx @@ -345,7 +345,7 @@ describe('chat row terminal body', () => { describe('BashRow terminal card', () => { const list = () => createSnapshotStore({ ids: [SID], - byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0 } }, + byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, createdAt: 0, updatedAt: 0 } }, current: undefined, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, @@ -451,7 +451,7 @@ describe('DetailsPanel Output section', () => { ? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined } : { ids: [SID], - byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd } }, + byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, createdAt: 0, updatedAt: 0, cwd } }, current: SID, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, diff --git a/packages/client/ui-workspace/tests/browser-styles.spec.ts b/packages/client/ui-workspace/tests/browser-styles.spec.ts index 4165971bff..86abd521bc 100644 --- a/packages/client/ui-workspace/tests/browser-styles.spec.ts +++ b/packages/client/ui-workspace/tests/browser-styles.spec.ts @@ -8,6 +8,7 @@ import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' const css = readFileSync(fileURLToPath(new URL('../src/client/WorkspaceBrowser.module.css', import.meta.url)), 'utf8') +const rowsCss = readFileSync(fileURLToPath(new URL('../src/client/rows/Rows.module.css', import.meta.url)), 'utf8') /** * Declarations of one selector rule, keyed by property with whitespace collapsed. @@ -15,21 +16,23 @@ const css = readFileSync(fileURLToPath(new URL('../src/client/WorkspaceBrowser.m * @param selector - one exact selector, including a leading dot for local classes. * @returns the rule's declarations, or undefined when no such rule exists. */ -function declarations(selector: string): Map | undefined { - const withoutComments = css.replace(/\/\*[\s\S]*?\*\//g, ' ') +function declarationsFrom(source: string, selector: string): Map | undefined { + const withoutComments = source.replace(/\/\*[\s\S]*?\*\//g, ' ') + const found = new Map() for (const [, selectorList = '', body = ''] of withoutComments.matchAll(/([^{}]+)\{([^{}]*)\}/g)) { if (!selectorList.split(',').map(value => value.trim()).includes(selector)) continue - const found = new Map() for (const part of body.split(';')) { const colon = part.indexOf(':') if (colon === -1) continue found.set(part.slice(0, colon).trim(), part.slice(colon + 1).trim().replace(/\s+/g, ' ')) } - return found } - return undefined + return found.size === 0 ? undefined : found } +const declarations = (selector: string): Map | undefined => declarationsFrom(css, selector) +const rowDeclarations = (selector: string): Map | undefined => declarationsFrom(rowsCss, selector) + describe('WorkspaceBrowser.module.css list', () => { const root = declarations('.root') const listArea = declarations('.listArea') @@ -68,4 +71,15 @@ describe('WorkspaceBrowser.module.css list', () => { expect(declarations('.groupSection > * + *')?.get('margin-top')).toBe('2px') expect(declarations('.groupSection + .groupSection')?.get('margin-top')).toBe('4px') }) + + it('keeps the compact fade, overflow control, search field, and row heights', () => { + expect(declarations('.fade')?.get('height')).toBe('24px') + expect(declarations('.sessionOverflowButton')?.get('height')).toBe('28px') + expect(declarations('.searchExpanded')?.get('height')).toBe('34px') + expect(rowDeclarations('.projectRow')?.get('height')).toBe('34px') + expect(rowDeclarations('.sessionRow')?.get('height')).toBe('32px') + expect(rowDeclarations('.searchResultRow')?.get('min-height')).toBe('48px') + expect(rowDeclarations('.sessionRow.selected')?.get('background')) + .toBe('var(--dsw-alias-interactive-bg-hover)') + }) }) diff --git a/packages/client/ui-workspace/tests/rows.spec.tsx b/packages/client/ui-workspace/tests/rows.spec.tsx index 7e0971cf72..edb60c23bb 100644 --- a/packages/client/ui-workspace/tests/rows.spec.tsx +++ b/packages/client/ui-workspace/tests/rows.spec.tsx @@ -46,7 +46,7 @@ function installClipboard(writeText: (text: string) => Promise): () => voi } } -const dataTransfer = { effectAllowed: '', dropEffect: '' } +const dataTransfer = { effectAllowed: '', dropEffect: '', setData: vi.fn() } /** jsdom lacks DragEvent — the fireEvent fallback drops clientY, so pin it on the built event. */ function fireDrag(row: HTMLElement, kind: 'dragOver' | 'drop', clientY: number): void { @@ -105,7 +105,6 @@ describe('workspace browser rows', () => { } render() - expect(screen.getByText('1 个会话')).toBeTruthy() expect(screen.getByRole('treeitem').getAttribute('aria-expanded')).toBe('true') fireEvent.click(screen.getByRole('button', { name: '在“Project”中新建会话' })) expect(onCreate).toHaveBeenCalledOnce() @@ -117,7 +116,7 @@ describe('workspace browser rows', () => { it('renders and opens a selected running Session row', () => { const node: SessionNode = { id: sid('session'), title: 'Session', blank: false, running: true, - runningSubagentCount: 0, completed: false, updatedAt: 0, + runningSubagentCount: 0, completed: false, createdAt: 0, updatedAt: 0, } const onOpen = vi.fn() render( @@ -138,7 +137,7 @@ describe('workspace browser rows', () => { { try { const node: SessionNode = { id: sid('owner'), title: 'Delegating', blank: false, running: false, - runningSubagentCount: 2, completed: false, updatedAt: 0, + runningSubagentCount: 2, completed: false, createdAt: 0, updatedAt: 0, } render() @@ -192,7 +191,7 @@ describe('workspace browser rows', () => { try { const node: SessionNode = { id: sid('owner'), title: 'Delegating', blank: false, running: true, - runningSubagentCount: 1, completed: false, updatedAt: 0, + runningSubagentCount: 1, completed: false, createdAt: 0, updatedAt: 0, } render() @@ -213,7 +212,7 @@ describe('workspace browser rows', () => { it('keeps child activity as a secondary status while user attention is primary', () => { const node: SessionNode = { id: sid('owner'), title: 'Needs input', blank: false, pendingInteraction: 'question', - running: false, runningSubagentCount: 1, completed: false, updatedAt: 0, + running: false, runningSubagentCount: 1, completed: false, createdAt: 0, updatedAt: 0, } render() @@ -304,7 +303,7 @@ describe('workspace browser rows', () => { try { const node: SessionNode = { id: sid('s-blank'), title: 'ignored', blank: true, running: false, - runningSubagentCount: 0, completed: false, updatedAt: 0, + runningSubagentCount: 0, completed: false, createdAt: 0, updatedAt: 0, } render() @@ -331,7 +330,7 @@ describe('workspace browser rows', () => { const onArchive = vi.fn() const node: SessionNode = { id: sid('s1'), title: 'One', blank: false, running: false, - runningSubagentCount: 0, completed: false, updatedAt: 0, + runningSubagentCount: 0, completed: false, createdAt: 0, updatedAt: 0, } render() @@ -365,7 +364,7 @@ describe('workspace browser rows', () => { try { const node: SessionNode = { id: sid('s1'), title: 'Hovered', blank: false, running: true, - runningSubagentCount: 0, completed: false, updatedAt: 0, + runningSubagentCount: 0, completed: false, createdAt: 0, updatedAt: 0, } render() @@ -396,7 +395,7 @@ describe('workspace browser rows', () => { try { const node: SessionNode = { id: sid(pendingInteraction), title: 'Needs input', blank: false, - pendingInteraction, running: true, runningSubagentCount: 0, completed: false, updatedAt: 0, + pendingInteraction, running: true, runningSubagentCount: 0, completed: false, createdAt: 0, updatedAt: 0, } const view = render() @@ -423,7 +422,7 @@ describe('workspace browser rows', () => { try { const node: SessionNode = { id: sid('s1'), title: 'Quiet', blank: false, running: false, - runningSubagentCount: 0, completed: false, updatedAt: 0, + runningSubagentCount: 0, completed: false, createdAt: 0, updatedAt: 0, } render() @@ -441,7 +440,7 @@ describe('workspace browser rows', () => { try { const node: SessionNode = { id: sid('s1'), title: 'Done', blank: false, running: false, - runningSubagentCount: 0, completed: true, updatedAt: 0, + runningSubagentCount: 0, completed: true, createdAt: 0, updatedAt: 0, } render() @@ -457,7 +456,7 @@ describe('workspace browser rows', () => { it('draggable row wires start/end and gates hover/drop on an active same-group drag', () => { const node: SessionNode = { id: sid('s1'), title: 'Drag me', blank: false, running: false, - runningSubagentCount: 0, completed: false, updatedAt: 0, + runningSubagentCount: 0, completed: false, createdAt: 0, updatedAt: 0, } const inactive = dragProps() const { rerender } = render( diff --git a/packages/client/ui-workspace/tests/tree.spec.ts b/packages/client/ui-workspace/tests/tree.spec.ts index 5a6d145e36..4ee5c19561 100644 --- a/packages/client/ui-workspace/tests/tree.spec.ts +++ b/packages/client/ui-workspace/tests/tree.spec.ts @@ -11,7 +11,8 @@ import { createWorkspaceViewStore } from '../src/client/stores.ts' const sid = (id: string) => id as SessionId const wid = (id: string) => id as WorkspaceId const summary = (id: string, updatedAt: number, cwd?: string): SessionSummary => ({ - id: sid(id), displayTitle: id, running: false, blank: false, updatedAt, ...(cwd === undefined ? {} : { cwd }), + id: sid(id), displayTitle: id, running: false, blank: false, + createdAt: updatedAt, updatedAt, ...(cwd === undefined ? {} : { cwd }), }) const list = (...items: SessionSummary[]): SessionListState => ({ ids: items.map(item => item.id), @@ -377,11 +378,22 @@ describe('deriveSearchResults', () => { }) describe('createWorkspaceViewStore', () => { - it('defaults to workspace grouping; setGroupBy is the sole mutation', () => { + it('stores grouping, ordering, Workspace expansion, and recent-session view order', () => { const store = createWorkspaceViewStore().create() expect(store.getSnapshot().groupBy).toBe('workspace') + expect(store.getSnapshot().orderBy).toBe('manual') store.actions.setGroupBy('flat') + store.actions.setOrderBy('updated') + store.actions.setWorkspaceExpanded('alpha', true) + store.actions.syncRecentSessions('alpha', ['two', 'one'], { one: 1, two: 2 }) + store.actions.setRecentSessionOrder('alpha', ['one', 'two']) expect(store.getSnapshot().groupBy).toBe('flat') + expect(store.getSnapshot()).toMatchObject({ + orderBy: 'updated', + workspaceExpansion: { alpha: true }, + recentSessionOrder: { alpha: ['one', 'two'] }, + recentSessionUpdatedAt: { alpha: { one: 1, two: 2 } }, + }) }) }) diff --git a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx index 918a3f6c0e..ca48733dda 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx @@ -22,7 +22,7 @@ const t: WorkspaceBrowserProps['t'] = makeTranslate(zh, commonZh) const sid = (id: string) => id as SessionId const wid = (id: string) => id as WorkspaceId const summary = (id: string, updatedAt: number, overrides: Partial = {}): SessionSummary => ({ - id: sid(id), displayTitle: id, running: false, blank: false, updatedAt, ...overrides, + id: sid(id), displayTitle: id, running: false, blank: false, createdAt: updatedAt, updatedAt, ...overrides, }) const sessionState = (items: readonly SessionSummary[], overrides: Partial = {}): SessionListState => ({ ids: items.map(item => item.id), @@ -53,6 +53,10 @@ function fireDrag(row: HTMLElement, kind: 'dragOver' | 'drop', clientY: number): fireEvent(row, event) } +function dragData(): Pick { + return { effectAllowed: 'uninitialized', dropEffect: 'none', setData: vi.fn() } +} + function mount(overrides: Partial = {}) { const store = createWorkspaceViewStore().create() const props: WorkspaceBrowserProps = { @@ -71,6 +75,7 @@ function mount(overrides: Partial = {}) { renameWorkspace: vi.fn(async () => {}), deleteWorkspace: vi.fn(async () => {}), archiveSession: vi.fn(async () => {}), + insertWorkspaceBefore: vi.fn(async () => {}), insertSessionBefore: vi.fn(async () => {}), createWorkspace: vi.fn(async () => workspace('created', [])), useDirectoryFlow: bindSnapshotSelector({ getSnapshot: () => true, subscribe: () => () => {} }), @@ -102,6 +107,8 @@ describe('WorkspaceBrowser', () => { fireEvent.click(screen.getByRole('button', { name: '分组方式' })) expect(screen.getByText('分组方式')).toBeTruthy() // the menu heading label + expect(screen.getByRole('menuitem', { name: '手动排序' }).querySelector('svg')).toBeTruthy() + expect(screen.queryByText('创建时间')).toBeNull() fireEvent.click(screen.getByRole('menuitem', { name: '单列表' })) // Store-driven flip: title changes, rows flatten newest-first, headers gone. expect(b.store.getSnapshot().groupBy).toBe('flat') @@ -138,6 +145,61 @@ describe('WorkspaceBrowser', () => { expect(screen.queryByText('alpha-s')).toBeNull() }) + it('shows five sessions by default and clears transient show-all when the Workspace collapses', () => { + const items = Array.from({ length: 7 }, (_, index) => summary(`session-${index + 1}`, 7 - index)) + const b = mount({ + useSessions: hook(sessionState(items)), + useWorkspaces: hook(workspaceState([workspace('alpha', items.map(item => item.id))])), + }) + fireEvent.click(screen.getByText('alpha')) + for (const item of items.slice(0, 5)) expect(screen.getByText(item.displayTitle)).toBeTruthy() + expect(screen.queryByText('session-6')).toBeNull() + expect(screen.queryByText('session-7')).toBeNull() + + fireEvent.click(screen.getByRole('button', { name: '展开其余 2 个会话' })) + expect(screen.getByText('session-6')).toBeTruthy() + expect(screen.getByText('session-7')).toBeTruthy() + expect(screen.getByRole('button', { name: '收起' })).toBeTruthy() + + fireEvent.click(screen.getByText('alpha')) + expect(b.store.getSnapshot().workspaceExpansion).toEqual({ alpha: false }) + fireEvent.click(screen.getByText('alpha')) + expect(b.store.getSnapshot().workspaceExpansion).toEqual({ alpha: true }) + expect(screen.queryByText('session-6')).toBeNull() + expect(screen.getByRole('button', { name: '展开其余 2 个会话' })).toBeTruthy() + }) + + it('keeps recent-update order editable and promotes a Session when its timestamp advances', async () => { + const initial = sessionState([summary('one', 3), summary('two', 2)]) + const b = mount({ + useSessions: hook(initial), + useWorkspaces: hook(workspaceState([workspace('alpha', ['two', 'one'])])), + }) + fireEvent.click(screen.getByText('alpha')) + fireEvent.click(screen.getByRole('button', { name: '分组方式' })) + fireEvent.click(screen.getByRole('menuitem', { name: '最近更新' })) + await waitFor(() => { + const rows = screen.getAllByRole('treeitem').slice(1) + expect(rows[0]?.textContent).toContain('one') + expect(rows[1]?.textContent).toContain('two') + }) + + const [one, two] = screen.getAllByRole('treeitem').slice(1) as [HTMLElement, HTMLElement] + two.getBoundingClientRect = () => ({ + top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}), + }) + fireEvent.dragStart(one, { dataTransfer: dragData() }) + fireDrag(two, 'drop', 180) + expect(b.store.getSnapshot().recentSessionOrder.alpha).toEqual(['two', 'one']) + + const updated = sessionState([summary('one', 4), summary('two', 2)]) + rerender(b, { useSessions: hook(updated) }) + await waitFor(() => { + expect(b.store.getSnapshot().recentSessionOrder.alpha).toEqual(['one', 'two']) + expect(screen.getAllByRole('treeitem').slice(1)[0]?.textContent).toContain('one') + }) + }) + it('archives a session from the row menu and hides archived rows in both modes', async () => { const archiveSession = vi.fn(async () => {}) const b = mount({ @@ -150,10 +212,9 @@ describe('WorkspaceBrowser', () => { 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. + // The archive-set echo hides the row in grouped and flat modes. 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: '分组方式' })) fireEvent.click(screen.getByRole('menuitem', { name: '单列表' })) expect(screen.getByText('kept-s')).toBeTruthy() @@ -253,7 +314,6 @@ describe('WorkspaceBrowser', () => { expect(screen.getByText('新会话')).toBeTruthy() expect(screen.queryByText('alpha-blank')).toBeNull() expect(screen.queryByText('beta-blank')).toBeNull() - expect(screen.getByText('1 个会话')).toBeTruthy() rerender(b, { useSessions: hook({ ...sessions, current: staleBlank.id }) }) expect(screen.getAllByText('新会话')).toHaveLength(1) @@ -279,6 +339,7 @@ describe('WorkspaceBrowser', () => { useSessions: hook(sessions), useWorkspaces: hook(workspaceState([workspace('alpha', ['needle-row', 'other-row'])])), }) + fireEvent.click(screen.getByRole('button', { name: '搜索会话' })) const input = screen.getByPlaceholderText('搜索名称、关键词…') fireEvent.change(input, { target: { value: 'needle' } }) const resultTree = screen.getByRole('tree', { name: '搜索结果' }) @@ -302,6 +363,22 @@ describe('WorkspaceBrowser', () => { } }) + it('collapses an empty search on outside click but keeps a non-empty query expanded', () => { + mount() + const search = screen.getByRole('button', { name: '搜索会话' }) + fireEvent.click(search) + expect(search.getAttribute('aria-expanded')).toBe('true') + fireEvent.click(document.body) + expect(search.getAttribute('aria-expanded')).toBe('false') + + fireEvent.click(search) + const input = screen.getByPlaceholderText('搜索名称、关键词…') + fireEvent.change(input, { target: { value: 'kept' } }) + fireEvent.click(document.body) + expect(search.getAttribute('aria-expanded')).toBe('true') + expect(input.value).toBe('kept') + }) + it('adds Host content hits with context, shows the result bound, and opens without clearing the query', async () => { vi.useFakeTimers() try { @@ -524,6 +601,34 @@ describe('WorkspaceBrowser', () => { expect(screen.getByText('alpha')).toBeTruthy() }) + it('uses the full expanded Workspace section when resolving a Workspace drop half', () => { + const insertWorkspaceBefore = vi.fn(async () => {}) + const sessions = sessionState(Array.from({ length: 5 }, (_, index) => summary(`beta-${index}`, index))) + mount({ + useSessions: hook(sessions), + useWorkspaces: hook(workspaceState([ + workspace('alpha', []), + workspace('beta', sessions.ids), + workspace('tail', []), + ])), + insertWorkspaceBefore, + }) + fireEvent.click(screen.getByText('beta')) + const source = screen.getByText('tail').closest('[role="treeitem"]') as HTMLElement + let targetSection = screen.getByText('beta').closest('[role="treeitem"]')?.parentElement as HTMLElement + while (targetSection.parentElement?.getAttribute('role') !== 'tree') { + targetSection = targetSection.parentElement as HTMLElement + } + targetSection.getBoundingClientRect = () => ({ + top: 100, bottom: 300, left: 0, right: 200, width: 200, height: 200, x: 0, y: 100, toJSON: () => ({}), + }) + fireEvent.dragStart(source, { dataTransfer: dragData() }) + // y=190 is below the header row but still in the top half of the whole + // expanded section, so the target is before beta rather than after it. + fireDrag(targetSection, 'drop', 190) + expect(insertWorkspaceBefore).toHaveBeenCalledWith(wid('tail'), wid('beta')) + }) + it('drag reorder reports the anchor to insertSessionBefore and skips no-op drops', () => { const insertSessionBefore = vi.fn(async () => {}) const sessions = sessionState([summary('one', 3), summary('two', 2), summary('three', 1)]) @@ -538,7 +643,7 @@ describe('WorkspaceBrowser', () => { three.getBoundingClientRect = () => ({ top: 200, bottom: 234, left: 0, right: 200, width: 200, height: 34, x: 0, y: 200, toJSON: () => ({}), }) - const dataTransfer = { effectAllowed: '', dropEffect: '' } + const dataTransfer = dragData() fireEvent.dragStart(one, { dataTransfer }) // Drop on the top half of "three": insert one before three. fireDrag(three, 'dragOver', 205) @@ -569,7 +674,7 @@ describe('WorkspaceBrowser', () => { }) fireEvent.click(screen.getByText('alpha')) const one = screen.getByText('one').closest('[role="treeitem"]') as HTMLElement - fireEvent.dragStart(one, { dataTransfer: { effectAllowed: '', dropEffect: '' } }) + fireEvent.dragStart(one, { dataTransfer: dragData() }) // The host dropped "one" from the workspace account while the drag is in // flight: the source index is gone but the drop still resolves its anchor. rerender(b, { useWorkspaces: hook(workspaceState([workspace('alpha', ['two'])])) }) @@ -594,7 +699,7 @@ describe('WorkspaceBrowser', () => { two.getBoundingClientRect = () => ({ top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}), }) - const dataTransfer = { effectAllowed: '', dropEffect: '' } + const dataTransfer = dragData() fireEvent.dragStart(one, { dataTransfer }) fireEvent.dragEnd(one) // The drag ended: rows no longer accept drops. @@ -608,6 +713,28 @@ describe('WorkspaceBrowser', () => { expect(insertSessionBefore).toHaveBeenCalledWith(wid('alpha'), sid('one'), undefined) }) + it('accepts a document-level drop and commits the last Session marker on drag end', () => { + const insertSessionBefore = vi.fn(async () => {}) + mount({ + useSessions: hook(sessionState([summary('one', 2), summary('two', 1)])), + useWorkspaces: hook(workspaceState([workspace('alpha', ['one', 'two'])])), + insertSessionBefore, + }) + fireEvent.click(screen.getByText('alpha')) + const [one, two] = screen.getAllByRole('treeitem').slice(1) as [HTMLElement, HTMLElement] + two.getBoundingClientRect = () => ({ + top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}), + }) + fireEvent.dragStart(one, { dataTransfer: dragData() }) + fireDrag(two, 'dragOver', 180) + const outsideDrop = createEvent.drop(document.body) + Object.defineProperty(outsideDrop, 'dataTransfer', { value: dragData() }) + fireEvent(document.body, outsideDrop) + expect(outsideDrop.defaultPrevented).toBe(true) + fireEvent.dragEnd(one) + expect(insertSessionBefore).toHaveBeenCalledWith(wid('alpha'), sid('one'), undefined) + }) + it('logs and keeps the order when the reorder call rejects', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) try { @@ -623,7 +750,7 @@ describe('WorkspaceBrowser', () => { two.getBoundingClientRect = () => ({ top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}), }) - const dataTransfer = { effectAllowed: '', dropEffect: '' } + const dataTransfer = dragData() fireEvent.dragStart(one, { dataTransfer }) fireDrag(two, 'drop', 180) await waitFor(() => { expect(warn).toHaveBeenCalledWith('session reorder rejected:', expect.any(Error)) }) diff --git a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts index a8f12641da..1b98fedac5 100644 --- a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts @@ -308,6 +308,48 @@ describe('workspace.create', () => { }) }) +describe('workspace.insertBefore', () => { + it('commits the complete order, streams one order frame, and maps unknown ids', async () => { + const { api, root } = await harness() + const first = expectOk(await api.workspace.create(request({ path: stageDir(root, 'first') }))).workspace + const second = expectOk(await api.workspace.create(request({ path: stageDir(root, 'second') }))).workspace + const third = expectOk(await api.workspace.create(request({ path: stageDir(root, 'third') }))).workspace + + const abort = new AbortController() + const stream: AsyncIterator> = + api.events.host(request({}), abort.signal)[Symbol.asyncIterator]() + const changed = nextHostFrame(stream) + const reordered = expectOk(await api.workspace.insertBefore(request({ + workspaceId: first.workspaceId, + beforeWorkspaceId: second.workspaceId, + }))) + expect(reordered.workspaceIds).toEqual([third.workspaceId, first.workspaceId, second.workspaceId]) + expect(await changed).toMatchObject({ + payload: { + type: 'host/workspace-order-changed', + workspaceIds: [third.workspaceId, first.workspaceId, second.workspaceId], + }, + }) + expect(expectOk(await api.workspace.list(request({}))).items.map(item => item.workspaceId)) + .toEqual(reordered.workspaceIds) + + const missingSource = await api.workspace.insertBefore(request({ + workspaceId: 'missing' as WorkspaceId, + })) + expect(missingSource.result).toMatchObject({ + ok: false, error: { code: 'workspace-not-found', details: { workspaceId: 'missing' } }, + }) + const missingAnchor = await api.workspace.insertBefore(request({ + workspaceId: first.workspaceId, + beforeWorkspaceId: 'missing-anchor' as WorkspaceId, + })) + expect(missingAnchor.result).toMatchObject({ + ok: false, error: { code: 'workspace-not-found', details: { workspaceId: 'missing-anchor' } }, + }) + abort.abort() + }) +}) + describe('session creation and Workspace membership', () => { it('attaches a preallocated idempotent session while cwd-only sessions stay ungrouped', async () => { const { api, ctx, root } = await harness() diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index 9365006de4..9fd5b6b18d 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -84,6 +84,7 @@ function scriptedApi(overrides: { 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 }), + insertBefore: r => ok(r, { workspaceIds: [r.payload.workspaceId] }), 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] }), }, diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index ed286334a8..a73064536e 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -174,6 +174,9 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra async delete(request) { return { rpcId: request.rpcId, result: { ok: true, value: { deleted: true as const } } } }, + async insertBefore(request) { + return { rpcId: request.rpcId, result: { ok: true, value: { workspaceIds: [request.payload.workspaceId] } } } + }, async insertSessionBefore(request) { return { rpcId: request.rpcId, diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 789639b70d..1278e30456 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -23,6 +23,7 @@ import { workspaceArchiveSessionRequestSchema, workspaceArchiveSessionValueSchema, workspaceCreateRequestSchema, workspaceCreateValueSchema, workspaceIdSchema, workspaceDeleteRequestSchema, workspaceDeleteValueSchema, + workspaceInsertBeforeRequestSchema, workspaceInsertBeforeValueSchema, workspaceInsertSessionBeforeRequestSchema, workspaceInsertSessionBeforeValueSchema, workspaceListRequestSchema, workspaceListValueSchema, workspaceRenameRequestSchema, workspaceRenameValueSchema, workspaceViewSchema, @@ -351,6 +352,17 @@ describe('workspace domain schemas', () => { expect(() => workspaceDeleteValueSchema.parse({ deleted: false })).toThrow() }) + it('insertBefore accepts an anchored or anchorless Workspace move and returns the complete order', () => { + expect(workspaceInsertBeforeRequestSchema.parse({ + workspaceId: 'w1', beforeWorkspaceId: 'w2', + }).beforeWorkspaceId).toBe('w2') + expect(workspaceInsertBeforeRequestSchema.parse({ workspaceId: 'w1' }).beforeWorkspaceId) + .toBeUndefined() + expect(() => workspaceInsertBeforeRequestSchema.parse({ beforeWorkspaceId: 'w2' })).toThrow() + expect(workspaceInsertBeforeValueSchema.parse({ workspaceIds: ['w2', 'w1'] }).workspaceIds) + .toEqual(['w2', 'w1']) + }) + it('insertSessionBefore accepts an anchored and an anchorless move', () => { expect(workspaceInsertSessionBeforeRequestSchema.parse({ workspaceId: 'w1', sessionId: 's1', beforeSessionId: 's2' }).beforeSessionId).toBe('s2') expect(workspaceInsertSessionBeforeRequestSchema.parse({ workspaceId: 'w1', sessionId: 's1' }).beforeSessionId).toBeUndefined() diff --git a/packages/workspace/workspace/tests/workspace.spec.ts b/packages/workspace/workspace/tests/workspace.spec.ts index 3c4b6185fb..c04980eecd 100644 --- a/packages/workspace/workspace/tests/workspace.spec.ts +++ b/packages/workspace/workspace/tests/workspace.spec.ts @@ -10,7 +10,11 @@ import type { DomainChanged } from '@deepseek-ai/dsh-storage-domain' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { SessionHeader } from '@deepseek-ai/dsh-session' import { MemoryMediaPool, MemoryStorageBackend } from '../../../storage/storage-domain/tests/helpers/memory-backend.ts' -import WorkspaceRegistry, { WorkspaceId, WorkspaceMoveInvalidError } from '../src/index.ts' +import WorkspaceRegistry, { + WorkspaceId, + WorkspaceMoveInvalidError, + WorkspaceOrderInvalidError, +} from '../src/index.ts' import type { WorkspaceDomainState, WorkspaceRecord } from '../src/index.ts' const DOMAIN_VERSION = 2 @@ -568,6 +572,49 @@ describe('WorkspaceRegistry create and lookup', () => { }) }) +describe('Workspace registry ordering', () => { + it('moves a workspace before an anchor or to the end and restores that order after restart', async () => { + const firstDir = await makeDir('order-first') + const secondDir = await makeDir('order-second') + const thirdDir = await makeDir('order-third') + const result = await harness() + const first = await result.registry.create(firstDir) + const second = await result.registry.create(secondDir) + const third = await result.registry.create(thirdDir) + expect(result.registry.list().map(item => item.id)).toEqual([third.id, second.id, first.id]) + + await expect(result.registry.insertBefore(first.id, second.id)) + .resolves.toEqual([third.id, first.id, second.id]) + await expect(result.registry.insertBefore(third.id)) + .resolves.toEqual([first.id, second.id, third.id]) + expect(storedState(result.pool).workspaceIds).toEqual([first.id, second.id, third.id]) + + const restarted = await harness({ pool: result.pool }) + expect(restarted.registry.list().map(item => item.id)).toEqual([first.id, second.id, third.id]) + }) + + it('keeps self-anchored and already-positioned moves write-free and rejects unknown ids', async () => { + const firstDir = await makeDir('order-noop-first') + const secondDir = await makeDir('order-noop-second') + const result = await harness() + const first = await result.registry.create(firstDir) + const second = await result.registry.create(secondDir) + const written = result.changes.length + + await result.registry.insertBefore(second.id, second.id) + await result.registry.insertBefore(second.id, first.id) + await result.registry.insertBefore(first.id) + expect(result.changes).toHaveLength(written) + expect(result.registry.list().map(item => item.id)).toEqual([second.id, first.id]) + + await expect(result.registry.insertBefore(WorkspaceId('missing'))) + .rejects.toBeInstanceOf(WorkspaceOrderInvalidError) + await expect(result.registry.insertBefore(second.id, WorkspaceId('missing-anchor'))) + .rejects.toMatchObject({ workspaceId: 'missing-anchor' }) + expect(result.changes).toHaveLength(written) + }) +}) + describe('Workspace session ordering', () => { it('prepends new attaches and keeps repeat attach idempotent', async () => { const dir = await makeDir('attach-order') From 3f09330f4beda54a0df715866d7576cef2f20ea0 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 11 Aug 2026 15:24:56 +0800 Subject: [PATCH 18/81] docs(web): record workspace sidebar behavior --- ...n-list-browsing-and-manual-order.i18n.yaml | 4 +- ...-session-list-browsing-and-manual-order.md | 4 +- ...ssion-list-browsing-and-manual-order.zh.md | 4 +- ...-07-25-workspace-ui-product-flow.i18n.yaml | 4 +- .../2026-07-25-workspace-ui-product-flow.md | 15 +++--- ...2026-07-25-workspace-ui-product-flow.zh.md | 15 +++--- ...kspace-sidebar-order-and-folding.i18n.yaml | 6 +++ ...-11-workspace-sidebar-order-and-folding.md | 54 +++++++++++++++++++ ...-workspace-sidebar-order-and-folding.zh.md | 54 +++++++++++++++++++ docs/subsystems/workspace.i18n.yaml | 4 +- docs/subsystems/workspace.md | 11 +++- docs/subsystems/workspace.zh.md | 11 +++- packages/client/runtime/README.i18n.yaml | 4 +- packages/client/runtime/README.md | 4 +- packages/client/runtime/README.zh.md | 4 +- packages/client/ui-sidebar/README.i18n.yaml | 4 +- packages/client/ui-sidebar/README.md | 8 +-- packages/client/ui-sidebar/README.zh.md | 8 +-- packages/client/ui-workspace/README.i18n.yaml | 4 +- packages/client/ui-workspace/README.md | 4 +- packages/client/ui-workspace/README.zh.md | 4 +- packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/README.zh.md | 2 +- .../tool-cordis/src/api-catalog.ts | 4 ++ packages/workspace/workspace/README.i18n.yaml | 4 +- packages/workspace/workspace/README.md | 3 +- packages/workspace/workspace/README.zh.md | 3 +- 28 files changed, 198 insertions(+), 54 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.md create mode 100644 .agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.zh.md diff --git a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.i18n.yaml b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.i18n.yaml index 097c0c9f2d..015852b082 100644 --- a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.i18n.yaml @@ -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-25-session-list-browsing-and-manual-order.md -2026-07-25-session-list-browsing-and-manual-order.md: 3d2125bdf67a70a1a5bca43c5d5acb09fda178b7 -2026-07-25-session-list-browsing-and-manual-order.zh.md: 161ebd2857073d4dd9cfc2883880cd3e2d91c040 +2026-07-25-session-list-browsing-and-manual-order.md: 52a0fe0c94106cb4178c57e737b1c9a3f458f803 +2026-07-25-session-list-browsing-and-manual-order.zh.md: a6c44579c685479ca460da8e52ea885f20e4776b diff --git a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md index 3d2125bdf6..52a0fe0c94 100644 --- a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md +++ b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md @@ -14,7 +14,7 @@ Two existing mechanisms stood in the way. First, the host durably promoted the a ### Flat rows and viewing state -The group-by menu offers two modes, WorkSpace / In one list. WorkSpace mode renders peer session rows within each group in the manual order from `WorkspaceView.sessionIds`; In one list combines every session and sorts them strictly newest-first by `updatedAt`. Neither mode projects `parentId` into a list hierarchy; fork lineage remains session data only. [Web session fork actions](2026-07-27-web-session-fork-actions.md) define the complete fork behavior. The mode choice persists in the browser (`dsh.workspace.view`) across reloads. +The group-by menu offers two modes, WorkSpace / In one list. WorkSpace mode renders peer session rows within each group in the manual order from `WorkspaceView.sessionIds`; In one list combines every session and sorts them strictly newest-first by `updatedAt`. Neither mode projects `parentId` into a list hierarchy; fork lineage remains session data only. [Web session fork actions](2026-07-27-web-session-fork-actions.md) define the complete fork behavior. The mode choice persists in the browser (`dsh.workspace.view`) across reloads. [Workspace Sidebar Order and Folding](2026-08-11-workspace-sidebar-order-and-folding.md) later added a browser-local recent-update view without changing the Host account's manual-order authority. ### Row interactions @@ -50,7 +50,7 @@ ui-sidebar shrinks to the column-geometry shell: brand row, fold state machine, ## Consequences -- Manual order is the sole authority over the workspace account: an order the user arranges is never scrambled by activity; the cost is losing float-to-top-on-activity, whose signal now rides the row status dot and time label. The `WorkspaceView.sessionIds` wire contract is reworded to the manual-order semantics. +- Manual order is the sole authority over the Host workspace account: activity never mutates `WorkspaceView.sessionIds`. A later browser-local recent-update view may promote active rows without changing that account; its separate semantics are defined in [Workspace Sidebar Order and Folding](2026-08-11-workspace-sidebar-order-and-folding.md). - The two-fact shell/region contract funnels every future workspace-domain feature (Delete confirmation, cross-group moves, Ungrouped adoption) into the single ui-workspace package; ui-sidebar no longer evolves with session-list features. - Flat mode supports neither reordering nor a create-in-workspace entry point (switching back to grouped view is required) — an accepted scope reduction. - Wiring session Delete and growing the wire status enum remain future iterations. diff --git a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md index 161ebd2857..a6c44579c6 100644 --- a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md +++ b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md @@ -14,7 +14,7 @@ Status: implemented ### 平铺行与浏览态 -group-by 菜单提供 WorkSpace / In one list 两种模式。WorkSpace 模式按 `WorkspaceView.sessionIds` 的手动序在各组内展示同级 session 行;In one list 把所有 session 合并后严格按 `updatedAt` 新→旧排序。两种模式都不把 `parentId` 投影成列表层级,fork 谱系只保留为 session 数据;完整 fork 行为由 [Web session fork 操作](2026-07-27-web-session-fork-actions.md)定义。模式选择持久化在浏览器(`dsh.workspace.view`),刷新保持。 +group-by 菜单提供 WorkSpace / In one list 两种模式。WorkSpace 模式按 `WorkspaceView.sessionIds` 的手动序在各组内展示同级 session 行;In one list 把所有 session 合并后严格按 `updatedAt` 新→旧排序。两种模式都不把 `parentId` 投影成列表层级,fork 谱系只保留为 session 数据;完整 fork 行为由 [Web session fork 操作](2026-07-27-web-session-fork-actions.md)定义。模式选择持久化在浏览器(`dsh.workspace.view`),刷新保持。[Workspace 侧边栏顺序与折叠](2026-08-11-workspace-sidebar-order-and-folding.md)随后加入浏览器本地的最近更新视图,而未改变 Host 记账的手动顺序权威。 ### 行交互 @@ -50,7 +50,7 @@ ui-sidebar 缩为列几何壳:品牌行、折叠状态机、New Session、Settin ## Consequences -- 手动序是唯一的 workspace 账本序权威:用户排好的顺序不再被活动打乱;代价是「最近活跃浮到最上」的行为消失,活跃感知转由行内状态点与时间标签承担。`WorkspaceView.sessionIds` 的 wire 约定随之改为手动序措辞。 +- 手动序是 Host workspace 账本的唯一顺序权威:活动绝不改动 `WorkspaceView.sessionIds`。后续加入的浏览器本地最近更新视图可以把活跃行提到最前,但不会改变该账本;其独立语义见 [Workspace 侧边栏顺序与折叠](2026-08-11-workspace-sidebar-order-and-folding.md)。 - 壳/区域两事实约定把 workspace 域的后续功能(Delete 确认、跨组移动、Ungrouped 收编)全部收进 ui-workspace 单包;ui-sidebar 不再随 session 列表功能演进。 - 平铺模式不支持排序与分组入口(建到指定 workspace 需切回分组视图),是拍板接受的范围收窄。 - session Delete 的功能接线与状态枚举扩 wire,留待后续迭代。 diff --git a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml index c0813607d3..8d8d36e87d 100644 --- a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.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-25-workspace-ui-product-flow.md -2026-07-25-workspace-ui-product-flow.md: 98e963195126df2ec8291a11b3d9fc7a2baeb0df -2026-07-25-workspace-ui-product-flow.zh.md: 486093be0b8d10c2ae0b8083b305ecad5386351c +2026-07-25-workspace-ui-product-flow.md: 76d279bf2101d7487fe4f5231c7cea4809e166f4 +2026-07-25-workspace-ui-product-flow.zh.md: e15ead7b437d8f2324f7ea51222eb4fcfb4a9e4a diff --git a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md index 98e9631951..76d279bf21 100644 --- a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md +++ b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md @@ -20,6 +20,7 @@ The Host provides the following GUI wiring on the Workspace entity: | --- | --- | | `workspace.list` | Returns persistent Workspaces in order and filters out Session ids that fail header validation | | `workspace.create({ path })` | Adopts an existing directory by canonical path; basename-derived display titles may repeat | +| `workspace.insertBefore({ workspaceId, beforeWorkspaceId? })` | Moves one Workspace within durable registry order and returns the complete committed order | | `workspace.delete({ workspaceId })` | Removes the Workspace registration while retaining its directory and session logs; its Sessions become Ungrouped | | `session.create({ workspaceId, sessionId? })` | Resolves cwd from the Workspace, idempotently creates a Session with an optional preallocated id, and attaches it | | `session.create({ cwd })` | Remains available to non-Workspace callers and creates an Ungrouped Session | @@ -49,7 +50,7 @@ On initial entry, the application waits until both the Workspace and Session bas When no Workspace exists, the page creates a frontend Workspace object named `workspace` and a frontend Session that targets it. Neither writes to the Host, and the composer always accepts input; the first send materializes the Workspace, attaches the Session, and sends the message in that order. -Top-level New Session, the plus button on a Workspace row, and the Workspace picker all invoke the same New Session action. An explicit Workspace id becomes the target directly; when none is specified, the action uses the most recent Workspace, or the Workspace Intent if no real Workspace exists. The Workspace picker's one Add workspace action ([one-route Note](../simplification/2026-07-31-one-route-to-add-a-workspace.md); it was a pair of Use-an-existing-folder and create-by-name actions when this was decided) immediately creates a real Workspace when the user confirms a directory, then retargets the frontend Session to it; an explicitly created empty Workspace remains even if the user sends no message. +Top-level New Session, the plus button on a Workspace row, and the Workspace picker all invoke the same New Session action. An explicit Workspace id becomes the target directly; when none is specified, the action uses the current Session's Workspace, then the most recent Workspace, and enters the blank New Session page when no real Workspace exists. The Workspace picker's one Add workspace action ([one-route Note](../simplification/2026-07-31-one-route-to-add-a-workspace.md); it was a pair of Use-an-existing-folder and create-by-name actions when this was decided) immediately creates a real Workspace when the user confirms a directory, then retargets the frontend Session to it; an explicitly created empty Workspace remains even if the user sends no message. A new Workspace takes its display name from the directory it was created in. Distinct canonical paths may share the same basename-derived title ([identity decision](../bug-fix/2026-07-31-same-basename-workspace-adoption.md)); the explicit rename operation retains its duplicate-title check. Moving Sessions across Workspaces, manual adoption from Ungrouped, and separate display-name and directory-name inputs remain outside this flow. @@ -67,11 +68,11 @@ Lost RPC responses, Host frames arriving before completions, and completions arr ### Sidebar and ordering -Workspace groups strictly follow the persistent order returned by the Host. Bootstrap determines the historical order once, explicitly created Workspaces are placed first, and Session activity does not move Workspace groups. +Workspace groups follow the persistent order returned by the Host. Bootstrap determines the historical order once, explicitly created Workspaces are placed first, and `workspace.insertBefore` durably applies user drag order. Session activity does not move Workspace groups. -Within each group, order strictly follows `Workspace.sessionIds`. A newly attached Session is placed first; when a Session later becomes active, the Host moves only that id to the front and persists the change. The Client does not reorder the entire group by time after the Session list arrives, so it never displays one Workspace order and then jumps to another during hydration. +The Host account remains the manual `Workspace.sessionIds` order: a newly attached Session is placed first and activity does not mutate it. The grouped browser can instead select a browser-local recent-update view that promotes a Session when its `updatedAt` advances and remains manually editable. Five Sessions are visible per open Workspace until the user transiently expands the remainder. The durable Workspace reorder and browser-local Session order are defined in [Workspace Sidebar Order and Folding](2026-08-11-workspace-sidebar-order-and-folding.md). -A frontend Session Intent appears as a “New session” row and temporarily counts toward the group's Session total only when it targets a real Workspace. When it targets a Workspace Intent, neither the Workspace nor the Session appears in the sidebar. After the Intent is published, the real row with the same preallocated id takes its place; after refresh, both the Intent row and temporary count disappear. Search mode neither retains nor filters Intent rows. +The current blank Session appears as a “New session” row without a count, time label, or row menu; other blank Sessions remain hidden and eligible for per-Workspace reuse. Search excludes blank rows. Real Sessions that cannot be assigned to any Workspace appear under Ungrouped. Host `session-added` and `workspace-changed` events may arrive in either order; list merging does not depend on frame order. @@ -105,15 +106,15 @@ The Sidebar and conversation empty hero receive standardized actions through slo - Frontend Sessions and Workspaces preserve object identity across materialization; input, errors, focus, and sidebar projections always originate from the object layer. - The first send advances through Workspace, Session, and prompt in order; successful stages are not rolled back, input is not lost before the prompt is accepted, and creation retries use the same SessionId. - Workspace list performs one reentrant bootstrap using only headers; an initialized empty registry does not initialize again after restart, and membership reads validate both the index and canonical cwd. -- The initial default target is determined exactly once after both baselines are ready; Workspace groups are not reordered as a whole by hydration or Session activity, and an active Session moves only itself to the front. -- A frontend Session under a real Workspace temporarily counts toward the sidebar total, while a Workspace Intent remains hidden; neither publication nor refresh leaves duplicate rows or counts. +- The initial default target is determined exactly once after both baselines are ready; Workspace groups are not reordered by hydration or Session activity, and explicit Workspace drag order survives reconnect. +- The current blank Session can appear as a single New Session row without exposing other reusable blanks or a Session count. - The UI and Host admit distinct same-basename directories as separate Workspaces, while the explicit rename operation rejects duplicate titles; cwd-only Sessions, Sessions with invalid historical cwd values, and unattached Sessions remain Ungrouped. - Confirmed Workspace deletion removes only the registration, retains the current Session, directory, files, and session log, and survives reload; package tests pin unary/frame/baseline races and failure rollback. - Keyless runnable snapshots cover the zero state, explicit creation, and the first send; package-level tests cover bootstrap, membership validation, ordering, idempotency, failure recovery, and arbitrary frame order. ## Consequences -- SessionHeader does not record last-active time, so historical bootstrap can initialize order only by `createdAt`; real Session activity events move individual entries afterward. +- SessionHeader does not record last-active time, so historical bootstrap can initialize the Host manual order only by `createdAt`; the browser's optional recent-update view begins from Session summaries after hydration. - Historical Sessions with a missing cwd, an invalid directory, or a failed realpath remain Ungrouped; this iteration has no manual-adoption entry point. - Refreshing the page discards unmaterialized Workspace and Session Intents and input not yet accepted by the Host; this is the page-local contract. - Explicit Create Workspace writes to disk immediately, so leaving without sending still leaves an empty Workspace. diff --git a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md index 486093be0b..e15ead7b43 100644 --- a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md +++ b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md @@ -20,6 +20,7 @@ Host 在 Workspace entity 上提供以下 GUI 接线: | --- | --- | | `workspace.list` | 返回持久有序的 Workspace,并过滤未通过 header 校验的 Session id | | `workspace.create({ path })` | 按 canonical path 收编已有目录;由 basename 派生的显示名可以重复 | +| `workspace.insertBefore({ workspaceId, beforeWorkspaceId? })` | 在持久注册表顺序内移动一个 Workspace,并返回完整的已提交顺序 | | `workspace.delete({ workspaceId })` | 移除 Workspace 注册记录,同时保留目录和会话日志;相关 Session 进入 Ungrouped | | `session.create({ workspaceId, sessionId? })` | 从 Workspace 解析 cwd,以可选预分配 id 幂等创建 Session 并 attach | | `session.create({ cwd })` | 保留给非 Workspace 调用方,创建 Ungrouped Session | @@ -49,7 +50,7 @@ Session 自己持有首条输入并驱动一条内部流水线:必要时以预 完全没有 Workspace 时,页面创建默认名为 `workspace` 的前端 Workspace 对象和指向它的前端 Session。两者不写 Host,composer 始终可输入;首次发送才依次 materialize Workspace、attach Session、发送消息。 -顶部 New Session、Workspace 行内加号和 Workspace picker 最终都调用同一 New Session 动作:显式 Workspace id 直接成为目标,未指定时使用最近 Workspace,没有真实 Workspace 时使用 Workspace Intent。Workspace picker 的单一 Add workspace 动作(见[单一路径 Note](../simplification/2026-07-31-one-route-to-add-a-workspace.md);本决策做出时是 Use an existing folder 与按名称创建两个动作)会在用户确认目录时立即创建真实 Workspace,再把前端 Session 定位到该 Workspace;即使用户不发送消息,显式创建的空 Workspace 也保留。 +顶部 New Session、Workspace 行内加号和 Workspace picker 最终都调用同一 New Session 动作:显式 Workspace id 直接成为目标,未指定时先使用当前 Session 所属 Workspace,再使用最近 Workspace;没有真实 Workspace 时进入空白 New Session 页面。Workspace picker 的单一 Add workspace 动作(见[单一路径 Note](../simplification/2026-07-31-one-route-to-add-a-workspace.md);本决策做出时是 Use an existing folder 与按名称创建两个动作)会在用户确认目录时立即创建真实 Workspace,再把前端 Session 定位到该 Workspace;即使用户不发送消息,显式创建的空 Workspace 也保留。 新建 Workspace 的显示名取自其所在目录。不同 canonical path 可以拥有相同的 basename 派生显示名(见[身份决策](../bug-fix/2026-07-31-same-basename-workspace-adoption.md));显式的重命名操作仍保留显示名重名检查。跨 Workspace 移动 Session、从 Ungrouped 手动收编以及分别输入显示名和目录名仍不在此动线范围内。 @@ -67,11 +68,11 @@ RPC 响应丢失、Host frame 先于 completion 和 completion 先于 Host frame ### Sidebar 与排序 -Workspace 组严格使用 Host 返回的持久顺序。Bootstrap 一次性确定历史顺序,显式创建的新 Workspace 放在首位;Session 活跃不会移动 Workspace 组。 +Workspace 组使用 Host 返回的持久顺序。Bootstrap 一次性确定历史顺序,显式创建的新 Workspace 放在首位,`workspace.insertBefore` 则持久应用用户拖拽顺序;Session 活跃不会移动 Workspace 组。 -组内严格使用 `Workspace.sessionIds`。新 attach 的 Session 放在首位,后续某个 Session 活跃时 Host 只前移该 id 并持久化。Client 不在 Session list 到达后按时间整体重排,因此不会先显示一套 Workspace 顺序再因 hydration 瞬间跳动。 +Host 记账保持手动的 `Workspace.sessionIds` 顺序:新 attach 的 Session 放在首位,活动不会改动该顺序。分组浏览器可以改选浏览器本地的最近更新视图;当 Session 的 `updatedAt` 增大时该视图会把它移到首位,同时仍允许手动调整。每个打开的 Workspace 默认显示五条 Session,用户可临时展开其余条目。持久 Workspace 重排序和浏览器本地 Session 顺序见 [Workspace 侧边栏顺序与折叠](2026-08-11-workspace-sidebar-order-and-folding.md)。 -前端 Session Intent 只有在目标是真实 Workspace 时才作为 「New session」 行显示,并临时计入该组 Session 数量;目标是 Workspace Intent 时,Workspace 与 Session 都不进入 sidebar。Intent 发布后由同一预分配 id 对应的真实行接替,刷新后 Intent 行和临时计数一起消失。搜索模式不保存或筛选 Intent 行。 +当前空白 Session 会显示为一条「New session」行,但不显示数量、时间标签或行菜单;其他空白 Session 保持隐藏,并可由对应 Workspace 复用。搜索会排除空白行。 无法归入任何 Workspace 的真实 Session 进入 Ungrouped。Host `session-added` 与 `workspace-changed` 可以任意顺序到达,列表合并不依赖 frame 顺序。 @@ -105,15 +106,15 @@ Sidebar 与 conversation empty hero 通过 slot 获得标准化动作:`startSe - 前端 Session 与 Workspace 在 materialize 前后保持对象身份,输入、错误、焦点和 sidebar 投影始终来自对象层。 - 首发按 Workspace、Session、提示词顺序推进,各成功阶段不回滚,输入在提示词被接受前不丢失,创建重试使用同一 SessionId。 - Workspace list 只读取 header 完成一次可重入 bootstrap;initialized 的空 registry 重启不重复初始化,成员读取同时校验索引与 canonical cwd。 -- 初始默认目标只在两份基线 ready 后确定一次;Workspace 组不因 hydration 或 Session 活跃整体重排,单个活跃 Session 只前移自身。 -- 真实 Workspace 下的前端 Session 临时计入 sidebar 数量,Workspace Intent 保持隐藏,发布与刷新都不会留下重复行或重复计数。 +- 初始默认目标只在两份基线 ready 后确定一次;Workspace 组不因 hydration 或 Session 活跃重排,显式 Workspace 拖拽顺序在重连后仍然保持。 +- 当前空白 Session 可显示为唯一的 New Session 行,同时不暴露其他可复用空白会话,也不显示 Session 数量。 - UI 与 Host 会将 canonical path 不同但 basename 相同的目录接纳为独立 Workspace,而显式的重命名操作会拒绝重复显示名;cwd-only Session、无效历史 cwd 和未 attach Session 保持 Ungrouped。 - 经确认的 Workspace 删除只移除注册记录,保留当前 Session、目录、文件和会话日志,并在刷新后保持该状态;包级测试固定一元响应/帧/基线竞态和失败回滚行为。 - keyless runnable snapshot 覆盖零态、显式创建和首次发送;包级测试覆盖 bootstrap、成员校验、排序、幂等、失败恢复及任意 frame 顺序。 ## Consequences -- SessionHeader 不记录最后活跃时间,历史 bootstrap 只能按 `createdAt` 初始化;此后由真实 Session 活跃事件逐项前移。 +- SessionHeader 不记录最后活跃时间,历史 bootstrap 只能按 `createdAt` 初始化 Host 手动顺序;浏览器可选的最近更新视图在 hydration 后从 Session 摘要开始建立。 - 历史 cwd 缺失、目录无效或 realpath 失败的 Session 留在 Ungrouped;本期没有手动收编入口。 - 页面刷新会丢弃未 materialize 的 Workspace/Session Intent 和尚未被 Host 接受的输入,这是 page-local 约定。 - 显式 Create Workspace 立即落盘,用户不发送就离开也会留下空 Workspace。 diff --git a/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.i18n.yaml b/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.i18n.yaml new file mode 100644 index 0000000000..a389aedae9 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.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-08-11-workspace-sidebar-order-and-folding.md +2026-08-11-workspace-sidebar-order-and-folding.md: 799c972ead9ac5d56fa70d2f6eedda58986ab65e +2026-08-11-workspace-sidebar-order-and-folding.zh.md: 7ac93e08aeaea5ddd455f5cde0395c667a38cca1 diff --git a/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.md b/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.md new file mode 100644 index 0000000000..799c972ead --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.md @@ -0,0 +1,54 @@ +# Agent Note: Workspace Sidebar Order and Folding + +Status: implemented + +English | [中文](2026-08-11-workspace-sidebar-order-and-folding.zh.md) + +## Problem + +A Workspace with many Sessions can consume the entire sidebar and push other Workspaces out of reach. A compact list needs a bounded default while preserving an explicit route to every Session. The sidebar also needs an activity-oriented order, but `WorkspaceView.sessionIds` is the durable manual account and must not be rewritten by Session activity. + +Workspace groups themselves had no user-controlled durable order. Browser-native drag additionally rejects a drop released outside the list and animates the row back even when the application still has a valid insertion marker. Expanded Workspace sections make header-only hit testing ambiguous because the visual boundary between two groups does not match either header's midpoint. + +## Decision + +### Workspace order + +The Workspace registry owns a durable `workspaceIds` order and exposes `insertBefore(id, beforeId?)` with DOM `insertBefore` semantics. The Host RPC `workspace.insertBefore` returns the complete committed order, and a pure order mutation emits `host/workspace-order-changed` with the same complete order. Unknown source or anchor ids reject as `workspace-not-found`; self-anchored and already-positioned moves do not write. + +The client installs a Workspace drag optimistically. Request and frame generations ensure that only the latest unary echo can replace local order and that a newer Host frame outranks an older response; a latest rejected request restores the preceding order. Every successful list baseline restores Host order so reconnects adopt durable changes made elsewhere. + +### Session folding and view order + +Each Workspace persists one browser-local open state: closed means zero Session rows and open means up to five. When more Sessions exist, **Show more** reveals the remainder only for the current mount; closing the whole Workspace clears this transient expansion, so reopening returns to five. The current Session's group opens automatically only when the user has not already stored an explicit state for that Workspace. + +The combined view menu offers **Manual** and **Last updated**. Manual follows the Host account in `WorkspaceView.sessionIds`. Last updated maintains a browser-local per-Workspace order that users may still edit by dragging; whenever a Session summary's `updatedAt` advances, that Session is promoted to the front. This view order never writes the Host Session account. The flat list uses recent-update order because it has no single Workspace account for durable Session drag. + +### Drag and compact chrome + +Workspace hit testing uses the complete rendered group section, including visible Session rows. One insertion boundary is shared by the preceding group's lower half and the following group's upper half, and the indicator is an absolutely positioned line that does not affect layout. During a Session drag, document-level `dragover` and `drop` handlers accept the native operation; if release occurs outside the Workspace list, `dragend` commits the last valid marker. + +Search is a header action while collapsed and expands across the title and trailing actions. An outside click collapses an empty search but retains a non-empty query. Compact Workspace and Session rows, a 24px bottom fade, and the absence of per-Workspace Session counts preserve vertical space without removing navigation affordances. + +## Alternatives considered + +**Persist the recent-update view into `Workspace.sessionIds`.** Activity would overwrite a deliberate manual order and recreate two competing meanings for the same Host field. + +**Always show every Session in an open Workspace.** One large Workspace would continue to crowd out the rest, and remembering only the whole-group open state would not bound its height. + +**Persist the expanded-remainder state.** A Workspace reopened much later could unexpectedly occupy the full sidebar. Only the zero-or-five state represents a stable navigation preference; revealing the remainder is a local inspection. + +**Use numeric drop indices or header-only hit testing.** Indices drift when rows change during a drag, while header midpoints disagree with the visible boundary when a Workspace is expanded. Anchor ids and full-section geometry remain stable under both conditions. + +**Let the browser reject an outside release.** The application would commit the last valid marker while the browser displays a rejected-drop animation, presenting contradictory feedback. + +## Consequences + +- Workspace order is durable and shared through the Host, while grouping, open state, recent-update Session order, and query state remain browser-local presentation preferences. +- Recent-update mode preserves manual edits until a Session becomes active again; a newer `updatedAt` intentionally promotes that Session to the front. +- Opening a Workspace never shows more than five Sessions without an explicit **Show more** gesture, and closing it resets only that transient gesture. +- The Host Session account retains the manual-order meaning established by [Session List Browsing and Manual Workspace Order](2026-07-25-session-list-browsing-and-manual-order.md). + +## Testing + +Domain and Host tests cover durable Workspace moves, no-op and invalid anchors, restart recovery, full-order RPC responses, and order frames. Runtime tests cover optimistic order, frame/response precedence, rejection rollback, reconnect baselines, and New Session target priority. UI tests cover five-row folding, transient expansion reset, recent-update promotion with manual drag, selected view indicators, expanded-section Workspace hit testing, outside-list Session drops, search collapse rules, and compact CSS dimensions. diff --git a/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.zh.md b/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.zh.md new file mode 100644 index 0000000000..7ac93e08ae --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.zh.md @@ -0,0 +1,54 @@ +# Agent Note: Workspace 侧边栏顺序与折叠 + +Status: implemented + +[English](2026-08-11-workspace-sidebar-order-and-folding.md) | 中文 + +## 问题 + +Session 很多的 Workspace 会占满整个侧边栏,把其他 Workspace 挤出可见范围。紧凑列表需要有界的默认高度,同时仍要提供到达每条 Session 的明确入口。侧边栏还需要面向活动时间的顺序,但 `WorkspaceView.sessionIds` 是持久的手动记账,不能被 Session 活动改写。 + +Workspace 分组本身没有用户可控的持久顺序。浏览器原生拖拽还会把列表外松手判为拒绝,并把行弹回原位,即使应用仍持有有效插入标记。Workspace 展开后,若只按组头命中,两个分组之间的视觉边界也不再等于任一组头的中点。 + +## 决策 + +### Workspace 顺序 + +Workspace 注册表持有持久 `workspaceIds` 顺序,并提供采用 DOM `insertBefore` 语义的 `insertBefore(id, beforeId?)`。Host RPC `workspace.insertBefore` 返回完整的已提交顺序;单纯顺序变更通过 `host/workspace-order-changed` 推送同一份完整顺序。未知来源或锚点 id 以 `workspace-not-found` 拒绝;以自身为锚点或移动到当前位置不会写入。 + +客户端对 Workspace 拖拽进行乐观安装。请求代次与帧代次保证只有最新一元回声可以替换本地顺序,且更新的 Host 帧优先于旧响应;最新请求被拒时恢复此前顺序。每次成功的列表基线都会恢复 Host 顺序,因此重连会接纳其他位置提交的持久变更。 + +### Session 折叠与视图顺序 + +每个 Workspace 持久化一项浏览器本地打开状态:关闭表示零条 Session 行,打开表示最多五条。存在更多 Session 时,**展开其余**只在当前挂载期间显示剩余项;关闭整个 Workspace 会清除此临时展开,因此重新打开时恢复为五条。只有在用户尚未为该 Workspace 存储明确状态时,当前 Session 所在分组才会自动打开。 + +组合视图菜单提供**手动排序**和**最近更新**。手动排序遵循 `WorkspaceView.sessionIds` 中的 Host 记账。最近更新为每个 Workspace 维护一份浏览器本地顺序,用户仍可通过拖拽编辑;每当 Session 摘要的 `updatedAt` 增大时,该 Session 会被移到最前。此视图顺序绝不写入 Host Session 记账。平铺列表使用最近更新顺序,因为它没有可承载持久 Session 拖拽的单一 Workspace 记账。 + +### 拖拽与紧凑界面 + +Workspace 命中测试使用完整渲染分组区段,包括可见 Session 行。前一分组的下半部与后一分组的上半部共享同一条插入边界,指示器是一条不影响布局的绝对定位横线。Session 拖拽期间,文档级 `dragover` 与 `drop` 处理器会接受原生操作;若在 Workspace 列表外松手,`dragend` 会提交最后一个有效标记。 + +搜索在折叠时是区头操作,展开后占据标题与尾部操作的空间。点击外部会收起空搜索,但保留非空查询。紧凑的 Workspace 与 Session 行、24px 底部渐隐以及取消每个 Workspace 的 Session 数量共同节省纵向空间,同时保留导航入口。 + +## 考虑过的替代方案 + +**把最近更新视图持久化到 `Workspace.sessionIds`。** 活动会覆盖用户明确安排的手动顺序,并让同一 Host 字段重新承担两种相互竞争的含义。 + +**打开 Workspace 时始终显示全部 Session。** 大型 Workspace 仍会挤占其他分组;只记忆整个分组的打开状态无法限制其高度。 + +**持久化展开剩余状态。** 很久以后重新打开 Workspace 时,它可能意外占满侧边栏。只有零条或五条状态属于稳定导航偏好;显示剩余项只是一次本地查看。 + +**使用数字下标或只按组头命中拖拽。** 拖拽期间行发生变化会使下标漂移;Workspace 展开时,组头中点与可见边界不一致。锚点 id 与完整区段几何在两种情况下都保持稳定。 + +**让浏览器拒绝列表外松手。** 应用会提交最后一个有效标记,而浏览器同时播放拒绝动画,形成相互矛盾的反馈。 + +## 后果 + +- Workspace 顺序通过 Host 持久并共享;分组方式、打开状态、最近更新 Session 顺序和查询状态仍是浏览器本地呈现偏好。 +- 最近更新模式会保持手动调整,直到某条 Session 再次活跃;更大的 `updatedAt` 会有意把它移到最前。 +- 未执行明确的**展开其余**手势时,打开 Workspace 最多显示五条 Session;关闭分组只重置这项临时手势。 +- Host Session 记账继续采用[会话列表浏览与 Workspace 手动排序](2026-07-25-session-list-browsing-and-manual-order.md)确立的手动顺序含义。 + +## 测试 + +领域与 Host 测试覆盖持久 Workspace 移动、无操作与无效锚点、重启恢复、完整顺序 RPC 响应和顺序帧。运行时测试覆盖乐观顺序、帧/响应优先级、拒绝回滚、重连基线以及 New Session 目标优先级。UI 测试覆盖五行折叠、临时展开重置、最近更新置顶与手动拖拽、当前视图标记、展开区段的 Workspace 命中、列表外 Session 松手、搜索收起规则和紧凑 CSS 尺寸。 diff --git a/docs/subsystems/workspace.i18n.yaml b/docs/subsystems/workspace.i18n.yaml index b2c2248876..5a7aa0bae4 100644 --- a/docs/subsystems/workspace.i18n.yaml +++ b/docs/subsystems/workspace.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/workspace.md -workspace.md: ca088a2091a7f47a3d52992fec13fae44061a608 -workspace.zh.md: e414c759a043f934e1a8b5d89c7a3b6101bbb6f4 +workspace.md: dba519f7eecd0f50ab91e1e2346f09ade154029d +workspace.zh.md: be91c4a5aeacc9ad379a784f93c6c8535eefb11e diff --git a/docs/subsystems/workspace.md b/docs/subsystems/workspace.md index ca088a2091..dba519f7ee 100644 --- a/docs/subsystems/workspace.md +++ b/docs/subsystems/workspace.md @@ -194,6 +194,15 @@ list(): Workspace[] */ delete(id: WorkspaceId): Promise +/** + * Move one workspace within the durable display order, DOM-insertBefore-like. + * With an anchor it lands before that workspace; without one it appends. + * @param id - Workspace to move. + * @param beforeId - Workspace anchor; omitted appends. + * @returns the complete committed workspace order. + */ +insertBefore(id: WorkspaceId, beforeId?: WorkspaceId): Promise + /** * Archive one session durably. The session must exist (live or in session * persistence); its workspace accounting — or lack of one — is irrelevant. @@ -215,5 +224,5 @@ async resolveByPath(path: string): Promise Types: [SessionId](core.md) -Source: [`packages/workspace/workspace/src/index.ts:81`](../../packages/workspace/workspace/src/index.ts) +Source: [`packages/workspace/workspace/src/index.ts:92`](../../packages/workspace/workspace/src/index.ts) diff --git a/docs/subsystems/workspace.zh.md b/docs/subsystems/workspace.zh.md index e414c759a0..be91c4a5ae 100644 --- a/docs/subsystems/workspace.zh.md +++ b/docs/subsystems/workspace.zh.md @@ -194,6 +194,15 @@ list(): Workspace[] */ delete(id: WorkspaceId): Promise +/** + * Move one workspace within the durable display order, DOM-insertBefore-like. + * With an anchor it lands before that workspace; without one it appends. + * @param id - Workspace to move. + * @param beforeId - Workspace anchor; omitted appends. + * @returns the complete committed workspace order. + */ +insertBefore(id: WorkspaceId, beforeId?: WorkspaceId): Promise + /** * Archive one session durably. The session must exist (live or in session * persistence); its workspace accounting — or lack of one — is irrelevant. @@ -215,5 +224,5 @@ async resolveByPath(path: string): Promise Types: [SessionId](core.md) -Source: [`packages/workspace/workspace/src/index.ts:81`](../../packages/workspace/workspace/src/index.ts) +Source: [`packages/workspace/workspace/src/index.ts:92`](../../packages/workspace/workspace/src/index.ts) diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index 3d3c29e757..b69029bc2b 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: 7c835deb58db149710495f97a2553c3de58d99da -README.zh.md: edf4473bec7df2253c032c3da86da878cdeade09 +README.md: 5ac081d6f257bc1c3a7d8a2dd75234f72f209da7 +README.zh.md: 15a1a4602fbb4ae6bd56d728b17782da8cfa4693 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 7c835deb58..5ac081d6f2 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -13,7 +13,7 @@ The callback returns one synchronous disposer or an iterable of disposers. A gen ## Workspace and Session lists -Workspace and Session lists have independent monotone `pending` → `ready` baseline phases and separate refresh activity/error state. Incremental upsert/removal frames and unary mutation echoes arriving during a list request replay over its response. The first successful baseline establishes Host order; later refreshes update rows and membership without changing the relative order of identities already shown. Removed Workspace ids retain process-local tombstones so late changed frames cannot resurrect them; reconnect still takes `workspace.list` as the baseline. Workspace recency is derived only after both baselines are ready and never changes Workspace list order. +Workspace and Session lists have independent monotone `pending` → `ready` baseline phases and separate refresh activity/error state. Incremental upsert/removal/order frames and unary mutation echoes arriving during a list request replay over its response. Every successful Workspace baseline re-establishes Host-durable Workspace order so reconnects adopt changes committed while this client was offline. `WorkspacesService.insertBefore` installs an optimistic order immediately; only the latest unary echo may replace it, a newer Host order frame outranks an older echo, and a latest rejected request rolls back. Removed Workspace ids retain process-local tombstones so late changed frames cannot resurrect them. Workspace recency is derived only after both baselines are ready and never changes Workspace list order. `SessionSummary.pendingInteraction` classifies the live user action blocking a Session as `approval`, `plan-review`, or `question`. `SessionManager` tracks answerable requested/resolved mux frames by their stable request identities even before a Session object is instantiated; pre-instantiation buffering retains every live request, replaces replay duplicates, and removes resolved requests so the list status always has a matching answerable `PendingWait` when the Session is opened. The first pending question takes presentation priority over concurrent approvals to match composer routing, while only a request that satisfies the plan-review composer's binary rendering constraints keeps the distinct `plan-review` status. The state is connection-generation scoped: disconnect clears it, and mux-open replay restores only requests that remain pending. @@ -31,7 +31,7 @@ SlotsService gives the renderer separate bare observables for `useSessions` and ## New Session and the blank mirror -`WorkspacesService.connectWorkspace(workspaceId)` resolves the session a New Session flow lands in: it reuses the workspace's existing blank session from the list mirror (`blank && cwd == workspace.path && sessionIds.includes(id)` — the host's own membership rule, never cwd alone, so a cwd-matching unaccounted blank session is never hijacked) or calls `session.create({workspaceId})`, returning the session id for the caller to open. `SessionSummary.blank` mirrors the host's derived empty-log bit and only ever lowers on the client: seeded by `session.list` / the `host/session-added` frame, flipped false by the first ACCEPTED local `prompt()` (on the RPC success response — acceptance proves the user message is in the host log; a rejected first prompt keeps the session blank and reusable) and by any `running: true` status frame, re-aligned by every list re-pull. List surfaces hide blank rows; the store carries every row. `SessionsService.create` accepts an optional caller-preallocated SessionId and throws `SessionCreateError` (carrying `requestedSessionId`) on failure. +`WorkspacesService.connectWorkspace(workspaceId)` resolves the session a New Session flow lands in: it reuses the workspace's existing blank session from the list mirror (`blank && cwd == workspace.path && sessionIds.includes(id)` — the host's own membership rule, never cwd alone, so a cwd-matching unaccounted blank session is never hijacked) or calls `session.create({workspaceId})`, returning the session id for the caller to open. The shared `startSession` action targets an explicit Workspace first, then the current Session's Workspace, then the derived recent Workspace; with no Workspace it clears into the blank New Session page. `SessionSummary.blank` mirrors the host's derived empty-log bit and only ever lowers on the client: seeded by `session.list` / the `host/session-added` frame, flipped false by the first ACCEPTED local `prompt()` (on the RPC success response — acceptance proves the user message is in the host log; a rejected first prompt keeps the session blank and reusable) and by any `running: true` status frame, re-aligned by every list re-pull. List surfaces hide blank rows; the store carries every row. `SessionsService.create` accepts an optional caller-preallocated SessionId and throws `SessionCreateError` (carrying `requestedSessionId`) on failure. ## Pending queue projection diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index edf4473bec..15a1a4602f 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -13,7 +13,7 @@ ## Workspace 与 Session 列表 -Workspace 和 Session 列表各自具有单调的 `pending` → `ready` 基线阶段,也有各自的刷新活动/错误状态。列表请求期间到达的增量插入或更新/移除帧与一元变更回显会在其响应之上回放。第一次成功的基线建立 Host 顺序;后续刷新更新行和成员关系,但不改变已经显示的标识之间的相对顺序。已移除的 Workspace id 会保留进程本地删除标记,避免延迟到达的 changed 帧将其复活;重连仍以 `workspace.list` 作为基线。Workspace 新近程度只在两条基线都 ready 后派生,且绝不改变 Workspace 列表顺序。 +Workspace 和 Session 列表各自具有单调的 `pending` → `ready` 基线阶段,也有各自的刷新活动/错误状态。列表请求期间到达的增量插入或更新/移除/顺序帧与一元变更回显会在其响应之上回放。每次成功的 Workspace 基线都会重新建立 Host 持久 Workspace 顺序,因此重连会接纳该客户端离线期间提交的变更。`WorkspacesService.insertBefore` 会立即安装乐观顺序;只有最新一元回声可以替换它,更新的 Host 顺序帧优先于旧回声,而最新请求被拒时会回滚。已移除的 Workspace id 会保留进程本地删除标记,避免延迟到达的 changed 帧将其复活。Workspace 新近程度只在两条基线都 ready 后派生,且绝不改变 Workspace 列表顺序。 `SessionSummary.pendingInteraction` 将阻塞 Session 的实时用户操作分类为 `approval`、`plan-review` 或 `question`。`SessionManager` 依据稳定的请求标识跟踪可应答请求的 requested/resolved mux 帧,即使 `Session` 对象尚未实例化也不例外;实例化前的缓冲会保留每个仍有效的请求,替换回放产生的重复项,并移除已解决的请求,因此打开 Session 时,列表状态始终有一个对应的可应答 `PendingWait`。审批与问题并发时,第一个 pending 问题具有更高的呈现优先级,以匹配 composer 路由;只有满足 plan-review composer 二元呈现约束的请求才会保留独立的 `plan-review` 状态。该状态的作用域限定在连接代次内:断连时清除,mux 打开时的回放只恢复仍处于 pending 的请求。 @@ -31,7 +31,7 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 ## New Session 与 blank 镜像 -`WorkspacesService.connectWorkspace(workspaceId)` 解析 New Session 流程最终落入的会话:先在列表镜像中复用该 workspace 的既有空会话(`blank && cwd == workspace.path && sessionIds.includes(id)`——host 自己的成员规则,绝不只按 cwd,避免劫持 cwd 匹配但未入账的空白会话),未命中则调用 `session.create({workspaceId})`,返回会话 id 由调用方 open。`SessionSummary.blank` 镜像主机派生的空日志位,在客户端只降不升:由 `session.list`/`host/session-added` 帧播种,本地首次获 Host 接受的 `prompt()`(RPC 成功响应时——受理即证明用户消息已入主机日志;首讯被拒则会话保持 blank、保持可复用)与任何 `running: true` 状态帧翻为 false,每次列表重拉重新对齐。列表界面隐藏 blank 行;store 保留全部行。`SessionsService.create` 接受可选的、由调用方预先分配的 SessionId,失败时抛出 `SessionCreateError`(携带 `requestedSessionId`)。 +`WorkspacesService.connectWorkspace(workspaceId)` 解析 New Session 流程最终落入的会话:先在列表镜像中复用该 workspace 的既有空会话(`blank && cwd == workspace.path && sessionIds.includes(id)`——host 自己的成员规则,绝不只按 cwd,避免劫持 cwd 匹配但未入账的空白会话),未命中则调用 `session.create({workspaceId})`,返回会话 id 由调用方 open。共享的 `startSession` 操作优先使用明确指定的 Workspace,其次使用当前 Session 所属 Workspace,再其次使用派生的最近活跃 Workspace;一个 Workspace 都没有时则清空选择,进入空白 New Session 页面。`SessionSummary.blank` 镜像主机派生的空日志位,在客户端只降不升:由 `session.list`/`host/session-added` 帧播种,本地首次获 Host 接受的 `prompt()`(RPC 成功响应时——受理即证明用户消息已入主机日志;首讯被拒则会话保持 blank、保持可复用)与任何 `running: true` 状态帧翻为 false,每次列表重拉重新对齐。列表界面隐藏 blank 行;store 保留全部行。`SessionsService.create` 接受可选的、由调用方预先分配的 SessionId,失败时抛出 `SessionCreateError`(携带 `requestedSessionId`)。 ## 待处理队列投影 diff --git a/packages/client/ui-sidebar/README.i18n.yaml b/packages/client/ui-sidebar/README.i18n.yaml index 92507c838e..7365c8d654 100644 --- a/packages/client/ui-sidebar/README.i18n.yaml +++ b/packages/client/ui-sidebar/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-sidebar/README.md -README.md: 45ae267d98b17bbc612cf932f5b95b42ba6ff4bf -README.zh.md: a9fb927305d0bab5fb4d27adbfdbec90dfa1dd6d +README.md: 10a3bdaf96512124e88b82f643930241671e066d +README.zh.md: 11b0aa142cf62626ab6105e2c405d506e35349b0 diff --git a/packages/client/ui-sidebar/README.md b/packages/client/ui-sidebar/README.md index 45ae267d98..10a3bdaf96 100644 --- a/packages/client/ui-sidebar/README.md +++ b/packages/client/ui-sidebar/README.md @@ -2,11 +2,11 @@ English | [中文](README.zh.md) -Sidebar plugin: real Host Workspaces in stable Host order, each containing its `sessionIds` in Workspace order with `parentId` nesting; Sessions outside every Workspace appear in a trailing `Ungrouped` section. Search, state dots, and collapse into the layout-owned 56px rail are presentation-local. Contract: the [slot system standard](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md). +Sidebar shell plugin: the wordmark, New Session action, layout-owned collapse control, scroll-aware region seat, and bottom-pinned Settings seat. [ui-workspace](../ui-workspace/README.md) owns the Workspace and Session browser rendered into `sidebar.workspaces`; this package neither derives its rows nor owns its view preferences. Collapse into the layout-owned 56px rail remains presentation-local. Contract: the [slot system standard](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md). -New Session starts the runtime's page-local frontend Session Intent; a real Workspace's "+" starts one targeted to that Workspace. The Workspace header "+" opens ui-workspace's shared picker, whose selection also targets a frontend Session. A Workspace Intent does not appear in the sidebar. +New Session starts the runtime's page-local frontend Session Intent. The runtime targets the explicit Workspace used by a scoped action, otherwise the current Session's Workspace, otherwise the most recently active Workspace; when none exists it clears into the blank New Session page. Workspace-specific controls and the shared picker belong to ui-workspace. -`SidebarRootComponentProps` composes the layout owner share, the global `useSessions` and `useWorkspaces` hooks, the declared `sidebar.workspace` and `sidebar.settings` child slots, and injected `startSession`, `open`, and sidebar-toggle callbacks. There is no plugin store: `deriveGroups` consumes object-layer snapshots and component-local expansion/search state. +`SidebarRootComponentProps` composes the layout owner share, the global `useSessions` and `useWorkspaces` hooks, the declared `sidebar.workspaces` and `sidebar.settings` child slots, and injected `startSession` plus sidebar-toggle callbacks. There is no plugin store. Scrollbars in the column are a pointer affordance: the shell rebinds ui-theme's [scrollbar indirection](../ui-theme/README.md) to `transparent` whenever the pointer is outside it, and keeps the thumb drawn for 2s after the pointer leaves, so a list nobody is pointing at carries no bar. The reservation that keeps rows from moving belongs to the scrolling region ([ui-workspace](../ui-workspace/README.md)), so revealing a thumb never reflows. @@ -25,5 +25,5 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work - **Session state-dot rendering is owned by [ui-workspace](../ui-workspace/README.md)** — no done/error notification sources are available. -- **Group-by supports Workspace only** — Update and Status are not available strategies. +- **Workspace browser behavior is composition-owned** — grouping, ordering, search, and row state belong to [ui-workspace](../ui-workspace/README.md), not this shell. - **"New task completed" unread marking is local viewing state** — completion-time > last-seen never reaches the host. diff --git a/packages/client/ui-sidebar/README.zh.md b/packages/client/ui-sidebar/README.zh.md index a9fb927305..11b0aa142c 100644 --- a/packages/client/ui-sidebar/README.zh.md +++ b/packages/client/ui-sidebar/README.zh.md @@ -2,11 +2,11 @@ [English](README.md) | 中文 -侧边栏插件:真实 Host Workspace 按稳定的 Host 顺序排列;每个 Workspace 按自身顺序包含其 `sessionIds`,并以 `parentId` 嵌套;不属于任何 Workspace 的会话显示在末尾的 `Ungrouped` 分区。搜索、状态点以及折叠到布局拥有的 56px 轨道,都只属于呈现层。约定:[slot 系统标准](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md)。 +侧边栏外壳插件:负责字标、New Session 操作、布局持有的折叠控件、可感知滚动的区域 seat,以及固定在底部的 Settings seat。[ui-workspace](../ui-workspace/README.md) 持有渲染到 `sidebar.workspaces` 的 Workspace 与 Session 浏览器;本包既不派生其中的行,也不持有其视图偏好。折叠到布局拥有的 56px 轨道仍属于本地呈现行为。约定:[slot 系统标准](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md)。 -New Session 会启动运行时的页面局部前端 Session Intent;真实 Workspace 的「+」会启动一项以该 Workspace 为目标的 Intent。Workspace 标题栏的「+」打开 ui-workspace 的共享选择器,选择结果同样以一个前端会话为目标。Workspace Intent 不会出现在侧边栏中。 +New Session 会启动运行时的页面局部前端 Session Intent。运行时优先使用作用域操作明确指定的 Workspace,否则使用当前 Session 所属 Workspace,再否则使用最近活跃 Workspace;一个 Workspace 都没有时则清空选择,进入空白 New Session 页面。Workspace 专属控件与共享选择器由 ui-workspace 持有。 -`SidebarRootComponentProps` 组合布局 owner share、全局 `useSessions` 和 `useWorkspaces` 钩子、已声明的 `sidebar.workspace` 与 `sidebar.settings` 子 slot,以及注入的 `startSession`、`open` 和侧边栏切换回调。这里没有插件 store:`deriveGroups` 消费对象层快照与组件局部的展开/搜索状态。 +`SidebarRootComponentProps` 组合布局 owner share、全局 `useSessions` 和 `useWorkspaces` 钩子、已声明的 `sidebar.workspaces` 与 `sidebar.settings` 子 slot,以及注入的 `startSession` 与侧边栏切换回调。这里没有插件 store。 栏内的滚动条是一种指针可供性:只要指针不在栏内,外壳就把 ui-theme 的[滚动条间接层](../ui-theme/README.md)重新绑定为 `transparent`;指针离开后滑块再保留 2 秒,因此没人指向的列表不会带着滚动条。避免行位移的空间预留属于滚动区域本身([ui-workspace](../ui-workspace/README.md)),所以显示滑块不会引起重排。 @@ -25,5 +25,5 @@ New Session 会启动运行时的页面局部前端 Session Intent;真实 Work ## 已知限制与暂缓事项 - **Session 状态点渲染由 [ui-workspace](../ui-workspace/README.md) 持有**:没有可用的 done/error 通知数据源。 -- **分组只支持 Workspace**:Update 和 Status 不是可用策略。 +- **Workspace 浏览行为由组合持有**:分组、排序、搜索与行状态都属于 [ui-workspace](../ui-workspace/README.md),不属于此外壳。 - **「New task completed」未读标记是本地查看状态**:完成时间 > 上次查看时间这一事实永远不会到达宿主。 diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index 1a1dd057f0..bff5c6410a 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: 1ec07bd41e72bb5a26b2cfc3bf90e57e7d92db08 -README.zh.md: 8edd0fed6d3bdefd9df339a8b3d0588533264538 +README.md: f54b8b0b2070c81089be1703b536492522d38774 +README.zh.md: b1977e7f6a67fa7bcb6751d7b214cfff4bfdbb4f diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index 1ec07bd41e..f54b8b0b20 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -4,7 +4,9 @@ English | [中文](README.zh.md) Shared Workspace browser and picker plugin. `WorkspaceBrowser` fills the sidebar's `sidebar.workspaces` slot, while `WorkspacePicker` fills the page-local Session Intent hero's `conversation.hero.workspace` slot; both surfaces use the same Workspace menu and add flow. -The browser renders grouped or flat Session rows from the global runtime hooks and owns the Workspace add/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 browser renders grouped or flat Session rows from the global runtime hooks and owns Workspace add/rename/reorder plus in-Workspace Session reorder. A Workspace remembers whether it is closed or showing Sessions; an open Workspace shows five Sessions by default, offers a transient **Show more** control for the remainder, and returns to five after the whole Workspace is closed and reopened. View options combine grouping with Session order: **Manual** follows the Host Workspace account, while **Last updated** keeps a browser-local editable order and moves a Session to the front whenever a newer `updatedAt` arrives. Workspace drag order is Host-durable in either Session order mode. + +Collapsed search is one header action beside the view and add actions. Activating it expands the field across the header; an outside click collapses only an empty query, while the clear control always resets and collapses it. 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. Distinct canonical paths remain separate id-keyed Workspaces when their basenames and display titles match; the sidebar hover detail exposes the full path. 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 **Add workspace...** 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, under which the sidebar header drops its add button rather than offering a dead one). 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. Adding has exactly one route: the occupant's own create-folder affordance already covers a brand-new directory, so no separate create-by-name dialog exists. A menu only appears where there is something to choose between — with no Workspace listed, the anchor gesture raises the flow directly instead of a one-row popover, and it waits for the list baseline before treating an empty list as final. 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. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index 8edd0fed6d..b1977e7f6a 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -4,7 +4,9 @@ 共享 Workspace 浏览器与选择器插件。`WorkspaceBrowser` 填充侧边栏的 `sidebar.workspaces` slot,`WorkspacePicker` 则填充页面局部 Session Intent 主视觉区的 `conversation.hero.workspace` slot;两个界面使用同一套 Workspace 菜单和添加流程。 -该浏览器通过全局运行时钩子将 Session 行渲染为分组或扁平形式,并负责 Workspace 添加/重命名和 Workspace 内的重排序流程。非空白查询会以单一扁平结果列表替代任一浏览模式:不区分大小写的标题和 Workspace 子串匹配项会立即显示,经 250 ms 防抖的 Host 请求则会加入经过排序的当前对话内容匹配项及其摘要片段。英文搜索输入框及其防御性请求路径会移除 NUL,将查询限制在传输 schema 规定的 500 个 UTF-16 代码单元内且不会拆分代理项对,并保留现有的防抖与取消行为。每次新查询都会中止前一个请求;内容搜索失败时,元数据匹配项仍会显示,同时给出警告。列表最多显示 20 条结果,并会在查询过宽时提示用户缩小范围;打开所选 Session 时既不会清除查询,也不会跳转至特定事件。 +该浏览器通过全局运行时钩子将 Session 行渲染为分组或扁平形式,并负责 Workspace 添加/重命名/重排序以及 Workspace 内的 Session 重排序。每个 Workspace 会记住自身是关闭还是显示 Session;打开后默认显示五条 Session,其余条目通过临时的**展开其余**控件显示,而关闭并重新打开整个 Workspace 后会恢复为五条。视图选项把分组方式和 Session 顺序放在一起:**手动排序**遵循 Host Workspace 记账顺序,**最近更新**则维护可编辑的浏览器本地顺序,并在收到更大的 `updatedAt` 时把该 Session 移到首位。无论采用哪种 Session 顺序,Workspace 拖拽顺序都由 Host 持久化。 + +折叠搜索是视图和添加操作旁的一枚区头按钮。激活后,输入框会扩展并占据区头;点击外部只会收起空查询,而清除控件总会重置并收起搜索。非空白查询会以单一扁平结果列表替代任一浏览模式:不区分大小写的标题和 Workspace 子串匹配项会立即显示,经 250 ms 防抖的 Host 请求则会加入经过排序的当前对话内容匹配项及其摘要片段。英文搜索输入框及其防御性请求路径会移除 NUL,将查询限制在传输 schema 规定的 500 个 UTF-16 代码单元内且不会拆分代理项对,并保留现有的防抖与取消行为。每次新查询都会中止前一个请求;内容搜索失败时,元数据匹配项仍会显示,同时给出警告。列表最多显示 20 条结果,并会在查询过宽时提示用户缩小范围;打开所选 Session 时既不会清除查询,也不会跳转至特定事件。 该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。不同的规范化路径即使 basename 和显示标题相同,仍会作为由 id 区分的独立 Workspace;侧边栏的悬停详情会显示完整路径。每个注册各自声明一个**目录流子 slot**(`single` kind:`conversation.hero.workspace.directoryFlow`/`sidebar.workspaces.directoryFlow`),由组合的选择器包 client half 填入其选取交互——今天是 [`-native`](../../host/directory-picker-native/README.md) 后端的无渲染 OS 选择器驱动,`-browse` 组合下则是应用内浏览对话框。平铺显示的 **添加工作区…** 操作仅在当前界面的 slot 被占用时渲染(每次菜单渲染读取占用状态;slot 为空意味着该组合没有目录选择能力——seam 文档化的无流程默认行为,此时侧边栏区头直接不渲染添加按钮,而非留下一个点了没反应的按钮)。本包持有触发与接纳:占用方通过 slot 的属主交互约定(`open`/`busy`/`onPicked`/`onCancel`/`onError`)每次打开上报一个所选路径,owner 通过对象层接纳它,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,错误落入可重试的文件夹对话框,其 **重新选择** 会重新打开流程。添加只有一条路径:占用者自带的新建文件夹能力已经覆盖了全新目录,因此不再单设按名称创建的对话框。菜单只在确有多个目标可选时出现——没有 Workspace 可列时,锚点手势直接拉起流程,而不是弹出只有一行的浮层;在列表基线落地前,空列表不算最终结果。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。Session 行内的 Rename 操作打开同款浏览器持有的对话框,并以该行的显示标题预填:客户端不设名称冲突规则(host 负责规范化,可能以 `title-invalid` 拒绝,错误渲染在对话框告警区);确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。Session 行内的 Archive 操作不经确认对话框直接提交(非破坏性:日志和 workspace 记账席位保持不变),通过 `ctx.workspaces.archiveSession` 归档;归档集合回声落地后,该行从所有分组视图——workspace 分组、Ungrouped、内容搜索和平铺列表——中消失,失败只作为控制台诊断输出,树保持不变。空白的「新会话」行只是占位符:不渲染行菜单和时间标签(其中还没有发生任何事),重命名、fork 和归档都从首条提示词落地后才可用。 diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 72568f241d..aa648fdc00 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: 5fe19af8069766c56f8926ccef88dc1d9fb3c950 -README.zh.md: bdb26a63832c1461b4e56798e64c1253a916118d +README.md: f7023407c4fad559847f71e56804fe0737e09966 +README.zh.md: 2502f550c8bf76137337d879b5013ba38e734bb6 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 5fe19af806..f7023407c4 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -40,7 +40,7 @@ Pending queued input is a live control-plane contract, not conversation history. Background tasks ride the same live-push posture. When `ctx.tasks` is composed, the gateway subscribes to its change feed and broadcasts a whole `session/tasks` snapshot after every registry commit that alters what a session can see — registration, the stopping transition, settlement, and owner-disposal removal — plus a subscription baseline for each session that already has tasks (an absent baseline is the empty set; a change that empties a set still sends `[]`). A change carrying an owner reads through that exact `Agent`, so a push stays correct while its scope tears down; the baseline reads `ctx.agents.get(sessionId)`, which yields only unowned tasks for a session with no live Agent and never resumes a cold one. An unowned change fans out to every subscribed session, because unowned tasks are visible to every caller. The wire `TaskView` drops `ownerSession`, `reported`, and `outputLimitBytes`: the frame's own `sessionId` carries the first, and the other two are internal notice and model-presentation policy. A composition without the registry emits no such frames. -Workspace and Session lists are separate reconnect baselines. `workspace.create({ path })` adopts an existing canonical directory and permits basename-derived titles to repeat. `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()`. +Workspace and Session lists are separate reconnect baselines. `workspace.create({ path })` adopts an existing canonical directory and permits basename-derived titles to repeat. `workspace.insertBefore({ workspaceId, beforeWorkspaceId? })` commits one registry-order move and answers the complete order; a pure reorder emits `host/workspace-order-changed` with that complete order, while unknown sources or anchors return `workspace-not-found`. `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 bdb26a6383..2502f550c8 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -40,7 +40,7 @@ Settings 分节中的 `reasoningEffort` 在 agent-default-model 插件配置中 后台任务沿用同一种实时推送姿态。当组合中有 `ctx.tasks` 时,网关订阅它的变更订阅,并在注册表每一次改变某个会话可见内容的提交后——注册、转入 stopping、结算,以及 owner 销毁时的移除——广播一份完整的 `session/tasks` 快照,另外为每个已经有任务的会话发送订阅 baseline(没有 baseline 即表示空集;把集合清空的那次变更仍然发送 `[]`)。带 owner 的变更通过那个确切的 `Agent` 读取,因此推送在其 scope 拆除期间依然正确;baseline 读 `ctx.agents.get(sessionId)`,对没有活体 Agent 的会话只得到无主任务,且绝不恢复冷会话。无主变更向每一个已订阅会话扇出,因为无主任务对所有调用方可见。线路上的 `TaskView` 丢弃 `ownerSession`、`reported` 和 `outputLimitBytes`:第一个由帧自身的 `sessionId` 携带,另外两个分别是内部通知位和模型呈现策略。没有该注册表的组合不发出这类帧。 -Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create({ path })` 会接纳已有的规范目录,并允许由 basename 派生的标题重复。`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()` 中。 +Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create({ path })` 会接纳已有的规范目录,并允许由 basename 派生的标题重复。`workspace.insertBefore({ workspaceId, beforeWorkspaceId? })` 提交一次注册表顺序移动并应答完整顺序;单纯重排序会通过 `host/workspace-order-changed` 推送同一份完整顺序,而未知来源或锚点返回 `workspace-not-found`。`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` 服务请求全局排序后的当前内容视图中的 user、assistant 和 steering 匹配项,并持续消费该结果流,直到获得至多 20 个可见会话/snippet 对及一个前瞻项;返回前仍会依据从列表推导的授权集合重新校验每个命中。提供方分页初始请求 20 个命中;如果第一页请求因这一上限被拒绝,网关会依次探测 10、5、2、1,并在续传和陈旧世代重启中沿用探测所得的页面大小。返回的 snippet 最多包含 240 个 Unicode 码点,响应 schema 则会在每个客户端边界独立强制执行该上限。将授权集合保留在宿主内存中,可在不削弱可见性或排序的前提下避开有效大型语料库的 SQLite 变量上限。 diff --git a/packages/self-modification/tool-cordis/src/api-catalog.ts b/packages/self-modification/tool-cordis/src/api-catalog.ts index 1c310ab8e0..e0c4500cfb 100644 --- a/packages/self-modification/tool-cordis/src/api-catalog.ts +++ b/packages/self-modification/tool-cordis/src/api-catalog.ts @@ -1384,6 +1384,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: 'insertBefore(id: WorkspaceId, beforeId?: WorkspaceId): Promise', + jsDoc: '/**\n * Move one workspace within the durable display order, DOM-insertBefore-like.\n * With an anchor it lands before that workspace; without one it appends.\n * @param id - Workspace to move.\n * @param beforeId - Workspace anchor; omitted appends.\n * @returns the complete committed workspace order.\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 */', diff --git a/packages/workspace/workspace/README.i18n.yaml b/packages/workspace/workspace/README.i18n.yaml index 63d5ce0e8e..caeadcc3d8 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: 057765e38de9cc700210eb8edeb1ddc7ffc861ff -README.zh.md: 7416875dbf2ee1652f6e1fa1663144d7407a1ae7 +README.md: 4f7e2925ca7572dc3cc32c2a294bd1f40b243254 +README.zh.md: 2f4f38dea881b2c8a2bb135c8f7b1b3c88b9190a diff --git a/packages/workspace/workspace/README.md b/packages/workspace/workspace/README.md index 057765e38d..4f7e2925ca 100644 --- a/packages/workspace/workspace/README.md +++ b/packages/workspace/workspace/README.md @@ -10,9 +10,10 @@ The entity/storage rationale lives in the [domain Agent Note](../../../.agents/n - `ctx.workspace.create(path, title?)` — canonicalizes `path` via `fs.realpath`, rejects a nonexistent or non-directory path, creates at most one record per canonical path, and prepends a new record to durable workspace order. Repeated calls for that path return the existing workspace without changing its title; different paths may share a display title. - `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.insertBefore(id, before?)` — moves a registered Workspace within durable registry order, DOM-insertBefore-like: before the anchor, or appended when the anchor is omitted. A source or anchor absent from the registry rejects without writing; a self-anchor or move to the current position resolves without writing. The returned id list is the complete committed order. - `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. -- `Workspace.insertSessionBefore(id, before?)` — moves an accounted session within the manual order, DOM-insertBefore-like: before the anchor, or appended when the anchor is omitted. A session or anchor absent from the account rejects without writing; a move to the current position resolves without writing. Workspace order never changes. +- `Workspace.insertSessionBefore(id, before?)` — moves an accounted session within the manual order, DOM-insertBefore-like: before the anchor, or appended when the anchor is omitted. A session or anchor absent from the account rejects without writing; a move to the current position resolves without writing. Registry 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 7416875dbf..2f4f38dea8 100644 --- a/packages/workspace/workspace/README.zh.md +++ b/packages/workspace/workspace/README.zh.md @@ -10,9 +10,10 @@ DeepSeek Harness 的 Workspace 实体注册表(`ctx.workspace`):通过领 - `ctx.workspace.create(path, title?)`:规范化 `path` 时使用 `fs.realpath`,拒绝不存在或非目录的路径,每个规范路径最多创建一条记录,并将新记录前置到持久 workspace 顺序。对同一路径重复调用会返回现有 workspace,且不改变其标题;不同路径可以共用显示标题。 - `ctx.workspace.get(id)`/`list()`/`resolveByPath(path)`:由缓存提供的查找。`list()` 为同步操作,并遵循持久注册表顺序;`resolveByPath` 为异步操作,因为它采用相同的 `realpath` 规范化方式,并会拒绝缺失路径,而不是创建路径。 +- `ctx.workspace.insertBefore(id, before?)`:在持久注册表顺序内移动一个已注册 Workspace,语义类似 DOM 的 insertBefore:插到锚点之前,省略锚点则追加到末尾。来源或锚点不在注册表中时拒绝且不写入;以自身为锚点或移动到当前位置时直接完成且不写入。返回的 id 列表是完整的已提交顺序。 - `ctx.workspace.delete(id)`:只移除 Workspace 注册记录、对应的持久顺序条目及会话归属记录。未知 id 返回 `false`,成功移除记录则返回 `true`。目录、用户文件、活跃会话和持久化会话日志绝不受影响,因此相关会话会进入 Ungrouped。表写入失败时会恢复原顺序和此前发布的实体。 - `Workspace.attachSession(id)`:对照 workspace 路径验证实时或已持久化的会话头 cwd,并将新 id 前置。未知会话、缺失/无法解析/非目录的 cwd 值和不匹配情况都会在不写入的前提下被拒绝。`detachSession` 只移除候选索引条目。 -- `Workspace.insertSessionBefore(id, before?)`:在手动顺序内移动一个已记账的会话,语义类似 DOM 的 insertBefore:插到锚点之前,省略锚点则追加到末尾。会话或锚点不在记账中时拒绝且不写入;移动到当前位置时直接完成且不写入。Workspace 顺序绝不改变。 +- `Workspace.insertSessionBefore(id, before?)`:在手动顺序内移动一个已记账的会话,语义类似 DOM 的 insertBefore:插到锚点之前,省略锚点则追加到末尾。会话或锚点不在记账中时拒绝且不写入;移动到当前位置时直接完成且不写入。注册表中的 Workspace 顺序绝不改变。 - `ctx.workspace.archiveSession(id)`/`archivedSessionIds`:覆盖在 workspace 记账之上的注册表级全局归档集合:被归档的会话从各分组视图中消失,但其会话日志和 `sessionIds` 席位保持不变,未来取消归档时可恢复原位置。归档接受任何实时或已持久化的会话(无论已记账还是 Ungrouped),对已归档的 id 直接完成而不写入,并拒绝未知 id。在该字段出现之前写入的状态解析为一个空集合。 - `Workspace.sessionIds`:按持久候选顺序提供同步 id 加规范 cwd 成员投影。缺失头部、无效 cwd 值和不匹配情况都被过滤;下一次 workspace 变更会剪除它们。如果同一存储介质将一个会话索引到两个 workspace 下、用两条记录声明同一路径,或偏离持久 workspace 顺序,启动会被拒绝。 - `Workspace.status()`:未缓存的目录检查,返回 `'ok' | 'missing-dir'`;目录缺失绝不会改动记录。 From d672eace2f42918a5ca2459704c757bb1ac7a6d8 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 11 Aug 2026 15:42:44 +0800 Subject: [PATCH 19/81] style(client): refine drag insertion markers --- .../src/client/SidebarRoot.module.css | 4 +++ .../ui-sidebar/tests/sidebar-styles.spec.ts | 4 +++ .../src/client/WorkspaceBrowser.module.css | 26 ++++++++++++++----- .../src/client/rows/Rows.module.css | 24 +++++++++++------ .../ui-workspace/tests/browser-styles.spec.ts | 18 +++++++++++++ 5 files changed, 62 insertions(+), 14 deletions(-) diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css index 2cc99e3159..17333b5ccc 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css @@ -215,12 +215,16 @@ min-height: 0; display: flex; flex-direction: column; + margin-left: -4px; margin-right: calc(-1 * var(--dsh-sidebar-inline-padding)); + padding-left: 4px; overflow: hidden; } .collapsed .regionArea { + margin-left: 0; margin-right: 0; + padding-left: 0; } /* Foot seat: a pure layout socket pinned under the region; the ui-settings diff --git a/packages/client/ui-sidebar/tests/sidebar-styles.spec.ts b/packages/client/ui-sidebar/tests/sidebar-styles.spec.ts index 63721258c9..c4abce1911 100644 --- a/packages/client/ui-sidebar/tests/sidebar-styles.spec.ts +++ b/packages/client/ui-sidebar/tests/sidebar-styles.spec.ts @@ -30,9 +30,13 @@ describe('SidebarRoot.module.css inset', () => { const root = declarations('.root') expect(root?.get('--dsh-sidebar-inline-padding')).toBe('12px') expect(root?.get('padding')).toBe('6px var(--dsh-sidebar-inline-padding)') + expect(declarations('.regionArea')?.get('margin-left')).toBe('-4px') + expect(declarations('.regionArea')?.get('padding-left')).toBe('4px') expect(declarations('.regionArea')?.get('margin-right')).toBe( 'calc(-1 * var(--dsh-sidebar-inline-padding))', ) + expect(declarations('.collapsed .regionArea')?.get('margin-left')).toBe('0') + expect(declarations('.collapsed .regionArea')?.get('padding-left')).toBe('0') expect(declarations('.collapsed .regionArea')?.get('margin-right')).toBe('0') }) }) diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css b/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css index 1431096afe..ee79d4b56a 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css @@ -296,12 +296,16 @@ min-height: 0; display: flex; flex-direction: column; + margin-left: -4px; margin-right: calc(-1 * var(--dsh-session-list-edge-inset)); + padding-left: 4px; overflow: hidden; } .rail .listArea { + margin-left: 0; margin-right: 0; + padding-left: 0; } /* Relative for the bottom fade overlay. */ @@ -342,7 +346,9 @@ flex: 1; min-height: 0; overflow-y: auto; + margin-left: -4px; margin-right: var(--dsh-session-list-scrollbar-offset); + padding-left: 4px; padding-right: calc( var(--dsh-session-list-edge-inset) - var(--dsh-session-list-scrollbar-width) @@ -386,20 +392,28 @@ content: ''; position: absolute; z-index: 1; - left: 4px; + left: -4px; right: 4px; - height: 2px; - border-radius: 999px; - background: var(--dsw-alias-state-business-primary); + height: 12px; + background: + radial-gradient( + circle at 6px 6px, + transparent 0 3px, + var(--dsw-alias-state-business-primary) 3px 5px, + transparent 5px + ), + linear-gradient( + var(--dsw-alias-state-business-primary) 0 0 + ) 10px 5px / calc(100% - 10px) 2px no-repeat; pointer-events: none; } .workspaceDropBefore::before { - top: -3px; + top: -8px; } .workspaceDropAfter::after { - bottom: -3px; + bottom: -8px; } .sessionOverflowButton { diff --git a/packages/client/ui-workspace/src/client/rows/Rows.module.css b/packages/client/ui-workspace/src/client/rows/Rows.module.css index 548407fd40..528cc16f59 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.module.css +++ b/packages/client/ui-workspace/src/client/rows/Rows.module.css @@ -236,8 +236,8 @@ background: var(--dsw-alias-interactive-bg-hover); } -/* Session drag insert line: an independent 2px rule between rows, absolutely - positioned so it neither resembles a row border nor changes layout. */ +/* Session drag insert marker: a hollow leading dot and 2px rule between rows, + absolutely positioned so it neither resembles a row border nor changes layout. */ .sessionRow.dropBefore, .sessionRow.dropAfter { position: relative; @@ -248,20 +248,28 @@ content: ''; position: absolute; z-index: 1; - left: 4px; + left: 0; right: 4px; - height: 2px; - border-radius: 999px; - background: var(--dsw-alias-state-business-primary); + height: 12px; + background: + radial-gradient( + circle at 6px 6px, + transparent 0 3px, + var(--dsw-alias-state-business-primary) 3px 5px, + transparent 5px + ), + linear-gradient( + var(--dsw-alias-state-business-primary) 0 0 + ) 10px 5px / calc(100% - 10px) 2px no-repeat; pointer-events: none; } .sessionRow.dropBefore::before { - top: -2px; + top: -7px; } .sessionRow.dropAfter::after { - bottom: -2px; + bottom: -7px; } /* Hover-card body (figma 169:16903): dark surface, fixed colors both themes. */ diff --git a/packages/client/ui-workspace/tests/browser-styles.spec.ts b/packages/client/ui-workspace/tests/browser-styles.spec.ts index 86abd521bc..9930aea0b1 100644 --- a/packages/client/ui-workspace/tests/browser-styles.spec.ts +++ b/packages/client/ui-workspace/tests/browser-styles.spec.ts @@ -48,9 +48,13 @@ describe('WorkspaceBrowser.module.css list', () => { expect(root?.get('--dsh-session-list-scrollbar-width')).toBe('8px') expect(root?.get('--dsh-session-list-scrollbar-offset')).toBe('2px') expect(root?.get('padding-right')).toBe('var(--dsh-session-list-edge-inset)') + expect(listArea?.get('margin-left')).toBe('-4px') + expect(listArea?.get('padding-left')).toBe('4px') expect(listArea?.get('margin-right')).toBe('calc(-1 * var(--dsh-session-list-edge-inset))') expect(declarations('.fade')?.get('right')).toBe('var(--dsh-session-list-edge-inset)') expect(list?.get('margin-right')).toBe('var(--dsh-session-list-scrollbar-offset)') + expect(list?.get('margin-left')).toBe('-4px') + expect(list?.get('padding-left')).toBe('4px') expect(list?.get('padding-right')).toBe([ 'calc(', 'var(--dsh-session-list-edge-inset)', @@ -72,6 +76,20 @@ describe('WorkspaceBrowser.module.css list', () => { expect(declarations('.groupSection + .groupSection')?.get('margin-top')).toBe('4px') }) + it('draws drag targets as a hollow leading dot joined to the insertion line', () => { + const workspaceMarker = declarations('.workspaceDropBefore::before') + const sessionMarker = rowDeclarations('.sessionRow.dropBefore::before') + expect(workspaceMarker?.get('left')).toBe('-4px') + expect(sessionMarker?.get('left')).toBe('0') + for (const marker of [workspaceMarker, sessionMarker]) { + expect(marker?.get('height')).toBe('12px') + expect(marker?.get('background')).toContain('radial-gradient') + expect(marker?.get('background')).toContain('linear-gradient') + expect(marker?.get('background')).toContain('var(--dsw-alias-state-business-primary) 3px 5px') + expect(marker?.get('background')).toContain('10px 5px / calc(100% - 10px) 2px') + } + }) + it('keeps the compact fade, overflow control, search field, and row heights', () => { expect(declarations('.fade')?.get('height')).toBe('24px') expect(declarations('.sessionOverflowButton')?.get('height')).toBe('28px') From ad1d690dd4744d4f63db665e8070d6f09650728b Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Tue, 11 Aug 2026 15:54:37 +0800 Subject: [PATCH 20/81] fix(bundle): record base README pairing and drop the deleted patch path from the note --- .../2026-08-11-loader-entry-disabled-interpolation.i18n.yaml | 4 ++-- .../process/2026-08-11-loader-entry-disabled-interpolation.md | 2 +- .../2026-08-11-loader-entry-disabled-interpolation.zh.md | 2 +- packages/bundle/base/README.i18n.yaml | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.agents/notes/implemented/process/2026-08-11-loader-entry-disabled-interpolation.i18n.yaml b/.agents/notes/implemented/process/2026-08-11-loader-entry-disabled-interpolation.i18n.yaml index 0a40f082d8..92b67825c5 100644 --- a/.agents/notes/implemented/process/2026-08-11-loader-entry-disabled-interpolation.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-11-loader-entry-disabled-interpolation.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/process/2026-08-11-loader-entry-disabled-interpolation.md -2026-08-11-loader-entry-disabled-interpolation.md: c916c6b667fc85f68e79e33edf5a1a63921b2d71 -2026-08-11-loader-entry-disabled-interpolation.zh.md: b5f5a527dcdfe2a906001e31aa4a32884e67f3ab +2026-08-11-loader-entry-disabled-interpolation.md: 63be14ac5b25a3189f97e0283c36c29bfbcb5cec +2026-08-11-loader-entry-disabled-interpolation.zh.md: 74f14443221bbae5b3d602c3819d611783b59670 diff --git a/.agents/notes/implemented/process/2026-08-11-loader-entry-disabled-interpolation.md b/.agents/notes/implemented/process/2026-08-11-loader-entry-disabled-interpolation.md index c916c6b667..63be14ac5b 100644 --- a/.agents/notes/implemented/process/2026-08-11-loader-entry-disabled-interpolation.md +++ b/.agents/notes/implemented/process/2026-08-11-loader-entry-disabled-interpolation.md @@ -6,7 +6,7 @@ English | [中文](2026-08-11-loader-entry-disabled-interpolation.zh.md) ## Problem -The Windows platform layer (then a separate `packages/bundle/base/windows.cordis.patch.yml`, since folded into the base patch — see Decision) disabled `tool-bash` on win32, but the shipped presets each mount a `tool-bash` row. Preset rows compose last, so the same-id row re-enabled the tool on Windows — the session had both `tool-bash` (PowerShell-backed) and `tool-pwsh`, silently, because no spec pinned the composed preset layer. Entry metadata had no conditional mechanism: `!!js` interpolates only under plugin `config`, and [postmortem 0002](../../../../docs/postmortem/0002-js-expression-disabled-filesystem-tools.md) documents that `disabled: !!js ...` stays a truthy expression object, disabling the row everywhere. +The Windows platform layer (then a separate `windows.cordis.patch.yml` beside the base patch, since folded into the base rows — see Decision) disabled `tool-bash` on win32, but the shipped presets each mount a `tool-bash` row. Preset rows compose last, so the same-id row re-enabled the tool on Windows — the session had both `tool-bash` (PowerShell-backed) and `tool-pwsh`, silently, because no spec pinned the composed preset layer. Entry metadata had no conditional mechanism: `!!js` interpolates only under plugin `config`, and [postmortem 0002](../../../../docs/postmortem/0002-js-expression-disabled-filesystem-tools.md) documents that `disabled: !!js ...` stays a truthy expression object, disabling the row everywhere. ## Decision diff --git a/.agents/notes/implemented/process/2026-08-11-loader-entry-disabled-interpolation.zh.md b/.agents/notes/implemented/process/2026-08-11-loader-entry-disabled-interpolation.zh.md index b5f5a527dc..74f1444322 100644 --- a/.agents/notes/implemented/process/2026-08-11-loader-entry-disabled-interpolation.zh.md +++ b/.agents/notes/implemented/process/2026-08-11-loader-entry-disabled-interpolation.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -Windows 平台层(当时是独立的 `packages/bundle/base/windows.cordis.patch.yml`,现已折入 base patch——见「决策」)在 win32 上禁用 `tool-bash`,但 shipped 预设各自挂载了一行 `tool-bash`。预设行最后组合,同名行在 Windows 上重新启用了该工具——会话同时拥有 `tool-bash`(PowerShell 后端)与 `tool-pwsh`,且是静默的,因为没有 spec pin 组合后的预设层。条目元数据没有条件机制:`!!js` 只在插件 `config` 下插值,[postmortem 0002](../../../../docs/postmortem/0002-js-expression-disabled-filesystem-tools.md) 记录了 `disabled: !!js ...` 保持真值表达式对象、在所有平台上禁用该行的事故。 +Windows 平台层(当时是 base patch 旁独立的 `windows.cordis.patch.yml`,现已折入 base 行——见「决策」)在 win32 上禁用 `tool-bash`,但 shipped 预设各自挂载了一行 `tool-bash`。预设行最后组合,同名行在 Windows 上重新启用了该工具——会话同时拥有 `tool-bash`(PowerShell 后端)与 `tool-pwsh`,且是静默的,因为没有 spec pin 组合后的预设层。条目元数据没有条件机制:`!!js` 只在插件 `config` 下插值,[postmortem 0002](../../../../docs/postmortem/0002-js-expression-disabled-filesystem-tools.md) 记录了 `disabled: !!js ...` 保持真值表达式对象、在所有平台上禁用该行的事故。 ## 决策 diff --git a/packages/bundle/base/README.i18n.yaml b/packages/bundle/base/README.i18n.yaml index 502cdf16d4..3e15c33837 100644 --- a/packages/bundle/base/README.i18n.yaml +++ b/packages/bundle/base/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/bundle/base/README.md -README.md: 8b0db20274036a2601da19617a35e6bf4aeb30ca -README.zh.md: ac5ab10a523fa211c1c1daf4c55d4dc8702eb782 +README.md: bd38f39f58ee1f765ff34d40cf57cc6daed2b32b +README.zh.md: 2c6ff8513bae2b7d4b595733e83223bb7af34780 From 31768a348ee037160a609d87031979a5a248be69 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 11 Aug 2026 15:57:34 +0800 Subject: [PATCH 21/81] fix(client): update recency from user messages --- .../runtime/src/client/sessions/manager.ts | 16 +++++++ packages/client/runtime/tests/manager.spec.ts | 42 ++++++++++++++++++- 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index a6e7bf867c..c114be3c66 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -71,6 +71,7 @@ type SessionListMutation = | { kind: 'upsert'; summary: SessionSummary } | { kind: 'remove'; sessionId: SessionId } | { kind: 'status'; sessionId: SessionId; running: boolean } + | { kind: 'activity'; sessionId: SessionId; updatedAt: number } /** Local first-send flip: the sender clears blank without waiting for a host frame. */ | { kind: 'engaged'; sessionId: SessionId } @@ -685,6 +686,16 @@ export class SessionManager { handleMuxEnvelope(envelope: RpcRequest): void { const frame = envelope.payload if (frame.type === 'stream/error') return // Controller already treats this as stream failure + if ( + frame.type === 'session/event' + && frame.event.type === 'user/message' + && frame.event.data.source.kind === 'user' + ) { + // session.list supplies the cold baseline, while a direct prompt or an + // admitted steer advances it between pulls. Max keeps replayed or + // repaired older user messages from moving the row backwards. + this.recordMutation({ kind: 'activity', sessionId: frame.sessionId, updatedAt: frame.event.time }) + } if (frame.type === 'session/projection') { // Finished host-computed value: land it in the resident store whether or // not the Session is instantiated (list rows read the 'title' key). The @@ -1115,6 +1126,11 @@ function applyMutation(summaries: readonly SessionSummary[], mutation: SessionLi && (summary.running !== mutation.running || (mutation.running && summary.blank)) ? { ...summary, running: mutation.running, blank: summary.blank && !mutation.running } : summary) + case 'activity': + return summaries.map(summary => summary.sessionId === mutation.sessionId + && mutation.updatedAt > summary.updatedAt + ? { ...summary, updatedAt: mutation.updatedAt } + : summary) case 'engaged': return summaries.map(summary => summary.sessionId === mutation.sessionId && summary.blank ? { ...summary, blank: false } diff --git a/packages/client/runtime/tests/manager.spec.ts b/packages/client/runtime/tests/manager.spec.ts index 7eba55fd64..cd6e6b9a3a 100644 --- a/packages/client/runtime/tests/manager.spec.ts +++ b/packages/client/runtime/tests/manager.spec.ts @@ -7,7 +7,7 @@ import { describe, expect, it, vi } from 'vitest' import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' import { SessionManager } from '../src/client/sessions/manager.ts' import { FakeApiClient, deferred, err, ok } from './fake-api.ts' -import { entries, plainTurn } from './event-script.ts' +import { entries, ev, plainTurn } from './event-script.ts' const S1 = 'fk-m1' as SessionId const S2 = 'fk-m2' as SessionId @@ -113,6 +113,46 @@ describe('list lifecycle', () => { expect(manager.getListSnapshot().items.map(item => item.sessionId)).toEqual([S2, S1]) }) + it('advances list activity only for direct user messages', async () => { + const api = new FakeApiClient() + api.onList = () => Promise.resolve(ok({ items: [summary(S1)] as never[] })) + const manager = new SessionManager(api) + await manager.refreshList() + + // Both a new prompt and an admitted steer land as a user-sourced message. + const activity = { ...ev.user(10, 'new'), time: 500 } + manager.handleMuxEnvelope({ + rpcId: 'activity' as never, + payload: { type: 'session/event', sessionId: S1, event: activity }, + }) + expect(manager.getListSnapshot().items[0]?.updatedAt).toBe(500) + + manager.handleMuxEnvelope({ + rpcId: 'older' as never, + payload: { type: 'session/event', sessionId: S1, event: { ...activity, time: 400 } }, + }) + manager.handleMuxEnvelope({ + rpcId: 'assistant' as never, + payload: { type: 'session/event', sessionId: S1, event: { ...ev.assistant(11, 0, 'reply'), time: 600 } }, + }) + + const injected = ev.user(12, 'context') + if (injected.type !== 'user/message') throw new Error('user builder returned another event type') + manager.handleMuxEnvelope({ + rpcId: 'injected' as never, + payload: { + type: 'session/event', + sessionId: S1, + event: { + ...injected, + time: 700, + data: { ...injected.data, source: { kind: 'plugin', plugin: 'test' } }, + }, + }, + }) + expect(manager.getListSnapshot().items[0]?.updatedAt).toBe(500) + }) + it('keeps the error in the list snapshot on failure', async () => { const api = new FakeApiClient() api.onList = () => Promise.resolve(err({ code: 'internal', message: 'boom', details: {} })) From 2ad471123d99acb337ceec6a47ed91342e75ea0f Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 11 Aug 2026 15:57:43 +0800 Subject: [PATCH 22/81] fix(client): persist workspace drag order --- .../runtime/src/client/workspaces/manager.ts | 17 ++++-- .../runtime/tests/workspaces-service.spec.ts | 6 +++ .../src/client/WorkspaceBrowser.tsx | 54 +++++++++++++------ .../tests/workspace-browser.spec.tsx | 28 ++++++++++ 4 files changed, 84 insertions(+), 21 deletions(-) diff --git a/packages/client/runtime/src/client/workspaces/manager.ts b/packages/client/runtime/src/client/workspaces/manager.ts index 89f96295ef..6f2e95d351 100644 --- a/packages/client/runtime/src/client/workspaces/manager.ts +++ b/packages/client/runtime/src/client/workspaces/manager.ts @@ -173,10 +173,19 @@ export class WorkspaceManager { const frameGeneration = this.orderFrameGeneration const previousOrder = this.itemViews().map(workspace => workspace.workspaceId) this.installOrder(insertIdBefore(previousOrder, workspaceId, beforeWorkspaceId)) - const { result } = await this.api.workspace.insertBefore({ - workspaceId, - ...beforeWorkspaceId === undefined ? {} : { beforeWorkspaceId }, - }) + let result: RpcResult<{ workspaceIds: WorkspaceId[] }> + try { + ;({ result } = await this.api.workspace.insertBefore({ + workspaceId, + ...beforeWorkspaceId === undefined ? {} : { beforeWorkspaceId }, + })) + } catch (error) { + if (requestGeneration === this.orderRequestGeneration + && frameGeneration === this.orderFrameGeneration) { + this.installOrder(previousOrder) + } + throw error + } if (result.ok && requestGeneration === this.orderRequestGeneration && frameGeneration === this.orderFrameGeneration) { this.installOrder(result.value.workspaceIds) diff --git a/packages/client/runtime/tests/workspaces-service.spec.ts b/packages/client/runtime/tests/workspaces-service.spec.ts index 2c4ed551d8..db80e98337 100644 --- a/packages/client/runtime/tests/workspaces-service.spec.ts +++ b/packages/client/runtime/tests/workspaces-service.spec.ts @@ -107,6 +107,12 @@ describe('WorkspaceManager', () => { expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'two', 'three']) await expect(rejected).resolves.toMatchObject({ ok: false }) expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'three', 'two']) + + api.onWorkspaceInsertBefore = () => Promise.reject(new Error('transport down')) + const disconnected = manager.insertBefore(wid('three'), wid('one')) + expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['three', 'one', 'two']) + await expect(disconnected).rejects.toThrow('transport down') + expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'three', 'two']) }) it('replays removal over an in-flight baseline and ignores duplicate or late updates', async () => { diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx index 637b89c162..eeb47e2cb1 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx @@ -200,9 +200,10 @@ function SessionTree({ const [drag, setDrag] = useState(null) const sessionDropCommitted = useRef(false) const [workspaceDrag, setWorkspaceDrag] = useState(null) - const sessionDragging = drag !== null + const workspaceDropCommitted = useRef(false) + const nativeDragActive = drag !== null || workspaceDrag !== null useEffect(() => { - if (!sessionDragging) return + if (!nativeDragActive) return // Row hover still owns the insertion marker. Accept the native drag at // document level so releasing outside the list is not rendered as a // rejected drop before dragend commits that last marker. @@ -217,7 +218,7 @@ function SessionTree({ document.removeEventListener('dragover', acceptDrag) document.removeEventListener('drop', acceptDrop) } - }, [sessionDragging]) + }, [nativeDragActive]) const currentGroup = current === undefined ? undefined : (workspaces.find(w => w.sessionIds.includes(current))?.workspaceId as string | undefined) @@ -310,6 +311,26 @@ function SessionTree({ console.warn('session reorder rejected:', reason) }) } + const commitWorkspaceDrag = ( + activeDrag: WorkspaceDragState, + over: NonNullable, + ): void => { + if (workspaceDropCommitted.current) return + workspaceDropCommitted.current = true + setWorkspaceDrag(null) + const rowIndex = workspaces.findIndex(workspace => workspace.workspaceId === over.id) + if (rowIndex === -1) return + const anchor = over.half === 'before' ? over.id : workspaces[rowIndex + 1]?.workspaceId + if (anchor === activeDrag.workspaceId) return + const sourceIndex = workspaces.findIndex(workspace => workspace.workspaceId === activeDrag.workspaceId) + const anchorIndex = anchor === undefined + ? workspaces.length + : workspaces.findIndex(workspace => workspace.workspaceId === anchor) + if (sourceIndex !== -1 && (anchorIndex === sourceIndex || anchorIndex === sourceIndex + 1)) return + insertWorkspaceBefore(activeDrag.workspaceId, anchor).catch((reason: unknown) => { + console.warn('workspace reorder rejected:', reason) + }) + } return (
@@ -323,8 +344,18 @@ function SessionTree({ ? workspaceDrag.over.half : null const workspaceDragProps = workspaceId === undefined ? undefined : { - start: () => { setWorkspaceDrag({ workspaceId, over: null }) }, - end: () => { setWorkspaceDrag(null) }, + start: () => { + workspaceDropCommitted.current = false + setWorkspaceDrag({ workspaceId, over: null }) + }, + end: () => { + if (workspaceDrag?.over !== null && workspaceDrag?.over !== undefined) { + commitWorkspaceDrag(workspaceDrag, workspaceDrag.over) + } else { + setWorkspaceDrag(null) + } + workspaceDropCommitted.current = false + }, } const hoverWorkspace = workspaceId === undefined ? undefined @@ -337,18 +368,7 @@ function SessionTree({ ? undefined : (half: 'before' | 'after') => { if (workspaceDrag === null) return - const rowIndex = workspaces.findIndex(workspace => workspace.workspaceId === workspaceId) - const anchor = half === 'before' ? workspaceId : workspaces[rowIndex + 1]?.workspaceId - setWorkspaceDrag(null) - if (anchor === workspaceDrag.workspaceId) return - const sourceIndex = workspaces.findIndex(workspace => workspace.workspaceId === workspaceDrag.workspaceId) - const anchorIndex = anchor === undefined - ? workspaces.length - : workspaces.findIndex(workspace => workspace.workspaceId === anchor) - if (sourceIndex !== -1 && (anchorIndex === sourceIndex || anchorIndex === sourceIndex + 1)) return - insertWorkspaceBefore(workspaceDrag.workspaceId, anchor).catch((reason: unknown) => { - console.warn('workspace reorder rejected:', reason) - }) + commitWorkspaceDrag(workspaceDrag, { id: workspaceId, half }) } return ( // Group section: header row + expanded top-level session rows. The diff --git a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx index ca48733dda..414353f684 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx @@ -629,6 +629,34 @@ describe('WorkspaceBrowser', () => { expect(insertWorkspaceBefore).toHaveBeenCalledWith(wid('tail'), wid('beta')) }) + it('accepts a document-level drop and commits the last Workspace marker on drag end', () => { + const insertWorkspaceBefore = vi.fn(async () => {}) + mount({ + useWorkspaces: hook(workspaceState([ + workspace('alpha', []), + workspace('beta', []), + workspace('tail', []), + ])), + insertWorkspaceBefore, + }) + const source = screen.getByText('tail').closest('[role="treeitem"]') as HTMLElement + let target = screen.getByText('beta').closest('[role="treeitem"]')?.parentElement as HTMLElement + while (target.parentElement?.getAttribute('role') !== 'tree') { + target = target.parentElement as HTMLElement + } + target.getBoundingClientRect = () => ({ + top: 100, bottom: 134, left: 0, right: 200, width: 200, height: 34, x: 0, y: 100, toJSON: () => ({}), + }) + fireEvent.dragStart(source, { dataTransfer: dragData() }) + fireDrag(target, 'dragOver', 105) + const outsideDrop = createEvent.drop(document.body) + Object.defineProperty(outsideDrop, 'dataTransfer', { value: dragData() }) + fireEvent(document.body, outsideDrop) + expect(outsideDrop.defaultPrevented).toBe(true) + fireEvent.dragEnd(source) + expect(insertWorkspaceBefore).toHaveBeenCalledWith(wid('tail'), wid('beta')) + }) + it('drag reorder reports the anchor to insertSessionBefore and skips no-op drops', () => { const insertSessionBefore = vi.fn(async () => {}) const sessions = sessionState([summary('one', 3), summary('two', 2), summary('three', 1)]) From ebe932e24c02b2afefd416a9ddee580cc6d8e7cd Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 11 Aug 2026 15:59:43 +0800 Subject: [PATCH 23/81] fix(subprocess): clean managed processes on host exit --- ...chronous-subprocess-exit-cleanup.i18n.yaml | 6 + ...-11-synchronous-subprocess-exit-cleanup.md | 51 ++++++ ...-synchronous-subprocess-exit-cleanup.zh.md | 51 ++++++ .../subprocess-local/README.i18n.yaml | 4 +- .../subprocess/subprocess-local/README.md | 4 +- .../subprocess/subprocess-local/README.zh.md | 4 +- .../subprocess/subprocess-local/package.json | 1 + .../subprocess/subprocess-local/src/index.ts | 89 ++++++--- .../subprocess/subprocess-local/src/spawn.ts | 16 +- .../subprocess-local/src/terminal.ts | 37 ++++ .../tests/fixtures/managed-tree.ts | 16 ++ .../tests/fixtures/process-exit-host.ts | 79 ++++++++ .../subprocess-local/tests/local.spec.ts | 95 ++++++++++ .../tests/process-exit.spec.ts | 169 ++++++++++++++++++ .../subprocess-local/tests/spawn.spec.ts | 36 ++++ .../subprocess-local/tests/terminal.spec.ts | 80 +++++++++ pnpm-lock.yaml | 3 + vitest.config.ts | 25 ++- 18 files changed, 728 insertions(+), 38 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.zh.md create mode 100644 packages/subprocess/subprocess-local/tests/fixtures/managed-tree.ts create mode 100644 packages/subprocess/subprocess-local/tests/fixtures/process-exit-host.ts create mode 100644 packages/subprocess/subprocess-local/tests/process-exit.spec.ts diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.i18n.yaml new file mode 100644 index 0000000000..622b1b983c --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.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/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md +2026-08-11-synchronous-subprocess-exit-cleanup.md: e120f87350f1acd28f7f449791f01b6c0a57674e +2026-08-11-synchronous-subprocess-exit-cleanup.zh.md: 6cf7620ed006c22b79b523242fa5f1429c58a2f3 diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md b/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md new file mode 100644 index 0000000000..e120f87350 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md @@ -0,0 +1,51 @@ +# Agent Note: Synchronous cleanup of managed subprocesses on host exit + +Status: implemented + +English | [中文](2026-08-11-synchronous-subprocess-exit-cleanup.zh.md) + +## Problem + +The local subprocess provider owns ordinary detached process trees and terminal sessions, but it previously reached them only through asynchronous Cordis disposal. A fatal launcher may call `process.exit()` before that disposal finishes: the [fail-loud release](2026-07-31-fail-loud-releases-the-terminal.md) waits at most two seconds, while a local process can have a longer termination grace. Once Node enters its synchronous exit phase, pending promises and escalation timers do not continue, so a TERM-resistant child can outlive the host and keep CPU, memory, or ports. Some ACP, JSON-RPC, and SDK entry points also have no root release callback. + +The public subprocess seam correctly promises awaited quiescence during normal disposal. The defect is a separate final host-exit path below that seam, not a reason to weaken the normal lifecycle or duplicate process ownership in every launcher. + +## Decision + +`LocalSubprocessService` installs one synchronous Node `exit` listener in its Cordis effect. The same effect removes the listener only after normal disposal settles. Ordinary and terminal handles remain in the service's existing live sets while asynchronous cleanup is pending, so a shorter outer exit bound still sees and force-terminates them. If awaited disposal reports a cleanup failure, the service invokes the same synchronous final operations before clearing the sets and removing the listener. + +The listener uses local-only final operations that are absent from the public `SubprocessHandle` and `SubprocessTerminalHandle` interfaces: + +- An ordinary handle immediately sends SIGKILL to its detached POSIX process group or runs synchronous `taskkill /PID /T /F` on Windows. +- A terminal handle synchronously signals every captured and currently observable descendant with SIGKILL, kills the PTY root, then rescans once for members that became observable during that boundary. +- The service contains each target's failure and continues with the remaining handles. The callback creates no promise or timer, writes no diagnostic, and does not change the original exit code or error. + +Normal disposal remains the [subprocess seam's](../architecture/2026-07-26-subprocess-seam.md) terminate-and-join path: ordinary trees receive TERM, the configured grace, then KILL, and every ordinary or terminal cleanup is awaited to quiescence. The synchronous path requests final termination but does not publish a completion result or claim the OS tree is already gone when the callback returns. Remote providers retain their own sandbox ownership and do not inherit a local Node listener. + +| Host path | Local provider action | Completion evidence | +| --- | --- | --- | +| Normal Cordis disposal | Cooperative termination, bounded escalation, and awaited ordinary/terminal cleanup | Every owned handle reaches quiescence before disposal settles | +| `process.exit()`, default uncaught exception, or default unhandled rejection | Synchronous final signals against the service's current live sets | External observation after the host exits | +| `SIGKILL`, fatal OOM, `process.abort()`, native crash, or power loss | No in-process action can run | External supervisor, container, or OS ownership is required | + +## Verification + +A parent test starts an isolated TypeScript host through the repository source launcher, waits until exact root and descendant process identities are observable, then allows the host to take each fatal path. Direct exit, default uncaught exception, and default unhandled rejection cover ordinary TERM-resistant trees; direct exit also covers a real terminal root and descendant. The parent asserts the original host exit category and waits for every recorded process to disappear, while failure cleanup targets only recorded identities or the recorded Windows tree. + +Unit evidence pins synchronous POSIX group and Windows taskkill delivery, terminal scans before and after the PTY root kill, repeated finalization, per-target failure containment, normal TERM-to-KILL disposal, live-set retention during pending disposal, and listener removal after disposal. + +## Alternatives considered + +**Rely only on launcher release callbacks.** Rejected because not every entry point supplies one, and a bounded release can still end before the subprocess provider's grace and timers complete. + +**Call the existing asynchronous `terminate()` methods from the `exit` listener.** Rejected because Node does not await exit listeners; promises, timers, output draining, and quiescence polling cannot finish after the callback returns. + +**Add a public raw `forceKill()` operation to subprocess handles.** Rejected because consumers need one cooperative termination contract. Immediate final termination is an implementation responsibility used only by the local service's host-exit owner. + +**Delegate every failure mode to an external supervisor.** Rejected as the only solution because Node exposes a reliable synchronous callback for several common fatal paths and the provider already owns the exact targets. External ownership remains necessary when JavaScript cannot run. + +## Consequences + +Each active local subprocess service contributes one process-global exit listener, removed with the service effect. Fatal exit gives up grace, output draining, and an in-process quiescence proof in exchange for issuing the strongest available local termination before the host disappears. Normal disposal keeps those guarantees and costs unchanged. + +The listener cannot cover failures that do not execute JavaScript, and it cannot discover a terminal descendant that escaped before the provider ever observed it; that separate ownership gap remains tracked by Issue #1726. diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.zh.md b/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.zh.md new file mode 100644 index 0000000000..6cf7620ed0 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.zh.md @@ -0,0 +1,51 @@ +# Agent Note: 宿主退出时同步清理受管子进程 + +Status: implemented + +[English](2026-08-11-synchronous-subprocess-exit-cleanup.md) | 中文 + +## Problem + +本地 subprocess provider拥有普通 detached进程树和 terminal session,但此前只能通过异步 Cordis dispose触及它们。致命 launcher可能在 dispose完成前调用 `process.exit()`:[fail-loud release](2026-07-31-fail-loud-releases-the-terminal.md)最多等待两秒,而本地进程可以拥有更长的终止宽限期。Node进入同步退出阶段后,待处理的 Promise与升级 timer不会继续执行,因此忽略 TERM的子进程可能比宿主存活更久,继续占用 CPU、内存或端口。部分 ACP、JSON-RPC和 SDK入口也没有 root release回调。 + +公共 subprocess seam在正常 dispose期间承诺等待完全停稳,这项承诺是正确的。缺陷属于 seam之下另一条最终宿主退出路径,不应削弱正常生命周期,也不应让每个 launcher重复保存进程所有权。 + +## Decision + +`LocalSubprocessService`在自身 Cordis effect中安装一个同步 Node `exit` listener。只有正常 dispose结算后,同一 effect才移除该 listener。异步清理仍在等待时,普通和 terminal handle继续保留在服务已有的存活集合中,因此更短的外层退出上限仍能看到并强制终止它们。等待中的 dispose报告清理失败时,服务会在清空集合并移除 listener前调用同一组同步最终操作。 + +该 listener使用本地实现私有的最终操作;公共 `SubprocessHandle`和 `SubprocessTerminalHandle`接口不包含这些操作: + +- 普通 handle立即向 detached POSIX进程组发送 SIGKILL,或在 Windows同步运行 `taskkill /PID /T /F`。 +- Terminal handle同步向全部已捕获及当前可观察的后代发送 SIGKILL,终止 PTY root,然后再扫描一次并终止在该边界期间变得可观察的成员。 +- 服务分别包含每个目标的失败并继续处理其余 handle。回调不会创建 Promise或 timer,不写诊断,也不改变原始退出码或错误。 + +正常 dispose继续使用[subprocess seam](../architecture/2026-07-26-subprocess-seam.md)的先终止再等待退出路径:普通进程树先接收 TERM,经过配置的宽限期后再接收 KILL,并等待每个普通或 terminal清理达到完全停稳。同步路径只请求最终终止,不发布完成结果,也不声称回调返回时 OS进程树已经消失。远程 provider继续由其 sandbox独立拥有,不继承本地 Node listener。 + +| 宿主路径 | 本地 provider动作 | 完成证据 | +| --- | --- | --- | +| 正常 Cordis dispose | 协作式终止、有界升级,并等待普通/terminal清理 | dispose结算前,每个自有 handle均达到完全停稳 | +| `process.exit()`、默认未捕获异常或默认未处理 rejection | 对服务当前存活集合发送同步最终信号 | 宿主退出后的外部观察 | +| `SIGKILL`、fatal OOM、`process.abort()`、native crash或断电 | 进程内操作无法运行 | 必须由外部 supervisor、容器或 OS所有权负责 | + +## Verification + +父测试通过仓库 source launcher启动隔离的 TypeScript宿主,等待精确 root与后代进程身份可观察后,再允许宿主进入各条致命路径。直接退出、默认未捕获异常和默认未处理 rejection覆盖忽略 TERM的普通进程树;直接退出还覆盖真实 terminal root与后代。父测试断言原始宿主退出类别,并等待所有已记录进程消失;失败清理只针对已记录身份或已记录的 Windows进程树。 + +单元证据固定同步 POSIX进程组与 Windows taskkill投递、PTY root终止前后的 terminal扫描、重复最终清理、逐目标失败包含、正常 TERM到 KILL dispose、dispose等待期间保留存活集合,以及 dispose后移除 listener。 + +## Alternatives considered + +**只依赖 launcher release回调。** 拒绝,因为不是每个入口都会提供该回调,而且有界 release仍可能在 subprocess provider的宽限期与 timer完成前结束。 + +**在 `exit` listener中调用现有异步 `terminate()`。** 拒绝,因为 Node不会等待 exit listener;回调返回后,Promise、timer、输出排空与停稳轮询都无法完成。 + +**向公共 subprocess handle增加 raw `forceKill()`操作。** 拒绝,因为消费方只需要一项协作式终止约定。立即最终终止属于实现职责,只由本地服务的宿主退出 owner使用。 + +**把所有故障模式交给外部 supervisor。** 不接受将其作为唯一方案,因为 Node为几条常见致命路径提供可靠的同步回调,而 provider已经拥有精确目标。JavaScript无法运行时仍必须依赖外部所有权。 + +## Consequences + +每个有效的本地 subprocess service都会贡献一个进程全局 exit listener,并随服务 effect移除。致命退出放弃宽限、输出排空与进程内停稳证明,以换取宿主消失前发出本地可用的最强终止操作。正常 dispose的保证与成本保持不变。 + +listener无法覆盖不执行 JavaScript的故障,也无法发现 provider首次观察前已经逃逸的 terminal后代;该独立所有权缺口仍由 Issue #1726跟踪。 diff --git a/packages/subprocess/subprocess-local/README.i18n.yaml b/packages/subprocess/subprocess-local/README.i18n.yaml index a9be76e0c0..ca13399e55 100644 --- a/packages/subprocess/subprocess-local/README.i18n.yaml +++ b/packages/subprocess/subprocess-local/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/subprocess/subprocess-local/README.md -README.md: 15fc001b9fcf2eadd7b37415fd92386565b649c3 -README.zh.md: 48b662302183f07d514f975089d4b49bd17c8e69 +README.md: 40c01caa3daf00d490e935bd828e15ce8fa7fb68 +README.zh.md: edafa0e030cd2af3308bcdea3a0e190de4448125 diff --git a/packages/subprocess/subprocess-local/README.md b/packages/subprocess/subprocess-local/README.md index 15fc001b9f..40c01caa3d 100644 --- a/packages/subprocess/subprocess-local/README.md +++ b/packages/subprocess/subprocess-local/README.md @@ -12,7 +12,8 @@ Local Service provider for the [`@deepseek-ai/dsh-subprocess`](../subprocess/REA - **Offset-based reads** — collect-mode readers return deltas in whole-stream byte coordinates; the service never holds a cursor, so consumer-owned cursors (the bash background read path) and full-stream re-reads coexist, before and after settlement. - **Executable lookup** — `resolveExecutable` checks absolute files or searches the scrubbed effective PATH with platform-aware executable extensions; relative paths containing separators are rejected at the seam, and relative PATH entries resolve from the host process cwd. - **Terminal-process ownership** — `spawnTerminal` allocates `node-pty`, bridges UTF-8 terminal text, inspects and signals the current foreground process group, and exposes one awaited termination operation that sweeps descendants before and after terminating the top-level shell. Each foreground inspection retains exact identities from the rooted tree; Linux also enumerates the POSIX session after its leader exits. A previously observed macOS descendant and any same-session Linux member therefore remain fenced after reparenting, while pid/start identity prevents cleanup from following PID reuse. The higher PTY backend owns prompt readiness, buffers, and model-facing operations. -- **Terminate-and-join disposal** — the service retains live handles only so its own disposal can escalate every running tree and await its exit; settled and spawn-failed handles leave the live set on settlement. +- **Terminate-and-join disposal** — the service retains live handles so its own disposal can escalate every running tree and await its exit; quiescent and spawn-failed handles leave the live set after whole-tree or terminal-session cleanup finishes. +- **Synchronous host-exit finalization** — while the service effect is active, a Node `exit` listener force-terminates every ordinary tree and observable terminal session still in the same live sets. The local-only operations send POSIX SIGKILL to the managed group, run Windows `taskkill /T /F`, and synchronously signal captured/current terminal identities around the PTY root kill; they create no promise or timer, preserve the host's exit code and diagnostic, contain each target's failure, and do not claim quiescence. Normal disposal keeps the awaited graceful path above. See the [host-exit cleanup decision](../../../.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md). ## Model Experience @@ -27,6 +28,7 @@ No direct invalidation; the named consumers own any request-prefix changes. - **Windows tree support is best-effort** — termination routes through `taskkill /PID /T /F` with all outcomes contained (absent tree, races, missing binary), and liveness falls back to the direct-child boundary. - **Terminal process inspection is Linux/macOS only** — the terminal primitive fails when its inspector has no supported platform implementation; Linux exact probes cover x64 and arm64, while macOS uses `ps` snapshots. - **A daemonized terminal descendant can still escape the observable boundary** — on macOS, a child that reparents before any foreground-inspection snapshot is no longer discoverable from the `node-pty` root; on Linux, a child that calls `setsid` leaves both the tree and owned terminal session. The local provider does not add a continuous process-table monitor. +- **In-process cleanup requires a JavaScript-observable exit** — direct `process.exit()`, default uncaught exceptions, and default unhandled rejections emit Node's synchronous `exit` event. `SIGKILL`, fatal OOM, `process.abort()`, native crashes, power loss, and any failure that cannot run JavaScript require an external supervisor, container init, or equivalent OS owner. - **The credential scrub is a name heuristic** — `*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*` only; differently-named secrets (e.g. `*PASSPHRASE*`) pass through, and a whitelist for over-scrubbed vars is noted future work. - **Completed spill files are not deleted** — bounded full-output recovery files (and the private per-process spill dir) accumulate under the OS tmpdir until something external cleans them; oversize incomplete spills are discarded and deletion is attempted immediately, but a cleanup failure can leave a bounded file behind. diff --git a/packages/subprocess/subprocess-local/README.zh.md b/packages/subprocess/subprocess-local/README.zh.md index 48b6623021..edafa0e030 100644 --- a/packages/subprocess/subprocess-local/README.zh.md +++ b/packages/subprocess/subprocess-local/README.zh.md @@ -12,7 +12,8 @@ - **基于偏移量的读取**:收集模式的读取器按完整流的字节坐标返回增量;服务自身从不持有游标,因此消费方自有的游标(bash 的后台读取路径)与完整流重读可以共存,结算前后皆然。 - **可执行文件查找**:`resolveExecutable` 检查绝对文件,或根据平台可执行文件扩展名在清理后的有效 PATH 中搜索;含分隔符的相对路径在该能力入口被拒绝,相对 PATH 条目从宿主进程 cwd 解析。 - **终端进程所有权**:`spawnTerminal` 分配 `node-pty`,桥接 UTF-8 终端文本,检查当前前台进程组并向其发送信号,还会公开一项须等待的终止操作,在终止顶层 shell 前后清理后代进程。每次前台检查都会保留根进程树中的精确身份;Linux 还会在 POSIX 会话 leader 退出后枚举该会话。因此,之前观察到的 macOS 后代以及同会话 Linux 成员在重新设定父进程后仍受围栏保护,pid/start 身份则防止清理跟随 PID 复用。上层 PTY 后端负责提示符就绪、缓冲区与面向模型的操作。 -- **先终止再等待退出的 dispose(资源释放)**:服务保留存活句柄,只为让自身的 dispose 能对每个仍在运行的进程树执行升级并等待其退出;已结算与 spawn 失败的句柄在结算时即离开存活集合。 +- **先终止再等待退出的 dispose(资源释放)**:服务保留存活句柄,使自身的 dispose 能对每个仍在运行的进程树执行升级并等待其退出;完全停稳与 spawn失败的句柄会在整棵进程树或 terminal session清理完成后离开存活集合。 +- **同步宿主退出最终清理**:服务 effect仍有效时,Node `exit` listener会强制终止同一组存活集合中仍存在的每棵普通进程树和可观察 terminal session。这些仅供本地实现使用的操作会向受管 POSIX进程组发送 SIGKILL、在 Windows运行 `taskkill /T /F`,并在终止 PTY root前后同步向已捕获及当前可观察的 terminal身份发送信号;它们不会创建 Promise或 timer,不改变宿主退出码与诊断,会分别包含每个目标的失败,也不会声称已经完全停稳。正常 dispose仍使用上面的须等待温和路径。参见[宿主退出清理决策](../../../.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md)。 ## 模型体验 @@ -27,6 +28,7 @@ - **Windows 进程树支持仅为尽力而为**:终止经由 `taskkill /PID /T /F` 完成,所有结果都被就地吸收,不向外抛出(进程树已不存在、竞态、二进制缺失),存活探测则回退到直接子进程边界。 - **终端进程检查仅支持 Linux/macOS**:检查器没有受支持的平台实现时,终端原语会失败;Linux 精确探针覆盖 x64 与 arm64,macOS 则使用 `ps` 快照。 - **守护化的终端后代仍可能逃出可观察边界**:在 macOS 上,子进程如果在任何前台检查快照之前重新设定父进程,将无法再从 `node-pty` 根进程发现;在 Linux 上,调用 `setsid` 的子进程会同时离开进程树与自有终端会话。本地提供方不会新增持续进程表监视器。 +- **进程内清理要求退出阶段仍能执行 JavaScript**:直接 `process.exit()`、默认未捕获异常和默认未处理 rejection会发出 Node同步 `exit`事件。`SIGKILL`、fatal OOM、`process.abort()`、native crash、断电,以及任何无法运行 JavaScript的故障,都需要外部 supervisor、容器 init或等价的 OS所有者负责。 - **凭据清除依赖名称启发式规则**:只匹配 `*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`;名称不同的 secret(例如 `*PASSPHRASE*`)会继续传递,对误删变量引入白名单属于已记录的后续工作。 - **不会删除已完成的 spill 文件**:有界的完整输出恢复文件(以及每个进程的私有 spill 目录)会在 OS tmpdir 下累积,直到外部机制进行清理;超大的不完整 spill 会被丢弃并立即尝试删除,但清理失败可能留下一个有界文件。 diff --git a/packages/subprocess/subprocess-local/package.json b/packages/subprocess/subprocess-local/package.json index 12f47c0400..fb27fc80c4 100644 --- a/packages/subprocess/subprocess-local/package.json +++ b/packages/subprocess/subprocess-local/package.json @@ -46,6 +46,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-loader-smoke": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/cordis": "workspace:^" diff --git a/packages/subprocess/subprocess-local/src/index.ts b/packages/subprocess/subprocess-local/src/index.ts index 5242986b3b..68e781b2af 100644 --- a/packages/subprocess/subprocess-local/src/index.ts +++ b/packages/subprocess/subprocess-local/src/index.ts @@ -1,7 +1,8 @@ /** * Local Service provider for the subprocess capability seam. Each spawn is a detached - * process tree with the spec's per-stream stdio dispositions; disposal - * terminates and joins live trees. It has no config: every disposition and + * process tree with the spec's per-stream stdio dispositions. Normal disposal + * terminates and joins live trees; Node's synchronous exit phase force-stops + * any trees the service still owns. It has no config: every disposition and * limit arrives on the spec, so the deployment-varying choices stay with the * caller's config (the bash executor's, the LSP host's, …). * @module @deepseek-ai/dsh-subprocess-local @@ -21,7 +22,7 @@ import type { SubprocessTerminalSpawnSpec, } from '@deepseek-ai/dsh-subprocess' import { childEnv, spawnSubprocess } from './spawn.ts' -import type { SpawnInternals } from './spawn.ts' +import type { LocalSubprocessHandle, SpawnInternals } from './spawn.ts' import { createProcessInspector } from './process-inspector.ts' import type { ProcessInspector } from './process-inspector.ts' import { LocalTerminalHandle } from './terminal.ts' @@ -30,13 +31,14 @@ import { LocalTerminalHandle } from './terminal.ts' * Local subprocess service: detached process trees, Node-shaped stdio * dispositions (raw pipes, inherit, bounded tail-keep collection with spill * files), credential-scrubbed environment, and tree-scoped signalling with - * SIGTERM→grace→SIGKILL escalation. + * SIGTERM→grace→SIGKILL escalation, plus synchronous final termination during + * JavaScript-observable host exit. */ export class LocalSubprocessService extends SubprocessService { - /** Live handles retained only so disposal can terminate and join them. */ - private live = new Set() - /** Live terminal sessions retained through whole-session quiescence. */ - private terminals = new Set() + /** Live handles retained for normal disposal and synchronous host-exit finalization. */ + private live = new Set() + /** Live terminals retained through normal quiescence or host-exit finalization. */ + private terminals = new Set() /** Test hook: spill and platform knobs forwarded to spawnSubprocess. */ internals: SpawnInternals = {} /** Test hook for platform process inspection; production resolves lazily on terminal spawn. */ @@ -44,30 +46,61 @@ export class LocalSubprocessService extends SubprocessService { constructor(ctx: Context) { super(ctx) - ctx.effect(() => async () => { - // Terminate (escalating), then await WHOLE-TREE exit — not just the - // direct child's settlement — so even a TERM-trapping descendant cannot - // outlive the fiber. - const pending: Promise[] = [] - for (const handle of this.live) { - handle.terminate() - // Spawn-failure rejections already settled and left the live set. - pending.push(handle.done.catch(() => {}).then(() => handle.waitForExit())) + ctx.effect(() => { + const onHostExit = (): void => { this.terminateForHostExit() } + process.on('exit', onHostExit) + return async () => { + try { + await this.disposeManagedProcesses() + } finally { + process.off('exit', onHostExit) + } } - for (const terminal of this.terminals) { - pending.push(terminal.terminate()) - } - this.live.clear() - this.terminals.clear() - const outcomes = await Promise.allSettled(pending) - const failures = outcomes.flatMap(outcome => outcome.status === 'rejected' - ? [outcome.reason as unknown] - : []) - if (failures.length === 1) throw failures[0] - if (failures.length > 1) throw new AggregateError(failures, 'local subprocess teardown failed') }, 'local subprocess teardown') } + private terminateForHostExit(): void { + for (const handle of this.live) { + try { + handle.terminateForHostExit() + } catch (_ordinaryTreeTerminationFailed) { + // Host exit cannot await or report one target; continue with the rest. + } + } + for (const terminal of this.terminals) { + try { + terminal.terminateForHostExit() + } catch (_terminalTerminationFailed) { + // One terminal must not prevent final termination of another target. + } + } + } + + private async disposeManagedProcesses(): Promise { + // Terminate (escalating), then await WHOLE-TREE exit — not just the + // direct child's settlement — so even a TERM-trapping descendant cannot + // outlive the fiber. Keep both sets authoritative while these waits are + // pending so a shorter process-level exit bound can still force-kill them. + const pending: Promise[] = [] + for (const handle of this.live) { + handle.terminate() + // Spawn-failure rejections already settled and left the live set. + pending.push(handle.done.catch(() => {}).then(() => handle.waitForExit())) + } + for (const terminal of this.terminals) { + pending.push(terminal.terminate()) + } + const outcomes = await Promise.allSettled(pending) + const failures = outcomes.flatMap(outcome => outcome.status === 'rejected' + ? [outcome.reason as unknown] + : []) + if (failures.length > 0) this.terminateForHostExit() + this.live.clear() + this.terminals.clear() + if (failures.length === 1) throw failures[0] + if (failures.length > 1) throw new AggregateError(failures, 'local subprocess teardown failed') + } + async resolveExecutable( command: string, env?: Readonly>, diff --git a/packages/subprocess/subprocess-local/src/spawn.ts b/packages/subprocess/subprocess-local/src/spawn.ts index 5b977cacc1..91db98f944 100644 --- a/packages/subprocess/subprocess-local/src/spawn.ts +++ b/packages/subprocess/subprocess-local/src/spawn.ts @@ -58,6 +58,15 @@ export interface SpawnInternals { linuxProcessGroupHasLiveMembers?: (processGroupId: number) => boolean | undefined } +/** + * Local-only extension used by the owning service during Node's synchronous + * host-exit phase. It is intentionally absent from the public subprocess seam. + */ +export interface LocalSubprocessHandle extends SubprocessHandle { + /** Force-terminate the current tree synchronously without starting timers or waits. */ + terminateForHostExit(): void +} + /** * Liveness-poll cadence for tree-exit waits. The timer stays ref'd: an * awaited teardown must keep the event loop alive until the tree really @@ -313,7 +322,7 @@ function signalTree( * @returns live subprocess handle. * @throws when `graceMs` cannot be represented by one Node timer. */ -export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInternals = {}): SubprocessHandle { +export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInternals = {}): LocalSubprocessHandle { if (!Number.isFinite(spec.graceMs) || spec.graceMs <= 0 || spec.graceMs > MAX_TIMER_DELAY_MS) { throw new Error(`subprocess graceMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`) } @@ -442,6 +451,10 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter graceTimer = setTimeout(() => { kill('SIGKILL') }, spec.graceMs) } + const terminateForHostExit = (): void => { + kill('SIGKILL') + } + // The caller owns timeout classification; this layer only reacts to abort. const onAbort = (): void => { terminate() } spec.signal?.addEventListener('abort', onAbort, { once: true }) @@ -523,6 +536,7 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter }, done, terminate, + terminateForHostExit, waitForExit, } } diff --git a/packages/subprocess/subprocess-local/src/terminal.ts b/packages/subprocess/subprocess-local/src/terminal.ts index 11d13a405a..6d818c8a7f 100644 --- a/packages/subprocess/subprocess-local/src/terminal.ts +++ b/packages/subprocess/subprocess-local/src/terminal.ts @@ -110,6 +110,33 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { return cleanup } + /** + * Force-terminate the observable session synchronously during Node's exit + * event. This does not claim quiescence and does not replace terminate(). + */ + terminateForHostExit(): void { + this.forceStopDescendants() + this.forceStopShell() + this.forceStopDescendants() + } + + private forceStopShell(): void { + if (this.exited) return + if (this.rootIdentity !== undefined) { + try { + this.inspector.signalProcess(this.rootIdentity, 'SIGKILL') + } catch (_rootExitedDuringHostExit) { + // Exact identity signalling contains both exit races and PID reuse. + } + return + } + try { + this.terminal.kill('SIGKILL') + } catch (_unidentifiedShellExitedDuringHostExit) { + // Without a captured identity, node-pty is the only root kill primitive. + } + } + private survivors(members: ProcessIdentity[]): ProcessIdentity[] { return members.filter(member => this.inspector.isAlive(member)) } @@ -152,6 +179,16 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { } } + private forceStopDescendants(): void { + let members = this.trackedDescendants + try { + members = this.descendants() + } catch (_processTableUnavailableDuringHostExit) { + // Preserve already-captured identities when a final process-table scan fails. + } + this.signalMembers(members, 'SIGKILL') + } + private unionMembers(...groups: ProcessIdentity[][]): ProcessIdentity[] { const members: ProcessIdentity[] = [] const seen = new Set() diff --git a/packages/subprocess/subprocess-local/tests/fixtures/managed-tree.ts b/packages/subprocess/subprocess-local/tests/fixtures/managed-tree.ts new file mode 100644 index 0000000000..31d26b9e39 --- /dev/null +++ b/packages/subprocess/subprocess-local/tests/fixtures/managed-tree.ts @@ -0,0 +1,16 @@ +import { spawn } from 'node:child_process' +import { writeFile } from 'node:fs/promises' + +const [statePath] = process.argv.slice(2) +if (statePath === undefined) throw new Error('usage: managed-tree.ts ') + +process.on('SIGTERM', () => {}) +process.on('SIGHUP', () => {}) +const descendant = spawn(process.execPath, [ + '-e', + 'process.on("SIGTERM",()=>{});process.on("SIGHUP",()=>{});setInterval(()=>{},60_000)', +], { stdio: 'ignore' }) +if (descendant.pid === undefined) throw new Error('managed descendant did not publish a pid') + +await writeFile(statePath, JSON.stringify({ root: process.pid, descendant: descendant.pid })) +setInterval(() => {}, 60_000) diff --git a/packages/subprocess/subprocess-local/tests/fixtures/process-exit-host.ts b/packages/subprocess/subprocess-local/tests/fixtures/process-exit-host.ts new file mode 100644 index 0000000000..83b4664cae --- /dev/null +++ b/packages/subprocess/subprocess-local/tests/fixtures/process-exit-host.ts @@ -0,0 +1,79 @@ +import { access, readFile, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { Context } from '@deepseek-ai/cordis' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' + +const [kind, trigger, root] = process.argv.slice(2) +if ((kind !== 'ordinary' && kind !== 'terminal') + || (trigger !== 'direct' && trigger !== 'uncaught-exception' + && trigger !== 'unhandled-rejection' && trigger !== 'dispose') + || root === undefined) { + throw new Error('usage: process-exit-host.ts ') +} + +const treeState = join(root, 'tree.json') +const ready = join(root, 'ready') +const proceed = join(root, 'proceed') +const managedTree = fileURLToPath(new URL('./managed-tree.ts', import.meta.url)) + +async function waitForFile(path: string): Promise { + for (;;) { + try { + await access(path) + return + } catch (_notReady) { + await new Promise(resolve => setTimeout(resolve, 10)) + } + } +} + +const listenersBefore = process.listenerCount('exit') +const ctx = new Context() +const fiber = await ctx.plugin(LocalSubprocessService) +const listenersAfterLoad = process.listenerCount('exit') +if (kind === 'ordinary') { + ctx.subprocess.spawn({ + argv: [process.execPath, managedTree, treeState], + cwd: process.cwd(), + stdio: { + stdin: 'ignore', + stdout: { maxBytes: 1024 }, + stderr: { maxBytes: 1024 }, + }, + graceMs: trigger === 'dispose' ? 100 : 30_000, + }) +} else { + await ctx.subprocess.spawnTerminal({ + argv: [process.execPath, managedTree, treeState], + cwd: process.cwd(), + rows: 24, + cols: 80, + graceMs: 30_000, + }) +} + +await waitForFile(treeState) +const published = JSON.parse(await readFile(treeState, 'utf8')) as { root?: unknown; descendant?: unknown } +if (!Number.isSafeInteger(published.root) || !Number.isSafeInteger(published.descendant)) { + throw new Error('managed tree published invalid process ids') +} +await writeFile(ready, 'ready') +await waitForFile(proceed) + +if (trigger === 'dispose') { + await fiber.dispose() + await writeFile(join(root, 'dispose.json'), JSON.stringify({ + listenersBefore, + listenersAfterLoad, + listenersAfterDispose: process.listenerCount('exit'), + })) +} else if (trigger === 'direct') { + process.exit(23) +} else if (trigger === 'uncaught-exception') { + setImmediate(() => { throw new Error('host-exit-uncaught-exception') }) + await new Promise(() => {}) +} else { + void Promise.reject(new Error('host-exit-unhandled-rejection')) + await new Promise(() => {}) +} diff --git a/packages/subprocess/subprocess-local/tests/local.spec.ts b/packages/subprocess/subprocess-local/tests/local.spec.ts index e3131543f4..22affc50e0 100644 --- a/packages/subprocess/subprocess-local/tests/local.spec.ts +++ b/packages/subprocess/subprocess-local/tests/local.spec.ts @@ -21,6 +21,77 @@ function spec(command: string, overrides: Partial = {}): Su } describe('LocalSubprocessService', () => { + it('keeps the host-exit finalizer active until normal disposal reaches quiescence', async () => { + const before = new Set(process.listeners('exit')) + const ctx = new Context() + const fiber = await ctx.plugin(LocalSubprocessService) + const listener = process.listeners('exit').find(candidate => !before.has(candidate)) + expect(listener).toBeTypeOf('function') + + let finishExit!: () => void + const exited = new Promise((resolve) => { finishExit = resolve }) + const terminate = vi.fn() + const terminateForHostExit = vi.fn() + const live = (ctx.subprocess as unknown as { + live: Set<{ + done: Promise<{ exitCode: number; signal: null }> + terminate(): void + terminateForHostExit(): void + waitForExit(): Promise + }> + }).live + live.add({ + done: Promise.resolve({ exitCode: 0, signal: null }), + terminate, + terminateForHostExit, + waitForExit: async () => { await exited; return true }, + }) + + let disposed = false + const disposing = fiber.dispose().then(() => { disposed = true }) + await new Promise(resolve => setImmediate(resolve)) + expect(disposed).toBe(false) + expect(live.size).toBe(1) + listener?.(0) + expect(terminate).toHaveBeenCalledOnce() + expect(terminateForHostExit).toHaveBeenCalledOnce() + + finishExit() + await disposing + expect(live.size).toBe(0) + expect(process.listeners('exit')).not.toContain(listener) + }) + + it('contains each host-exit termination failure and continues with the other targets', async () => { + const before = new Set(process.listeners('exit')) + const ctx = new Context() + const fiber = await ctx.plugin(LocalSubprocessService) + const listener = process.listeners('exit').find(candidate => !before.has(candidate)) + expect(listener).toBeTypeOf('function') + const ordinaryFailure = vi.fn(() => { throw new Error('ordinary failed') }) + const ordinarySuccess = vi.fn() + const terminalFailure = vi.fn(() => { throw new Error('terminal failed') }) + const terminalSuccess = vi.fn() + const service = ctx.subprocess as unknown as { + live: Set<{ terminateForHostExit(): void }> + terminals: Set<{ terminateForHostExit(): void }> + } + service.live.add({ terminateForHostExit: ordinaryFailure }) + service.live.add({ terminateForHostExit: ordinarySuccess }) + service.terminals.add({ terminateForHostExit: terminalFailure }) + service.terminals.add({ terminateForHostExit: terminalSuccess }) + + expect(() => { listener?.(0) }).not.toThrow() + expect(ordinaryFailure).toHaveBeenCalledOnce() + expect(ordinarySuccess).toHaveBeenCalledOnce() + expect(terminalFailure).toHaveBeenCalledOnce() + expect(terminalSuccess).toHaveBeenCalledOnce() + + service.live.clear() + service.terminals.clear() + await fiber.dispose() + }) + it('resolves absolute and PATH executables and honors lookup cancellation', async () => { const ctx = new Context() const fiber = await ctx.plugin(LocalSubprocessService) @@ -177,6 +248,30 @@ describe('LocalSubprocessService', () => { expect(disposalErrors).toEqual([failure]) }) + it('force-terminates remaining targets before releasing a failed disposal', async () => { + const before = new Set(process.listeners('exit')) + const ctx = new Context() + const fiber = await ctx.plugin(LocalSubprocessService) + const listener = process.listeners('exit').find(candidate => !before.has(candidate)) + expect(listener).toBeTypeOf('function') + const failure = new Error('cleanup failed') + const terminateForHostExit = vi.fn(() => { + expect(process.listeners('exit')).toContain(listener) + }) + const terminal = { + terminate: vi.fn(async () => { throw failure }), + terminateForHostExit, + } + const terminals = (ctx.subprocess as unknown as { terminals: Set }).terminals + terminals.add(terminal) + + await fiber.dispose() + + expect(terminateForHostExit).toHaveBeenCalledOnce() + expect(terminals.size).toBe(0) + expect(process.listeners('exit')).not.toContain(listener) + }) + it('releases a terminal after top-level exit reaches quiescence', async () => { let exitListener: ((event: { exitCode: number; signal?: number }) => void) | undefined const inspector = { diff --git a/packages/subprocess/subprocess-local/tests/process-exit.spec.ts b/packages/subprocess/subprocess-local/tests/process-exit.spec.ts new file mode 100644 index 0000000000..940d21e838 --- /dev/null +++ b/packages/subprocess/subprocess-local/tests/process-exit.spec.ts @@ -0,0 +1,169 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { execa } from 'execa' +import { describe, expect, it, vi } from 'vitest' +import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' +import { createProcessInspector } from '../src/process-inspector.ts' +import type { ProcessIdentity, ProcessInspector } from '../src/process-inspector.ts' +import { taskkillProcessTree } from '../src/spawn.ts' + +type ExitTrigger = 'direct' | 'uncaught-exception' | 'unhandled-rejection' | 'dispose' +type ManagedKind = 'ordinary' | 'terminal' +interface TreeState { root: number; descendant: number } + +const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url)) +const hostScript = fileURLToPath(new URL('./fixtures/process-exit-host.ts', import.meta.url)) +const scenarioTimeoutMs = 30_000 + +function processExists(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch (error: unknown) { + if ((error as NodeJS.ErrnoException).code === 'ESRCH') return false + throw error + } +} + +async function readTree(path: string): Promise { + return vi.waitFor(async () => { + const text = await readFile(path, 'utf8') + const state = JSON.parse(text) as Partial + if (!Number.isSafeInteger(state.root) || !Number.isSafeInteger(state.descendant) + || (state.root ?? 0) <= 0 || (state.descendant ?? 0) <= 0 || state.root === state.descendant) { + throw new Error(`invalid managed-tree state: ${text}`) + } + return state as TreeState + }, { interval: 10, timeout: scenarioTimeoutMs }) +} + +async function captureIdentities(inspector: ProcessInspector, state: TreeState): Promise { + return vi.waitFor(() => { + const expected = new Set([state.root, state.descendant]) + const identities = inspector.processTree(state.root).filter(identity => expected.has(identity.pid)) + if (identities.length !== expected.size) throw new Error('managed tree is not fully observable yet') + return identities + }, { interval: 10, timeout: scenarioTimeoutMs }) +} + +async function waitForGone(state: TreeState): Promise { + await Promise.all([state.root, state.descendant].map(pid => vi.waitFor(() => { + if (processExists(pid)) throw new Error(`managed pid ${pid} is still alive`) + }, { interval: 25, timeout: 10_000 }))) +} + +function cleanupTree(state: TreeState | undefined, identities: ProcessIdentity[]): void { + if (state === undefined) return + if (process.platform === 'win32') { + taskkillProcessTree(state.root) + for (const pid of [state.descendant, state.root]) { + try { + process.kill(pid, 'SIGKILL') + } catch (_alreadyGone) { + // The exact recorded process already exited. + } + } + return + } + const inspector = createProcessInspector() + for (const identity of identities) { + try { + inspector.signalProcess(identity, 'SIGKILL') + } catch (_alreadyGone) { + // Exact start identity prevents PID-reuse cleanup from reaching another process. + } + } + if (identities.length === 0) { + for (const pid of [state.descendant, state.root]) { + try { + process.kill(pid, 'SIGKILL') + } catch (_alreadyGone) { + // The scenario failed before process identities became observable. + } + } + } +} + +async function runScenario(kind: ManagedKind, trigger: ExitTrigger) { + const root = await mkdtemp(join(tmpdir(), `dsh-subprocess-host-exit-${kind}-${trigger}-`)) + const launch = resolveExampleLaunch({ + srcBin: hostScript, + mode: 'src', + tsconfigPath: join(repoRoot, 'tsconfig.json'), + configArgs: [kind, trigger, root], + }) + const child = execa(launch.command, launch.args, { + cwd: repoRoot, + env: launch.env, + stdin: 'ignore', + reject: false, + timeout: scenarioTimeoutMs, + }) + let state: TreeState | undefined + let identities: ProcessIdentity[] = [] + let settled = false + try { + state = await readTree(join(root, 'tree.json')) + await vi.waitFor(() => readFile(join(root, 'ready'), 'utf8'), { + interval: 10, + timeout: scenarioTimeoutMs, + }) + if (process.platform !== 'win32') identities = await captureIdentities(createProcessInspector(), state) + await writeFile(join(root, 'proceed'), 'proceed') + const outcome = await child + settled = true + await waitForGone(state) + const disposeCounts = trigger === 'dispose' + ? JSON.parse(await readFile(join(root, 'dispose.json'), 'utf8')) as { + listenersBefore: number + listenersAfterLoad: number + listenersAfterDispose: number + } + : undefined + return { outcome, disposeCounts } + } finally { + if (!settled) { + child.kill('SIGKILL') + await child.catch(() => {}) + } + cleanupTree(state, identities) + if (state !== undefined) await waitForGone(state).catch(() => {}) + await rm(root, { recursive: true, force: true }) + } +} + +describe('synchronous cleanup on host exit', () => { + it.each([ + { trigger: 'direct' as const, expectedCode: 23, diagnostic: undefined }, + { trigger: 'uncaught-exception' as const, expectedCode: 1, diagnostic: 'host-exit-uncaught-exception' }, + { trigger: 'unhandled-rejection' as const, expectedCode: 1, diagnostic: 'host-exit-unhandled-rejection' }, + ])('removes an ordinary managed tree after $trigger', { timeout: 45_000 }, async ({ + trigger, + expectedCode, + diagnostic, + }) => { + const { outcome } = await runScenario('ordinary', trigger) + expect(outcome.exitCode).toBe(expectedCode) + expect(outcome.signal).toBeUndefined() + if (diagnostic !== undefined) expect(outcome.stderr).toContain(diagnostic) + }) + + it.skipIf(process.platform === 'win32')( + 'removes a terminal root and descendant after direct exit', + { timeout: 45_000 }, + async () => { + const { outcome } = await runScenario('terminal', 'direct') + expect(outcome.exitCode).toBe(23) + expect(outcome.signal).toBeUndefined() + }, + ) + + it('preserves normal terminate-and-join disposal and removes the exit listener', { timeout: 45_000 }, async () => { + const { outcome, disposeCounts } = await runScenario('ordinary', 'dispose') + expect(outcome.exitCode).toBe(0) + expect(disposeCounts?.listenersAfterLoad).toBe((disposeCounts?.listenersBefore ?? 0) + 1) + expect(disposeCounts?.listenersAfterDispose).toBe(disposeCounts?.listenersBefore) + }) +}) diff --git a/packages/subprocess/subprocess-local/tests/spawn.spec.ts b/packages/subprocess/subprocess-local/tests/spawn.spec.ts index 4cffde6432..3d16376904 100644 --- a/packages/subprocess/subprocess-local/tests/spawn.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn.spec.ts @@ -582,6 +582,25 @@ describe('stdio dispositions', () => { }) describe('windows tree semantics (injected platform)', () => { + it('host-exit termination routes through taskkill immediately', async () => { + const killed: number[] = [] + const running = spawnSubprocess(spec('sleep 60', { graceMs: 60_000 }), { + spillDir, + platform: 'win32', + taskkill: (pid) => { + killed.push(pid) + try { + process.kill(pid, 'SIGKILL') + } catch { + // Already gone — matches taskkill's tolerated not-found status. + } + }, + }) + running.terminateForHostExit() + await running.done + expect(killed).toEqual([running.pid]) + }) + it('terminate routes through taskkill by root pid', async () => { const killed: number[] = [] const running = spawnSubprocess(spec('sleep 60', { graceMs: 100 }), { @@ -631,6 +650,23 @@ describe('waitForExit', () => { }) }) +describe('synchronous host-exit termination', () => { + it('force-kills the current process tree without waiting for the normal grace', async () => { + const running = spawnSubprocess(spec('trap "" TERM; sleep 60', { graceMs: 60_000 })) + running.terminateForHostExit() + await expect(running.done).resolves.toMatchObject({ exitCode: null, signal: 'SIGKILL' }) + await expect(running.waitForExit()).resolves.toBe(true) + + const kill = vi.spyOn(process, 'kill') + try { + running.terminateForHostExit() + expect(kill).not.toHaveBeenCalled() + } finally { + kill.mockRestore() + } + }) +}) + describe('tree-survivor escalation (terminate and bounded waits reach helpers the leader left behind)', () => { it('terminate() SIGKILLs a TERM-trapping descendant after the direct child settles', async () => { // The leader spawns a TERM-trapping helper with all stdio detached from diff --git a/packages/subprocess/subprocess-local/tests/terminal.spec.ts b/packages/subprocess/subprocess-local/tests/terminal.spec.ts index 79501c7dc4..4bfd9f1025 100644 --- a/packages/subprocess/subprocess-local/tests/terminal.spec.ts +++ b/packages/subprocess/subprocess-local/tests/terminal.spec.ts @@ -74,6 +74,7 @@ class FakeInspector implements ProcessInspector { } signalProcess(identity: ProcessIdentity, signal: 'SIGTERM' | 'SIGKILL') { if (this.throwProcess) throw new Error('process raced') + if (!this.isAlive(identity)) return this.processes.push([identity.pid, signal]) if (this.removeOnSignal) this.alive.delete(identity.pid) } @@ -82,6 +83,85 @@ class FakeInspector implements ProcessInspector { afterEach(() => { vi.useRealTimers() }) describe('LocalTerminalHandle', () => { + it('force-kills descendants around the shell during synchronous host exit', () => { + const pty = new FakePty() + const inspector = new FakeInspector() + const first = { pid: 124, started: 'first' } + const late = { pid: 125, started: 'late' } + inspector.members = [first] + inspector.alive.add(pty.pid) + inspector.alive.add(first.pid) + const signalProcess = inspector.signalProcess.bind(inspector) + inspector.signalProcess = (identity, signal) => { + signalProcess(identity, signal) + if (identity.pid === pty.pid) { + inspector.members = [first, late] + inspector.alive.add(late.pid) + } + } + const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10) + + handle.terminateForHostExit() + expect(inspector.processes).toEqual([ + [first.pid, 'SIGKILL'], + [pty.pid, 'SIGKILL'], + [late.pid, 'SIGKILL'], + ]) + expect(pty.kills).toEqual([]) + + pty.emitExit() + handle.terminateForHostExit() + expect(pty.kills).toEqual([]) + }) + + it('uses captured identities and contains shell races when final inspection fails', async () => { + const pty = new FakePty() + const inspector = new FakeInspector() + const captured = { pid: 124, started: 'captured' } + inspector.members = [captured] + inspector.alive.add(pty.pid) + inspector.alive.add(captured.pid) + const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10) + await handle.inspectForeground() + inspector.processTree = () => { throw new Error('process table unavailable') } + inspector.throwProcess = true + + expect(() => { handle.terminateForHostExit() }).not.toThrow() + expect(inspector.processes).toEqual([]) + expect(pty.kills).toEqual([]) + }) + + it('uses node-pty only when the shell start identity was unavailable', () => { + const pty = new FakePty() + const inspector = new FakeInspector() + inspector.root = undefined + const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10) + + handle.terminateForHostExit() + expect(pty.kills).toEqual(['SIGKILL']) + + const racingPty = new FakePty() + const racingInspector = new FakeInspector() + racingInspector.root = undefined + racingPty.throwKill = true + const racingHandle = new LocalTerminalHandle(racingPty.asPty(), racingInspector, 10) + expect(() => { racingHandle.terminateForHostExit() }).not.toThrow() + }) + + it('does not signal a recycled terminal root before its delayed exit callback', () => { + const pty = new FakePty() + const inspector = new FakeInspector() + inspector.alive.add(pty.pid) + const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10) + inspector.root = { pid: pty.pid, started: 'recycled' } + inspector.isAlive = identity => identity.started === 'recycled' + + handle.terminateForHostExit() + + expect(inspector.processes).toEqual([]) + expect(pty.kills).toEqual([]) + }) + it('bridges terminal bytes, foreground control, and signalled exit facts', async () => { const pty = new FakePty() const inspector = new FakeInspector() diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5612f5bc13..9ebc69c284 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6992,6 +6992,9 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants + '@deepseek-ai/dsh-loader-smoke': + specifier: workspace:^ + version: link:../../support/loader-smoke '@deepseek-ai/dsh-subprocess': specifier: workspace:^ version: link:../subprocess diff --git a/vitest.config.ts b/vitest.config.ts index c0eb076b5c..44ce8ce176 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -29,7 +29,6 @@ const windowsUnsupportedPackages = process.platform === 'win32' 'packages/bash/bash-sandbox', 'packages/bash/tool-bash', 'packages/hooks/*', - 'packages/subprocess/*', 'packages/pty/pty-local', 'packages/sandbox/sandbox-local', 'packages/scaffold/create-sdk', @@ -37,6 +36,21 @@ const windowsUnsupportedPackages = process.platform === 'win32' ] : [] +const windowsUnsupportedTests = process.platform === 'win32' + ? [ + ...windowsUnsupportedPackages.map(path => `${path}/tests/**/*.spec.ts`), + 'packages/subprocess/subprocess/tests/**/*.spec.ts', + 'packages/subprocess/subprocess-local/tests/local.spec.ts', + 'packages/subprocess/subprocess-local/tests/process-inspector.spec.ts', + 'packages/subprocess/subprocess-local/tests/spawn.spec.ts', + 'packages/subprocess/subprocess-local/tests/terminal.spec.ts', + ] + : [] + +const windowsUnsupportedCoveragePackages = process.platform === 'win32' + ? [...windowsUnsupportedPackages, 'packages/subprocess/*'] + : [] + // Windows-only packages: their sources execute exclusively on win32 (koffi // loads Win32 libraries), so the Linux coverage lane can never cover them. // The Windows dev/CI lane exercises them through the probe/runner suites; the @@ -94,6 +108,7 @@ const coverageExemptExcludes = coverageExemptRaw === '1' const processBoundTests = [ 'packages/session/session-persistence-jsonl/tests/jsonl.spec.ts', 'packages/subagent/subagent-acp/tests/subagent-acp.spec.ts', + 'packages/subprocess/subprocess-local/tests/process-exit.spec.ts', 'packages/subprocess/subprocess-local/tests/spawn.spec.ts', 'packages/context/time-context/tests/time-context.spec.ts', 'packages/llm/llm-pi-ai/tests/adapter.spec.ts', @@ -107,7 +122,7 @@ export default defineConfig({ setupFiles: ['./scripts/test-invariants.ts'], // .tsx: client component specs (jsdom via per-file @vitest-environment pragma). include: testIncludes, - exclude: windowsUnsupportedPackages.map(path => `${path}/tests/**/*.spec.ts`), + exclude: windowsUnsupportedTests, // One coverage invocation aggregates both projects. Every suite forks for // Node stability; process-bound suites stay separate for inventory control. projects: [ @@ -123,7 +138,7 @@ export default defineConfig({ setupFiles: ['./scripts/test-invariants.ts'], include: testIncludes, exclude: [ - ...windowsUnsupportedPackages.map(path => `${path}/tests/**/*.spec.ts`), + ...windowsUnsupportedTests, ...processBoundTests, ...coverageExemptExcludes, ], @@ -138,7 +153,7 @@ export default defineConfig({ setupFiles: ['./scripts/test-invariants.ts'], include: processBoundTests, exclude: [ - ...windowsUnsupportedPackages.map(path => `${path}/tests/**/*.spec.ts`), + ...windowsUnsupportedTests, ...coverageExemptExcludes, ], }, @@ -241,7 +256,7 @@ export default defineConfig({ 'packages/interaction/commands/src/index.ts', 'packages/interaction/commands/src/invariant.ts', 'packages/session/session-projection/src/index.ts', - ...windowsUnsupportedPackages.map(path => `${path}/src/**/*.ts`), + ...windowsUnsupportedCoveragePackages.map(path => `${path}/src/**/*.ts`), ...windowsOnlyCoverageExclusions, ...windowsRunnerCoverageExclusions, ...pwshCoverageExclusions, From 34e90dc3fe370630b50a421696b1323944138cc5 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 11 Aug 2026 16:03:13 +0800 Subject: [PATCH 24/81] test(client): accept session creation timestamps --- packages/client/connection/tests/fixture.spec.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts index b7d4d12e8d..5d64fa4682 100644 --- a/packages/client/connection/tests/fixture.spec.ts +++ b/packages/client/connection/tests/fixture.spec.ts @@ -264,7 +264,9 @@ describe('createFixtureApi', () => { await consuming if (!created.result.ok) throw new Error('create failed') const createdId = created.result.value.sessionId - expect(seen).toEqual([{ type: 'host/session-added', sessionId: createdId, blank: true, cwd: '/tmp/fixture' }]) + expect(seen).toEqual([{ + type: 'host/session-added', sessionId: createdId, createdAt: expect.any(Number), blank: true, cwd: '/tmp/fixture', + }]) const list = await api.sessions.list(req({})) if (!list.result.ok) throw new Error('list failed') expect(list.result.value.items.some(s => s.sessionId === createdId)).toBe(true) @@ -699,7 +701,9 @@ describe('createFixtureApi', () => { await consuming // The session lands with the workspace's path as cwd, and the account // write pushes the fresh workspace snapshot after session-added. - expect(seen[0]).toEqual({ type: 'host/session-added', sessionId: id, blank: true, cwd: '/tmp/fixture' }) + expect(seen[0]).toEqual({ + type: 'host/session-added', sessionId: id, createdAt: expect.any(Number), blank: true, cwd: '/tmp/fixture', + }) expect(seen[1]).toMatchObject({ type: 'host/workspace-changed', workspace: { workspaceId: 'fx-ws-fixture', sessionIds: [id, 'fx-alpha', 'fx-beta', 'fx-gamma'] }, @@ -728,7 +732,10 @@ describe('createFixtureApi', () => { expect(frames[0]).toMatchObject({ type: 'host/workspace-changed', workspace: { sessionIds: [preallocated] }, }) - expect(frames[1]).toEqual({ type: 'host/session-added', sessionId: preallocated, blank: true, cwd: made.result.value.workspace.path }) + expect(frames[1]).toEqual({ + type: 'host/session-added', sessionId: preallocated, createdAt: expect.any(Number), blank: true, + cwd: made.result.value.workspace.path, + }) const retried = await api.sessions.create(req({ workspaceId: made.result.value.workspace.workspaceId, From 5b1da441d5e767992d8ee6ecc96fb5236f5c21c9 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 11 Aug 2026 16:46:52 +0800 Subject: [PATCH 25/81] fix(client): refine workspace view controls --- packages/client/ui-workspace/README.i18n.yaml | 4 +- packages/client/ui-workspace/README.md | 2 +- packages/client/ui-workspace/README.zh.md | 2 +- .../src/client/WorkspaceBrowser.tsx | 29 ++++---- .../client/ui-workspace/src/client/locales.ts | 6 +- .../client/ui-workspace/src/client/stores.ts | 6 +- .../tests/workspace-browser.spec.tsx | 70 ++++++++++++++----- 7 files changed, 77 insertions(+), 42 deletions(-) diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index bff5c6410a..81b52ef012 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: f54b8b0b2070c81089be1703b536492522d38774 -README.zh.md: b1977e7f6a67fa7bcb6751d7b214cfff4bfdbb4f +README.md: 24d73beba56f527eb57276accb2693255bad9020 +README.zh.md: 85a5ce9210dd084e6e6e0746eb19aac12d98d54d diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index f54b8b0b20..24d73beba5 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Shared Workspace browser and picker plugin. `WorkspaceBrowser` fills the sidebar's `sidebar.workspaces` slot, while `WorkspacePicker` fills the page-local Session Intent hero's `conversation.hero.workspace` slot; both surfaces use the same Workspace menu and add flow. -The browser renders grouped or flat Session rows from the global runtime hooks and owns Workspace add/rename/reorder plus in-Workspace Session reorder. A Workspace remembers whether it is closed or showing Sessions; an open Workspace shows five Sessions by default, offers a transient **Show more** control for the remainder, and returns to five after the whole Workspace is closed and reopened. View options combine grouping with Session order: **Manual** follows the Host Workspace account, while **Last updated** keeps a browser-local editable order and moves a Session to the front whenever a newer `updatedAt` arrives. Workspace drag order is Host-durable in either Session order mode. +The browser renders grouped or flat Session rows from the global runtime hooks and owns Workspace add/rename/reorder plus in-Workspace Session reorder. A Workspace remembers whether it is closed or showing Sessions; an open Workspace shows five Sessions by default, offers a transient **Show more** control for the remainder, and returns to five after the whole Workspace is closed and reopened. View options combine grouping with one browser-persisted Session order: entering **Last updated** performs a complete recency sort and later user prompts or steers promote their Session once, while entering **Manual** preserves every current position and disables later promotion. Dragging edits the current order in either mode; Manual-mode drags also update the Host Workspace account. Workspace drag order is Host-durable in either Session order mode. Collapsed search is one header action beside the view and add actions. Activating it expands the field across the header; an outside click collapses only an empty query, while the clear control always resets and collapses it. 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. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index b1977e7f6a..85a5ce9210 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -4,7 +4,7 @@ 共享 Workspace 浏览器与选择器插件。`WorkspaceBrowser` 填充侧边栏的 `sidebar.workspaces` slot,`WorkspacePicker` 则填充页面局部 Session Intent 主视觉区的 `conversation.hero.workspace` slot;两个界面使用同一套 Workspace 菜单和添加流程。 -该浏览器通过全局运行时钩子将 Session 行渲染为分组或扁平形式,并负责 Workspace 添加/重命名/重排序以及 Workspace 内的 Session 重排序。每个 Workspace 会记住自身是关闭还是显示 Session;打开后默认显示五条 Session,其余条目通过临时的**展开其余**控件显示,而关闭并重新打开整个 Workspace 后会恢复为五条。视图选项把分组方式和 Session 顺序放在一起:**手动排序**遵循 Host Workspace 记账顺序,**最近更新**则维护可编辑的浏览器本地顺序,并在收到更大的 `updatedAt` 时把该 Session 移到首位。无论采用哪种 Session 顺序,Workspace 拖拽顺序都由 Host 持久化。 +该浏览器通过全局运行时钩子将 Session 行渲染为分组或扁平形式,并负责 Workspace 添加/重命名/重排序以及 Workspace 内的 Session 重排序。每个 Workspace 会记住自身是关闭还是显示 Session;打开后默认显示五条 Session,其余条目通过临时的**展开其余**控件显示,而关闭并重新打开整个 Workspace 后会恢复为五条。视图选项把分组方式和一份浏览器持久化的 Session 顺序放在一起:进入**最近更新**时执行一次完整的时间排序,后续 user prompt 或 steer 会将对应 Session 置顶一次;进入**手动排序**则保留所有当前位置并停用后续置顶。两种模式下的拖拽都会编辑当前顺序,手动模式下的拖拽还会更新 Host Workspace 记账。无论采用哪种 Session 顺序,Workspace 拖拽顺序都由 Host 持久化。 折叠搜索是视图和添加操作旁的一枚区头按钮。激活后,输入框会扩展并占据区头;点击外部只会收起空查询,而清除控件总会重置并收起搜索。非空白查询会以单一扁平结果列表替代任一浏览模式:不区分大小写的标题和 Workspace 子串匹配项会立即显示,经 250 ms 防抖的 Host 请求则会加入经过排序的当前对话内容匹配项及其摘要片段。英文搜索输入框及其防御性请求路径会移除 NUL,将查询限制在传输 schema 规定的 500 个 UTF-16 代码单元内且不会拆分代理项对,并保留现有的防抖与取消行为。每次新查询都会中止前一个请求;内容搜索失败时,元数据匹配项仍会显示,同时给出警告。列表最多显示 20 条结果,并会在查询过宽时提示用户缩小范围;打开所选 Session 时既不会清除查询,也不会跳转至特定事件。 diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx index eeb47e2cb1..305cabad08 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx @@ -118,11 +118,11 @@ function ViewOptionsMenu({ groupBy, orderBy, onGroupPick, onOrderPick, t }: { // be cut off at the header's bounds. portal anchor={( - + +
+ ) : null} + {state.status === 'ready' ? ( +
+ +
+

{t('catalog')}

+ {filteredEntries.length} +
+ {state.snapshot.entries.length === 0 ?

{t('empty')}

: null} + {state.snapshot.entries.length > 0 && filteredEntries.length === 0 + ?

{t('emptySearch')}

+ : null} + {filteredEntries.length > 0 ? ( +
    + {filteredEntries.map((entry) => { + const status = phaseLabel(entry.fiberPhase, t) + return ( +
  • +
    + {entry.displayId} + + + + {t(entry.enabled ? 'enabledTag' : 'disabledTag')} + + +
    +
  • + ) + })} +
+ ) : null} +
+ ) : null} + + ) +} diff --git a/packages/client/ui-plugins/src/client/index.ts b/packages/client/ui-plugins/src/client/index.ts new file mode 100644 index 0000000000..b3034a86f9 --- /dev/null +++ b/packages/client/ui-plugins/src/client/index.ts @@ -0,0 +1,42 @@ +/** Read-only Host plugin inventory registered into Web Settings. */ + +import type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client' +import type {} from '@deepseek-ai/dsh-client-locale/client' +import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import type {} from '@deepseek-ai/dsh-client-ui-settings/client' +import { PluginSettingsSection, type PluginSettingsSectionInjected } from './PluginSettingsSection.tsx' +import { en, zh, type PluginsKey } from './locales.ts' + +export type { PluginSettingsSectionInjected, PluginSettingsSectionProps } from './PluginSettingsSection.tsx' +export type { PluginsKey } from './locales.ts' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** Read-only Host plugin inventory copy. */ + 'settings.plugins': PluginsKey + } +} + +/** Dictionary namespace owned by this plugin. */ +export const NS = 'settings.plugins' + +/** Services required by the Settings registration and generated Remote face. */ +export const inject = ['slots', 'locale', 'remote', 'remote.pluginInventory'] + +/** Register the lazy plugin inventory page below Models in Settings. */ +export function apply(ctx: ClientContext): void { + ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-plugins: dictionaries') + + const t = ctx.locale.bind(NS) + const list: ClientRemote['pluginInventory']['list'] = () => ctx.remote.pluginInventory.list() + const injected = (): PluginSettingsSectionInjected => ({ list }) + + ctx.slots.inject('settings.section', () => ctx.slots.register({ + name: 'settings.section', + id: 'plugin-inventory', + order: 15, + label: () => t('nav'), + locale: NS, + inject: injected, + }, PluginSettingsSection)) +} diff --git a/packages/client/ui-plugins/src/client/locales.ts b/packages/client/ui-plugins/src/client/locales.ts new file mode 100644 index 0000000000..64745ce14f --- /dev/null +++ b/packages/client/ui-plugins/src/client/locales.ts @@ -0,0 +1,46 @@ +/** Copy dictionaries for the plugin inventory Settings section. */ + +/** Simplified Chinese dictionary and key source of truth. */ +export const zh = { + nav: '插件', + title: '插件', + loading: '正在读取插件…', + error: '暂时无法读取插件。', + retry: '重试', + search: '搜索插件', + catalog: '插件列表', + empty: '暂无插件。', + emptySearch: '没有匹配的插件。', + enabledTag: '已启用', + disabledTag: '已停用', + unobserved: '无根 Fiber', + pending: '等待依赖', + loadingPhase: '加载中', + active: '存活', + failed: '失败', + unloading: '卸载中', +} satisfies Record + +/** Plugin inventory locale key union. */ +export type PluginsKey = keyof typeof zh + +/** English dictionary checked against the Chinese key set. */ +export const en = { + nav: 'Plugins', + title: 'Plugins', + loading: 'Reading plugins…', + error: 'Plugins are temporarily unavailable.', + retry: 'Retry', + search: 'Search plugins', + catalog: 'Plugin list', + empty: 'No plugins are available.', + emptySearch: 'No matching plugins.', + enabledTag: 'Enabled', + disabledTag: 'Disabled', + unobserved: 'No root Fiber', + pending: 'Pending', + loadingPhase: 'Loading', + active: 'Active', + failed: 'Failed', + unloading: 'Unloading', +} satisfies Record diff --git a/packages/client/ui-plugins/src/css-modules.d.ts b/packages/client/ui-plugins/src/css-modules.d.ts new file mode 100644 index 0000000000..bc5e482353 --- /dev/null +++ b/packages/client/ui-plugins/src/css-modules.d.ts @@ -0,0 +1,6 @@ +declare module '*.module.css' { + const classes: Record + export default classes +} + +declare module '*.css' diff --git a/packages/client/ui-plugins/src/index.ts b/packages/client/ui-plugins/src/index.ts new file mode 100644 index 0000000000..489544a421 --- /dev/null +++ b/packages/client/ui-plugins/src/index.ts @@ -0,0 +1,4 @@ +/** Host loader entry for the browser implementation exported from `./client`. */ + +/** Host plugin body — no host-side behavior for the plugin settings section. */ +export function apply(): void {} diff --git a/packages/client/ui-plugins/src/invariant.ts b/packages/client/ui-plugins/src/invariant.ts new file mode 100644 index 0000000000..2d001d4312 --- /dev/null +++ b/packages/client/ui-plugins/src/invariant.ts @@ -0,0 +1,20 @@ +/** Package-owned invariant companion. @module @deepseek-ai/dsh-client-ui-plugins/invariant */ + +/* jscpd:ignore-start */ +import type { Context } from '@deepseek-ai/cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-plugins' + +/** Cordis companion plugin name. */ +export const name = 'client-ui-plugins-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** No runtime invariant: this package owns a read-only Settings contribution. */ +const install: InvariantInstaller = () => {} + +/** Register this package's invariant companion. */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/client/ui-plugins/tests/browser-plugin.spec.tsx b/packages/client/ui-plugins/tests/browser-plugin.spec.tsx new file mode 100644 index 0000000000..c2e3c741fd --- /dev/null +++ b/packages/client/ui-plugins/tests/browser-plugin.spec.tsx @@ -0,0 +1,87 @@ +// @vitest-environment jsdom +import { Context, Service } from '@deepseek-ai/cordis' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup } from '@testing-library/react' +import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' +import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' +import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' +import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' +import { apply, inject, NS } from '../src/client/index.ts' +import { PluginSettingsSection } from '../src/client/PluginSettingsSection.tsx' +import type { PluginSettingsSectionInjected } from '../src/client/PluginSettingsSection.tsx' + +usePinnedBrowserLanguages('zh-CN') +afterEach(cleanup) + +const EMPTY = { entries: [] } + +async function bench() { + const ctx = new Context() + await ctx.plugin(SlotsService).await() + const locale = new LocaleService(ctx) + ctx.provide('locale', locale) + class RemoteService extends Service { + constructor(serviceCtx: Context) { + super(serviceCtx, 'remote') + } + } + new RemoteService(ctx) + const list = vi.fn(() => Promise.resolve(EMPTY)) + ctx.provide('remote.pluginInventory', { list }) + return { ctx, slots: ctx.get('slots') as SlotsService, locale, list } +} + +function declare(slots: SlotsService): () => void { + return slots.register({ + name: 'root', + children: { 'settings.section': { kind: 'list', scope: 'root' } }, + } as never, () => null) +} + +describe('ui-plugins browser plugin', () => { + it('declares only the services used by the Settings Remote contribution', () => { + expect(inject).toEqual(['slots', 'locale', 'remote', 'remote.pluginInventory']) + }) + + it('registers a localized section without reading the Remote eagerly', async () => { + const b = await bench() + declare(b.slots) + await b.ctx.plugin({ inject: [...inject], apply }).await() + + const entry = b.slots.entries('settings.section')[0]! + expect(entry.component).toBe(PluginSettingsSection) + expect(entry.options).toMatchObject({ id: 'plugin-inventory', order: 15 }) + expect(entry.locale).toBe(NS) + expect(resolveSlotLabel(entry.options.label)).toBe('插件') + expect(b.list).not.toHaveBeenCalled() + + const injected = (entry.inject as unknown as () => PluginSettingsSectionInjected)() + await expect(injected.list()).resolves.toEqual(EMPTY) + expect(b.list).toHaveBeenCalledOnce() + await b.ctx.fiber.dispose() + }) + + it('follows locale and recovers across late declaration and declarer reload', async () => { + const b = await bench() + const fiber = b.ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + expect(b.slots.entries('settings.section')).toHaveLength(0) + + const stop = declare(b.slots) + await vi.waitFor(() => { expect(b.slots.entries('settings.section')).toHaveLength(1) }) + b.locale.setLocale('en') + expect(resolveSlotLabel(b.slots.entries('settings.section')[0]!.options.label)).toBe('Plugins') + + stop() + expect(b.slots.entries('settings.section')).toHaveLength(0) + declare(b.slots) + await vi.waitFor(() => { + expect(b.slots.entries('settings.section')[0]?.component).toBe(PluginSettingsSection) + }) + + await fiber.dispose() + expect(b.slots.entries('settings.section')).toHaveLength(0) + expect(() => b.locale.register(NS, 'zh', {})).not.toThrow() + await b.ctx.fiber.dispose() + }) +}) diff --git a/packages/client/ui-plugins/tests/components.spec.tsx b/packages/client/ui-plugins/tests/components.spec.tsx new file mode 100644 index 0000000000..af49f96ad5 --- /dev/null +++ b/packages/client/ui-plugins/tests/components.spec.tsx @@ -0,0 +1,100 @@ +// @vitest-environment jsdom +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { PluginSettingsSection } from '../src/client/PluginSettingsSection.tsx' +import type { + PluginSettingsSectionInjected, + PluginSettingsSectionProps, +} from '../src/client/PluginSettingsSection.tsx' +import { en, type PluginsKey } from '../src/client/locales.ts' + +afterEach(cleanup) + +type Snapshot = Awaited> +const t = ((key: PluginsKey): string => en[key]) as PluginSettingsSectionProps['t'] +const unusedHook = (() => { throw new Error('unused by plugin inventory') }) as never + +function props(list: PluginSettingsSectionInjected['list']): PluginSettingsSectionProps { + return { + close: vi.fn(), + useSessions: unusedHook, + useWorkspaces: unusedHook, + t, + list, + } +} + +const SNAPSHOT = { + entries: [ + { entryId: 'active', displayId: 'active-name', enabled: true, fiberPhase: 'active' }, + { entryId: 'pending', displayId: 'pending-name', enabled: true, fiberPhase: 'pending' }, + { entryId: 'loading', displayId: 'loading-name', enabled: true, fiberPhase: 'loading' }, + { entryId: 'failed', displayId: 'failed-name', enabled: true, fiberPhase: 'failed' }, + { entryId: 'unloading', displayId: 'unloading-name', enabled: true, fiberPhase: 'unloading' }, + { entryId: 'disabled-entry', displayId: 'disabled-name', enabled: false, fiberPhase: null }, + ], +} as unknown as Snapshot + +describe('PluginSettingsSection', () => { + it('renders searchable two-column-card semantics with dots and tags', async () => { + const deferred = Promise.withResolvers() + const list = vi.fn(() => deferred.promise) + const view = render() + expect(screen.getByText(en.loading)).toBeTruthy() + + await act(async () => { deferred.resolve(SNAPSHOT) }) + expect(list).toHaveBeenCalledOnce() + expect(screen.getByRole('searchbox', { name: en.search })).toBeTruthy() + expect(screen.getByRole('heading', { name: en.catalog })).toBeTruthy() + expect(view.container.querySelector('[data-plugin-count]')?.textContent).toBe('6') + expect(screen.getAllByRole('listitem')).toHaveLength(6) + expect(screen.getAllByText(en.enabledTag)).toHaveLength(5) + expect(screen.getByText(en.disabledTag)).toBeTruthy() + for (const value of ['Active', 'Pending', 'Loading', 'Failed', 'Unloading', 'No root Fiber']) { + expect(screen.getByRole('img', { name: value })).toBeTruthy() + } + expect(screen.getByRole('listitem', { name: 'active-name, Active, Enabled' })).toBeTruthy() + }) + + it('filters by local id or Loader entry id', async () => { + render( SNAPSHOT)} />) + const search = await screen.findByRole('searchbox', { name: en.search }) + + fireEvent.change(search, { target: { value: 'disabled-entry' } }) + expect(screen.getAllByRole('listitem')).toHaveLength(1) + expect(screen.getByText('disabled-name')).toBeTruthy() + + fireEvent.change(search, { target: { value: 'pending' } }) + expect(screen.getAllByRole('listitem')).toHaveLength(1) + expect(screen.getByText('pending-name')).toBeTruthy() + + fireEvent.change(search, { target: { value: 'not-a-plugin' } }) + expect(screen.queryAllByRole('listitem')).toHaveLength(0) + expect(screen.getByText(en.emptySearch)).toBeTruthy() + }) + + it('shows a generic failure and retries into the empty state', async () => { + const list = vi.fn() + .mockRejectedValueOnce(new Error('private transport detail')) + .mockResolvedValueOnce({ entries: [] }) + render() + + expect((await screen.findByRole('alert')).textContent).toBe(en.error) + expect(screen.queryByText('private transport detail')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: en.retry })) + await waitFor(() => { expect(list).toHaveBeenCalledTimes(2) }) + expect(await screen.findByText(en.empty)).toBeTruthy() + }) + + it('contains a synchronous Remote failure and ignores a result after unmount', async () => { + const syncFailure = vi.fn(() => { throw new Error('namespace unavailable') }) as PluginSettingsSectionInjected['list'] + const failed = render() + expect((await screen.findByRole('alert')).textContent).toBe(en.error) + failed.unmount() + + const deferred = Promise.withResolvers() + const pending = render( deferred.promise)} />) + pending.unmount() + await act(async () => { deferred.resolve(SNAPSHOT) }) + }) +}) diff --git a/packages/client/ui-plugins/tests/invariant.spec.ts b/packages/client/ui-plugins/tests/invariant.spec.ts new file mode 100644 index 0000000000..df4161cb13 --- /dev/null +++ b/packages/client/ui-plugins/tests/invariant.spec.ts @@ -0,0 +1,15 @@ +import { Context } from '@deepseek-ai/cordis' +import { describe, expect, it } from 'vitest' +import InvariantService from '@deepseek-ai/dsh-invariants' +import * as PluginsInvariant from '../src/invariant.ts' + +describe('ui-plugins invariant companion', () => { + it('registers the empty installer and keeps the node half inert', async () => { + const ctx = new Context() + await ctx.plugin(InvariantService, { enabled: true }) + await expect(ctx.plugin(PluginsInvariant).await()).resolves.toBeDefined() + const { apply } = await import('../src/index.ts') + apply() + await ctx.fiber.dispose() + }) +}) diff --git a/packages/client/ui-plugins/tsconfig.json b/packages/client/ui-plugins/tsconfig.json new file mode 100644 index 0000000000..2019585ff7 --- /dev/null +++ b/packages/client/ui-plugins/tsconfig.json @@ -0,0 +1,36 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../api/remotes/tsconfig.client.json" + }, + { + "path": "../locale" + }, + { + "path": "../runtime" + }, + { + "path": "../ui-settings" + }, + { + "path": "../ui-primitives" + }, + { + "path": "../ui-slots" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/client/ui-plugins/tsdown.config.ts b/packages/client/ui-plugins/tsdown.config.ts new file mode 100644 index 0000000000..a85ab4569f --- /dev/null +++ b/packages/client/ui-plugins/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-ui-plugins', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/host/README.i18n.yaml b/packages/host/README.i18n.yaml index 3eb8fe7eb8..84e471c7fb 100644 --- a/packages/host/README.i18n.yaml +++ b/packages/host/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/README.md -README.md: 926cb0b6b87a8ee76cb2dab745a31f620f4e7f5c -README.zh.md: 7ef057ee56e56ddc2baa7092ccbe44fb161b7448 +README.md: 1c3b6ab3192fe35a5532183414e45d1b02325e57 +README.zh.md: a062d5fce055e3266953993d532a86bec1375377 diff --git a/packages/host/README.md b/packages/host/README.md index 926cb0b6b8..1c3b6ab319 100644 --- a/packages/host/README.md +++ b/packages/host/README.md @@ -13,6 +13,7 @@ The host side of the dsh web GUI: the API gateway every client shape shares, and | [`directory-picker-native/`](directory-picker-native/README.md) | Native directory-picker backend and browser interaction | registers `ctx.directoryPicker` | | [`directory-picker-browse/`](directory-picker-browse/README.md) | In-app directory-browser backend and interaction | registers `ctx.directoryPicker` | | [`directory-picker-auto/`](directory-picker-auto/README.md) | Host-adaptive picker composition | mounts a backend | +| [`plugin-inventory/`](plugin-inventory/README.md) | Read-only projection of current Loader entries | Remote `pluginInventory/list` | `apiproxy` remains transport-independent; [`client/connection`](../client/connection/README.md) supplies the browser/HTTP carrier. Picker implementations replace one another behind the shared seam. diff --git a/packages/host/README.zh.md b/packages/host/README.zh.md index 7ef057ee56..a062d5fce0 100644 --- a/packages/host/README.zh.md +++ b/packages/host/README.zh.md @@ -13,6 +13,7 @@ dsh Web GUI 的宿主侧:所有客户端形态共享的 API 网关,以及承 | [`directory-picker-native/`](directory-picker-native/README.md) | 原生目录选择器后端和浏览器交互 | 注册 `ctx.directoryPicker` | | [`directory-picker-browse/`](directory-picker-browse/README.md) | 应用内目录浏览器后端和交互 | 注册 `ctx.directoryPicker` | | [`directory-picker-auto/`](directory-picker-auto/README.md) | 宿主自适应选择器组合 | 挂载一个后端 | +| [`plugin-inventory/`](plugin-inventory/README.md) | 当前 Loader 条目的只读投影 | Remote `pluginInventory/list` | `apiproxy` 保持传输无关;[`client/connection`](../client/connection/README.md) 提供浏览器/HTTP 载体。选择器实现可在共享 seam 后互相替换。 diff --git a/packages/host/plugin-inventory/README.i18n.yaml b/packages/host/plugin-inventory/README.i18n.yaml new file mode 100644 index 0000000000..761b9c6850 --- /dev/null +++ b/packages/host/plugin-inventory/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/host/plugin-inventory/README.md +README.md: d7a50824d337c66a30ba4332ffb1e36f9ad46547 +README.zh.md: e424b968f1f692fb30b3b0e49efe6c3cd5fadf70 diff --git a/packages/host/plugin-inventory/README.md b/packages/host/plugin-inventory/README.md new file mode 100644 index 0000000000..d7a50824d3 --- /dev/null +++ b/packages/host/plugin-inventory/README.md @@ -0,0 +1,22 @@ +# @deepseek-ai/dsh-host-plugin-inventory + +English | [中文](README.zh.md) + +Read-only Host projection of the current Cordis Loader tree. `PluginInventoryService` registers the `pluginInventory` service and publishes one generated direct Remote, `pluginInventory/list`. Every call reads `ctx.loader.entries()` directly, skips structural group rows, and returns the remaining entries in Loader order with only their Loader entry id, local display id, effective enablement, and current root Fiber phase. + +The phase is `pending`, `loading`, `active`, `failed`, or `unloading`; it is `null` when the entry has no live root Fiber. The snapshot is intentionally point-in-time: Loader remains the sole lifecycle authority, while this package owns no cache, history, provenance model, event stream, or mutation path. Its public payload types live under `./types`, and TypeRT generates the Host and Client Remote artifacts exposed by `./typert` and `./remote`. + +The service is Remote-only and deliberately declares no same-process Cordis `Context` merge. Client packages consume it through the explicit [`api-remotes`](../../api/remotes/README.md) assembly rather than importing the Host implementation. + +## Model Experience + +None, as this Host-only inventory projection registers no prompt, tool, message, or provider request. + +#### KV Cache effect + +None; this package never assembles model input. + +## Known Limitations and Deferred Work + +- **Point-in-time state only** — the result contains no durable failure history or subscription; a missing root Fiber is reported as `null`, regardless of why no live root exists. +- **No provenance or mutation** — the service does not identify which bundle, profile, or override introduced an entry, and it cannot enable, disable, add, or remove plugins. diff --git a/packages/host/plugin-inventory/README.zh.md b/packages/host/plugin-inventory/README.zh.md new file mode 100644 index 0000000000..e424b968f1 --- /dev/null +++ b/packages/host/plugin-inventory/README.zh.md @@ -0,0 +1,22 @@ +# @deepseek-ai/dsh-host-plugin-inventory + +[English](README.md) | 中文 + +当前 Cordis Loader 树的只读 Host 投影。`PluginInventoryService` 注册 `pluginInventory` 服务,并发布一个由 TypeRT 生成的直接 Remote:`pluginInventory/list`。每次调用都直接读取 `ctx.loader.entries()`,跳过结构性的 group 行,再按 Loader 顺序返回其余条目,并且只包含 Loader 条目 id、本地展示 id、有效启用状态与当前根 Fiber 阶段。 + +阶段为 `pending`、`loading`、`active`、`failed` 或 `unloading`;条目没有存活的根 Fiber 时则为 `null`。该快照刻意只表示调用当下:Loader 仍是唯一的生命周期权威,本包不拥有缓存、历史、来源模型、事件流或修改路径。公开 payload 类型位于 `./types`,TypeRT 生成由 `./typert` 与 `./remote` 导出的 Host 和 Client Remote 产物。 + +该服务仅供 Remote 使用,刻意不声明同进程 Cordis `Context` merge。Client 包通过显式的 [`api-remotes`](../../api/remotes/README.md) 组合消费它,而不导入 Host 实现。 + +## 模型体验 + +无,因为这个仅限 Host 的清单投影不注册提示词、工具、消息或提供方请求。 + +#### KV Cache 影响 + +无;本包从不组装模型输入。 + +## 已知限制与暂缓事项 + +- **仅表示调用当下** —— 结果不包含持久的失败历史或订阅;只要不存在存活的根 Fiber,就会报告 `null`,而不区分其原因。 +- **无来源与修改能力** —— 服务不识别条目由哪个 bundle、profile 或 override 引入,也不能启用、停用、添加或移除插件。 diff --git a/packages/host/plugin-inventory/package.json b/packages/host/plugin-inventory/package.json new file mode 100644 index 0000000000..4da1a61f06 --- /dev/null +++ b/packages/host/plugin-inventory/package.json @@ -0,0 +1,70 @@ +{ + "name": "@deepseek-ai/dsh-host-plugin-inventory", + "description": "Read-only Remote projection of current Cordis Loader plugin state", + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/host/plugin-inventory" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, + "./typert": { + "types": "./lib/typert.host.d.ts", + "default": "./lib/typert.host.js" + }, + "./remote": { + "types": "./lib/typert.remote-client.d.ts", + "default": "./lib/typert.remote-client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.js", + "lib/types/**/*.d.ts", + "lib/typert.host.js", + "lib/typert.host.d.ts", + "lib/typert.remote-client.js", + "lib/typert.remote-client.d.ts", + "lib/typert.remote-client.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "dependencies": { + "zod": "^4.4.3" + }, + "peerDependencies": { + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-type-meta": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-type-meta": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + } +} diff --git a/packages/host/plugin-inventory/src/index.ts b/packages/host/plugin-inventory/src/index.ts new file mode 100644 index 0000000000..8aeafcebed --- /dev/null +++ b/packages/host/plugin-inventory/src/index.ts @@ -0,0 +1,72 @@ +/** Read-only projection of the current Cordis Loader plugin entries. */ + +import type { Context, FiberState } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/cordis-plugin-loader' +import { GatewayService, Remote } from '@deepseek-ai/dsh-type-meta' +// TypeRT-generated ./typert and ./remote artifacts import Zod at runtime. +import type {} from 'zod' +import type { + PluginEntryId, + PluginFiberPhase, + PluginInventoryEntry, + PluginInventorySnapshot, +} from './types.ts' + +export type * from './types.ts' + +/** Brand an existing Loader-tree entry id at the owning boundary. */ +function pluginEntryId(value: string): PluginEntryId { + return value as PluginEntryId +} + +/** Runtime mirror: FiberState is a cross-package const enum. */ +const FIBER_STATE = { + PENDING: 0 as FiberState.PENDING, + LOADING: 1 as FiberState.LOADING, + ACTIVE: 2 as FiberState.ACTIVE, + FAILED: 3 as FiberState.FAILED, + DISPOSED: 4 as FiberState.DISPOSED, + UNLOADING: 5 as FiberState.UNLOADING, +} as const + +/** Complete public projection of Cordis Fiber states. */ +const FIBER_PHASE = { + [FIBER_STATE.PENDING]: 'pending', + [FIBER_STATE.LOADING]: 'loading', + [FIBER_STATE.ACTIVE]: 'active', + [FIBER_STATE.FAILED]: 'failed', + [FIBER_STATE.DISPOSED]: null, + [FIBER_STATE.UNLOADING]: 'unloading', +} as const satisfies Record + +/** Remote-only service exposing the Loader's current non-group entry state. */ +export class PluginInventoryService extends GatewayService { + static inject = ['loader'] + + constructor(ctx: Context) { + super(ctx, 'pluginInventory') + } + + /** + * Read the Loader directly on every call. Cordis's internal plugin/status + * events already maintain Entry.fiber and Fiber.state, so a second cache + * would only add another lifecycle truth to keep synchronized. + * @returns Current non-group Loader entries in Loader order. + */ + @Remote('list') + list(): PluginInventorySnapshot { + const entries: PluginInventoryEntry[] = [] + for (const entry of this.ctx.loader.entries()) { + if (entry.options.group) continue + entries.push({ + entryId: pluginEntryId(entry.id), + displayId: entry.options.id, + enabled: !entry.disabled, + fiberPhase: entry.fiber === undefined ? null : FIBER_PHASE[entry.fiber.state], + }) + } + return { entries } + } +} + +export default PluginInventoryService diff --git a/packages/host/plugin-inventory/src/invariant.ts b/packages/host/plugin-inventory/src/invariant.ts new file mode 100644 index 0000000000..34acc058aa --- /dev/null +++ b/packages/host/plugin-inventory/src/invariant.ts @@ -0,0 +1,20 @@ +/** Package-owned invariant companion. @module @deepseek-ai/dsh-host-plugin-inventory/invariant */ + +/* jscpd:ignore-start */ +import type { Context } from '@deepseek-ai/cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-host-plugin-inventory' + +/** Cordis companion plugin name. */ +export const name = 'host-plugin-inventory-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** No runtime invariant: every snapshot is projected directly from Loader-owned state. */ +const install: InvariantInstaller = () => {} + +/** Register this package's invariant companion. */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/host/plugin-inventory/src/types.ts b/packages/host/plugin-inventory/src/types.ts new file mode 100644 index 0000000000..d1c81f5310 --- /dev/null +++ b/packages/host/plugin-inventory/src/types.ts @@ -0,0 +1,28 @@ +import type { Branded } from '@deepseek-ai/dsh-brand' + +/** Stable Loader-tree identity of one configured plugin entry. */ +export type PluginEntryId = Branded<'PluginEntryId'> + +/** Lifecycle state of an entry's root Fiber, or null when it has no live root Fiber. */ +export type PluginFiberPhase = + | 'pending' + | 'loading' + | 'active' + | 'failed' + | 'unloading' + | null + +/** One non-group Loader entry exposed to trusted clients. */ +export interface PluginInventoryEntry { + readonly entryId: PluginEntryId + /** Local Loader id used as the compact card title. */ + readonly displayId: string + /** Effective Loader enablement, including disabled ancestor groups. */ + readonly enabled: boolean + readonly fiberPhase: PluginFiberPhase +} + +/** Point-in-time inventory returned by the plugin inventory Remote. */ +export interface PluginInventorySnapshot { + readonly entries: readonly PluginInventoryEntry[] +} diff --git a/packages/host/plugin-inventory/tests/invariant.spec.ts b/packages/host/plugin-inventory/tests/invariant.spec.ts new file mode 100644 index 0000000000..d7e3b99fd8 --- /dev/null +++ b/packages/host/plugin-inventory/tests/invariant.spec.ts @@ -0,0 +1,16 @@ +import { Context } from '@deepseek-ai/cordis' +import { describe, expect, it } from 'vitest' +import InvariantService from '@deepseek-ai/dsh-invariants' +import * as PluginInventoryInvariant from '../src/invariant.ts' + +describe('plugin-inventory invariant companion', () => { + it('registers the package-owned empty installer', async () => { + const ctx = new Context() + await ctx.plugin(InvariantService, { enabled: true }) + const fiber = ctx.plugin(PluginInventoryInvariant) + await expect(fiber.await()).resolves.toBeDefined() + await fiber.dispose() + await expect(ctx.plugin(PluginInventoryInvariant).await()).resolves.toBeDefined() + await ctx.fiber.dispose() + }) +}) diff --git a/packages/host/plugin-inventory/tests/inventory.spec.ts b/packages/host/plugin-inventory/tests/inventory.spec.ts new file mode 100644 index 0000000000..a822fab2c2 --- /dev/null +++ b/packages/host/plugin-inventory/tests/inventory.spec.ts @@ -0,0 +1,89 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { Context, type Plugin } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import { remoteMethods } from '@deepseek-ai/dsh-type-meta' +import PluginInventoryService from '../src/index.ts' + +const contexts: Context[] = [] + +afterEach(async () => { + await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) +}) + +const activePlugin: Plugin.Function = () => {} +const pendingPlugin: Plugin.Object = { + inject: ['neverReady'], + apply() {}, +} + +async function harness(): Promise<{ + ctx: Context + inventory: PluginInventoryService +}> { + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(Loader) + ctx.loader.builtins.active = activePlugin + ctx.loader.builtins.pending = pendingPlugin + await ctx.plugin(PluginInventoryService) + const inventory = ctx.get('pluginInventory') as PluginInventoryService + return { ctx, inventory } +} + +describe('PluginInventoryService', () => { + it('publishes one direct list method under the pluginInventory namespace', async () => { + const { inventory } = await harness() + expect(inventory.typertGateway).toMatchObject({ + serviceKey: 'pluginInventory', + namespace: 'pluginInventory', + }) + expect(remoteMethods(inventory)).toEqual([ + { method: 'list', invocation: { kind: 'direct' } }, + ]) + }) + + it('projects current non-group Loader entries without a second cache', async () => { + const { ctx, inventory } = await harness() + const activeId = await ctx.loader.create({ name: 'cordis:active' }) + const pendingId = await ctx.loader.create({ name: 'cordis:pending' }) + const disabledId = await ctx.loader.create({ + name: 'cordis:not-installed', + disabled: true, + }) + await ctx.loader.create({ name: 'cordis:active', group: true }) + + expect(inventory.list()).toEqual({ + entries: [ + { + entryId: activeId, + displayId: activeId, + enabled: true, + fiberPhase: 'active', + }, + { + entryId: pendingId, + displayId: pendingId, + enabled: true, + fiberPhase: 'pending', + }, + { + entryId: disabledId, + displayId: disabledId, + enabled: false, + fiberPhase: null, + }, + ], + }) + + await ctx.loader.update(activeId, { disabled: true }) + expect(inventory.list().entries.find(entry => entry.entryId === activeId)).toEqual({ + entryId: activeId, + displayId: activeId, + enabled: false, + fiberPhase: null, + }) + + await ctx.loader.remove(pendingId) + expect(inventory.list().entries.some(entry => entry.entryId === pendingId)).toBe(false) + }) +}) diff --git a/packages/host/plugin-inventory/tsconfig.json b/packages/host/plugin-inventory/tsconfig.json new file mode 100644 index 0000000000..524783f8b8 --- /dev/null +++ b/packages/host/plugin-inventory/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/loader" + }, + { + "path": "../../util/brand" + }, + { + "path": "../../typert/type-meta" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2e71c12536..9d6c610aaa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -846,6 +846,9 @@ importers: '@deepseek-ai/dsh-goal': specifier: workspace:^ version: link:../../goal/goal + '@deepseek-ai/dsh-host-plugin-inventory': + specifier: workspace:^ + version: link:../../host/plugin-inventory '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -1593,6 +1596,9 @@ importers: '@deepseek-ai/dsh-client-ui-plugin-config': specifier: workspace:^ version: link:../../client/ui-plugin-config + '@deepseek-ai/dsh-client-ui-plugins': + specifier: workspace:^ + version: link:../../client/ui-plugins '@deepseek-ai/dsh-client-ui-question': specifier: workspace:^ version: link:../../client/ui-question @@ -1656,6 +1662,9 @@ importers: '@deepseek-ai/dsh-host-directory-picker-native': specifier: workspace:^ version: link:../../host/directory-picker-native + '@deepseek-ai/dsh-host-plugin-inventory': + specifier: workspace:^ + version: link:../../host/plugin-inventory '@deepseek-ai/dsh-host-webserver': specifier: workspace:^ version: link:../../host/webserver @@ -2582,6 +2591,48 @@ importers: specifier: ^18.2.0 version: 18.3.1 + packages/client/ui-plugins: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-remotes': + specifier: workspace:^ + version: link:../../api/remotes + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../runtime + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../test-runtime + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-settings': + specifier: workspace:^ + version: link:../ui-settings + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@testing-library/react': + specifier: ^16.1.0 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + react: + specifier: ^18.2.0 + version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) + packages/client/ui-primitives: dependencies: '@shikijs/langs': @@ -4942,6 +4993,28 @@ importers: specifier: workspace:^ version: link:../../support/invariants + packages/host/plugin-inventory: + dependencies: + zod: + specifier: ^4.4.3 + version: 4.4.3 + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-type-meta': + specifier: workspace:^ + version: link:../../typert/type-meta + packages/host/webserver: dependencies: '@deepseek-ai/schemastery': diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index b11a8845e8..f2270444ac 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -91,6 +91,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/client/ui-settings': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/client/ui-settings-general': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/client/ui-models': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, + 'packages/client/ui-plugins': { kind: 'none', reason: 'Browser-side inventory projection; registers nothing model-facing.' }, 'packages/client/locale': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/client/web': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/examples/agent-spine-demo': { kind: 'indirect', reason: 'The bundle only mounts model-facing child plugins.' }, @@ -105,6 +106,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/host/directory-picker-native': { kind: 'none', reason: 'The GUI-host picking backend registers nothing model-facing.' }, 'packages/host/webserver': { kind: 'none', reason: 'The HTTP carrier bridges browser and API handler and registers nothing model-facing.' }, 'packages/host/frontend-static': { kind: 'none', reason: 'The SPA dist server answers browser asset requests and registers nothing model-facing.' }, + 'packages/host/plugin-inventory': { kind: 'none', reason: 'Host-side read-only Loader projection; registers nothing model-facing.' }, 'packages/bundle/base': { kind: 'indirect', reason: 'The bundle is a patch-list carrier; each inserted row\'s package owns its model-facing behavior.' }, 'packages/bundle/headless': { kind: 'none', reason: 'The one-shot runner submits the task as an ordinary user message; prompts and tools belong to the composed base and headless bundles.' }, 'packages/llm/llm': { kind: 'none', reason: 'The adapter registry forwards already-assembled requests unchanged.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index ff8e58e361..b373642dab 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -161,6 +161,8 @@ "@deepseek-ai/dsh-host-apiproxy/client": ["./packages/host/apiproxy/src/fetch/client.ts"], "@deepseek-ai/dsh-host-apiproxy/*": ["./packages/host/apiproxy/src/*"], "@deepseek-ai/dsh-host-webserver": ["./packages/host/webserver/src"], + "@deepseek-ai/dsh-host-plugin-inventory": ["./packages/host/plugin-inventory/src"], + "@deepseek-ai/dsh-host-plugin-inventory/types": ["./packages/host/plugin-inventory/src/types.ts"], "@deepseek-ai/dsh-client-ui-slots": ["./packages/client/ui-slots/src"], "@deepseek-ai/dsh-client-ui-attachment": ["./packages/client/ui-attachment/src"], "@deepseek-ai/dsh-client-ui-primitives": ["./packages/client/ui-primitives/src"], @@ -199,6 +201,7 @@ "@deepseek-ai/dsh-client-ui-settings": ["./packages/client/ui-settings/src"], "@deepseek-ai/dsh-client-ui-settings-general": ["./packages/client/ui-settings-general/src"], "@deepseek-ai/dsh-client-ui-models": ["./packages/client/ui-models/src"], + "@deepseek-ai/dsh-client-ui-plugins": ["./packages/client/ui-plugins/src"], "@deepseek-ai/dsh-client-locale": ["./packages/client/locale/src"], "@deepseek-ai/dsh-client-web": ["./packages/client/web/src"], // sdk/ folders are role-named without their npm-side sdk/jsonrpc prefixes, diff --git a/tsconfig.client.json b/tsconfig.client.json index ce48a77ea9..6def6dddd9 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -81,6 +81,7 @@ { "path": "./packages/client/ui-settings" }, { "path": "./packages/client/ui-settings-general" }, { "path": "./packages/client/ui-models" }, + { "path": "./packages/client/ui-plugins" }, { "path": "./packages/client/locale" }, { "path": "./packages/client/web" }, { "path": "./apps/web" } diff --git a/tsconfig.host.json b/tsconfig.host.json index d4e7f7ba3b..2f055167a0 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -281,6 +281,7 @@ { "path": "./packages/host/directory-picker-browse" }, { "path": "./packages/host/directory-picker-native" }, { "path": "./packages/host/frontend-static" }, + { "path": "./packages/host/plugin-inventory" }, { "path": "./packages/host/webserver" }, { "path": "./packages/sdk/client" }, { "path": "./packages/sdk/protocol" }, From 4cf76f23622937d2672e616eeb699628ce90ad79 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Tue, 11 Aug 2026 07:12:37 -0700 Subject: [PATCH 69/81] Fix plugin status tag clipping --- .../ui-plugins/src/client/PluginSettingsSection.module.css | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/client/ui-plugins/src/client/PluginSettingsSection.module.css b/packages/client/ui-plugins/src/client/PluginSettingsSection.module.css index eb26d02298..c2cf16cf16 100644 --- a/packages/client/ui-plugins/src/client/PluginSettingsSection.module.css +++ b/packages/client/ui-plugins/src/client/PluginSettingsSection.module.css @@ -129,7 +129,6 @@ align-items: center; justify-content: space-between; gap: 12px; - width: 100%; min-height: 52px; border: 0; padding: 12px 14px; From 2614a2df93b038505fda3d505ff2b920f7cb1db2 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Tue, 11 Aug 2026 08:03:39 -0700 Subject: [PATCH 70/81] Refine plugin inventory card details --- .../settings-chrome/plugins.expected.md | 10 ++- packages/client/ui-plugins/README.i18n.yaml | 4 +- packages/client/ui-plugins/README.md | 2 +- packages/client/ui-plugins/README.zh.md | 2 +- .../client/PluginSettingsSection.module.css | 74 +++++++++++++++++++ .../src/client/PluginSettingsSection.tsx | 45 ++++++++++- .../client/ui-plugins/src/client/locales.ts | 18 +++-- .../ui-plugins/tests/components.spec.tsx | 19 ++++- 8 files changed, 153 insertions(+), 21 deletions(-) diff --git a/apps/web/tests/snapshots/settings-chrome/plugins.expected.md b/apps/web/tests/snapshots/settings-chrome/plugins.expected.md index e3b2e9ee54..9e8362a942 100644 --- a/apps/web/tests/snapshots/settings-chrome/plugins.expected.md +++ b/apps/web/tests/snapshots/settings-chrome/plugins.expected.md @@ -1,4 +1,6 @@ -- listitem "ui-settings, 存活, 已启用": - - strong: ui-settings - - img "存活" - - text: 已启用 +- listitem: + - button "ui-settings, 已挂载, 已启用": + - strong: ui-settings + - img "已挂载" + - text: 已启用 + - img diff --git a/packages/client/ui-plugins/README.i18n.yaml b/packages/client/ui-plugins/README.i18n.yaml index ce5113cde1..62085c3d6b 100644 --- a/packages/client/ui-plugins/README.i18n.yaml +++ b/packages/client/ui-plugins/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-plugins/README.md -README.md: c3236935b2a568c6996fcac8469f061a813cd95c -README.zh.md: 77810bccf95a55761518f89909fddd99818f686a +README.md: bb487d5e2cbd34406d83867997ede4d70b190d70 +README.zh.md: 48a11911509ea260aa9727d55c0b4df6efbfb1c9 diff --git a/packages/client/ui-plugins/README.md b/packages/client/ui-plugins/README.md index c3236935b2..bb487d5e2c 100644 --- a/packages/client/ui-plugins/README.md +++ b/packages/client/ui-plugins/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Read-only Plugins section for Web Settings. The browser plugin registers one localized `settings.section` contribution with id `plugin-inventory`, after Models, and lets the Settings shell supply its ordinary fallback icon. It performs no Remote read during plugin activation; mounting the section lazily calls `ctx.remote.pluginInventory.list()` through [`api-remotes`](../../api/remotes/README.md). -The page renders a searchable two-column catalog of compact cards. Each card uses the local Loader id as its title, a colored root-Fiber status dot, and a small effective-enablement tag. Loading, empty, no-match, and generic failure states stay local to the mounted component, and a failed read can be retried without exposing transport details. The registration uses `ctx.slots.inject()`, so it follows late Settings declaration, redeclaration, locale changes, and teardown without owning another global store. +The page renders a searchable two-column catalog of compact disclosure cards. Each collapsed card uses the local Loader id as its title, a colored root-Fiber status dot, and a small effective-enablement tag. Expanding one card reveals its Loader-tree entry value without a redundant field label, followed by the effective configuration and Cordis status. Loading, empty, no-match, and generic failure states stay local to the mounted component, and a failed read can be retried without exposing transport details. The registration uses `ctx.slots.inject()`, so it follows late Settings declaration, redeclaration, locale changes, and teardown without owning another global store. ## Model Experience diff --git a/packages/client/ui-plugins/README.zh.md b/packages/client/ui-plugins/README.zh.md index 77810bccf9..48a1191150 100644 --- a/packages/client/ui-plugins/README.zh.md +++ b/packages/client/ui-plugins/README.zh.md @@ -4,7 +4,7 @@ Web 设置中的只读“插件”分区。浏览器插件在“模型”之后注册一个 id 为 `plugin-inventory` 的本地化 `settings.section` 贡献,并由 Settings shell 提供常规的回退图标。插件激活期间不会读取 Remote;挂载该分区时,组件才通过 [`api-remotes`](../../api/remotes/README.md) 懒调用 `ctx.remote.pluginInventory.list()`。 -页面以可搜索的双列紧凑卡片展示清单。每张卡片使用 Loader 本地 id 作为标题,以彩色圆点表示根 Fiber 状态,以小标签表示有效启停状态。加载、空结果、无匹配结果与通用失败状态只属于已挂载组件;读取失败后可以重试,且不会暴露传输细节。注册使用 `ctx.slots.inject()`,因此能跟随 Settings 的延迟声明、重新声明、本地化变化与 teardown,而不拥有另一份全局 store。 +页面以可搜索的双列紧凑折叠卡片展示清单。每张收起的卡片使用 Loader 本地 id 作为标题,以彩色圆点表示根 Fiber 状态,以小标签表示有效启停状态。展开卡片后会直接展示 Loader 树条目值,不附加重复的字段标题,并列出有效配置状态与 Cordis 状态。加载、空结果、无匹配结果与通用失败状态只属于已挂载组件;读取失败后可以重试,且不会暴露传输细节。注册使用 `ctx.slots.inject()`,因此能跟随 Settings 的延迟声明、重新声明、本地化变化与 teardown,而不拥有另一份全局 store。 ## 模型体验 diff --git a/packages/client/ui-plugins/src/client/PluginSettingsSection.module.css b/packages/client/ui-plugins/src/client/PluginSettingsSection.module.css index c2cf16cf16..9429b60bb5 100644 --- a/packages/client/ui-plugins/src/client/PluginSettingsSection.module.css +++ b/packages/client/ui-plugins/src/client/PluginSettingsSection.module.css @@ -124,11 +124,18 @@ background: var(--dsw-alias-bg-layer-3); } +.card[data-open='true'] { + border-color: var(--dsw-alias-border-l1); + box-shadow: var(--dsw-shadow-lv1); +} + .cardContent { + box-sizing: border-box; display: flex; align-items: center; justify-content: space-between; gap: 12px; + width: 100%; min-height: 52px; border: 0; padding: 12px 14px; @@ -136,6 +143,17 @@ color: inherit; font: inherit; text-align: left; + cursor: pointer; +} + +.cardContent:hover, +.card[data-open='true'] > .cardContent { + background: var(--dsw-alias-interactive-bg-hover); +} + +.cardContent:focus-visible { + outline: 2px solid var(--dsw-alias-state-business-primary); + outline-offset: -2px; } .cardTitle { @@ -195,6 +213,56 @@ color: var(--dsw-alias-state-success-primary); } +.chevron { + flex: none; + color: var(--dsw-alias-label-tertiary); +} + +.card[data-open='true'] .chevron { + transform: rotate(180deg); +} + +.cardDetails { + border-top: 1px solid var(--dsw-alias-border-l2); + padding: 10px 14px 12px; + background: var(--dsw-alias-bg-module-platform); +} + +.entryValue { + display: block; + overflow-wrap: anywhere; + color: var(--dsw-alias-label-primary); + font-family: var(--ds-font-family-code); + font-size: 12px; + line-height: 18px; +} + +.details { + display: grid; + grid-template-columns: 76px minmax(0, 1fr); + gap: 6px 10px; + margin: 8px 0 0; +} + +.details div { + display: contents; +} + +.details dt { + color: var(--dsw-alias-label-tertiary); + font-size: 11px; + line-height: 17px; +} + +.details dd { + min-width: 0; + margin: 0; + overflow-wrap: anywhere; + color: var(--dsw-alias-label-secondary); + font-size: 12px; + line-height: 17px; +} + .visuallyHidden { position: absolute; width: 1px; @@ -205,6 +273,12 @@ white-space: nowrap; } +@media (prefers-reduced-motion: no-preference) { + .chevron { + transition: transform 140ms var(--ds-ease-in-out); + } +} + @media (max-width: 680px) { .cards { grid-template-columns: minmax(0, 1fr); diff --git a/packages/client/ui-plugins/src/client/PluginSettingsSection.tsx b/packages/client/ui-plugins/src/client/PluginSettingsSection.tsx index 2d33ac2e8b..fc04e9b12a 100644 --- a/packages/client/ui-plugins/src/client/PluginSettingsSection.tsx +++ b/packages/client/ui-plugins/src/client/PluginSettingsSection.tsx @@ -1,6 +1,9 @@ import { useEffect, useId, useMemo, useState, type ReactNode } from 'react' import type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client' -import { IconSearchOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import { + IconChevronDownOutline14, + IconSearchOutline16, +} from '@deepseek-ai/dsh-client-ui-primitives' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { PluginsKey } from './locales.ts' import css from './PluginSettingsSection.module.css' @@ -54,6 +57,7 @@ export function PluginSettingsSection({ list, t }: PluginSettingsSectionProps): const titleId = useId() const [request, setRequest] = useState(0) const [query, setQuery] = useState('') + const [expanded, setExpanded] = useState(null) const [state, setState] = useState({ status: 'loading' }) useEffect(() => { @@ -73,6 +77,12 @@ export function PluginSettingsSection({ list, t }: PluginSettingsSectionProps): [normalizedQuery, state], ) + useEffect(() => { + if (expanded !== null && !filteredEntries.some(entry => entry.entryId === expanded)) { + setExpanded(null) + } + }, [expanded, filteredEntries]) + const retry = (): void => { setState({ status: 'loading' }) setRequest(value => value + 1) @@ -115,14 +125,25 @@ export function PluginSettingsSection({ list, t }: PluginSettingsSectionProps):
    {filteredEntries.map((entry) => { const status = phaseLabel(entry.fiberPhase, t) + const open = expanded === entry.entryId + const detailId = `${titleId}-details-${encodeURIComponent(entry.entryId)}` return (
  • -
    +
    + + {open ? ( +
    + {entry.entryId} +
    +
    +
    {t('configuration')}
    +
    {t(entry.enabled ? 'enabledTag' : 'disabledTag')}
    +
    +
    +
    {t('cordis')}
    +
    {status}
    +
    +
    +
    + ) : null}
  • ) })} diff --git a/packages/client/ui-plugins/src/client/locales.ts b/packages/client/ui-plugins/src/client/locales.ts index 64745ce14f..c505296f38 100644 --- a/packages/client/ui-plugins/src/client/locales.ts +++ b/packages/client/ui-plugins/src/client/locales.ts @@ -13,11 +13,13 @@ export const zh = { emptySearch: '没有匹配的插件。', enabledTag: '已启用', disabledTag: '已停用', - unobserved: '无根 Fiber', + configuration: '配置状态', + cordis: 'Cordis 状态', + unobserved: '未挂载', pending: '等待依赖', loadingPhase: '加载中', - active: '存活', - failed: '失败', + active: '已挂载', + failed: '挂载失败', unloading: '卸载中', } satisfies Record @@ -37,10 +39,12 @@ export const en = { emptySearch: 'No matching plugins.', enabledTag: 'Enabled', disabledTag: 'Disabled', - unobserved: 'No root Fiber', - pending: 'Pending', + configuration: 'Configuration', + cordis: 'Cordis status', + unobserved: 'Not mounted', + pending: 'Waiting for dependencies', loadingPhase: 'Loading', - active: 'Active', - failed: 'Failed', + active: 'Mounted', + failed: 'Mount failed', unloading: 'Unloading', } satisfies Record diff --git a/packages/client/ui-plugins/tests/components.spec.tsx b/packages/client/ui-plugins/tests/components.spec.tsx index af49f96ad5..059d09b738 100644 --- a/packages/client/ui-plugins/tests/components.spec.tsx +++ b/packages/client/ui-plugins/tests/components.spec.tsx @@ -50,10 +50,25 @@ describe('PluginSettingsSection', () => { expect(screen.getAllByRole('listitem')).toHaveLength(6) expect(screen.getAllByText(en.enabledTag)).toHaveLength(5) expect(screen.getByText(en.disabledTag)).toBeTruthy() - for (const value of ['Active', 'Pending', 'Loading', 'Failed', 'Unloading', 'No root Fiber']) { + for (const value of [ + 'Mounted', + 'Waiting for dependencies', + 'Loading', + 'Mount failed', + 'Unloading', + 'Not mounted', + ]) { expect(screen.getByRole('img', { name: value })).toBeTruthy() } - expect(screen.getByRole('listitem', { name: 'active-name, Active, Enabled' })).toBeTruthy() + const active = screen.getByRole('button', { name: 'active-name, Mounted, Enabled' }) + expect(active.getAttribute('aria-expanded')).toBe('false') + fireEvent.click(active) + expect(active.getAttribute('aria-expanded')).toBe('true') + expect(view.container.querySelector('[data-loader-entry]')?.textContent).toBe('active') + expect(screen.getByText(en.configuration)).toBeTruthy() + expect(screen.getByText(en.cordis)).toBeTruthy() + fireEvent.click(active) + expect(view.container.querySelector('[data-loader-entry]')).toBeNull() }) it('filters by local id or Loader entry id', async () => { From eea356e7852713a95e8a109506191e998f993272 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Tue, 11 Aug 2026 08:54:29 -0700 Subject: [PATCH 71/81] Show module names for dynamic plugins --- .../src/client/PluginSettingsSection.tsx | 16 ++++++++++--- .../ui-plugins/tests/components.spec.tsx | 24 +++++++++---------- .../host/plugin-inventory/README.i18n.yaml | 4 ++-- packages/host/plugin-inventory/README.md | 2 +- packages/host/plugin-inventory/README.zh.md | 2 +- packages/host/plugin-inventory/src/index.ts | 2 +- packages/host/plugin-inventory/src/types.ts | 4 ++-- .../plugin-inventory/tests/inventory.spec.ts | 8 +++---- 8 files changed, 36 insertions(+), 26 deletions(-) diff --git a/packages/client/ui-plugins/src/client/PluginSettingsSection.tsx b/packages/client/ui-plugins/src/client/PluginSettingsSection.tsx index fc04e9b12a..6fdf058c02 100644 --- a/packages/client/ui-plugins/src/client/PluginSettingsSection.tsx +++ b/packages/client/ui-plugins/src/client/PluginSettingsSection.tsx @@ -45,10 +45,19 @@ function phaseLabel( return phase === null ? t('unobserved') : t(PHASE_KEYS[phase]) } +/** Compact a module specifier without guessing whether its Loader id was generated. */ +function moduleShortName(moduleName: string): string { + const unscoped = moduleName.startsWith('@') ? moduleName.slice(moduleName.indexOf('/') + 1) : moduleName + return unscoped + .replace(/^cordis:/, '') + .replace(/^cordis-plugin-/, '') + .replace(/^dsh-(?:host-|client-)?/, '') +} + /** Whether an inventory row matches the local catalog query. */ function matches(entry: PluginInventoryEntry, normalizedQuery: string): boolean { if (normalizedQuery.length === 0) return true - return [entry.displayId, entry.entryId] + return [entry.moduleName, entry.entryId] .some(value => value.toLocaleLowerCase().includes(normalizedQuery)) } @@ -125,6 +134,7 @@ export function PluginSettingsSection({ list, t }: PluginSettingsSectionProps):
      {filteredEntries.map((entry) => { const status = phaseLabel(entry.fiberPhase, t) + const title = moduleShortName(entry.moduleName) const open = expanded === entry.entryId const detailId = `${titleId}-details-${encodeURIComponent(entry.entryId)}` return ( @@ -139,12 +149,12 @@ export function PluginSettingsSection({ list, t }: PluginSettingsSectionProps): type="button" aria-expanded={open} aria-controls={detailId} - aria-label={`${entry.displayId}, ${status}, ${t(entry.enabled ? 'enabledTag' : 'disabledTag')}`} + aria-label={`${title}, ${status}, ${t(entry.enabled ? 'enabledTag' : 'disabledTag')}`} onClick={() => { setExpanded(current => current === entry.entryId ? null : entry.entryId) }} > - {entry.displayId} + {title} { ]) { expect(screen.getByRole('img', { name: value })).toBeTruthy() } - const active = screen.getByRole('button', { name: 'active-name, Mounted, Enabled' }) + const active = screen.getByRole('button', { name: 'hmr, Mounted, Enabled' }) expect(active.getAttribute('aria-expanded')).toBe('false') fireEvent.click(active) expect(active.getAttribute('aria-expanded')).toBe('true') - expect(view.container.querySelector('[data-loader-entry]')?.textContent).toBe('active') + expect(view.container.querySelector('[data-loader-entry]')?.textContent).toBe('8a1b2c3d') expect(screen.getByText(en.configuration)).toBeTruthy() expect(screen.getByText(en.cordis)).toBeTruthy() fireEvent.click(active) expect(view.container.querySelector('[data-loader-entry]')).toBeNull() }) - it('filters by local id or Loader entry id', async () => { + it('filters by module name or Loader entry id', async () => { render( SNAPSHOT)} />) const search = await screen.findByRole('searchbox', { name: en.search }) fireEvent.change(search, { target: { value: 'disabled-entry' } }) expect(screen.getAllByRole('listitem')).toHaveLength(1) - expect(screen.getByText('disabled-name')).toBeTruthy() + expect(screen.getByText('directory-picker-native')).toBeTruthy() - fireEvent.change(search, { target: { value: 'pending' } }) + fireEvent.change(search, { target: { value: 'cordis-plugin-hmr' } }) expect(screen.getAllByRole('listitem')).toHaveLength(1) - expect(screen.getByText('pending-name')).toBeTruthy() + expect(screen.getByText('hmr')).toBeTruthy() fireEvent.change(search, { target: { value: 'not-a-plugin' } }) expect(screen.queryAllByRole('listitem')).toHaveLength(0) diff --git a/packages/host/plugin-inventory/README.i18n.yaml b/packages/host/plugin-inventory/README.i18n.yaml index 761b9c6850..e9fc3f9a09 100644 --- a/packages/host/plugin-inventory/README.i18n.yaml +++ b/packages/host/plugin-inventory/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/plugin-inventory/README.md -README.md: d7a50824d337c66a30ba4332ffb1e36f9ad46547 -README.zh.md: e424b968f1f692fb30b3b0e49efe6c3cd5fadf70 +README.md: 23fbf07d7900ecc881f81b5da3f8cbe6a45669de +README.zh.md: 87058cde595b83e980b8f3cec4192e6099b8d9ea diff --git a/packages/host/plugin-inventory/README.md b/packages/host/plugin-inventory/README.md index d7a50824d3..23fbf07d79 100644 --- a/packages/host/plugin-inventory/README.md +++ b/packages/host/plugin-inventory/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Read-only Host projection of the current Cordis Loader tree. `PluginInventoryService` registers the `pluginInventory` service and publishes one generated direct Remote, `pluginInventory/list`. Every call reads `ctx.loader.entries()` directly, skips structural group rows, and returns the remaining entries in Loader order with only their Loader entry id, local display id, effective enablement, and current root Fiber phase. +Read-only Host projection of the current Cordis Loader tree. `PluginInventoryService` registers the `pluginInventory` service and publishes one generated direct Remote, `pluginInventory/list`. Every call reads `ctx.loader.entries()` directly, skips structural group rows, and returns the remaining entries in Loader order with only their Loader entry id, module specifier, effective enablement, and current root Fiber phase. The phase is `pending`, `loading`, `active`, `failed`, or `unloading`; it is `null` when the entry has no live root Fiber. The snapshot is intentionally point-in-time: Loader remains the sole lifecycle authority, while this package owns no cache, history, provenance model, event stream, or mutation path. Its public payload types live under `./types`, and TypeRT generates the Host and Client Remote artifacts exposed by `./typert` and `./remote`. diff --git a/packages/host/plugin-inventory/README.zh.md b/packages/host/plugin-inventory/README.zh.md index e424b968f1..87058cde59 100644 --- a/packages/host/plugin-inventory/README.zh.md +++ b/packages/host/plugin-inventory/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -当前 Cordis Loader 树的只读 Host 投影。`PluginInventoryService` 注册 `pluginInventory` 服务,并发布一个由 TypeRT 生成的直接 Remote:`pluginInventory/list`。每次调用都直接读取 `ctx.loader.entries()`,跳过结构性的 group 行,再按 Loader 顺序返回其余条目,并且只包含 Loader 条目 id、本地展示 id、有效启用状态与当前根 Fiber 阶段。 +当前 Cordis Loader 树的只读 Host 投影。`PluginInventoryService` 注册 `pluginInventory` 服务,并发布一个由 TypeRT 生成的直接 Remote:`pluginInventory/list`。每次调用都直接读取 `ctx.loader.entries()`,跳过结构性的 group 行,再按 Loader 顺序返回其余条目,并且只包含 Loader 条目 id、模块标识、有效启用状态与当前根 Fiber 阶段。 阶段为 `pending`、`loading`、`active`、`failed` 或 `unloading`;条目没有存活的根 Fiber 时则为 `null`。该快照刻意只表示调用当下:Loader 仍是唯一的生命周期权威,本包不拥有缓存、历史、来源模型、事件流或修改路径。公开 payload 类型位于 `./types`,TypeRT 生成由 `./typert` 与 `./remote` 导出的 Host 和 Client Remote 产物。 diff --git a/packages/host/plugin-inventory/src/index.ts b/packages/host/plugin-inventory/src/index.ts index 8aeafcebed..5bc4db936a 100644 --- a/packages/host/plugin-inventory/src/index.ts +++ b/packages/host/plugin-inventory/src/index.ts @@ -60,7 +60,7 @@ export class PluginInventoryService extends GatewayService { if (entry.options.group) continue entries.push({ entryId: pluginEntryId(entry.id), - displayId: entry.options.id, + moduleName: entry.options.name, enabled: !entry.disabled, fiberPhase: entry.fiber === undefined ? null : FIBER_PHASE[entry.fiber.state], }) diff --git a/packages/host/plugin-inventory/src/types.ts b/packages/host/plugin-inventory/src/types.ts index d1c81f5310..f5678fc3c2 100644 --- a/packages/host/plugin-inventory/src/types.ts +++ b/packages/host/plugin-inventory/src/types.ts @@ -15,8 +15,8 @@ export type PluginFiberPhase = /** One non-group Loader entry exposed to trusted clients. */ export interface PluginInventoryEntry { readonly entryId: PluginEntryId - /** Local Loader id used as the compact card title. */ - readonly displayId: string + /** Exact module specifier imported by the Loader entry. */ + readonly moduleName: string /** Effective Loader enablement, including disabled ancestor groups. */ readonly enabled: boolean readonly fiberPhase: PluginFiberPhase diff --git a/packages/host/plugin-inventory/tests/inventory.spec.ts b/packages/host/plugin-inventory/tests/inventory.spec.ts index a822fab2c2..e979d34306 100644 --- a/packages/host/plugin-inventory/tests/inventory.spec.ts +++ b/packages/host/plugin-inventory/tests/inventory.spec.ts @@ -56,19 +56,19 @@ describe('PluginInventoryService', () => { entries: [ { entryId: activeId, - displayId: activeId, + moduleName: 'cordis:active', enabled: true, fiberPhase: 'active', }, { entryId: pendingId, - displayId: pendingId, + moduleName: 'cordis:pending', enabled: true, fiberPhase: 'pending', }, { entryId: disabledId, - displayId: disabledId, + moduleName: 'cordis:not-installed', enabled: false, fiberPhase: null, }, @@ -78,7 +78,7 @@ describe('PluginInventoryService', () => { await ctx.loader.update(activeId, { disabled: true }) expect(inventory.list().entries.find(entry => entry.entryId === activeId)).toEqual({ entryId: activeId, - displayId: activeId, + moduleName: 'cordis:active', enabled: false, fiberPhase: null, }) From 46c0e3dba7b1c108a8f5d8a981c4ba0c2fdcfece Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:38:11 -0700 Subject: [PATCH 72/81] fix(ci): align plugin inventory with current contracts --- packages/api/remotes/src/client/index.ts | 1 + packages/client/ui-plugins/package.json | 2 +- .../src/client/PluginSettingsSection.tsx | 7 +++---- packages/client/ui-plugins/src/client/index.ts | 9 +++++++-- ...in.spec.tsx => browser-plugin.client.spec.tsx} | 8 +++++++- ...onents.spec.tsx => components.client.spec.tsx} | 15 ++++++++++++++- ...invariant.spec.ts => invariant.client.spec.ts} | 0 packages/host/plugin-inventory/package.json | 6 ++---- 8 files changed, 35 insertions(+), 13 deletions(-) rename packages/client/ui-plugins/tests/{browser-plugin.spec.tsx => browser-plugin.client.spec.tsx} (87%) rename packages/client/ui-plugins/tests/{components.spec.tsx => components.client.spec.tsx} (87%) rename packages/client/ui-plugins/tests/{invariant.spec.ts => invariant.client.spec.ts} (100%) diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index 2f077dfd75..026fd864f2 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -7,6 +7,7 @@ import pluginInventoryRemote from '@deepseek-ai/dsh-host-plugin-inventory/remote import type { TypeRTClientRemote } from '@deepseek-ai/dsh-type-meta' export type { TypeRTClientRemote as ClientRemote } from '@deepseek-ai/dsh-type-meta' +export type { PluginInventorySnapshot } from '@deepseek-ai/dsh-host-plugin-inventory/types' export type {} from '@deepseek-ai/dsh-commands/remote' export type {} from '@deepseek-ai/dsh-goal/remote' export type {} from '@deepseek-ai/dsh-host-plugin-inventory/remote' diff --git a/packages/client/ui-plugins/package.json b/packages/client/ui-plugins/package.json index cca1843929..07fb9d162c 100644 --- a/packages/client/ui-plugins/package.json +++ b/packages/client/ui-plugins/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-plugins", "description": "Read-only Cordis Loader plugin inventory in Web settings", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/client/ui-plugins/src/client/PluginSettingsSection.tsx b/packages/client/ui-plugins/src/client/PluginSettingsSection.tsx index 6fdf058c02..87d6486000 100644 --- a/packages/client/ui-plugins/src/client/PluginSettingsSection.tsx +++ b/packages/client/ui-plugins/src/client/PluginSettingsSection.tsx @@ -1,5 +1,5 @@ import { useEffect, useId, useMemo, useState, type ReactNode } from 'react' -import type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client' +import type { PluginInventorySnapshot } from '@deepseek-ai/dsh-api-remotes/client' import { IconChevronDownOutline14, IconSearchOutline16, @@ -11,10 +11,9 @@ import css from './PluginSettingsSection.module.css' /** Registration-side Remote face used by the section. */ export interface PluginSettingsSectionInjected { /** Read a current Host inventory snapshot. */ - list: ClientRemote['pluginInventory']['list'] + list: () => Promise } -type PluginInventorySnapshot = Awaited> type PluginInventoryEntry = PluginInventorySnapshot['entries'][number] type PluginFiberPhase = PluginInventoryEntry['fiberPhase'] @@ -119,7 +118,7 @@ export function PluginSettingsSection({ list, t }: PluginSettingsSectionProps): value={query} placeholder={t('search')} aria-label={t('search')} - onChange={event => setQuery(event.currentTarget.value)} + onChange={(event) => { setQuery(event.currentTarget.value) }} />
      diff --git a/packages/client/ui-plugins/src/client/index.ts b/packages/client/ui-plugins/src/client/index.ts index b3034a86f9..ccf12ab989 100644 --- a/packages/client/ui-plugins/src/client/index.ts +++ b/packages/client/ui-plugins/src/client/index.ts @@ -1,6 +1,5 @@ /** Read-only Host plugin inventory registered into Web Settings. */ -import type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client' import type {} from '@deepseek-ai/dsh-client-locale/client' import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' import type {} from '@deepseek-ai/dsh-client-ui-settings/client' @@ -28,7 +27,13 @@ export function apply(ctx: ClientContext): void { ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-plugins: dictionaries') const t = ctx.locale.bind(NS) - const list: ClientRemote['pluginInventory']['list'] = () => ctx.remote.pluginInventory.list() + const list: PluginSettingsSectionInjected['list'] = async () => { + const result = await ctx.remote.pluginInventory.list() + if (!result.ok) { + throw new Error(`pluginInventory.list failed: ${result.error.code}: ${result.error.message}`) + } + return result.value + } const injected = (): PluginSettingsSectionInjected => ({ list }) ctx.slots.inject('settings.section', () => ctx.slots.register({ diff --git a/packages/client/ui-plugins/tests/browser-plugin.spec.tsx b/packages/client/ui-plugins/tests/browser-plugin.client.spec.tsx similarity index 87% rename from packages/client/ui-plugins/tests/browser-plugin.spec.tsx rename to packages/client/ui-plugins/tests/browser-plugin.client.spec.tsx index c2e3c741fd..d9d8a43cd8 100644 --- a/packages/client/ui-plugins/tests/browser-plugin.spec.tsx +++ b/packages/client/ui-plugins/tests/browser-plugin.client.spec.tsx @@ -14,6 +14,9 @@ usePinnedBrowserLanguages('zh-CN') afterEach(cleanup) const EMPTY = { entries: [] } +type ListResult = + | { readonly ok: true; readonly value: typeof EMPTY } + | { readonly ok: false; readonly error: { readonly code: string; readonly message: string } } async function bench() { const ctx = new Context() @@ -26,7 +29,8 @@ async function bench() { } } new RemoteService(ctx) - const list = vi.fn(() => Promise.resolve(EMPTY)) + const list = vi.fn<() => Promise>() + .mockResolvedValue({ ok: true, value: EMPTY }) ctx.provide('remote.pluginInventory', { list }) return { ctx, slots: ctx.get('slots') as SlotsService, locale, list } } @@ -58,6 +62,8 @@ describe('ui-plugins browser plugin', () => { const injected = (entry.inject as unknown as () => PluginSettingsSectionInjected)() await expect(injected.list()).resolves.toEqual(EMPTY) expect(b.list).toHaveBeenCalledOnce() + b.list.mockResolvedValueOnce({ ok: false, error: { code: 'REMOTE_ERROR', message: 'unavailable' } }) + await expect(injected.list()).rejects.toThrow('pluginInventory.list failed: REMOTE_ERROR: unavailable') await b.ctx.fiber.dispose() }) diff --git a/packages/client/ui-plugins/tests/components.spec.tsx b/packages/client/ui-plugins/tests/components.client.spec.tsx similarity index 87% rename from packages/client/ui-plugins/tests/components.spec.tsx rename to packages/client/ui-plugins/tests/components.client.spec.tsx index 9e4fcbc5f3..9da8a79b0d 100644 --- a/packages/client/ui-plugins/tests/components.spec.tsx +++ b/packages/client/ui-plugins/tests/components.client.spec.tsx @@ -27,7 +27,7 @@ function props(list: PluginSettingsSectionInjected['list']): PluginSettingsSecti const SNAPSHOT = { entries: [ { entryId: '8a1b2c3d', moduleName: '@deepseek-ai/cordis-plugin-hmr', enabled: true, fiberPhase: 'active' }, - { entryId: 'pending', moduleName: '@fixture/pending-name', enabled: true, fiberPhase: 'pending' }, + { entryId: 'pending', moduleName: 'cordis:pending-name', enabled: true, fiberPhase: 'pending' }, { entryId: 'loading', moduleName: '@fixture/loading-name', enabled: true, fiberPhase: 'loading' }, { entryId: 'failed', moduleName: '@fixture/failed-name', enabled: true, fiberPhase: 'failed' }, { entryId: 'unloading', moduleName: '@fixture/unloading-name', enabled: true, fiberPhase: 'unloading' }, @@ -69,6 +69,14 @@ describe('PluginSettingsSection', () => { expect(screen.getByText(en.cordis)).toBeTruthy() fireEvent.click(active) expect(view.container.querySelector('[data-loader-entry]')).toBeNull() + + fireEvent.click(active) + fireEvent.change(screen.getByRole('searchbox', { name: en.search }), { + target: { value: 'disabled-entry' }, + }) + expect(view.container.querySelector('[data-loader-entry]')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'directory-picker-native, Not mounted, Disabled' })) + expect(screen.getAllByText(en.disabledTag)).toHaveLength(2) }) it('filters by module name or Loader entry id', async () => { @@ -111,5 +119,10 @@ describe('PluginSettingsSection', () => { const pending = render( deferred.promise)} />) pending.unmount() await act(async () => { deferred.resolve(SNAPSHOT) }) + + const deferredFailure = Promise.withResolvers() + const pendingFailure = render( deferredFailure.promise)} />) + pendingFailure.unmount() + await act(async () => { deferredFailure.reject(new Error('late failure')) }) }) }) diff --git a/packages/client/ui-plugins/tests/invariant.spec.ts b/packages/client/ui-plugins/tests/invariant.client.spec.ts similarity index 100% rename from packages/client/ui-plugins/tests/invariant.spec.ts rename to packages/client/ui-plugins/tests/invariant.client.spec.ts diff --git a/packages/host/plugin-inventory/package.json b/packages/host/plugin-inventory/package.json index 4da1a61f06..ac51ce4aa8 100644 --- a/packages/host/plugin-inventory/package.json +++ b/packages/host/plugin-inventory/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-host-plugin-inventory", "description": "Read-only Remote projection of current Cordis Loader plugin state", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, @@ -45,9 +45,7 @@ "lib/typert.host.js", "lib/typert.host.d.ts", "lib/typert.remote-client.js", - "lib/typert.remote-client.d.ts", - "lib/typert.remote-client.d.ts.map", - "src" + "lib/typert.remote-client.d.ts" ], "license": "BSD-3-Clause", "dependencies": { From d57463c2fbab8c17cc1107fb12171b0ee80d100d Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 12 Aug 2026 12:50:40 +0800 Subject: [PATCH 73/81] fix(web): end first-run onboarding on any usable provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The step and the Models page both asked one question of a join that describes every provider: is deepseek-official's credential stored? A user who configured some other route was taken over on every blank session, and the DeepSeek setup card opened over them on every visit to Models with a Cancel that could not close it — while clearing the add card's draft, because it shared the row-editor close handler. providerUsable(row) now answers what both surfaces need: the route is registered and whatever credential its profile names is stored. Readiness (renamed onboardingReadiness) ends on any usable row, needsSetup takes the same fact, and each card kind owns its own close handler. Fixes #2325 --- ...-onboarding-reads-every-provider.i18n.yaml | 6 + ...6-08-12-onboarding-reads-every-provider.md | 38 ++++ ...8-12-onboarding-reads-every-provider.zh.md | 38 ++++ .../tests/onboarding-usable-provider.e2e.ts | 128 +++++++++++ .../dismissed.expected.md | 71 +++++++ apps/web/tsconfig.json | 1 + packages/client/ui-models/README.i18n.yaml | 4 +- packages/client/ui-models/README.md | 4 +- packages/client/ui-models/README.zh.md | 4 +- .../src/client/DeepSeekOnboardingDialog.tsx | 18 +- .../ui-models/src/client/ModelsSection.tsx | 58 +++-- packages/client/ui-models/src/client/store.ts | 59 +++-- .../tests/components.client.spec.tsx | 201 +++++++++++------- .../ui-models/tests/readiness.client.spec.ts | 83 +++++--- tsconfig.host.json | 1 + 15 files changed, 557 insertions(+), 157 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.zh.md create mode 100644 apps/web/tests/onboarding-usable-provider.e2e.ts create mode 100644 apps/web/tests/snapshots/onboarding-usable-provider/dismissed.expected.md diff --git a/.agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.i18n.yaml new file mode 100644 index 0000000000..cc3873f137 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.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/bug-fix/2026-08-12-onboarding-reads-every-provider.md +2026-08-12-onboarding-reads-every-provider.md: 1f247a6c93257c24052f55eb4297ec3c9c3df06d +2026-08-12-onboarding-reads-every-provider.zh.md: fc6e43195a46eaea881f8b4bee3219b5e583b284 diff --git a/.agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.md b/.agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.md new file mode 100644 index 0000000000..1f247a6c93 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.md @@ -0,0 +1,38 @@ +# Agent Note: First-run readiness reads every provider, and the setup card closes + +Status: implemented + +English | [中文](2026-08-12-onboarding-reads-every-provider.zh.md) + +## Problem + +The first-run step and the Models page both asked one question — is `deepseek-official`'s credential stored? — of a join that describes every provider. Two defects followed from that single reading. + +A user who configured some other provider (a pi-ai gateway, a self-hosted route) and never wanted the official DeepSeek endpoint was taken over by the full-screen credential prompt on every blank session, with a working model already selected in the composer behind it. Nothing they could do short of storing a DeepSeek key would end it, because the step's readiness projection never looked at the row they had configured. + +On the Models page the same reading opened the DeepSeek setup card over them on every visit, and that card could not be closed: it was rendered from row data with no local state a Cancel could flip, so its Cancel button did nothing visible. Worse, it shared the row-editor/add/declare close handler, which unconditionally clears all three of those states — so cancelling the card that owned none of them discarded the add card's draft while staying open itself. + +## Decision + +One predicate answers what both surfaces actually need. `providerUsable(row)` is true when the route is registered with the adapter registry (`entry.active`) and whatever credential its resolved profile names is stored; a profile naming no reference authenticates through the provider's own path, as does a live route with no settings address, so neither owes this page a key. + +`onboardingReadiness` (renamed from `deepSeekReadiness`, which no longer describes what it reads) returns `provider-ready` as soon as any joined row is usable. Only a user with none of those reaches the official DeepSeek lookup, which is unchanged: it is the one route the prompt can offer a key field for. The gate subsumes two diagnostics the old projection carried — `settings-unavailable` and `credential-ref-unavailable` — because both described an active route the new gate now calls usable; the outcome for the user was already identical (the step completed without rendering). + +`needsSetup(row, anyUsable)` takes the same fact, so the setup card is the first-run posture alone. With another provider reachable, DeepSeek is an ordinary row carrying the missing-key dot, one Edit click from the same card. + +Each card kind now owns its own close handler. `closeSetup` records the provider in a component-local `dismissedSetup` set and touches nothing else; `closeEditor` keeps clearing the three states its cards own. Both route the post-save reload through one `announceSaved` helper. Dismissal is viewing state, like the open editor and the add card: a reload restores the first-run posture for a user still in it. + +## Alternatives considered + +- **Deriving readiness from the model catalog (`llm.models`) instead of the join.** It answers "can the user talk to something" most directly, but it costs a per-provider listing round trip on a surface that already holds the join, and a provider whose listing fails transiently would re-open onboarding. +- **Requiring `row.configured` in `providerUsable`.** It reads as the stricter check, and would exclude exactly the routes a deployment mounts through `cordis.yml` without a configurable-provider declaration — live routes serving models that this page cannot configure. Registration, not configurability, is what makes a provider usable. +- **Only adding the dismissal, leaving the card auto-opening.** It fixes the Cancel button and nothing else: a user with a working provider would still be handed the DeepSeek form on every visit to Models, which is the same misreading in a quieter form. +- **Persisting the dismissal to settings.** A durable "do not ask about DeepSeek" flag is a second fact about first-run state that can disagree with the join. The credential itself already ends the posture permanently, and every other card on this page is session-local. + +## Consequences + +Onboarding now ends for reasons the DeepSeek route knows nothing about, so the step's name is the last thing tying it to that adapter; a future step that offers more than one route to configure would replace the prompt, not the readiness projection. The narrowed diagnostic union means an unresolvable `llm-deepseek` settings address is reported as `provider-ready` rather than as its own reason — the user-visible behavior is unchanged, and the Models page remains the diagnostic surface. + +## Testing + +Package tests pin `providerUsable` over the four join states and `onboardingReadiness` over both the new gate and every surviving diagnostic; the section tests cover the first-run posture, the plain-row posture, and the cancel that collapses the setup card while the add card keeps its draft. The `onboarding-usable-provider` web e2e lane replays the whole scenario through the real wire: cancel with both cards open, configure `minimax-cn` instead, reload, and find no takeover — with one aria golden of the dismissed state. diff --git a/.agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.zh.md b/.agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.zh.md new file mode 100644 index 0000000000..fc6e43195a --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.zh.md @@ -0,0 +1,38 @@ +# Agent Note: First-run readiness reads every provider, and the setup card closes + +Status: implemented + +[English](2026-08-12-onboarding-reads-every-provider.md) | 中文 + +## Problem + +首次使用引导步骤与 Models 页都只向一个描述全部提供方的联接快照提出了同一个问题——`deepseek-official` 的凭据存了吗?两个缺陷由这一次读取而来。 + +配置了别的提供方(某个 pi-ai 网关、某条自建路由)、根本不打算用 DeepSeek 官方端点的用户,会在每一个空白会话上被全屏凭据提示接管,而其背后输入框里早已选好了一个可用模型。除了存入一把 DeepSeek 密钥,他们做什么都结束不了它——因为该步骤的就绪投影从不看他们已经配好的那一行。 + +在 Models 页上,同一次读取每次进入都会把 DeepSeek 设置卡片展开在他们面前,而这张卡片关不掉:它由行数据渲染而来,没有任何本地状态可供「取消」翻转,因此那颗取消按钮不产生任何可见效果。更糟的是,它与行内编辑卡/新增卡/自定义声明卡共用同一个关闭回调,而该回调会无条件清空那三个状态——于是取消一张它们一个都不拥有的卡片,反而丢弃了新增卡里的草稿,自己却仍然开着。 + +## Decision + +一个谓词回答两处界面真正需要的事实。`providerUsable(row)` 在路由已注册进适配器注册表(`entry.active`)、且其解析后 profile 所指名的凭据已存储时为真;不指名任何引用的 profile 走提供方自己的认证路径,没有 settings 地址的存活路由亦然,因此二者都不欠这个页面一把密钥。 + +`onboardingReadiness`(原名 `deepSeekReadiness`,该名称已不再描述它读取的内容)只要联接中有任意一行可用,就返回 `provider-ready`。只有二者皆无的用户才会走到官方 DeepSeek 查找,那部分保持不变:它是这条提示唯一能为其提供密钥输入框的路由。这道门槛吸收了旧投影携带的两个诊断——`settings-unavailable` 与 `credential-ref-unavailable`——因为二者描述的都是新门槛现在判为可用的活跃路由;对用户而言结果本就一致(该步骤不渲染直接完成)。 + +`needsSetup(row, anyUsable)` 接受同一个事实,因此设置卡片仅代表首次运行姿态。当另有可触达的提供方时,DeepSeek 就是一行带缺失密钥点的普通行,距离同一张卡片只有一次「编辑」点击。 + +现在每一类卡片各自拥有自己的关闭回调。`closeSetup` 把该提供方记入组件本地的 `dismissedSetup` 集合,别的一概不碰;`closeEditor` 继续清空它那些卡片所拥有的三个状态。两者都经由同一个 `announceSaved` 助手完成保存后的重载。关闭状态属于查看态,与展开的编辑卡和新增卡一样:对仍处于首次运行姿态的用户,重载会恢复该姿态。 + +## Alternatives considered + +- **从模型目录(`llm.models`)而非联接推导就绪状态。** 它最直接地回答「用户有没有能对话的东西」,但会在一个已经持有联接的界面上多花每提供方一次列举往返,而且某个提供方列举的瞬时失败会让引导重新弹出。 +- **在 `providerUsable` 中要求 `row.configured`。** 它读起来更严格,却会恰好排除部署通过 `cordis.yml` 挂载、没有可配置提供方声明的那些路由——它们是正在提供模型、只是这个页面配置不了的存活路由。使一个提供方可用的是注册,不是可配置性。 +- **只加关闭状态,保留卡片自动展开。** 那只修好取消按钮,别的什么都没修:已有可用提供方的用户每次进入 Models 仍会被塞一张 DeepSeek 表单,那是同一个误读的安静版本。 +- **把关闭状态持久化到 settings。** 一个「别再问 DeepSeek」的持久标志,是关于首次运行状态的第二个事实,可能与联接互相矛盾。凭据本身已经永久结束该姿态,而这个页面上其他每一张卡片都是会话内的。 + +## Consequences + +引导现在会因为 DeepSeek 路由一无所知的理由而结束,因此该步骤的名字是最后一处把它和那个适配器绑在一起的东西;未来若有一个步骤能提供不止一条可配置路由,替换掉的会是提示本身,而非就绪投影。收窄后的诊断联合意味着无法解析的 `llm-deepseek` settings 地址会被报为 `provider-ready` 而非它自己的理由——用户可见行为不变,Models 页仍是诊断界面。 + +## Testing + +包内测试针对四种联接状态钉住 `providerUsable`,并针对新门槛与每一个存留的诊断钉住 `onboardingReadiness`;分区测试覆盖首次运行姿态、普通行姿态,以及在新增卡保住草稿的同时折叠设置卡片的那次取消。`onboarding-usable-provider` web e2e 泳道通过真实协议重放整个场景:两张卡片都开着时取消、改配 `minimax-cn`、重载,然后不再出现接管——并附一份关闭后状态的 aria golden。 diff --git a/apps/web/tests/onboarding-usable-provider.e2e.ts b/apps/web/tests/onboarding-usable-provider.e2e.ts new file mode 100644 index 0000000000..09e3923064 --- /dev/null +++ b/apps/web/tests/onboarding-usable-provider.e2e.ts @@ -0,0 +1,128 @@ +// Keyless browser e2e: a user who configures some OTHER provider is not asked +// for the official DeepSeek key again, and the first-run setup card is a card +// they can close. The shipped DeepSeek adapter stays mounted without a +// credential throughout, so the only thing that ends onboarding here is the +// pi-ai route the user configures through the real wire. Zero model calls: +// configuration is pure settings/credentials/llm-domain traffic. +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { + acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, + launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/onboarding-usable-provider', import.meta.url)) +const DISMISSED_EXPECTED = join(SNAPSHOT_DIR, 'dismissed.expected.md') +const MODE = webSnapshotMode() +const CREDENTIAL_STEP = '添加一个 API Key 开始使用' + +describe.skipIf(MODE === 'record')('web e2e: another usable provider ends first-run onboarding', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold({ deepSeekMissingCredential: true }) + browser = await chromium.launch() + // The scenario asserts the shipped Chinese copy, so the browser asks for it. + page = await browser.newPage({ viewport: { width: 1440, height: 960 }, locale: ZH_BROWSER_LOCALE }) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('closes the setup card without discarding the add card beside it', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-onboarding-setup-card-cancel')) + const credentialStep = page.getByRole('region', { name: CREDENTIAL_STEP }) + await credentialStep.waitFor({ timeout: 15_000 }) + await credentialStep.getByRole('button', { name: '前往配置' }).click() + await credentialStep.waitFor({ state: 'detached', timeout: 15_000 }) + + const settings = page.getByRole('dialog', { name: '设置' }) + await settings.waitFor({ timeout: 10_000 }) + // Nothing is reachable yet, so DeepSeek presents itself as its open card. + const setupKey = settings.getByRole('textbox', { name: 'API 密钥', exact: true }) + await setupKey.waitFor({ timeout: 10_000 }) + + const add = settings.getByRole('button', { name: '添加提供方' }) + await expect.poll(async () => add.isEnabled(), { timeout: 10_000 }).toBe(true) + await add.click() + const pick = settings.getByLabel('提供方') + await pick.waitFor({ timeout: 10_000 }) + await pick.selectOption('minimax-cn') + await expect.poll( + async () => settings.getByRole('textbox', { name: 'API 密钥', exact: true }).count(), + { timeout: 10_000 }, + ).toBe(2) + + // Cancelling the setup card is the regression: it used to leave itself open + // and close the add card, discarding that draft. + await settings.getByRole('button', { name: '取消', exact: true }).first().click() + expect(await settings.getByLabel('提供方').count()).toBe(1) + await expect.poll( + async () => settings.getByRole('textbox', { name: 'API 密钥', exact: true }).count(), + { timeout: 10_000 }, + ).toBe(1) + // DeepSeek is now an ordinary row: a missing-key dot and an Edit button. + await settings.getByRole('button', { name: '编辑 DeepSeek (deepseek-official)' }).waitFor({ timeout: 10_000 }) + const dismissed = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(DISMISSED_EXPECTED, dismissed, MODE) + + expect(tripwire.warnings).toEqual([]) + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + + it('stops prompting for DeepSeek once the other provider can serve requests', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-onboarding-other-provider')) + const settings = page.getByRole('dialog', { name: '设置' }) + await settings.getByRole('textbox', { name: 'API 密钥', exact: true }).fill('sk-e2e-minimax') + await settings.getByRole('button', { name: '保存', exact: true }).click() + await settings.getByText('已保存 minimax-cn。', { exact: true }).waitFor({ timeout: 15_000 }) + + // Only minimax-cn is reachable; DeepSeek still holds no credential. + const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8') + expect(document).toContain('apiKeyEnv: MINIMAX_CN_API_KEY') + const credentials = await readFile(join(scaffold.harnessHome, '.credentials.yaml'), 'utf8') + expect(credentials).toContain('MINIMAX_CN_API_KEY: sk-e2e-minimax') + expect(credentials).not.toContain('DEEPSEEK_API_KEY') + + const warningsBefore = tripwire.warnings.length + await page.reload({ waitUntil: 'load' }) + acknowledgeReloadConnectionLoss(tripwire, warningsBefore) + await page.waitForSelector('[class*="frame"]', { timeout: 15_000 }) + // The regression: the step read only the official route's credential, so a + // fully configured user was taken over on every blank session. + await expect.poll( + async () => page.getByRole('region', { name: CREDENTIAL_STEP }).count(), + { timeout: 10_000 }, + ).toBe(0) + expect(await page.locator('[class*="onboardingStage"]').count()).toBe(0) + expect(await page.locator('#root').evaluate(root => (root as HTMLElement).inert)).toBe(false) + + // The Models page agrees: DeepSeek stays a row rather than reopening its + // setup card over a user who already has somewhere to send a request. + await page.getByRole('button', { name: '设置', exact: true }).click() + await settings.waitFor({ timeout: 10_000 }) + await settings.getByRole('button', { name: '模型' }).click() + await settings.getByRole('button', { name: '编辑 DeepSeek (deepseek-official)' }).waitFor({ timeout: 10_000 }) + expect(await settings.getByRole('textbox', { name: 'API 密钥', exact: true }).count()).toBe(0) + + expect((await page.content()).includes('sk-e2e-minimax')).toBe(false) + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + + it('keeps the fixture inventory closed', async () => { + await assertFixtureInventory(SNAPSHOT_DIR, ['dismissed.expected.md']) + }) +}) diff --git a/apps/web/tests/snapshots/onboarding-usable-provider/dismissed.expected.md b/apps/web/tests/snapshots/onboarding-usable-provider/dismissed.expected.md new file mode 100644 index 0000000000..182fadf973 --- /dev/null +++ b/apps/web/tests/snapshots/onboarding-usable-provider/dismissed.expected.md @@ -0,0 +1,71 @@ +- dialog "设置": + - navigation: + - text: 设置 + - button "通用设置": + - img + - text: 通用设置 + - button "模型": + - img + - text: 模型 + - button "Agent 预设": + - img + - text: Agent 预设 + - button "插件配置": + - img + - text: 插件配置 + - button "打开配置文件" + - button "关闭": + - img + - text: 关闭 + - heading "模型" [level=2] + - paragraph: 填入各提供方的 API 密钥即可使用其模型。 + - list: + - listitem: + - text: DeepSeek + - img "API 密钥缺失" + - button "编辑 DeepSeek (deepseek-official)": 编辑 + - text: 提供方 + - combobox "提供方": + - option "amazon-bedrock" + - option "ant-ling" + - option "anthropic" + - option "azure-openai-responses" + - option "cerebras" + - option "cloudflare-ai-gateway" + - option "cloudflare-workers-ai" + - option "deepseek" + - option "fireworks" + - option "github-copilot" + - option "google" + - option "google-vertex" + - option "groq" + - option "huggingface" + - option "kimi-coding" + - option "minimax" + - option "minimax-cn" [selected] + - option "mistral" + - option "moonshotai" + - option "moonshotai-cn" + - option "nvidia" + - option "openai" + - option "openai-codex" + - option "opencode" + - option "opencode-go" + - option "openrouter" + - option "qwen-token-plan" + - option "qwen-token-plan-cn" + - option "together" + - option "vercel-ai-gateway" + - option "xai" + - option "xiaomi" + - option "xiaomi-token-plan-ams" + - option "xiaomi-token-plan-cn" + - option "xiaomi-token-plan-sgp" + - option "zai" + - option "zai-coding-cn" + - text: API 密钥 + - textbox "API 密钥": + - /placeholder: 输入 API 密钥,或留空使用环境认证 + - group: 自定义设置 + - button "取消" + - button "保存" diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index b1153f4b8e..e656b099ea 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -42,6 +42,7 @@ "tests/default-model.e2e.ts", "tests/declared-reasoning.e2e.ts", "tests/onboarding-deepseek-config.e2e.ts", + "tests/onboarding-usable-provider.e2e.ts", "tests/remote-welcome.e2e.ts", "tests/workspace-management.e2e.ts", "tests/replay-round-trip.e2e.ts", diff --git a/packages/client/ui-models/README.i18n.yaml b/packages/client/ui-models/README.i18n.yaml index 1a4c53cc8d..671d4a2bfd 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: f6604f822412e9eb4574696f5b99e73fb7bd98ff -README.zh.md: 2500bbae0982571a9a88dd5c259749e3504728de +README.md: a8d030b7676e87709fb36b87a6599decc43e0b4b +README.zh.md: 63fb1b486acc2bca34792f485ffd89fb32749e43 diff --git a/packages/client/ui-models/README.md b/packages/client/ui-models/README.md index f6604f8224..a8d030b767 100644 --- a/packages/client/ui-models/README.md +++ b/packages/client/ui-models/README.md @@ -4,9 +4,9 @@ English | [中文](README.zh.md) Models settings plugin: the provider configuration page and official-DeepSeek conditional onboarding step. 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 pi-ai card additionally edits that route's **model list** and can ask the provider what it serves. A row labels API-key state with a green solid dot only when a referenced credential is confirmed configured, and with a red solid dot only when a named reference is confirmed missing; reference-free provider-native authentication and unavailable credential enrichment remain unmarked. 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. Leaving a new pi-ai provider's key blank saves a reference-free profile and therefore preserves provider-native authentication such as the Bedrock credential chain or Vertex ADC. A successful Apply emits a local accessible status message without echoing secret material. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), each adapter's model catalog, and the **display name** and **API protocol** of a pi-ai route the adapter does not ship. Those two are what a hand-declared route names for itself: the create card asks for both because nothing can default them, so the editor reaches both rather than leaving them to `settings.yaml`. Clearing the name unsets it and the route falls back to its id, which is what the placeholder shows; the protocol has no such fallback. A catalog route gets neither — it defaults its name from its catalog entry, and its models each carry their own protocol, so a route-level one could only override every one of them. The Provider ID stays fixed: it is the settings key, the name every other namespace and every logged session references, and the stem of a credential reference the page cannot read back to move. Reasoning effort is deliberately NOT among them: it is a per-model capability and the models under one provider disagree about which levels they accept, so a provider-scoped control could only be set to a value some of them reject — which would hide even the models that support the level. The composer's model picker offers each model its own levels, and a switch there records provider, model, and effort together as the default for the next session. The profile field stays in `settings.yaml` for a deployment that knows its route. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`/`maxTokens`; existing fields outside that curated set survive edits, while 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 its localized confirmation dialog names the provider in the title, description, and final action. A row is tagged **Custom** when the directory entry says the owning adapter ships nothing under that key. The tag follows that answer alone: having a stored profile does not make a route custom — narrowing a shipped provider's models stores one too — and an adapter that reports nothing leaves its rows untagged rather than being read as shipped. +Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere renders as its open setup card instead of a row, but only in the first-run posture — while no provider is registered with the credential its profile names — and only until the user closes that card, after which it is an ordinary row carrying the missing-key dot. Each card kind owns its own open state, so closing one never discards a draft in another. 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 pi-ai card additionally edits that route's **model list** and can ask the provider what it serves. A row labels API-key state with a green solid dot only when a referenced credential is confirmed configured, and with a red solid dot only when a named reference is confirmed missing; reference-free provider-native authentication and unavailable credential enrichment remain unmarked. 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. Leaving a new pi-ai provider's key blank saves a reference-free profile and therefore preserves provider-native authentication such as the Bedrock credential chain or Vertex ADC. A successful Apply emits a local accessible status message without echoing secret material. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), each adapter's model catalog, and the **display name** and **API protocol** of a pi-ai route the adapter does not ship. Those two are what a hand-declared route names for itself: the create card asks for both because nothing can default them, so the editor reaches both rather than leaving them to `settings.yaml`. Clearing the name unsets it and the route falls back to its id, which is what the placeholder shows; the protocol has no such fallback. A catalog route gets neither — it defaults its name from its catalog entry, and its models each carry their own protocol, so a route-level one could only override every one of them. The Provider ID stays fixed: it is the settings key, the name every other namespace and every logged session references, and the stem of a credential reference the page cannot read back to move. Reasoning effort is deliberately NOT among them: it is a per-model capability and the models under one provider disagree about which levels they accept, so a provider-scoped control could only be set to a value some of them reject — which would hide even the models that support the level. The composer's model picker offers each model its own levels, and a switch there records provider, model, and effort together as the default for the next session. The profile field stays in `settings.yaml` for a deployment that knows its route. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`/`maxTokens`; existing fields outside that curated set survive edits, while 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 its localized confirmation dialog names the provider in the title, description, and final action. A row is tagged **Custom** when the directory entry says the owning adapter ships nothing under that key. The tag follows that answer alone: having a stored profile does not make a route custom — narrowing a shipped provider's models stores one too — and an adapter that reports nothing leaves its rows untagged rather than being read as shipped. -The DeepSeek step projects `deepseek-official` readiness from that same joined snapshot after earlier onboarding pages complete. 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 credential reference completes the step without rendering, including a read-only launch-environment credential. Only a mounted, active adapter with a missing writable reference shows the page that opens Settings on Models, whose existing setup card exclusively owns key input and `credentials.set`; the step never holds a secret. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability completes the step without rendering so onboarding cannot block the product; Models remains the diagnostic surface. +The DeepSeek step projects first-run readiness from that same joined snapshot after earlier onboarding pages complete. The step exists to leave the user with a model to talk to, so ANY provider they can already reach ends it without rendering — a registered route whose named credential reference is stored, including a read-only launch-environment credential, or one whose profile names no reference at all and therefore authenticates natively. Only a user with none of those is asked about DeepSeek, the one route the prompt can offer a key field for. 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. Only a mounted, active adapter with a missing writable reference shows the page that opens Settings on Models, whose existing setup card exclusively owns key input and `credentials.set`; the step never holds a secret. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability completes the step without rendering so onboarding cannot block the product; Models remains the diagnostic surface. Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted provider row. The page only ever holds the REDACTED descriptor, so it mutates the fields it can see rather than rebuilding a section. DeepSeek's `models` is one replace-by-value array: the editor shows inherited effective rows until the first model edit materializes the complete array in the user layer, while reset unsets that override. A row carries the model id and display name; its context window and output cap sit behind the row's own disclosure, with the same fields the pi-ai provider form uses. Either capacity is typed as a count with an optional decimal `K` or `M` suffix (`256K`, `1M`; `1M` is 1000K) and stored as the plain count, spelled back in the shortest form that round-trips. Empty ids, duplicate ids, empty explicit names, and unreadable, non-positive, or fractional capacities fail before any write. A typed API key is judged on its own field the same way: after trimming, it must be non-empty and every character must be printable ASCII (`[\x21-\x7E]`), which is exactly what an HTTP header value can carry — the twin of `normalizeApiKey` in `@deepseek-ai/dsh-llm`, mirrored here because the source-plane split forbids importing it. A value matching a pasted `NAME=value` environment line or wrapped in matching quotes is refused as the same format failure; that pasted-line check runs only in the browser, since a false positive in a resolver would leave the environment refusing the key as well. A field holding only whitespace fails rather than being silently dropped, while an empty field is not a failure at all: it means keep the stored key on an editor card, and authenticate some other way on a create card. A refused key blocks both the write and the endpoint interrogation, so the page never spends a round trip to be told what the field already says. Each settings write carries the card's current `revision`, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict`; after settings commit, the card adopts the returned redacted user subtree and revision before storing the credential, which makes a failed credential stage retry only that stage. Deletion removes a configured, writable credential only when the profile names the page's derived `_API_KEY` target, then unsets the profile; both operations are idempotent, and a partial failure remains in the identified confirmation dialog for retry. Environment credentials, custom references, and credentials whose target cannot be identified remain untouched. Once loaded, the page subscribes directly to forwarded `settings/document-updated`, `credentials/updated`, and `llm/adapters-updated` owner events, plus local `connection/reset`, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling. diff --git a/packages/client/ui-models/README.zh.md b/packages/client/ui-models/README.zh.md index 2500bbae09..63fb1b486a 100644 --- a/packages/client/ui-models/README.zh.md +++ b/packages/client/ui-models/README.zh.md @@ -4,9 +4,9 @@ 模型设置插件:提供方配置页和按条件显示的 DeepSeek 官方首次使用引导步骤。它把三个协议领域汇聚为一个共享快照:`llm.providers`(可配置提供方目录,含每条路由的存活/休眠状态)、`settings.describe`(序列化 schema、分层脱敏值、secret 槽位)与 `credentials.describe`(不含值的 configured/source/writable 徽标);页面据此渲染提供方行,一次只展开一张编辑卡片,且不把路由存活状态呈现为提供方状态。 -行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。pi-ai 卡片还会编辑该路由的**模型列表**,并可查询提供方所提供的模型。只有确认引用的凭据已配置时,行才会以绿色实心点标示 API 密钥状态;只有确认具名引用缺失时,才会以红色实心点标示。无引用的提供方原生认证以及无法取得凭据补充信息时都不显示状态点。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。为新的 pi-ai 提供方留空密钥会保存一个不带引用的 profile,因此能保留提供方原生认证,例如 Bedrock 凭据链或 Vertex ADC。「应用」成功后会发出本地无障碍状态消息,且绝不回显任何机密内容。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点)、各适配器自己的模型目录,以及适配器未提供的那类 pi-ai 路由的**显示名称**与 **API 协议**。这两个字段是手工声明路由为自己命名的东西:创建卡片之所以索要它们,正因为没有东西能为它们兜底,因此编辑器也够得着这两个,而不是把它们留给 `settings.yaml`。清空名称即取消设置,路由退回自己的 id——占位符显示的就是它;协议没有这样的兜底。内置目录路由两个都不给:它的名称由目录条目兜底,它的每个模型各自带着自己的协议,路由级协议只可能把它们全部覆盖掉。Provider ID 保持固定:它是 settings 的键、是其他每个 namespace 与每一条已记录会话引用的名字,也是页面读不回、因而搬不走的凭据引用词干。推理等级刻意**不在**其中:它是按模型的能力,而同一提供方下各模型接受的档位并不一致,因此提供方级的控件只可能被设成其中一些模型会拒绝的值——那会连支持该档位的模型也一并隐藏。输入框的模型选择器为每个模型提供它自己的档位,在那里切换会把提供方、模型、推理等级一并记为下一个会话的默认值。profile 字段仍留在 `settings.yaml`,供清楚自己路由的部署使用。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`/`maxTokens`;精选集合以外的现有字段会在编辑后保留,其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base),其本地化确认对话框会在标题、说明和最终操作中点名该提供方。当目录条目表明拥有该路由的适配器在这个键下什么都没有时,该行会带上 **自定义** 标签。标签只跟随这个答案:存了 profile 并不使一条路由成为自定义——收窄一个内置提供方的模型同样会存下 profile——而什么都不回答的适配器,其路由保持无标签,不会被当成内置。 +行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方会渲染为其展开的设置卡片而非一行,但仅限首次运行姿态——即尚无任何提供方已注册且备齐其 profile 所指名的凭据——且仅持续到用户关闭该卡片为止,此后它就是一行带缺失密钥点的普通行。每一类卡片各自持有自己的展开状态,因此关掉其中一张绝不会丢弃另一张里的草稿。「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。pi-ai 卡片还会编辑该路由的**模型列表**,并可查询提供方所提供的模型。只有确认引用的凭据已配置时,行才会以绿色实心点标示 API 密钥状态;只有确认具名引用缺失时,才会以红色实心点标示。无引用的提供方原生认证以及无法取得凭据补充信息时都不显示状态点。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。为新的 pi-ai 提供方留空密钥会保存一个不带引用的 profile,因此能保留提供方原生认证,例如 Bedrock 凭据链或 Vertex ADC。「应用」成功后会发出本地无障碍状态消息,且绝不回显任何机密内容。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点)、各适配器自己的模型目录,以及适配器未提供的那类 pi-ai 路由的**显示名称**与 **API 协议**。这两个字段是手工声明路由为自己命名的东西:创建卡片之所以索要它们,正因为没有东西能为它们兜底,因此编辑器也够得着这两个,而不是把它们留给 `settings.yaml`。清空名称即取消设置,路由退回自己的 id——占位符显示的就是它;协议没有这样的兜底。内置目录路由两个都不给:它的名称由目录条目兜底,它的每个模型各自带着自己的协议,路由级协议只可能把它们全部覆盖掉。Provider ID 保持固定:它是 settings 的键、是其他每个 namespace 与每一条已记录会话引用的名字,也是页面读不回、因而搬不走的凭据引用词干。推理等级刻意**不在**其中:它是按模型的能力,而同一提供方下各模型接受的档位并不一致,因此提供方级的控件只可能被设成其中一些模型会拒绝的值——那会连支持该档位的模型也一并隐藏。输入框的模型选择器为每个模型提供它自己的档位,在那里切换会把提供方、模型、推理等级一并记为下一个会话的默认值。profile 字段仍留在 `settings.yaml`,供清楚自己路由的部署使用。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`/`maxTokens`;精选集合以外的现有字段会在编辑后保留,其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base),其本地化确认对话框会在标题、说明和最终操作中点名该提供方。当目录条目表明拥有该路由的适配器在这个键下什么都没有时,该行会带上 **自定义** 标签。标签只跟随这个答案:存了 profile 并不使一条路由成为自定义——收窄一个内置提供方的模型同样会存下 profile——而什么都不回答的适配器,其路由保持无标签,不会被当成内置。 -前序首次使用引导页面完成后,DeepSeek 步骤会从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此同 id 但未声明的存活路由不属于可修复配置。凭据引用已配置时,该步骤会直接完成而不渲染,其中包括来自启动环境且只读的凭据。只有已挂载且活跃、引用可写但尚未配置的适配器才会显示前往「设置」Models 分区的页面;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,该步骤绝不持有 secret。适配器缺失、路由不活跃、联接失败、部署只读或设置/凭据能力不可用时,该步骤均不渲染并直接完成,以免首次使用引导阻塞产品;Models 页仍是诊断界面。 +前序首次使用引导页面完成后,DeepSeek 步骤会从同一个联接快照得出首次运行就绪状态。该步骤的存在是为了让用户手上有一个可对话的模型,因此只要用户已经能触达**任何**一个提供方,它就直接完成而不渲染——已注册且其具名凭据引用已存储的路由(包括来自启动环境且只读的凭据),或 profile 根本不指名任何引用、因而走原生认证的路由。只有二者皆无的用户才会被问到 DeepSeek,即这条提示唯一能为其提供密钥输入框的路由。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此同 id 但未声明的存活路由不属于可修复配置。只有已挂载且活跃、引用可写但尚未配置的适配器才会显示前往「设置」Models 分区的页面;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,该步骤绝不持有 secret。适配器缺失、路由不活跃、联接失败、部署只读或设置/凭据能力不可用时,该步骤均不渲染并直接完成,以免首次使用引导阻塞产品;Models 页仍是诊断界面。 每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除提供方行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor,因此它只修改自己看得见的字段,而不重建分节。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器会显示继承而来的生效模型行,直到第一次模型编辑将完整数组具化到用户层;重置则会取消该覆盖。每个模型行承载模型 ID 与显示名称,其上下文窗口与最大输出 token 数则收在该行自己的折叠区里,使用与 pi-ai 提供方表单相同的字段。两项容量都按数值键入,可带十进制的 `K` 或 `M` 后缀(`256K`、`1M`;`1M` 即 1000K),存储为纯数值,回显时写成能够往返的最短形式。空 ID、重复 ID、显式填写的空名称,以及无法读取、非正数或非整数的容量都会在写入前失败。键入的 API 密钥同样在它自己的字段上被判定:trim 之后必须非空,且每个字符都是可打印 ASCII(`[\x21-\x7E]`)——这正是 HTTP 标头值所能承载的范围,是 `@deepseek-ai/dsh-llm` 中 `normalizeApiKey` 的孪生体,因源码平面分割禁止直接引入而在此镜像。与整行粘贴的 `NAME=value` 环境变量匹配或首尾成对引号包裹的值,会以同一条格式失败被拒绝;这项粘贴行检查只在浏览器中运行,因为 resolver 中的一次误判会连带让环境变量这条路也拒绝该密钥。只含空白的输入框会失败而不是被静默丢弃;留空则完全不是失败:在编辑卡片上意味着保持已存储的密钥,在新建卡片上则意味着以其他方式鉴权。被拒绝的密钥会同时拦截写入与端点探测,因此页面不会白花一次往返去换取字段上已经写明的答案。每次 settings 写入都携带卡片当前的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝;settings 提交成功后,卡片会在存储凭据前采用响应返回的脱敏用户子树与 revision,因此凭据阶段失败时,重试只会重复该阶段。删除操作只会在 profile 指向页面派生的 `_API_KEY` 目标时清除已配置且可写的凭据,随后取消设置 profile;两项操作都具备幂等性,部分失败会停留在点名目标的确认对话框中供重试。环境凭据、自定义引用和无法识别目标的凭据保持不变。页面加载完成后会直接订阅转发的 owner 事件 `settings/document-updated`、`credentials/updated`、`llm/adapters-updated`,以及本地 `connection/reset`,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。 diff --git a/packages/client/ui-models/src/client/DeepSeekOnboardingDialog.tsx b/packages/client/ui-models/src/client/DeepSeekOnboardingDialog.tsx index c8668c3700..302d4592f8 100644 --- a/packages/client/ui-models/src/client/DeepSeekOnboardingDialog.tsx +++ b/packages/client/ui-models/src/client/DeepSeekOnboardingDialog.tsx @@ -1,7 +1,9 @@ /** * Official-DeepSeek first-run step. Readiness comes from the same - * provider/settings/credential join as the Models page; the prompt only - * routes the user to that page's single credential editor. + * provider/settings/credential join as the Models page: any provider the user + * can already talk to ends the step, and only a user with none is offered the + * official DeepSeek route. The prompt itself only routes to that page's single + * credential editor. */ import { useEffect, useRef } from 'react' @@ -10,7 +12,7 @@ import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import { BrandWordmark, Button, OnboardingSurface } from '@deepseek-ai/dsh-client-ui-primitives' import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react' import type { ModelsSettingsState, ModelsSettingsStore } from './store.ts' -import { deepSeekReadiness } from './store.ts' +import { onboardingReadiness } from './store.ts' import type { en } from './locales.ts' import styles from './DeepSeekOnboardingDialog.module.css' @@ -34,15 +36,15 @@ function assertNever(_value: never): never { } /** - * Prompt a first-run user to open Models while the official adapter exists - * and its effective credential is not configured. + * Prompt a first-run user to open Models while no provider can serve requests + * and the official adapter exists with an unconfigured effective credential. * @param props - settings-shell owner state and Models feature dependencies. * @returns the onboarding page or null when onboarding needs no intervention. */ export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps): ReactNode { const { complete, openSection, controller, useSnapshot, t } = props const state = useSnapshot(snapshot => snapshot) - const readiness = deepSeekReadiness(state) + const readiness = onboardingReadiness(state) const titleRef = useRef(null) useEffect(() => { @@ -52,7 +54,7 @@ export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps): useEffect(() => { if ( readiness.kind === 'adapter-absent' - || readiness.kind === 'configured' + || readiness.kind === 'provider-ready' || readiness.kind === 'unavailable' ) complete() }, [complete, readiness.kind]) @@ -72,7 +74,7 @@ export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps): switch (readiness.kind) { case 'loading': case 'adapter-absent': - case 'configured': + case 'provider-ready': case 'unavailable': return null case 'credential-missing': diff --git a/packages/client/ui-models/src/client/ModelsSection.tsx b/packages/client/ui-models/src/client/ModelsSection.tsx index 1eba48903e..5fe5647b88 100644 --- a/packages/client/ui-models/src/client/ModelsSection.tsx +++ b/packages/client/ui-models/src/client/ModelsSection.tsx @@ -3,11 +3,13 @@ * directory, settings namespaces, and credential states, with one editor * card at a time. Rows expose only confirmed API-key state through accessible * solid configured or missing dots. 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, while a provider removal first requires - * confirmation; the page re-renders from pushed invalidations or the - * post-apply reload. + * configured key renders as its open setup card instead of a row, but only in + * the first-run posture — no provider on the page can serve requests yet — and + * only until the user closes that card; the add flow is a card carrying the + * dormant-provider select. Each card kind owns its own open state, so closing + * one never discards a draft in another. Every 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' @@ -16,7 +18,7 @@ import type { IApiClient } from '@deepseek-ai/dsh-api-remotes/client' import { Button, IconPlusOutline16, Modal } from '@deepseek-ai/dsh-client-ui-primitives' import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react' import { CustomProviderCard } from './CustomProviderCard.tsx' -import { deriveKeyRef, messageOf, protocolChoices } from './store.ts' +import { deriveKeyRef, messageOf, protocolChoices, providerUsable } from './store.ts' import type { ModelsSettingsState, ModelsSettingsStore, ProviderRow } from './store.ts' import { ProviderEditor, type ProviderEditorProps } from './ProviderEditor.tsx' import type { en } from './locales.ts' @@ -116,11 +118,15 @@ export async function removeProviderProfile( /** * Whether a whole-section provider still needs its first key: an unconfigured - * credential opens the setup card instead of showing a row. + * credential opens the setup card instead of showing a row. This is the + * first-run posture alone — a user who can already reach some provider gets an + * ordinary row with the missing-key dot, since nothing here is blocking them. * @param row - the joined provider row. + * @param anyUsable - whether any joined row can already serve requests. * @returns whether to render the setup card. */ -export function needsSetup(row: ProviderRow): boolean { +export function needsSetup(row: ProviderRow, anyUsable: boolean): boolean { + if (anyUsable) return false if (row.entry.settingsPath.length > 0) return false return row.credential?.configured !== true } @@ -178,17 +184,32 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode { const [deleteFailure, setDeleteFailure] = useState(undefined) const [savedTarget, setSavedTarget] = useState(undefined) const [declaring, setDeclaring] = useState(false) + const [dismissedSetup, setDismissedSetup] = useState>(() => new Set()) + + const announceSaved = (target: ProviderIdentity): void => { + // Announced only once the refreshed directory is in the snapshot the + // notice reads its name from: an apply can rename the route, and the + // target captured when the card opened still carries the old name. + void controller.load().then(() => { setSavedTarget(target) }) + } const closeEditor = (changed: boolean, target: ProviderIdentity): void => { setEditing(undefined) setAdding(false) setDeclaring(false) - if (changed) { - // Announced only once the refreshed directory is in the snapshot the - // notice reads its name from: an apply can rename the route, and the - // target captured when the card opened still carries the old name. - void controller.load().then(() => { setSavedTarget(target) }) - } + if (changed) announceSaved(target) + } + + /** + * Close a setup card, which owns none of the state above: the row-editor, + * add, and declare cards each own one of those, so clearing them here would + * discard a draft the user opened beside this card. Dismissal is this card's + * own — the provider falls back to an ordinary row for the rest of the + * session, and reopens through Edit. + */ + const closeSetup = (changed: boolean, target: ProviderIdentity): void => { + setDismissedSetup(previous => new Set([...previous, target.provider])) + if (changed) announceSaved(target) } const closeDelete = (): void => { @@ -238,6 +259,9 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode { ? savedTarget : { provider: savedRow.entry.provider, displayName: savedRow.entry.displayName } + // One fact decides both first-run postures on this page and the onboarding + // step: whether the user already has a provider to talk to. + const anyUsable = state.rows.some(providerUsable) const configured = state.rows.filter(row => row.configured) const addable = state.rows.filter(row => !row.configured && row.entry.settingsNs !== '') const addTarget = adding ? editing : undefined @@ -265,9 +289,9 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode { const namespace = state.namespaces.get(target.settingsNs) /* v8 ignore next -- the join marks a row configured only when its namespace resolved */ if (namespace === undefined) return null - if (needsSetup(row)) { + if (needsSetup(row, anyUsable) && !dismissedSetup.has(row.entry.provider)) { // First-run posture: the provider exists but has no key — the - // setup card IS its presence on the page. + // setup card IS its presence on the page, until the user closes it. return (
    • {renderProviderEditor({ @@ -276,7 +300,7 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode { api, t, readOnly: !state.writable, - onClose: (changed) => { closeEditor(changed, target) }, + onClose: (changed) => { closeSetup(changed, target) }, })}
    • ) diff --git a/packages/client/ui-models/src/client/store.ts b/packages/client/ui-models/src/client/store.ts index 9cc2cb7c77..4389b9a6cb 100644 --- a/packages/client/ui-models/src/client/store.ts +++ b/packages/client/ui-models/src/client/store.ts @@ -189,32 +189,49 @@ export class ModelsSettingsStore { } } -/** DeepSeek onboarding readiness derived only from the shared Models join. */ -export type DeepSeekReadiness = +/** + * Whether a joined row can serve model requests as it stands: the route is + * registered with the adapter registry, and whatever credential its resolved + * profile names is stored. A profile naming no reference authenticates through + * the provider's own path (the Bedrock chain, Vertex ADC, a gateway that needs + * nothing), as does a live route with no settings address at all, so neither + * owes this page a key. + * @param row - one joined provider row. + * @returns whether the user already has this provider to talk to. + */ +export function providerUsable(row: ProviderRow): boolean { + if (!row.entry.active) return false + if (row.apiKeyEnv === undefined) return true + return row.credential?.configured === true +} + +/** First-run onboarding readiness derived only from the shared Models join. */ +export type OnboardingReadiness = | { kind: 'loading' } | { kind: 'adapter-absent' } - | { kind: 'configured' } + | { kind: 'provider-ready' } | { kind: 'credential-missing' } | { kind: 'unavailable' reason: | 'load-failed' | 'provider-inactive' - | 'settings-unavailable' - | 'credential-ref-unavailable' | 'credentials-unavailable' | 'settings-read-only' | 'credential-read-only' } /** - * Project official-DeepSeek readiness from the provider/settings/credential - * join used by the Models page. A missing official configurable-provider + * Project first-run readiness from the provider/settings/credential join used + * by the Models page. The step exists to leave the user with a model to talk + * to, so ANY usable provider ends it; only when none exists does the official + * DeepSeek route — the one route the prompt can offer a key field for — decide + * whether prompting can help. A missing official configurable-provider * declaration means the adapter is not repairable by navigating to Models. * @param state - current shared Models join snapshot. * @returns the onboarding state without reading a parallel fact source. */ -export function deepSeekReadiness(state: ModelsSettingsState): DeepSeekReadiness { +export function onboardingReadiness(state: ModelsSettingsState): OnboardingReadiness { if ((state.status === 'idle' || state.status === 'loading') && state.rows.length === 0) { return { kind: 'loading' } } @@ -224,6 +241,7 @@ export function deepSeekReadiness(state: ModelsSettingsState): DeepSeekReadiness reason: 'load-failed', } } + if (state.rows.some(providerUsable)) return { kind: 'provider-ready' } const row = state.rows.find(candidate => candidate.entry.provider === 'deepseek-official' && candidate.entry.settingsNs === 'llm-deepseek' @@ -235,33 +253,14 @@ export function deepSeekReadiness(state: ModelsSettingsState): DeepSeekReadiness reason: 'provider-inactive', } } - if (!row.configured) { - return { - kind: 'unavailable', - reason: 'settings-unavailable', - } - } - if (row.apiKeyEnv === undefined) { - return { - kind: 'unavailable', - reason: 'credential-ref-unavailable', - } - } - if (state.credentialError !== null) { + // Past the usable gate an active route names a reference it has no stored + // credential for, so the remaining questions are all about that credential. + if (state.credentialError !== null || row.credential === undefined) { return { kind: 'unavailable', reason: 'credentials-unavailable', } } - if (row.credential === undefined) { - return { - kind: 'unavailable', - reason: 'credentials-unavailable', - } - } - if (row.credential.configured) { - return { kind: 'configured' } - } if (!state.writable) { return { kind: 'unavailable', diff --git a/packages/client/ui-models/tests/components.client.spec.tsx b/packages/client/ui-models/tests/components.client.spec.tsx index b1582a5fb8..01f0a32349 100644 --- a/packages/client/ui-models/tests/components.client.spec.tsx +++ b/packages/client/ui-models/tests/components.client.spec.tsx @@ -23,6 +23,8 @@ afterEach(cleanup) const t: ModelsSectionInjected['t'] = key => en[key] const OPENAI_TARGET = { provider: 'openai', displayName: 'openai' } const openaiCopy = (template: string): string => providerCopy(template, OPENAI_TARGET) +const DEEPSEEK_TARGET = { provider: 'deepseek-official', displayName: 'DeepSeek' } +const deepSeekCopy = (template: string): string => providerCopy(template, DEEPSEEK_TARGET) /** Open one row's capacity disclosure (1-based, as the labels read). */ function expandRow(position: number): void { @@ -181,8 +183,8 @@ function scriptedFace(overrides: { type WireFace = ConstructorParameters[0] -async function mountSection(overrides: Parameters[0] = {}) { - const { face, update, replace, mutate, set, unset } = scriptedFace(overrides) +async function mountFace(scripted: ReturnType) { + const { face, update, replace, mutate, set, unset } = scripted const controller = new ModelsSettingsStore(face as unknown as WireFace) await controller.load() const injected: ModelsSectionInjected = { @@ -195,6 +197,34 @@ async function mountSection(overrides: Parameters[0] = {}) return { view, face, update, replace, mutate, set, unset, controller } } +async function mountSection(overrides: Parameters[0] = {}) { + return mountFace(scriptedFace(overrides)) +} + +/** + * Mount for a user who cannot reach any provider yet: no credential is stored + * anywhere, so the whole-section DeepSeek route owns the first-run setup card. + */ +async function mountFirstRun(overrides: Parameters[0] = {}) { + const scripted = scriptedFace(overrides) + scripted.face.credentials.describe.mockImplementation((payload: { refs: string[] }) => + Promise.resolve(ok({ + credentials: Object.fromEntries(payload.refs.map(ref => [ref, { configured: false, writable: true }])), + }))) + return mountFace(scripted) +} + +/** + * Mount and open the DeepSeek editor. The shared fixture already has a usable + * openai route, so DeepSeek is an ordinary row whose card opens through Edit + * rather than by itself. + */ +async function mountDeepSeekCard(overrides: Parameters[0] = {}) { + const mounted = await mountSection(overrides) + fireEvent.click(screen.getByRole('button', { name: deepSeekCopy(en.editProvider) })) + return mounted +} + describe('ModelsSection', () => { it('renders nothing before the slot injects its dependencies', () => { const uninjected = {} as ModelsSectionProps @@ -202,20 +232,32 @@ describe('ModelsSection', () => { expect(document.body.textContent).toBe('') }) - it('renders the unkeyed whole-section provider as an open setup card beside the rows', async () => { - await mountSection() - // DeepSeek has no configured credential and no stored apiKey → setup card. + it('renders the unkeyed whole-section provider as an open setup card in the first-run posture', async () => { + await mountFirstRun() + // Nothing is reachable yet, and DeepSeek has no configured credential and + // no stored apiKey → setup card. expect(screen.getByText('DeepSeek')).toBeTruthy() expect(screen.getByLabelText(en.keyInput)).toBeTruthy() expect(screen.getByText('openai')).toBeTruthy() expect(screen.queryByText('Active')).toBeNull() expect(screen.queryByText('Inactive')).toBeNull() + expect(screen.getByText(en.add)).toBeTruthy() + }) + + it('leaves the unkeyed provider a plain row once another provider is usable', async () => { + await mountSection() + // openai's key is stored, so the user is not blocked and nothing on the + // page opens itself over them. + expect(screen.queryByLabelText(en.keyInput)).toBeNull() const configured = screen.getByRole('img', { name: en.credentialConfigured }) expect(configured.getAttribute('title')).toBe(en.credentialConfigured) expect(configured.className).toContain('credentialDotConfigured') expect(configured.closest('li')?.textContent).toContain('openai') - expect(screen.queryByRole('img', { name: en.credentialMissing })).toBeNull() - expect(screen.getByText(en.add)).toBeTruthy() + const missing = screen.getByRole('img', { name: en.credentialMissing }) + expect(missing.closest('li')?.textContent).toContain('DeepSeek') + // The card is still one click away. + fireEvent.click(screen.getByRole('button', { name: deepSeekCopy(en.editProvider) })) + expect(screen.getByLabelText(en.keyInput)).toBeTruthy() }) it('marks only a confirmed missing reference and leaves native or unavailable state unmarked', async () => { @@ -241,7 +283,7 @@ describe('ModelsSection', () => { }) it('turns the setup card into a row once the credential reports configured', async () => { - const { face } = await mountSection() + const { face } = await mountFirstRun() face.credentials.describe.mockImplementation((payload: { refs: string[] }) => Promise.resolve(ok({ credentials: Object.fromEntries(payload.refs.map(ref => [ref, { configured: true, writable: true }])), }))) @@ -259,7 +301,7 @@ describe('ModelsSection', () => { expect(screen.queryByLabelText(en.keyInput)).toBeNull() }) - it('decides setup need from the joined credential state', () => { + it('decides setup need from the joined credential state and the first-run posture', () => { const entry = { provider: 'p', displayName: 'p', settingsNs: 'llm-deepseek', settingsPath: [], active: true } const row = (credential: ProviderRow['credential']): ProviderRow => ({ entry, @@ -268,10 +310,13 @@ describe('ModelsSection', () => { apiKeyEnv: 'X', credential, }) - expect(needsSetup(row(undefined))).toBe(true) - expect(needsSetup(row({ configured: true, writable: true }))).toBe(false) + expect(needsSetup(row(undefined), false)).toBe(true) + expect(needsSetup(row({ configured: true, writable: true }), false)).toBe(false) const nested = { ...row(undefined), entry: { ...entry, settingsPath: ['providers', 'x'] } } - expect(needsSetup(nested)).toBe(false) + expect(needsSetup(nested, false)).toBe(false) + // A user who can already reach some provider is not in the first-run + // posture, so nothing on the page opens itself. + expect(needsSetup(row(undefined), true)).toBe(false) }) it('derives conventional credential references from route ids', () => { @@ -296,7 +341,7 @@ describe('ModelsSection', () => { }) it('stores a typed key write-only from the setup card without touching settings', async () => { - const { set, update, face } = await mountSection() + const { set, update, face } = await mountFirstRun() const key = screen.getByLabelText(en.keyInput) fireEvent.change(key, { target: { value: ' sk-live ' } }) fireEvent.click(screen.getByText(en.apply)) @@ -311,7 +356,7 @@ describe('ModelsSection', () => { }) it('applies customized deepseek fields as path ops', async () => { - const { mutate } = await mountSection({ + const { mutate } = await mountDeepSeekCard({ mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))), }) fireEvent.click(screen.getByText(en.customized)) @@ -332,7 +377,7 @@ describe('ModelsSection', () => { }) it('materializes inherited models and adds an arbitrary DeepSeek id', async () => { - const { mutate } = await mountSection({ + const { mutate } = await mountDeepSeekCard({ mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))), }) fireEvent.click(screen.getByText(en.customized)) @@ -366,7 +411,7 @@ describe('ModelsSection', () => { }) it('rejects duplicate DeepSeek model ids before writing', async () => { - const { mutate } = await mountSection() + const { mutate } = await mountDeepSeekCard() fireEvent.click(screen.getByText(en.customized)) fireEvent.click(screen.getByText(en.addModel)) const ids = screen.getAllByLabelText(new RegExp(en.modelId)) @@ -436,7 +481,7 @@ describe('ModelsSection', () => { }) it('accepts a suffixed context window and stores the plain count', async () => { - const { mutate } = await mountSection({ + const { mutate } = await mountDeepSeekCard({ mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))), }) fireEvent.click(screen.getByText(en.customized)) @@ -476,7 +521,7 @@ describe('ModelsSection', () => { }) it('keeps unreadable context-window text on screen and refuses the write', async () => { - const { mutate } = await mountSection() + const { mutate } = await mountDeepSeekCard() fireEvent.click(screen.getByText(en.customized)) expandRow(1) expandRow(2) @@ -539,7 +584,7 @@ describe('ModelsSection', () => { // The regression: one active buffer meant editing a second row displaced // the first, which then fell back to rendering its stored NaN as `NaN` — // losing the text the user was told they could still correct. - await mountSection() + await mountDeepSeekCard() fireEvent.click(screen.getByText(en.customized)) expandRow(1) expandRow(2) @@ -553,7 +598,7 @@ describe('ModelsSection', () => { }) it('re-keys the typed text around a removed row', async () => { - await mountSection() + await mountDeepSeekCard() fireEvent.click(screen.getByText(en.customized)) const windows = (): HTMLInputElement[] => capacityInputs(en.contextWindow) const removeRow = (at: number): void => { @@ -587,7 +632,7 @@ describe('ModelsSection', () => { // The regression: reset removed the override but left the buffer, so an // inherited row displayed text no settings layer stores — and because an // unreadable buffer never settles, it stayed there indefinitely. - const { mutate } = await mountSection({ + const { mutate } = await mountDeepSeekCard({ mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))), }) fireEvent.click(screen.getByText(en.customized)) @@ -605,12 +650,12 @@ describe('ModelsSection', () => { // Reset put the draft back where it started, so Apply writes nothing at // all rather than persisting whatever the stale text had parsed to. fireEvent.click(screen.getByText(en.apply)) - await waitFor(() => { expect(screen.getByText(en.apply)).toBeTruthy() }) + await waitFor(() => { expect(screen.queryByText(en.apply)).toBeNull() }) expect(mutate).not.toHaveBeenCalled() }) it('edits an output cap per model and carries its text across a removal', async () => { - const { mutate } = await mountSection({ + const { mutate } = await mountDeepSeekCard({ mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))), }) fireEvent.click(screen.getByText(en.customized)) @@ -644,7 +689,7 @@ describe('ModelsSection', () => { }) it('settles a pasted id and refuses whitespace that would never match', async () => { - await mountSection() + await mountDeepSeekCard() fireEvent.click(screen.getByText(en.customized)) const ids = screen.getAllByLabelText(new RegExp(en.modelId)) fireEvent.change(ids[0] as HTMLInputElement, { target: { value: ' deepseek-v4-flash ' } }) @@ -681,7 +726,7 @@ describe('ModelsSection', () => { }) it('can empty and reset the model override, then clear optional fields without dropping hidden data', async () => { - const { mutate } = await mountSection({ + const { mutate } = await mountDeepSeekCard({ mutate: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))), }) fireEvent.click(screen.getByText(en.customized)) @@ -715,7 +760,7 @@ describe('ModelsSection', () => { it('clears an inherited override with an unset op, never a whole-section replace', async () => { // A whole-section replace would clobber sibling overrides to clear one field. - const { replace, update, mutate } = await mountSection() + const { replace, update, mutate } = await mountDeepSeekCard() fireEvent.click(screen.getByText(en.customized)) const url = screen.getByLabelText(en.baseUrl) expect(url.value).toBe('https://base') @@ -762,7 +807,7 @@ describe('ModelsSection', () => { }) it('rejects an invalid draft before writing', async () => { - const { update } = await mountSection() + const { update } = await mountDeepSeekCard() fireEvent.click(screen.getByText(en.customized)) fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'not-a-url' } }) fireEvent.click(screen.getByText(en.apply)) @@ -772,19 +817,17 @@ describe('ModelsSection', () => { it('edits a pi-ai profile with the curated fields only', async () => { const { mutate } = await mountSection() - fireEvent.click(screen.getAllByText(en.edit)[0] as HTMLElement) + fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.editProvider) })) // The configured credential shows as the stored placeholder. - const keys = await screen.findAllByLabelText(en.keyInput) - const editorKey = keys[keys.length - 1] as HTMLInputElement + const editorKey = await screen.findByLabelText(en.keyInput) await waitFor(() => { expect(editorKey.placeholder).toBe(en.keyStored) }) // pi-ai carries Base URL too: the stored override shows as the value and // the effective profile endpoint as its placeholder source. - fireEvent.click(screen.getAllByText(en.customized)[1] as HTMLElement) - const urls = screen.getAllByLabelText(en.baseUrl) - expect(urls).toHaveLength(2) - expect((urls[1] as HTMLInputElement).value).toBe('https://proxy') - fireEvent.change(urls[1] as HTMLInputElement, { target: { value: 'https://proxy/v2' } }) - fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement) + fireEvent.click(screen.getByText(en.customized)) + const url = screen.getByLabelText(en.baseUrl) + expect(url.value).toBe('https://proxy') + fireEvent.change(url, { target: { value: 'https://proxy/v2' } }) + fireEvent.click(screen.getByText(en.apply)) await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) }) // Only the edited field travels: apiKeyEnv and headers were already stored // with these values, so no op restates them. @@ -803,14 +846,12 @@ describe('ModelsSection', () => { expect(pick.value).toBe('anthropic') // A dormant profile has no endpoint anywhere: the pi-ai placeholder // falls back to the provider-default wording. - fireEvent.click(screen.getAllByText(en.customized)[1] as HTMLElement) - const urls = screen.getAllByLabelText(en.baseUrl) - expect((urls[1] as HTMLInputElement).placeholder).toBe(en.baseUrlDefault) - const keys = screen.getAllByLabelText(en.keyInput) - const addKey = keys[keys.length - 1] as HTMLInputElement + fireEvent.click(screen.getByText(en.customized)) + expect(screen.getByLabelText(en.baseUrl).placeholder).toBe(en.baseUrlDefault) + const addKey = screen.getByLabelText(en.keyInput) expect(addKey.placeholder).toBe(en.keyPlaceholderNative) fireEvent.change(addKey, { target: { value: 'sk-ant' } }) - fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement) + fireEvent.click(screen.getByText(en.apply)) await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) }) expect(mutate.mock.calls[0]?.[0]).toEqual({ ns: 'llm-pi-ai', @@ -824,7 +865,7 @@ describe('ModelsSection', () => { const { mutate, set } = await mountSection() fireEvent.click(screen.getByText(en.add)) await screen.findByLabelText(en.provider) - fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement) + fireEvent.click(screen.getByText(en.apply)) await waitFor(() => { expect(mutate).toHaveBeenCalledOnce() }) expect(mutate.mock.calls[0]?.[0]).toEqual({ ns: 'llm-pi-ai', @@ -855,9 +896,8 @@ describe('ModelsSection', () => { const { face, controller } = await mountSection({ mutate, set }) fireEvent.click(screen.getByText(en.add)) await screen.findByLabelText(en.provider) - const keys = screen.getAllByLabelText(en.keyInput) - fireEvent.change(keys[keys.length - 1] as HTMLInputElement, { target: { value: 'sk-ant' } }) - fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement) + fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'sk-ant' } }) + fireEvent.click(screen.getByText(en.apply)) await screen.findByText('credential store unavailable') expect(mutate).toHaveBeenCalledOnce() face.settings.describe.mockResolvedValue(ok({ @@ -867,7 +907,7 @@ describe('ModelsSection', () => { })) await act(async () => { await controller.load() }) expect(controller.store.getSnapshot().namespaces.get('llm-pi-ai')?.revision).toBe(1) - fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement) + fireEvent.click(screen.getByText(en.apply)) await waitFor(() => { expect(set).toHaveBeenCalledTimes(2) }) expect(mutate).toHaveBeenCalledOnce() expect(set).toHaveBeenLastCalledWith({ ref: 'ANTHROPIC_API_KEY', value: 'sk-ant' }) @@ -883,10 +923,9 @@ describe('ModelsSection', () => { await waitFor(() => { expect(screen.getAllByText(content => content.includes(en.advancedHint)).length).toBeGreaterThan(0) }) - // The hint-only card cannot apply anything. - const applies = screen.getAllByText(en.apply) - expect((applies[applies.length - 1] as HTMLButtonElement).disabled).toBe(true) - expect(screen.getAllByLabelText(en.keyInput)).toHaveLength(1) + // The hint-only card cannot apply anything, and offers no key field. + expect(screen.getByText(en.apply).disabled).toBe(true) + expect(screen.queryAllByLabelText(en.keyInput)).toHaveLength(0) }) it('surfaces a rejected settings write and never stores the key after it', async () => { @@ -895,9 +934,8 @@ describe('ModelsSection', () => { }) fireEvent.click(screen.getByText(en.add)) await screen.findByLabelText(en.provider) - const keys = screen.getAllByLabelText(en.keyInput) - fireEvent.change(keys[keys.length - 1] as HTMLInputElement, { target: { value: 'sk-x' } }) - fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement) + fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'sk-x' } }) + fireEvent.click(screen.getByText(en.apply)) await screen.findByText(/unknown pi-ai provider/) expect(set).not.toHaveBeenCalled() }) @@ -930,7 +968,7 @@ describe('ModelsSection', () => { it('tells the user to reopen when another writer moved the namespace first', async () => { // The stale-draft overwrite: two tabs open the same card, the other saves, // and this one must be refused rather than replay its opening snapshot. - const { set } = await mountSection({ + const { set } = await mountDeepSeekCard({ mutate: vi.fn(() => Promise.resolve(fail('changed since it was read', 'settings-conflict'))), }) fireEvent.click(screen.getByText(en.customized)) @@ -944,7 +982,7 @@ describe('ModelsSection', () => { // A transport failure (disconnect, or the 403 a non-loopback browser now // gets on the whole configuration plane) rejects rather than returning a // failed envelope: without a catch the card would stay busy forever. - await mountSection({ mutate: vi.fn(() => Promise.reject(new Error('connection lost'))) }) + await mountDeepSeekCard({ mutate: vi.fn(() => Promise.reject(new Error('connection lost'))) }) fireEvent.click(screen.getByText(en.customized)) fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://next' } }) fireEvent.click(screen.getByText(en.apply)) @@ -954,7 +992,7 @@ describe('ModelsSection', () => { }) it('surfaces a shadowed credential write on the card', async () => { - await mountSection({ + await mountFirstRun({ set: vi.fn(() => Promise.resolve(fail('credentials: DEEPSEEK_API_KEY is shadowed by the read-only environment', 'credential-rejected'))), }) const key = screen.getByLabelText(en.keyInput) @@ -971,9 +1009,8 @@ describe('ModelsSection', () => { configured: ref === 'OPENAI_API_KEY', source: 'env', writable: false, }])), }))) - fireEvent.click(screen.getAllByText(en.edit)[0] as HTMLElement) - const keys = await screen.findAllByLabelText(en.keyInput) - const editorKey = keys[keys.length - 1] as HTMLInputElement + fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.editProvider) })) + const editorKey = await screen.findByLabelText(en.keyInput) await waitFor(() => { expect(editorKey.placeholder).toBe(en.keyEnvLocked) }) expect(editorKey.disabled).toBe(true) }) @@ -981,12 +1018,11 @@ describe('ModelsSection', () => { it('keeps a failed credential describe silent and the input usable', async () => { const { face, set } = await mountSection() face.credentials.describe.mockImplementation(() => Promise.resolve(fail('down', 'internal')) as never) - fireEvent.click(screen.getAllByText(en.edit)[0] as HTMLElement) - const keys = await screen.findAllByLabelText(en.keyInput) - const editorKey = keys[keys.length - 1] as HTMLInputElement + fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.editProvider) })) + const editorKey = await screen.findByLabelText(en.keyInput) expect(editorKey.placeholder).toBe(en.keyPlaceholderNative) fireEvent.change(editorKey, { target: { value: 'sk-live' } }) - fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement) + fireEvent.click(screen.getByText(en.apply)) await waitFor(() => { expect(set).toHaveBeenCalledTimes(1) }) }) @@ -1085,15 +1121,15 @@ describe('ModelsSection', () => { it('toggles the row editor closed on a second edit click and on cancel', async () => { const { update } = await mountSection() - const edit = screen.getAllByText(en.edit)[0] as HTMLElement + const edit = screen.getByRole('button', { name: openaiCopy(en.editProvider) }) fireEvent.click(edit) - await waitFor(() => { expect(screen.getAllByLabelText(en.keyInput).length).toBe(2) }) + await waitFor(() => { expect(screen.queryAllByLabelText(en.keyInput).length).toBe(1) }) fireEvent.click(edit) - expect(screen.getAllByLabelText(en.keyInput)).toHaveLength(1) + expect(screen.queryAllByLabelText(en.keyInput)).toHaveLength(0) fireEvent.click(edit) - await waitFor(() => { expect(screen.getAllByLabelText(en.keyInput).length).toBe(2) }) - fireEvent.click(screen.getAllByText(en.cancel)[1] as HTMLElement) - expect(screen.getAllByLabelText(en.keyInput)).toHaveLength(1) + await waitFor(() => { expect(screen.queryAllByLabelText(en.keyInput).length).toBe(1) }) + fireEvent.click(screen.getByText(en.cancel)) + expect(screen.queryAllByLabelText(en.keyInput)).toHaveLength(0) expect(update).not.toHaveBeenCalled() }) @@ -1101,11 +1137,34 @@ describe('ModelsSection', () => { await mountSection() fireEvent.click(screen.getByText(en.add)) await screen.findByLabelText(en.provider) - fireEvent.click(screen.getAllByText(en.cancel)[1] as HTMLElement) + fireEvent.click(screen.getByText(en.cancel)) await screen.findByText(en.add) expect(screen.queryByLabelText(en.provider)).toBeNull() }) + it('collapses the setup card on cancel without disturbing another open card', async () => { + // The regression: the setup card shared the row/add/declare close handler, + // so cancelling it discarded the add card's draft while staying open itself. + await mountFirstRun() + expect(screen.getAllByLabelText(en.keyInput)).toHaveLength(1) + fireEvent.click(screen.getByText(en.add)) + await screen.findByLabelText(en.provider) + expect(screen.getAllByLabelText(en.keyInput)).toHaveLength(2) + + // The setup card is the first one on the page, above the add block. + fireEvent.click(screen.getAllByText(en.cancel)[0] as HTMLElement) + // The add card kept its draft… + expect(screen.getByLabelText(en.provider)).toBeTruthy() + // …and DeepSeek collapsed to an ordinary row carrying the missing-key dot. + expect(screen.getAllByLabelText(en.keyInput)).toHaveLength(1) + expect(screen.getAllByRole('img', { name: en.credentialMissing }) + .some(dot => dot.closest('li')?.textContent?.includes('DeepSeek') === true)).toBe(true) + // Its card reopens through Edit, which closes the add card as any row does. + fireEvent.click(screen.getByRole('button', { name: deepSeekCopy(en.editProvider) })) + expect(screen.getAllByLabelText(en.keyInput)).toHaveLength(1) + expect(screen.queryByLabelText(en.provider)).toBeNull() + }) + it('loads on first render of an idle controller', async () => { const { face } = scriptedFace() const controller = new ModelsSettingsStore(face as unknown as WireFace) diff --git a/packages/client/ui-models/tests/readiness.client.spec.ts b/packages/client/ui-models/tests/readiness.client.spec.ts index 8647a2da83..f01e821767 100644 --- a/packages/client/ui-models/tests/readiness.client.spec.ts +++ b/packages/client/ui-models/tests/readiness.client.spec.ts @@ -1,8 +1,8 @@ -/** Pure official-DeepSeek readiness projection over the shared Models join. */ +/** Pure first-run readiness projection over the shared Models join. */ import { describe, expect, it } from 'vitest' import type { CredentialView } from '@deepseek-ai/dsh-api-remotes/client' import type { ModelsSettingsState, ProviderRow } from '../src/client/store.ts' -import { deepSeekReadiness } from '../src/client/store.ts' +import { onboardingReadiness, providerUsable } from '../src/client/store.ts' const missingCredential: CredentialView = { configured: false, writable: true } @@ -23,6 +23,24 @@ function row(overrides: Partial = {}): ProviderRow { } } +/** A second provider the user configured themselves. */ +function otherRow(overrides: Partial = {}): ProviderRow { + return { + entry: { + provider: 'hfai', + displayName: 'HFAI', + settingsNs: 'llm-pi-ai', + settingsPath: ['providers', 'hfai'], + active: true, + }, + configured: true, + removable: true, + apiKeyEnv: 'HFAI_API_KEY', + credential: { configured: true, source: 'file', writable: true }, + ...overrides, + } +} + function state(overrides: Partial = {}): ModelsSettingsState { return { status: 'ready', @@ -35,12 +53,25 @@ function state(overrides: Partial = {}): ModelsSettingsStat } } -describe('deepSeekReadiness', () => { +describe('providerUsable', () => { + it('requires a registered route and a stored key for every named reference', () => { + expect(providerUsable(otherRow())).toBe(true) + expect(providerUsable(otherRow({ entry: { ...otherRow().entry, active: false } }))).toBe(false) + expect(providerUsable(otherRow({ credential: missingCredential }))).toBe(false) + expect(providerUsable(otherRow({ credential: undefined }))).toBe(false) + }) + + it('treats a reference-free registered route as provider-native authentication', () => { + expect(providerUsable(otherRow({ apiKeyEnv: undefined, credential: undefined }))).toBe(true) + }) +}) + +describe('onboardingReadiness', () => { it('waits for the first join and skips onboarding when the adapter directory entry is absent', () => { - expect(deepSeekReadiness(state({ status: 'idle', rows: [] }))).toEqual({ kind: 'loading' }) - expect(deepSeekReadiness(state({ status: 'loading', rows: [] }))).toEqual({ kind: 'loading' }) - expect(deepSeekReadiness(state({ rows: [] }))).toEqual({ kind: 'adapter-absent' }) - expect(deepSeekReadiness(state({ + expect(onboardingReadiness(state({ status: 'idle', rows: [] }))).toEqual({ kind: 'loading' }) + expect(onboardingReadiness(state({ status: 'loading', rows: [] }))).toEqual({ kind: 'loading' }) + expect(onboardingReadiness(state({ rows: [] }))).toEqual({ kind: 'adapter-absent' }) + expect(onboardingReadiness(state({ rows: [row({ entry: { ...row().entry, @@ -51,45 +82,47 @@ describe('deepSeekReadiness', () => { }) it('reports a missing writable effective credential', () => { - expect(deepSeekReadiness(state())).toEqual({ kind: 'credential-missing' }) + expect(onboardingReadiness(state())).toEqual({ kind: 'credential-missing' }) + }) + + it('ends onboarding once any other registered provider can serve requests', () => { + expect(onboardingReadiness(state({ rows: [row(), otherRow()] }))).toEqual({ kind: 'provider-ready' }) + // A provider the user cannot reach yet leaves the prompt in place. + expect(onboardingReadiness(state({ + rows: [row(), otherRow({ credential: missingCredential })], + }))).toEqual({ kind: 'credential-missing' }) }) it('accepts file and process-environment credentials without prompting', () => { - expect(deepSeekReadiness(state({ + expect(onboardingReadiness(state({ rows: [row({ credential: { configured: true, source: 'file', writable: true } })], - }))).toEqual({ kind: 'configured' }) - expect(deepSeekReadiness(state({ + }))).toEqual({ kind: 'provider-ready' }) + expect(onboardingReadiness(state({ rows: [row({ credential: { configured: true, source: 'env', writable: false } })], - }))).toEqual({ kind: 'configured' }) + }))).toEqual({ kind: 'provider-ready' }) }) - it('turns missing capabilities and inconsistent descriptors into diagnostics', () => { - expect(deepSeekReadiness(state({ status: 'error', error: 'settings down' }))).toEqual({ + it('turns missing capabilities into diagnostics that never block the product', () => { + expect(onboardingReadiness(state({ status: 'error', error: 'settings down' }))).toEqual({ kind: 'unavailable', reason: 'load-failed', }) - expect(deepSeekReadiness(state({ + expect(onboardingReadiness(state({ rows: [row({ entry: { ...row().entry, active: false } })], }))).toEqual({ kind: 'unavailable', reason: 'provider-inactive' }) - expect(deepSeekReadiness(state({ - rows: [row({ configured: false })], - }))).toEqual({ kind: 'unavailable', reason: 'settings-unavailable' }) - expect(deepSeekReadiness(state({ - rows: [row({ apiKeyEnv: undefined })], - }))).toEqual({ kind: 'unavailable', reason: 'credential-ref-unavailable' }) - expect(deepSeekReadiness(state({ + expect(onboardingReadiness(state({ credentialError: 'credentials service is absent', }))).toEqual({ kind: 'unavailable', reason: 'credentials-unavailable', }) - expect(deepSeekReadiness(state({ + expect(onboardingReadiness(state({ rows: [row({ credential: undefined })], }))).toEqual({ kind: 'unavailable', reason: 'credentials-unavailable' }) - expect(deepSeekReadiness(state({ + expect(onboardingReadiness(state({ rows: [row({ credential: { configured: false, writable: false } })], }))).toEqual({ kind: 'unavailable', reason: 'credential-read-only' }) - expect(deepSeekReadiness(state({ writable: false }))).toEqual({ + expect(onboardingReadiness(state({ writable: false }))).toEqual({ kind: 'unavailable', reason: 'settings-read-only', }) diff --git a/tsconfig.host.json b/tsconfig.host.json index d4e7f7ba3b..5002ec7e45 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -29,6 +29,7 @@ "apps/web/tests/settings-chrome.e2e.ts", "apps/web/tests/models-settings.e2e.ts", "apps/web/tests/onboarding-deepseek-config.e2e.ts", + "apps/web/tests/onboarding-usable-provider.e2e.ts", "apps/web/tests/remote-welcome.e2e.ts", "apps/web/tests/workspace-management.e2e.ts", "apps/web/tests/replay-round-trip.e2e.ts", From 625711e71824cb3c5436b0a4834a5c4f58ef676b Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Sat, 8 Aug 2026 11:04:11 +0800 Subject: [PATCH 74/81] fix(client): distinguish grep/glob rows from the web search row The grep, glob, and web_search tool rows all rendered as "Search" with the same magnifier icon, so a transcript full of local searches was indistinguishable from web searches. Grep and glob now carry their own command-named titles through TOOL_TITLES, and the web_search row wears a new globe glyph (IconGlobeOutline14) while keeping its "Search" title. --- packages/client/ui-primitives/src/icons/index.tsx | 12 ++++++++++++ .../client/ui-primitives/tests/icons.client.spec.tsx | 4 ++-- .../ui-tool/src/client/tool/toolviews/search-row.tsx | 9 +++++++-- .../ui-tool/src/client/tool/toolviews/web-row.tsx | 5 +++-- .../client/ui-tool/tests/search-card.client.spec.tsx | 5 ++++- .../client/ui-tool/tests/web-card.client.spec.tsx | 3 +++ 6 files changed, 31 insertions(+), 7 deletions(-) diff --git a/packages/client/ui-primitives/src/icons/index.tsx b/packages/client/ui-primitives/src/icons/index.tsx index 972e0ec14d..2606f54e62 100644 --- a/packages/client/ui-primitives/src/icons/index.tsx +++ b/packages/client/ui-primitives/src/icons/index.tsx @@ -31,6 +31,18 @@ export const IconSearchOutline16 = ({ size = 16, className }: IconProps) => ( ) +/** ic_ds_globe_outline_14 — meridian globe (harness-only figma extract). */ +export const IconGlobeOutline14 = ({ size = 14, className }: IconProps) => ( + + + +) + /** ic_ds_settings_outline_14 */ export const IconSettingsOutline14 = ({ size = 14, className }: IconProps) => ( diff --git a/packages/client/ui-primitives/tests/icons.client.spec.tsx b/packages/client/ui-primitives/tests/icons.client.spec.tsx index f6560a4cc1..41f8caad47 100644 --- a/packages/client/ui-primitives/tests/icons.client.spec.tsx +++ b/packages/client/ui-primitives/tests/icons.client.spec.tsx @@ -16,8 +16,8 @@ const icons = Object.fromEntries( const iconNames = Object.keys(icons) describe('ic_ds_ icon set', () => { - it('exports the full icon set (46 deepsuite + 19 figma extracts + three product glyphs outside those sets)', () => { - expect(iconNames.length).toBe(68) + it('exports the full icon set (46 deepsuite + 20 figma extracts + three product glyphs outside those sets)', () => { + expect(iconNames.length).toBe(69) }) it.each(iconNames)('%s renders an svg with currentColor fills and no hardcoded palette', (name) => { diff --git a/packages/client/ui-tool/src/client/tool/toolviews/search-row.tsx b/packages/client/ui-tool/src/client/tool/toolviews/search-row.tsx index 47b17b6fa8..e49189d6d4 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/search-row.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/search-row.tsx @@ -23,8 +23,13 @@ import { CONVERSATION_NS as NS } from '../../locale.ts' /** Full row props: the toolview runtime share plus the standard locale seat. */ type SearchRowProps = ToolCallViewProps & PropsLocale<'conversation'> +const SEARCH_TITLES: Record = { + grep: 'Grep', + glob: 'Glob', +} + /** - * Search row: icon + Search · {summary} in the shared ToolRow chrome, with the + * Search row: icon + Grep/Glob · {summary} in the shared ToolRow chrome, with the * completed search's card as the row's collapsed-by-default card body (a capped * search's recovery footer rides below it, inside ToolRow). Registered under * both `grep` and `glob`; the derived model's `kind` decides the card shape. A @@ -40,7 +45,7 @@ export function SearchRow({ toolName, block, inspect, t }: SearchRowProps) { variant={model.variant} toolName={toolName} icon={} - title={model.title} + title={SEARCH_TITLES[toolName] ?? model.title} // The result view's replacement title outranks the args-derived summary, // matching the terminal card's description precedence. summary={search?.title ?? model.summary} diff --git a/packages/client/ui-tool/src/client/tool/toolviews/web-row.tsx b/packages/client/ui-tool/src/client/tool/toolviews/web-row.tsx index c0e546f071..3dab222e95 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/web-row.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/web-row.tsx @@ -10,7 +10,7 @@ // summary line alone. import type { Context } from '@deepseek-ai/cordis' -import { IconBrowseOutline16, IconSearchOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconBrowseOutline16, IconGlobeOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { ToolCallViewProps } from '../../contract/slots.ts' import { webCardModel } from '../models/web-card-model.ts' @@ -35,7 +35,8 @@ const WEB_TITLES: Record = { export function WebRow({ toolName, block, inspect, t }: WebRowProps) { const model = toolRowModel(toolName, block) const web = webCardModel(block) - const icon = toolName === 'web_fetch' ? : + // Web search uses a globe; local grep/glob keep the magnifier family. + const icon = toolName === 'web_fetch' ? : return ( { it('collapses to the summary row; expanding reveals the grep card', () => { const view = render() - expect(view.getByText('Search')).toBeTruthy() + expect(view.getByText('Grep')).toBeTruthy() + expect(view.queryByText('Search')).toBeNull() // Collapsed: the card is not in the DOM until the row is expanded. expect(searchKindOf(view.container)).toBeNull() expect(view.queryByText(/const foo = 1/)).toBeNull() @@ -259,6 +260,8 @@ describe('SearchRow keyed card', () => { it('expands to the glob path card', () => { const view = render() + expect(view.getByText('Glob')).toBeTruthy() + expect(view.queryByText('Search')).toBeNull() expect(searchKindOf(view.container)).toBeNull() toggleRow(view) expect(view.getByText('src/a.ts')).toBeTruthy() diff --git a/packages/client/ui-tool/tests/web-card.client.spec.tsx b/packages/client/ui-tool/tests/web-card.client.spec.tsx index 8a3efd2627..3b5a7ebf32 100644 --- a/packages/client/ui-tool/tests/web-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/web-card.client.spec.tsx @@ -20,6 +20,7 @@ import type { ToolResultView } from '@deepseek-ai/dsh-api-remotes/client' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' import type { SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { ToolCallOwnerProps } from '@deepseek-ai/dsh-client-ui-tool/client' +import { IconGlobeOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' import { webCardModel } from '../src/client/tool/models/web-card-model.ts' import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/client/stores.ts' import { GenericToolCard } from '../src/client/tool/toolviews/GenericToolCard.tsx' @@ -140,9 +141,11 @@ describe('chat row web body', () => { } it('the WebRow collapses to the summary row, expanding to the full search card', () => { + const globe = render().container.querySelector('svg')!.outerHTML const view = render() // Collapsed: the summary row alone, no card in the DOM. expect(view.getByText('Search')).toBeTruthy() + expect(view.container.querySelector('svg')?.outerHTML).toBe(globe) expect(view.queryByText('Titled')).toBeNull() expect(view.container.querySelector('[data-web]')).toBeNull() toggleRow(view) From 54dd75a96973b27212754b7000c1f93d02293570 Mon Sep 17 00:00:00 2001 From: Turtle Date: Tue, 11 Aug 2026 18:41:21 +0800 Subject: [PATCH 75/81] refactor(cmdline): run the program's own commander action instead of a plan callback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parseCmdline(ctx, program): void only adapts commander control flow to the launcher: it parses the immutable cmdlineArgs snapshot and turns help, version, parse errors, and action rejections into a ctx.appExit request. App validation and the ctx.provide of the app-owned service live in the program's own synchronous .action(), which commander runs inside parse — program.error(...) there shares the exit path with a grammar rejection. Deletes the CmdlinePlan export, its unread ctx parameter, the type-unsound (() => ({}) as T) default, and the T | undefined return with its per-caller publish guard. --- ...026-08-06-app-owned-command-line.i18n.yaml | 4 +- .../2026-08-06-app-owned-command-line.md | 2 +- .../2026-08-06-app-owned-command-line.zh.md | 2 +- ...026-08-11-cmdline-program-action.i18n.yaml | 6 ++ .../2026-08-11-cmdline-program-action.md | 29 ++++++ .../2026-08-11-cmdline-program-action.zh.md | 29 ++++++ apps/cli/tests/built-bin.e2e.ts | 4 +- docs/user/develop/basic/publish.i18n.yaml | 4 +- docs/user/develop/basic/publish.md | 2 +- docs/user/develop/basic/publish.zh.md | 2 +- packages/boot/cmdline/README.i18n.yaml | 4 +- packages/boot/cmdline/README.md | 9 +- packages/boot/cmdline/README.zh.md | 9 +- packages/boot/cmdline/src/index.ts | 96 ++++++++++++------- packages/boot/cmdline/tests/cmdline.spec.ts | 80 +++++++++++----- packages/bundle/headless/src/startup.ts | 25 ++--- packages/bundle/web-app/src/startup.ts | 38 ++++---- 17 files changed, 229 insertions(+), 116 deletions(-) create mode 100644 .agents/notes/implemented/simplification/2026-08-11-cmdline-program-action.i18n.yaml create mode 100644 .agents/notes/implemented/simplification/2026-08-11-cmdline-program-action.md create mode 100644 .agents/notes/implemented/simplification/2026-08-11-cmdline-program-action.zh.md diff --git a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml index 5ee9d06358..37ab609908 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.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-08-06-app-owned-command-line.md -2026-08-06-app-owned-command-line.md: 2480775f654fd5c2fecebc8d59e311acee878920 -2026-08-06-app-owned-command-line.zh.md: d754c125d5bc683156f5ac3f285e2cd711e6773b +2026-08-06-app-owned-command-line.md: 6d84ba457564ef250e1acfbcc71fcc91b1d49aee +2026-08-06-app-owned-command-line.zh.md: f964f7a7de7aae7e97b52fbc572443352dc5ae26 diff --git a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md index 2480775f65..6d84ba4575 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md +++ b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md @@ -12,7 +12,7 @@ After profiles, compositions were installable but their command lines were not. The launcher parses only what it owns — `--profile`, `--patch`, the config dumps — and hands **everything after its own flags** to the booted tree verbatim. The split is positional: the first token the launcher does not recognize starts the app's arguments (commander's `passThroughOptions` + `allowUnknownOption` + `helpOption(false)`). A bare `dsh -h`, which has no app to hand the flag to, still prints the launcher's own help. -The new `@deepseek-ai/dsh-cmdline` package owns the handoff. A launcher calls `provideCmdline(ctx, host)` before any entry mounts, providing `ctx.cmdlineArgs` (whose whole interface is `get(): readonly string[]`) and `ctx.appExit`. Any ordinary app plugin may inject `cmdlineArgs`, call `parseCmdline(ctx, program, plan)` with its own commander program, and provide the returned value as an app-owned service. Its Loader row carries no launcher marker or special kind, and the launcher does not inspect the composition for an owner. Multiple plugins may read the same immutable snapshot; a profile with no reader ignores its app arguments. Rows configured from a provider inject its service and read direct lazy config expressions (`port: !!js ctx.webStartup.port ?? 3080`), so a flag beats the value written beside it and nothing is written back into any row. +The new `@deepseek-ai/dsh-cmdline` package owns the handoff. A launcher calls `provideCmdline(ctx, host)` before any entry mounts, providing `ctx.cmdlineArgs` (whose whole interface is `get(): readonly string[]`) and `ctx.appExit`. Any ordinary app plugin may inject `cmdlineArgs`, call `parseCmdline(ctx, program)` with its own commander program, and provide the resolved value as an app-owned service from the program's action. Its Loader row carries no launcher marker or special kind, and the launcher does not inspect the composition for an owner. Multiple plugins may read the same immutable snapshot; a profile with no reader ignores its app arguments. Rows configured from a provider inject its service and read direct lazy config expressions (`port: !!js ctx.webStartup.port ?? 3080`), so a flag beats the value written beside it and nothing is written back into any row. The boot mounts the composition once. Cordis holds each row until its injections are active; Loader then interpolates that row's `!!js` against the injection-ready plugin context immediately before activation. Include keeps nested row expressions raw until their target row reaches this point. `--help` leaves the provider's service absent, so dependent rows never activate, and a live patch reload interpolates again against the service that remains active, so a served port cannot be silently reset. diff --git a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md index d754c125d5..f964f7a7de 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md @@ -12,7 +12,7 @@ profile 落地之后,组合可以安装,命令行却不能。`apps/cli` 仍 启动器只解析属于自己的部分(`--profile`、`--patch`、配置 dump),并把**自己 flag 之后的一切**原样交给引导起来的配置树。切分按位置进行:启动器不认识的第一个 token 就是应用参数的起点(依靠 commander 的 `passThroughOptions` + `allowUnknownOption` + `helpOption(false)`)。裸的 `dsh -h` 没有可交付的应用,仍然打印启动器自己的 help。 -新包 `@deepseek-ai/dsh-cmdline` 持有这次交接。启动器在任何条目挂载之前调用 `provideCmdline(ctx, host)`,提供 `ctx.cmdlineArgs`(其全部接口就是 `get(): readonly string[]`)与 `ctx.appExit`。任何普通应用插件都可以注入 `cmdlineArgs`,用自己的 commander program 调用 `parseCmdline(ctx, program, plan)`,再把返回值作为应用自有服务提供出去。它的 Loader 行不携带启动器标记或特殊类型,启动器也不会检查组合中的所有者。多个插件可以读取同一份不可变快照;没有读取方的 profile 会忽略自己的应用参数。由提供方配置的行注入其服务,并在惰性配置表达式中直接读取它(`port: !!js ctx.webStartup.port ?? 3080`),因此 flag 胜过写在它旁边的值,也没有任何东西被写回任何一行。 +新包 `@deepseek-ai/dsh-cmdline` 持有这次交接。启动器在任何条目挂载之前调用 `provideCmdline(ctx, host)`,提供 `ctx.cmdlineArgs`(其全部接口就是 `get(): readonly string[]`)与 `ctx.appExit`。任何普通应用插件都可以注入 `cmdlineArgs`,用自己的 commander program 调用 `parseCmdline(ctx, program)`,再在 program 自己的 action 中把解析出的取值作为应用自有服务提供出去。它的 Loader 行不携带启动器标记或特殊类型,启动器也不会检查组合中的所有者。多个插件可以读取同一份不可变快照;没有读取方的 profile 会忽略自己的应用参数。由提供方配置的行注入其服务,并在惰性配置表达式中直接读取它(`port: !!js ctx.webStartup.port ?? 3080`),因此 flag 胜过写在它旁边的值,也没有任何东西被写回任何一行。 boot 只挂载一次整套组合。Cordis 让每一行等待其注入激活;Loader 随后在激活前一刻,基于已注入就绪的插件上下文插值该行的 `!!js`。Include 会保留嵌套的行表达式,直到目标行到达这一时点。`--help` 会让提供方服务保持缺失,因此依赖行永不激活;活动 patch 重载会针对仍然在线的服务再次插值,所以已经服务中的端口不会被悄悄重置。 diff --git a/.agents/notes/implemented/simplification/2026-08-11-cmdline-program-action.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-11-cmdline-program-action.i18n.yaml new file mode 100644 index 0000000000..97163e3a85 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-11-cmdline-program-action.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/simplification/2026-08-11-cmdline-program-action.md +2026-08-11-cmdline-program-action.md: 40c4dae1d3461f25ac7f34dee7c166434e6cd24d +2026-08-11-cmdline-program-action.zh.md: 91036f1c52b60d28055935813d6698205f045422 diff --git a/.agents/notes/implemented/simplification/2026-08-11-cmdline-program-action.md b/.agents/notes/implemented/simplification/2026-08-11-cmdline-program-action.md new file mode 100644 index 0000000000..40c4dae1d3 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-11-cmdline-program-action.md @@ -0,0 +1,29 @@ +# Agent Note: parseCmdline runs the program's own commander action + +Status: implemented + +English | [中文](2026-08-11-cmdline-program-action.zh.md) + +## Problem + +`dsh-cmdline`'s ([app-owned command line](../architecture/2026-08-06-app-owned-command-line.md)) `parseCmdline` carried a bespoke callback: `CmdlinePlan = (program, ctx) => T`, invoked after a successful parse inside the helper's catch so a plan's `program.error(...)` shared the help/parse-error exit path, with a type-unsound `(() => ({}) as T)` default only tests used and a `ctx` argument no plan read. The whole seam duplicated a slot commander already defines: a command's action handler runs inside `parse`, and `program.error(...)` thrown from it obeys `exitOverride` exactly like a grammar rejection. + +## Decision + +`parseCmdline(ctx, program): void` only adapts commander control flow to the launcher: it parses the immutable `cmdlineArgs` snapshot and turns help, version, parse errors, and action rejections into a `ctx.appExit` request. App code — validation commander's grammar cannot express and the `ctx.provide` of the app-owned service — lives in the program's own synchronous `.action()`, which commander runs on a successful parse and never runs on help or rejection. The `CmdlinePlan` export, its `ctx` parameter, the default plan, and the `T | undefined` return are deleted; both bundle providers publish from their action. Because the `Command` type cannot express the action precondition, `parseCmdline` reads the handler structurally (as `isCommanderError` reads commander's control-flow errors) and refuses at load a program in which no command declares an action — without the guard, a provider that forgot its action (or a stale caller still passing the deleted third argument) parses successfully, publishes nothing, and surfaces only as dependent rows pending on the absent service at settlement. The helper configures `exitOverride` and output on the whole command tree, not the root alone: commander copies those settings into a subcommand only at registration, so a root-only override would let a pre-registered subcommand's rejection call `process.exit` past `ctx.appExit`. An action must reject before it publishes; statements before its `program.error(...)` have already run. + +Verified on commander 15 before shipping: an action runs inside `parse` and its `program.error(...)` throws a `CommanderError` through `exitOverride`; help and version short-circuit before the action; excess-argument handling is identical with and without an action. + +## Alternatives considered + +- **Keeping a bespoke `resolve`/plan callback**: it existed only so app rejection could share the helper's catch, which commander's action slot already provides; a second callback seam for the same moment in the parse lifecycle is duplication. +- **Returning the parsed `Command` for the caller to read**: a post-parse `program.error(...)` in the caller escapes the helper's catch as an uncaught `CommanderError`, turning a usage rejection into a plugin load failure; every app with validation would rebuild the try/catch the helper owns. +- **Moving all validation into commander option/argument parsers**: `InvalidArgumentError` covers per-value checks, but the headless bundle rejects a joined variadic ("task must be non-blank") with its own usage message, which per-argument parsers cannot express. +- **Accepting an action-less program and relying on the settlement diagnostic**: the assembled launcher does fail loud (`pending (waiting for service: …)`), but that error names the consumers, not the misconfigured provider, and an embedding host without the settlement assertion would hang silently; the load-time guard reports the culprit program directly. +- **Replacing the `CmdlineArgs` accessor with a bare frozen `readonly string[]` service**: the maintainer keeps the accessor object as the service's named interface. + +## Consequences + +- `parseCmdline` loses its generic, callback parameter, and `undefined` sentinel; callers lose the `if (values !== undefined)` publish guard. +- An app's command is self-contained — flags, help text, validation, and the publishing effect travel together on the `Command`. +- Actions must be synchronous: the helper calls `parse`, not `parseAsync`, so a returned promise would escape the catch unobserved. diff --git a/.agents/notes/implemented/simplification/2026-08-11-cmdline-program-action.zh.md b/.agents/notes/implemented/simplification/2026-08-11-cmdline-program-action.zh.md new file mode 100644 index 0000000000..91036f1c52 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-11-cmdline-program-action.zh.md @@ -0,0 +1,29 @@ +# Agent Note: parseCmdline 运行 program 自己的 commander action + +Status: implemented + +[English](2026-08-11-cmdline-program-action.md) | 中文 + +## Problem + +`dsh-cmdline`([应用自有命令行](../architecture/2026-08-06-app-owned-command-line.md))的 `parseCmdline` 曾带着一个自造的回调:`CmdlinePlan = (program, ctx) => T`,在解析成功后于该适配器的 catch 之内调用,使 plan 的 `program.error(...)` 与 help/解析错误共用同一条退出路径;它还带有只被测试使用、类型不健全的默认值 `(() => ({}) as T)`,以及没有任何 plan 读取的 `ctx` 参数。这整条接缝复制了 commander 本就定义的席位:命令的 action 处理器在 `parse` 内部运行,从中抛出的 `program.error(...)` 与语法拒绝一样遵循 `exitOverride`。 + +## Decision + +`parseCmdline(ctx, program): void` 只把 commander 的控制流适配到启动器:它解析不可变的 `cmdlineArgs` 快照,并把 help、version、解析错误与 action 的拒绝转换为一次 `ctx.appExit` 请求。应用代码——commander 语法表达不了的校验,以及应用自有服务的 `ctx.provide`——放在 program 自己的同步 `.action()` 里,commander 在解析成功时运行它,在 help 或拒绝时绝不运行。`CmdlinePlan` 导出、其 `ctx` 参数、默认 plan 与 `T | undefined` 返回值全部删除;两个组合包提供方都在各自的 action 中发布。由于 `Command` 类型无法表达 action 前置条件,`parseCmdline` 按结构读取处理器(如同 `isCommanderError` 按结构识别 commander 的控制流错误),在加载时拒绝整棵命令树中没有任何命令声明 action 的 program 并点名它——若无此守卫,漏写 action 的提供方(或仍在传已删除第三参数的陈旧调用方)会解析成功、什么也不发布,只在 settlement 时以依赖行 pending 等待缺席服务的形式浮现。该适配器在整棵命令树而非仅根命令上配置 `exitOverride` 与输出:commander 只在注册时把这些设置复制进子命令,只配置根命令会让已注册子命令的拒绝绕过 `ctx.appExit` 直接调用 `process.exit`。action 必须先拒绝后发布;写在 `program.error(...)` 之前的语句已经执行。 + +交付前已在 commander 15 上验证:action 在 `parse` 内部运行,其 `program.error(...)` 经 `exitOverride` 抛出 `CommanderError`;help 与 version 在 action 之前短路;有无 action 时的多余参数处理完全一致。 + +## Alternatives considered + +- **保留自造的 `resolve`/plan 回调**:它存在的唯一理由是让应用侧的拒绝共用适配器的 catch,而 commander 的 action 席位本就提供这一点;为解析生命周期的同一时刻再造第二条回调接缝属于重复。 +- **返回解析后的 `Command` 交调用方读取**:调用方在解析之后调用 `program.error(...)` 会以未捕获的 `CommanderError` 逃出适配器的 catch,把一次用法拒绝变成插件加载失败;每个带校验的应用都得重建适配器持有的那套 try/catch。 +- **把全部校验移进 commander 的 option/argument 解析器**:`InvalidArgumentError` 覆盖逐值检查,但 headless 组合包用自己的用法信息拒绝拼接后的可变参数("任务不得为空白"),逐参数解析器表达不了。 +- **接受没有 action 的 program,依赖 settlement 诊断**:组装好的启动器确实会大声失败(`pending (waiting for service: …)`),但那个错误点名的是消费者而非配置错误的提供方,且没有 settlement 断言的嵌入宿主会静默挂起;加载时守卫直接报出肇事的 program。 +- **用裸的冻结 `readonly string[]` 服务替换 `CmdlineArgs` 访问器**:维护者保留该访问器对象作为服务的具名接口。 + +## Consequences + +- `parseCmdline` 失去泛型、回调参数与 `undefined` 哨兵值;调用方不再需要 `if (values !== undefined)` 的发布守卫。 +- 应用的命令是自包含的——flag、help 文本、校验与发布效果一起挂在 `Command` 上。 +- action 必须是同步的:适配器调用的是 `parse` 而非 `parseAsync`,返回的 promise 会在无人观察的情况下逃出 catch。 diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index 81acefadc5..760f7c58ad 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -227,8 +227,8 @@ function createStartupFixture(): StartupFixture { "export const inject = ['cmdlineArgs']", 'export function apply(ctx) {', " const program = new Command().name('fixture').option('--generation ', 'echoed generation')", - ' const values = parseCmdline(ctx, program, parsed => ({ generation: parsed.opts().generation }))', - ' if (values !== undefined) ctx.provide(\'fixtureStartup\', values)', + " program.action(() => ctx.provide('fixtureStartup', { generation: program.opts().generation }))", + ' parseCmdline(ctx, program)', '}', '', ].join('\n')) diff --git a/docs/user/develop/basic/publish.i18n.yaml b/docs/user/develop/basic/publish.i18n.yaml index 91dba947bb..a7b9b1d39a 100644 --- a/docs/user/develop/basic/publish.i18n.yaml +++ b/docs/user/develop/basic/publish.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/develop/basic/publish.md -publish.md: 8437c7ea5c4cb966f9f3d68977949c78986ec9a5 -publish.zh.md: 4409dbfda060a84b316029d87ec985209cfa286a +publish.md: 588531a28020ebe620643cd1aaaa43de000e658a +publish.zh.md: 938e4b0aa2ea09f80fce897413e9c57f90d3209a diff --git a/docs/user/develop/basic/publish.md b/docs/user/develop/basic/publish.md index 8437c7ea5c..588531a280 100644 --- a/docs/user/develop/basic/publish.md +++ b/docs/user/develop/basic/publish.md @@ -117,7 +117,7 @@ A bundle that defines a runnable app mounts an ordinary provider plugin: name: 'dsh-hello-plugin/startup' ``` -The plugin exports `inject = ['cmdlineArgs']`, calls `parseCmdline` from [`@deepseek-ai/dsh-cmdline`](../../../../packages/boot/cmdline/README.md) with its own commander program, and provides the returned value as its app-owned service. The launcher hands every plugin the same immutable arguments after launcher flags, so app-specific flags need no launcher change and multiple plugins may parse the snapshot. The Loader row needs no launcher marker or special kind. +The plugin exports `inject = ['cmdlineArgs']`, calls `parseCmdline` from [`@deepseek-ai/dsh-cmdline`](../../../../packages/boot/cmdline/README.md) with its own commander program, and provides its app-owned service from the program's action. The launcher hands every plugin the same immutable arguments after launcher flags, so app-specific flags need no launcher change and multiple plugins may parse the snapshot. The Loader row needs no launcher marker or special kind. Rows configured by those arguments inject the provider's service and read it from their own `!!js` options, with the deployment value beside it as the fallback: diff --git a/docs/user/develop/basic/publish.zh.md b/docs/user/develop/basic/publish.zh.md index 4409dbfda0..938e4b0aa2 100644 --- a/docs/user/develop/basic/publish.zh.md +++ b/docs/user/develop/basic/publish.zh.md @@ -117,7 +117,7 @@ dsh --profile demo name: 'dsh-hello-plugin/startup' ``` -该插件导出 `inject = ['cmdlineArgs']`,使用自己的 commander program 调用 [`@deepseek-ai/dsh-cmdline`](../../../../packages/boot/cmdline/README.md) 中的 `parseCmdline`,再把返回值作为应用自有服务提供出去。启动器把自身 flag 之后的同一份不可变参数交给每个插件,因此添加应用专属 flag 无需修改启动器,多个插件也可以解析该快照。Loader 行不需要启动器标记或特殊类型。 +该插件导出 `inject = ['cmdlineArgs']`,使用自己的 commander program 调用 [`@deepseek-ai/dsh-cmdline`](../../../../packages/boot/cmdline/README.md) 中的 `parseCmdline`,再在 program 自己的 action 中把应用自有服务提供出去。启动器把自身 flag 之后的同一份不可变参数交给每个插件,因此添加应用专属 flag 无需修改启动器,多个插件也可以解析该快照。Loader 行不需要启动器标记或特殊类型。 受这些参数配置的行会注入提供方服务,并在自己的 `!!js` 选项中读取它,同时把部署取值写在旁边作为回退: diff --git a/packages/boot/cmdline/README.i18n.yaml b/packages/boot/cmdline/README.i18n.yaml index 9d30c65bb8..22a80a7e13 100644 --- a/packages/boot/cmdline/README.i18n.yaml +++ b/packages/boot/cmdline/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/boot/cmdline/README.md -README.md: 2e8e58b23785fa78bd2663a459817669309a81be -README.zh.md: c04d76905edb4afa6b18b36b8284b14990be6bdd +README.md: 33125014539e801dbd2952a3b4513cafc80bdcee +README.zh.md: 7ef49a1027d3c17817c9171e1166ed6feecd8559 diff --git a/packages/boot/cmdline/README.md b/packages/boot/cmdline/README.md index 2e8e58b237..3312501453 100644 --- a/packages/boot/cmdline/README.md +++ b/packages/boot/cmdline/README.md @@ -15,15 +15,16 @@ An embedding host with no command line provides an empty list; that is the hones ## Ordinary providers and injected config -Any app plugin may inject `cmdlineArgs`, parse it, and publish an ordinary app-owned service. `parseCmdline(ctx, program, plan)` is only a commander adapter; the caller owns the returned value and service: +Any app plugin may inject `cmdlineArgs`, parse it, and publish an ordinary app-owned service. `parseCmdline(ctx, program)` is only a commander adapter; the program's own action owns validation and the published service: ```ts ignore export const name = 'web-startup' export const inject = ['cmdlineArgs'] export function apply(ctx: Context): void { - const values = parseCmdline(ctx, webCommand(), planWebStartup) - if (values !== undefined) ctx.provide('webStartup', values) + const program = webCommand() + program.action(() => ctx.provide('webStartup', webValuesFrom(program))) + parseCmdline(ctx, program) } ``` @@ -45,7 +46,7 @@ Every row configured from those values uses ordinary service injection and direc port: !!js ctx.webStartup.port ?? 3080 ``` -`parseCmdline` parses the immutable arguments and asks `plan` for the app-owned value. On `--help`, `--version`, a parse error, or a `program.error(...)` from the plan, it writes commander's text, requests exit, and returns `undefined`; the provider publishes nothing, so dependent rows never activate. +`parseCmdline` refuses at load a program in which no command declares an action, routes every command's exit and output through the launcher (commander copies those settings into subcommands only at registration), and parses the immutable arguments; commander runs the invoked command's synchronous action on success. An action rejects an invalid invocation with `program.error(...)` — before publishing, since statements ahead of the rejection have already run. On `--help`, `--version`, a parse error, or that rejection, the helper writes commander's text and requests exit; the provider publishes nothing, so dependent rows never activate. ### How injection orders config diff --git a/packages/boot/cmdline/README.zh.md b/packages/boot/cmdline/README.zh.md index c04d76905e..7ef49a1027 100644 --- a/packages/boot/cmdline/README.zh.md +++ b/packages/boot/cmdline/README.zh.md @@ -15,15 +15,16 @@ dsh 启动器交给它所引导应用的那条命令行。启动器只解析属 ## 普通提供方与注入配置 -任何应用插件都可以注入 `cmdlineArgs`、解析它,再发布一个普通的应用自有服务。`parseCmdline(ctx, program, plan)` 只适配 commander;返回值与服务都归调用方持有: +任何应用插件都可以注入 `cmdlineArgs`、解析它,再发布一个普通的应用自有服务。`parseCmdline(ctx, program)` 只适配 commander;校验与发布的服务都归 program 自己的 action 持有: ```ts ignore export const name = 'web-startup' export const inject = ['cmdlineArgs'] export function apply(ctx: Context): void { - const values = parseCmdline(ctx, webCommand(), planWebStartup) - if (values !== undefined) ctx.provide('webStartup', values) + const program = webCommand() + program.action(() => ctx.provide('webStartup', webValuesFrom(program))) + parseCmdline(ctx, program) } ``` @@ -45,7 +46,7 @@ export function apply(ctx: Context): void { port: !!js ctx.webStartup.port ?? 3080 ``` -`parseCmdline` 解析不可变参数,再向 `plan` 索取应用自有取值。遇到 `--help`、`--version`、解析错误,或 `plan` 发出的 `program.error(...)` 时,它输出 commander 文本、请求退出并返回 `undefined`;提供方什么也不发布,因此依赖行不会激活。 +`parseCmdline` 在加载时拒绝整棵命令树中没有任何命令声明 action 的 program,把每个命令的退出与输出都接到启动器上(commander 只在注册时把这些设置复制进子命令),再解析不可变参数;解析成功时 commander 运行被调用命令的同步 action。action 用 `program.error(...)` 拒绝无效调用——必须先拒绝后发布,因为写在拒绝之前的语句已经执行。遇到 `--help`、`--version`、解析错误或这种拒绝时,该适配器输出 commander 文本并请求退出;提供方什么也不发布,因此依赖行不会激活。 ### 注入如何排列配置求值 diff --git a/packages/boot/cmdline/src/index.ts b/packages/boot/cmdline/src/index.ts index ebe8d95aee..c053dcb95f 100644 --- a/packages/boot/cmdline/src/index.ts +++ b/packages/boot/cmdline/src/index.ts @@ -8,7 +8,8 @@ * text, and its parse errors instead of the launcher knowing them. * * Any app plugin can inject `cmdlineArgs` and call {@link parseCmdline}. A - * provider may publish the parsed values as its own service, and ordinary rows + * provider may publish the parsed values as its own service from its program's + * commander action, and ordinary rows * can inject that service and read it from lazily resolved config — * `port: !!js ctx.webStartup.port ?? 3080` — so a flag beats the value written * beside it. No row has launcher-level command-line status. @@ -76,35 +77,25 @@ export const internals: { stdout: { write(chunk: string): unknown }; stderr: { w stderr: process.stderr, } -/** - * Resolve parsed arguments into an app-owned value. Call - * `program.error(...)` to reject the invocation with a usage message instead - * of throwing. - * @param program - the parsed commander program. - * @param ctx - the plugin context that received the command line. - * @returns the value an ordinary provider plugin may publish. - */ -export type CmdlinePlan = (program: Command, ctx: Context) => T - /** * Parse the launcher's immutable argument snapshot with an app's commander - * program. The caller decides whether and how to publish the returned value; - * this helper has no Loader-row or service ownership semantics. + * program. Commander runs the program's own synchronous action handler on a + * successful parse; app code there publishes its service and rejects an + * invalid invocation with `program.error(...)`. This helper has no Loader-row + * or service ownership semantics. * - * Help, version, and rejected arguments are terminal for the process: commander - * writes the text, the helper requests `ctx.appExit`, and it returns - * `undefined` so the caller publishes nothing. + * Help, version, and rejected arguments — from the grammar or from an action + * — are terminal for the process: commander writes the text and the helper + * requests `ctx.appExit`. The action never runs on help, version, or a + * grammar rejection; an action must reject before it publishes, because + * statements before its `program.error(...)` have already run. * @param ctx - plugin context carrying `cmdlineArgs` and `appExit`. - * @param program - the app's commander program, with its flags and description already declared. - * @param plan - this invocation's resolved value; omitted returns an empty object. - * @returns the resolved value, or `undefined` when the app asked to exit. - * @throws when the launcher did not provide the command line and exit request. + * @param program - the app's commander program, with its flags, description, + * actions, and any subcommands already declared. + * @throws when the launcher did not provide the command line and exit request, + * or when no command in the program declares an action. */ -export function parseCmdline( - ctx: Context, - program: Command, - plan: CmdlinePlan = (() => ({}) as T), -): T | undefined { +export function parseCmdline(ctx: Context, program: Command): void { // Read through the global service store, not the property proxy: appExit is // an optional host value and the plugin only needs to inject cmdlineArgs. const args = ctx.get('cmdlineArgs') @@ -112,23 +103,54 @@ export function parseCmdline( if (args === undefined || exit === undefined) { throw new Error(`${program.name()}: the launcher must provide ctx.cmdlineArgs and ctx.appExit before the tree mounts`) } - program + if (!hasAction(program)) { + throw new Error(`${program.name()}: no command in the program declares an action; parseCmdline runs the invoked command's action on a successful parse, and app code there publishes its service`) + } + configureExitAndOutput(program) + try { + program.parse(args.get(), { from: 'user' }) + } catch (error) { + // exitOverride turns help, version, a parse error, and the action's own + // program.error() into a CommanderError; commander has already written the + // text through the output configured above. + if (!isCommanderError(error)) throw error + exit(error.exitCode) + } +} + +/** + * Whether any command in the tree declares an action handler. + * + * The `Command` type cannot express the action precondition, so the handler is + * read structurally (as {@link isCommanderError} reads commander's control-flow + * errors): without this guard, a program that forgot its action would parse + * successfully, publish nothing, and surface only as dependent rows pending on + * the absent service. + * @param command - the command whose tree is inspected. + * @returns true when the command or any registered subcommand has an action. + */ +function hasAction(command: Command): boolean { + if (typeof (command as unknown as { _actionHandler?: unknown })._actionHandler === 'function') return true + return command.commands.some(hasAction) +} + +/** + * Route every command's exit and output through the launcher adapter. + * + * Commander copies `exitOverride` and output configuration into a subcommand + * only at registration, so a root-only override would let an + * already-registered subcommand's rejection write to the process streams and + * call `process.exit` directly, bypassing `ctx.appExit`. + * @param command - the root of the command tree to configure. + */ +function configureExitAndOutput(command: Command): void { + command .exitOverride() .configureOutput({ writeOut: text => void internals.stdout.write(text), writeErr: text => void internals.stderr.write(text), }) - try { - program.parse(args.get(), { from: 'user' }) - return plan(program, ctx) - } catch (error) { - // exitOverride turns help, version, a parse error, and a plan's own - // program.error() into a CommanderError; commander has already written the - // text through the output configured above. - if (!isCommanderError(error)) throw error - exit(error.exitCode) - return undefined - } + for (const child of command.commands) configureExitAndOutput(child) } /** diff --git a/packages/boot/cmdline/tests/cmdline.spec.ts b/packages/boot/cmdline/tests/cmdline.spec.ts index 941bfe727e..d05126a29f 100644 --- a/packages/boot/cmdline/tests/cmdline.spec.ts +++ b/packages/boot/cmdline/tests/cmdline.spec.ts @@ -14,7 +14,7 @@ import Loader from '@deepseek-ai/cordis-plugin-loader' import Include from '@deepseek-ai/cordis-plugin-include' import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' import { afterEach, describe, expect, it } from 'vitest' -import { internals, parseCmdline, provideCmdline, type CmdlinePlan } from '../src/index.ts' +import { internals, parseCmdline, provideCmdline } from '../src/index.ts' /** Every value one boot of the fixture tree observed. */ interface Observed { @@ -43,8 +43,8 @@ function demoCommand(): Command { return new Command().name('demo').exitOverride().option('--port ', 'listen port') } -/** The fixture app's plan: the resolved values its rows read. */ -const demoPlan: CmdlinePlan<{ port?: number }> = (program) => { +/** The fixture app's action body: the resolved values its rows read. */ +const resolveDemo = (program: Command): { port?: number } => { const port = program.opts<{ port?: string }>().port if (port === undefined) return {} if (!/^\d+$/.test(port)) program.error(`error: --port must be a number, got ${JSON.stringify(port)}`) @@ -58,12 +58,12 @@ const expression = (source: string): unknown => ({ __jsExpr: source }) * Mount a two-row composition the way a profile boot does: both rows at once, * with Loader ordering config resolution from their injections. * @param args - the invocation's inner arguments. - * @param plan - the app's plan; defaults to the fixture's own. + * @param resolve - the app's action body; defaults to the fixture's own. * @returns the booted fixture. */ async function bootFixture( args: string[], - plan: CmdlinePlan = demoPlan, + resolve: (program: Command) => unknown = resolveDemo, options: { objectInject?: boolean; withoutProvider?: boolean } = {}, ): Promise { const dir = mkdtempSync(join(tmpdir(), 'dsh-cmdline-')) @@ -88,8 +88,9 @@ export function apply(ctx) { return globalThis.__provideDemoArgs(ctx) } const globals = globalThis as unknown as { __observed: Observed; __provideDemoArgs: (ctx: Context) => void } globals.__observed = observed globals.__provideDemoArgs = (ctx: Context) => { - const values = parseCmdline(ctx, demoCommand(), plan) - if (values !== undefined) ctx.provide('demoStartup', values) + const program = demoCommand() + program.action(() => { ctx.provide('demoStartup', resolve(program)) }) + parseCmdline(ctx, program) } // The composition, exactly as a profile delivers one: include patches whose @@ -133,7 +134,7 @@ describe('parseCmdline', () => { }) it('recognizes the Loader object form of a provider-service injection', async () => { - const { observed } = await bootFixture(['--port', '8080'], demoPlan, { objectInject: true }) + const { observed } = await bootFixture(['--port', '8080'], resolveDemo, { objectInject: true }) expect(observed.started).toEqual({ port: 8080 }) }) @@ -144,31 +145,35 @@ describe('parseCmdline', () => { expect(observed.exits).toEqual([0]) }) - it('rejects the invocation from the plan without starting the app', async () => { + it('rejects the invocation from the action without starting the app', async () => { const { observed } = await bootFixture(['--port', 'abc']) expect(observed.out).toContain('--port must be a number') expect(observed.started).toBeUndefined() expect(observed.exits).toEqual([1]) }) - it('rethrows a plan failure that is not commander asking to exit', async () => { - const { ctx } = await bootFixture([], demoPlan, { withoutProvider: true }) - const plan: CmdlinePlan = () => { throw new Error('plan exploded') } - expect(() => { parseCmdline(ctx, demoCommand(), plan) }).toThrow('plan exploded') + it('rethrows an action failure that is not commander asking to exit', async () => { + const { ctx } = await bootFixture([], resolveDemo, { withoutProvider: true }) + const program = demoCommand().action(() => { throw new Error('action exploded') }) + expect(() => { parseCmdline(ctx, program) }).toThrow('action exploded') }) it('rethrows a thrown value that is not an object at all', async () => { - const { ctx } = await bootFixture([], demoPlan, { withoutProvider: true }) - const plan: CmdlinePlan = () => { - const thrown: unknown = 'plan threw a string' + const { ctx } = await bootFixture([], resolveDemo, { withoutProvider: true }) + const program = demoCommand().action(() => { + const thrown: unknown = 'action threw a string' throw thrown - } - expect(() => { parseCmdline(ctx, demoCommand(), plan) }).toThrow('plan threw a string') + }) + expect(() => { parseCmdline(ctx, program) }).toThrow('action threw a string') }) - it('returns values without inspecting Loader rows or owning a service', async () => { - const { ctx } = await bootFixture([], demoPlan, { withoutProvider: true }) - expect(parseCmdline(ctx, demoCommand())).toEqual({}) + it('runs the action without inspecting Loader rows or owning a service', async () => { + const { ctx } = await bootFixture([], resolveDemo, { withoutProvider: true }) + let values: unknown + const program = demoCommand() + program.action(() => { values = resolveDemo(program) }) + parseCmdline(ctx, program) + expect(values).toEqual({}) expect(ctx.get('demoStartup')).toBeUndefined() }) }) @@ -182,6 +187,28 @@ describe('provideCmdline', () => { expect(ctx.cmdlineArgs?.get()).toEqual(['--resume', 'abc']) }) + it('refuses at load a program in which no command declares an action', async () => { + const { ctx } = await bootFixture([], resolveDemo, { withoutProvider: true }) + expect(() => { parseCmdline(ctx, demoCommand()) }) + .toThrow('no command in the program declares an action') + }) + + it('routes a pre-registered subcommand rejection through the launcher exit request', () => { + const ctx = new Context() + const exits: number[] = [] + let err = '' + internals.stderr = { write: (chunk: string) => { err += chunk; return true } } + provideCmdline(ctx, { args: ['serve'], exit: code => void exits.push(code) }) + // The root declares no action of its own: the tree-wide guard accepts the + // subcommand's, and the subcommand inherits the exit and output routing. + const program = new Command().name('demo') + const child = program.command('serve') + child.action(() => { child.error('error: serve rejected') }) + parseCmdline(ctx, program) + expect(err).toContain('serve rejected') + expect(exits).toEqual([1]) + }) + it('fails loud when a parser runs without the launcher values', () => { const ctx = new Context() expect(() => { parseCmdline(ctx, demoCommand()) }) @@ -191,8 +218,15 @@ describe('provideCmdline', () => { it('lets multiple parsers read the same immutable snapshot', () => { const ctx = new Context() provideCmdline(ctx, { args: ['--port', '8080'], exit: () => {} }) - expect(parseCmdline(ctx, demoCommand(), demoPlan)).toEqual({ port: 8080 }) - expect(parseCmdline(ctx, demoCommand(), demoPlan)).toEqual({ port: 8080 }) + const parseOnce = (): unknown => { + let values: unknown + const program = demoCommand() + program.action(() => { values = resolveDemo(program) }) + parseCmdline(ctx, program) + return values + } + expect(parseOnce()).toEqual({ port: 8080 }) + expect(parseOnce()).toEqual({ port: 8080 }) expect(Object.isFrozen(ctx.cmdlineArgs?.get())).toBe(true) }) }) diff --git a/packages/bundle/headless/src/startup.ts b/packages/bundle/headless/src/startup.ts index bfb4d44e51..cb56b5ae9a 100644 --- a/packages/bundle/headless/src/startup.ts +++ b/packages/bundle/headless/src/startup.ts @@ -41,22 +41,17 @@ Examples: } /** - * Turn the parsed command line into the runner's task. - * @param program - the parsed headless command. - * @returns the runner's service value. - */ -function planHeadlessStartup(program: Command): HeadlessStartupValues { - const task = program.args.join(' ') - if (task.trim() === '') program.error('error: a task is required, for example: dsh --profile headless "run the tests"') - return { task } -} - -/** - * Parse and provide the one-shot task as an ordinary Cordis service. + * Parse and provide the one-shot task as an ordinary Cordis service. The + * command's action publishes the task; a missing or whitespace-only task is a + * usage error, so on rejection (and on `--help`) nothing is provided. * @param ctx - plugin context carrying the command line. - * @returns nothing once the task is provided, or when the command requested exit. */ export function apply(ctx: Context): void { - const values = parseCmdline(ctx, headlessCommand(), planHeadlessStartup) - if (values !== undefined) ctx.provide(HEADLESS_STARTUP_SERVICE, values) + const program = headlessCommand() + program.action(() => { + const task = program.args.join(' ') + if (task.trim() === '') program.error('error: a task is required, for example: dsh --profile headless "run the tests"') + ctx.provide(HEADLESS_STARTUP_SERVICE, { task } satisfies HeadlessStartupValues) + }) + parseCmdline(ctx, program) } diff --git a/packages/bundle/web-app/src/startup.ts b/packages/bundle/web-app/src/startup.ts index 90de34b01d..2aaf89a742 100644 --- a/packages/bundle/web-app/src/startup.ts +++ b/packages/bundle/web-app/src/startup.ts @@ -57,28 +57,24 @@ Examples: } /** - * Turn the parsed flags into the value injected rows read. - * @param program - the parsed web command. - * @returns this invocation's immutable Web options. - */ -function planWebStartup(program: Command): WebStartupValues { - const options = program.opts() - if (options.port !== undefined && !/^\d+$/.test(options.port)) { - program.error(`error: --port must be a number, got ${JSON.stringify(options.port)}`) - } - return { - ...options.host !== undefined && { host: options.host }, - ...options.port !== undefined && { port: Number(options.port) }, - trustedHosts: options.trustedHost ?? [], - } -} - -/** - * Parse and provide the Web invocation as an ordinary Cordis service. + * Parse and provide the Web invocation as an ordinary Cordis service. The + * command's action publishes the flags this invocation named; a non-numeric + * `--port` is a usage error, so on rejection (and on `--help`) nothing is + * provided. * @param ctx - plugin context carrying the command line. - * @returns nothing once values are provided, or when the command requested exit. */ export function apply(ctx: Context): void { - const values = parseCmdline(ctx, webCommand(), planWebStartup) - if (values !== undefined) ctx.provide(WEB_STARTUP_SERVICE, values) + const program = webCommand() + program.action(() => { + const options = program.opts() + if (options.port !== undefined && !/^\d+$/.test(options.port)) { + program.error(`error: --port must be a number, got ${JSON.stringify(options.port)}`) + } + ctx.provide(WEB_STARTUP_SERVICE, { + ...options.host !== undefined && { host: options.host }, + ...options.port !== undefined && { port: Number(options.port) }, + trustedHosts: options.trustedHost ?? [], + } satisfies WebStartupValues) + }) + parseCmdline(ctx, program) } From 0a2ac90617a7ba5c7a813c0f2abffb83daad1cc2 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 12 Aug 2026 13:52:27 +0800 Subject: [PATCH 76/81] docs: fix reference sidebar ordering and group the subsystem pages The VitePress config declared no position for the subsystem or other-interface sections, so `indexOf` returned -1 and sorted them ahead of every declared group: the reference landing page's own sidebar entry sat 1549px below the fold. Four subsystem pages also shared `order` values with pages in the same section, resolved only by sort stability and array concatenation order. Section placement and collapse move into the manifest as a per-locale declaration, and `sectionSpec` throws for an undeclared section instead of sorting it silently to the top. Subsystem pages are grouped by concern, the six topical groups collapse until one holds the page being read, and page order derives from array position. The projector drops the language-switcher line and repository badge the canonical pages carry for their GitHub readers. The navigation bar gains the DeepSeek wordmark, a release-stage tag, and a favicon; the sidebar scrollbar rests invisible and appears while scrolling. Subsystem pages carry a two-level outline, and the two plugin-development tracks now cross-link. --- ...ation-site-navigation-and-chrome.i18n.yaml | 6 + ...ocumentation-site-navigation-and-chrome.md | 37 ++++ ...mentation-site-navigation-and-chrome.zh.md | 37 ++++ docs/cordis-tutorial/index.i18n.yaml | 4 +- docs/cordis-tutorial/index.md | 2 + docs/cordis-tutorial/index.zh.md | 2 + docs/user/develop/basic/index.i18n.yaml | 4 +- docs/user/develop/basic/index.md | 1 + docs/user/develop/basic/index.zh.md | 1 + docs/user/develop/framework/index.i18n.yaml | 4 +- docs/user/develop/framework/index.md | 1 + docs/user/develop/framework/index.zh.md | 1 + scripts/project-doc-site.spec.ts | 65 +++++- scripts/project-doc-site.ts | 33 ++- website/.vitepress/config.ts | 170 +++++++++++---- website/docs.ts | 204 ++++++++++++------ website/public/favicon.svg | 3 + website/public/wordmark.svg | 19 ++ 18 files changed, 477 insertions(+), 117 deletions(-) create mode 100644 .agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.md create mode 100644 .agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.zh.md create mode 100644 website/public/favicon.svg create mode 100644 website/public/wordmark.svg diff --git a/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.i18n.yaml b/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.i18n.yaml new file mode 100644 index 0000000000..d0e78c1c71 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.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/process/2026-08-12-documentation-site-navigation-and-chrome.md +2026-08-12-documentation-site-navigation-and-chrome.md: 1b1868a011744decf1c0a25a825fd022ea3609ab +2026-08-12-documentation-site-navigation-and-chrome.zh.md: 0fd685ec8c6b7f3f2fd93013ebaf9266a70b2c81 diff --git a/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.md b/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.md new file mode 100644 index 0000000000..1b1868a011 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.md @@ -0,0 +1,37 @@ +# Agent Note: Documentation-site navigation and repository chrome + +Status: implemented + +English | [中文](2026-08-12-documentation-site-navigation-and-chrome.zh.md) + +## Problem + +The reference sidebar rendered its 43 subsystem pages first, ahead of every other group: `sectionOrder` in the VitePress config listed no position for `子系统`/`Subsystems` — nor for `其他接口`/`Other interfaces` — so `indexOf` returned `-1` and sorted them ahead of the ordered sections. Clicking the `参考` navigation item landed on the architecture page whose own sidebar entry was link 44 of 62, 1549px down a 2478px sidebar — outside the viewport. Four subsystem pages carried `order` values already taken by other pages in the same section, resolved only by `Array.prototype.sort` stability and the order the manifest's arrays happened to be concatenated. + +Separately, every canonical page carries lines written for its GitHub reader — a language switcher under the heading, and for some, a repository badge — which the site projected verbatim even though its navigation bar already offers both. + +## Decision + +[website/docs.ts](../../../../website/docs.ts) owns section placement. `sections` declares the groups per locale, and `sectionSpec(locale, label)` returns a group's position and collapse behavior, throwing when a locale declares no placement for a label. A group absent from the declaration now fails the build instead of sorting silently to the top. Placement is per locale because the two sidebars name their groups independently: one shared list ordered both label sets by convention and accepted a label missing from either without complaint. + +Subsystem pages are grouped by concern — overview, core and scopes, sessions and persistence, model and context, execution and tools, policy and interaction, platform and access — and the six topical groups render collapsed until one holds the page being read. The groups sort last within the reference sidebar: expanded, they outnumber every other group combined, so anything placed after them is reachable only by scrolling past the whole list. Page `order` derives from array position rather than a hand-written number. + +`projectedPageContent` in [scripts/project-doc-site.ts](../../../../scripts/project-doc-site.ts) drops the language-switcher line and the repository badge. The switcher match is confined to the first eight lines so a tutorial that shows the convention still renders its example. + +The navigation-bar title is the DeepSeek wordmark inlined into `siteTitle`, which VitePress renders as HTML. Inlining is what lets the mark's `currentColor` fills follow the active theme; `themeConfig.logo` renders an ``, which freezes the mark at the colors its file declares and would need one asset per theme. The sidebar scrollbar rests invisible and appears while scrolling, marked by a `data-` attribute rather than a class because Vue rewrites `class` wholesale when it patches the element. + +## Alternatives considered + +**A search tokenizer for Chinese queries.** Built and reverted. The premise — that MiniSearch leaves Chinese prose as untokenizable whole sentences — was tested against a term (`子代理`) that appears nowhere in the corpus; the Chinese pages write `Subagent` and `子 agent`. Measured against the unmodified index, `插件配置` returns 120 hits, `会话持久化` 85, `工作流` 28, `沙箱` 12, each ranking its own page first: `prefix: true` already reaches Chinese terms through the short tokens punctuation produces. Adjacent-character pairs grew the Chinese index from 1.23MB to 2.12MB for no gain. The attempt also surfaced a trap worth keeping: VitePress ships search-option functions to the browser through `Function.prototype.toString` and rebuilds them with `new Function`, so any such function that closes over a module-level constant throws in an empty scope and silently returns no results. + +**Placing the subsystem groups directly after `概念`.** Rejected: it restores the architecture page to the top but leaves generated reference, the Cordis API, and the cookbook below 43 rows. + +**Rewriting filename link text during projection.** The subsystem index table writes `[core.md](core.md)`, which reads as a repository file index on the site. `scripts/project-doc-site.spec.ts` asserts that exact row format, so the filenames are a deliberate convention rather than an oversight; changing what the site displays means changing the convention and its gate together, not working around them in the projector. + +## Consequences + +The reference sidebar measures 1452px with every subsystem group collapsed, against 2478px before, and the architecture page is its first entry. Section placement and collapse are declared in one manifest instead of split between the manifest and the config, and `scripts/project-doc-site.spec.ts` pins three invariants: every sidebar-owning page resolves a placement, an undeclared section is refused, and no two pages share an `order` within a section. + +Canonical Markdown is unchanged by the chrome stripping — the switcher and badge still serve GitHub readers. The cost is that the projector now knows two presentation conventions of the source corpus, which a page written with a different switcher wording would not match. + +The wordmark is a second copy of a mark that also lives in `apps/web/public/favicon.svg` and `packages/client/ui-primitives/src/FishLogo.tsx`, each carrying its own presentation. A change to the DeepSeek wordmark reaches the documentation site only by updating this copy. diff --git a/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.zh.md b/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.zh.md new file mode 100644 index 0000000000..0fd685ec8c --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.zh.md @@ -0,0 +1,37 @@ +# Agent Note: 文档站导航与仓库 chrome + +Status: implemented + +[English](2026-08-12-documentation-site-navigation-and-chrome.md) | 中文 + +## 问题 + +参考侧边栏把 43 个子系统页排在了所有其他分组之前:VitePress 配置中的 `sectionOrder` 既没有为 `子系统`/`Subsystems` 也没有为 `其他接口`/`Other interfaces` 声明位置,`indexOf` 返回 `-1`,于是它们排到了所有已排序分区的前面。点击 `参考` 导航项落在架构页,而该页自己的侧边栏条目是 62 条中的第 44 条,位于 2478px 侧边栏的 1549px 处——在视口之外。四个子系统页所用的 `order` 值已被同一分区内的其他页占用,只靠 `Array.prototype.sort` 的稳定性和 manifest 数组恰好的拼接顺序才没有错乱。 + +另外,每个规范页面都带有写给 GitHub 读者的行——标题下的语言切换行,部分页面还有仓库徽章——站点原样投影了它们,尽管其导航栏已经提供了这两者。 + +## 决定 + +[website/docs.ts](../../../../website/docs.ts) 拥有分区位置。`sections` 按 locale 声明各分组,`sectionSpec(locale, label)` 返回分组的位置与折叠行为,当某 locale 未为该 label 声明位置时抛错。未出现在声明中的分组现在会让构建失败,而不是静默排到最前。位置按 locale 声明,是因为两侧侧边栏各自命名分组:单一共享列表既要按约定排列两套标签,又会对任一侧缺失的标签毫无反应。 + +子系统页按关注点分组——总览、内核与作用域、会话与持久化、模型与上下文、执行与工具、策略与交互、平台与接入——其中六个主题组保持折叠,直到某一组包含正在阅读的页面。这些分组排在参考侧边栏的最后:展开时它们的数量超过其余所有分组之和,因此排在它们之后的任何内容都只能靠滚过整个列表才能到达。页面 `order` 由数组位置推导,不再手写数字。 + +[scripts/project-doc-site.ts](../../../../scripts/project-doc-site.ts) 中的 `projectedPageContent` 会丢弃语言切换行和仓库徽章。切换行的匹配被限制在前八行内,因此展示该约定的教程仍能渲染出它的示例。 + +导航栏标题是内联进 `siteTitle` 的 DeepSeek 字标,VitePress 会将其按 HTML 渲染。内联正是让字标的 `currentColor` 填充跟随当前主题的原因;`themeConfig.logo` 渲染为 ``,会把字标固定为文件声明的颜色,并且需要为每套主题各准备一份资源。侧边栏滚动条平时不可见,滚动时出现,通过 `data-` 属性而非 class 标记,因为 Vue 在 patch 该元素时会整体重写 `class`。 + +## 考虑过的替代方案 + +**为中文查询定制搜索分词器。** 已实现并撤回。其前提——MiniSearch 会把中文散文留作无法切分的整句——是用一个语料中根本不存在的词(`子代理`)验证的;中文页面写的是 `Subagent` 和 `子 agent`。在未改动的索引上实测,`插件配置` 返回 120 条命中、`会话持久化` 85 条、`工作流` 28 条、`沙箱` 12 条,且各自的页面均排在首位:`prefix: true` 已经能通过标点切出的短 token 命中中文词。相邻字符二元组把中文索引从 1.23MB 增至 2.12MB,却没有带来收益。该尝试还暴露出一个值得保留的陷阱:VitePress 通过 `Function.prototype.toString` 把搜索选项中的函数送到浏览器,再用 `new Function` 重建,因此任何闭包引用了模块级常量的此类函数都会在空作用域中抛错,并静默地返回零结果。 + +**把子系统分组直接放在 `概念` 之后。** 已否决:这样能让架构页回到顶部,但生成参考、Cordis API 和开发手册仍处在 43 行之下。 + +**在投影时重写文件名链接文字。** 子系统索引表写的是 `[core.md](core.md)`,在站点上读起来像仓库文件索引。`scripts/project-doc-site.spec.ts` 断言了该行的确切格式,因此这些文件名是刻意的约定而非疏漏;要改变站点显示的内容,就要连同该约定及其门禁一起改,而不是在投影器里绕开它们。 + +## 影响 + +在所有子系统分组折叠时,参考侧边栏高度为 1452px,此前为 2478px,且架构页是它的第一个条目。分区位置与折叠行为声明在同一份 manifest 中,不再分散于 manifest 与配置之间;`scripts/project-doc-site.spec.ts` 固定了三条不变式:每个拥有侧边栏的页面都能解析到位置、未声明的分区会被拒绝、同一分区内没有两个页面共用 `order`。 + +剥离 chrome 不改动规范 Markdown——切换行与徽章仍服务于 GitHub 读者。代价是投影器现在知晓源语料的两项呈现约定,而采用不同切换行措辞的页面将不会被匹配到。 + +字标是同一图形的第二份副本,另两份位于 `apps/web/public/favicon.svg` 和 `packages/client/ui-primitives/src/FishLogo.tsx`,各自承载自己的呈现方式。DeepSeek 字标的变更只有通过更新这份副本才能到达文档站。 diff --git a/docs/cordis-tutorial/index.i18n.yaml b/docs/cordis-tutorial/index.i18n.yaml index 1ce61a593a..af65a4b898 100644 --- a/docs/cordis-tutorial/index.i18n.yaml +++ b/docs/cordis-tutorial/index.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cordis-tutorial/index.md -index.md: a10a0f93fde4f710af2ab14f74b854ee07d7c03f -index.zh.md: fb2c4f0959eab8c7a072c44207943c31b0bed8ea +index.md: dc9bc13c80885857d42bbc32532f678d8942a40d +index.zh.md: 4bd3837d7df0c9bcc1d512e1505c0934b56cf0a7 diff --git a/docs/cordis-tutorial/index.md b/docs/cordis-tutorial/index.md index a10a0f93fd..dc9bc13c80 100644 --- a/docs/cordis-tutorial/index.md +++ b/docs/cordis-tutorial/index.md @@ -8,6 +8,8 @@ The audience is agent developers. You do not need deep TypeScript experience; th If you want the condensed concept reference instead of a walkthrough, read the [Cordis primer](../cordis-primer.md). The exhaustive API reference lives in the generated `cordis-surface` regions on the [subsystem pages](../subsystems/core.md) and the [Cordis core API](../cordis-api/context.md) pages. +To write plugins for the harness itself — loaded from a `cordis.yml` and driven from the Web UI rather than the launcher below — start from [your first Harness plugin](../user/develop/basic/index.md). + ## Setup You need a clone of this repository with dependencies installed; the [development guide](../development.md#setup-tutorial) lists the prerequisites. No API key is needed for this tutorial; every example runs keylessly. diff --git a/docs/cordis-tutorial/index.zh.md b/docs/cordis-tutorial/index.zh.md index fb2c4f0959..4bd3837d7d 100644 --- a/docs/cordis-tutorial/index.zh.md +++ b/docs/cordis-tutorial/index.zh.md @@ -8,6 +8,8 @@ Cordis 是 DeepSeek Harness SDK 底层的插件框架:它是一个小型运行 如果你想阅读精简的概念参考,而不是逐步实践,请参阅 [Cordis 入门](../cordis-primer.md)。详尽的 API 参考见[子系统页面](../subsystems/core.md)上生成的 `cordis-surface` 区块,以及 [Cordis 核心 API](../cordis-api/context.md)页面。 +如果你要为 harness 本身编写插件——由 `cordis.yml` 加载、在 Web UI 中驱动,而不是下面这个启动器——请从[第一个 Harness 插件](../user/develop/basic/index.md)开始。 + ## 准备工作 你需要克隆本仓库并安装依赖;[开发指南](../development.md#setup-tutorial)列出了前置条件。本教程不需要 API 密钥;所有示例均可在无密钥环境中运行。 diff --git a/docs/user/develop/basic/index.i18n.yaml b/docs/user/develop/basic/index.i18n.yaml index 1bfaea7b92..e4075e6325 100644 --- a/docs/user/develop/basic/index.i18n.yaml +++ b/docs/user/develop/basic/index.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/develop/basic/index.md -index.md: 71b5bd5ef5d296999420c40d3b8c9cf46c918841 -index.zh.md: 5dafe8bf0938337fa1f38634088acf00a2fcab46 +index.md: 494b7869be6ffdf5767fac260b36b2585305b516 +index.zh.md: 92b5ad4e876b31bc10d57a19d45b1bfdf2fdb9ba diff --git a/docs/user/develop/basic/index.md b/docs/user/develop/basic/index.md index 71b5bd5ef5..494b7869be 100644 --- a/docs/user/develop/basic/index.md +++ b/docs/user/develop/basic/index.md @@ -139,3 +139,4 @@ Function form is sufficient in most cases. Use class form when the plugin provid - [Build a tool](./tool.md) — learn the tool definition DSL - [Plugin configuration](./config.md) — accept user configuration +- [Cordis tutorial](../../../cordis-tutorial/index.md) — the plugin framework underneath, built from a scratch directory with no API key diff --git a/docs/user/develop/basic/index.zh.md b/docs/user/develop/basic/index.zh.md index 5dafe8bf09..92b5ad4e87 100644 --- a/docs/user/develop/basic/index.zh.md +++ b/docs/user/develop/basic/index.zh.md @@ -139,3 +139,4 @@ export default class MyService extends Service { - [开发一个工具](./tool.md) — 详细了解工具定义 DSL - [插件配置](./config.md) — 让插件接受用户配置 +- [Cordis 框架教程](../../../cordis-tutorial/index.md) — 底层的插件框架,在临时目录中动手构建,无需 API 密钥 diff --git a/docs/user/develop/framework/index.i18n.yaml b/docs/user/develop/framework/index.i18n.yaml index 1c8dc3dae4..3e27e77e5a 100644 --- a/docs/user/develop/framework/index.i18n.yaml +++ b/docs/user/develop/framework/index.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/develop/framework/index.md -index.md: 85701ce281d92da0c805b39291179df73eb65f51 -index.zh.md: 871aa55ef81a7dcbfe3cbde5986244220ee32f98 +index.md: 8cc673148d7fec4f7d9b994907e17293bc3a6a97 +index.zh.md: 1a1f7feb8685e124babb182544bde332b52da42c diff --git a/docs/user/develop/framework/index.md b/docs/user/develop/framework/index.md index 85701ce281..8cc673148d 100644 --- a/docs/user/develop/framework/index.md +++ b/docs/user/develop/framework/index.md @@ -134,3 +134,4 @@ effect cleaned up - [Services and dependencies](./service.md) — expose a capability to other plugins - [Event system](./events.md) — communicate between plugins +- [Cordis tutorial](../../../cordis-tutorial/index.md) — the same lifecycle, services, and events built step by step against the Cordis runtime diff --git a/docs/user/develop/framework/index.zh.md b/docs/user/develop/framework/index.zh.md index 871aa55ef8..1a1f7feb86 100644 --- a/docs/user/develop/framework/index.zh.md +++ b/docs/user/develop/framework/index.zh.md @@ -134,3 +134,4 @@ effect cleaned up - [服务与依赖](./service.md) — 让插件向其他插件提供能力 - [事件系统](./events.md) — 在插件之间通信 +- [Cordis 框架教程](../../../cordis-tutorial/index.md) — 在 Cordis 运行时上逐步搭出同一套生命周期、服务与事件 diff --git a/scripts/project-doc-site.spec.ts b/scripts/project-doc-site.spec.ts index 107f2c1034..439664ddd0 100644 --- a/scripts/project-doc-site.spec.ts +++ b/scripts/project-doc-site.spec.ts @@ -5,7 +5,7 @@ import { existsSync, globSync, mkdirSync, mkdtempSync, readFileSync, realpathSyn import { tmpdir } from 'node:os' import { basename, join, resolve } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { docsPages, type DocsPage } from '../website/docs.ts' +import { docsPages, sectionSpec, type DocsPage } from '../website/docs.ts' import { addProjectionFrontmatter, projectedPageContent, publishableImage, rewriteMarkdown, } from './project-doc-site.ts' @@ -364,6 +364,50 @@ describe('docsPages locale routes', () => { }) }) +describe('sidebar ordering', () => { + it('places every section a sidebar collection owns', () => { + for (const page of docsPages) { + if (page.sidebar === null) continue + expect(() => sectionSpec(page.locale, page.section), page.route).not.toThrow() + } + }) + + it('refuses a section with no declared placement', () => { + expect(() => sectionSpec('root', '数据结构')) + .toThrow('Sidebar section "数据结构" has no placement in the root locale.') + }) + + it('declares placements per locale rather than in one shared list', () => { + // Each locale ranks only its own labels, so a label one locale never uses + // cannot borrow a rank from the other. + expect(sectionSpec('root', '入门').index).toBe(0) + expect(sectionSpec('en', 'Guide').index).toBe(0) + expect(() => sectionSpec('en', '入门')).toThrow() + expect(() => sectionSpec('root', 'Guide')).toThrow() + }) + + it('collapses the subsystem groups and leaves the smaller ones open', () => { + expect(sectionSpec('root', '执行与工具').collapsed).toBe(true) + expect(sectionSpec('en', 'Execution and tools').collapsed).toBe(true) + expect(sectionSpec('root', '概念').collapsed).toBeUndefined() + }) + + it('gives each page its own position within a section', () => { + // Sidebar entries sort by order alone, so a shared value leaves the two + // pages ranked by whichever manifest block happens to be concatenated + // first rather than by an intent the manifest states. + const taken = new Map() + const collisions: string[] = [] + for (const page of docsPages) { + const slot = `${page.locale}/${String(page.sidebar)}/${page.section}#${page.order}` + const holder = taken.get(slot) + if (holder === undefined) taken.set(slot, page.label) + else collisions.push(`${slot}: ${holder} / ${page.label}`) + } + expect(collisions).toEqual([]) + }) +}) + describe('addProjectionFrontmatter', () => { it('adds frontmatter to an ordinary Markdown page', () => { expect(addProjectionFrontmatter('# Guide\n', { source: 'docs/guide.md' })).toBe( @@ -411,6 +455,25 @@ describe('projectedPageContent', () => { expect(projectedPageContent(markdown, page('zh-guide'))).toBe(markdown) }) + it('drops the language switcher the navigation bar already offers', () => { + expect(projectedPageContent('# Guide\n\nEnglish | [中文](./en/guide)\n\nBody.\n', page('zh-guide'))) + .toBe('# Guide\n\nBody.\n') + expect(projectedPageContent('# 指南\n\n[English](./en/guide) | 中文\n\n正文。\n', page('zh-guide'))) + .toBe('# 指南\n\n正文。\n') + }) + + it('drops the repository badge every page links from its footer', () => { + const badge = '[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square)](https://github.com/deepseek-ai/deepseek-harness)' + expect(projectedPageContent(`# Guide\n\nBody.\n\n${badge}\n`, page('zh-guide'))) + .toBe('# Guide\n\nBody.\n') + }) + + it('keeps a switcher-shaped line that is not the page header', () => { + // A tutorial showing the convention must still render the example. + const sample = '# Guide\n\nA\n\nB\n\nC\n\nD\n\nE\n\nEnglish | [中文](./x)\n' + expect(projectedPageContent(sample, page('zh-guide'))).toBe(sample) + }) + it('rejects a locale home source without frontmatter', () => { expect(() => projectedPageContent('# Harness\n', page(null))) .toThrow('locale home source "docs/index.zh.md" must start with YAML frontmatter') diff --git a/scripts/project-doc-site.ts b/scripts/project-doc-site.ts index e7acc73998..1d0ea9072a 100644 --- a/scripts/project-doc-site.ts +++ b/scripts/project-doc-site.ts @@ -292,6 +292,37 @@ export function addProjectionFrontmatter(markdown: string, page: Pick LANGUAGE_SWITCHER.test(line)) + // Only the switcher introducing the page qualifies; further down the same + // text is prose or a sample rather than the page's own header. + if (switcher !== -1 && switcher < 8) { + lines.splice(switcher, lines[switcher + 1] === '' ? 2 : 1) + } + const badge = lines.findLastIndex(line => REPOSITORY_BADGE.test(line)) + if (badge !== -1) { + lines.splice(lines[badge - 1] === '' ? badge - 1 : badge, lines[badge - 1] === '' ? 2 : 1) + } + return lines.join('\n') +} + /** * Select the Markdown rendered for one published page. * @@ -300,7 +331,7 @@ export function addProjectionFrontmatter(markdown: string, page: Pick page.sidebar === collection) - const sections = new Map() +function sidebar(locale: DocsLocale, collection: DocsPage['sidebar']): DefaultTheme.SidebarItem[] { + const pages = docsPages.filter(page => page.locale === locale && page.sidebar === collection) + const groups = new Map() for (const page of pages) { - const entries = sections.get(page.section) ?? [] + const entries = groups.get(page.section) ?? [] entries.push(page) - sections.set(page.section, entries) + groups.set(page.section, entries) } - return [...sections.entries()] - .sort(([left], [right]) => sectionOrder.indexOf(left) - sectionOrder.indexOf(right)) - .map(([text, entries]) => ({ - text, - items: entries - .sort((left, right) => left.order - right.order) - .map(page => ({ text: page.label, link: `/${page.route.replace(/(?:index)?\.md$/, '')}` })), - })) + return [...groups.entries()] + .sort(([left], [right]) => sectionSpec(locale, left).index - sectionSpec(locale, right).index) + .map(([text, entries]) => { + const { collapsed } = sectionSpec(locale, text) + return { + text, + // A present `collapsed` is what makes the default theme render the + // group as collapsible at all, so an open group must omit the key. + ...(collapsed === undefined ? {} : { collapsed }), + items: entries + .sort((left, right) => left.order - right.order) + .map(page => ({ text: page.label, link: `/${page.route.replace(/(?:index)?\.md$/, '')}` })), + } + }) } function watchCanonicalDocs(server: ViteDevServer): void { @@ -107,10 +92,102 @@ const sharedTheme: Pick` would freeze the mark at the colors the file declares. + */ +const wordmark = readFileSync(resolve(import.meta.dirname, '../public/wordmark.svg'), 'utf8') + .trim() + .replace(' { + let idle + addEventListener('scroll', (event) => { + const target = event.target + if (!(target instanceof Element) || !target.classList.contains('VPSidebar')) return + target.dataset.scrolling = '' + clearTimeout(idle) + idle = setTimeout(() => delete target.dataset.scrolling, 800) + }, true) +})() +` + +/** + * Navigation-bar title: the DeepSeek wordmark and the release-stage tag. + * VitePress renders `siteTitle` as HTML. + * + * @param previewTag - Localized release-stage label. + * @returns Markup placed beside the navigation-bar home link. + */ +function siteTitle(previewTag: string): string { + return `${wordmark}${previewTag}` +} + export default withMermaid({ title: 'DeepSeek Harness', description: '用于构建 Agent Harness 的插件化 SDK', - base: process.env.DOCS_BASE ?? '/', + base, + head: [ + // VitePress leaves head hrefs untouched, so the base belongs here explicitly. + ['link', { rel: 'icon', type: 'image/svg+xml', href: `${base}favicon.svg` }], + ['style', {}, siteStyle], + ['script', {}, scrollbarScript], + ], cleanUrls: true, srcDir: '.generated', cacheDir: '.cache', @@ -120,15 +197,16 @@ export default withMermaid({ label: '简体中文', lang: 'zh-CN', themeConfig: { + siteTitle: siteTitle('技术预览'), nav: [ { text: '入门', link: '/guide/', activeMatch: '^/guide/' }, { text: '开发', link: '/develop/basic/', activeMatch: '^/develop/' }, { text: '参考', link: '/reference/', activeMatch: '^/reference/' }, ], sidebar: { - '/guide/': sidebar('zh-guide'), - '/develop/': sidebar('zh-develop'), - '/reference/': sidebar('zh-reference'), + '/guide/': sidebar('root', 'zh-guide'), + '/develop/': sidebar('root', 'zh-develop'), + '/reference/': sidebar('root', 'zh-reference'), }, outline: { label: '本页目录' }, docFooter: { prev: '上一篇', next: '下一篇' }, @@ -146,15 +224,16 @@ export default withMermaid({ lang: 'en-US', link: '/en/', themeConfig: { + siteTitle: siteTitle('Preview'), nav: [ { text: 'Guide', link: '/en/guide/', activeMatch: '^/en/guide/' }, { text: 'Develop', link: '/en/develop/basic/', activeMatch: '^/en/develop/' }, { text: 'Reference', link: '/en/reference/', activeMatch: '^/en/reference/' }, ], sidebar: { - '/en/guide/': sidebar('en-guide'), - '/en/develop/': sidebar('en-develop'), - '/en/reference/': sidebar('en-reference'), + '/en/guide/': sidebar('en', 'en-guide'), + '/en/develop/': sidebar('en', 'en-develop'), + '/en/reference/': sidebar('en', 'en-reference'), }, editLink: { pattern: ({ frontmatter }: PageData) => { @@ -171,6 +250,9 @@ export default withMermaid({ }, }, vite: { + // `srcDir` puts the Vite root inside the disposable generated tree, whose + // own `public/` no tracked asset can live in. + publicDir: resolve(import.meta.dirname, '../public'), plugins: [ { name: 'deepseek-harness-doc-projector', diff --git a/website/docs.ts b/website/docs.ts index 3cf1dab74c..7615952d0e 100644 --- a/website/docs.ts +++ b/website/docs.ts @@ -144,7 +144,7 @@ const develop = pairedPages([ { source: 'docs/user/develop/basic/index.md', route: 'develop/basic/index.md', - label: { root: '第一个插件', en: 'First plugin' }, + label: { root: '第一个 Harness 插件', en: 'Your first Harness plugin' }, sidebar: { root: 'zh-develop', en: 'en-develop' }, section: { root: '基础', en: 'Basics' }, order: 1, @@ -219,7 +219,7 @@ const develop = pairedPages([ ]) const cordisTutorial = pairedPages(([ - ['index.md', 'Cordis 教程', 'Cordis tutorial'], + ['index.md', '总览', 'Overview'], ['01-first-plugin.md', '1. 第一个插件', '1. Your first plugin'], ['02-lifecycle-and-effects.md', '2. 生命周期与副作用', '2. Lifecycle and effects'], ['03-services.md', '3. 服务', '3. Services'], @@ -232,7 +232,7 @@ const cordisTutorial = pairedPages(([ route: `develop/cordis-tutorial/${file}`, label: { root: rootLabel, en: enLabel }, sidebar: { root: 'zh-develop', en: 'en-develop' }, - section: { root: 'Cordis 教程', en: 'Cordis tutorial' }, + section: { root: 'Cordis 框架教程', en: 'Cordis framework tutorial' }, order, ...(file === 'index.md' ? { sourceAliases: ['docs/cordis-tutorial'] } : {}), }))) @@ -248,55 +248,84 @@ const cordisPrimerReference = pairedPages([ }, ]) -const subsystemsReference = pairedPages(([ - ['README.md', '子系统', 'Subsystems', 0], - ['core.md', '核心', 'Core', 1], - ['scope.md', '作用域', 'Scopes', 2], - ['typert.md', 'TypeRT', 'TypeRT', 39], - ['session.md', '会话', 'Sessions', 3], - ['session-query.md', '会话查询', 'Session query', 4], - ['session-reference.md', '会话引用', 'Session references', 5], - ['session-title.md', '会话标题', 'Session titles', 6], - ['settings.md', '用户设置', 'User settings', 7], - ['credentials.md', '用户凭据', 'User credentials', 8], - ['system-prompt.md', '系统提示词', 'System prompts', 9], - ['tools.md', '工具', 'Tools', 10], - ['llm-streaming.md', 'LLM 流式响应', 'LLM streaming', 11], - ['token-meter.md', 'Token 计量', 'Token metering', 12], - ['bash.md', 'Bash 执行', 'Bash execution', 13], - ['subprocess.md', '子进程', 'Subprocesses', 14], - ['tasks.md', '后台任务', 'Background tasks', 15], - ['filesystem.md', '文件系统', 'Filesystem', 16], - ['lsp.md', 'LSP 导航', 'LSP navigation', 17], - ['code-runtime.md', '代码运行时', 'Code runtime', 18], - ['compaction.md', '上下文压缩', 'Compaction', 19], - ['subagent.md', '子代理', 'Subagents', 20], - ['workflow.md', '工作流', 'Workflows', 21], - ['skills.md', '技能', 'Skills', 22], - ['approval.md', '审批', 'Approvals', 23], - ['permission.md', '权限预设', 'Permission presets', 24], - ['plan.md', '计划模式', 'Plan mode', 25], - ['user-interaction.md', '用户交互', 'User interaction', 26], - ['sandbox.md', '沙箱', 'Sandboxing', 27], - ['web.md', 'Web 访问', 'Web access', 28], - ['spill.md', 'Spill 存储', 'Spill storage', 29], - ['persistence.md', '会话持久化', 'Session persistence', 30], - ['storage.md', '存储', 'Storage', 31], - ['workspace.md', '工作区', 'Workspaces', 32], - ['http-server.md', 'HTTP 服务器', 'HTTP server', 33], - ['client-modules.md', '客户端模块', 'Client modules', 34], - ['invariants.md', '运行时不变式', 'Runtime invariants', 36], - ['session-projection.md', '会话投影', 'Session projections', 37], - ['telemetry.md', '遥测', 'Telemetry', 38], -] as const).map(([file, rootLabel, enLabel, order]): PairedPage => ({ - source: `docs/subsystems/${file}`, - route: file === 'README.md' ? 'reference/subsystems/index.md' : `reference/subsystems/${file}`, - label: { root: rootLabel, en: enLabel }, - sidebar: { root: 'zh-reference', en: 'en-reference' }, - section: { root: '子系统', en: 'Subsystems' }, - order, - ...(file === 'README.md' ? { sourceAliases: ['docs/subsystems'] } : {}), -}))) +/** + * Subsystem pages grouped by the concern they document, as `[Chinese section, + * English section, pages]`. One flat list of every subsystem pushed the rest of + * the reference sidebar below the fold. + */ +const subsystemGroups = [ + ['总览', 'Overview', [ + ['README.md', '子系统', 'Subsystems'], + ]], + ['内核与作用域', 'Core and scopes', [ + ['core.md', '核心', 'Core'], + ['scope.md', '作用域', 'Scopes'], + ['invariants.md', '运行时不变式', 'Runtime invariants'], + ]], + ['会话与持久化', 'Sessions and persistence', [ + ['session.md', '会话', 'Sessions'], + ['session-query.md', '会话查询', 'Session query'], + ['session-reference.md', '会话引用', 'Session references'], + ['session-title.md', '会话标题', 'Session titles'], + ['session-projection.md', '会话投影', 'Session projections'], + ['persistence.md', '会话持久化', 'Session persistence'], + ['spill.md', 'Spill 存储', 'Spill storage'], + ['telemetry.md', '遥测', 'Telemetry'], + ]], + ['模型与上下文', 'Model and context', [ + ['llm-streaming.md', 'LLM 流式响应', 'LLM streaming'], + ['token-meter.md', 'Token 计量', 'Token metering'], + ['system-prompt.md', '系统提示词', 'System prompts'], + ['compaction.md', '上下文压缩', 'Compaction'], + ]], + ['执行与工具', 'Execution and tools', [ + ['tools.md', '工具', 'Tools'], + ['bash.md', 'Bash 执行', 'Bash execution'], + ['subprocess.md', '子进程', 'Subprocesses'], + ['pty.md', 'PTY 会话', 'PTY sessions'], + ['tasks.md', '后台任务', 'Background tasks'], + ['filesystem.md', '文件系统', 'Filesystem'], + ['lsp.md', 'LSP 导航', 'LSP navigation'], + ['code-runtime.md', '代码运行时', 'Code runtime'], + ['web.md', 'Web 访问', 'Web access'], + ['skills.md', '技能', 'Skills'], + ['workflow.md', '工作流', 'Workflows'], + ['subagent.md', '子代理', 'Subagents'], + ]], + ['策略与交互', 'Policy and interaction', [ + ['approval.md', '审批', 'Approvals'], + ['permission.md', '权限预设', 'Permission presets'], + ['sandbox.md', '沙箱', 'Sandboxing'], + ['plan.md', '计划模式', 'Plan mode'], + ['user-interaction.md', '用户交互', 'User interaction'], + ['commands.md', '命令', 'Human commands'], + ['goal.md', '目标', 'Goals'], + ['schedule.md', '定时提醒', 'Scheduled reminders'], + ]], + ['平台与接入', 'Platform and access', [ + ['http-server.md', 'HTTP 服务器', 'HTTP server'], + ['typert.md', 'TypeRT', 'TypeRT'], + ['client-modules.md', '客户端模块', 'Client modules'], + ['storage.md', '存储', 'Storage'], + ['workspace.md', '工作区', 'Workspaces'], + ['settings.md', '用户设置', 'User settings'], + ['credentials.md', '用户凭据', 'User credentials'], + ]], +] as const + +const subsystemsReference = subsystemGroups.flatMap(([rootSection, enSection, files]) => pairedPages( + files.map(([file, rootLabel, enLabel], order): PairedPage => ({ + source: `docs/subsystems/${file}`, + route: file === 'README.md' ? 'reference/subsystems/index.md' : `reference/subsystems/${file}`, + label: { root: rootLabel, en: enLabel }, + sidebar: { root: 'zh-reference', en: 'en-reference' }, + section: { root: rootSection, en: enSection }, + order, + // Subsystem pages carry long third-level sections a two-level outline reaches. + outline: [2, 3], + ...(file === 'README.md' ? { sourceAliases: ['docs/subsystems'] } : {}), + })), +)) const reference = [ ...pairedPages(([ @@ -359,19 +388,6 @@ const reference = [ section: { root: 'Cordis API', en: 'Cordis Core API' }, order: order + 5, }))), - ...pairedPages(([ - ['goal.md', '目标', 'Goals', 14], - ['schedule.md', '定时提醒', 'Scheduled reminders', 15], - ['pty.md', 'PTY 会话', 'PTY sessions', 26], - ['commands.md', '命令', 'Human commands', 38], - ] as const).map(([file, rootLabel, enLabel, order]): PairedPage => ({ - source: `docs/subsystems/${file}`, - route: `reference/subsystems/${file}`, - label: { root: rootLabel, en: enLabel }, - sidebar: { root: 'zh-reference', en: 'en-reference' }, - section: { root: '子系统', en: 'Subsystems' }, - order, - }))), ...pairedPages(([ ['adding-a-package.md', '新增 Package', 'Adding a package'], ['adding-a-tool.md', '新增 Tool', 'Adding a tool'], @@ -395,6 +411,64 @@ const reference = [ }]), ] +/** A sidebar group, matched to pages by `label`. */ +export interface DocsSection { + /** Group heading, equal to the `section` field of every page it holds. */ + label: string + /** Render the group collapsed until it holds the page being read. */ + collapsed?: boolean +} + +/** + * Every sidebar group, in the order its locale renders it. + * + * The subsystem groups collapse because together they outnumber the rest of the + * reference sidebar; expanded, they push every other group below the fold. + */ +const sections: Record = { + root: [ + { label: '入门' }, { label: '其他接口' }, + { label: '基础' }, { label: '框架能力' }, { label: '实战' }, { label: 'Cordis 框架教程' }, + { label: '概念' }, { label: '生成参考' }, { label: 'Cordis API' }, { label: '开发手册' }, + { label: '总览' }, + { label: '内核与作用域', collapsed: true }, + { label: '会话与持久化', collapsed: true }, + { label: '模型与上下文', collapsed: true }, + { label: '执行与工具', collapsed: true }, + { label: '策略与交互', collapsed: true }, + { label: '平台与接入', collapsed: true }, + ], + en: [ + { label: 'Guide' }, { label: 'Other interfaces' }, + { label: 'Basics' }, { label: 'Framework' }, { label: 'Practice' }, { label: 'Cordis framework tutorial' }, + { label: 'Concepts' }, { label: 'Generated reference' }, { label: 'Cordis Core API' }, { label: 'Cookbook' }, + { label: 'Overview' }, + { label: 'Core and scopes', collapsed: true }, + { label: 'Sessions and persistence', collapsed: true }, + { label: 'Model and context', collapsed: true }, + { label: 'Execution and tools', collapsed: true }, + { label: 'Policy and interaction', collapsed: true }, + { label: 'Platform and access', collapsed: true }, + ], +} + +/** + * Placement and collapse behavior of one sidebar group. + * + * @param locale - Route tree whose sidebar is being built. + * @param label - Section label carried by the pages in the group. + * @returns The declared group, plus its zero-based position in the locale. + * @throws When the locale declares no placement for the label. Ranking by list + * membership alone would sort an undeclared group silently ahead of every + * declared one. + */ +export function sectionSpec(locale: DocsLocale, label: string): DocsSection & { index: number } { + const declared = sections[locale] + const section = declared.find(candidate => candidate.label === label) + if (section === undefined) throw new Error(`Sidebar section "${label}" has no placement in the ${locale} locale.`) + return { ...section, index: declared.indexOf(section) } +} + /** Every canonical page published by the documentation website. */ export const docsPages: DocsPage[] = [ ...homeAndGuide, diff --git a/website/public/favicon.svg b/website/public/favicon.svg new file mode 100644 index 0000000000..653b77e157 --- /dev/null +++ b/website/public/favicon.svg @@ -0,0 +1,3 @@ + + + diff --git a/website/public/wordmark.svg b/website/public/wordmark.svg new file mode 100644 index 0000000000..36e055ff2f --- /dev/null +++ b/website/public/wordmark.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + From d6af042cf7ceeadcb9c9b1d860ddb1687717da03 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 12 Aug 2026 14:02:29 +0800 Subject: [PATCH 77/81] docs(website): derive navigation targets from the publication manifest The navigation bar named `/guide/` while the manifest published the guide's first page at `guide/quickstart.md`, so the item served a 404 in both locales. `landingLink` resolves each item against `orderedPages`, the ordering the sidebar already renders, and a test asserts every navigation target is a route the manifest publishes. --- ...ation-site-navigation-and-chrome.i18n.yaml | 4 +- ...ocumentation-site-navigation-and-chrome.md | 4 ++ ...mentation-site-navigation-and-chrome.zh.md | 4 ++ scripts/project-doc-site.spec.ts | 15 +++++- website/.vitepress/config.ts | 45 +++++++++--------- website/docs.ts | 46 ++++++++++++++++++- 6 files changed, 90 insertions(+), 28 deletions(-) diff --git a/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.i18n.yaml b/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.i18n.yaml index d0e78c1c71..c5b67aa400 100644 --- a/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.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/process/2026-08-12-documentation-site-navigation-and-chrome.md -2026-08-12-documentation-site-navigation-and-chrome.md: 1b1868a011744decf1c0a25a825fd022ea3609ab -2026-08-12-documentation-site-navigation-and-chrome.zh.md: 0fd685ec8c6b7f3f2fd93013ebaf9266a70b2c81 +2026-08-12-documentation-site-navigation-and-chrome.md: 07f88d303a96676806cce0801bce5d478fb5406e +2026-08-12-documentation-site-navigation-and-chrome.zh.md: ca605e2a890bac4bcff9f22a6c6ab8928d1a8aa8 diff --git a/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.md b/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.md index 1b1868a011..07f88d303a 100644 --- a/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.md +++ b/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.md @@ -8,6 +8,8 @@ English | [中文](2026-08-12-documentation-site-navigation-and-chrome.zh.md) The reference sidebar rendered its 43 subsystem pages first, ahead of every other group: `sectionOrder` in the VitePress config listed no position for `子系统`/`Subsystems` — nor for `其他接口`/`Other interfaces` — so `indexOf` returned `-1` and sorted them ahead of the ordered sections. Clicking the `参考` navigation item landed on the architecture page whose own sidebar entry was link 44 of 62, 1549px down a 2478px sidebar — outside the viewport. Four subsystem pages carried `order` values already taken by other pages in the same section, resolved only by `Array.prototype.sort` stability and the order the manifest's arrays happened to be concatenated. +The navigation bar named `/guide/` while the manifest published the guide's first page at `guide/quickstart.md`, so that item served a 404: written-down navigation targets drift from the routes the manifest publishes. + Separately, every canonical page carries lines written for its GitHub reader — a language switcher under the heading, and for some, a repository badge — which the site projected verbatim even though its navigation bar already offers both. ## Decision @@ -16,6 +18,8 @@ Separately, every canonical page carries lines written for its GitHub reader — Subsystem pages are grouped by concern — overview, core and scopes, sessions and persistence, model and context, execution and tools, policy and interaction, platform and access — and the six topical groups render collapsed until one holds the page being read. The groups sort last within the reference sidebar: expanded, they outnumber every other group combined, so anything placed after them is reachable only by scrolling past the whole list. Page `order` derives from array position rather than a hand-written number. +`landingLink(locale, collection)` derives each navigation item's target from `orderedPages`, the same ordering the sidebar renders, so an item always opens its collection's first published page. + `projectedPageContent` in [scripts/project-doc-site.ts](../../../../scripts/project-doc-site.ts) drops the language-switcher line and the repository badge. The switcher match is confined to the first eight lines so a tutorial that shows the convention still renders its example. The navigation-bar title is the DeepSeek wordmark inlined into `siteTitle`, which VitePress renders as HTML. Inlining is what lets the mark's `currentColor` fills follow the active theme; `themeConfig.logo` renders an ``, which freezes the mark at the colors its file declares and would need one asset per theme. The sidebar scrollbar rests invisible and appears while scrolling, marked by a `data-` attribute rather than a class because Vue rewrites `class` wholesale when it patches the element. diff --git a/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.zh.md b/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.zh.md index 0fd685ec8c..ca605e2a89 100644 --- a/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.zh.md +++ b/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.zh.md @@ -8,6 +8,8 @@ Status: implemented 参考侧边栏把 43 个子系统页排在了所有其他分组之前:VitePress 配置中的 `sectionOrder` 既没有为 `子系统`/`Subsystems` 也没有为 `其他接口`/`Other interfaces` 声明位置,`indexOf` 返回 `-1`,于是它们排到了所有已排序分区的前面。点击 `参考` 导航项落在架构页,而该页自己的侧边栏条目是 62 条中的第 44 条,位于 2478px 侧边栏的 1549px 处——在视口之外。四个子系统页所用的 `order` 值已被同一分区内的其他页占用,只靠 `Array.prototype.sort` 的稳定性和 manifest 数组恰好的拼接顺序才没有错乱。 +顶栏把 `入门` 指向 `/guide/`,而 manifest 已把入门首页发布在 `guide/quickstart.md`,该导航项因此返回 404:写死的导航目标会与 manifest 实际发布的路由脱节。 + 另外,每个规范页面都带有写给 GitHub 读者的行——标题下的语言切换行,部分页面还有仓库徽章——站点原样投影了它们,尽管其导航栏已经提供了这两者。 ## 决定 @@ -16,6 +18,8 @@ Status: implemented 子系统页按关注点分组——总览、内核与作用域、会话与持久化、模型与上下文、执行与工具、策略与交互、平台与接入——其中六个主题组保持折叠,直到某一组包含正在阅读的页面。这些分组排在参考侧边栏的最后:展开时它们的数量超过其余所有分组之和,因此排在它们之后的任何内容都只能靠滚过整个列表才能到达。页面 `order` 由数组位置推导,不再手写数字。 +`landingLink(locale, collection)` 依据 `orderedPages`——即侧边栏所用的同一套排序——推导每个导航项的目标,因此导航项始终打开该分区已发布的首个页面。 + [scripts/project-doc-site.ts](../../../../scripts/project-doc-site.ts) 中的 `projectedPageContent` 会丢弃语言切换行和仓库徽章。切换行的匹配被限制在前八行内,因此展示该约定的教程仍能渲染出它的示例。 导航栏标题是内联进 `siteTitle` 的 DeepSeek 字标,VitePress 会将其按 HTML 渲染。内联正是让字标的 `currentColor` 填充跟随当前主题的原因;`themeConfig.logo` 渲染为 ``,会把字标固定为文件声明的颜色,并且需要为每套主题各准备一份资源。侧边栏滚动条平时不可见,滚动时出现,通过 `data-` 属性而非 class 标记,因为 Vue 在 patch 该元素时会整体重写 `class`。 diff --git a/scripts/project-doc-site.spec.ts b/scripts/project-doc-site.spec.ts index 439664ddd0..e52017cbd8 100644 --- a/scripts/project-doc-site.spec.ts +++ b/scripts/project-doc-site.spec.ts @@ -5,7 +5,7 @@ import { existsSync, globSync, mkdirSync, mkdtempSync, readFileSync, realpathSyn import { tmpdir } from 'node:os' import { basename, join, resolve } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { docsPages, sectionSpec, type DocsPage } from '../website/docs.ts' +import { docsPages, landingLink, routeLink, sectionSpec, type DocsPage } from '../website/docs.ts' import { addProjectionFrontmatter, projectedPageContent, publishableImage, rewriteMarkdown, } from './project-doc-site.ts' @@ -386,6 +386,19 @@ describe('sidebar ordering', () => { expect(() => sectionSpec('root', 'Guide')).toThrow() }) + it('lands every navigation item on a page the manifest publishes', () => { + // The navigation bar named `/guide/` while the manifest published the guide's + // first page at `guide/quickstart.md`, so the item served a 404. + const collections = [ + ['root', 'zh-guide'], ['root', 'zh-develop'], ['root', 'zh-reference'], + ['en', 'en-guide'], ['en', 'en-develop'], ['en', 'en-reference'], + ] as const + const published = new Set(docsPages.map(page => routeLink(page.route))) + for (const [locale, collection] of collections) { + expect(published, `${locale}/${collection}`).toContain(landingLink(locale, collection)) + } + }) + it('collapses the subsystem groups and leaves the smaller ones open', () => { expect(sectionSpec('root', '执行与工具').collapsed).toBe(true) expect(sectionSpec('en', 'Execution and tools').collapsed).toBe(true) diff --git a/website/.vitepress/config.ts b/website/.vitepress/config.ts index f611804927..e451ecb68c 100644 --- a/website/.vitepress/config.ts +++ b/website/.vitepress/config.ts @@ -5,33 +5,30 @@ import { resolve } from 'node:path' import type { DefaultTheme, PageData } from 'vitepress' import type { ViteDevServer } from 'vite' import { withMermaid } from 'vitepress-plugin-mermaid' -import { docsPages, sectionSpec, type DocsLocale, type DocsPage } from '../docs.ts' +import { landingLink, orderedPages, routeLink, sectionSpec, type DocsLocale, type DocsPage } from '../docs.ts' import { docsSourceFiles, projectDocs } from '../../scripts/project-doc-site.ts' projectDocs() -function sidebar(locale: DocsLocale, collection: DocsPage['sidebar']): DefaultTheme.SidebarItem[] { - const pages = docsPages.filter(page => page.locale === locale && page.sidebar === collection) +function sidebar(locale: DocsLocale, collection: NonNullable): DefaultTheme.SidebarItem[] { + // `orderedPages` already sorts by section placement, so insertion order + // carries the group order and each group keeps its pages in sequence. const groups = new Map() - for (const page of pages) { + for (const page of orderedPages(locale, collection)) { const entries = groups.get(page.section) ?? [] entries.push(page) groups.set(page.section, entries) } - return [...groups.entries()] - .sort(([left], [right]) => sectionSpec(locale, left).index - sectionSpec(locale, right).index) - .map(([text, entries]) => { - const { collapsed } = sectionSpec(locale, text) - return { - text, - // A present `collapsed` is what makes the default theme render the - // group as collapsible at all, so an open group must omit the key. - ...(collapsed === undefined ? {} : { collapsed }), - items: entries - .sort((left, right) => left.order - right.order) - .map(page => ({ text: page.label, link: `/${page.route.replace(/(?:index)?\.md$/, '')}` })), - } - }) + return [...groups.entries()].map(([text, entries]) => { + const { collapsed } = sectionSpec(locale, text) + return { + text, + // A present `collapsed` is what makes the default theme render the + // group as collapsible at all, so an open group must omit the key. + ...(collapsed === undefined ? {} : { collapsed }), + items: entries.map(page => ({ text: page.label, link: routeLink(page.route) })), + } + }) } function watchCanonicalDocs(server: ViteDevServer): void { @@ -199,9 +196,9 @@ export default withMermaid({ themeConfig: { siteTitle: siteTitle('技术预览'), nav: [ - { text: '入门', link: '/guide/', activeMatch: '^/guide/' }, - { text: '开发', link: '/develop/basic/', activeMatch: '^/develop/' }, - { text: '参考', link: '/reference/', activeMatch: '^/reference/' }, + { text: '入门', link: landingLink('root', 'zh-guide'), activeMatch: '^/guide/' }, + { text: '开发', link: landingLink('root', 'zh-develop'), activeMatch: '^/develop/' }, + { text: '参考', link: landingLink('root', 'zh-reference'), activeMatch: '^/reference/' }, ], sidebar: { '/guide/': sidebar('root', 'zh-guide'), @@ -226,9 +223,9 @@ export default withMermaid({ themeConfig: { siteTitle: siteTitle('Preview'), nav: [ - { text: 'Guide', link: '/en/guide/', activeMatch: '^/en/guide/' }, - { text: 'Develop', link: '/en/develop/basic/', activeMatch: '^/en/develop/' }, - { text: 'Reference', link: '/en/reference/', activeMatch: '^/en/reference/' }, + { text: 'Guide', link: landingLink('en', 'en-guide'), activeMatch: '^/en/guide/' }, + { text: 'Develop', link: landingLink('en', 'en-develop'), activeMatch: '^/en/develop/' }, + { text: 'Reference', link: landingLink('en', 'en-reference'), activeMatch: '^/en/reference/' }, ], sidebar: { '/en/guide/': sidebar('en', 'en-guide'), diff --git a/website/docs.ts b/website/docs.ts index 7615952d0e..12280585cc 100644 --- a/website/docs.ts +++ b/website/docs.ts @@ -11,7 +11,7 @@ export type DocsLocale = 'root' | 'en' /** Sidebar collection rendered for one locale and top-level module. */ -type DocsSidebar = +export type DocsSidebar = | 'zh-guide' | 'zh-develop' | 'zh-reference' @@ -478,3 +478,47 @@ export const docsPages: DocsPage[] = [ ...subsystemsReference, ...reference, ] + +/** + * Pages of one sidebar collection, in the order the sidebar lists them. + * + * @param locale - Route tree whose sidebar is being built. + * @param collection - Sidebar collection to read. + * @returns The collection's pages, ordered by section placement then by `order`. + */ +export function orderedPages(locale: DocsLocale, collection: DocsSidebar): DocsPage[] { + return docsPages + .filter(page => page.locale === locale && page.sidebar === collection) + .sort((left, right) => ( + sectionSpec(locale, left.section).index - sectionSpec(locale, right.section).index + || left.order - right.order + )) +} + +/** + * Site-relative link for a published route. + * + * @param route - Manifest route, including its `.md` suffix. + * @returns The link VitePress serves the route at. + */ +export function routeLink(route: string): string { + return `/${route.replace(/(?:index)?\.md$/, '')}` +} + +/** + * Where a top-level navigation item lands. + * + * The target is derived rather than written down: a collection whose first page + * is renamed or reordered would otherwise leave the navigation bar pointing at + * a route the manifest no longer publishes. + * + * @param locale - Route tree the navigation item belongs to. + * @param collection - Sidebar collection the item opens. + * @returns Site-relative link of the collection's first page. + * @throws When the collection publishes no page. + */ +export function landingLink(locale: DocsLocale, collection: DocsSidebar): string { + const first = orderedPages(locale, collection)[0] + if (first === undefined) throw new Error(`Sidebar collection "${collection}" publishes no page.`) + return routeLink(first.route) +} From 0f13ffa45825c954bc3eaa02dafef4816ba0846c Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 12 Aug 2026 14:03:11 +0800 Subject: [PATCH 78/81] docs: link Web UI guide index explicitly --- README.i18n.yaml | 4 ++-- README.md | 2 +- README.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.i18n.yaml b/README.i18n.yaml index 4d38f6e19c..f1bd278906 100644 --- a/README.i18n.yaml +++ b/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 README.md -README.md: 690cde099d93ea2a371b31f441030153b1aca973 -README.zh.md: 2a8046011da7c1970d1210f291973db36e379665 +README.md: 785d7dd41cb64b0c0cbd6c23abcd2cdd6ba815db +README.zh.md: 82bc2eace173d4f56892f514e5a9eebc4f2079d8 diff --git a/README.md b/README.md index 690cde099d..785d7dd41c 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ npx @deepseek-ai/dsh web The command initializes the Web profile and prints the Web UI URL, which is `http://127.0.0.1:3080` by default. Open it, add a DeepSeek API key under **Settings → Models**, then start a session. The invoking directory is the default workspace; try `Summarize this repository and identify its main packages.` -Continue with the [Web UI guide](docs/user/guide/). +Continue with the [Web UI guide](docs/user/guide/index.md). ### Run from source diff --git a/README.zh.md b/README.zh.md index 2a8046011d..82bc2eace1 100644 --- a/README.zh.md +++ b/README.zh.md @@ -22,7 +22,7 @@ npx @deepseek-ai/dsh web 该命令会初始化 Web profile 并打印 Web UI 地址,默认地址为 `http://127.0.0.1:3080`。打开该地址,在**设置 → 模型**中添加 DeepSeek API 密钥,然后启动一个会话。调用目录是默认工作区;你可以尝试输入 `Summarize this repository and identify its main packages.`。 -下一步请阅读 [Web UI 指南](docs/user/guide/)。 +下一步请阅读 [Web UI 指南](docs/user/guide/index.md)。 ### 从源码运行 From 555771496bf8da2c6a07f0d2aa0db48fc85d5fc0 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 12 Aug 2026 14:08:18 +0800 Subject: [PATCH 79/81] docs(website): name the Python SDK group SDK The group holds language SDKs, and the sidebar reads `SDK > Python` rather than repeating the word in the page label. --- ...-12-documentation-site-navigation-and-chrome.i18n.yaml | 4 ++-- ...2026-08-12-documentation-site-navigation-and-chrome.md | 4 ++-- ...6-08-12-documentation-site-navigation-and-chrome.zh.md | 4 ++-- scripts/project-doc-site.spec.ts | 8 ++++---- website/docs.ts | 8 ++++---- 5 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.i18n.yaml b/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.i18n.yaml index c5b67aa400..ef5f11a4f8 100644 --- a/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.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/process/2026-08-12-documentation-site-navigation-and-chrome.md -2026-08-12-documentation-site-navigation-and-chrome.md: 07f88d303a96676806cce0801bce5d478fb5406e -2026-08-12-documentation-site-navigation-and-chrome.zh.md: ca605e2a890bac4bcff9f22a6c6ab8928d1a8aa8 +2026-08-12-documentation-site-navigation-and-chrome.md: 03cd44b94f853725da33800e8c89886b1a657a0b +2026-08-12-documentation-site-navigation-and-chrome.zh.md: d0972f909e648278cb3cecb7788705b228f4b675 diff --git a/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.md b/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.md index 07f88d303a..03cd44b94f 100644 --- a/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.md +++ b/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.md @@ -6,7 +6,7 @@ English | [中文](2026-08-12-documentation-site-navigation-and-chrome.zh.md) ## Problem -The reference sidebar rendered its 43 subsystem pages first, ahead of every other group: `sectionOrder` in the VitePress config listed no position for `子系统`/`Subsystems` — nor for `其他接口`/`Other interfaces` — so `indexOf` returned `-1` and sorted them ahead of the ordered sections. Clicking the `参考` navigation item landed on the architecture page whose own sidebar entry was link 44 of 62, 1549px down a 2478px sidebar — outside the viewport. Four subsystem pages carried `order` values already taken by other pages in the same section, resolved only by `Array.prototype.sort` stability and the order the manifest's arrays happened to be concatenated. +The reference sidebar rendered its 43 subsystem pages first, ahead of every other group: `sectionOrder` in the VitePress config listed no position for the subsystem groups, nor for the group holding the Python SDK page, so `indexOf` returned `-1` and sorted them ahead of the ordered sections. Clicking the `参考` navigation item landed on the architecture page whose own sidebar entry was link 44 of 62, 1549px down a 2478px sidebar — outside the viewport. Four subsystem pages carried `order` values already taken by other pages in the same section, resolved only by `Array.prototype.sort` stability and the order the manifest's arrays happened to be concatenated. The navigation bar named `/guide/` while the manifest published the guide's first page at `guide/quickstart.md`, so that item served a 404: written-down navigation targets drift from the routes the manifest publishes. @@ -14,7 +14,7 @@ Separately, every canonical page carries lines written for its GitHub reader — ## Decision -[website/docs.ts](../../../../website/docs.ts) owns section placement. `sections` declares the groups per locale, and `sectionSpec(locale, label)` returns a group's position and collapse behavior, throwing when a locale declares no placement for a label. A group absent from the declaration now fails the build instead of sorting silently to the top. Placement is per locale because the two sidebars name their groups independently: one shared list ordered both label sets by convention and accepted a label missing from either without complaint. +[website/docs.ts](../../../../website/docs.ts) owns section placement. `sections` declares the groups per locale, and `sectionSpec(locale, label)` returns a group's position and collapse behavior, throwing when a locale declares no placement for a label. A group absent from the declaration now fails the build instead of sorting silently to the top. Placement is per locale because the two sidebars name their groups independently, and a label both use — `SDK` — cannot hold one rank against `入门` and against `Guide` at once. Subsystem pages are grouped by concern — overview, core and scopes, sessions and persistence, model and context, execution and tools, policy and interaction, platform and access — and the six topical groups render collapsed until one holds the page being read. The groups sort last within the reference sidebar: expanded, they outnumber every other group combined, so anything placed after them is reachable only by scrolling past the whole list. Page `order` derives from array position rather than a hand-written number. diff --git a/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.zh.md b/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.zh.md index ca605e2a89..d0972f909e 100644 --- a/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.zh.md +++ b/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -参考侧边栏把 43 个子系统页排在了所有其他分组之前:VitePress 配置中的 `sectionOrder` 既没有为 `子系统`/`Subsystems` 也没有为 `其他接口`/`Other interfaces` 声明位置,`indexOf` 返回 `-1`,于是它们排到了所有已排序分区的前面。点击 `参考` 导航项落在架构页,而该页自己的侧边栏条目是 62 条中的第 44 条,位于 2478px 侧边栏的 1549px 处——在视口之外。四个子系统页所用的 `order` 值已被同一分区内的其他页占用,只靠 `Array.prototype.sort` 的稳定性和 manifest 数组恰好的拼接顺序才没有错乱。 +参考侧边栏把 43 个子系统页排在了所有其他分组之前:VitePress 配置中的 `sectionOrder` 既没有为子系统分组、也没有为承载 Python SDK 页的分组声明位置,`indexOf` 返回 `-1`,于是它们排到了所有已排序分区的前面。点击 `参考` 导航项落在架构页,而该页自己的侧边栏条目是 62 条中的第 44 条,位于 2478px 侧边栏的 1549px 处——在视口之外。四个子系统页所用的 `order` 值已被同一分区内的其他页占用,只靠 `Array.prototype.sort` 的稳定性和 manifest 数组恰好的拼接顺序才没有错乱。 顶栏把 `入门` 指向 `/guide/`,而 manifest 已把入门首页发布在 `guide/quickstart.md`,该导航项因此返回 404:写死的导航目标会与 manifest 实际发布的路由脱节。 @@ -14,7 +14,7 @@ Status: implemented ## 决定 -[website/docs.ts](../../../../website/docs.ts) 拥有分区位置。`sections` 按 locale 声明各分组,`sectionSpec(locale, label)` 返回分组的位置与折叠行为,当某 locale 未为该 label 声明位置时抛错。未出现在声明中的分组现在会让构建失败,而不是静默排到最前。位置按 locale 声明,是因为两侧侧边栏各自命名分组:单一共享列表既要按约定排列两套标签,又会对任一侧缺失的标签毫无反应。 +[website/docs.ts](../../../../website/docs.ts) 拥有分区位置。`sections` 按 locale 声明各分组,`sectionSpec(locale, label)` 返回分组的位置与折叠行为,当某 locale 未为该 label 声明位置时抛错。未出现在声明中的分组现在会让构建失败,而不是静默排到最前。位置按 locale 声明,是因为两侧侧边栏各自命名分组,而两侧共用的标签 `SDK` 无法同时相对 `入门` 和相对 `Guide` 取同一位次。 子系统页按关注点分组——总览、内核与作用域、会话与持久化、模型与上下文、执行与工具、策略与交互、平台与接入——其中六个主题组保持折叠,直到某一组包含正在阅读的页面。这些分组排在参考侧边栏的最后:展开时它们的数量超过其余所有分组之和,因此排在它们之后的任何内容都只能靠滚过整个列表才能到达。页面 `order` 由数组位置推导,不再手写数字。 diff --git a/scripts/project-doc-site.spec.ts b/scripts/project-doc-site.spec.ts index e52017cbd8..b5f5218de7 100644 --- a/scripts/project-doc-site.spec.ts +++ b/scripts/project-doc-site.spec.ts @@ -378,10 +378,10 @@ describe('sidebar ordering', () => { }) it('declares placements per locale rather than in one shared list', () => { - // Each locale ranks only its own labels, so a label one locale never uses - // cannot borrow a rank from the other. - expect(sectionSpec('root', '入门').index).toBe(0) - expect(sectionSpec('en', 'Guide').index).toBe(0) + // `SDK` labels a group in both locales, so one shared list would have to + // rank it against `入门` and against `Guide` at the same position. + expect(sectionSpec('root', 'SDK').index).toBeGreaterThan(sectionSpec('root', '入门').index) + expect(sectionSpec('en', 'SDK').index).toBeGreaterThan(sectionSpec('en', 'Guide').index) expect(() => sectionSpec('en', '入门')).toThrow() expect(() => sectionSpec('root', 'Guide')).toThrow() }) diff --git a/website/docs.ts b/website/docs.ts index 12280585cc..6c75635a80 100644 --- a/website/docs.ts +++ b/website/docs.ts @@ -133,9 +133,9 @@ const homeAndGuide = pairedPages([ { source: 'docs/user/guide/python-sdk.md', route: 'guide/python-sdk.md', - label: { root: 'Python SDK', en: 'Python SDK' }, + label: { root: 'Python', en: 'Python' }, sidebar: { root: 'zh-guide', en: 'en-guide' }, - section: { root: '其他接口', en: 'Other interfaces' }, + section: { root: 'SDK', en: 'SDK' }, order: 1, }, ]) @@ -427,7 +427,7 @@ export interface DocsSection { */ const sections: Record = { root: [ - { label: '入门' }, { label: '其他接口' }, + { label: '入门' }, { label: 'SDK' }, { label: '基础' }, { label: '框架能力' }, { label: '实战' }, { label: 'Cordis 框架教程' }, { label: '概念' }, { label: '生成参考' }, { label: 'Cordis API' }, { label: '开发手册' }, { label: '总览' }, @@ -439,7 +439,7 @@ const sections: Record = { { label: '平台与接入', collapsed: true }, ], en: [ - { label: 'Guide' }, { label: 'Other interfaces' }, + { label: 'Guide' }, { label: 'SDK' }, { label: 'Basics' }, { label: 'Framework' }, { label: 'Practice' }, { label: 'Cordis framework tutorial' }, { label: 'Concepts' }, { label: 'Generated reference' }, { label: 'Cordis Core API' }, { label: 'Cookbook' }, { label: 'Overview' }, From 0fc77a7ba1a3b15a74cd1431978f5ccbac65bd09 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Tue, 11 Aug 2026 23:13:28 -0700 Subject: [PATCH 80/81] test(web): update onboarding settings snapshot --- .../snapshots/onboarding-usable-provider/dismissed.expected.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/web/tests/snapshots/onboarding-usable-provider/dismissed.expected.md b/apps/web/tests/snapshots/onboarding-usable-provider/dismissed.expected.md index 182fadf973..b3e1141abc 100644 --- a/apps/web/tests/snapshots/onboarding-usable-provider/dismissed.expected.md +++ b/apps/web/tests/snapshots/onboarding-usable-provider/dismissed.expected.md @@ -7,6 +7,9 @@ - button "模型": - img - text: 模型 + - button "插件": + - img + - text: 插件 - button "Agent 预设": - img - text: Agent 预设 From 5142bc3eb671a3140346af6beb829378e5714007 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 12 Aug 2026 14:18:37 +0800 Subject: [PATCH 81/81] test: refresh translation prompt snapshot --- .../translation-prompt-v4/request-response.expected.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index a7c283d3ae..cbdd685dae 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -8,11 +8,11 @@ }, { "role": "user", - "content": "# DeepSeek Harness\n\nEnglish | [中文](README.zh.md)\n\nDeepSeek Harness (`dsh`) is an open-source coding agent built on the DeepSeek Harness SDK.\n\nIt uses an architecture where **everything is a plugin**.\n\n## Internal testing notice\n\nDeepSeek Harness is under internal testing. Features and interfaces may change.\n\nThe internal build uploads all Session Logs by default to help diagnose reported problems. Set `DSH_TELEMETRY_DISABLED=1` to disable telemetry. Send feedback through the internal WeChat group.\n\n## Run\n\nInstall Node.js ^22.19 or >= 24 and pnpm 11, then run the published package:\n\n```sh\nnpx @deepseek-ai/dsh web\n```\n\nThe command initializes the Web profile and prints the Web UI URL, which is `http://127.0.0.1:3080` by default. Open it, add a DeepSeek API key under **Settings → Models**, then start a session. The invoking directory is the default workspace; try `Summarize this repository and identify its main packages.`\n\nContinue with the [Web UI guide](docs/user/guide/).\n\n### Run from source\n\nTo run a repository checkout instead:\n\n```sh\ngit clone https://github.com/deepseek-harness/deepseek-harness.git\ncd deepseek-harness\npnpm install\npnpm dsh web\n```\n\nThe last command builds the repository and opens the same Web UI path.\n\n## Profiles and plugins\n\nA profile is an ordered list of plugin bundles. The shipped `web` profile powers `dsh web`. Manage a profile with `dsh plugin --profile `, which forwards the remaining arguments to pnpm in that profile's directory:\n\n```sh\nnpx -p @deepseek-ai/dsh dsh plugin --profile web add \nnpx -p @deepseek-ai/dsh dsh plugin --profile web remove \n```\n\n`add`, `remove`, `update`, `why`, and other pnpm commands work unchanged. The command initializes a missing profile before changing its packages and updates its bundle list from installed packages that declare `dsh.bundle`. See the [CLI reference](apps/cli/reference/README.md#plugin-management) for the exact behavior.\n\nThe [CLI reference](apps/cli/README.md) covers headless execution and custom profiles. The [Python SDK](python/README.md) and [examples](examples/README.md) cover programmatic and custom compositions.\n\n## Community\n\nFollow DeepSeek Harness on Twitter for project updates.\n\n## Development\n\nStart with the [development guide](docs/development.md) and read the [architecture](docs/architecture.md) before changing packages.\n\nFor agents, follow [AGENTS.md](AGENTS.md).\n\n## License\n\n[BSD 3-Clause](LICENSE)\n\nThird-party dependencies and their licenses are disclosed in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).\n\n## Contributing\n\nRead [CONTRIBUTING.md](CONTRIBUTING.md) before contributing to this repository.\n" + "content": "# DeepSeek Harness\n\nEnglish | [中文](README.zh.md)\n\nDeepSeek Harness (`dsh`) is an open-source coding agent built on the DeepSeek Harness SDK.\n\nIt uses an architecture where **everything is a plugin**.\n\n## Internal testing notice\n\nDeepSeek Harness is under internal testing. Features and interfaces may change.\n\nThe internal build uploads all Session Logs by default to help diagnose reported problems. Set `DSH_TELEMETRY_DISABLED=1` to disable telemetry. Send feedback through the internal WeChat group.\n\n## Run\n\nInstall Node.js ^22.19 or >= 24 and pnpm 11, then run the published package:\n\n```sh\nnpx @deepseek-ai/dsh web\n```\n\nThe command initializes the Web profile and prints the Web UI URL, which is `http://127.0.0.1:3080` by default. Open it, add a DeepSeek API key under **Settings → Models**, then start a session. The invoking directory is the default workspace; try `Summarize this repository and identify its main packages.`\n\nContinue with the [Web UI guide](docs/user/guide/index.md).\n\n### Run from source\n\nTo run a repository checkout instead:\n\n```sh\ngit clone https://github.com/deepseek-harness/deepseek-harness.git\ncd deepseek-harness\npnpm install\npnpm dsh web\n```\n\nThe last command builds the repository and opens the same Web UI path.\n\n## Profiles and plugins\n\nA profile is an ordered list of plugin bundles. The shipped `web` profile powers `dsh web`. Manage a profile with `dsh plugin --profile `, which forwards the remaining arguments to pnpm in that profile's directory:\n\n```sh\nnpx -p @deepseek-ai/dsh dsh plugin --profile web add \nnpx -p @deepseek-ai/dsh dsh plugin --profile web remove \n```\n\n`add`, `remove`, `update`, `why`, and other pnpm commands work unchanged. The command initializes a missing profile before changing its packages and updates its bundle list from installed packages that declare `dsh.bundle`. See the [CLI reference](apps/cli/reference/README.md#plugin-management) for the exact behavior.\n\nThe [CLI reference](apps/cli/README.md) covers headless execution and custom profiles. The [Python SDK](python/README.md) and [examples](examples/README.md) cover programmatic and custom compositions.\n\n## Community\n\nFollow DeepSeek Harness on Twitter for project updates.\n\n## Development\n\nStart with the [development guide](docs/development.md) and read the [architecture](docs/architecture.md) before changing packages.\n\nFor agents, follow [AGENTS.md](AGENTS.md).\n\n## License\n\n[BSD 3-Clause](LICENSE)\n\nThird-party dependencies and their licenses are disclosed in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).\n\n## Contributing\n\nRead [CONTRIBUTING.md](CONTRIBUTING.md) before contributing to this repository.\n" }, { "role": "assistant", - "content": "# DeepSeek Harness\n\n[English](README.md) | 中文\n\nDeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源 coding agent(智能体)。\n\n它采用了**一切皆插件**的架构。\n\n## 内测声明\n\nDeepSeek Harness 正处于内部测试阶段,功能和接口可能发生变化。\n\n为帮助诊断上报的问题,内测版本默认上传所有会话日志。设置 `DSH_TELEMETRY_DISABLED=1` 可关闭遥测。请通过内部企业微信群反馈问题和建议。\n\n## 运行\n\n安装 Node.js ^22.19 或 >= 24 和 pnpm 11,然后运行已发布的包:\n\n```sh\nnpx @deepseek-ai/dsh web\n```\n\n该命令会初始化 Web profile 并打印 Web UI 地址,默认地址为 `http://127.0.0.1:3080`。打开该地址,在**设置 → 模型**中添加 DeepSeek API 密钥,然后启动一个会话。调用目录是默认工作区;你可以尝试输入 `Summarize this repository and identify its main packages.`。\n\n下一步请阅读 [Web UI 指南](docs/user/guide/)。\n\n### 从源码运行\n\n如需改为运行仓库 checkout:\n\n```sh\ngit clone https://github.com/deepseek-harness/deepseek-harness.git\ncd deepseek-harness\npnpm install\npnpm dsh web\n```\n\n最后一条命令会构建仓库,并进入相同的 Web UI 路径。\n\n## Profile 与插件\n\nprofile 是按顺序排列的插件 bundle 列表。随附的 `web` profile 为 `dsh web` 提供功能。使用 `dsh plugin --profile ` 管理 profile;该命令会在对应 profile 目录中将剩余参数转发给 pnpm:\n\n```sh\nnpx -p @deepseek-ai/dsh dsh plugin --profile web add \nnpx -p @deepseek-ai/dsh dsh plugin --profile web remove \n```\n\n`add`、`remove`、`update`、`why` 等 pnpm 命令均可直接使用。该命令会先初始化不存在的 profile,再修改其中的包,并根据声明了 `dsh.bundle` 的已安装包更新 bundle 列表。准确行为见 [CLI 参考](apps/cli/reference/README.md#plugin-management)。\n\n[CLI(命令行界面)参考](apps/cli/README.md)介绍 headless 执行与自定义 profile。[Python SDK](python/README.md) 和[示例](examples/README.md)介绍程序化组合与自定义组合。\n\n## 社区\n\n扫描二维码,或打开 DeepSeek Harness 微信社区申请页面 申请加入。\n\n

      \n \"DeepSeek\n

      \n\n## 开发\n\n请先阅读[开发指南](docs/development.md);修改包之前,请阅读[架构文档](docs/architecture.md)。\n\n面向 agent:遵循 [AGENTS.md](AGENTS.md)。\n\n## 许可证\n\n[BSD 3-Clause](LICENSE)\n\n第三方依赖及其许可证在 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) 中披露。\n\n## 参与贡献\n\n向本仓库贡献前,请阅读 [CONTRIBUTING.md](CONTRIBUTING.md)。\n" + "content": "# DeepSeek Harness\n\n[English](README.md) | 中文\n\nDeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源 coding agent(智能体)。\n\n它采用了**一切皆插件**的架构。\n\n## 内测声明\n\nDeepSeek Harness 正处于内部测试阶段,功能和接口可能发生变化。\n\n为帮助诊断上报的问题,内测版本默认上传所有会话日志。设置 `DSH_TELEMETRY_DISABLED=1` 可关闭遥测。请通过内部企业微信群反馈问题和建议。\n\n## 运行\n\n安装 Node.js ^22.19 或 >= 24 和 pnpm 11,然后运行已发布的包:\n\n```sh\nnpx @deepseek-ai/dsh web\n```\n\n该命令会初始化 Web profile 并打印 Web UI 地址,默认地址为 `http://127.0.0.1:3080`。打开该地址,在**设置 → 模型**中添加 DeepSeek API 密钥,然后启动一个会话。调用目录是默认工作区;你可以尝试输入 `Summarize this repository and identify its main packages.`。\n\n下一步请阅读 [Web UI 指南](docs/user/guide/index.md)。\n\n### 从源码运行\n\n如需改为运行仓库 checkout:\n\n```sh\ngit clone https://github.com/deepseek-harness/deepseek-harness.git\ncd deepseek-harness\npnpm install\npnpm dsh web\n```\n\n最后一条命令会构建仓库,并进入相同的 Web UI 路径。\n\n## Profile 与插件\n\nprofile 是按顺序排列的插件 bundle 列表。随附的 `web` profile 为 `dsh web` 提供功能。使用 `dsh plugin --profile ` 管理 profile;该命令会在对应 profile 目录中将剩余参数转发给 pnpm:\n\n```sh\nnpx -p @deepseek-ai/dsh dsh plugin --profile web add \nnpx -p @deepseek-ai/dsh dsh plugin --profile web remove \n```\n\n`add`、`remove`、`update`、`why` 等 pnpm 命令均可直接使用。该命令会先初始化不存在的 profile,再修改其中的包,并根据声明了 `dsh.bundle` 的已安装包更新 bundle 列表。准确行为见 [CLI 参考](apps/cli/reference/README.md#plugin-management)。\n\n[CLI(命令行界面)参考](apps/cli/README.md)介绍 headless 执行与自定义 profile。[Python SDK](python/README.md) 和[示例](examples/README.md)介绍程序化组合与自定义组合。\n\n## 社区\n\n扫描二维码,或打开 DeepSeek Harness 微信社区申请页面 申请加入。\n\n

      \n \"DeepSeek\n

      \n\n## 开发\n\n请先阅读[开发指南](docs/development.md);修改包之前,请阅读[架构文档](docs/architecture.md)。\n\n面向 agent:遵循 [AGENTS.md](AGENTS.md)。\n\n## 许可证\n\n[BSD 3-Clause](LICENSE)\n\n第三方依赖及其许可证在 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) 中披露。\n\n## 参与贡献\n\n向本仓库贡献前,请阅读 [CONTRIBUTING.md](CONTRIBUTING.md)。\n" }, { "role": "user",