diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml index 56cdebf198..9695955b54 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.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-03-per-session-agent-presets.md -2026-08-03-per-session-agent-presets.md: 14d568689a662e3e7d1fedf26c22aea6faddd56b -2026-08-03-per-session-agent-presets.zh.md: 9b296fa7a1c2b9164d9e7a0ad676929179a64fef +2026-08-03-per-session-agent-presets.md: 8af48979b49f08c8e3ac945f648acbb615757a98 +2026-08-03-per-session-agent-presets.zh.md: 2889487e989d093c162848c3c972d46fece3726d diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md index 14d568689a..8af48979b4 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md @@ -27,11 +27,11 @@ The presets the deployment ships are the directories under `packages/preset/agen Mounting is per-session by default. Measured cost for a twelve-row composition is ~3ms and ~600KB per session, so isolation is the cheaper default than any sharing scheme, and a preset authored by a user or by an agent then has the smallest possible blast radius. A preset that genuinely owns an expensive singleton opts into sharing with Cordis's own `isolate` vocabulary: a named realm label is process-global, so two subtrees naming the same label resolve one instance. -Which preset an unnamed session gets is a user setting (`agent-presets.default`) layered over the composition's own `default`, which becomes the `base`. Both layers are needed: the composition value is what a deployment ships and must keep working with no settings provider at all, and the setting is what a person changes without editing a `cordis.yml` they may not own. +The `agent-presets` user-settings namespace carries `modeSelectionEnabled` and `default`. `modeSelectionEnabled` defaults to `true`: the existing new-session picker remains present and an unnamed session resolves to the saved user `default`, or the composition's deployment `default` when none exists. The Web Settings toggle changes only that policy: disabling selection temporarily uses the deployment default, while re-enabling it restores the saved user `default`. This is a deliberate exception to the ordinary user-over-composition settings precedence established in [#1539](https://github.com/deepseek-harness/deepseek-harness/pull/1539): hiding the chooser disables the user's mode-selection policy without deleting its saved value. The Host policy governs every later session whose caller omits a preset; explicitly named presets and existing sessions remain unchanged. The composition value also keeps the package working with no settings provider, while an enabled user override changes later sessions without editing a deployment-owned `cordis.yml`. ## Consequences -**The effective default is read per resolution, never snapshotted.** A cached value would need a `watch` subscription and a reload path to stay honest, and the resolved scope already re-reads a hot-reloaded document. Reading through is also what makes the boundary correct rather than merely cheap: the new value applies to the next session created, and every running session keeps the composition it was built from. That invariant is the same one the session log enforces from the other side — the header records the id a session was CREATED with and an `agent-preset/selected` event records any later blank-session switch, so a reader resolves the pair (`resolveSessionPreset`) and never the header alone: a resume rebuilds the composition its history was produced under rather than the deployment default at resume time, a cold transcript's presenters resolve in that composition's layer, and the gateway rejects an attempt to adopt a live session under a preset other than the one it currently runs. A snapshot would make the two disagree at exactly the moment the setting changes. +**The effective default is read per resolution, never snapshotted.** A cached value would need a `watch` subscription and a reload path to stay honest, and the resolved scope already re-reads a hot-reloaded document. The Host setting itself applies when an unnamed session is resolved afterwards. An explicit Web Settings action additionally routes its accepted effective default through the existing blank-session selection path only when the captured session id is still current and blank; it never recomposes a running session or rewrites that session's history. The session log enforces the same invariant from the other side — the header records the id a session was CREATED with and an `agent-preset/selected` event records any later blank-session switch, so a reader resolves the pair (`resolveSessionPreset`) and never the header alone: a resume rebuilds the composition its history was produced under rather than the deployment default at resume time, a cold transcript's presenters resolve in that composition's layer, and the gateway rejects an attempt to adopt a live session under a preset other than the one it currently runs. A snapshot would make the two disagree at exactly the moment the setting changes. **A directly-plugged subtree is invisible to the boot audit.** It never links itself to an `Entry`, so it is absent from `ctx.loader.entries()` and `assertEntriesActivated` cannot see it. The mount audits its own rows instead, reading the tree through an `Include` subclass that publishes it. @@ -61,11 +61,11 @@ Which preset an unnamed session gets is a user setting (`agent-presets.default`) **A preset's package names must resolve from the harness, not from the preset.** `EntryTree.import()` resolves a row against its own tree's `baseUrl`, which `Include` sets to the composition's directory. That is right for a relative specifier and fatal for a package name: a locally authored preset lives under the user's home, where Node's upward `node_modules` walk never reaches the installed harness, so every `@deepseek-ai/dsh-*` row fails to import and the whole preset is unmountable. The shipped presets hid this — they sit inside the install. The mount records the host composition's base before plugging the subtree and sends bare specifiers there, leaving relative paths resolving from the preset so its own files still travel with it. The real-composition test writing a preset into a temp root is what found it. -**The preset id is model-visible and must be logged.** It determines the tool set and prompt, so a resumed session has to restore the same composition; recording it is a session fact, not runtime state. It rides the session header beside `cwd`, and the summary carries it so a picker shows what a session actually runs rather than the deployment's current default. +**The preset id is model-visible and must be logged.** It determines the tool set and prompt, so a resumed session has to restore the same composition; recording it is a session fact, not runtime state. It rides the session header beside `cwd`, and the summary carries it so the client surfaces show what a session actually runs rather than the deployment's current default. **A durable header field is not durable until the provider writes it.** `agentPreset` landed on `SessionHeader` with the right rationale and the JSONL provider omitted it; the derived query index also maps header fields explicitly, so a resumed Session came back with no preset and the surfaces that name it fell silent. `summarizeCold` had the same form — it hand-built the cold list row instead of reusing the shared projection. A field declared durable needs a test that crosses a real store, not only the type that declares it. -**The choice belongs to the screen where it still works.** The composer seat spent almost its whole life disabled, since the preset is fixed once a turn has run. It moved to the new-session screen beside the workspace picker, where the pick is *staged*: that screen precedes the session it applies to, and the stage lands when a session becomes current and is still blank — covering both the session a workspace connect creates and the blank one it reuses, which riding `sessions.create` would miss. It is spent on first use, matching the workspace picker beside it. What a running session runs is then a read-only label in its header: a control there would promise a switch the host refuses outright. +**The choice belongs to the screen where it still works.** The control lives on the new-session screen beside the workspace picker, is present under the default-on Host policy, and disappears only after `modeSelectionEnabled` is disabled. Its pick is *staged*: that screen precedes the session it applies to, and the stage lands when a session becomes current and is still blank — covering both the session a workspace connect creates and the blank one it reuses, which riding `sessions.create` would miss. It is spent on first use, matching the workspace picker beside it; hiding the picker discards a stage that has not reached a session and returns the current blank session to the deployment default through the same selection path. What a running or historical session runs remains a read-only label in its header: a control there would promise a switch the host refuses outright. **A preset multiplies a cost the host was already paying: nothing disposes an agent.** Measured against the shipped compositions with `--expose-gc`, one live agent holds ~0.17 MB on `minimal` and ~1.31 MB on `standard`/`cordis`, mounting in ~38 ms and ~135 ms; the first agent of a process costs ~7 MB more as Node imports the modules, which every later mount then shares. Growth is strictly linear — 10, 30 and 50 agents give the same per-agent delta — and disposal reclaims essentially all of it (50 `standard` agents held 57.8 MB and returned it). So the object graph does not leak; the lifecycle does. `ApiSessionAgentController` discards the `AgentHandle` returned by the registry, `archiveSession` only edits the workspace registry, `AgentRegistry` has no eviction, and the sole disposal site in the host is the JSON-RPC server's own shutdown. A web host therefore retains every session it has touched, at ~1.3 MB each once presets are composed rather than ~0.2 MB before. Note that pruning the mount registry does not help here: it drops records whose fiber `uid` has cleared, and an agent that never dies never clears one. diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md index 9b296fa7a1..2889487e98 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md @@ -27,12 +27,11 @@ Status: implemented 挂载默认按会话进行。实测一份十二行组装每会话约 3ms、约 600KB,因此隔离比任何共享方案都更划算;而由用户或 agent 写出的 preset 也因此拥有尽可能小的影响面。确实自带昂贵单例的 preset,可以用 Cordis 自身的 `isolate` 词汇显式选择共享:命名 realm 的 label 是进程级全局的,因此两棵子树只要写同一个 label 就解析到同一个实例。 -未指名 preset 的会话拿到哪一个,是一项用户设置(`agent-presets.default`),叠在组装自身的 `default` 之上——后者成为 `base`。两层都需要:组装里的值是部署交付的东西,在完全没有 settings 提供方时也必须照常工作;而设置是让人不必去改一份可能并不属于自己的 `cordis.yml` 就能调整的东西。 +`agent-presets` 用户设置命名空间同时携带 `modeSelectionEnabled` 与 `default`。`modeSelectionEnabled` 默认为 `true`:既有的新建会话选择器保持显示;未指名会话会解析到已保存的用户 `default`,尚未保存时则使用组装中 `default` 指定的部署默认值。Web 设置开关只改变该策略:关闭选择时临时使用部署默认值,再次开启时恢复已保存的用户 `default`。这是对 [#1539](https://github.com/deepseek-harness/deepseek-harness/pull/1539) 所确立“用户值覆盖组装值”这一普通 settings 优先级的有意例外:隐藏选择器会停用用户的模式选择策略,但不会删除其保存值。该 Host 策略适用于此后所有未显式指定 preset 的会话;显式指定及既有会话不受影响。组装值还使本包在没有 settings 提供方时照常工作;选择器开启后,用户可覆盖默认值来改变后续会话,而无需编辑部署所拥有的 `cordis.yml`。 ## 后果 -**有效默认值在每次解析时读取,绝不保存快照。** 缓存下来就需要一个 `watch` 订阅和一条重载路径才能保持诚实,而解析后的 scope 本来就会重读热重载过的文档。读穿也不只是省事,它让边界本身是对的:新值作用于**下一个新建的会话**,每个运行中的会话保持它被构建时的那份组装。这条不变量正是 session 日志从另一侧执行的同一条——header 记录会话**创建时**的 id,此后空白期的任何切换由 `agent-preset/selected` 事件记录,因此读取方解析的是两者之和(`resolveSessionPreset`)、绝不单看 header:恢复重建的是其历史所产出的那份组装而不是恢复时的部署默认值,冷读记录的 presenter 在那份组装的层里解析,网关也会拒绝把一个活着的会话收编到它当前运行的 preset 以外的 preset 之下。快照会让两者恰好在设置改变的那一刻各说各话。 - +**有效默认值在每次解析时读取,绝不保存快照。** 缓存下来就需要一个 `watch` 订阅和一条重载路径才能保持诚实,而解析后的 scope 本来就会重读热重载过的文档。Host 设置本身会在此后解析未指名会话时生效。Web Settings 中的明确操作还会把已接受的有效默认值送入既有的空白会话选择链路,但只在操作前捕获的会话 id 仍是当前空白会话时对齐;它绝不会重新组装运行中的会话,也不会改写该会话的历史。session 日志从另一侧执行同一条不变量——header 记录会话**创建时**的 id,此后空白期的任何切换由 `agent-preset/selected` 事件记录,因此读取方解析的是两者之和(`resolveSessionPreset`)、绝不单看 header:恢复重建的是其历史所产出的那份组装而不是恢复时的部署默认值,冷读记录的 presenter 在那份组装的层里解析,网关也会拒绝把一个活着的会话收编到它当前运行的 preset 以外的 preset 之下。快照会让两者恰好在设置改变的那一刻各说各话。 **直接挂载的子树对启动审计不可见。** 它不会把自己关联到 `Entry`,因此不在 `ctx.loader.entries()` 中,`assertEntriesActivated` 也看不到它。改由挂载过程自行校验各行,通过一个会公开自身 tree 的 `Include` 子类读取。 @@ -62,11 +61,11 @@ Status: implemented **preset 的包名必须从 harness 解析,而非从 preset 解析。** `EntryTree.import()` 按行所属树的 `baseUrl` 解析,而 `Include` 把它设为组装文件所在的目录。这对相对标识符是对的,对包名却是致命的:本地创作的 preset 位于用户主目录之下,Node 向上查找 `node_modules` 永远够不到已安装的 harness,因此每一个 `@deepseek-ai/dsh-*` 行都会导入失败,整个 preset 无法挂载。随部署提供的 preset 掩盖了这一点——它们本就在安装目录之内。挂载在插入子树之前先记录宿主组装的基址,并把裸标识符送往那里,同时让相对路径继续从 preset 解析,使它自带的文件仍随它一同迁移。发现它的正是那个把 preset 写入临时根目录的真实组装测试。 -**preset id 对模型可见,必须写入日志。** 它决定工具集与提示词,因此被恢复的会话必须还原同一份组装;记录它属于会话事实,而非运行时状态。它与 `cwd` 并列写在会话头部,并由会话摘要携带,使选择器显示的是某个会话实际运行的 preset,而非部署当前的默认值。 +**preset id 对模型可见,必须写入日志。** 它决定工具集与提示词,因此被恢复的会话必须还原同一份组装;记录它属于会话事实,而非运行时状态。它与 `cwd` 并列写在会话头部,并由会话摘要携带,使客户端界面显示某个会话实际运行的 preset,而非部署当前的默认值。 **持久化 header 字段在 provider 写入前都算不上持久。** `agentPreset` 带着正确理由落在 `SessionHeader` 上,而 JSONL provider 遗漏了它;派生 query index 也显式映射 header 字段,于是恢复后的 Session 没有 preset,所有据以命名它的 surface 随之失声。`summarizeCold` 是同一种形式——它手工拼装 cold list row,而没有复用共享 projection。声明为持久的字段,需要一个跨越真实 store 的测试,而不只是声明它的类型。 -**这个选择属于它仍然可用的那个界面。** composer 座位几乎一生都处于禁用状态,因为一旦跑过一个轮次,preset 即固定。它移到了新建会话界面、工作区选择器旁边,选择在那里是**暂存**的:该界面先于它要应用到的会话存在,暂存值在某个会话成为当前会话且仍为空白时落地——这既覆盖工作区连接新建的会话,也覆盖它复用的那个空白会话,而搭 `sessions.create` 的便车会漏掉后者。它一经使用即被清空,与旁边的工作区选择器一致。至于运行中的会话在跑什么,则是其标题旁的一个只读标签:在那里放控件,等于承诺一次宿主会断然拒绝的切换。 +**这个选择属于它仍然可用的那个界面。** 控件位于新建会话界面、工作区选择器旁边,在 Host 默认开启策略下直接显示,仅在 `modeSelectionEnabled` 关闭后隐藏。选择在那里是**暂存**的:该界面先于它要应用到的会话存在,暂存值在某个会话成为当前会话且仍为空白时落地——这既覆盖工作区连接新建的会话,也覆盖它复用的那个空白会话,而搭 `sessions.create` 的便车会漏掉后者。它一经使用即被清空,与旁边的工作区选择器一致;隐藏选择器会丢弃尚未到达会话的暂存选择,并通过同一条选择链路把当前空白会话带回部署默认值。至于运行中或历史会话在跑什么,仍由其标题旁的只读标签展示:在那里放控件,等于承诺一次宿主会断然拒绝的切换。 **preset 放大的是宿主本来就在付的代价:没有任何东西会 dispose 一个 agent。** 用 `--expose-gc` 对随附组装实测:一个存活的 agent 在 `minimal` 上约占 0.17 MB、在 `standard`/`cordis` 上约 1.31 MB,挂载耗时分别约 38 ms 与 135 ms;进程里第一个 agent 另需约 7 MB,那是 Node 首次 import 模块的一次性成本,此后每次挂载共享。增长严格线性——10、30、50 个的单个增量一致——且 dispose 后基本全额回收(50 个 `standard` 占住 57.8 MB,释放后全部归还)。所以对象图并不泄漏,缺的是生命周期。`ApiSessionAgentController` 会丢弃注册表返回的 `AgentHandle`,`archiveSession` 只改工作区注册表,`AgentRegistry` 没有驱逐机制,而宿主里唯一一处 dispose 是 JSON-RPC 服务器自身的关停。于是一个 web 宿主会留住它接触过的每一个会话,组装 preset 之后每个约 1.3 MB,而在此之前约 0.2 MB。注意:剪枝挂载注册表在这里没有用——它丢弃的是 fiber `uid` 已清空的记录,而永不死亡的 agent 永远不会清空它。 diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index eacd602971..befd23d7ee 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -874,6 +874,7 @@ describe('authoring a preset on the shipped composition', () => { */ describe('the default preset as a user setting', () => { it('composes an unnamed session from the stored default, not the composed one', async () => { + expect((await ctx.agentPresets.remoteExportList()).modeSelectionEnabled).toBe(true) expect(ctx.agentPresets.defaultId).toBe('standard') await ctx.settings.update(SETTINGS_NAMESPACE, { default: 'minimal' }) diff --git a/apps/web/tests/agent-preset-authoring.e2e.ts b/apps/web/tests/agent-preset-authoring.e2e.ts index fe7b5ec14e..9811043ea9 100644 --- a/apps/web/tests/agent-preset-authoring.e2e.ts +++ b/apps/web/tests/agent-preset-authoring.e2e.ts @@ -75,11 +75,15 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => { await dialog.waitFor({ timeout: 10_000 }) await dialog.getByRole('button', { name: 'Agent 预设' }).click() await dialog.getByRole('heading', { name: 'Agent 预设' }).waitFor({ timeout: 10_000 }) - await dialog.getByText('标准模式').first().waitFor({ timeout: 10_000 }) + // The intro copy also names 标准模式. Wait for the roster's own action so + // the snapshot cannot land between the section shell and its cards. + await dialog.getByRole('button', { name: '查看: 标准模式', exact: true }).waitFor({ timeout: 10_000 }) const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) await compareOrRefreshGolden(SECTION_EXPECTED, snapshot, MODE) + const toggle = dialog.getByRole('switch', { name: '允许切换agent模式' }) + expect(await toggle.getAttribute('aria-checked')).toBe('true') // The intro states the copy path directly, and the shipped rows offer // view/copy but never delete or a location — their // install is overwritten by upgrades and is not the user's to manage. @@ -88,6 +92,8 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => { expect(snapshot).toContain('查看: 标准模式') expect(snapshot).not.toContain('删除: 标准模式') expect(snapshot).not.toContain('打开目录') + // The rest of this scenario exercises the existing default and Creator + // actions with the beta picker enabled by default. }, 60_000) it('views a shipped composition read-only instead of editing it', async () => { diff --git a/apps/web/tests/agent-preset-selection.e2e.ts b/apps/web/tests/agent-preset-selection.e2e.ts index 8207a98261..a06c0da136 100644 --- a/apps/web/tests/agent-preset-selection.e2e.ts +++ b/apps/web/tests/agent-preset-selection.e2e.ts @@ -247,15 +247,21 @@ describe('web e2e: agent-preset selection', () => { await rm(presetRoot, { recursive: true, force: true }) }) - it('offers the chip on the new-session screen, beside the workspace picker', async () => { + it('starts with mode selection shown on the Standard default', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-hero')) await connectFreshWorkspace(page, scaffold.workspaceCwd) + await page.getByRole('button', { name: 'Standard mode', exact: true }).waitFor({ timeout: 10_000 }) + + await page.getByRole('button', { name: 'Settings', exact: true }).click() + const dialog = page.getByRole('dialog', { name: 'Settings' }) + await dialog.getByRole('button', { name: 'Agent presets' }).click() + const toggle = dialog.getByRole('switch', { name: 'Allow switching Agent modes' }) + await dialog.getByRole('button', { name: 'New task default: Standard mode' }).waitFor({ timeout: 10_000 }) + expect(await toggle.getAttribute('aria-checked')).toBe('true') + await dialog.getByRole('button', { name: 'Close' }).last().click() const snapshot = await captureStableAria(page, '[class*="heroWorkspaceRow"]', scaffold.workspaceCwd) - await compareOrRefreshGolden(HERO_EXPECTED, snapshot, MODE) - // The chip opens on the deployment default, by the name that preset - // publishes rather than its directory name. expect(snapshot).toContain('Standard mode') }) @@ -283,6 +289,8 @@ describe('web e2e: agent-preset selection', () => { // The chip stages; the blank session the workspace connect produced is // what the stage lands on. The host's own answer is what comes back. await expect.poll(() => livePreset(scaffold), { timeout: 15_000 }).toBe('minimal') + const roster = await scaffold.ctx.agentPresets.remoteExportList() + expect(roster.presets.find(preset => preset.isDefault)?.id).toBe('standard') }) it('says why a switch was refused instead of letting the chip revert in silence', async () => { @@ -340,6 +348,39 @@ describe('web e2e: agent-preset selection', () => { await writeComposerDraft(page, composer, '') }, 90_000) + it('aligns the current blank task and restores its saved default when re-enabled', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-disabled')) + await expect.poll(() => livePreset(scaffold), { timeout: 15_000 }).toBe('standard') + + await page.getByRole('button', { name: 'Settings', exact: true }).click() + const dialog = page.getByRole('dialog', { name: 'Settings' }) + await dialog.getByRole('button', { name: 'Agent presets' }).click() + await dialog.getByRole('button', { name: 'Set as default: Minimal mode' }).click() + await dialog.getByRole('button', { name: 'New task default: Minimal mode' }).waitFor({ timeout: 10_000 }) + await expect.poll(() => livePreset(scaffold), { timeout: 15_000 }).toBe('minimal') + const toggle = dialog.getByRole('switch', { name: 'Allow switching Agent modes' }) + await toggle.click() + await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('false') + await dialog.getByRole('button', { name: 'Default: Standard mode' }).waitFor({ timeout: 10_000 }) + await dialog.getByRole('button', { name: 'Close' }).last().click() + + await expect.poll(() => page.getByRole('button', { name: / mode$/ }).count()).toBe(0) + await expect.poll(() => livePreset(scaffold), { timeout: 15_000 }).toBe('standard') + + // The switch controls availability only: re-enabling restores the saved + // default and aligns this same still-blank task with it. + await page.getByRole('button', { name: 'Settings', exact: true }).click() + const reopened = page.getByRole('dialog', { name: 'Settings' }) + await reopened.getByRole('button', { name: 'Agent presets' }).click() + const reopenedToggle = reopened.getByRole('switch', { name: 'Allow switching Agent modes' }) + await reopenedToggle.click() + await expect.poll(() => reopenedToggle.getAttribute('aria-checked')).toBe('true') + await reopened.getByRole('button', { name: 'New task default: Minimal mode' }).waitFor({ timeout: 10_000 }) + await reopened.getByRole('button', { name: 'Close' }).last().click() + await expect.poll(() => livePreset(scaffold), { timeout: 15_000 }).toBe('minimal') + await page.getByRole('button', { name: 'Minimal mode' }).waitFor({ timeout: 10_000 }) + }) + it('labels a resumed session with the preset it was created under', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-header')) // The seeded session's cwd is the scaffold root rather than the connected diff --git a/apps/web/tests/expected/agent-preset-authoring/created.expected.md b/apps/web/tests/expected/agent-preset-authoring/created.expected.md index f9daab0c26..71f2f5fa21 100644 --- a/apps/web/tests/expected/agent-preset-authoring/created.expected.md +++ b/apps/web/tests/expected/agent-preset-authoring/created.expected.md @@ -19,11 +19,14 @@ - text: 关闭 - heading "Agent 预设" [level=2] - paragraph: 预设即一个会话的 Agent 所运行的插件组装 —— 它的工具、提示词与能力。复制一份既有预设改成自己的,或用「创造模式」让 Agent 帮你创建。 + - text: 允许切换agent模式 beta + - paragraph: 开启后,新任务可选择标准、PTC、创造、极简及自定义模式;关闭后统一使用默认模式(默认为标准模式,可自定义)。仅影响新任务。 + - switch "允许切换agent模式" [checked] - heading "内置" [level=3] - list: - listitem: - - 'button "当前使用: 标准模式" [disabled] [pressed]': - - text: 标准模式 内置 当前使用 功能完整的编码 Agent,支持文件编辑、Shell、文件与网页检索、Skills、计划、目标、子代理和工作流。 + - 'button "新任务默认: 标准模式" [disabled] [pressed]': + - text: 标准模式 内置 新任务默认 功能完整的编码 Agent,支持文件编辑、Shell、文件与网页检索、Skills、计划、目标、子代理和工作流。 - code: standard - 'button "查看: 标准模式"': - img diff --git a/apps/web/tests/expected/agent-preset-authoring/damaged.expected.md b/apps/web/tests/expected/agent-preset-authoring/damaged.expected.md index cfae761ba3..6d039c01a7 100644 --- a/apps/web/tests/expected/agent-preset-authoring/damaged.expected.md +++ b/apps/web/tests/expected/agent-preset-authoring/damaged.expected.md @@ -19,11 +19,14 @@ - text: 关闭 - heading "Agent 预设" [level=2] - paragraph: 预设即一个会话的 Agent 所运行的插件组装 —— 它的工具、提示词与能力。复制一份既有预设改成自己的,或用「创造模式」让 Agent 帮你创建。 + - text: 允许切换agent模式 beta + - paragraph: 开启后,新任务可选择标准、PTC、创造、极简及自定义模式;关闭后统一使用默认模式(默认为标准模式,可自定义)。仅影响新任务。 + - switch "允许切换agent模式" [checked] - heading "内置" [level=3] - list: - listitem: - - 'button "当前使用: 标准模式" [disabled] [pressed]': - - text: 标准模式 内置 当前使用 功能完整的编码 Agent,支持文件编辑、Shell、文件与网页检索、Skills、计划、目标、子代理和工作流。 + - 'button "新任务默认: 标准模式" [disabled] [pressed]': + - text: 标准模式 内置 新任务默认 功能完整的编码 Agent,支持文件编辑、Shell、文件与网页检索、Skills、计划、目标、子代理和工作流。 - code: standard - 'button "查看: 标准模式"': - img diff --git a/apps/web/tests/expected/agent-preset-authoring/section.expected.md b/apps/web/tests/expected/agent-preset-authoring/section.expected.md index 7efe280eb5..9d00083663 100644 --- a/apps/web/tests/expected/agent-preset-authoring/section.expected.md +++ b/apps/web/tests/expected/agent-preset-authoring/section.expected.md @@ -19,11 +19,14 @@ - text: 关闭 - heading "Agent 预设" [level=2] - paragraph: 预设即一个会话的 Agent 所运行的插件组装 —— 它的工具、提示词与能力。复制一份既有预设改成自己的,或用「创造模式」让 Agent 帮你创建。 + - text: 允许切换agent模式 beta + - paragraph: 开启后,新任务可选择标准、PTC、创造、极简及自定义模式;关闭后统一使用默认模式(默认为标准模式,可自定义)。仅影响新任务。 + - switch "允许切换agent模式" [checked] - heading "内置" [level=3] - list: - listitem: - - 'button "当前使用: 标准模式" [disabled] [pressed]': - - text: 标准模式 内置 当前使用 功能完整的编码 Agent,支持文件编辑、Shell、文件与网页检索、Skills、计划、目标、子代理和工作流。 + - 'button "新任务默认: 标准模式" [disabled] [pressed]': + - text: 标准模式 内置 新任务默认 功能完整的编码 Agent,支持文件编辑、Shell、文件与网页检索、Skills、计划、目标、子代理和工作流。 - code: standard - 'button "查看: 标准模式"': - img diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 251333b2c1..0bd8fecb7c 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.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/event-producer-consumer.md -event-producer-consumer.md: b6fd2188e4088ae1de3bba0a17b991add39365be +event-producer-consumer.md: 31bcff64bdbd4caa24124f196beef86233f22538 event-producer-consumer.zh.md: 239149009d0ade0fa2869989e32d2c41fad46c83 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index b6fd2188e4..31bcff64bd 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -8,7 +8,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:246`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | -| `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:80`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `remotes` | +| `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:82`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `remotes` | | `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:373`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` | | `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:258`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`loader-smoke`](../packages/test-support/loader-smoke), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | | `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:267`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index 07faf5ab83..caf832356e 100644 --- a/docs/subsystems/core.i18n.yaml +++ b/docs/subsystems/core.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/core.md -core.md: 27ff30a9e63c86ffb54ccf57dea18ebf3fe39846 -core.zh.md: cedd113de5d5d551b8b558f9f33c7cacf6aa4953 +core.md: deb4743df146983857555016b5bdbe8d2ce29b42 +core.zh.md: f18a00eb02aac23ec07e80b4a9e3ea2234d7e576 diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index 27ff30a9e6..deb4743df1 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -498,11 +498,12 @@ async list(): Promise /** * The roster off the Host: {@link list} projected to path-free rows, with - * the default marked and this deployment's authoring capability beside it. + * the policy-effective default marked, this deployment's authoring + * capability, and its mode-selection policy beside it. * * Whether a client can open a preset's directory is the Host's own opener * capability, not a roster property — a caller needing both joins them. - * @returns the rows and the authoring capability. + * @returns the rows, authoring capability, and effective selection policy. */ @Remote('list') async remoteExportList(): Promise diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index cedd113de5..f18a00eb02 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -508,11 +508,12 @@ async list(): Promise /** * The roster off the Host: {@link list} projected to path-free rows, with - * the default marked and this deployment's authoring capability beside it. + * the policy-effective default marked, this deployment's authoring + * capability, and its mode-selection policy beside it. * * Whether a client can open a preset's directory is the Host's own opener * capability, not a roster property — a caller needing both joins them. - * @returns the rows and the authoring capability. + * @returns the rows, authoring capability, and effective selection policy. */ @Remote('list') async remoteExportList(): Promise diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index b17e21658f..a27749f61a 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -2735,7 +2735,11 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { const presetRemotes = { // Both trusts appear, because a surface must present a locally authored // preset differently from one the deployment vetted. - list(): RpcResult<{ presets: { id: string; trust: 'system' | 'user'; isDefault: boolean }[]; authorable: boolean }> { + list(): RpcResult<{ + presets: { id: string; trust: 'system' | 'user'; isDefault: boolean }[] + authorable: boolean + modeSelectionEnabled: boolean + }> { return { ok: true, value: { @@ -2745,6 +2749,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { isDefault: id === fixtureDefaultPreset, })), authorable: true, + modeSelectionEnabled: true, }, } }, diff --git a/packages/client/ui-agent-preset/README.i18n.yaml b/packages/client/ui-agent-preset/README.i18n.yaml index bf763f71ef..a6ca005fdf 100644 --- a/packages/client/ui-agent-preset/README.i18n.yaml +++ b/packages/client/ui-agent-preset/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-agent-preset/README.md -README.md: df5a46ae7d1668483b60538cffc4fc1b851163bc -README.zh.md: c3bb7467dd4ea4df8882f2c5e514e8d6027e5700 +README.md: 476d0a351896835b4f92372c4a62854f2162a6e1 +README.zh.md: fa5a81b4af6299ae2253cf43809f07478d66885c diff --git a/packages/client/ui-agent-preset/README.md b/packages/client/ui-agent-preset/README.md index df5a46ae7d..476d0a3518 100644 --- a/packages/client/ui-agent-preset/README.md +++ b/packages/client/ui-agent-preset/README.md @@ -1,5 +1,5 @@ --- -description: "Agent-preset surfaces for the Web GUI: the default-preset setting, the new-session chip, the session-header label, and the preset roster management section; for users and maintainers of agent composition." +description: "Agent-preset surfaces for the Web GUI: picker visibility and default settings, the new-session chip, the session-header label, and preset roster management; for users and maintainers of agent composition." kind: "package-reference" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Use this package to choose the agent preset for a new Web GUI session, see the active preset in the session header, and manage available presets in Settings. A preset is fixed when a session is created, so changing the selection or default affects only later sessions. If the deployment provides no presets, these controls stay hidden and every session uses the host composition. +Use this package to choose the agent preset for a new Web GUI session, see the active preset in the session header, and manage available presets in Settings. The Agent mode picker is shown by default; Settings can hide it without changing running or historical sessions. A preset is fixed when a session is created, so changing the selection or default affects only later sessions. If the deployment provides no presets, these controls stay hidden and every session uses the host composition. ## Table of Contents @@ -25,15 +25,15 @@ Use this package to choose the agent preset for a new Web GUI session, see the a ## Use this package -Mount this plugin alongside the settings and conversation packages; the preset surfaces then appear where their slots render. The new-session chip opens on the deployment default and stages a pick that lands on the next blank session; the stage is spent on first use, so the following new session opens on the default again. +Mount this plugin alongside the settings and conversation packages; the management section then shows a visibility switch that is on by default. While it is off, the new-session chip is absent and the Host composes an unnamed session from the deployment default (`standard` in the shipped Web bundle). Turning it on restores the saved user default, or uses the deployment default when none has been saved, and carries that default to the current blank task; a chip pick itself is staged only once for the next blank session. Turning the picker off again returns the current blank task to the deployment default the same way and discards an unconsumed stage; started and historical sessions keep their labels, compositions, and recorded history. ### Managing the roster -The settings section shows the roster as cards: a copy dialog is the only way a preset is created — the browser edits no composition text — and every custom card keeps a location action that opens the preset's own files. The default is set from any surface; deleting removes the preset directory while sessions already composed from it keep running. A shipped preset opens in a read-only viewer and offers no location or delete. A roster row carrying `broken` renders as a marked card whose body and duplication are disabled, because a copy of a broken preset is another broken preset; broken custom rows keep their location and delete actions so the files can be fixed and ghost directories cleared. The card face still shows the preset's own description — a chooser cannot act on a package specifier there — and the host's reason rides the badge as a tooltip, plus a visually hidden alert that carries it to assistive technology, which a disabled card body cannot. +The settings section shows the roster as cards: a copy dialog is the only way a preset is created — the browser edits no composition text — and every custom card keeps a location action that opens the preset's own files. The visibility switch changes only whether the saved user default is active: the Host uses the deployment default while hidden and restores the saved default when the picker is shown again. While the picker is enabled, choosing a healthy non-default card writes a new user default for later sessions; if the current new-task surface already reuses a blank session, that explicit Settings action carries the same preset to that exact blank session through the existing selection path. Started and historical sessions remain unchanged. The switch is disabled while saving, and a failed write keeps the prior preference and shows an error. Hiding the picker disables default selection and the Creator launch but leaves roster viewing, copying, location, and deletion available. Deleting removes the preset directory while sessions already composed from it keep running. A shipped preset opens in a read-only viewer and offers no location or delete. A roster row carrying `broken` renders as a marked card whose body and duplication are disabled, because a copy of a broken preset is another broken preset; broken custom rows keep their location and delete actions so the files can be fixed and ghost directories cleared. The card face still shows the preset's own description — a chooser cannot act on a package specifier there — and the host's reason rides the badge as a tooltip, plus a visually hidden alert that carries it to assistive technology, which a disabled card body cannot. ### The conversational entry -When the roster carries the self-referential `cordis` preset, a dashed add-card stages it and starts a new session — the section closes the settings panel and the new-session chip's own applier composes the blank session the workspace flow produces. +When the roster carries the self-referential `cordis` preset, its dashed add-card stays disabled until the picker is enabled. It then stages `cordis` and starts a new session — the section closes the settings panel and the new-session chip's own applier composes the blank session the workspace flow produces. ----- @@ -43,7 +43,7 @@ When the roster carries the self-referential `cordis` preset, a dashed add-card
Implementation internals — click to expand -The display options come from one `agentPresets/list` call — the roster already reports which id a session with no explicit choice gets, so no surface introspects the settings schema — and the default write, the settings section's make-default action, targets the `agent-presets` settings namespace's `default` field, which is what the host resolves at creation. The settings section queries `settings.canOpenAgentPresetDirectory()` when it first loads and joins that result with the roster; a failed query removes only the native-open affordance. The new-session chip and the header label share one controller, because the staged choice belongs to the flow rather than to any one session; the stage is applied when a session arrives (covering both the session a workspace connect created and the blank one it reused) and dropped on refusal. A refusal announces itself as a transient banner over the composer column, because the chip's label has already reverted and a preset the host refuses to mount is one discovery reported healthy — its roster card carries no reason to go back and read. Only a pick a person just made is announced; the applier that runs when a session becomes current is not. [`dsh-client-connection`](../connection/README.md) authenticates `agentPresets/read`, `agentPresets/copy`, `settings/openAgentPresetDirectory`, `agentPresets/deletePreset`, `agentPresets/list`, and every other Host API method with the same browser session. A composition still names the plugins a session runs, so reading one is reconnaissance, while copy, delete, and the settings-owned directory opener manage the roster and drive the host desktop. The section re-reads on its own actions, `settings/document-updated`, and `connection/reset`, because composition files are edited outside the browser and nothing on the wire announces a file change. +The settings section writes the Host's existing `agent-presets` namespace through `settings.update`. Its visibility switch sets only `modeSelectionEnabled`, and its make-default action writes `default` only while the picker is shown. After either write, the Host roster supplies the effective default, and the chip controller's `agentPresets/select` path carries it to the same still-blank session; that path is the only session-mutation API these surfaces use. Display options and Host-effective visibility come from `agentPresets/list` — the roster already marks the effective Host default and carries `modeSelectionEnabled`, so a non-loopback read-only client stays consistent without introspecting the settings schema. The settings section queries `settings.canOpenAgentPresetDirectory()` when it first loads and joins that result with the roster; a failed query removes only the native-open affordance. The new-session chip renders only while `modeSelectionEnabled` is true; hiding it drops a pending stage and local menu or refusal state, while the header label remains registered and reads each session's recorded preset. The stage is applied when a session arrives (covering both the session a workspace connect created and the blank one it reused) and dropped on refusal. A refusal announces itself as a transient banner over the composer column, because the chip's label has already reverted and a preset the Host refuses to mount is one discovery reported healthy — its roster card carries no reason to go back and read. Only a pick a person just made is announced; the applier that runs when a session becomes current is not. [`dsh-client-connection`](../connection/README.md) authenticates `agentPresets/read`, `agentPresets/copy`, `settings/openAgentPresetDirectory`, `agentPresets/deletePreset`, `agentPresets/list`, and every other Host API method with the same browser session. A composition still names the plugins a session runs, so reading one is reconnaissance, while copy, delete, and the settings-owned directory opener manage the roster and drive the Host desktop. The section re-reads on its own actions, `settings/document-updated`, and `connection/reset`, because composition files are edited outside the browser and nothing on the wire announces a file change.
@@ -68,7 +68,7 @@ Indirectly, through the preset a later session is composed from; the preset it s #### KV Cache effect -No direct invalidation. Changing the default never touches a running session's prefix; a session created afterwards establishes its own prefix from its own composition. +No direct invalidation. Changing picker visibility or the default does not alter a running session's composition or prefix, or a historical session's recorded preset; a session created afterwards establishes its own prefix from its own composition. ## Known Limitations and Deferred Work @@ -79,7 +79,7 @@ These limits define the current preset surfaces. They are current package constr - **A preset without metadata is listed by id** — display text is optional, and a copy given no name deliberately falls back to its directory name rather than presenting itself identically to its source. The resolution itself is the shared `presetDisplayText` fold from [`dsh-agent-presets/display`](../../preset/agent-presets/README.md), which the Settings plugin list inlines over this plugin’s dictionaries to show shipped presets in the active locale without translating user-authored metadata. - **A revealed path is display text, not a link** — where the host has no desktop opener the row shows the directory to copy by hand; the browser cannot open a host filesystem location itself. -- **Composition edits are invisible to the page** — the files are edited outside the browser and nothing on the wire announces a file change, so the roster re-reads on its own actions, `settings/changed`, and `connection/reset`, not on every disk edit. +- **Composition edits are invisible to the page** — the files are edited outside the browser and nothing on the wire announces a file change, so the roster re-reads on its own actions, `settings/document-updated`, and `connection/reset`, not on every disk edit. ### Dev Note diff --git a/packages/client/ui-agent-preset/README.zh.md b/packages/client/ui-agent-preset/README.zh.md index c3bb7467dd..fa5a81b4af 100644 --- a/packages/client/ui-agent-preset/README.zh.md +++ b/packages/client/ui-agent-preset/README.zh.md @@ -1,5 +1,5 @@ --- -description: "Web GUI 的 agent(智能体) preset 界面:默认 preset 设置、新建会话 chip、会话标题标签与 preset 名单管理分区;供 agent 组装的用户与维护者阅读。" +description: "Web GUI 的 agent(智能体) preset 界面:选择器可见性与默认设置、新建会话 chip、会话标题标签与 preset 名单管理分区;供 agent 组装的用户与维护者阅读。" kind: "package-reference" --- @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -使用本包可以为新的 Web GUI 会话选择 agent preset、在会话标题中查看当前 preset,并在设置中管理可用 preset。preset 在会话创建时即固定,因此更改选择或默认值只影响此后创建的会话。如果部署未提供任何 preset,这些控件保持隐藏,每个会话都使用宿主组装。 +使用本包可以为新的 Web GUI 会话选择 agent preset、在会话标题中查看当前 preset,并在设置中管理可用 preset。Agent 模式选择器默认显示;设置可以隐藏它,而不会改变运行中或历史会话。preset 在会话创建时即固定,因此更改选择或默认值只影响此后创建的会话。如果部署未提供任何 preset,这些控件保持隐藏,每个会话都使用宿主组装。 ## 目录 @@ -25,15 +25,15 @@ kind: "package-reference" ## 使用本包 -与设置与对话包一起挂载本插件;preset 界面随即出现在各自槽位渲染之处。新建会话 chip 以部署默认值打开并暂存一个选择,落到下一个空白会话上;暂存一经使用即被清空,因此再下一个新会话重新以默认值打开。 +与设置和对话包一起挂载本插件;管理分区随后显示一个默认开启的可见性开关。关闭期间,新建会话 chip 不出现,宿主会依据部署默认值(随附 Web bundle 中为 `standard`)组装未指名会话。开启时会恢复已保存的用户默认值;尚未保存时则使用部署默认值;该默认值会同时带到当前空白任务上。chip 中的选择本身只为下一个空白会话暂存一次。再次关闭选择器会以同样方式把当前空白任务带回部署默认值,并丢弃尚未使用的暂存选择;已开始及历史会话的标签、组装与已记录历史均保持不变。 ### 管理名单 -设置分区把名单呈现为卡片:复制对话框是创建 preset 的唯一入口——浏览器不编辑任何组装文本——每张自定义卡片都保留一个打开 preset 自身文件的位置动作。默认值可在任一界面设置;删除会移除 preset 目录,而已据其组装的会话继续运行。随附 preset 在只读查看器中打开,不提供位置或删除。名单行携带 `broken` 时渲染为标记卡片,其主体与复制均被禁用,因为损坏 preset 的副本只是另一个损坏 preset;损坏的自定义行保留位置与删除动作,以便修复文件、清掉幽灵目录。卡片正面仍显示 preset 自己的描述——在选择器里,一个包说明符不足以让人采取行动——宿主给出的原因作为工具提示附在徽标上,另有一个视觉隐藏的 alert 将该原因传达给辅助技术,而被禁用的卡片主体无法做到这一点。 +设置分区把名单呈现为卡片:复制对话框是创建 preset 的唯一入口——浏览器不编辑任何组装文本——每张自定义卡片都保留一个打开 preset 自身文件的位置动作。可见性开关只决定已保存的用户默认值是否生效:宿主在隐藏期间使用部署默认值,再次显示选择器时恢复已保存的默认值。选择器开启期间,选择健康且非默认的卡片会为后续会话写入新的用户默认值;如果当前新任务页已经复用一个空白会话,这次在设置中的明确选择也会通过既有选择链路把同一 preset 带到这个精确的空白会话。已开始及历史会话保持不变。保存期间开关会被禁用;写入失败时,界面保留先前的偏好并显示错误。隐藏选择器会禁用默认值选择与 Creator 启动,但名单查看、复制、位置和删除仍然可用。删除会移除 preset 目录,而已据其组装的会话继续运行。随附 preset 在只读查看器中打开,不提供位置或删除。名单行携带 `broken` 时渲染为标记卡片,其主体与复制均被禁用,因为损坏 preset 的副本只是另一个损坏 preset;损坏的自定义行保留位置与删除动作,以便修复文件、清掉幽灵目录。卡片正面仍显示 preset 自己的描述——在选择器里,一个包说明符不足以让人采取行动——宿主给出的原因作为工具提示附在徽标上,另有一个视觉隐藏的 alert 将该原因传达给辅助技术,而被禁用的卡片主体无法做到这一点。 ### 对话式入口 -名单携带自指的 `cordis` preset 时,一张虚线添加卡会暂存它并开启新会话——分区关闭设置面板,新建会话 chip 自己的应用器负责组装工作区流程产出的空白会话。 +名单携带自指的 `cordis` preset 时,其虚线添加卡在选择器开启前保持禁用。开启后,它会暂存 `cordis` 并启动新会话——分区关闭设置面板,新建会话 chip 自己的应用器负责组装工作区流程产出的空白会话。 ----- @@ -43,7 +43,7 @@ kind: "package-reference"
实现细节——点击展开 -展示选项来自同一次 `agentPresets/list` 调用——名单本身已报告未显式选择的会话会得到哪个 id,因此任何界面都无需对 settings schema 做内省——默认值的写入即设置分区的设为默认动作,目标是 `agent-presets` settings 命名空间的 `default` 字段,也正是宿主在创建时解析的字段。设置分区首次加载时查询 `settings.canOpenAgentPresetDirectory()`,并把结果与名单合并;查询失败只会移除原生打开动作。新建会话 chip 与标题标签共用一个控制器,因为暂存选择属于流程而非任何单个会话;暂存值在会话到达时应用(既覆盖工作区连接新建的会话,也覆盖它复用的空白会话),被拒绝时丢弃。系统会通过 composer 列上方的瞬时横幅提示拒绝结果,因为 chip 的标签此时已经恢复原值,而被宿主拒绝挂载的 preset 正是发现过程报告为健康的那一种——它的名单卡片上没有任何原因可供回头查看。只有用户刚做出的选择会触发提示;会话成为当前会话时触发的应用器不会。[`dsh-client-connection`](../connection/README.zh.md) 使用同一浏览器会话认证 `agentPresets/read`、`agentPresets/copy`、`settings/openAgentPresetDirectory`、`agentPresets/deletePreset`、`agentPresets/list` 及其他所有 Host API 方法。组装仍会指明一个会话所运行的插件,因此读取属于侦察,而复制、删除与设置模块拥有的目录打开操作负责管理名单并驱动宿主桌面。分区在自身操作、`settings/document-updated` 与 `connection/reset` 时重读,因为组装文件在浏览器之外编辑,协议链路不会通知文件变动。 +设置分区通过现有的 `settings.update` 写入宿主的 `agent-presets` 命名空间。可见性开关只设置 `modeSelectionEnabled`;仅当选择器显示时,设为默认动作才会写入 `default`。两种写入之后,都由宿主名单给出当前生效的默认值,再由 chip controller 的 `agentPresets/select` 链路把它带到同一个仍为空白的会话;这些界面只使用这一条会话修改 API。展示选项与宿主的生效可见性来自 `agentPresets/list`——名单本身已标记宿主当前生效的默认值并携带 `modeSelectionEnabled`,因此非 loopback 的只读客户端无需内省 settings schema 也能保持一致。设置分区首次加载时查询 `settings.canOpenAgentPresetDirectory()`,并把结果与名单合并;查询失败只会移除原生打开动作。新建会话 chip 仅在 `modeSelectionEnabled` 为 `true` 时渲染;隐藏它会丢弃待处理的暂存选择及本地菜单或失败横幅状态,而标题标签保持注册并读取每个会话已记录的 preset。暂存值在会话到达时应用(既覆盖工作区连接新建的会话,也覆盖它复用的空白会话),被拒绝时丢弃。系统会通过 composer 列上方的瞬时横幅提示拒绝结果,因为 chip 的标签此时已经恢复原值,而被宿主拒绝挂载的 preset 正是发现过程报告为健康的那一种——它的名单卡片上没有任何原因可供回头查看。只有用户刚做出的选择会触发提示;会话成为当前会话时触发的应用器不会。[`dsh-client-connection`](../connection/README.zh.md) 使用同一浏览器会话认证 `agentPresets/read`、`agentPresets/copy`、`settings/openAgentPresetDirectory`、`agentPresets/deletePreset`、`agentPresets/list` 及其他所有宿主 API 方法。组装仍会指明一个会话所运行的插件,因此读取属于侦察,而复制、删除与设置模块拥有的目录打开操作负责管理名单并驱动宿主桌面。分区在自身操作、`settings/document-updated` 与 `connection/reset` 时重读,因为组装文件在浏览器之外编辑,协议链路不会通知文件变动。
@@ -68,7 +68,7 @@ kind: "package-reference" #### KV Cache 影响 -没有直接的失效影响。更改默认值绝不触及运行中会话的前缀;此后创建的会话依据它自己的组装建立自己的前缀。 +没有直接的失效影响。更改选择器可见性或默认值不会改变运行中会话的组装或前缀,也不会改变历史会话已记录的 preset;此后创建的会话依据它自己的组装建立自己的前缀。 ## 已知限制与延期工作 @@ -79,7 +79,7 @@ kind: "package-reference" - **没有元数据的 preset 按 id 列出**——展示文本是可选的,未取名的副本刻意回退到目录名,而不是与其来源呈现得一模一样。解析本身使用 [`dsh-agent-presets/display`](../../preset/agent-presets/README.zh.md) 共享的 `presetDisplayText` 解析逻辑,设置的插件列表把它内联在本插件的字典之上,按当前语言显示随附 preset 的名称,同时不翻译用户自建的元数据。 - **展示的路径是文本,不是链接**——宿主没有桌面打开器时,卡片显示目录供手工复制;浏览器自身无法打开宿主文件系统上的位置。 -- **组装编辑对页面不可见**——文件在浏览器之外编辑,协议链路不广播文件变动,因此名单只在自身操作、`settings/changed` 与 `connection/reset` 时重读,而非每次磁盘编辑。 +- **组装编辑对页面不可见**——文件在浏览器之外编辑,协议链路不广播文件变动,因此名单只在自身操作、`settings/document-updated` 与 `connection/reset` 时重读,而非每次磁盘编辑。 ### 开发备注 diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx index f16d1c83d9..0f5d762e74 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx @@ -89,11 +89,22 @@ export function AgentPresetSeat({ load, select, introduced, useAgentPresetSeat, // it rather than leaving the first one silently in place. const toastSeq = useRef(0) const [toast, setToast] = useState<{ seq: number; text: string } | null>(null) + const pickerVisible = useRef(state.showPicker) + pickerVisible.current = state.showPicker useEffect(() => { void load() }, [load]) + // The component stays registered while hidden, so clear local disclosure + // state explicitly; otherwise an external off/on edit can revive an old + // menu or refusal banner. + useEffect(() => { + if (state.showPicker) return + setOpen(false) + setToast(null) + }, [state.showPicker]) + const chosen = state.options.find(option => option.id === state.current) const chosenText = chosen === undefined ? undefined : presetDisplayText(chosen, t) const label = chosenText?.name ?? state.current @@ -121,7 +132,7 @@ export function AgentPresetSeat({ load, select, introduced, useAgentPresetSeat, // Nothing to choose between: the deployment composes no presets and every // session shares the host composition. - if (!ready) return null + if (!state.showPicker || !ready) return null // One wrapper span: the chip is a flex row with a gap, so loose character // spans would each pick up the gap between them. @@ -174,7 +185,7 @@ export function AgentPresetSeat({ load, select, introduced, useAgentPresetSeat, // Announced only for a pick a person just made: `apply()` also runs // when a session becomes current, and a banner over that would // report a refusal nobody asked for. - if (refusal === undefined) return + if (refusal === undefined || !pickerVisible.current) return toastSeq.current += 1 setToast({ seq: toastSeq.current, text: t('switchRefused', { name, reason: refusal }) }) }) diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css b/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css index 658788e38a..91111b0682 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css @@ -18,6 +18,43 @@ color: var(--dsw-alias-label-tertiary); } +.pickerPreference { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 20px; + padding: 14px 16px; + border: 0.5px solid var(--dsw-alias-border-l4); + border-radius: 14px; + background: var(--dsw-alias-bg-module-platform); +} + +.pickerPreferenceCopy { + display: grid; + gap: 4px; + min-width: 0; +} + +.pickerPreferenceTitleRow { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} + +.pickerPreferenceTitle { + font-size: 14px; + font-weight: 600; + line-height: 1.5; +} + +.pickerPreferenceDescription { + margin: 0; + font-size: 12px; + line-height: 1.5; + color: var(--dsw-alias-label-tertiary); +} + /* Cards, not rows: a preset is a thing you pick, and the description is the part that tells them apart — a row would bury it beside the actions. */ .group { @@ -63,7 +100,7 @@ } -.card:hover:not(.cardActive) { +.card:hover:not(.cardActive):not(.cardSelectionDisabled) { background: var(--dsw-alias-interactive-bg-hover); } @@ -75,6 +112,16 @@ border-color: var(--dsw-static-neutral-bluish-400); } +/* Mode selection is off, not the preset itself: mute only the selectable + body while the independent view/copy/location actions stay fully usable. */ +.cardSelectionDisabled { + background: var(--dsw-alias-bg-module-platform); +} + +.cardSelectionDisabled .cardMain { + opacity: 0.48; +} + /* A broken preset reads as damaged before anything else: the card cannot be picked, so its border carries the warning the disabled body cannot. */ .cardBroken { diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx index 1da7d49450..d275913ff5 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx @@ -13,7 +13,8 @@ import { useEffect, useLayoutEffect, useRef, useState } from 'react' import type { ReactNode } from 'react' import { - Button, IconBrowseOutline16, IconCopyOutline16, IconFolderOpenOutline16, IconPlusOutline16, IconTrashOutline16, Modal, Tag, Tooltip, + Button, IconBrowseOutline16, IconCopyOutline16, IconFolderOpenOutline16, + IconPlusOutline16, IconTrashOutline16, Modal, Switch, Tag, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' @@ -57,6 +58,8 @@ export interface AgentPresetSectionInjected { remove: () => Promise /** Make one preset the default for sessions created later. */ makeDefault: (id: string) => Promise + /** Show or hide preset selection on new-session surfaces. */ + setPickerVisible: (showPicker: boolean) => Promise } /** Full component props. */ @@ -214,8 +217,10 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {