Merge pull request #5413 from deepseek-harness/fix/beginner-mode-choices-20260928

fix(web): 关闭代码工作视图时隐藏开发模式并修正默认预设
This commit is contained in:
Ziya authored and GitHub committed 2026-09-29 07:54:42 -07:00
commit 95efadbfbd
28 files changed
+779 -223

No files matched your search

@@ -9,8 +9,8 @@
en: 725f773bb350218e
zh: dd8fbc09ef48208d
/agent-note-declarative-agent-presets-and-retained-revisions/decision:
en: 2a2489303fc76fc0
zh: 1a760e9a5edfccc8
en: 53fd72bf4bbe4eb3
zh: 406eee6e4f818e7c
/agent-note-declarative-agent-presets-and-retained-revisions/alternatives-considered:
en: 09f40eab4659e9f6
zh: 7a70da0d0ae6845b
@@ -16,7 +16,7 @@ The registry owns each revision's scope and Loader tree. Agents link their own s
The Web editor and `agent_preset` tool accept only child plugin YAML. Saving writes the active profile's user patch, preserving display metadata and unrelated configuration. Revision checks and profile locking prevent overwriting concurrent saves; validation rejects a higher-priority override that would defeat the edit. Persistence and activation outcomes are reported separately. Tool writes require approval or full access because configuration executes in the Host.
Session data continues to record the preset identity. Restart resolves that identity against current configuration and rejects a missing definition. Old executable revisions are process-local, not serialized. The saved user default and blank-session selection remain; `selectedDefault` is what an unnamed new session resolves, and the Client's Developer tools preference decides only whether the choice is shown.
Session data continues to record the preset identity. Restart resolves that identity against current configuration and rejects a missing definition. Old executable revisions are process-local, not serialized. New sessions without an explicit preset resolve `selectedDefault`; Client choice filtering and default correction follow [the preset UI policy](../../../../packages/client/ui-agent-preset/README.md).
## Alternatives considered
@@ -16,7 +16,7 @@ Preset 目录重复承担 Cordis 配置的所有权。独立的发现、元数
Web 编辑器与 `agent_preset` 工具只接受子插件 YAML。保存写入当前 profile 的用户补丁,保留显示元数据和无关配置。版本检查和 profile 锁防止覆盖并发保存;验证拒绝会使编辑失效的更高优先级覆盖。持久化与激活结果分别报告。配置在 Host 中执行,因此工具写入需要批准或完全访问权限。
会话数据继续记录 preset 标识。重启时根据当前配置解析该标识,定义缺失则拒绝恢复。旧的可执行代际仅保留在进程内,不会序列化。保存的用户默认值与空白会话选择保持不变:未指定 preset 的新会话解析 `selectedDefault`,客户端的开发者工具偏好只决定是否展示这项选择。
会话数据继续记录 preset 标识。重启时根据当前配置解析该标识,定义缺失则拒绝恢复。旧的可执行代际仅保留在进程内,不会序列化。未显式指定 preset 的新会话解析 `selectedDefault`;客户端选项过滤和默认值修正遵循[预设 UI 策略](../../../../packages/client/ui-agent-preset/README.zh.md)。
## Alternatives considered
@@ -9,11 +9,11 @@
en: e22ee243b1503319
zh: b53c1a932d547f55
/agent-note-shared-developer-tool-settings/decision:
en: 6b94e4c856dbd92a
zh: e923b1118dfad7b6
en: b579ed62e2846939
zh: 4d5c121599413807
/agent-note-shared-developer-tool-settings/alternatives-considered:
en: df1b80e5b8d2a178
zh: 0136d16fc44d57bf
/agent-note-shared-developer-tool-settings/consequences:
en: c1e05aebc88fc12c
zh: 0ace5bced7b32437
en: 3f50fdc4b4233266
zh: 8647e00b474da639
@@ -12,7 +12,7 @@ Diagnostic views, preset selection and changed-file summaries add complexity to
The settings-owned `ctx.configForms.developerTools` preference uses a shared entry form and exposes one accepted `ui-settings.enabled` preference. The initial default-off choice reduces routine-task complexity; [Developer tools default on after preference resolution](2026-09-20-developer-tools-default-on.md) supersedes that default and owns startup enablement. General Settings offers the same switch on Web and desktop. The existing settings transport owns validation, ordered writes, persistence and recovery; loopback Web and desktop follow Host acceptance. Remote Web owns one browser-local observable shared by all consumers until reload, with no Host writes.
Off hides the Trajectory View, hides tool Inspect actions when no visible View declares tool-call inspection, hides the conversation View tab bar while fewer than two Views are available, hides the new-session preset chip, and omits the changed-files card and its summary read. Views registered by other plugins stay available. Turning it off while Trajectory is active activates Chat. The preset composition, saved default, Session events and explicit delivery cards remain unchanged. This preference is presentation policy, not Host authorization.
Off hides the Trajectory View, hides tool Inspect actions when no visible View declares tool-call inspection, hides the conversation View tab bar while fewer than two Views are available, hides built-in PTC and Minimal choices, and omits the changed-files card and its summary read. Views registered by other plugins stay available. Turning it off while Trajectory is active activates Chat. Existing Session presets, Session events and explicit delivery cards remain unchanged. Preset choices, default correction and unapplied selections follow [the preset UI policy](../../../../packages/client/ui-agent-preset/README.md). This preference is presentation policy, not Host authorization.
Plugin assembly translates the shared preference into component-specific capabilities (`showCodeDiff`, the new-session preset chip's `developerTools`, and `interactivePreview`). Conversation supplies the inspection callback using the visible target's `toolCallFocus`; Chat does not name the target. The builtin HTML renderer uses DOMPurify for complete-document sanitization and parser-mutation defenses. Off preserves root styling, removes active documents, resource hints, SVG animation and all `href` attributes, and mounts an iframe with no sandbox permissions and a CSP denying scripts and external resources. It reads no related files. On retains the opaque scripted Blob renderer and its bounded related-file reads. Switching modes replaces the browsing context and disposes pending reads. The [document-preview decision](../architecture/2026-09-08-document-preview-operations.md) and [filesystem-read authority](../architecture/2026-09-09-workspace-file-read-authority.md) remain authoritative for advanced rendering and Host read access.
@@ -26,4 +26,4 @@ Plugin assembly translates the shared preference into component-specific capabil
Basic HTML sacrifices JavaScript, external and related resources, and link navigation; inline styles and data images remain available. Advanced mode retains normal browser networking and does not gain parent-origin, popup, form or top-navigation privileges. The setting does not constrain third-party preview implementations or revoke Host filesystem authority.
Component and settings tests cover defaulting, schema rejection, accepted-state updates, chip disclosure, changed-file visibility and iframe disposal. The recorded-session [document preview](../../../../apps/web/tests/document-preview.e2e.ts) and [changed-files](../../../../apps/web/tests/changed-files-turn.e2e.ts) scenarios exercise the real settings control and the shared renderer. The browser scaffold uses the shipped default unless a scenario specifies a preference. Settings chrome, document preview and changed-files scenarios explicitly disable developer tools; together they cover hidden diagnostic controls, static previews, and switching back to Chat. Refused Host writes recover accepted state and show the localized retry message. Native Electron execution remains unverified.
Component and settings tests cover defaulting, schema rejection, accepted-state updates, preset choice visibility, changed-file visibility and iframe disposal. The recorded-session [document preview](../../../../apps/web/tests/document-preview.e2e.ts) and [changed-files](../../../../apps/web/tests/changed-files-turn.e2e.ts) scenarios exercise the real settings control and the shared renderer. The browser scaffold uses the shipped default unless a scenario specifies a preference. Settings chrome, document preview and changed-files scenarios explicitly disable developer tools; together they cover hidden diagnostic controls, static previews, and switching back to Chat. Refused Host writes recover accepted state and show the localized retry message. Native Electron execution remains unverified.
@@ -12,7 +12,7 @@ Status: implemented
设置域内的 `ctx.configForms.developerTools` 偏好对象使用共享条目表单,提供一个已接受的 `ui-settings.enabled` 偏好。最初默认关闭的选择用于降低日常任务的复杂度;[偏好解析后默认开启开发者工具](2026-09-20-developer-tools-default-on.zh.md)取代该默认值并负责启动时的启用策略。Web 和桌面端的通用设置提供同一个开关。现有设置传输负责验证、有序写入、持久化和恢复;回环 Web 和桌面端跟随 Host 接受的状态。远程 Web 持有一个所有消费者共享的浏览器本地可观察值,刷新后重置,不发送 Host 写入。
关闭时隐藏 Trajectory View,在没有可见 View 声明工具调用检查能力时隐藏 Inspect 操作,可见 View 少于两个时隐藏会话 View 标签栏,隐藏新会话预设入口,并省略改动文件卡片及其摘要读取。其他插件注册的 View 保持可用。在 Trajectory 中关闭开关会激活 Chat。预设组合、保存的默认值、Session 事件和显式交付卡片保持不变。此偏好是展示策略,不是 Host 授权。
关闭时隐藏 Trajectory View,在没有可见 View 声明工具调用检查能力时隐藏 Inspect 操作,可见 View 少于两个时隐藏会话 View 标签栏,隐藏内置 PTC 和极简选项,并省略改动文件卡片及其摘要读取。其他插件注册的 View 保持可用。在 Trajectory 中关闭开关会激活 Chat。已有 Session 的预设、Session 事件和显式交付卡片保持不变。预设选项、默认值修正和尚未应用的选择遵循[预设 UI 策略](../../../../packages/client/ui-agent-preset/README.zh.md)。此偏好是展示策略,不是 Host 授权。
插件组装层将共享偏好转换为组件自身的能力(`showCodeDiff`、新会话预设入口读取的 `developerTools`,以及 `interactivePreview`)。Conversation 根据可见目标的 `toolCallFocus` 提供检查回调;Chat 不再指定目标名称。内置 HTML 渲染器使用 DOMPurify 清理完整文档并防护解析变异。关闭时保留根节点样式,移除主动文档、资源提示、SVG 动画及所有 `href` 属性,再放入不授予沙箱权限的 iframe,并通过 CSP 禁止脚本和外部资源。不读取关联文件。开启时保留不透明源的脚本 Blob 渲染器及其有界关联文件读取。切换模式会替换浏览上下文并清理待处理读取。[文档预览决策](../architecture/2026-09-08-document-preview-operations.zh.md)和[文件系统读取权限](../architecture/2026-09-09-workspace-file-read-authority.zh.md)继续定义高级渲染与 Host 读取访问。
@@ -26,4 +26,4 @@ Status: implemented
基础 HTML 放弃 JavaScript、外部与关联资源和链接导航;行内样式与 data 图片仍可用。高级模式保留正常浏览器网络能力,不增加父源、弹窗、表单或顶层导航权限。此设置不约束第三方预览实现,也不撤销 Host 文件系统读取权限。
组件和设置测试覆盖默认值、schema 拒绝、已接受状态更新、入口折叠、改动文件展示和 iframe 清理。基于已记录 Session 的[文档预览](../../../../apps/web/tests/document-preview.e2e.ts)与[改动文件](../../../../apps/web/tests/changed-files-turn.e2e.ts)场景验证真实设置控件及共用渲染器。浏览器脚手架使用产品默认值,除非场景指定偏好。设置外壳、文档预览和改动文件场景显式关闭开发者工具;这些场景共同覆盖诊断控件隐藏、静态预览和切回 Chat。Host 拒绝写入后会恢复已接受状态,并显示本地化重试提示。原生 Electron 执行尚未验证。
组件和设置测试覆盖默认值、schema 拒绝、已接受状态更新、预设选项展示、改动文件展示和 iframe 清理。基于已记录 Session 的[文档预览](../../../../apps/web/tests/document-preview.e2e.ts)与[改动文件](../../../../apps/web/tests/changed-files-turn.e2e.ts)场景验证真实设置控件及共用渲染器。浏览器脚手架使用产品默认值,除非场景指定偏好。设置外壳、文档预览和改动文件场景显式关闭开发者工具;这些场景共同覆盖诊断控件隐藏、静态预览和切回 Chat。Host 拒绝写入后会恢复已接受状态,并显示本地化重试提示。原生 Electron 执行尚未验证。
+39 -10
View File
@@ -249,13 +249,25 @@ describe('web e2e: agent-preset selection', () => {
await rm(fixtureRoot, { recursive: true, force: true })
})
it('starts on the Standard default with the roster editable in Settings', async () => {
it('clears an unapplied hidden pick and creates a Standard session with the roster editable in Settings', 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 })
expect(await livePreset(scaffold)).toBeUndefined()
await page.getByRole('button', { name: 'Standard mode', exact: true }).click()
await page.getByRole('menuitem', { name: /^Minimal mode/ }).click()
await page.getByRole('button', { name: 'Minimal mode', exact: true }).waitFor()
await openSettings(page, 'en')
const dialog = page.getByRole('dialog', { name: 'Settings' })
const codingTools = dialog.getByRole('switch', { name: 'Show coding view' })
await codingTools.click()
await expect.poll(() => codingTools.getAttribute('aria-checked')).toBe('false')
await dialog.getByRole('button', { name: 'Close' }).last().click()
await connectFreshWorkspace(page, scaffold.workspaceCwd)
await page.getByRole('button', { name: 'Standard mode', exact: true }).waitFor({ timeout: 10_000 })
await expect.poll(() => livePreset(scaffold)).toBe('standard')
await openSettings(page, 'en')
await codingTools.click()
await expect.poll(() => codingTools.getAttribute('aria-checked')).toBe('true')
await dialog.getByRole('button', { name: 'Agent presets' }).click()
await dialog.getByRole('button', { name: 'New task default: Standard mode' }).waitFor({ timeout: 10_000 })
expect(await dialog.getByRole('switch').count()).toBe(0)
@@ -343,7 +355,7 @@ describe('web e2e: agent-preset selection', () => {
await writeComposerDraft(page, composer, '')
}, 90_000)
it('keeps the saved default composing sessions while Developer tools only gate the choice', async () => {
it('resets the hidden default to Standard while preserving the blank session until the user switches it', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-disabled'))
await expect.poll(() => livePreset(scaffold), { timeout: 15_000 }).toBe('standard')
@@ -359,21 +371,38 @@ describe('web e2e: agent-preset selection', () => {
await expect.poll(() => developerTools.getAttribute('aria-checked')).toBe('false')
await dialog.getByRole('button', { name: 'Close' }).last().click()
// The gate hides the choice; the blank task keeps its saved composition.
await expect.poll(() => page.getByRole('button', { name: / mode$/ }).count()).toBe(0)
await expect.poll(() => livePreset(scaffold), { timeout: 15_000 }).toBe('minimal')
await page.getByRole('button', { name: 'Minimal mode', exact: true }).click()
await page.getByRole('menu').waitFor()
expect(await page.getByRole('menuitem', { name: /^PTC mode|^Minimal mode/ }).count()).toBe(0)
expect(await page.getByRole('menuitem', { name: /^Standard mode|^Creator mode/ }).count()).toBe(2)
await page.keyboard.press('Escape')
await expect.poll(async () => (await scaffold.ctx.agentPresets.remoteExportList()).presets.find(preset => preset.isDefault)?.id).toBe('standard')
const reuse = page.waitForResponse('**/api/session/create')
await page.getByRole('button', { name: 'New session', exact: true }).last().click()
const reused = await reuse
expect(reused.request().postDataJSON()).toHaveProperty('payload.args.request.sessionId')
expect(await reused.json()).toMatchObject({ result: { ok: true, value: { agentPreset: 'minimal' } } })
await page.reload()
await page.getByRole('button', { name: 'Minimal mode', exact: true }).click()
await expect.poll(() => livePreset(scaffold), { timeout: 15_000 }).toBe('minimal')
await page.getByRole('menuitem', { name: /^Standard mode/ }).click()
await expect.poll(() => livePreset(scaffold), { timeout: 15_000 }).toBe('standard')
await openSettings(page, 'en')
const reopened = page.getByRole('dialog', { name: 'Settings' })
await reopened.getByRole('button', { name: 'General', exact: true }).click()
const reopenedDeveloperTools = reopened.getByRole('switch', { name: 'Show coding view' })
await expect.poll(() => reopenedDeveloperTools.getAttribute('aria-checked')).toBe('false')
await reopenedDeveloperTools.click()
await expect.poll(() => reopenedDeveloperTools.getAttribute('aria-checked')).toBe('true')
await reopened.getByRole('button', { name: 'Agent presets' }).click()
await reopened.getByRole('button', { name: 'New task default: Minimal mode' }).waitFor({ timeout: 10_000 })
await reopened.getByRole('button', { name: 'New task default: Standard mode' }).waitFor({ timeout: 10_000 })
await reopened.getByRole('button', { name: 'Set as 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 })
await expect.poll(() => livePreset(scaffold), { timeout: 15_000 }).toBe('standard')
await page.getByRole('button', { name: 'Standard mode' }).waitFor({ timeout: 10_000 })
})
it('labels a resumed session with the preset it was created under', async () => {
+82 -14
View File
@@ -1,17 +1,23 @@
/** Developer tools default on for a fresh Host and survive browser reload through the ordinary settings UI. */
/** Coding Tools persist while Standard, Creator, and custom presets remain selectable when disabled. */
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import type {} from '@deepseek-ai/dsh-api-session-controller'
import { chromium } from 'playwright'
import { expect, it, onTestFinished } from 'vitest'
import { launchWebScaffold } from './scaffold.ts'
import { openSettings, newEnglishPage } from './support.ts'
import { launchWebScaffold, watchConsole } from './scaffold.ts'
import { connectFreshWorkspace, openSettings, newEnglishPage } from './support.ts'
it('persists developer tools in the Host settings document and restores the accepted choice', async () => {
const scaffold = await launchWebScaffold()
it('persists Coding Tools and limits the built-in PTC and Minimal choices in sessions and Settings', async () => {
const scaffold = await launchWebScaffold({
agentPresets: { default: 'standard', definitions: [{ id: 'custom', name: 'Custom mode', plugins: [] }] },
})
onTestFinished(() => scaffold.close())
const browser = await chromium.launch()
onTestFinished(() => browser.close())
const page = await newEnglishPage(browser)
const tripwire = watchConsole(page)
const livePresets = async () => (await scaffold.ctx.sessionController.list({}, new AbortController().signal))
.items.map(item => item.projections?.values.agentPreset)
await page.goto(scaffold.authenticatedUrl)
await openSettings(page, 'en')
const toggle = page.getByRole('switch', { name: 'Show coding view' })
@@ -26,14 +32,76 @@ it('persists developer tools in the Host settings document and restores the acce
await page.getByRole('button', { name: 'Agent presets', exact: true }).click()
await expect.poll(() => page.getByRole('heading', { name: 'Agent presets', exact: true }).count()).toBe(1)
// The page owns no selection switch; the cards and the Creator entry stay usable with Coding Tools off.
const section = page.locator('section')
.filter({ has: page.getByRole('heading', { name: 'Agent presets', exact: true }) })
const setDefault = section.getByRole('button', { name: 'Set as new task default: Minimal mode' })
expect(await section.getByRole('switch').count()).toBe(0)
await expect.poll(() => setDefault.isEnabled()).toBe(true)
expect(await section.getByRole('button', { name: 'Let the agent help me create a preset', exact: true }).isEnabled()).toBe(true)
const settings = page.getByRole('dialog', { name: 'Settings', exact: true })
expect(await settings.getByRole('switch').count()).toBe(0)
const visiblePresets = () => settings.locator('[data-agent-preset-id]').evaluateAll(cards => cards.map(card => card.getAttribute('data-agent-preset-id')))
await expect.poll(visiblePresets).toEqual(['standard', 'cordis', 'custom'])
await settings.getByRole('button', { name: 'Set as new task default: Custom mode', exact: true }).click()
await settings.getByRole('button', { name: 'New task default: Custom mode', exact: true }).waitFor()
await scaffold.ctx.settings.update('ui-settings', { enabled: true })
await expect.poll(() => setDefault.isEnabled()).toBe(true)
expect(await section.getByRole('switch').count()).toBe(0)
await expect.poll(visiblePresets).toEqual(['standard', 'ptc', 'minimal', 'cordis', 'custom'])
expect((await scaffold.ctx.agentPresets.remoteExportList()).presets.find(preset => preset.isDefault)?.id).toBe('custom')
await settings.getByRole('button', { name: 'Set as new task default: Minimal mode', exact: true }).click()
await settings.getByRole('button', { name: 'New task default: Minimal mode', exact: true }).waitFor()
await scaffold.ctx.settings.update('ui-settings', { enabled: false })
await expect.poll(visiblePresets).toEqual(['standard', 'cordis', 'custom'])
await settings.getByRole('button', { name: 'New task default: Standard mode', exact: true }).waitFor()
await expect.poll(async () => (await scaffold.ctx.agentPresets.remoteExportList()).presets.find(preset => preset.isDefault)?.id).toBe('standard')
expect(await settings.getByRole('button', { name: 'Let the agent help me create a preset', exact: true }).isEnabled()).toBe(true)
await settings.getByRole('button', { name: 'Close', exact: true }).last().click()
await connectFreshWorkspace(page, scaffold.workspaceCwd)
await expect.poll(livePresets).toEqual(['standard'])
const menu = page.getByRole('menu')
await page.getByRole('button', { name: 'Standard mode', exact: true }).click()
await expect.poll(() => menu.getByRole('menuitem').count()).toBe(3)
expect(await menu.getByRole('menuitem', { name: /^PTC mode|^Minimal mode/ }).count()).toBe(0)
expect(await menu.getByRole('menuitem', { name: /^Custom mode/ }).count()).toBe(1)
await menu.getByRole('menuitem', { name: /^Creator mode/ }).click()
await expect.poll(livePresets, { timeout: 15_000 }).toEqual(['cordis'])
await page.getByRole('button', { name: 'Creator mode', exact: true }).click()
await menu.waitFor()
await scaffold.ctx.settings.update('ui-settings', { enabled: true })
await menu.waitFor({ state: 'detached' })
await page.getByRole('button', { name: 'Creator mode', exact: true }).click()
await expect.poll(() => menu.getByRole('menuitem').count()).toBe(5)
expect(await menu.getByRole('menuitem', { name: /^PTC mode/ }).count()).toBe(1)
expect(await menu.getByRole('menuitem', { name: /^Minimal mode/ }).count()).toBe(1)
expect(await livePresets()).toEqual(['cordis'])
await scaffold.ctx.settings.update('ui-settings', { enabled: false })
await menu.waitFor({ state: 'detached' })
expect(await livePresets()).toEqual(['cordis'])
await page.getByRole('button', { name: 'Creator mode', exact: true }).click()
await menu.waitFor()
await scaffold.ctx.settings.update('ui-settings', { enabled: true })
await menu.waitFor({ state: 'detached' })
await page.getByRole('button', { name: 'Creator mode', exact: true }).click()
await menu.getByRole('menuitem', { name: /^PTC mode/ }).click()
await expect.poll(livePresets, { timeout: 15_000 }).toEqual(['ptc'])
await page.getByRole('button', { name: 'PTC mode', exact: true }).click()
await scaffold.ctx.settings.update('ui-settings', { enabled: false })
await menu.waitFor({ state: 'detached' })
expect(await livePresets()).toEqual(['ptc'])
await page.getByRole('button', { name: 'PTC mode', exact: true }).click()
await expect.poll(() => menu.getByRole('menuitem').count()).toBe(3)
expect(await menu.getByRole('menuitem', { name: /^PTC mode|^Minimal mode/ }).count()).toBe(0)
await menu.getByRole('menuitem', { name: /^Standard mode/ }).click()
await expect.poll(livePresets, { timeout: 15_000 }).toEqual(['standard'])
await page.reload()
await page.getByRole('button', { name: 'Standard mode', exact: true }).click()
await expect.poll(() => menu.getByRole('menuitem').count()).toBe(3)
await page.keyboard.press('Escape')
await openSettings(page, 'en')
await settings.getByRole('button', { name: 'General', exact: true }).click()
await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('false')
await settings.getByRole('button', { name: 'Agent presets', exact: true }).click()
await expect.poll(visiblePresets).toEqual(['standard', 'cordis', 'custom'])
await settings.getByRole('button', { name: 'New task default: Standard mode', exact: true }).waitFor()
await settings.getByRole('button', { name: 'Let the agent help me create a preset', exact: true }).click()
await settings.waitFor({ state: 'detached' })
await expect.poll(livePresets, { timeout: 15_000 }).toContain('cordis')
expect((await scaffold.ctx.agentPresets.remoteExportList()).presets.find(preset => preset.isDefault)?.id).toBe('standard')
expect(tripwire.pageErrors).toEqual([])
})
+4 -1
View File
@@ -610,7 +610,10 @@ else process.exit(1);
await mkdir(SHOT_DIR, { recursive: true })
await saveFailureShot(page, `screenshots/0908-document-preview/smoke-${process.pid}`)
})
expect(await page.locator('[data-slot="conversation.hero.agentPreset"] button').count()).toBe(0)
await page.locator('[data-slot="conversation.hero.agentPreset"]').getByRole('button', { name: 'Standard mode', exact: true }).click()
await expect.poll(() => page.getByRole('menuitem').count()).toBe(2)
expect(await page.getByRole('menuitem', { name: /^PTC mode|^Minimal mode/ }).count()).toBe(0)
await page.keyboard.press('Escape')
expect(scaffold.ctx.settings.describe().find(row => row.ns === 'ui-settings')?.value).toEqual({ enabled: false })
const settled = scaffold.whenTurnSettled()
const input = page.locator('[data-composer-input]').first()
+2 -2
View File
@@ -24,8 +24,8 @@
en: c063711f8247c240
zh: 5e54758bbaf1670c
/web-client-architecture/conversation-and-presentation:
en: 9e0d6a3dfdbc4c12
zh: 0eea67d515ec56ef
en: 71362f4ac9490bcb
zh: 025507ca615f5e2a
/web-client-architecture/data-paths:
en: a254278432416473
zh: 567c2d3c6a20b304
+1 -1
View File
@@ -55,7 +55,7 @@ This pairing is not a second source of business truth. Host controllers decide d
## Conversation and presentation
Web and desktop share the [Coding Tools preference](../../packages/client/ui-settings/README.md#use-this-package). It controls diagnostic Views, new-session preset selection, changed-file cards, and the builtin HTML preview policy without changing Session records.
Web and desktop share the [Coding Tools preference](../../packages/client/ui-settings/README.md#use-this-package). It controls diagnostic Views, [available preset choices](../../packages/client/ui-agent-preset/README.md), changed-file cards, and the builtin HTML preview policy without changing Session records.
`ui-session` installs the Session scope adapter and publishes `useSessions`, `useSessionStatus`, `useSessionRetainInfo`, `useSession`, `sessionId`, and `useProjection`. `SessionProvider` inherits an outer binding or binds an explicit `SessionReference`, so concurrent subtrees can target different Sessions. Domain adapters add further standard sources without putting React hooks on the model objects.
+1 -1
View File
@@ -55,7 +55,7 @@ Connection 拥有请求 URL 解析、request correlation、`/api` carrier、trus
## Conversation 与 presentation
Web 和桌面端共享[代码工作工具偏好](../../packages/client/ui-settings/README.zh.md#use-this-package)。它控制诊断 View、新会话预设选择、改动文件卡片和内置 HTML 预览策略,不改变 Session 记录。
Web 和桌面端共享[代码工作工具偏好](../../packages/client/ui-settings/README.zh.md#use-this-package)。它控制诊断 View、[可用的预设选项](../../packages/client/ui-agent-preset/README.zh.md)、改动文件卡片和内置 HTML 预览策略,不改变 Session 记录。
`ui-session` 安装 Session scope adapter,并提供 `useSessions`、`useSessionStatus`、`useSessionRetainInfo`、`useSession`、`sessionId` 和 `useProjection`。`SessionProvider` 可以继承外围 binding,也可以绑定显式 `SessionReference`,因此并存子树可以指向不同 Session。领域 adapter 可以继续添加标准 source,但不会把 React hook 放进 model object。
@@ -15,8 +15,8 @@
en: d152484eb41ac6b4
zh: b3ad7867f23dad1c
/deepseek-ai-dsh-client-ui-agent-preset/use-this-package:
en: a50da1a9dfdaf624
zh: faaffc5fbeec446b
en: fb8633d84f7ae9df
zh: 85dddc83f4abe686
/deepseek-ai-dsh-client-ui-agent-preset/understand-the-implementation:
en: 7813f0063c15e241
zh: 6eabc242edfc45ec
+3 -1
View File
@@ -27,7 +27,9 @@ Choose Agent presets and the new-task default in Web, read what each mode does a
Settings shows the built-in and custom card groups with default highlighting and card-body selection; a group without presets is omitted, except the custom group, which keeps its Creator entry on screen. Every card offers “View configuration”, which opens the preset's declared plugin list as read-only YAML in the Loader's own dialect (`!!js` conditions included); a failed preset stays readable because its diagnostic points into that YAML. Escape closes only the viewer and returns focus to its card; leaving Settings clears the viewer, and a late read does not reopen it. The page edits nothing: the Creator entry starts a Creator-mode task that authors or overrides a preset as a bundle, offered while the `cordis` preset is on the roster and a conversation flow exists.
Coding Tools in General Settings decide whether a mode can be chosen at all: with them off the new-session picker disappears, while the settings cards still choose the saved default that keeps composing new tasks and Creator stays available. With Coding Tools on, choosing a healthy default also synchronizes the blank session on the current new-task surface; with them off, a reused blank session keeps its preset. Creator starts a new task using the `cordis` preset.
With Coding Tools off in General Settings, the new-session picker and Settings roster offer Standard, Creator, and custom presets; turning them on also offers PTC and Minimal. Default selection and the Creator entry remain available in both states. Choosing a healthy default also synchronizes the blank session on the current new-task surface. Creator starts a new task using the `cordis` preset.
Turning Coding Tools off clears all unapplied choices through the existing Developer tools subscription. Once the off value is accepted, a host-backed connection resets a saved built-in PTC or Minimal default to Standard. Saved Standard, Creator, and custom defaults (including named PTC or Minimal overrides) stay unchanged. The user can select an available preset again after turning Coding Tools off. Existing sessions, including blank sessions reused by New Session, keep their selected mode; its label remains visible even when that mode is absent from the menu. Turning Coding Tools back on does not restore the old default or cleared choice. A remote connection using in-memory settings does not rewrite the Host's default. If Standard is missing or broken, the default remains unchanged and an error is shown.
Known shipped presets offer mode details and usage examples in a read-only dialog. Its tabs preserve each page's scroll position; closing returns focus to the opening action. Help does not change the new-task default. The default badge replaces the card's group badge, and the preset id appears beside the title. Guide copy and examples belong to this package.
+3 -1
View File
@@ -27,7 +27,9 @@ kind: "package-reference"
设置页显示内置与自定义卡片分组、默认项高亮和点击卡片选择;没有 preset 的分组不显示,但自定义分组会保留其创造入口。每张卡片都提供「查看配置」,以只读 YAML 打开该 preset 声明的插件列表,使用 Loader 自己的方言(含 `!!js` 条件);加载失败的 preset 同样可读,因为其诊断信息正指向这份 YAML。Escape 只关闭查看器并将焦点还给卡片;离开设置页会清空查看器,迟到的读取结果不会重新打开它。本页不编辑任何内容:创造入口启动一个创造模式任务,以 bundle 形式创建或覆盖 preset;当 `cordis` preset 在列表中且存在会话流程时提供。
通用设置中的代码工作工具决定能否选择模式:关闭后新会话选择器消失,而设置卡片仍可选择继续用于新任务的已保存默认值,Creator 入口也保持可用。开启时,选择健康定义作为默认值也会同步当前新任务页面的空白会话;关闭时,被复用的空白会话保留原预设。Creator 入口开启一个使用 `cordis` preset 的新任务。
关闭通用设置中的代码工作工具后,新会话选择器和设置页列表提供标准、创造和自定义预设;开启后还提供 PTC 和极简模式。两种状态下均保留默认值选择和创造入口。选择健康定义作为默认值也会同步当前新任务页面的空白会话。Creator 入口开启一个使用 `cordis` preset 的新任务。
关闭代码工作工具时,沿用现有 Developer tools 订阅清空所有尚未应用的选择。关闭状态被接受后,使用 Host 持久化设置的连接会将已保存的内置 PTC 或极简默认值回退为标准模式。已保存的标准、创造和自定义默认值(包括有名称的 PTC 或极简覆盖项)保持不变。关闭后用户仍可重新选择可用预设。已有会话(包括点击新建会话时复用的空白会话)保留所选模式;即使该模式不在菜单中,其标签仍会显示。重新开启代码工作工具不会恢复原默认值或已清除的选择。使用内存设置的远程连接不会改写 Host 的默认值。标准模式缺失或损坏时,默认值保持不变,并显示错误。
已知的内置预设提供只读的模式说明与使用示例对话框。各页签保留各自的滚动位置;关闭后焦点回到打开它的操作。帮助不会改变新任务默认值。默认徽标取代卡片的分组徽标,预设 id 显示在标题旁。指南文案与示例归本包所有。
@@ -12,7 +12,7 @@
* Picking stages; the choice reaches a session when one becomes current.
*/
import { useEffect, useRef, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import type { ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import {
@@ -22,12 +22,13 @@ import {
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { AgentPresetSeatState } from './seat-store.ts'
import { presetDisplayText } from './locales.ts'
import { requiresCodingTools } from './settings-store.ts'
import css from './AgentPresetSeat.module.css'
/** Registration-side business face for the hero chip. */
export interface AgentPresetSeatInjected {
hooks: {
/** Shared Developer tools preference; off hides the chip. */
/** Shared Developer tools preference; off hides the PTC and Minimal menu choices. */
developerTools: ObservableSnapshot<boolean>
/** Seat snapshot bound by the renderer as useAgentPresetSeat. */
agentPresetSeat: SnapshotStore<AgentPresetSeatState>
@@ -73,8 +74,7 @@ export type AgentPresetSeatProps =
/**
* Render the new-session agent-preset chip.
* @param props - composed slot props.
* @returns The chip, or null outside the main view, with Developer tools off,
* or before the roster provides a preset choice.
* @returns The chip, or null outside the main view or before the roster provides a preset choice.
*/
export function AgentPresetSeat({
sessionId, useSessionRetainInfo, load, select, introduced, useAgentPresetSeat, useDeveloperTools, t,
@@ -88,21 +88,19 @@ export function AgentPresetSeat({
// 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(developerTools)
pickerVisible.current = developerTools
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.
const options = useMemo(
() => state.options.filter(option => developerTools || !requiresCodingTools(option)),
[state.options, developerTools],
)
useEffect(() => {
if (developerTools) return
setOpen(false)
setToast(null)
}, [developerTools])
}, [developerTools, options.length])
const chosen = state.options.find(option => option.id === state.current)
const chosenText = chosen === undefined ? undefined : presetDisplayText(chosen, t)
@@ -131,7 +129,7 @@ export function AgentPresetSeat({
// Nothing to choose between: the deployment composes no presets and every
// session shares the host composition.
if (!main || !developerTools || !ready) return null
if (!main || !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.
@@ -156,9 +154,9 @@ export function AgentPresetSeat({
return (
<>
<Menu
open={open}
open={open && options.length > 0}
onClose={() => { setOpen(false) }}
items={state.options.map((option) => {
items={options.map((option) => {
const text = presetDisplayText(option, t)
return {
id: option.id,
@@ -175,16 +173,16 @@ export function AgentPresetSeat({
selectedId={state.current}
onSelect={(id) => {
setOpen(false)
const picked = state.options.find(option => option.id === id)
const picked = options.find(option => option.id === id)
// The fallback is for the row shape `find` cannot promise; the menu's
// items ARE `state.options`, so an emitted id is always one of them.
// items ARE `options`, so an emitted id is always one of them.
/* v8 ignore next */
const name = picked === undefined ? id : presetDisplayText(picked, t).name
void select(id).then((refusal) => {
// 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 || !pickerVisible.current) return
if (refusal === undefined) return
toastSeq.current += 1
setToast({ seq: toastSeq.current, text: t('switchRefused', { name, reason: refusal }) })
})
@@ -197,9 +195,9 @@ export function AgentPresetSeat({
type="button"
className={css.seat}
aria-haspopup="menu"
aria-expanded={open}
aria-expanded={open && options.length > 0}
title={state.error ?? t('seatHint')}
disabled={state.busy}
disabled={state.busy || options.length === 0}
onClick={() => { setOpen(value => !value) }}
>
<IconAgentPresetOutlineRegular className={introducing ? `${css.seatIcon} ${css.introIcon}` : css.seatIcon} />
@@ -4,10 +4,11 @@ import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import {
Button, IconBrowseOutlineRegular, IconPlusOutlineRegular, Modal, Tag, Tooltip,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type { AgentPresetSectionState } from './section-store.ts'
import { isBuiltInPreset, presetDisplayText } from './locales.ts'
import { requiresCodingTools } from './settings-store.ts'
import { PresetGuideDialog, presetGuide, trapPresetReaderTab, type PresetGuidePage } from './PresetGuideDialog.tsx'
import css from './AgentPresetSection.module.css'
@@ -15,6 +16,8 @@ import css from './AgentPresetSection.module.css'
export interface AgentPresetSectionInjected {
hooks: {
agentPresetSection: SnapshotStore<AgentPresetSectionState>
/** Shared Coding Tools preference; off hides the built-in PTC and Minimal cards. */
developerTools: ObservableSnapshot<boolean>
}
/** Stage the `cordis` preset and start a Creator-mode task; absent without a conversation flow. */
startCreatorDraft?: () => void
@@ -60,9 +63,10 @@ function CardDescription({ text }: { text: string }): ReactNode {
*/
export function AgentPresetSection({
useAgentPresetSection, load, view, closeView, makeDefault, startCreatorDraft,
close: closeSettings, t,
close: closeSettings, useDeveloperTools, t,
}: AgentPresetSectionProps) {
const state = useAgentPresetSection(value => value)
const developerTools = useDeveloperTools(enabled => enabled)
const [guide, setGuide] = useState<{
content: NonNullable<ReturnType<typeof presetGuide>>
page: PresetGuidePage
@@ -99,7 +103,8 @@ export function AgentPresetSection({
<p className={css.intro}>{t('sectionIntro')}</p>
{state.error === null ? null : <p className={css.error} role="alert">{state.error}</p>}
{([true, false] as const).map((builtIn) => {
const rows = state.rows.filter(row => isBuiltInPreset(row) === builtIn)
const rows = state.rows.filter(row => isBuiltInPreset(row) === builtIn
&& (developerTools || !requiresCodingTools(row)))
const entry = builtIn ? null : creatorButton
if (rows.length === 0 && entry === null) return null
return <section key={String(builtIn)} className={css.group}>
@@ -12,9 +12,9 @@
* edited where the roster is visible — the settings section's "make default"
* — so General settings carries no duplicate control for the same field.
*
* Developer tools (General settings) are the single gate over selection: with
* them off the chip disappears and the card actions are disabled, while the
* saved default keeps composing new sessions.
* Coding Tools (General settings) hide PTC and Minimal from the hero menu
* and Settings roster when off. Hidden saved defaults fall back to Standard;
* the existing gate clears staged choices while existing sessions keep their composition.
*/
// Type-only: pulls the Session Controller service merge (ctx.sessions).
@@ -68,6 +68,12 @@ export const inject = [
* @param ctx - the browser plugin context.
*/
export function apply(ctx: ClientContext): void {
const toolsSettings = ctx.configForms.get<{ enabled: boolean }>('ui-settings')
const presetSettings = ctx.configForms.get(AGENT_PRESET_SETTINGS_NS)
let active = true
// Host preferences start at false before their first accepted document.
const codingToolsDisabled = (): boolean => active && toolsSettings.getSnapshot().mode === 'host'
&& toolsSettings.getSnapshot().value?.enabled === false
const controller = new AgentPresetSettingsController(ctx)
const staged: AgentPresetStage = { id: undefined, introduce: false }
const seats = new WeakMapWithValues<SessionBinding, AgentPresetSeatController>()
@@ -122,6 +128,22 @@ export function apply(ctx: ClientContext): void {
ctx.effect(() => ctx.locale.register('settings.agentPreset', { zh, en }), 'ui-agent-preset: settings row dictionaries')
ctx.effect(() => {
let requested = 0
let pending: Promise<void> | undefined
// Settings invalidations may land while a correction is waiting on a
// person's selection. Recheck once that work settles using the latest values.
const reconcile = (): void => {
requested++
pending ??= Promise.resolve().then(async () => {
try {
for (;;) {
const revision = requested
await section.reconcileCodingTools(codingToolsDisabled)
if (!active || requested === revision) return
}
} finally { pending = undefined }
})
}
// The roster reflects live declarations and the default is a settings field, so
// both an external settings edit and a reconnect can move this row.
const refresh = (): void => {
@@ -131,17 +153,23 @@ export function apply(ctx: ClientContext): void {
if (section.store.getSnapshot().status !== 'idle') void section.load()
void unboundSeat.load()
for (const seat of seats.values) void seat.load()
reconcile()
}
const disposers = [
toolsSettings.subscribe(reconcile),
presetSettings.subscribe(reconcile),
ctx.remote.$on('settings/document-updated', (ns) => {
if (ns !== AGENT_PRESET_SETTINGS_NS) return
refresh()
}),
ctx.on('connection/reset', () => {
refresh()
}),
ctx.on('connection/reset', refresh),
]
return () => { for (const dispose of disposers) dispose() }
reconcile()
return async () => {
active = false
for (const dispose of disposers) dispose()
await pending
}
}, 'ui-agent-preset: settings refresh')
// The settings section's conversational authoring entry: stage the
@@ -210,7 +238,7 @@ export function apply(ctx: ClientContext): void {
}
const sectionInjected = (): AgentPresetSectionInjected => ({
hooks: { agentPresetSection: section.store },
hooks: { agentPresetSection: section.store, developerTools: ctx.configForms.developerTools.enabled },
load: () => section.load(),
view: (id: string) => section.view(id),
closeView: () => { section.closeView() },
@@ -25,6 +25,7 @@ export type AgentPresetSettingsKey =
| 'noDescription'
| 'brokenBadge'
| 'switchRefused'
| 'standardUnavailable'
| 'close'
| 'creatorDraft'
@@ -60,6 +61,7 @@ export const en: Record<AgentPresetSettingsKey, string> = {
brokenBadge: 'Failed to load',
switchRefused: 'Could not switch to {name}: {reason}',
standardUnavailable: 'Standard mode is unavailable. Restore it or choose another available mode.',
close: 'Close',
@@ -95,6 +97,7 @@ export const zh: Record<AgentPresetSettingsKey, string> = {
brokenBadge: '加载失败',
switchRefused: '无法切换到「{name}」:{reason}',
standardUnavailable: '标准模式不可用,请恢复该模式或选择其他可用模式。',
close: '关闭',
@@ -13,8 +13,6 @@
import type { Context as ClientContext } from '@deepseek-ai/cordis'
// Type-only: pulls the ctx.remote merge into this program.
import type {} from '@deepseek-ai/dsh-api-remotes/client'
// Type-only: pulls the Developer tools preference (ctx.configForms) into this program.
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
import type { SessionSummary } from '@deepseek-ai/dsh-api-session-controller/client'
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
import type {} from '@deepseek-ai/dsh-agent-preset-registry/types'
@@ -85,11 +83,6 @@ export class AgentPresetSeatController {
this.staged.introduce = false
}
/** Developer tools are the single gate over preset selection. */
private selectionAvailable(): boolean {
return this.ctx.configForms.developerTools.enabled.getSnapshot()
}
/**
* Read the roster and open the chip on the Host-effective default.
* @returns once the snapshot reflects the host.
@@ -103,8 +96,6 @@ export class AgentPresetSeatController {
return
}
const { presets } = roster.value
// A stage outlives the screen that made it; Developer tools may have gone off since.
if (!this.selectionAvailable()) this.clearStage()
this.fallback = presets.find(preset => preset.isDefault)?.id ?? presets[0]?.id ?? ''
const session = this.currentSession()
this.set({
@@ -201,18 +192,12 @@ export class AgentPresetSeatController {
*/
async apply(): Promise<string | undefined> {
if (this.store.getSnapshot().busy) return
const available = this.selectionAvailable()
// A stage made while Developer tools were on must not outlive them.
if (!available) this.clearStage()
const staged = this.staged.id
const session = this.currentSession()
if (staged === undefined) {
const current = session === undefined ? this.fallback : presetOf(session) ?? ''
const shown = this.store.getSnapshot()
// Hiding the chip also withdraws the introduction cue armed for it;
// ordinary list movement leaves that cue to the chip that plays it.
if (!available && shown.introduce) this.set({ current, introduce: false })
else if (current !== shown.current) this.set({ current })
if (current !== shown.current) this.set({ current })
return
}
if (session === undefined) return
@@ -3,7 +3,7 @@ import type { Context } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/dsh-api-remotes/client'
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { AgentPresetRow } from '@deepseek-ai/dsh-agent-preset-registry/types'
import { writeDefaultPreset } from './settings-store.ts'
import { AGENT_PRESET_SETTINGS_NS, requiresCodingTools, writeDefaultPreset } from './settings-store.ts'
/** The read-only composition viewer over one preset. */
export interface PresetView {
@@ -31,6 +31,7 @@ export class AgentPresetSectionController {
/** Observable roster, selection and viewer state. */
readonly store: SnapshotStore<AgentPresetSectionState> = createSnapshotStore(INITIAL)
private loading: Promise<void> | undefined
private pendingSave: Promise<void> | undefined
private viewRequest = 0
constructor(private readonly ctx: Context) {}
@@ -76,8 +77,31 @@ export class AgentPresetSectionController {
async makeDefault(id: string, sync?: (id: string) => Promise<string | undefined>): Promise<void> {
await this.save(() => writeDefaultPreset(this.ctx, id), sync)
}
/** Replace a hidden built-in default after accepted Coding Tools changes.
* @param shouldReset Rechecked after waiting; false when tools are enabled or this owner is disposed.
* @returns Once the conditional save settles; errors remain visible in the section.
*/
async reconcileCodingTools(shouldReset: () => boolean): Promise<void> {
while (this.pendingSave !== undefined) await this.pendingSave
if (!shouldReset()) return
const settings = this.ctx.configForms.get(AGENT_PRESET_SETTINGS_NS).getSnapshot()
if (settings.mode !== 'host' || settings.status !== 'ready' || !settings.writable || settings.revision === undefined) return
await this.load()
if (!shouldReset() || this.store.getSnapshot().status !== 'ready') return
const rows = this.store.getSnapshot().rows
if (!requiresCodingTools(rows.find(row => row.isDefault))) return
if (!rows.some(row => row.id === 'standard' && row.broken === undefined)) {
this.set({ error: this.ctx.locale.bind('settings.agentPreset')('standardUnavailable') })
return
}
await this.save(() => writeDefaultPreset(this.ctx, 'standard', settings.revision))
}
private async save(write: () => Promise<string | undefined>, sync?: (id: string) => Promise<string | undefined>): Promise<void> {
if (this.store.getSnapshot().saving) return
const completion = Promise.withResolvers<void>()
this.pendingSave = completion.promise
this.set({ saving: true, error: null })
try {
const error = await write()
@@ -89,6 +113,10 @@ export class AgentPresetSectionController {
if (error !== undefined) throw new Error(error)
}
} catch (error) { this.set({ error: message(error) }) }
finally { this.set({ saving: false }) }
finally {
this.pendingSave = undefined
this.set({ saving: false })
completion.resolve()
}
}
}
@@ -11,6 +11,7 @@ import type { Context as ClientContext } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/dsh-api-remotes/client'
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { AgentPresetRoster } from '@deepseek-ai/dsh-agent-preset-registry/types'
import { isBuiltInPreset } from './locales.ts'
/** The agent-preset settings namespace on the host wire. */
export const AGENT_PRESET_SETTINGS_NS = 'agent-preset-registry'
@@ -23,13 +24,15 @@ export const AGENT_PRESET_SETTINGS_NS = 'agent-preset-registry'
* the host resolves at session creation.
* @param ctx - the browser plugin context carrying the Remote namespaces.
* @param id - the preset to make default.
* @param expectedRevision - optional fence for an automatic correction based on a previous settings read.
* @returns the failure message, or undefined once the write landed.
*/
export async function writeDefaultPreset(
ctx: ClientContext,
id: string,
expectedRevision?: number,
): Promise<string | undefined> {
const response = await ctx.remote.settings.update(AGENT_PRESET_SETTINGS_NS, { selectedDefault: id }, undefined)
const response = await ctx.remote.settings.update(AGENT_PRESET_SETTINGS_NS, { selectedDefault: id }, expectedRevision)
return response.ok ? undefined : response.error.message
}
@@ -43,6 +46,14 @@ export interface AgentPresetOption {
description?: string
}
/** Whether a shipped preset requires the Coding Tools preference.
* @param preset Roster entry; named custom overrides keep their own behavior.
* @returns True only for the built-in PTC and Minimal presets.
*/
export function requiresCodingTools(preset: AgentPresetOption | undefined): boolean {
return preset !== undefined && isBuiltInPreset(preset) && (preset.id === 'ptc' || preset.id === 'minimal')
}
/** One roster entry exactly as the host reports it. */
export type RosterPreset = AgentPresetRoster['presets'][number]
@@ -22,13 +22,15 @@ import type { AgentPresetSectionInjected } from '../src/client/AgentPresetSectio
import { AgentPresetSeat } from '../src/client/AgentPresetSeat.tsx'
import type { AgentPresetSeatInjected } from '../src/client/AgentPresetSeat.tsx'
import { AgentPresetSeatController } from '../src/client/seat-store.ts'
import { AgentPresetSectionController } from '../src/client/section-store.ts'
import { apply as hostApply } from '../src/index.ts'
import type { AgentPresetRow } from '@deepseek-ai/dsh-agent-preset-registry/types'
// These specs assert the shipped Chinese copy. The lane has no jsdom `window`,
// so browser-language detection never runs and a fresh LocaleRuntime opens on
// FALLBACK_LOCALE (en); each bench stages zh explicitly on the locale instead.
/** The Developer tools half of `ctx.configForms`, which gates all selection. */
/** The Developer tools half of `ctx.configForms`, which filters preset choices. */
function developerTools(enabled = true): { configForms: { developerTools: { enabled: ObservableSnapshot<boolean> } } } {
return { configForms: { developerTools: { enabled: createSnapshotStore(enabled) } } }
}
@@ -66,31 +68,36 @@ async function bench(options: {
failSettingsUpdate?: boolean
selectGate?: Promise<void>
settingsRosterGate?: Promise<undefined>
describeGate?: Promise<undefined>
presets?: AgentPresetRow[]
developerToolsEnabled?: boolean
memory?: boolean
} = {}) {
const ctx = new Context()
// The host's answer, mutable so a spec can move the default the way the
// settings surface does and watch who re-reads it.
let ROSTER: typeof ROSTER_ONE | typeof ROSTER_MOVED | typeof ROSTER_AUTHORED = ROSTER_ONE
let ROSTER: { ok: true; value: { presets: AgentPresetRow[] } } = options.presets === undefined
? ROSTER_ONE : { ok: true, value: { presets: options.presets } }
const moveDefault = (): void => { ROSTER = ROSTER_MOVED }
await ctx.plugin(SlotRegistry).await()
const locale = new LocaleRuntime(ctx)
locale.setLocale('zh')
ctx.provide('locale', locale)
const calls: string[] = []
let savedDefault = 'standard'
let developerToolsEnabled = true
let savedDefault = ROSTER.value.presets.find(row => row.isDefault)?.id ?? 'standard'
let presetRevision = 0
let developerToolsEnabled = options.developerToolsEnabled ?? true
let settingsSaved = false
const settingsRosterStarted = Promise.withResolvers<undefined>()
// The row reads `describe` to learn whether this browser may write at all,
// and its default write is the one op this spec records.
const settings = {
describe: () => Promise.resolve({
describe: () => Promise.resolve(options.describeGate).then(() => ({
ok: true as const,
value: {
writable: true,
hasDocument: true,
// The shared Developer tools preference the surfaces read: this browser
// has it on, so the roster is editable.
// The accepted Developer tools preference shared by both preset surfaces.
namespaces: [{
ns: 'ui-settings',
schema: { type: 'object', dict: { enabled: { type: 'boolean' } } },
@@ -99,9 +106,17 @@ async function bench(options: {
applies: 'live',
secrets: [],
revision: 0,
}, {
ns: 'agent-preset-registry',
schema: { type: 'object', dict: { selectedDefault: { type: 'string', meta: {} } } },
value: { selectedDefault: savedDefault },
autoGenerate: false,
applies: 'live',
secrets: [],
revision: presetRevision,
}],
},
}),
})),
update: (_ns: string, patch: { selectedDefault?: unknown }) => {
calls.push(`settings:${JSON.stringify(patch)}`)
if (options.failSettingsUpdate === true) {
@@ -113,13 +128,16 @@ async function bench(options: {
if (typeof patch.selectedDefault === 'string') {
savedDefault = patch.selectedDefault
}
ROSTER = savedDefault === 'minimal' ? ROSTER_MOVED : ROSTER_ONE
presetRevision += 1
ROSTER = options.presets === undefined ? savedDefault === 'minimal' ? ROSTER_MOVED : ROSTER_ONE
: { ok: true, value: { presets: ROSTER.value.presets.map(row => ({ ...row, isDefault: row.id === savedDefault })) } }
settingsSaved = true
return Promise.resolve({ ok: true as const, value: {} })
},
}
const remote = new TestRemote(ctx, { settings })
if (options.memory === true) remote.$host = { home: undefined, isLoopback: false }
// The roster and the switch are the AgentPresets Remote namespace; the
// shared double carries no generated namespaces, so this spec stages its
// own. Registered twice on purpose: the nested key satisfies the plugin's
@@ -153,8 +171,12 @@ async function bench(options: {
ctx.provide('remote.agentPresets', agentPresets as never)
Object.assign(remote, { agentPresets })
await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await()
// Every spec below starts from the accepted "Developer tools on" value.
await vi.waitFor(() => { expect(ctx.configForms.developerTools.enabled.getSnapshot()).toBe(true) })
if (options.describeGate === undefined) {
await vi.waitFor(() => {
expect(ctx.configForms.developerTools.enabled.getSnapshot()).toBe(options.memory === true ? true : developerToolsEnabled)
})
if (options.memory !== true) await vi.waitFor(() => { expect(ctx.configForms.get('ui-settings').getSnapshot().status).toBe('ready') })
}
/** Publish a new Developer tools choice the way the settings mirror does. */
const setDeveloperTools = async (enabled: boolean): Promise<void> => {
developerToolsEnabled = enabled
@@ -351,14 +373,17 @@ describe('ui-agent-preset apply', () => {
await vi.waitFor(() => { expect(slots.entries('settings.section')).toHaveLength(1) })
})
it('hands the section its own store and default write', async () => {
const { ctx, slots } = await bench()
it('hands the section its own store, live Coding Tools preference, and default write', async () => {
const { ctx, slots, setDeveloperTools } = await bench()
ctx.provide('sessions', sessionsDouble(ctx, { byId: {} }) as never)
declareRoot(slots)
await ctx.plugin({ inject: [...inject], apply }).await()
const section = (slots.entries('settings.section')[0]!.inject as unknown as () => AgentPresetSectionInjected)()
expect(section.hooks.developerTools).toBe(ctx.configForms.developerTools.enabled)
await setDeveloperTools(false)
expect(section.hooks.developerTools.getSnapshot()).toBe(false)
await section.load()
await section.makeDefault('standard')
expect(section.hooks.agentPresetSection.getSnapshot().rows)
@@ -735,65 +760,264 @@ describe('ui-agent-preset apply', () => {
expect(calls.filter(call => call === 'select:minimal')).toHaveLength(spent)
})
it('drops a cross-screen stage when Developer tools turn off, and releases its subscription with the fiber', async () => {
const { ctx, slots, calls, setDeveloperTools } = await bench()
declareRoot(slots)
const conversation = declareConversation(slots)
ctx.provide('conversation', {} as never)
const state: {
current?: string
byId: Record<string, { id: string; blank: boolean; projectionValues?: { agentPreset?: string | null } }>
} = {
current: 's0',
byId: {
s0: { id: 's0', blank: false, projectionValues: { agentPreset: 'standard' } },
s1: { id: 's1', blank: true, projectionValues: { agentPreset: 'standard' } },
},
it.each(['standard', 'cordis', 'mine', 'ptc', 'minimal']
.flatMap(preset => ['unbound', 'inactive bound'].map(source => ({ preset, source }))))(
'clears the staged $preset choice on Coding Tools off without changing the Session ($source)', async ({ preset, source }) => {
const { ctx, slots, calls, setDeveloperTools } = await bench({ presets: [
...ROSTER_MOVED.value.presets, { id: 'ptc', isDefault: false },
{ id: 'cordis', isDefault: false }, { id: 'mine', name: 'Mine', isDefault: false },
] })
declareRoot(slots)
declareConversation(slots)
ctx.provide('conversation', {} as never)
const state: {
current?: string
byId: Record<string, { id: string; blank: boolean; projectionValues?: { agentPreset?: string | null } }>
} = {
byId: { s1: { id: 's1', blank: true, projectionValues: { agentPreset: 'minimal' } } },
}
const sessions = sessionsDouble(ctx, state)
ctx.provide('sessions', sessions as never)
ctx.provide('uiWorkspace', uiWorkspaceDouble() as never)
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiWorkspace'], apply }).await()
const injectSeat = slots.entries('conversation.hero.agentPreset')[0]!
.inject as (sessionId?: SessionId) => AgentPresetSeatInjected & Record<string, unknown>
const chip = injectSeat(source === 'unbound' ? undefined : SessionId('s1'))
await chip.load()
await chip.select(preset)
await setDeveloperTools(false)
await vi.waitFor(() => { expect(calls.filter(call => call.startsWith('settings:'))).toEqual(['settings:{"selectedDefault":"standard"}']) })
await chip.load()
expect(chip.hooks.agentPresetSeat.getSnapshot().current).toBe('standard')
expect(calls.filter(call => call.startsWith('select:'))).toEqual([])
state.current = 's1'
const bound = injectSeat(SessionId('s1'))
sessions.notify()
await bound.load()
expect(calls.filter(call => call.startsWith('select:'))).toEqual([])
expect(bound.hooks.agentPresetSeat.getSnapshot().current).toBe('minimal')
if (['standard', 'cordis', 'mine'].includes(preset)) {
await bound.select(preset)
expect(calls.filter(call => call.startsWith('select:'))).toEqual([`select:${preset}`])
expect(bound.hooks.agentPresetSeat.getSnapshot().current).toBe(preset)
}
await ctx.fiber.dispose()
},
)
it('does not cancel an in-flight preset selection when Coding Tools turn off', async () => {
const selection = Promise.withResolvers<undefined>()
const b = await bench({ selectGate: selection.promise, presets: ROSTER_MOVED.value.presets })
try {
declareRoot(b.slots)
declareConversation(b.slots)
b.ctx.provide('conversation', {} as never)
b.ctx.provide('sessions', sessionsDouble(b.ctx, {
current: 's1', byId: { s1: { id: 's1', blank: true, projectionValues: { agentPreset: 'standard' } } },
}) as never)
b.ctx.provide('uiWorkspace', uiWorkspaceDouble() as never)
await b.ctx.plugin({ inject: [...inject], apply }).await()
const chip = (b.slots.entries('conversation.hero.agentPreset')[0]!
.inject as (sessionId: SessionId) => AgentPresetSeatInjected & Record<string, unknown>)(SessionId('s1'))
await chip.load()
const selecting = chip.select('minimal')
expect(chip.hooks.agentPresetSeat.getSnapshot().busy).toBe(true)
await b.setDeveloperTools(false)
await vi.waitFor(() => { expect(b.calls.filter(call => call.startsWith('settings:'))).toEqual(['settings:{"selectedDefault":"standard"}']) })
expect(b.calls.filter(call => call.startsWith('select:'))).toEqual(['select:minimal'])
selection.resolve(undefined)
await selecting
expect(chip.hooks.agentPresetSeat.getSnapshot()).toMatchObject({ current: 'minimal', busy: false })
expect(b.calls.filter(call => call.startsWith('select:'))).toEqual(['select:minimal'])
} finally {
selection.resolve(undefined)
await b.ctx.fiber.dispose()
}
const sessions = sessionsDouble(ctx, state)
ctx.provide('sessions', sessions as never)
ctx.provide('uiWorkspace', uiWorkspaceDouble() as never)
// The gate's seat sweep runs over live Provider bindings; s1's binding is
// not the main view yet, so its seat keeps a stage it cannot apply.
let released = 0
const dispatch = ctx.configForms.developerTools.enabled.subscribe.bind(ctx.configForms.developerTools.enabled)
vi.spyOn(ctx.configForms.developerTools.enabled, 'subscribe').mockImplementation((listener) => {
const dispose = dispatch(listener)
return () => { released += 1; dispose() }
})
it('does not reset a default during bootstrap or after accepting Coding Tools on', async () => {
const described = Promise.withResolvers<undefined>()
const b = await bench({ describeGate: described.promise, presets: ROSTER_MOVED.value.presets })
b.ctx.provide('sessions', sessionsDouble(b.ctx, { byId: {} }) as never)
declareRoot(b.slots)
await b.ctx.plugin({ inject: [...inject], apply }).await()
expect(b.ctx.configForms.developerTools.enabled.getSnapshot()).toBe(false)
await Promise.resolve()
expect(b.calls.filter(call => call.startsWith('settings:'))).toEqual([])
described.resolve(undefined)
await vi.waitFor(() => { expect(b.ctx.configForms.developerTools.enabled.getSnapshot()).toBe(true) })
expect(b.calls.filter(call => call.startsWith('settings:'))).toEqual([])
await b.ctx.fiber.dispose()
})
it.each(['ptc', 'minimal'])('resets an accepted disabled %s default once, without restoring it on enable', async (id) => {
const b = await bench({ developerToolsEnabled: false, presets: [
{ id: 'standard', isDefault: false }, { id, isDefault: true },
] })
b.ctx.provide('sessions', sessionsDouble(b.ctx, { byId: {} }) as never)
declareRoot(b.slots)
await b.ctx.plugin({ inject: [...inject], apply }).await()
await vi.waitFor(() => { expect(b.calls.filter(call => call.startsWith('settings:'))).toEqual(['settings:{"selectedDefault":"standard"}']) })
const section = (b.slots.entries('settings.section')[0]!.inject as () => AgentPresetSectionInjected & Record<string, unknown>)()
await vi.waitFor(() => { expect(section.hooks.agentPresetSection.getSnapshot().rows.find(row => row.isDefault)?.id).toBe('standard') })
await b.setDeveloperTools(true)
expect(b.calls.filter(call => call.startsWith('settings:'))).toEqual(['settings:{"selectedDefault":"standard"}'])
expect(section.hooks.agentPresetSection.getSnapshot().rows.find(row => row.isDefault)?.id).toBe('standard')
await b.ctx.fiber.dispose()
})
it.each([['standard', undefined], ['cordis', undefined], ['mine', 'Mine'], ['ptc', 'Custom PTC'], ['minimal', 'Custom Minimal']] as const)(
'keeps the allowed default %s (%s) when Coding Tools are disabled', async (id, name) => {
const b = await bench({ developerToolsEnabled: false, presets: [
{ id, isDefault: true, ...(name === undefined ? {} : { name }) },
...(id === 'standard' ? [] : [{ id: 'standard', isDefault: false }]),
] })
b.ctx.provide('sessions', sessionsDouble(b.ctx, { byId: {} }) as never)
declareRoot(b.slots)
await b.ctx.plugin({ inject: [...inject], apply }).await()
const section = (b.slots.entries('settings.section')[0]!.inject as () => AgentPresetSectionInjected & Record<string, unknown>)()
await section.load()
await Promise.resolve()
expect(b.calls.filter(call => call.startsWith('settings:'))).toEqual([])
expect(section.hooks.agentPresetSection.getSnapshot().rows.find(row => row.isDefault)?.id).toBe(id)
await b.ctx.fiber.dispose()
},
)
it('resets an existing hidden default when the accepted preference turns off', async () => {
const b = await bench({ presets: ROSTER_MOVED.value.presets })
b.ctx.provide('sessions', sessionsDouble(b.ctx, { byId: {} }) as never)
declareRoot(b.slots)
await b.ctx.plugin({ inject: [...inject], apply }).await()
expect(b.calls.filter(call => call.startsWith('settings:'))).toEqual([])
await b.setDeveloperTools(false)
expect(b.ctx.configForms.get('agent-preset-registry').getSnapshot()).toMatchObject({ status: 'ready', mode: 'host', writable: true, revision: 0 })
await vi.waitFor(() => { expect(b.calls.filter(call => call.startsWith('settings:'))).toEqual(['settings:{"selectedDefault":"standard"}']) })
await b.ctx.fiber.dispose()
})
it('corrects a hidden default when accepted settings arrive as the previous reconciliation settles', async () => {
const b = await bench({ presets: ROSTER_MOVED.value.presets })
b.ctx.provide('sessions', sessionsDouble(b.ctx, { byId: {} }) as never)
declareRoot(b.slots)
const answer = await b.ctx.remote.settings.describe()
if (!answer.ok) throw new Error(answer.error.message)
const describe = vi.spyOn(b.ctx.remote.settings, 'describe').mockResolvedValue({
...answer,
value: { ...answer.value, namespaces: answer.value.namespaces.map(row => row.ns === 'ui-settings'
? { ...row, value: { enabled: false } } : row) },
})
const feature = ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiWorkspace'], apply })
const disabled = Promise.withResolvers<undefined>()
const reconcile = vi.spyOn(AgentPresetSectionController.prototype, 'reconcileCodingTools')
.mockImplementationOnce(function (this: AgentPresetSectionController, shouldReset) {
reconcile.mockRestore()
const result = this.reconcileCodingTools(shouldReset)
// The real settings mirror publishes its already-answered read while
// the prior reconciliation's promise is settling.
void result.then(() => { void b.setDeveloperTools(false).then(() => { disabled.resolve(undefined) }, disabled.reject) })
return result
})
try {
await b.ctx.plugin({ inject: [...inject], apply }).await()
await disabled.promise
expect(b.calls.filter(call => call.startsWith('settings:'))).toEqual(['settings:{"selectedDefault":"standard"}'])
} finally {
await b.ctx.fiber.dispose()
reconcile.mockRestore()
describe.mockRestore()
}
})
it.each([['cordis', true], ['mine', true], ['ptc', true], ['minimal', true], ['ptc', false], ['minimal', false]] as const)(
'resets only the default while the current task uses %s (blank: %s)', async (preset, blank) => {
const b = await bench({ presets: [
{ id: 'standard', isDefault: false }, { id: 'minimal', isDefault: true }, { id: 'ptc', isDefault: false },
{ id: 'cordis', isDefault: false }, { id: 'mine', name: 'Mine', isDefault: false },
] })
declareRoot(b.slots)
declareConversation(b.slots)
b.ctx.provide('conversation', {} as never)
b.ctx.provide('sessions', sessionsDouble(b.ctx, {
current: 'blank', byId: { blank: { id: 'blank', blank, projectionValues: { agentPreset: preset } } },
}) as never)
b.ctx.provide('uiWorkspace', uiWorkspaceDouble() as never)
await b.ctx.plugin({ inject: [...inject], apply }).await()
const chip = (b.slots.entries('conversation.hero.agentPreset')[0]!.inject as (id: SessionId) => AgentPresetSeatInjected & Record<string, unknown>)(SessionId('blank'))
await chip.load()
expect(chip.hooks.agentPresetSeat.getSnapshot().current).toBe(preset)
await b.setDeveloperTools(false)
await vi.waitFor(() => { expect(b.calls.filter(call => call.startsWith('settings:'))).toEqual(['settings:{"selectedDefault":"standard"}']) })
await chip.load()
expect(b.calls.filter(call => call.startsWith('select:'))).toEqual([])
expect(chip.hooks.agentPresetSeat.getSnapshot().current).toBe(preset)
if (blank) {
const section = (b.slots.entries('settings.section')[0]!.inject as () => AgentPresetSectionInjected & Record<string, unknown>)()
await section.makeDefault('cordis')
expect(b.calls.filter(call => call.startsWith('select:'))).toEqual(preset === 'cordis' ? [] : ['select:cordis'])
expect(chip.hooks.agentPresetSeat.getSnapshot().current).toBe('cordis')
}
await b.ctx.fiber.dispose()
},
)
it('does not correct a default when settings first arrive after the feature is disposed', async () => {
const described = Promise.withResolvers<undefined>()
const b = await bench({ describeGate: described.promise, developerToolsEnabled: false, presets: ROSTER_MOVED.value.presets })
b.ctx.provide('sessions', sessionsDouble(b.ctx, { byId: {} }) as never)
declareRoot(b.slots)
const feature = b.ctx.plugin({ inject: [...inject], apply })
await feature.await()
const injectSeat = slots.entries('conversation.hero.agentPreset')[0]!
.inject as (sessionId?: SessionId) => AgentPresetSeatInjected & Record<string, unknown>
const chip = injectSeat(SessionId('s1'))
await chip.load()
await chip.select('minimal')
expect(chip.hooks.agentPresetSeat.getSnapshot()).toMatchObject({ current: 'minimal', introduce: false })
// The preference turns off with no seat load, so only the gate's own sweep
// can reconcile this live bound seat back to the roster default.
await setDeveloperTools(false)
await vi.waitFor(() => {
expect(chip.hooks.agentPresetSeat.getSnapshot()).toMatchObject({ current: 'standard', introduce: false })
})
expect(calls.filter(call => call.startsWith('select:'))).toEqual([])
// Turning back on must not resurrect the dropped stage when this blank
// Session becomes the main view's current one.
await setDeveloperTools(true)
state.current = 's1'
sessions.notify()
await chip.load()
expect(calls.filter(call => call.startsWith('select:'))).toEqual([])
expect(chip.hooks.agentPresetSeat.getSnapshot().current).toBe('standard')
const settled = chip.hooks.agentPresetSeat.getSnapshot()
await feature.dispose()
await setDeveloperTools(false)
expect(chip.hooks.agentPresetSeat.getSnapshot()).toBe(settled)
expect(released).toBe(1)
conversation()
described.resolve(undefined)
await vi.waitFor(() => { expect(b.ctx.configForms.get('ui-settings').getSnapshot().status).toBe('ready') })
expect(b.calls.filter(call => call.startsWith('settings:'))).toEqual([])
expect(b.calls.filter(call => call.startsWith('select:'))).toEqual([])
await b.ctx.fiber.dispose()
})
it('exposes a refused correction without repeatedly writing the same default', async () => {
const b = await bench({ failSettingsUpdate: true, presets: ROSTER_MOVED.value.presets })
b.ctx.provide('sessions', sessionsDouble(b.ctx, { byId: {} }) as never)
declareRoot(b.slots)
await b.ctx.plugin({ inject: [...inject], apply }).await()
const section = (b.slots.entries('settings.section')[0]!.inject as () => AgentPresetSectionInjected & Record<string, unknown>)()
await b.setDeveloperTools(false)
await vi.waitFor(() => { expect(section.hooks.agentPresetSection.getSnapshot().error).toBe('settings write disconnected') })
expect(b.calls.filter(call => call.startsWith('settings:'))).toEqual(['settings:{"selectedDefault":"standard"}'])
expect(section.hooks.agentPresetSection.getSnapshot().rows.find(row => row.isDefault)?.id).toBe('minimal')
await b.ctx.fiber.dispose()
})
it.each(['ptc', 'minimal'])('clears a browser-local staged %s choice without rewriting the Host default', async (preset) => {
const b = await bench({ memory: true, presets: [
...ROSTER_MOVED.value.presets, { id: 'ptc', isDefault: false },
] })
declareRoot(b.slots)
declareConversation(b.slots)
b.ctx.provide('conversation', {} as never)
const state: { current?: string; byId: Record<string, { id: string; blank: boolean }> } = {
byId: { s1: { id: 's1', blank: true } },
}
const sessions = sessionsDouble(b.ctx, state)
b.ctx.provide('sessions', sessions as never)
b.ctx.provide('uiWorkspace', uiWorkspaceDouble() as never)
await b.ctx.plugin({ inject: [...inject], apply }).await()
const injectSeat = b.slots.entries('conversation.hero.agentPreset')[0]!
.inject as (sessionId?: SessionId) => AgentPresetSeatInjected & Record<string, unknown>
const chip = injectSeat()
await chip.load()
await chip.select(preset)
await b.ctx.configForms.developerTools.setEnabled(false)
const section = (b.slots.entries('settings.section')[0]!.inject as () => AgentPresetSectionInjected & Record<string, unknown>)()
await section.load()
expect(section.hooks.agentPresetSection.getSnapshot().rows.find(row => row.isDefault)?.id).toBe('minimal')
expect(b.calls.filter(call => call.startsWith('settings:'))).toEqual([])
state.current = 's1'
await injectSeat(SessionId('s1')).load()
sessions.notify()
expect(b.calls.filter(call => call.startsWith('select:'))).toEqual([])
await b.ctx.fiber.dispose()
})
it('loads the header label from the shared roster store', async () => {
@@ -69,7 +69,7 @@ function renderSeat(
t: translate,
} as AgentPresetSeatProps
render(<AgentPresetSeat {...props} />)
return { ...actions, developerTools }
return { ...actions, developerTools, store }
}
function renderLabel(
@@ -93,10 +93,52 @@ function renderLabel(
}
describe('the new-session chip', () => {
it('renders nothing while Developer tools are off', () => {
renderSeat({}, undefined, undefined, false)
it.each([false, true])('offers Standard, Creator and custom presets with Developer tools %s', (enabled) => {
const actions = renderSeat({ options: [
{ id: 'standard' }, { id: 'ptc' }, { id: 'minimal' }, { id: 'cordis' }, { id: 'mine' },
] }, undefined, undefined, enabled)
expect(screen.queryByRole('button')).toBeNull()
fireEvent.click(screen.getByRole('button'))
expect(screen.getAllByRole('menuitem')).toHaveLength(enabled ? 5 : 3)
expect(screen.getByRole('menuitem', { name: /^Standard mode/ })).toBeTruthy()
expect(screen.getByRole('menuitem', { name: /^mine/ })).toBeTruthy()
expect(screen.queryByRole('menuitem', { name: /^PTC mode/ }) !== null).toBe(enabled)
expect(screen.queryByRole('menuitem', { name: /^Minimal mode/ }) !== null).toBe(enabled)
fireEvent.click(screen.getByRole('menuitem', { name: /^Creator mode/ }))
expect(actions.select).toHaveBeenCalledWith('cordis')
})
it('keeps a named custom preset that overrides a development preset id', () => {
renderSeat({ options: [{ id: 'ptc', name: 'My workflow' }] }, undefined, undefined, false)
fireEvent.click(screen.getByRole('button'))
expect(screen.getByRole('menuitem', { name: /^My workflow/ })).toBeTruthy()
})
it('keeps the current mode visible without opening a picker when all options are hidden', () => {
const actions = renderSeat({ current: 'minimal', options: [{ id: 'ptc' }, { id: 'minimal' }] }, undefined, undefined, false)
const trigger = screen.getByRole<HTMLButtonElement>('button', { name: en.presetMinimalName })
expect(trigger.disabled).toBe(true)
fireEvent.click(trigger)
expect(trigger.getAttribute('aria-expanded')).toBe('false')
expect(screen.queryByRole('menu')).toBeNull()
expect(actions.select).not.toHaveBeenCalled()
})
it('closes a picker when its last visible option disappears and keeps it closed when options return', () => {
const options = [{ id: 'minimal' }, { id: 'mine' }]
const actions = renderSeat({ current: 'minimal', options }, undefined, undefined, false)
const trigger = screen.getByRole<HTMLButtonElement>('button', { name: en.presetMinimalName })
fireEvent.click(trigger)
expect(screen.getByRole('menuitem', { name: /^mine/ })).toBeTruthy()
act(() => { actions.store.set({ ...actions.store.getSnapshot(), options: [{ id: 'ptc' }, { id: 'minimal' }] }) })
expect(trigger.disabled).toBe(true)
expect(trigger.getAttribute('aria-expanded')).toBe('false')
expect(screen.queryByRole('menu')).toBeNull()
act(() => { actions.store.set({ ...actions.store.getSnapshot(), options }) })
expect(trigger.disabled).toBe(false)
expect(trigger.getAttribute('aria-expanded')).toBe('false')
expect(screen.queryByRole('menu')).toBeNull()
expect(actions.select).not.toHaveBeenCalled()
})
it('renders only for a Session retained by the main view', () => {
@@ -137,7 +179,7 @@ describe('the new-session chip', () => {
fireEvent.click(screen.getByRole('button'))
expect(screen.getByText(en.presetStandardDescription)).toBeTruthy()
act(() => { actions.developerTools.set(false) })
expect(screen.queryByRole('button')).toBeNull()
expect(screen.getByRole('button').getAttribute('aria-expanded')).toBe('false')
expect(screen.queryByText(en.presetStandardDescription)).toBeNull()
expect(actions.select).not.toHaveBeenCalled()
act(() => { actions.developerTools.set(true) })
@@ -1,13 +1,26 @@
import { describe, expect, it, vi } from 'vitest'
import { AgentPresetSectionController } from '../src/client/section-store.ts'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { ConfigFormSnapshot } from '@deepseek-ai/dsh-client-ui-settings/client'
import type { AgentPresetRow } from '@deepseek-ai/dsh-agent-preset-registry/types'
import { en } from '../src/client/locales.ts'
function fixture() {
const form = createSnapshotStore<ConfigFormSnapshot<{ selectedDefault: string }>>({
status: 'ready', mode: 'host', writable: true, revision: 7, value: { selectedDefault: 'minimal' }, base: {}, user: {},
})
const remote = { agentPresets: {
list: vi.fn(async () => ({ ok: true as const, value: { presets: [{ id: 'standard', isDefault: true }] } })),
list: vi.fn(async (): Promise<{ ok: true; value: { presets: AgentPresetRow[] } }> => ({
ok: true, value: { presets: [{ id: 'standard', isDefault: true }] },
})),
read: vi.fn(async (id: string) => ({ ok: true as const, value: { agentPreset: id, name: 'Standard', content: '- name: fs\n' } })),
}, settings: { update: vi.fn(async () => ({ ok: true as const, value: {} })) } }
const controller = new AgentPresetSectionController({ remote } as never)
return { remote, controller, state: () => controller.store.getSnapshot() }
}, settings: {
update: vi.fn(async (_ns: string, _patch: { selectedDefault: string }, _revision?: number) => ({ ok: true as const, value: {} })),
} }
const controller = new AgentPresetSectionController({ remote, configForms: { get: () => form },
locale: { bind: () => (key: keyof typeof en) => en[key] },
} as never)
return { remote, controller, form, state: () => controller.store.getSnapshot() }
}
describe('the preset roster', () => {
@@ -99,3 +112,98 @@ describe('the preset roster', () => {
expect(sync).not.toHaveBeenCalled()
})
})
describe('Coding Tools default reconciliation', () => {
function setup(defaultId = 'minimal', name?: string) {
const f = fixture()
let rows: AgentPresetRow[] = [
{ id: 'standard', isDefault: defaultId === 'standard' },
...(defaultId === 'standard' ? [] : [{ id: defaultId, isDefault: true, ...(name === undefined ? {} : { name }) }]),
]
f.remote.agentPresets.list.mockImplementation(async () => ({ ok: true, value: { presets: rows } }))
f.remote.settings.update.mockImplementation(async (_ns, patch) => {
rows = rows.map(row => ({ ...row, isDefault: row.id === patch.selectedDefault }))
return { ok: true, value: {} }
})
return { ...f, rows: () => rows, setRows: (next: AgentPresetRow[]) => { rows = next } }
}
it.each(['ptc', 'minimal'])('saves Standard instead of built-in %s with the accepted revision', async (id) => {
const f = setup(id)
await f.controller.reconcileCodingTools(() => true)
expect(f.remote.settings.update).toHaveBeenCalledExactlyOnceWith('agent-preset-registry', { selectedDefault: 'standard' }, 7)
expect(f.state().rows.find(row => row.isDefault)?.id).toBe('standard')
expect(f.state().error).toBeNull()
await f.controller.reconcileCodingTools(() => true)
expect(f.remote.settings.update).toHaveBeenCalledOnce()
})
it.each([['standard', undefined], ['cordis', undefined], ['custom', 'Custom'], ['ptc', 'My PTC'], ['minimal', 'My Minimal']] as const)(
'preserves the allowed default %s (%s)', async (id, name) => {
const f = setup(id, name)
await f.controller.reconcileCodingTools(() => true)
expect(f.remote.settings.update).not.toHaveBeenCalled()
expect(f.rows().find(row => row.isDefault)?.id).toBe(id)
},
)
it.each([
{ status: 'loading' as const }, { status: 'unavailable' as const },
{ mode: 'memory' as const }, { writable: false }, { revision: undefined },
])('does not write without a writable accepted Host form: %j', async (patch) => {
const f = setup()
f.form.set({ ...f.form.getSnapshot(), ...patch })
await f.controller.reconcileCodingTools(() => true)
expect(f.remote.settings.update).not.toHaveBeenCalled()
})
it.each([false, true])('waits for a pending default write and checks the latest toggle (re-enabled: %s)', async (reEnabled) => {
const f = setup('standard')
f.setRows([{ id: 'standard', isDefault: true }, { id: 'minimal', isDefault: false }])
const reply = Promise.withResolvers<undefined>()
f.remote.settings.update.mockImplementationOnce(async () => {
await reply.promise
f.setRows([{ id: 'standard', isDefault: false }, { id: 'minimal', isDefault: true }])
return { ok: true, value: {} }
})
const pending = f.controller.makeDefault('minimal')
let off = true
const reconcile = f.controller.reconcileCodingTools(() => off)
expect(f.remote.settings.update).toHaveBeenCalledOnce()
if (reEnabled) off = false
reply.resolve(undefined)
await Promise.all([pending, reconcile])
expect(f.remote.settings.update).toHaveBeenCalledTimes(reEnabled ? 1 : 2)
expect(f.state().rows.find(row => row.isDefault)?.id).toBe(reEnabled ? 'minimal' : 'standard')
})
it('rechecks the toggle after a roster read', async () => {
const f = setup()
const roster = Promise.withResolvers<{ ok: true; value: { presets: AgentPresetRow[] } }>()
f.remote.agentPresets.list.mockImplementationOnce(() => roster.promise)
let off = true
const pending = f.controller.reconcileCodingTools(() => off)
off = false
roster.resolve({ ok: true, value: { presets: f.rows() } })
await pending
expect(f.remote.settings.update).not.toHaveBeenCalled()
})
it.each([false, true])('reports unavailable Standard without a default write (broken: %s)', async (broken) => {
const f = setup()
f.setRows([{ id: 'minimal', isDefault: true }, ...(broken ? [{ id: 'standard', isDefault: false, broken: 'Missing plugin' }] : [])])
await f.controller.reconcileCodingTools(() => true)
expect(f.remote.settings.update).not.toHaveBeenCalled()
expect(f.state().error).toBe(en.standardUnavailable)
expect(f.state().rows.find(row => row.isDefault)?.id).toBe('minimal')
})
it('reports one refused reset without hiding the actual default or retrying itself', async () => {
const f = setup()
f.remote.settings.update.mockResolvedValueOnce({ ok: false, error: { message: 'Revision changed' } } as never)
await f.controller.reconcileCodingTools(() => true)
expect(f.remote.settings.update).toHaveBeenCalledOnce()
expect(f.state()).toMatchObject({ saving: false, error: 'Revision changed' })
expect(f.state().rows.find(row => row.isDefault)?.id).toBe('minimal')
})
})
@@ -12,7 +12,8 @@ const translations: ReadonlyMap<string, string> = new Map(Object.entries(en))
function unusedHook(): never {
throw new Error('This section does not read global slot sources')
}
function view(partial: Partial<AgentPresetSectionState> = {}, startCreatorDraft?: () => void, outerClose?: () => void) {
function view(partial: Partial<AgentPresetSectionState> = {}, startCreatorDraft?: () => void, outerClose?: () => void, enabled = true) {
const developerTools = createSnapshotStore(enabled)
const store = createSnapshotStore<AgentPresetSectionState>({ status: 'ready', error: null,
saving: false, rows: [{ id: 'standard', isDefault: true }, { id: 'mine', name: 'Mine', isDefault: false }],
view: null, ...partial })
@@ -23,16 +24,55 @@ function view(partial: Partial<AgentPresetSectionState> = {}, startCreatorDraft?
usePanelInfo: unusedHook, useSessions: unusedHook, useSessionStatus: unusedHook, useSessionRetainInfo: unusedHook,
useWorkspaces: unusedHook, useResource: unusedHook,
useAgentPresetSection: bindSnapshotSelector(store),
useDeveloperTools: bindSnapshotSelector(developerTools),
t: key => translations.get(key) ?? key }
render(outerClose === undefined ? <AgentPresetSection {...props} />
: <Modal open onClose={outerClose} title="Settings" closeLabel="Close"><AgentPresetSection {...props} /></Modal>)
return { ...actions, store }
return { ...actions, store, developerTools }
}
function rowFor(id: string): HTMLElement {
const row = document.querySelector<HTMLElement>(`[data-agent-preset-id="${id}"]`)
if (row === null) throw new Error(`no card for ${id}`)
return row
}
it.each([false, true])('offers Standard, Creator and custom defaults with Coding Tools %s', (enabled) => {
const actions = view({ rows: [
{ id: 'standard', isDefault: false }, { id: 'ptc', isDefault: false },
{ id: 'minimal', isDefault: true }, { id: 'cordis', isDefault: false },
{ id: 'mine', name: 'Mine', isDefault: false },
] }, vi.fn(), undefined, enabled)
expect(screen.queryByRole('button', { name: `${en.setDefault}: ${en.presetPtcName}` }) !== null).toBe(enabled)
expect(screen.queryByRole('button', { name: `${en.inUse}: ${en.presetMinimalName}` }) !== null).toBe(enabled)
for (const [id, name] of [['standard', en.presetStandardName], ['cordis', en.presetCordisName], ['mine', 'Mine']]) {
const button = screen.getByRole<HTMLButtonElement>('button', { name: `${en.setDefault}: ${name}` })
expect(button.disabled).toBe(false)
fireEvent.click(button)
expect(actions.makeDefault).toHaveBeenLastCalledWith(id)
}
expect(screen.getByRole<HTMLButtonElement>('button', { name: en.creatorDraft }).disabled).toBe(false)
})
it('updates visible settings choices without writing defaults from the renderer', () => {
const actions = view({ rows: [
{ id: 'standard', isDefault: false }, { id: 'ptc', isDefault: false },
{ id: 'minimal', isDefault: true }, { id: 'cordis', isDefault: false },
] })
const rows = actions.store.getSnapshot().rows
act(() => { actions.developerTools.set(false) })
expect(document.querySelector('[data-agent-preset-id="ptc"]')).toBeNull()
expect(document.querySelector('[data-agent-preset-id="minimal"]')).toBeNull()
expect(rowFor('standard')).toBeTruthy()
expect(rowFor('cordis')).toBeTruthy()
expect(actions.makeDefault).not.toHaveBeenCalled()
expect(actions.store.getSnapshot().rows).toBe(rows)
act(() => { actions.developerTools.set(true) })
expect(screen.getByRole('button', { name: `${en.inUse}: ${en.presetMinimalName}` })).toBeTruthy()
expect(rowFor('ptc')).toBeTruthy()
})
it.each(['ptc', 'minimal'])('keeps a named custom %s override selectable with Coding Tools off', (id) => {
const actions = view({ rows: [{ id, name: 'My mode', isDefault: false }] }, undefined, undefined, false)
fireEvent.click(screen.getByRole('button', { name: `${en.setDefault}: My mode` }))
expect(actions.makeDefault).toHaveBeenCalledWith(id)
})
it('reads the roster once and sets a default from the card body', async () => {
const actions = view()
expect(screen.queryByRole('switch')).toBeNull()
@@ -75,6 +115,14 @@ it('offers no Creator entry without the conversation flow or the cordis preset',
view({}, vi.fn())
expect(screen.queryByRole('button', { name: en.creatorDraft })).toBeNull()
})
it('disables the Creator entry while a default is being saved', () => {
const launch = vi.fn()
view({ saving: true, rows: [{ id: 'cordis', isDefault: true }] }, launch)
const button = screen.getByRole<HTMLButtonElement>('button', { name: en.creatorDraft })
expect(button.disabled).toBe(true)
fireEvent.click(button)
expect(launch).not.toHaveBeenCalled()
})
it('reads a declared composition read-only from every card, broken ones included', () => {
const actions = view({ rows: [{ id: 'standard', isDefault: true }, { id: 'broken', isDefault: false, broken: 'Missing plugin' }] })
expect(screen.queryByRole('dialog')).toBeNull()
@@ -209,6 +257,12 @@ it('does not attach built-in claims to named or unknown presets', () => {
expect(screen.queryByRole('button', { name: new RegExp(en.modeExplanation) })).toBeNull()
expect(screen.queryByRole('button', { name: new RegExp(en.howToUse) })).toBeNull()
})
it('leaves help usable while a default is being saved', () => {
const actions = view({ saving: true })
fireEvent.click(within(rowFor('standard')).getByRole('button', { name: `${en.howToUse}: ${en.presetStandardName}` }))
expect(screen.getByRole('dialog', { name: en.presetStandardName })).toBeTruthy()
expect(actions.makeDefault).not.toHaveBeenCalled()
})
it('closes help even when the browser reports no previously focused element', () => {
const descriptor: TypedPropertyDescriptor<Element | null> = Object.getOwnPropertyDescriptor(Document.prototype, 'activeElement')!
const readActiveElement = descriptor.get!.bind(document)
@@ -11,7 +11,6 @@ import type { RemoteErrorCode } from '@deepseek-ai/dsh-api-remotes/client'
import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
import type { SessionSummary } from '@deepseek-ai/dsh-api-session-controller/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { createSnapshotStore, type ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
import {
AGENT_PRESET_SETTINGS_NS, AgentPresetSettingsController,
writeDefaultPreset,
@@ -206,12 +205,10 @@ describe('the new-session chip controller', () => {
failSelect?: string
failList?: string
failListCode?: RemoteErrorCode
developerTools?: ObservableSnapshot<boolean>
list?: () => Promise<ReturnType<typeof remoteRoster>>
} = {},
): AgentPresetSeatController {
const partial = {
configForms: { developerTools: { enabled: options.developerTools ?? createSnapshotStore(true) } },
remote: {
agentPresets: {
list: options.list ?? (() => {
@@ -466,51 +463,22 @@ describe('the new-session chip controller', () => {
expect(controller.store.getSnapshot().current).toBe('minimal')
})
it('clears an unconsumed stage when Developer tools are off', async () => {
it('keeps the introduction cue after applying a staged pick', async () => {
const writes: Recorded[] = []
const developerTools = createSnapshotStore(false)
const controller = chip(ROSTER, {
id: 's1' as SessionId,
blank: false,
projectionValues: { agentPreset: 'standard' },
}, { writes, developerTools })
controller.stage('minimal', true)
await controller.load()
await controller.apply()
expect(writes).toEqual([])
expect(controller.store.getSnapshot()).toMatchObject({
current: 'standard',
introduce: false,
})
})
it('drops a stage made while Developer tools were on once they turn off', async () => {
const writes: Recorded[] = []
const developerTools = createSnapshotStore(true)
const session = {
id: 's1' as SessionId,
blank: true,
projectionValues: { agentPreset: 'standard' },
}
const controller = chip(ROSTER, session, { writes, developerTools })
const controller = chip(ROSTER, session, { writes })
await controller.load()
// The blank session keeps the composition its own screen already names.
controller.stage('minimal', true)
developerTools.set(false)
await controller.apply()
expect(writes).toEqual([])
expect(controller.store.getSnapshot()).toMatchObject({
current: 'standard',
introduce: false,
})
expect(writes).toEqual([{ ns: 'select', ops: 'minimal' }])
expect(controller.store.getSnapshot()).toMatchObject({ current: 'minimal', introduce: true })
})
it('leaves the introduction cue to the chip while Developer tools stay on', async () => {
const developerTools = createSnapshotStore(true)
it('keeps the introduction cue until acknowledged when the Session already runs the staged preset', async () => {
const session = {
id: 's1' as SessionId,
blank: true,
@@ -518,7 +486,7 @@ describe('the new-session chip controller', () => {
// no-op that leaves the cue for the chip to play.
projectionValues: { agentPreset: 'minimal' },
}
const controller = chip(ROSTER, session, { developerTools })
const controller = chip(ROSTER, session)
await controller.load()
controller.stage('minimal', true)
@@ -131,18 +131,16 @@ async function placeDoc(root: string, id: string, name: string): Promise<Fixture
async function assertRewrite(ctx: Context, root: string, id: SessionId): Promise<void> {
const session = ctx.sessions.create(id)
session.append('fixtures-test/set-title', { title: '重写标题' })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
await ctx.sessionProjectionCache.write(session)
const path = join(root, projectionCacheDomainSpec.name, 'sessions', `${id}.json`)
await vi.waitFor(async () => {
const doc = JSON.parse(await readFile(path, 'utf8')) as FixtureDoc
expect(doc.version).toBe(projectionCacheDomainSpec.version)
expect(doc.record.identity).toMatchObject({
formatVersion: SESSION_FORMAT_VERSION,
isSeeded: false,
inheritedEventCount: 0,
})
expect(doc.record.rows['title']?.val).toBe('重写标题')
}, { timeout: 5_000 })
const doc = JSON.parse(await readFile(path, 'utf8')) as FixtureDoc
expect(doc.version).toBe(projectionCacheDomainSpec.version)
expect(doc.record.identity).toMatchObject({
formatVersion: SESSION_FORMAT_VERSION,
isSeeded: false,
inheritedEventCount: 0,
})
expect(doc.record.rows['title']?.val).toBe('重写标题')
}
afterEach(async () => {