diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md index cc40af246d..6f14c2ad99 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md @@ -53,6 +53,10 @@ Which preset an unnamed session gets is a user setting (`agent-presets.default`) **Switching is allowed only while a session is blank.** Once a turn has run, that history was produced under the preset's tools and swapping them would strand logged tool calls, so `agentPreset.select` answers `agent-preset-locked`. A blank switch keeps the agent and the session and replaces only the subtree, because the host discards the `AgentHandle` it creates and there is no delete RPC — and keeping them is the better outcome anyway, since the session id, its workspace attachment, and its projections all stay put. The swap is unmount-then-mount (two compositions would register the same tool names into one layer), so it resolves the new preset before tearing anything down and restores the previous one when the new mount fails. +**Authoring a preset is an RPC, and a privileged one.** A composition is a file, but "edit it on the filesystem" is not a browser affordance, so the roster gained `read`/`write`/`remove` beside `select`. All four are loopback-pinned: a composition names the plugins a session runs, so reading one is reconnaissance, writing one is arbitrary capability, and selecting one can move a session onto a preset that edits the live runtime. `list` deliberately stays ordinary — ids and trust only, and a LAN client's picker needs it. Containment is a property of the id (`[a-z0-9][a-z0-9-]*`), checked before it becomes a directory name rather than by inspecting the joined path afterwards; the text is parsed with the loader's own schema and dialect, so a save cannot leave a file no session could load. Shipped presets are refused for writes and deletes, because the deployment's copy is what a broken local preset is compared against — which also makes "duplicate, then edit" the authoring path rather than an afterthought. + +**A preset's package names must resolve from the harness, not from the preset.** `EntryTree.import()` resolves a row against its own tree's `baseUrl`, which `Include` sets to the composition's directory. That is right for a relative specifier and fatal for a package name: a locally authored preset lives under the user's home, where Node's upward `node_modules` walk never reaches the installed harness, so every `@deepseek-ai/dsh-*` row fails to import and the whole preset is unmountable. The shipped presets hid this — they sit inside the install. The mount records the host composition's base before plugging the subtree and sends bare specifiers there, leaving relative paths resolving from the preset so its own files still travel with it. The real-composition test writing a preset into a temp root is what found it. + **The preset id is model-visible and must be logged.** It determines the tool set and prompt, so a resumed session has to restore the same composition; recording it is a session fact, not runtime state. It rides the session header beside `cwd`, and the summary carries it so a picker shows what a session actually runs rather than the deployment's current default. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md index 5b6113ee9f..5d34c07872 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md @@ -54,6 +54,10 @@ Status: implemented **只有空白会话才允许切换。** 一旦跑过任何轮次,那段历史就是在该 preset 的工具下产生的,替换会留下无法执行的已记录 tool call,因此 `agentPreset.select` 返回 `agent-preset-locked`。空白期的切换保留 agent 与 session,只替换子树——因为宿主丢弃了它创建的 `AgentHandle`,也没有 delete RPC;而保留它们本身就是更好的结果,会话 id、workspace 挂接与 projections 都原地不动。该替换是"先卸后装"(两份组装会把同名工具注册进同一分层),因此它在拆除任何东西之前先解析新 preset,并在新组装装载失败时恢复原来的那一份。 +**创作 preset 是一次 RPC,而且是特权 RPC。** 组装是一个文件,但“去文件系统里改它”并不是浏览器能提供的操作,因此名单在 `select` 之外新增了 `read`/`write`/`remove`。这四者都被固定在环回地址:组装指明了一个会话所运行的插件,因此读取它是侦察,写入它是任意能力,而选择它可以把会话切到一个能编辑活动运行时的 preset 上。`list` 刻意保持为普通方法——只有 id 与信任级别,而局域网客户端的选择器需要它。约束是 id 自身的性质(`[a-z0-9][a-z0-9-]*`),在它成为目录名之前就检查,而不是事后再去审视拼接出的路径;文本使用 loader 自身的 schema 与方言解析,因此保存不会留下任何会话都无法加载的文件。随部署提供的 preset 拒绝写入与删除,因为部署自带的那一份正是用来对照有问题的本地 preset 的——这也让“先复制、再编辑”成为创作路径本身,而非事后补充。 + +**preset 的包名必须从 harness 解析,而非从 preset 解析。** `EntryTree.import()` 按行所属树的 `baseUrl` 解析,而 `Include` 把它设为组装文件所在的目录。这对相对标识符是对的,对包名却是致命的:本地创作的 preset 位于用户主目录之下,Node 向上查找 `node_modules` 永远够不到已安装的 harness,因此每一个 `@deepseek-ai/dsh-*` 行都会导入失败,整个 preset 无法挂载。随部署提供的 preset 掩盖了这一点——它们本就在安装目录之内。挂载在插入子树之前先记录宿主组装的基址,并把裸标识符送往那里,同时让相对路径继续从 preset 解析,使它自带的文件仍随它一同迁移。发现它的正是那个把 preset 写入临时根目录的真实组装测试。 + **preset id 对模型可见,必须写入日志。** 它决定工具集与提示词,因此被恢复的会话必须还原同一份组装;记录它属于会话事实,而非运行时状态。它与 `cwd` 并列写在会话头部,并由会话摘要携带,使选择器显示的是某个会话实际运行的 preset,而非部署当前的默认值。 ## 考虑过的替代方案 diff --git a/apps/cli/tests/web-agent-presets.spec.ts b/apps/cli/tests/web-agent-presets.spec.ts index 6b973d00a6..f8ff094e33 100644 --- a/apps/cli/tests/web-agent-presets.spec.ts +++ b/apps/cli/tests/web-agent-presets.spec.ts @@ -1,4 +1,4 @@ -import { mkdtemp, readFile, writeFile } from 'node:fs/promises' +import { mkdtemp, readFile, stat, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { fileURLToPath } from 'node:url' import { join } from 'node:path' @@ -21,7 +21,7 @@ const WEB_OVERLAY = join(CONFIG_DIR, 'web.cordis.yml') * touch the network, or write outside the test. Everything that decides an * agent's capabilities is the real thing, including both shipped presets. */ -async function bootWeb(settingsFile: string): Promise { +async function bootWeb(settingsFile: string, extra: PatchOptions[] = []): Promise { const patches: PatchOptions[] = [ ...loadOverlayPatches('dsh-test', WEB_OVERLAY), // The settings row defaults to `$DSH_HOME/settings.yaml`. Left alone it @@ -51,6 +51,7 @@ async function bootWeb(settingsFile: string): Promise { id: 'agent-presets', config: { default: 'standard', roots: [{ path: join(CONFIG_DIR, 'agent-presets'), trust: 'system' }] }, }, + ...extra, ] return await boot('dsh-test', BASE_CONFIG, patches) } @@ -289,6 +290,79 @@ describe('a forked session', () => { }) }) +describe('authoring a preset on the shipped composition', () => { + let authorCtx: Context + let userRoot: string + + beforeAll(async () => { + userRoot = join(await mkdtemp(join(tmpdir(), 'dsh-preset-authoring-')), 'presets') + const settingsFile = join(await mkdtemp(join(tmpdir(), 'dsh-preset-authoring-settings-')), 'settings.yaml') + await writeFile(settingsFile, '{}\n') + authorCtx = await bootWeb(settingsFile, [{ + id: 'agent-presets', + config: { + default: 'standard', + roots: [ + { path: join(CONFIG_DIR, 'agent-presets'), trust: 'system' }, + // The root does not exist yet: a deployment whose user has authored + // nothing is the normal first-run state. + { path: userRoot, trust: 'user' }, + ], + }, + }]) + }) + + 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/) + 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() + }) + + 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('writes a preset a session then really composes from', async () => { + const copied = await authorCtx.agentPresets.read('core-web') + + 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. + const preset = await authorCtx.agentPresets.resolve('my-agent') + expect(preset.trust).toBe('user') + expect(await authorCtx.agentPresets.read('my-agent')).toBe(copied) + // 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) + const handle = await authorCtx.agents.create({ + sessionId: SessionId('preset-authored'), + setup: agentCtx => authorCtx.agentPresets.mount(agentCtx, 'my-agent').then(() => undefined), + }) + try { + // The same tools the shipped `core-web` composes, from a file written + // through the service into a root outside the installed harness. + expect(toolNames(authorCtx, handle.agent)).toEqual(['ask_user_question', 'bash', 'str_replace_editor']) + } finally { + await handle.dispose() + } + }) + + it('deletes what it wrote', async () => { + await authorCtx.agentPresets.write('doomed', '- id: tool-web-search\n name: \'@deepseek-ai/dsh-tool-web-search\'\n') + + await authorCtx.agentPresets.remove('doomed') + + expect((await authorCtx.agentPresets.list()).map(preset => preset.id)).not.toContain('doomed') + }) +}) + /** * Which preset an unnamed session gets is a user setting layered over the * composition's own default. The package suite proves the layering against a diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 4244e1dae9..84154c375e 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -50,40 +50,68 @@ Source: [`packages/core/agent-loop/src/index.ts:277`](../../packages/core/agent- Registry over the deployment's agent presets. -Discovery is unmemoized: `list()` and `resolve()` re-read the roots on every call so a preset authored while the process runs is visible immediately, and a preset deleted underneath a picker disappears from the next read. +Discovery is unmemoized: `list()` and `resolve()` re-read the roots on every call so a profile authored while the process runs is visible immediately, and a profile deleted underneath a picker disappears from the next read. ```ts cordis-catalog /** - * Every preset the configured roots currently supply. - * @returns the presets, first-root-wins per id. + * Every profile the configured roots currently supply. + * @returns the profiles, first-root-wins per id. */ async list(): Promise /** - * Resolve one preset by id. - * @param id - the preset id, or `undefined` for {@link defaultId}. - * @returns the resolved preset. + * Resolve one profile by id. + * @param id - the profile id, or `undefined` for {@link defaultId}. + * @returns the resolved profile. * @throws when no configured root supplies that id. */ async resolve(id?: string): Promise /** - * Compose one agent from a preset, installing it under that agent alone. + * Compose one agent from a profile, installing it under that agent alone. * * Call from the agent factory's `setup(agentCtx)`; a rejection there rolls - * the agent creation back, so a broken preset never yields a half-composed + * the agent creation back, so a broken profile never yields a half-composed * session. * @param agentCtx - the agent's scope context. - * @param id - the preset id, or `undefined` for {@link defaultId}. - * @returns the preset that was mounted, for the caller to record. - * @throws when the preset is unknown or its composition is unusable. + * @param id - the profile id, or `undefined` for {@link defaultId}. + * @returns the profile that was mounted, for the caller to record. + * @throws when the profile is unknown or its composition is unusable. */ async mount(agentCtx: Context, id?: string): Promise /** - * One agent's instance of a service its preset mounted. + * Read one profile's composition text. + * @param id - the profile id. + * @returns the composition exactly as stored. + * @throws when no configured root supplies that id. + */ +async read(id: string): Promise + +/** + * Create or replace a locally authored profile. * - * A preset publishes services behind `isolate` realms, which are invisible + * 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 profile id, which becomes its directory name. + * @param content - the composition text. + * @throws when the id is unusable, the text is not an entry list, or the + * deployment configures no writable root. + */ +async write(id: string, content: string): Promise + +/** + * Delete a locally authored profile. + * @param id - the profile id. + * @throws when the profile is unknown or ships with the deployment. + */ +async remove(id: string): Promise + +/** + * One agent's instance of a service its profile mounted. + * + * A profile publishes services behind `isolate` realms, which are invisible * outside the group that declares them — including to the host. This is how a * caller holding the agent reads one anyway: a request that is ABOUT a * session but arrives from outside it, which is every browser RPC. @@ -92,8 +120,8 @@ async mount(agentCtx: Context, id?: string): Promise * because injection resolves before any session exists and has no agent to * key by; such a service belongs on the host plane instead. * @param agent - the agent whose composition to look inside. - * @param name - the service name as the preset's rows resolve it. - * @returns the agent's instance, or undefined when its preset mounts none. + * @param name - the service name as the profile's rows resolve it. + * @returns the agent's instance, or undefined when its profile mounts none. */ serviceFor(agent: { ctx: Context }, name: K): Context[K] | undefined @@ -109,15 +137,15 @@ serviceFor(agent: { ctx: Context }, name: K): * therefore restores the previous composition rather than leaving the agent * with nothing. * @param agentCtx - the agent's scope context. - * @param id - the preset to compose the agent from instead. - * @returns the preset now installed. - * @throws when the preset is unknown or its composition is unusable; the + * @param id - the profile to compose the agent from instead. + * @returns the profile now installed. + * @throws when the profile is unknown or its composition is unusable; the * previous composition is restored first. */ async recompose(agentCtx: Context, id: string): Promise ``` -Source: [`packages/preset/agent-presets/src/index.ts:57`](../../packages/preset/agent-presets/src/index.ts) +Source: [`packages/preset/agent-presets/src/index.ts:63`](../../packages/preset/agent-presets/src/index.ts) ## `ctx.agents` — `AgentRegistry` diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index 05b9bb4141..2072e6399e 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: 1393e79aacecbbf7b186f19e4c42269595854b0e -README.zh.md: 70380ceba1b16b2970e947fb6cd9b2af9085ae51 +README.md: 526df44ce2a167e6f06bedea6d57e4d703848e89 +README.zh.md: 02bba4aeed7155ebacae2ad66b4325d069f37301 diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index 1393e79aac..0392d51a92 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. 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) 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. 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.select`/`read`/`write`/`remove`, since a composition names the plugins a session runs, so reading one is reconnaissance, writing one is arbitrary capability, and selecting one can move a session onto a preset that edits the live runtime; `agentPreset.list` stays out, carrying only ids and trust) 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 70380ceba1..e82b25bf30 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;进程内载体满足同一双流抽象。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 桌面,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 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;进程内载体满足同一双流抽象。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.select`/`read`/`write`/`remove`,因为组装指明了一个会话所运行的插件,读取它是侦察,写入它是任意能力,而选择它可以把会话切到一个能编辑活动运行时的 preset 上;`agentPreset.list` 不在其中,它只携带 id 与信任级别)以空信任表过信任 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 db28dff49d..6383e9edac 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -1339,6 +1339,17 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { // DeepSeek route so unrelated GUI journeys do not enter first-run setup. ['DEEPSEEK_API_KEY', true], ]) + /** + * Preset compositions the fixture serves. Held as state rather than + * constants so the settings editor's save and delete are exercisable: the + * roster a GUI journey sees after writing is the text it wrote. + */ + const fixturePresets = new Map([ + ['standard', { trust: 'system', content: "- id: tool-bash\n name: '@deepseek-ai/dsh-tool-bash'\n" }], + ['core-web', { trust: 'system', content: "- id: tool-web-search\n name: '@deepseek-ai/dsh-tool-web-search'\n" }], + ['my-agent', { trust: 'user', content: "- id: tool-read\n name: '@deepseek-ai/dsh-tool-read'\n" }], + ]) + let fixtureDefaultPreset = 'standard' const nextTurn = new Map([[sid('fx-alpha'), 60]]) let nextSession = 1 let nextRpc = 1 @@ -2313,15 +2324,63 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { }, }, agentPresets: { - // Two rows so a picker has something to choose between, and so the - // trust distinction a surface must present is visible in the fixture. + // Both trusts appear, because a surface must present a locally authored + // preset differently from one the deployment vetted. list: request => ok(request, { - presets: [ - { id: 'standard', trust: 'system' as const, isDefault: true }, - { id: 'core-web', trust: 'system' as const, isDefault: false }, - ], + presets: [...fixturePresets].map(([id, preset]) => ({ + id, + trust: preset.trust, + isDefault: id === fixtureDefaultPreset, + })), + authorable: true, }), - select: request => ok(request, { agentPreset: request.payload.agentPreset }), + select: (request) => { + fixtureDefaultPreset = request.payload.agentPreset + return ok(request, { agentPreset: request.payload.agentPreset }) + }, + read: (request) => { + const { agentPreset } = request.payload + const preset = fixturePresets.get(agentPreset) + if (preset === undefined) { + return err(request, { + code: 'agent-preset-not-found', + message: `unknown agent preset "${agentPreset}"`, + details: { agentPreset, available: [...fixturePresets.keys()] }, + }) + } + return ok(request, { + agentPreset, + trust: preset.trust, + content: preset.content, + writable: preset.trust === 'user', + }) + }, + write: (request) => { + const { agentPreset, content } = request.payload + const existing = fixturePresets.get(agentPreset) + if (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 }) + }, + remove: (request) => { + const { agentPreset } = request.payload + const existing = fixturePresets.get(agentPreset) + if (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.delete(agentPreset) + return ok(request, {}) + }, }, skills: { @@ -2623,6 +2682,9 @@ export class FixtureApiClient extends AbstractApiClient { case 'skill.list': return this.api.skills.list(request) 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.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) case 'goal.pause': return this.api.goals.pause(request) diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index 2e27a78d70..af44fc2cf1 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -55,6 +55,17 @@ export const Config: z = z.object({ * keys, or key state — and a LAN client's model picker legitimately needs it. */ 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. `agentPreset.select` joins them because + // it can move a session from a two-tool preset onto one that edits the live + // runtime, which is a real escalation even though every candidate is already + // installed. `agentPreset.list` deliberately stays out: it carries ids and + // trust only, like the model catalog, and a LAN client's picker needs it. + 'agentPreset.select', + 'agentPreset.read', + 'agentPreset.write', + 'agentPreset.remove', 'host.pickDirectory', 'host.openPath', 'settings.describe', diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index 48812e4fd6..bcde736fa6 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -168,9 +168,17 @@ export class FakeApiClient implements IApiClient { } readonly agentPresets: IApiClient['agentPresets'] = { - list: (payload: unknown) => this.record('agentPreset.list', payload, Promise.resolve(ok({ presets: [] }))), + list: (payload: unknown) => this.record('agentPreset.list', payload, Promise.resolve(ok({ presets: [], authorable: 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, + }))), + write: (payload: { agentPreset: string }) => + this.record('agentPreset.write', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))), + remove: (payload: { agentPreset: string }) => + this.record('agentPreset.remove', payload, Promise.resolve(ok({}))), } readonly skills: IApiClient['skills'] = { diff --git a/packages/client/connection/tests/node-half.spec.ts b/packages/client/connection/tests/node-half.spec.ts index 3015881d2f..4335e6e0d4 100644 --- a/packages/client/connection/tests/node-half.spec.ts +++ b/packages/client/connection/tests/node-half.spec.ts @@ -138,6 +138,10 @@ describe('connection node half', () => { 'settings.describe', 'settings.openDocument', 'settings.update', 'settings.replace', 'settings.mutate', 'credentials.describe', 'credentials.set', 'credentials.unset', 'llm.discoverModels', + // A composition names the plugins a session runs: reading one is + // reconnaissance, writing one is arbitrary capability, and selecting one + // can move a session onto a preset that edits the live runtime. + 'agentPreset.select', 'agentPreset.read', 'agentPreset.write', 'agentPreset.remove', ]) { const denied = fakeResponse() await routes[0]!.handler( @@ -226,13 +230,16 @@ 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.select', 'agentPreset.read', 'agentPreset.write', 'agentPreset.remove', ]) { expect([method, await call(port, method, 'harness.example')]).toEqual([method, 403]) } // The model catalog stays reachable for the same authority: a LAN // client's model picker needs it, and it carries no key or endpoint // state (404 is the empty proxy's carrier answer — the fence passed). - for (const method of ['llm.providers', 'llm.models']) { + // `agentPreset.list` joins the model catalog for the same reason: ids and + // trust only, and a LAN client's preset picker needs it. + for (const method of ['llm.providers', 'llm.models', 'agentPreset.list']) { expect([method, await call(port, method, 'harness.example')]).toEqual([method, 404]) } // Loopback reaches everything, configuration included. diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index b715fb329b..584d4dc0f0 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -203,9 +203,17 @@ export class FakeApiClient implements IApiClient { } readonly agentPresets: IApiClient['agentPresets'] = { - list: (payload: unknown) => this.record('agentPreset.list', payload, Promise.resolve(ok({ presets: [] }))), + list: (payload: unknown) => this.record('agentPreset.list', payload, Promise.resolve(ok({ presets: [], authorable: 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, + }))), + write: (payload: { agentPreset: string }) => + this.record('agentPreset.write', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))), + remove: (payload: { agentPreset: string }) => + this.record('agentPreset.remove', payload, Promise.resolve(ok({}))), } readonly skills: IApiClient['skills'] = { diff --git a/packages/client/ui-agent-preset/README.i18n.yaml b/packages/client/ui-agent-preset/README.i18n.yaml index 9a5fdd5002..40e443b224 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: 14921afb7b90bb0b42a8f7f83ebc78773e8419a3 -README.zh.md: 06807199e996a6ae9d8a4216b8f686b6bbc044d9 +README.md: d775daf1e91c6eb9ebca69c7d0484c0029e93cfc +README.zh.md: b3fb3e64324dcb78465843ba2ba58f5a3efa3d08 diff --git a/packages/client/ui-agent-preset/README.md b/packages/client/ui-agent-preset/README.md index 14921afb7b..9425b50af9 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 surface: one General-settings row choosing which [preset](../../preset/agent-presets/README.md) new sessions are composed from. +The agent-preset surfaces: a General-settings row choosing which [preset](../../preset/agent-presets/README.md) new sessions are composed from, a composer seat choosing this session's, and a settings section that authors the compositions themselves. ## Why it is a new-session preference @@ -22,9 +22,21 @@ A locally authored preset is exactly as privileged as the plugins it names, so t The row re-reads on `settings/changed` for its own namespace and on `connection/reset`: the roster is a live directory and the default is a settings field, so an external edit or a reconnect can both move it. -## When the row is absent +## The management section -A deployment that composes no presets answers with an empty roster, and the row renders nothing — every session then shares the host composition, and there is nothing to choose between. +A third surface, its own settings page: the roster as rows, and one composition open in a YAML editor at a time. + +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. Authoring therefore starts by duplicating: **New preset** copies the current default, and **Duplicate** copies any row, because a copy always lands in the local root regardless of where the text came from. + +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. + +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. + +`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. + +## When the surfaces are absent + +A deployment that composes no presets answers with an empty roster, and the row, the seat, 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. ## Model Experience @@ -36,5 +48,6 @@ No direct invalidation. Changing the default never touches a running session's p ## Known Limitations and Deferred Work -- **Presets are listed by id** — a preset carries no display metadata, so the menu shows directory names. -- **No authoring** — creating, editing, or deleting a preset is a filesystem act; this surface only chooses among what the roster supplies. +- **Presets are listed by id** — a preset carries no display metadata, so the menus and rows show directory names. +- **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. diff --git a/packages/client/ui-agent-preset/README.zh.md b/packages/client/ui-agent-preset/README.zh.md index 06807199e9..c5be1ea017 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)。 +agent preset 的各个表层:General 设置中的一行,用于选择新建会话据以组装的 [preset](../../preset/agent-presets/README.md);composer 中的一个座位,用于选择**本会话**的 preset;以及一个设置页分区,用于创作组装本身。 ## 为什么它是"新建会话"的偏好设置 @@ -22,9 +22,21 @@ agent preset 表层:General 设置中的一行,用于选择新建会话据 本行在自身命名空间的 `settings/changed` 以及 `connection/reset` 时重新读取:名单是一个活动目录,默认值是一项设置,外部编辑与重新连接都可能改变它。 -## 何时不显示本行 +## 管理分区 -未组装任何 preset 的部署返回空名单,本行不渲染任何内容——此时每个会话共用宿主组装,也就无从选择。 +第三个表层,独立的设置页:名单以行呈现,同一时刻有一份组装在 YAML 编辑器中打开。 + +随部署提供的 preset 以只读方式打开。它是本地 preset 据以编写的已知良好组装,因此能读到它正是意义所在,而覆写它则不是——部署自带的那一份正是用来对照有问题的本地 preset 的。因此创作从复制开始:**新建 preset** 复制当前默认值,**复制**则复制任意一行;无论文本来自何处,副本总是落在本地根目录,所以副本总是可写的。 + +id 会成为目录名,因此编辑器复刻宿主自身的约束规则(`[a-z0-9][a-z0-9-]*`),并拒绝已被占用的名称——新建若落在已存在的名称上,就会覆盖用户从未打开过的 preset。这两项检查只是便利:宿主会连同组装的形状一起重新校验,失败时编辑器报告的正是宿主的答复。能解析的保存也仅仅是保存;引用了不存在插件的组装,会在下一个选择它的会话处失败。 + +删除会移除该文件。已据其组装的会话继续运行——组装在会话创建时挂载一次,此后没有任何东西会重新读取该文件。 + +`agentPreset.read`、`write`、`remove` 与 `select` 被固定在环回地址(见 [`dsh-client-connection`](../connection/README.md)):组装指明了一个会话所运行的插件,因此读取它是侦察,写入它是任意能力。`agentPreset.list` 不在其中——它只携带 id 与信任级别,而局域网客户端的选择器需要它。 + +## 何时不显示这些表层 + +未组装任何 preset 的部署返回空名单,本行、座位与分区都不渲染任何内容——此时每个会话共用宿主组装,也就无从选择或管理。未配置可写根目录的部署返回 `authorable: false`,分区随之退化为只读浏览:各行仍可打开,但任何位置都不提供"新建",而不是给出一个保存必然失败的按钮。 ## Model Experience @@ -36,5 +48,6 @@ Indirectly, through the preset a later session is composed from; [`dsh-agent-pre ## Known Limitations and Deferred Work -- **preset 按 id 列出** —— preset 不携带展示用元数据,因此菜单显示的是目录名。 -- **不提供创作能力** —— 创建、编辑或删除 preset 是文件系统行为;本表层只在名单提供的范围内做选择。 +- **preset 按 id 列出** —— preset 不携带展示用元数据,因此菜单与各行显示的是目录名。 +- **编辑器是纯文本域** —— 没有 YAML 语法高亮、折叠或 schema 补全;保存时宿主的形状检查是唯一的校验。 +- **保存的组装不会被挂载** —— 能解析但引用了缺失插件的 preset 会被接受,并在下一个选择它的会话处失败。 diff --git a/packages/client/ui-agent-preset/package.json b/packages/client/ui-agent-preset/package.json index 30f15ac353..6b42c14ec2 100644 --- a/packages/client/ui-agent-preset/package.json +++ b/packages/client/ui-agent-preset/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-client-ui-agent-preset", - "description": "Agent-preset surface: the default preset for later sessions, in General settings", + "description": "Agent-preset surfaces: the default for later sessions, this session's seat, and the composition editor", "version": "0.0.1", "private": true, "type": "module", @@ -27,7 +27,8 @@ "@deepseek-ai/dsh-client-connection", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-runtime", - "@deepseek-ai/dsh-client-ui-conversation" + "@deepseek-ai/dsh-client-ui-conversation", + "@deepseek-ai/dsh-client-ui-settings" ], "platform": "web" }, @@ -42,7 +43,9 @@ "@deepseek-ai/dsh-client-runtime": "^0.0.1", "@deepseek-ai/dsh-client-ui-conversation": "^0.0.1", "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", + "@deepseek-ai/dsh-client-ui-settings": "^0.0.1", "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", + "@deepseek-ai/dsh-client-web-react": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "cordis": "^4.0.0-rc.7", "react": "^18.2.0" @@ -51,9 +54,12 @@ "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-client-web-react": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", "cordis": "^4.0.0-rc.7", diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css b/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css new file mode 100644 index 0000000000..67c44a20ca --- /dev/null +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css @@ -0,0 +1,215 @@ +.section { + display: flex; + flex-direction: column; + gap: 12px; + max-width: 720px; + color: var(--dsw-alias-label-primary); +} + +.title { + margin: 0; + font-size: 18px; + font-weight: 600; +} + +.intro { + margin: 0; + font-size: 13px; + 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); +} + +.rows { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 10px; +} + +.rowCard { + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 12px; + padding: 12px 14px; + display: flex; + flex-direction: column; + gap: 12px; + background: var(--dsw-alias-bg-layer-3); +} + +.rowHead { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.rowName { + font-size: 15px; + font-weight: 600; +} + +.badge, +.defaultBadge { + border-radius: 999px; + padding: 2px 8px; + font-size: 11px; + line-height: 16px; +} + +.badge { + border: 1px solid var(--dsw-alias-border-l2); + color: var(--dsw-alias-label-tertiary); +} + +.defaultBadge { + background: var(--dsw-alias-brand-primary); + color: var(--dsw-alias-label-primary-foreground); +} + +.rowActions { + display: inline-flex; + gap: 8px; + margin-left: auto; +} + +.secondaryButton { + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 999px; + padding: 6px 14px; + background: var(--dsw-alias-bg-layer-3); + color: inherit; + font: inherit; + font-size: 13px; + cursor: pointer; +} + +.dangerButton { + border: none; + background: none; + color: var(--dsw-alias-state-error-primary); + font: inherit; + font-size: 13px; + cursor: pointer; +} + +.secondaryButton:disabled, +.dangerButton:disabled, +.addButton:disabled { + opacity: 0.5; + cursor: default; +} + +.editor { + display: flex; + flex-direction: column; + gap: 12px; +} + +.field { + display: flex; + flex-direction: column; + gap: 6px; +} + +.fieldLabel { + font-size: 12px; + font-weight: 500; + color: var(--dsw-alias-label-secondary); +} + +.input, +.code { + box-sizing: border-box; + padding: 9px 12px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 10px; + font: inherit; + font-size: 13px; + background: var(--dsw-alias-bg-layer-1); + 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 { + outline: none; + border-color: var(--dsw-alias-brand-primary); +} + +.input::placeholder { + 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); + background: var(--dsw-alias-bg-layer-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; +} + +.addCard { + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 12px; + background: var(--dsw-alias-bg-layer-3); + padding: 14px 16px; +} + +.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; + color: var(--dsw-alias-state-error-primary); +} + +.deleteDialog { + width: min(480px, 100%); +} + +.deleteConfirm:not(:disabled) { + border-color: var(--dsw-alias-state-error-primary); + color: var(--dsw-alias-state-error-primary); +} + +.deleteConfirm:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover-danger); +} diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx new file mode 100644 index 0000000000..bbd44aebfe --- /dev/null +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx @@ -0,0 +1,254 @@ +/** + * Agent-presets settings section: the roster as rows, and one composition + * open in a YAML editor at a time. + * + * 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. + */ + +import { useEffect } from 'react' +import type { ReactNode } from 'react' +import { Button, 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 type { AgentPresetSettingsKey } from './locales.ts' +import css from './AgentPresetSection.module.css' + +/** Registration-side business face for the management section. */ +export interface AgentPresetSectionInjected { + hooks: { + /** Page snapshot bound by the renderer as useAgentPresetSection. */ + agentPresetSection: SnapshotStore + } + /** 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 + /** Save the open draft. */ + save: () => Promise + /** Ask for delete confirmation, or dismiss it with null. */ + confirmDelete: (id: string | null) => void + /** Delete the preset awaiting confirmation. */ + remove: () => Promise + /** Make one preset the default for sessions created later. */ + makeDefault: (id: string) => Promise +} + +/** Full component props. */ +export type AgentPresetSectionProps = + PropsRuntime<'settings.section'> + & PropsLocale<'settings.agentPreset'> + & InjectFace + +/** Editor sub-view props: the draft plus the actions that mutate it. */ +interface EditorProps { + draft: PresetDraft + blocker: ReturnType + t: (key: AgentPresetSettingsKey) => string + actions: Pick +} + +function Editor({ draft, blocker, t, actions }: EditorProps): ReactNode { + const message = draft.error ?? (blocker === undefined ? null : t(blocker)) + return ( +
+ {draft.creating + ? ( + + ) + : null} + {draft.writable ? null :

{t('readOnlyNotice')}

} +