diff --git a/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.i18n.yaml index 6ec968a410..a8bd523cb6 100644 --- a/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.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-08-per-preset-standing-mounts.md -2026-08-08-per-preset-standing-mounts.md: 19a53926e9f07b01115cfbb4ddc89eb7ee0c59a0 -2026-08-08-per-preset-standing-mounts.zh.md: 856fbb742a843935902f9aa7578ba6821a9a13c3 +2026-08-08-per-preset-standing-mounts.md: 61aa737a4b7d6f15f160d3673a14cdc0c5df4a76 +2026-08-08-per-preset-standing-mounts.zh.md: 17de2cf3ec9ee9564cb5fde1fabb5014c389be39 diff --git a/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.md b/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.md index 19a53926e9..61aa737a4b 100644 --- a/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.md +++ b/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.md @@ -23,7 +23,7 @@ Standing mounts fix the class, not the instances: the registrations a reader nee ## Load-bearing details - **Standing mounts hang off the service's untraced `selfCtx`.** A method invoked through the traceable proxy sees `this.ctx` rebound to the caller with a shadow; reflect resolution for every fiber in a subtree minted from it starts at the shadow's fiber, so entries fail on services their own `inject` declares (`cannot get property "tools" without inject` while the entry's store holds it). The `tasks-local` selfCtx precedent, now with a second consumer. -- **A settled mount is permanent for the process.** The composition a running session joined must survive its file changing or disappearing; deletion and edits reach only future generations (the authoring layer swaps the map pointer, never disposes a joined generation), and superseded generations are reclaimed only by whole-tree teardown — deliberate, bounded by edit frequency, recorded in the package's Known Limitations. +- **A settled mount serves until its composition file's stamp changes.** The composition a running session joined must survive its file changing or disappearing; each generation records the file's stamp (mtime + size) and a session that finds it stale starts the next generation, so file edits — the only composition editor once authoring became copy-only — reach later sessions without any authoring call dropping the pointer. Joined sessions keep their generation, and superseded generations are reclaimed only by whole-tree teardown — deliberate, bounded by edit frequency, recorded in the package's Known Limitations. - **`peek()` stays chain-blind.** Restrictions and guards address one scope's own contributions; only registration VIEWS inherit. Restrictions along the chain intersect (any scope may mask a global-surface name for everything nested inside it). - **Re-linking a key (`setScopeParent` on a live agent) is the blank-session recompose path** — valid only while nothing produced under the old parent is retained, which the caller must uphold; the relation cannot see session logs. diff --git a/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.zh.md b/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.zh.md index 856fbb742a..17de2cf3ec 100644 --- a/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-08-per-preset-standing-mounts.zh.md @@ -23,7 +23,7 @@ Status: implemented ## Load-bearing details - **常驻挂载挂在服务未追踪的 `selfCtx` 上。** 经 traceable 代理调用的方法看到的 `this.ctx` 被重绑到调用方并携带 shadow;从它派生的子树里每个 fiber 的 reflect 解析都从 shadow 的 fiber 起步,entry 会在自己 `inject` 声明的服务上失败(`cannot get property "tools" without inject`,而它的 store 里明明有)。`tasks-local` 的 selfCtx 先例,如今有了第二个消费者。 -- **挂载一旦成功即进程级永久。** 运行中会话加入的组装必须在其文件被修改或删除后继续存活;删除与编辑只影响未来的代际(创作层替换 map 指针,绝不 dispose 已被加入的代际),被替代的代际只由整树卸载回收——刻意为之,上限取决于编辑频率,已记入包的 Known Limitations。 +- **挂载一旦成功即持续供职,直到组装文件的 stamp 变化。** 运行中会话加入的组装必须在其文件被修改或删除后继续存活;每个代际记录文件 stamp(mtime + 大小),发现过期的会话开启下一个代际,因此文件编辑——创作改为仅复制之后唯一的组装编辑器——无需任何创作调用丢弃指针即可达到后续会话。已加入的会话保持其代际,被替代的代际只由整树卸载回收——刻意为之,上限取决于编辑频率,已记入包的 Known Limitations。 - **`peek()` 保持不看链。** 限制与守卫定位的是单个作用域**自己**的贡献;只有注册**视图**沿链继承。链上的限制求交(链上任一作用域都可为嵌套其内的一切遮蔽某个全局面名字)。 - **对活 agent 重新认父(`setScopeParent`)是空白会话 recompose 的路径**——仅当旧父之下的产出一概不被保留时才合法,由调用方保证;该关系看不见会话日志。 diff --git a/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.i18n.yaml new file mode 100644 index 0000000000..94466cd530 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.md +2026-08-08-copy-only-preset-authoring.md: c16518b087c7acedbee3d89ce5cc8dbcaa0a0cde +2026-08-08-copy-only-preset-authoring.zh.md: dc2d7924cb0fd9363efa9387bc8ba68bf11af5d7 diff --git a/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.md b/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.md new file mode 100644 index 0000000000..c16518b087 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.md @@ -0,0 +1,30 @@ +# Agent Note: Copy-only preset authoring, and the way into a preset's files + +Status: implemented + +English | [中文](2026-08-08-copy-only-preset-authoring.zh.md) + +## Problem + +The agent-preset settings page carried a web YAML editor: `agentPreset.write` accepted arbitrary composition text, the page held a textarea with no completion, highlighting, or diff, and the shape check leaned on the Loader's own `entryListSchema` — whose dialect includes `!!js`, so "shape-checked text" was still arbitrary code on the next mount. Weak as an editor, wide as a capability, and the source of the editor-vs-roster races the section had to defend against. + +## Decision + +Authoring is a host-side copy, and files are the editor. `agentPreset.write` became `agentPreset.copy { from, agentPreset, name? }`: two ids the host resolves against its own roots plus an optional display name, whole-directory `cp` (symlinks dereferenced, modes re-tightened to owner-only with owner-execute kept), metadata rewritten to keep the source's description but never its name or `order`. The page becomes: read-only viewer over shipped compositions, copy dialog as the only create entry (no blank "new preset" — writing YAML from nothing is not a thing people do), delete for custom rows, and a location action that leads to the files — `agentPreset.openDocument { agentPreset }` resolves the directory host-side and opens it natively, or answers `{ opened: false, path }` for the row to show as text where the deployment has no desktop (`hasDocument` on `list`, pinned by the gateway's `nativeOpen` config where `canOpenNativePath` platform detection would mislead, e.g. e2e and containers). + +## Consequences + +- No composition text and no path crosses the browser wire in either authoring direction; the `entryListSchema`/`!!js` concern dissolves with `assertComposition` itself (deleted). The privileged set is now `read`/`copy`/`openDocument`/`remove` — none accepts a filesystem target. +- With the editor gone, hand-editing `agent.cordis.yml` is the ONLY composition edit, so the standing-mount layer grew stamp-keyed generations: `ensureStanding` compares the file's mtime+size and starts the next generation for later sessions ([standing-mounts note](../architecture/2026-08-08-per-preset-standing-mounts.md), updated in place). Without this, an edited file would serve stale compositions until process restart. +- A copy is a full snapshot that drifts from an upgraded shipped source — accepted; the preset layer has no patch semantics (that is the bundle layer's `cordis.patch.yml`), and the shipped set itself pays the same cost (`cordis`/`code` are full copies of `standard`) for one-file readability. +- `read` dropped `writable` (no editor to gate) and builtin directories are never opened (`openDocument` refuses non-`user` trust like `remove`): the install is overwritten by upgrades, and pointing an editor into it invites edits an upgrade silently discards. + +## Load-bearing details + +- **Copy target refusal is two checks on purpose.** The roster check refuses any id a root supplies — a user directory named like a shipped preset would be shadowed, so "create" would land a file nothing ever lists; the disk check (`PresetExistsError` before `cp` with `errorOnExist` as the race backstop) refuses a directory occupying the name without being a preset, which discovery cannot see. +- **The revealed path is response-direction disclosure, loopback-pinned.** The invariant "no browser payload can select an arbitrary filesystem target" is about the request direction; showing the resolved directory to the loopback user is the fallback the plan requires. It never rides the unprivileged `list`. +- **The e2e lane pins `nativeOpen: false`** (`agent-preset-authoring.overlay.yml`) — both so goldens render the same branch on macOS dev and headless Linux CI, and so test runs never pop a real file manager. The revealed directory is tokenized as `{{presetRoot}}` by the lane itself, since `normalizeAria` only knows the workspace cwd. + +## Alternatives considered + +Keeping write with a better editor (CodeMirror etc.): still arbitrary capability over the wire, still the race source, and still a worse editor than the user's own. Patch-semantics copies ("standard plus this diff"): no such layer exists below the bundle plane, and the repo's own shipped presets chose full copies deliberately. Browser-side `host.openPath` with a returned path: breaks the README's no-arbitrary-target invariant the moment the path is a request parameter. diff --git a/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.zh.md b/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.zh.md new file mode 100644 index 0000000000..dc2d7924cb --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.zh.md @@ -0,0 +1,30 @@ +# Agent Note: 仅复制的 preset 创作,与通往 preset 文件的入口 + +Status: implemented + +[English](2026-08-08-copy-only-preset-authoring.md) | 中文 + +## Problem + +agent-preset 设置页带着一个网页 YAML 编辑器:`agentPreset.write` 接收任意组装文本,页面是一个没有补全、高亮或 diff 的文本域,形状检查依赖 Loader 自己的 `entryListSchema`——其方言含 `!!js`,所以「过了形状检查的文本」在下一次挂载时仍是任意代码。作为编辑器很弱,作为能力很宽,还是该分区不得不防御的「编辑器 vs 名单」竞态的来源。 + +## Decision + +创作改为宿主端复制,文件就是编辑器。`agentPreset.write` 变为 `agentPreset.copy { from, agentPreset, name? }`:两个由宿主对照自身根目录解析的 id 加一个可选显示名,整目录 `cp`(符号链接解引用,权限收紧为仅属主并保留属主执行位),元数据重写为保留来源描述、但绝不保留其名称与 `order`。页面变为:随附组装的只读查看器、作为唯一创建入口的复制对话框(不再有空白「新建预设」——从零手写 YAML 不是人会做的事)、自定义行的删除,以及通向文件的位置操作——`agentPreset.openDocument { agentPreset }` 在宿主端解析目录并原生打开,部署没有桌面时回答 `{ opened: false, path }` 供卡片以文本展示(`list` 上的 `hasDocument`;在 `canOpenNativePath` 平台探测会失真处由网关的 `nativeOpen` 配置钉死,例如 e2e 与容器)。 + +## Consequences + +- 创作两个方向都不再有组装文本或路径跨越浏览器传输层;`entryListSchema`/`!!js` 的顾虑随 `assertComposition` 本身(已删除)一并消解。特权集现为 `read`/`copy`/`openDocument`/`remove`——没有一个接收文件系统目标。 +- 编辑器移除后,手改 `agent.cordis.yml` 成为**唯一**的组装编辑方式,因此常驻挂载层增加了以 stamp 为键的代际:`ensureStanding` 比对文件的 mtime+大小,为后续会话开启下一代际([常驻挂载 note](../architecture/2026-08-08-per-preset-standing-mounts.md),已就地更新)。没有它,改过的文件要等进程重启才生效。 +- 副本是完整快照,会随随附来源升级而漂移——接受;preset 层没有 patch 语义(那是 bundle 层 `cordis.patch.yml` 的能力),随附集合自己也为「一个文件读完整份组装」付了同样的代价(`cordis`/`code` 就是 `standard` 的完整副本)。 +- `read` 去掉了 `writable`(没有编辑器可门控),内置目录绝不被打开(`openDocument` 与 `remove` 一样拒绝非 `user` 信任):安装目录会被升级覆盖,把编辑器指向它等于招揽会被升级悄悄丢弃的编辑。 + +## Load-bearing details + +- **复制目标的拒绝刻意分两道检查。** roster 检查拒绝任一根目录提供的 id——与随附 preset 同名的用户目录会被遮蔽,「创建」只会落下一个永远不被列出的文件;磁盘检查(`cp` 之前的 `PresetExistsError`,`errorOnExist` 作竞态兜底)拒绝占着名字却不是 preset 的目录,那是 discovery 看不见的。 +- **展示的路径是响应方向的披露,且钉在环回。**「没有任何浏览器载荷能选中任意文件系统目标」这条不变量说的是请求方向;把解析出的目录展示给环回用户正是方案要求的降级。它绝不搭乘非特权的 `list`。 +- **e2e lane 钉死 `nativeOpen: false`**(`agent-preset-authoring.overlay.yml`)——既让 golden 在 macOS 开发机与无头 Linux CI 上渲染同一分支,也让测试运行永不弹出真实文件管理器。揭示的目录由 lane 自己 token 化为 `{{presetRoot}}`,因为 `normalizeAria` 只认识 workspace cwd。 + +## Alternatives considered + +保留 write 换个更好的编辑器(CodeMirror 等):传输层上仍是任意能力,仍是竞态来源,而且仍不如用户自己的编辑器。带 patch 语义的副本(「standard 加这点 diff」):bundle 面之下没有这样的层,仓库自己的随附 preset 也刻意选了完整副本。浏览器端拿返回路径调 `host.openPath`:路径一旦成为请求参数,就打破了 README 的「不可选中任意目标」不变量。 diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index 28b35d67e1..04ad03099d 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -376,32 +376,28 @@ describe('authoring a preset on the shipped composition', () => { }]) }) - it('refuses to overwrite or delete a shipped preset', async () => { - await expect(authorCtx.agentPresets.write('standard', '- id: x\n')).rejects.toThrow(/ships with the deployment/) + it('refuses to copy over or delete a shipped preset', async () => { + await expect(authorCtx.agentPresets.copy('minimal', 'standard')).rejects.toThrow(/already exists/) await expect(authorCtx.agentPresets.remove('standard')).rejects.toThrow(/ships with the deployment/) }) it.each(['../escape', 'a/b', '/abs', 'Upper'])('refuses the uncontainable id %j', async (id) => { // The id becomes a directory name under the user root, so containment is // checked on the id rather than on the joined path afterwards. - await expect(authorCtx.agentPresets.write(id, '- id: x\n')).rejects.toThrow() + await expect(authorCtx.agentPresets.copy('minimal', id)).rejects.toThrow() }) - it('refuses text that is not a Cordis entry list', async () => { - await expect(authorCtx.agentPresets.write('bad-shape', 'tools: []\n')).rejects.toThrow() - await expect(authorCtx.agentPresets.resolve('bad-shape')).rejects.toThrow() - }) + it('copies a shipped preset a session then really composes from', async () => { + await authorCtx.agentPresets.copy('minimal', 'my-agent', '我的模式') - it('writes a preset a session then really composes from', async () => { - const copied = await authorCtx.agentPresets.read('minimal') - - await authorCtx.agentPresets.write('my-agent', copied) - - // Round-trips through the roster as a `user` row, and the composition the - // editor saved is one the mount actually accepts. + // Round-trips through the roster as a `user` row carrying the given name + // and the source's description, over the source's own composition text. const preset = await authorCtx.agentPresets.resolve('my-agent') + const source = await authorCtx.agentPresets.resolve('minimal') expect(preset.trust).toBe('user') - expect(await authorCtx.agentPresets.read('my-agent')).toBe(copied) + expect(preset.name).toBe('我的模式') + expect(preset.description).toBe(source.description) + expect(await authorCtx.agentPresets.read('my-agent')).toBe(await authorCtx.agentPresets.read('minimal')) // Owner-only, in an owner-only directory: a composition is executable // configuration on a machine that may have other users. expect((await stat(preset.path)).mode & 0o777).toBe(0o600) @@ -410,7 +406,7 @@ describe('authoring a preset on the shipped composition', () => { setup: agentCtx => authorCtx.agentPresets.mount(agentCtx, 'my-agent').then(() => undefined), }) try { - // The same tools the shipped `minimal` composes, from a file written + // The same tools the shipped `minimal` composes, from a directory copied // through the service into a root outside the installed harness. expect(toolNames(authorCtx, handle.agent)).toEqual(['bash', 'str_replace_editor']) } finally { @@ -418,8 +414,8 @@ describe('authoring a preset on the shipped composition', () => { } }) - it('deletes what it wrote', async () => { - await authorCtx.agentPresets.write('doomed', '- id: tool-web-search\n name: \'@deepseek-ai/dsh-tool-web-search\'\n') + it('deletes what it copied', async () => { + await authorCtx.agentPresets.copy('minimal', 'doomed') await authorCtx.agentPresets.remove('doomed') diff --git a/apps/web/tests/agent-preset-authoring.e2e.ts b/apps/web/tests/agent-preset-authoring.e2e.ts new file mode 100644 index 0000000000..2bf90b2105 --- /dev/null +++ b/apps/web/tests/agent-preset-authoring.e2e.ts @@ -0,0 +1,181 @@ +// Web e2e scenario: the agent-preset settings section as copy-only authoring. +// The browser never edits composition text — a shipped preset opens in a +// read-only viewer, the copy dialog collects an id and an optional display +// name, and the host copies the whole directory. The section's other job is +// getting the user TO the files: this lane pins `nativeOpen: false` (see the +// overlay), so the location affordance answers the preset directory as text — +// the deterministic branch a golden can hold on every platform. +// +// Zero model calls: no replay fixture mounts, so a stray stream fails loud. +import { existsSync } from 'node:fs' +import { mkdtemp, readFile, realpath } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import type { Locator } from 'playwright' +import { + captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole, + webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/agent-preset-authoring', import.meta.url)) +const SECTION_EXPECTED = join(SNAPSHOT_DIR, 'section.expected.md') +const COPY_DIALOG_EXPECTED = join(SNAPSHOT_DIR, 'copy-dialog.expected.md') +const CREATED_EXPECTED = join(SNAPSHOT_DIR, 'created.expected.md') +/** The shipped roster, beside the composition that names it. */ +const SHIPPED_PRESETS = fileURLToPath(new URL('../../cli/config/agent-presets', import.meta.url)) +const OVERLAY = fileURLToPath(new URL('./agent-preset-authoring.overlay.yml', import.meta.url)) +const MODE = webSnapshotMode() + +describe('web e2e: agent-preset authoring is a host-side copy', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + let userRoot: string + + /** The settings dialog, opened on the Agent-presets section. */ + function settingsDialog(): Locator { + return page.getByRole('dialog', { name: '设置' }) + } + + /** Tokenize the lane-owned preset root the way the scaffold tokenizes cwd. */ + function withPresetRoot(snapshot: string): string { + return snapshot.split(userRoot).join('{{presetRoot}}') + } + + beforeAll(async () => { + userRoot = await realpath(await mkdtemp(join(tmpdir(), 'dsh-web-e2e-presets-'))) + scaffold = await launchWebScaffold({ + extraOverlayPath: OVERLAY, + agentPresets: { + roots: [ + { path: SHIPPED_PRESETS, trust: 'system' }, + { path: userRoot, trust: 'user' }, + ], + default: 'standard', + }, + }) + browser = await chromium.launch() + // The scenario asserts the shipped Chinese copy, so the browser asks for it. + page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE }) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('offers the roster with copy as the only way to create', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-preset-authoring-section')) + await page.getByRole('button', { name: '设置', exact: true }).click() + const dialog = settingsDialog() + 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 }) + + const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) + + await compareOrRefreshGolden(SECTION_EXPECTED, snapshot, MODE) + // The intro carries the guidance a create button used to imply, 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. + expect(snapshot).toContain('复制「极简模式」') + expect(snapshot).not.toContain('新建预设') + expect(snapshot).toContain('查看: 标准模式') + expect(snapshot).not.toContain('删除: 标准模式') + expect(snapshot).not.toContain('打开目录') + }, 60_000) + + it('views a shipped composition read-only instead of editing it', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-preset-authoring-view')) + const dialog = settingsDialog() + await dialog.getByRole('button', { name: '查看: 标准模式' }).click() + const viewer = page.getByRole('dialog', { name: '查看 · 标准模式' }) + await viewer.waitFor({ timeout: 10_000 }) + + // The real shipped composition, not a golden: the viewer shows whatever + // the deployment ships, and this lane only asserts it is shown read-only. + const shipped = await readFile(join(SHIPPED_PRESETS, 'standard', 'agent.cordis.yml'), 'utf8') + expect(await viewer.locator('pre').textContent()).toBe(shipped) + expect(await viewer.getByRole('textbox').count()).toBe(0) + // The header X and the footer button share the 关闭 name; the footer one + // is last in the dialog. + await viewer.getByRole('button', { name: '关闭' }).last().click() + await viewer.waitFor({ state: 'detached', timeout: 10_000 }) + }, 60_000) + + it('copies 极简模式 whole under a new id and lands in its files', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-preset-authoring-copy')) + const dialog = settingsDialog() + await dialog.getByRole('button', { name: '复制: 极简模式' }).click() + const copyDialog = page.getByRole('dialog', { name: '复制预设 · 复制自 极简模式' }) + await copyDialog.waitFor({ timeout: 10_000 }) + + const dialogSnapshot = await captureStableAria( + page, '[role="dialog"][aria-label^="复制预设"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(COPY_DIALOG_EXPECTED, dialogSnapshot, MODE) + // Two fields and nothing else: the id is the directory name the host + // needs up front; description and composition live in the files. + expect(dialogSnapshot).toContain('标识符') + expect(dialogSnapshot).not.toContain('描述') + + await copyDialog.getByPlaceholder('my-agent').fill('my-agent') + await copyDialog.getByPlaceholder('选择器中显示的名字,缺省用标识符').fill('我的模式') + await copyDialog.getByRole('button', { name: '创建' }).click() + await copyDialog.waitFor({ state: 'detached', timeout: 10_000 }) + + // The new row lands in the custom group, and — with no desktop opener — + // its directory is revealed as text right away: landing in the files is + // the completion of a copy, not a follow-up. + await dialog.getByText('我的模式').first().waitFor({ timeout: 10_000 }) + await dialog.getByText('预设文件:').waitFor({ timeout: 10_000 }) + // The copy dialog is detached, so the settings dialog is the only one + // left (it names itself via aria-labelledby, which a CSS attribute + // selector cannot address). + const snapshot = withPresetRoot( + await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)) + await compareOrRefreshGolden(CREATED_EXPECTED, snapshot, MODE) + expect(snapshot).toContain('{{presetRoot}}/my-agent') + + // The host copied the whole directory and rewrote only the display + // metadata: the composition is byte-identical to the shipped source, the + // description rides along for the user to edit in place, and neither the + // source's name nor its roster order survives into the copy. + const composition = await readFile(join(userRoot, 'my-agent', 'agent.cordis.yml'), 'utf8') + expect(composition).toBe(await readFile(join(SHIPPED_PRESETS, 'minimal', 'agent.cordis.yml'), 'utf8')) + const metadata = await readFile(join(userRoot, 'my-agent', 'preset.yml'), 'utf8') + expect(metadata).toContain('name: 我的模式') + expect(metadata).toContain('description: 只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现。') + expect(metadata).not.toContain('order:') + }, 60_000) + + it('deletes the copy after confirmation and reclaims the roster', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-preset-authoring-delete')) + const dialog = settingsDialog() + await dialog.getByRole('button', { name: '删除: 我的模式' }).click() + const confirm = page.getByRole('dialog', { name: '删除该预设?' }) + await confirm.waitFor({ timeout: 10_000 }) + await confirm.getByRole('button', { name: '删除', exact: true }).click() + await confirm.waitFor({ state: 'detached', timeout: 10_000 }) + + await expect.poll(async () => dialog.getByText('我的模式').count(), { timeout: 10_000 }).toBe(0) + expect(existsSync(join(userRoot, 'my-agent'))).toBe(false) + // Custom group gone with its only member; the shipped set stands. + expect(await dialog.getByRole('heading', { name: '自定义' }).count()).toBe(0) + expect(await dialog.getByText('标准模式').count()).toBeGreaterThan(0) + }, 60_000) + + it('drove every surface without a page error or a stream warning', () => { + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }) +}) diff --git a/apps/web/tests/agent-preset-authoring.overlay.yml b/apps/web/tests/agent-preset-authoring.overlay.yml new file mode 100644 index 0000000000..6644752bc2 --- /dev/null +++ b/apps/web/tests/agent-preset-authoring.overlay.yml @@ -0,0 +1,12 @@ +# The authoring lane drives the location affordance. A real desktop open +# would pop a file manager on the machine running the tests and the +# capability itself is platform-detected (macOS yes, headless Linux CI no), +# so the gateway is pinned headless: `hasDocument` is false everywhere and +# `openDocument` answers the directory as text — the same branch on every +# host, and the one whose rendering a golden can hold. A patch replaces the +# row's complete config, so the shipped routing defaults ride along. +- id: api-gateway + config: + provider: deepseek-official + model: deepseek-v4-flash + nativeOpen: false diff --git a/apps/web/tests/snapshots/agent-preset-authoring/copy-dialog.expected.md b/apps/web/tests/snapshots/agent-preset-authoring/copy-dialog.expected.md new file mode 100644 index 0000000000..dc4045c5cc --- /dev/null +++ b/apps/web/tests/snapshots/agent-preset-authoring/copy-dialog.expected.md @@ -0,0 +1,14 @@ +- dialog "复制预设 · 复制自 极简模式": + - heading "复制预设 · 复制自 极简模式" [level=2] + - button "关闭": + - img + - paragraph: 整个预设会在本机复制一份。标识符将成为目录名,事后无法更改;其余内容之后直接在预设自己的文件里编辑。 + - text: 标识符 + - textbox "标识符": + - /placeholder: my-agent + - text: 名称 + - textbox "名称": + - /placeholder: 选择器中显示的名字,缺省用标识符 + - alert: 请填写标识符。 + - button "取消" + - button "创建" [disabled] diff --git a/apps/web/tests/snapshots/agent-preset-authoring/created.expected.md b/apps/web/tests/snapshots/agent-preset-authoring/created.expected.md new file mode 100644 index 0000000000..46ab77a945 --- /dev/null +++ b/apps/web/tests/snapshots/agent-preset-authoring/created.expected.md @@ -0,0 +1,78 @@ +- dialog "设置": + - navigation: + - text: 设置 + - button "通用设置": + - img + - text: 通用设置 + - button "模型": + - img + - text: 模型 + - button "Agent 预设": + - img + - text: Agent 预设 + - button "打开配置文件" + - button "关闭": + - img + - text: 关闭 + - heading "Agent 预设" [level=2] + - paragraph: 预设即一个会话的 Agent 所运行的插件组装 —— 它的工具、提示词与能力。复制一份即可改成自己的,之后直接编辑它的文件。 想从最小的骨架开始,就复制「极简模式」。 + - heading "内置" [level=3] + - list: + - listitem: + - 'button "当前使用: 标准模式" [disabled] [pressed]': + - text: 标准模式 内置 当前使用 完整的编码 agent:文件读写、shell、检索、计划、委派与工作流。 + - code: standard + - 'button "查看: 标准模式"': + - img + - text: 查看 + - 'button "复制: 标准模式"': + - img + - text: 复制 + - listitem: + - 'button "设为默认: 代码模式"': + - text: 代码模式 内置 标准模式的工具改为 Code Mode 呈现:模型写一段 TypeScript 调用 SDK,一次执行代替多轮工具调用。 + - code: code + - 'button "查看: 代码模式"': + - img + - text: 查看 + - 'button "复制: 代码模式"': + - img + - text: 复制 + - listitem: + - 'button "设为默认: 极简模式"': + - text: 极简模式 内置 只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现。 + - code: minimal + - 'button "查看: 极简模式"': + - img + - text: 查看 + - 'button "复制: 极简模式"': + - img + - text: 复制 + - listitem: + - 'button "设为默认: 创造模式"': + - text: 创造模式 内置 标准模式加上自指工具集,可以读改自己运行的这套组装,并据此创作新的预设。 + - code: cordis + - 'button "查看: 创造模式"': + - img + - text: 查看 + - 'button "复制: 创造模式"': + - img + - text: 复制 + - heading "自定义" [level=3] + - list: + - listitem: + - 'button "设为默认: 我的模式"': + - text: 我的模式 自定义 只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现。 + - code: my-agent + - 'button "查看路径: 我的模式"': + - img + - text: 查看路径 + - 'button "复制: 我的模式"': + - img + - text: 复制 + - 'button "删除: 我的模式"': + - img + - text: 删除 + - paragraph: + - text: 预设文件: + - code: {{presetRoot}}/my-agent diff --git a/apps/web/tests/snapshots/agent-preset-authoring/section.expected.md b/apps/web/tests/snapshots/agent-preset-authoring/section.expected.md new file mode 100644 index 0000000000..61df1d7b5a --- /dev/null +++ b/apps/web/tests/snapshots/agent-preset-authoring/section.expected.md @@ -0,0 +1,60 @@ +- dialog "设置": + - navigation: + - text: 设置 + - button "通用设置": + - img + - text: 通用设置 + - button "模型": + - img + - text: 模型 + - button "Agent 预设": + - img + - text: Agent 预设 + - button "打开配置文件" + - button "关闭": + - img + - text: 关闭 + - heading "Agent 预设" [level=2] + - paragraph: 预设即一个会话的 Agent 所运行的插件组装 —— 它的工具、提示词与能力。复制一份即可改成自己的,之后直接编辑它的文件。 想从最小的骨架开始,就复制「极简模式」。 + - heading "内置" [level=3] + - list: + - listitem: + - 'button "当前使用: 标准模式" [disabled] [pressed]': + - text: 标准模式 内置 当前使用 完整的编码 agent:文件读写、shell、检索、计划、委派与工作流。 + - code: standard + - 'button "查看: 标准模式"': + - img + - text: 查看 + - 'button "复制: 标准模式"': + - img + - text: 复制 + - listitem: + - 'button "设为默认: 代码模式"': + - text: 代码模式 内置 标准模式的工具改为 Code Mode 呈现:模型写一段 TypeScript 调用 SDK,一次执行代替多轮工具调用。 + - code: code + - 'button "查看: 代码模式"': + - img + - text: 查看 + - 'button "复制: 代码模式"': + - img + - text: 复制 + - listitem: + - 'button "设为默认: 极简模式"': + - text: 极简模式 内置 只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现。 + - code: minimal + - 'button "查看: 极简模式"': + - img + - text: 查看 + - 'button "复制: 极简模式"': + - img + - text: 复制 + - listitem: + - 'button "设为默认: 创造模式"': + - text: 创造模式 内置 标准模式加上自指工具集,可以读改自己运行的这套组装,并据此创作新的预设。 + - code: cordis + - 'button "查看: 创造模式"': + - img + - text: 查看 + - 'button "复制: 创造模式"': + - img + - text: 复制 diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index c98d70ac97..dcb46df04e 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -60,6 +60,7 @@ "tests/permission-policy-context.e2e.ts", "tests/access-confirmation.e2e.ts", "tests/agent-preset-selection.e2e.ts", + "tests/agent-preset-authoring.e2e.ts", "tests/shipped-composition.e2e.ts", "tests/startup-auto-selection.e2e.ts", "tests/produced-files.e2e.ts", diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 1d21a40a13..52a42f47c8 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -649,6 +649,14 @@ export interface Config { model: string /** Parent directory for name-created Workspaces; defaults to the Host cwd. */ workspaceRoot?: string + /** + * Whether this deployment can hand paths to a native desktop opener — + * the `hasDocument` capability the agent-preset roster reports. Absent, + * the platform is asked (macOS/Windows/WSL yes; Linux only with a display + * server); set it explicitly where detection misleads, e.g. `false` in a + * container whose DISPLAY points nowhere a user can see. + */ + nativeOpen?: boolean } ``` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index cbcf894a1e..51239e4b6a 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -91,18 +91,21 @@ async mount(agentCtx: Context, id?: string): Promise async read(id: string): Promise /** - * Create or replace a locally authored preset. + * Create a locally authored preset by copying an existing one whole. * - * The text is shape-checked before it lands, so a save cannot leave a file no - * session could load; it is NOT mounted, so a composition that parses but - * names a missing plugin still fails at the next session that selects it. - * @param id - the preset id, which becomes its directory name. - * @param content - the composition text. - * @param metadata - display name and description; clearing both removes the file. - * @throws when the id is unusable, the text is not an entry list, or the - * deployment configures no writable root. + * Copy is the only authoring write. Composition text never crosses this + * seam: the source is named by id and its directory is copied as it stands, + * so the copy is exactly as loadable as its source and authoring grants no + * capability the roster did not already carry. The copy is NOT mounted to + * validate — a source that mounts today yields a copy that mounts today. + * @param from - the preset the copy starts from; shipped presets are the + * primary source, so any trust is accepted. + * @param id - the new preset's id, which becomes its directory name. + * @param name - display name for the copy; absent falls back to the id. + * @throws when the source is unknown, the id is unusable or already taken, + * or the deployment configures no writable root. */ -async write(id: string, content: string, metadata: PresetMetadata = {}): Promise +async copy(from: string, id: string, name?: string): Promise /** * Delete a locally authored preset. diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index 508f2df6f7..31ff1dcbed 100644 --- a/packages/client/connection/README.i18n.yaml +++ b/packages/client/connection/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/connection/README.md -README.md: e597d047ab36cf34ac1bce041e162a42c01dc58a -README.zh.md: 9442633526266c981aa7b39942e9c0a9b34dd161 +README.md: da2cb781de6726596d1003ac9c2756b6113afc19 +README.zh.md: 87998d73ae5b768e4c3ded2967ab972fbc872c9f diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index e597d047ab..da2cb781de 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + current-page loopback state + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The browser carrier uses HTTP POST for unary and respond operations and opens one downlink-only WebSocket each for `events.mux` and `events.host`; the in-process carrier satisfies the same two-stream abstraction. The Host half owns the single `/api` route and its Fetch bridge; a registered TypeRT interceptor claims its Remote endpoints before the API Proxy fallback. Loopback hostname classification stays package-internal: the `/api` Host fence and WebSocket upgrades use it directly, while other client plugins consume the derived `ctx.connection.isLoopback` state. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`openDocument`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`; reads and native actions included, since describing returns the exposed configuration, opening acts on the Host desktop, and probing an arbitrary reference reports where a credential comes from — and the agent-preset authoring plane, `agentPreset.read`/`write`/`remove`, since a composition names the plugins a session runs, so reading one is reconnaissance and writing one is arbitrary capability; `agentPreset.list` and `agentPreset.select` stay out — the roster carries only ids and trust, and choosing a preset grants nothing `session.create`'s own `agentPreset` did not, over a default that already carries bash) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform carriers and ConnectionController loop are package-internal; apply selects and drives them. The downlink boundary is documented in the [WebSocket downlink carrier Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md); the protocol contract is api-contracts v3 §3. +Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + current-page loopback state + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The browser carrier uses HTTP POST for unary and respond operations and opens one downlink-only WebSocket each for `events.mux` and `events.host`; the in-process carrier satisfies the same two-stream abstraction. The Host half owns the single `/api` route and its Fetch bridge; a registered TypeRT interceptor claims its Remote endpoints before the API Proxy fallback. Loopback hostname classification stays package-internal: the `/api` Host fence and WebSocket upgrades use it directly, while other client plugins consume the derived `ctx.connection.isLoopback` state. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`openDocument`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`; reads and native actions included, since describing returns the exposed configuration, opening acts on the Host desktop, and probing an arbitrary reference reports where a credential comes from — and the agent-preset authoring plane, `agentPreset.read`/`copy`/`openDocument`/`remove`, since a composition names the plugins a session runs, so reading one is reconnaissance, and copy/remove/openDocument manage the roster and drive the host desktop (authoring is copy-only, so none of them accepts composition text or a path); `agentPreset.list` and `agentPreset.select` stay out — the roster carries only ids and trust, and choosing a preset grants nothing `session.create`'s own `agentPreset` did not, over a default that already carries bash) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform carriers and ConnectionController loop are package-internal; apply selects and drives them. The downlink boundary is documented in the [WebSocket downlink carrier Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md); the protocol contract is api-contracts v3 §3. ## /api browser-trust fence diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index 9442633526..87998d73ae 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。浏览器载体以 HTTP POST 发送 unary/respond,并为 `events.mux` 与 `events.host` 各开一条只下行的 WebSocket;进程内载体满足同一双流抽象。Host half 持有唯一 `/api` route 及其 Fetch bridge;已注册的 TypeRT interceptor 会先认领自己的 Remote endpoint,未认领请求再回退 API Proxy。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 与 WebSocket upgrade 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`;读取与原生操作也在内,因为 describe 会返回已暴露的配置、打开操作会作用于 Host 桌面,而探测任意引用会报出某条凭据来自何处——以及 agent preset 的创作面 `agentPreset.read`/`write`/`remove`,因为组装指明了一个会话所运行的插件,读取它是侦察,写入它是任意能力;`agentPreset.list` 与 `agentPreset.select` 不在其中——名单只携带 id 与信任级别,而选择一个 preset 并不比 `session.create` 自带的 `agentPreset` 多给任何能力,何况默认 preset 本就带着 bash)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台载体与 ConnectionController 循环属于包内部;apply 负责选择并驱动它们。下行边界见 [WebSocket 下行载体 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md);协议契约见 api-contracts v3 §3。 +协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。浏览器载体以 HTTP POST 发送 unary/respond,并为 `events.mux` 与 `events.host` 各开一条只下行的 WebSocket;进程内载体满足同一双流抽象。Host half 持有唯一 `/api` route 及其 Fetch bridge;已注册的 TypeRT interceptor 会先认领自己的 Remote endpoint,未认领请求再回退 API Proxy。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 与 WebSocket upgrade 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`;读取与原生操作也在内,因为 describe 会返回已暴露的配置、打开操作会作用于 Host 桌面,而探测任意引用会报出某条凭据来自何处——以及 agent preset 的创作面 `agentPreset.read`/`copy`/`openDocument`/`remove`,因为组装指明了一个会话所运行的插件,读取它是侦察,而 copy/remove/openDocument 管理名单并驱动宿主桌面(创作只有复制一种写入,因此这些方法都不接收组装文本或路径);`agentPreset.list` 与 `agentPreset.select` 不在其中——名单只携带 id 与信任级别,而选择一个 preset 并不比 `session.create` 自带的 `agentPreset` 多给任何能力,何况默认 preset 本就带着 bash)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台载体与 ConnectionController 循环属于包内部;apply 负责选择并驱动它们。下行边界见 [WebSocket 下行载体 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md);协议契约见 api-contracts v3 §3。 ## /api 浏览器信任栅栏 diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index ea0df24b5b..6c2047691c 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -2464,6 +2464,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { isDefault: id === fixtureDefaultPreset, })), authorable: true, + hasDocument: true, }), select: (request) => { fixtureDefaultPreset = request.payload.agentPreset @@ -2483,21 +2484,42 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { agentPreset, trust: preset.trust, content: preset.content, - writable: preset.trust === 'user', }) }, - write: (request) => { - const { agentPreset, content } = request.payload + copy: (request) => { + const { from, agentPreset } = request.payload + const source = fixturePresets.get(from) + if (source === undefined) { + return err(request, { + code: 'agent-preset-not-found', + message: `unknown agent preset "${from}"`, + details: { agentPreset: from, available: [...fixturePresets.keys()] }, + }) + } + if (fixturePresets.has(agentPreset)) { + return err(request, { + code: 'agent-preset-invalid', + message: `agent preset "${agentPreset}" already exists`, + details: { agentPreset, reason: 'already exists' }, + }) + } + fixturePresets.set(agentPreset, { trust: 'user', content: source.content }) + return ok(request, { agentPreset }) + }, + // Native opens are deterministic no-op successes in this fixture, so the + // open-directory affordance renders and the path-text fallback stays a + // component-test concern. + openDocument: (request) => { + const { agentPreset } = request.payload const existing = fixturePresets.get(agentPreset) - if (existing?.trust === 'system') { + if (existing === undefined || existing.trust === 'system') { return err(request, { code: 'agent-preset-read-only', message: `agent preset "${agentPreset}" ships with the deployment`, details: { agentPreset, reason: 'it ships with the deployment' }, }) } - fixturePresets.set(agentPreset, { trust: 'user', content }) - return ok(request, { agentPreset }) + return ok(request, { opened: true as const }) }, remove: (request) => { const { agentPreset } = request.payload @@ -2835,7 +2857,8 @@ export class FixtureApiClient extends AbstractApiClient { case 'agentPreset.list': return this.api.agentPresets.list(request) case 'agentPreset.select': return this.api.agentPresets.select(request) case 'agentPreset.read': return this.api.agentPresets.read(request) - case 'agentPreset.write': return this.api.agentPresets.write(request) + case 'agentPreset.copy': return this.api.agentPresets.copy(request) + case 'agentPreset.openDocument': return this.api.agentPresets.openDocument(request, new AbortController().signal) case 'agentPreset.remove': return this.api.agentPresets.remove(request) case 'goal.create': return this.api.goals.create(request) case 'goal.edit': return this.api.goals.edit(request) diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index a99c942906..f865653b9f 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -67,8 +67,10 @@ export const Config: z = z.object({ */ const PRIVILEGED_METHODS = new Set([ // A preset composition names the plugins a session runs, so reading one is - // reconnaissance and writing one is arbitrary capability — strictly more than - // the settings document beside it. + // reconnaissance; copy and remove rearrange what the deployment offers, and + // openDocument drives the host desktop — all more than the roster beside + // them. (Authoring is copy-only, so no method here accepts composition text + // or a path; the pin is about who may manage the roster at all.) // // CHOOSING one is not pinned, and `agentPreset.list` is not either. Picking a // preset looks like escalation — one of them mounts the toolset that edits the @@ -79,7 +81,8 @@ const PRIVILEGED_METHODS = new Set([ // any caller that may start a session at all can already run commands as this // process. Pinning the switch would be a fence beside an open gate. 'agentPreset.read', - 'agentPreset.write', + 'agentPreset.copy', + 'agentPreset.openDocument', 'agentPreset.remove', 'host.pickDirectory', 'host.openPath', diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index 14b6010b68..8cc5d67136 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -169,15 +169,17 @@ export class FakeApiClient implements IApiClient { } readonly agentPresets: IApiClient['agentPresets'] = { - list: (payload: unknown) => this.record('agentPreset.list', payload, Promise.resolve(ok({ presets: [], authorable: false }))), + list: (payload: unknown) => this.record('agentPreset.list', payload, Promise.resolve(ok({ presets: [], authorable: false, hasDocument: false }))), select: (payload: { agentPreset: string }) => this.record('agentPreset.select', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))), read: (payload: { agentPreset: string }) => this.record('agentPreset.read', payload, Promise.resolve(ok({ - agentPreset: payload.agentPreset, trust: 'user' as const, content: '', writable: true, + agentPreset: payload.agentPreset, trust: 'user' as const, content: '', }))), - write: (payload: { agentPreset: string }) => - this.record('agentPreset.write', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))), + copy: (payload: { agentPreset: string }) => + this.record('agentPreset.copy', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))), + openDocument: (payload: { agentPreset: string }) => + this.record('agentPreset.openDocument', payload, Promise.resolve(ok({ opened: true as const }))), remove: (payload: { agentPreset: string }) => this.record('agentPreset.remove', payload, Promise.resolve(ok({}))), } diff --git a/packages/client/connection/tests/node-half.spec.ts b/packages/client/connection/tests/node-half.spec.ts index 8bda2904b8..e3a4d6cb26 100644 --- a/packages/client/connection/tests/node-half.spec.ts +++ b/packages/client/connection/tests/node-half.spec.ts @@ -160,8 +160,9 @@ describe('connection node half', () => { 'credentials.describe', 'credentials.set', 'credentials.unset', 'llm.discoverModels', // A composition names the plugins a session runs: reading one is - // reconnaissance and writing one is arbitrary capability. - 'agentPreset.read', 'agentPreset.write', 'agentPreset.remove', + // reconnaissance, and copy/remove/openDocument manage the roster and + // drive the host desktop. + 'agentPreset.read', 'agentPreset.copy', 'agentPreset.openDocument', 'agentPreset.remove', ]) { const denied = fakeResponse() await routes[0]!.handler( @@ -455,7 +456,7 @@ describe('connection node half over a real HTTP server', () => { // Carries a draft credential and turns the host into a fetcher for a // URL the caller picked: an anonymous LAN caller must not reach it. 'llm.discoverModels', - 'agentPreset.read', 'agentPreset.write', 'agentPreset.remove', + 'agentPreset.read', 'agentPreset.copy', 'agentPreset.openDocument', 'agentPreset.remove', ]) { expect([method, await call(port, method, 'harness.example')]).toEqual([method, 403]) } diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index b58984bf75..2e92f885be 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -204,15 +204,17 @@ export class FakeApiClient implements IApiClient { } readonly agentPresets: IApiClient['agentPresets'] = { - list: (payload: unknown) => this.record('agentPreset.list', payload, Promise.resolve(ok({ presets: [], authorable: false }))), + list: (payload: unknown) => this.record('agentPreset.list', payload, Promise.resolve(ok({ presets: [], authorable: false, hasDocument: false }))), select: (payload: { agentPreset: string }) => this.record('agentPreset.select', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))), read: (payload: { agentPreset: string }) => this.record('agentPreset.read', payload, Promise.resolve(ok({ - agentPreset: payload.agentPreset, trust: 'user' as const, content: '', writable: true, + agentPreset: payload.agentPreset, trust: 'user' as const, content: '', }))), - write: (payload: { agentPreset: string }) => - this.record('agentPreset.write', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))), + copy: (payload: { agentPreset: string }) => + this.record('agentPreset.copy', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))), + openDocument: (payload: { agentPreset: string }) => + this.record('agentPreset.openDocument', payload, Promise.resolve(ok({ opened: true as const }))), remove: (payload: { agentPreset: string }) => this.record('agentPreset.remove', payload, Promise.resolve(ok({}))), } diff --git a/packages/client/ui-agent-preset/README.i18n.yaml b/packages/client/ui-agent-preset/README.i18n.yaml index 0e6ce1245b..00d6c41704 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: c9aa370ebb7612cdc18bbe3d63c85cbb7097f69b -README.zh.md: 707745c547f2172abb4d11cda09f3de1c5c1ec78 +README.md: c43fbff7846153911d0dc5bd85935470b35dcbff +README.zh.md: 1fca73a5efe873cd92afb9881fbc41d5b3b42d43 diff --git a/packages/client/ui-agent-preset/README.md b/packages/client/ui-agent-preset/README.md index c9aa370ebb..c43fbff784 100644 --- a/packages/client/ui-agent-preset/README.md +++ b/packages/client/ui-agent-preset/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The agent-preset surfaces: a General-settings row choosing which [preset](../../preset/agent-presets/README.md) new sessions are composed from, a chip on the new-session screen choosing the next session's, a read-only label in the session header, and a settings section that authors the compositions themselves. +The agent-preset surfaces: a General-settings row choosing which [preset](../../preset/agent-presets/README.md) new sessions are composed from, a chip on the new-session screen choosing the next session's, a read-only label in the session header, and a settings section that manages the roster — copy, delete, default, and the way into a preset's own files. ## Why it is a new-session preference @@ -30,21 +30,23 @@ The row re-reads on `settings/changed` for its own namespace and on `connection/ ## The management section -A fourth surface, its own settings page (`settings.section` id `agent-presets`, ordered after Models — choosing a model is routine, composing an agent is the deployment-shaping act behind it): the roster as rows, and one composition open in a YAML editor at a time. +A fourth surface, its own settings page (`settings.section` id `agent-presets`, ordered after Models — choosing a model is routine, composing an agent is the deployment-shaping act behind it): the roster as cards, a copy dialog as the only way a preset is created, and a read-only viewer over the shipped compositions. -A shipped preset opens read-only. It is the known-good composition a local one is written against, so reading it is the point and overwriting it is not — the deployment's copy is what a broken local preset is compared against. **Duplicate** copies any row, because a copy always lands in the local root regardless of where the text came from; **New preset** starts blank, since copying is already offered on the row being copied and a composition nobody named is text the author has to recognise as unwanted before deleting it. +The browser edits no composition text. Editing YAML in a web textarea was a weak surface (no completion, no highlighting, no diff), so a new preset is a host-side copy of an existing one — the dialog collects an id (it becomes the directory name, which is why it must be named up front and cannot change later) and an optional display name, and `{ from, id, name? }` is all that crosses the wire. Everything else — description, composition, skills — is edited in the preset's own files, and the page's other job is getting the user TO those files: the copy completes by opening the new directory, and every custom row keeps a location action. Where the host has no desktop opener (`hasDocument: false` on the roster; remote and container deployments), the same actions answer the directory as text on the row instead of offering a button that would spawn into nothing. -An id becomes a directory name, so the editor mirrors the host's own containment rule (`[a-z0-9][a-z0-9-]*`) and refuses a name already in use — a create landing on an existing name would overwrite a preset the user never opened. Both checks are conveniences: the host re-applies them, along with the composition's shape, and its answer is what the editor reports on failure. A save that parses is still only a save; a composition naming a plugin that does not exist fails at the next session that selects it. +A shipped preset opens in the read-only viewer. It is the known-good composition a copy starts from, so reading it is the point; it offers no location and no delete — its install is overwritten by upgrades and is not the user's to manage. The intro carries the guidance a create button used to imply: to start from the smallest skeleton, duplicate 极简模式 (31 lines against standard's 233). -Deleting removes the file. Sessions already composed from it keep running — a composition is mounted once at session creation and nothing re-reads the file. +The dialog mirrors the host's own containment rule (`[a-z0-9][a-z0-9-]*`) and refuses a name already in use — a copy never overwrites. Both checks are conveniences: the host re-applies them and its answer is what the dialog reports on failure. + +Deleting removes the preset directory. Sessions already composed from it keep running — a composition is mounted once at session creation and nothing re-reads the file. Setting the default writes the `agent-presets` settings namespace, which the host exposes to configuration clients ([`dsh-apiproxy`](../../host/apiproxy/README.md) keeps an explicit allowlist — a namespace outside it makes a picker move and then silently forget). -`agentPreset.read`, `write`, `remove`, and `select` are loopback-pinned ([`dsh-client-connection`](../connection/README.md)): a composition names the plugins a session runs, so reading one is reconnaissance and writing one is arbitrary capability. `agentPreset.list` is not — it carries ids and trust, and a LAN client's picker needs it. +`agentPreset.read`, `copy`, `openDocument`, and `remove` are loopback-pinned ([`dsh-client-connection`](../connection/README.md)): a composition names the plugins a session runs, so reading one is reconnaissance, and the rest manage the roster and drive the host desktop. `agentPreset.list` is not — it carries ids, trust, and the two path-free capability flags, and a LAN client's picker needs it. ## When the surfaces are absent -A deployment that composes no presets answers with an empty roster, and the row, the chip, the label, and the section all render nothing — every session then shares the host composition, and there is nothing to choose between or manage. A deployment that configures no writable root answers `authorable: false`, and the section stays a read-only browser: the rows still open, but creating is offered nowhere rather than through a button whose save always fails. +A deployment that composes no presets answers with an empty roster, and the row, the chip, the label, and the section all render nothing — every session then shares the host composition, and there is nothing to choose between or manage. A deployment that configures no writable root answers `authorable: false`, and the section stays a read-only browser: the shipped compositions still open in the viewer, but every copy action is disabled with the reason as its tooltip rather than offering a dialog whose create always fails. ## Model Experience @@ -56,6 +58,6 @@ No direct invalidation. Changing the default never touches a running session's p ## Known Limitations and Deferred Work -- **A preset without metadata is listed by id** — display text is optional, and a preset that publishes none (every preset authored by duplicating another starts that way) shows its directory name. -- **The editor is a plain textarea** — no YAML syntax highlighting, folding, or schema completion; the host's shape check on save is the only validation. -- **A saved composition is not mounted** — a preset that parses but names a missing plugin is accepted, and fails at the next session that selects it. +- **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. +- **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. diff --git a/packages/client/ui-agent-preset/README.zh.md b/packages/client/ui-agent-preset/README.zh.md index 707745c547..1fca73a5ef 100644 --- a/packages/client/ui-agent-preset/README.zh.md +++ b/packages/client/ui-agent-preset/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -agent preset 的各个表层:General 设置中的一行,用于选择新建会话据以组装的 [preset](../../preset/agent-presets/README.md);新建会话界面上的一枚 chip,用于选择**下一个会话**的 preset;会话标题旁的一个只读标签;以及一个设置页分区,用于创作组装本身。 +agent preset 的各个表层:General 设置中的一行,用于选择新建会话据以组装的 [preset](../../preset/agent-presets/README.md);新建会话界面上的一枚 chip,用于选择**下一个会话**的 preset;会话标题旁的一个只读标签;以及一个设置页分区,用于管理名单——复制、删除、默认值,以及通往 preset 自身文件的入口。 ## 为什么它是"新建会话"的偏好设置 @@ -30,21 +30,23 @@ chip 以部署默认值打开,其选择是**暂存**的——该界面先于 ## 管理分区 -第四个表层,独立的设置页(`settings.section`,id 为 `agent-presets`,排在「模型」之后——选模型是日常操作,而组装 agent 是它背后那件塑造部署形态的事):名单以行呈现,同一时刻有一份组装在 YAML 编辑器中打开。 +第四个表层,独立的设置页(`settings.section`,id 为 `agent-presets`,排在「模型」之后——选模型是日常操作,而组装 agent 是它背后那件塑造部署形态的事):名单以卡片呈现,复制对话框是创建 preset 的唯一入口,随附组装则在只读查看器中展示。 -随部署提供的 preset 以只读方式打开。它是本地 preset 据以编写的已知良好组装,因此能读到它正是意义所在,而覆写它则不是——部署自带的那一份正是用来对照有问题的本地 preset 的。**复制**复制任意一行;无论文本来自何处,副本总是落在本地根目录,所以副本总是可写的。**新建 preset** 则从空白开始——复制这件事已经由被复制那一行自己提供,而一份没人指名的组装,只会让作者先认出它不是自己想要的、再把它删掉。 +浏览器不再编辑任何组装文本。在网页文本域里编 YAML 是弱功能(无补全、无高亮、无 diff),因此新 preset 是宿主端对既有 preset 的一次复制——对话框只收集一个 id(它将成为目录名,所以必须当场取好、事后无法更改)与一个可选显示名,跨越传输层的只有 `{ from, id, name? }`。其余一切——描述、组装、skills——都在 preset 自己的文件里编辑,而本页的另一职责正是把用户送到那些文件面前:复制以打开新目录作为收尾,每张自定义卡片也保有一个位置操作。宿主没有桌面打开器时(名单上的 `hasDocument: false`;远程与容器部署),同样的操作改为把目录以文本显示在卡片上,而不是提供一个点了没反应的按钮。 -id 会成为目录名,因此编辑器复刻宿主自身的约束规则(`[a-z0-9][a-z0-9-]*`),并拒绝已被占用的名称——新建若落在已存在的名称上,就会覆盖用户从未打开过的 preset。这两项检查只是便利:宿主会连同组装的形状一起重新校验,失败时编辑器报告的正是宿主的答复。能解析的保存也仅仅是保存;引用了不存在插件的组装,会在下一个选择它的会话处失败。 +随附 preset 在只读查看器中打开。它是副本据以出发的已知良好组装,因此能读到它正是意义所在;它不提供位置也不提供删除——它的安装目录会被升级覆盖,不归用户管理。开篇引导语承担了从前创建按钮所暗示的信息:想从最小的骨架开始,就复制极简模式(31 行,对照 standard 的 233 行)。 -删除会移除该文件。已据其组装的会话继续运行——组装在会话创建时挂载一次,此后没有任何东西会重新读取该文件。 +对话框复刻宿主自身的约束规则(`[a-z0-9][a-z0-9-]*`),并拒绝已被占用的名称——复制从不覆写。这两项检查只是便利:宿主会重新校验,失败时对话框报告的正是宿主的答复。 + +删除会移除整个 preset 目录。已据其组装的会话继续运行——组装在会话创建时挂载一次,此后没有任何东西会重新读取该文件。 设置默认值写入的是 `agent-presets` settings 命名空间,宿主需将其暴露给配置客户端([`dsh-apiproxy`](../../host/apiproxy/README.md) 维护一份显式白名单——不在其中的命名空间会让选择器动一下然后悄悄忘记)。 -`agentPreset.read`、`write`、`remove` 与 `select` 被固定在环回地址(见 [`dsh-client-connection`](../connection/README.md)):组装指明了一个会话所运行的插件,因此读取它是侦察,写入它是任意能力。`agentPreset.list` 不在其中——它只携带 id 与信任级别,而局域网客户端的选择器需要它。 +`agentPreset.read`、`copy`、`openDocument` 与 `remove` 被固定在环回地址(见 [`dsh-client-connection`](../connection/README.md)):组装指明了一个会话所运行的插件,因此读取它是侦察,其余几个则管理名单并驱动宿主桌面。`agentPreset.list` 不在其中——它携带 id、信任级别与两个不含路径的能力标志,而局域网客户端的选择器需要它。 ## 何时不显示这些表层 -未组装任何 preset 的部署返回空名单,本行、chip、标签与分区都不渲染任何内容——此时每个会话共用宿主组装,也就无从选择或管理。未配置可写根目录的部署返回 `authorable: false`,分区随之退化为只读浏览:各行仍可打开,但任何位置都不提供"新建",而不是给出一个保存必然失败的按钮。 +未组装任何 preset 的部署返回空名单,本行、chip、标签与分区都不渲染任何内容——此时每个会话共用宿主组装,也就无从选择或管理。未配置可写根目录的部署返回 `authorable: false`,分区随之退化为只读浏览:随附组装仍可在查看器中打开,但每个复制操作都被禁用并以原因作提示,而不是给出一个创建必然失败的对话框。 ## Model Experience @@ -56,6 +58,6 @@ Indirectly, through the preset a later session is composed from; [`dsh-agent-pre ## Known Limitations and Deferred Work -- **没有元数据的 preset 按 id 列出** —— 展示文本是可选的,未发布任何展示文本的 preset(每个由复制他人而来的 preset 起初都是如此)显示的是它的目录名。 -- **编辑器是纯文本域** —— 没有 YAML 语法高亮、折叠或 schema 补全;保存时宿主的形状检查是唯一的校验。 -- **保存的组装不会被挂载** —— 能解析但引用了缺失插件的 preset 会被接受,并在下一个选择它的会话处失败。 +- **没有元数据的 preset 按 id 列出** —— 展示文本是可选的,未取名的副本刻意回退到目录名,而不是与其来源呈现得一模一样。 +- **展示的路径是文本,不是链接** —— 宿主没有桌面打开器时,卡片显示目录供手工复制;浏览器自身无法打开宿主文件系统上的位置。 +- **组装编辑对页面不可见** —— 文件在浏览器之外编辑,传输层不广播文件变动,因此名单只在自身操作、`settings/changed` 与 `connection/reset` 时重读,而非每次磁盘编辑。 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 74f37266f8..8851ae4efe 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css @@ -6,31 +6,6 @@ color: var(--dsw-alias-label-primary); } -/* Editing form: take the settings column's whole height so the composition - gets the space the panel has, instead of a fixed-row box with an empty band - under it. The list keeps the default flow — a card grid pinned to the top - reads as a list, while a stretched one would drift its rows apart. */ -.sectionFill { - height: 100%; -} - -.sectionFill .editor { - flex: 1; - min-height: 0; -} - -.sectionFill .codeField { - flex: 1; - min-height: 0; -} - -.sectionFill .codeField .code { - flex: 1; - min-height: 160px; - /* The drag handle would fight the flex height it is nested in. */ - resize: none; -} - .title { margin: 0; font-size: 18px; @@ -43,17 +18,6 @@ color: var(--dsw-alias-label-tertiary); } -.notice { - margin: 0; - font-size: 12px; - color: var(--dsw-alias-state-warn-label); -} - -.hint { - font-size: 12px; - 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 { @@ -199,6 +163,11 @@ align-items: center; } +.iconButton:disabled { + opacity: 0.4; + cursor: default; +} + .iconButton:hover:not(:disabled) { background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary); @@ -237,6 +206,28 @@ color: var(--dsw-alias-state-error-primary); } +/* Where the host has no desktop opener, the row answers with the directory + itself — text to copy, not a control that would spawn into nothing. */ +.revealedPath { + margin: 0; + padding: 6px 16px 10px; + font-size: 11px; + color: var(--dsw-alias-label-tertiary); + display: flex; + gap: 6px; + align-items: baseline; +} + +.revealedPath code { + font-family: var(--dsw-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); + color: var(--dsw-alias-label-secondary); + user-select: all; + overflow-wrap: anywhere; +} + +.revealedPathLabel { + white-space: nowrap; +} .secondaryButton { border: none; @@ -254,18 +245,11 @@ background: var(--dsw-alias-bg-layer-1); } -.secondaryButton:disabled, -.addButton:disabled { +.secondaryButton:disabled { opacity: 0.5; cursor: default; } -.editor { - display: flex; - flex-direction: column; - gap: 12px; -} - .field { display: flex; flex-direction: column; @@ -278,8 +262,7 @@ color: var(--dsw-alias-label-secondary); } -.input, -.code { +.input { box-sizing: border-box; padding: 9px 12px; border: 1px solid var(--dsw-alias-border-l2); @@ -290,18 +273,7 @@ color: var(--dsw-alias-label-primary); } -.code { - font-family: var(--dsw-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); - line-height: 1.5; - resize: vertical; - white-space: pre; - overflow-wrap: normal; - overflow-x: auto; - tab-size: 2; -} - -.input:focus, -.code:focus { +.input:focus { outline: none; border-color: var(--dsw-alias-brand-primary); } @@ -310,67 +282,35 @@ color: var(--dsw-alias-label-dimmed); } -/* A shipped composition is drawn a rung up, and it is the one most likely to - overflow, so its scroll thumb rebinds to that rung. */ -.code[readonly] { - color: var(--dsw-alias-label-secondary); +.dialog { + width: min(560px, 100%); +} + +.dialogFields { + display: flex; + flex-direction: column; + gap: 12px; +} + +/* A shipped composition can be long; the dialog scrolls it rather than grow. */ +.viewerCode { + margin: 0; + padding: 12px; + max-height: min(52vh, 480px); + overflow: auto; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 10px; background: var(--dsw-alias-bg-layer-2); + color: var(--dsw-alias-label-secondary); + font-family: var(--dsw-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); + font-size: 12.5px; + line-height: 1.5; + white-space: pre; + tab-size: 2; --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); } -.editorActions { - display: flex; - justify-content: flex-end; - gap: 8px; -} - -.editorBar { - display: flex; - align-items: baseline; - gap: 12px; - padding-bottom: 4px; - border-bottom: 1px solid var(--dsw-alias-border-l2); -} - -.backButton { - appearance: none; - border: 0; - background: none; - padding: 0; - font: inherit; - font-size: 13px; - color: var(--dsw-alias-label-tertiary); - cursor: pointer; -} - -.backButton:hover { - color: var(--dsw-alias-label-primary); -} - -.backButton:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); - outline-offset: 2px; - border-radius: 4px; -} - -.editorTitle { - font-size: 14px; - font-weight: 600; -} - -.addButton { - align-self: flex-start; - border: 1px solid var(--dsw-alias-border-l2); - border-radius: 999px; - padding: 8px 16px; - font: inherit; - font-size: 13px; - background: var(--dsw-alias-bg-layer-3); - color: inherit; - cursor: pointer; -} - .error { margin: 0; font-size: 12px; diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx index dbd6ff05d0..8e4557dbf3 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx @@ -1,21 +1,23 @@ /** - * Agent-presets settings section: the roster as rows, and one composition - * open in a YAML editor at a time. + * Agent-presets settings section: the roster as cards, a copy dialog as the + * only way a preset is created, and a read-only viewer over the shipped + * compositions. * - * A shipped preset opens read-only — it is the known-good composition a local - * one is written against — so authoring starts by duplicating one. Deleting a - * preset leaves running sessions alone: a composition is mounted once at - * session creation and nothing re-reads the file. + * The browser edits no composition text — a shipped preset opens read-only to + * be READ (it is the known-good composition a copy starts from), and a custom + * preset is edited in its own files, which is what the location action leads + * to. Deleting a preset leaves running sessions alone: a composition is + * mounted once at session creation and nothing re-reads the file. */ import { useEffect } from 'react' import type { ReactNode } from 'react' import { - Button, IconBrowseOutline16, IconCopyOutline16, IconEditOutline16, IconTrashOutline16, Modal, + Button, IconBrowseOutline16, IconCopyOutline16, IconFolderOpen16, IconTrashOutline16, Modal, } from '@deepseek-ai/dsh-client-ui-primitives' import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' -import { draftBlocker, type AgentPresetSectionState, type PresetDraft } from './section-store.ts' +import { draftBlocker, type AgentPresetSectionState } from './section-store.ts' import type { AgentPresetSettingsKey } from './locales.ts' import css from './AgentPresetSection.module.css' @@ -27,22 +29,22 @@ export interface AgentPresetSectionInjected { } /** Read the roster; called once when the section first renders. */ load: () => Promise - /** Open one preset's composition in the editor. */ - open: (id: string) => Promise - /** Open a copy of one preset — or of the default — as a new preset. */ - createFrom: (from?: string) => Promise - /** Close the editor, discarding the draft. */ - close: () => void - /** Name the preset a new draft saves to. */ - setId: (id: string) => void - /** Replace the draft's composition text. */ - setContent: (content: string) => void - /** Rename the draft. */ - setName: (name: string) => void - /** Replace the draft's description. */ - setDescription: (description: string) => void - /** Save the open draft. */ - save: () => Promise + /** Open one shipped preset's composition in the read-only viewer. */ + view: (id: string) => Promise + /** Close the read-only viewer. */ + closeView: () => void + /** Open the copy dialog over one preset. */ + beginCopy: (from: string) => void + /** Close the copy dialog, discarding the draft. */ + cancelCopy: () => void + /** Name the preset the copy creates. */ + setCopyId: (id: string) => void + /** Name the copy's display name. */ + setCopyName: (name: string) => void + /** Submit the copy. */ + confirmCopy: () => Promise + /** Open one preset's directory, or reveal its path where there is no desktop. */ + openLocation: (id: string) => Promise /** Ask for delete confirmation, or dismiss it with null. */ confirmDelete: (id: string | null) => void /** Delete the preset awaiting confirmation. */ @@ -57,93 +59,73 @@ export type AgentPresetSectionProps = & PropsLocale<'settings.agentPreset'> & InjectFace -/** Editor sub-view props: the draft plus the actions that mutate it. */ -interface EditorProps { - draft: PresetDraft - blocker: ReturnType +/** Copy-dialog sub-view props: the draft plus the actions that mutate it. */ +interface CopyDialogProps { + state: AgentPresetSectionState t: (key: AgentPresetSettingsKey) => string actions: Pick + 'cancelCopy' | 'confirmCopy' | 'setCopyId' | 'setCopyName'> } -function Editor({ draft, blocker, t, actions }: EditorProps): ReactNode { - const message = draft.error ?? (blocker === undefined ? null : t(blocker)) +function CopyDialog({ state, t, actions }: CopyDialogProps): ReactNode { + const draft = state.copy + const blocker = draft === null ? undefined : draftBlocker(draft, state.rows) + const message = draft === null ? null : draft.error ?? (blocker === undefined ? null : t(blocker)) return ( -
- {draft.creating - ? ( - - ) - : null} - {draft.writable - ? ( - <> + { actions.cancelCopy() }} + title={draft === null ? t('copyTitle') : `${t('copyTitle')} · ${t('copyOf')} ${draft.fromTitle}`} + closeLabel={t('close')} + description={t('copyIntro')} + className={css.dialog as string} + footer={( + <> + + + + )} + > + {draft === null + ? null + : ( +
+ - - - ) - : null} - {draft.writable ? null :

{t('readOnlyNotice')}

} -