feat(web): author agent presets from a settings page

A composition is a file, but "edit it on the filesystem" is not a browser
affordance. The roster gains `read`/`write`/`remove` beside `select`, and
the browser gains a settings section over them: the presets as rows, one
composition open in a YAML editor at a time, and per-row default, duplicate,
and delete.

All four authoring methods 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. `agentPreset.list` deliberately stays ordinary
and now reports `authorable`, so a surface knows whether creating is
possible at all rather than offering a button whose save always fails.

Authoring starts by duplicating: a shipped preset opens read-only because
the deployment's copy is what a broken local one is compared against. Ids
are contained before they become directory names, and the text is parsed
with the loader's own schema, so a save cannot leave a file no session
could load.

Fixes a defect the real-composition test found: a preset written under the
user's home could never mount, because the loader resolves a row against the
composition's own directory and Node's `node_modules` walk from there never
reaches the installed harness. The mount now records the host base and sends
bare specifiers there, leaving relative paths resolving from the preset.

Also closes the coverage the earlier surfaces in this stack shipped without —
the General row, the composer seat, and the plugin halves now have tests.
This commit is contained in:
Yichen Jiang
2026-08-04 12:23:40 +08:00
parent 52607cab69
commit 6dfc568ec2
56 changed files with 3478 additions and 110 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam以及循环的 sink配置类型。浏览器载体以 HTTP POST 发送 unaryrespond并为 `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 发送 unaryrespond并为 `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 浏览器信任栅栏

View File

@@ -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<string, { trust: 'system' | 'user'; content: string }>([
['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<SessionId, number>([[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)

View File

@@ -55,6 +55,17 @@ export const Config: z<ConnectionConfig> = 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',

View File

@@ -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'] = {

View File

@@ -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.

View File

@@ -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'] = {

View File

@@ -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

View File

@@ -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.

View File

@@ -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 会被接受,并在下一个选择它的会话处失败。

View File

@@ -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",

View File

@@ -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);
}

View File

@@ -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<AgentPresetSectionState>
}
/** Read the roster; called once when the section first renders. */
load: () => Promise<void>
/** Open one preset's composition in the editor. */
open: (id: string) => Promise<void>
/** Open a copy of one preset — or of the default — as a new preset. */
createFrom: (from?: string) => Promise<void>
/** 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<void>
/** Ask for delete confirmation, or dismiss it with null. */
confirmDelete: (id: string | null) => void
/** Delete the preset awaiting confirmation. */
remove: () => Promise<void>
/** Make one preset the default for sessions created later. */
makeDefault: (id: string) => Promise<void>
}
/** Full component props. */
export type AgentPresetSectionProps =
PropsRuntime<'settings.section'>
& PropsLocale<'settings.agentPreset'>
& InjectFace<AgentPresetSectionInjected>
/** Editor sub-view props: the draft plus the actions that mutate it. */
interface EditorProps {
draft: PresetDraft
blocker: ReturnType<typeof draftBlocker>
t: (key: AgentPresetSettingsKey) => string
actions: Pick<AgentPresetSectionInjected, 'close' | 'save' | 'setContent' | 'setId'>
}
function Editor({ draft, blocker, t, actions }: EditorProps): ReactNode {
const message = draft.error ?? (blocker === undefined ? null : t(blocker))
return (
<div className={css.editor}>
{draft.creating
? (
<label className={css.field}>
<span className={css.fieldLabel}>{t('presetName')}</span>
<input
className={css.input}
value={draft.id}
autoFocus
spellCheck={false}
placeholder={t('presetNamePlaceholder')}
onChange={(event) => { actions.setId(event.target.value) }}
/>
<span className={css.hint}>{`${t('copyOf')} ${draft.source}`}</span>
</label>
)
: null}
{draft.writable ? null : <p className={css.notice}>{t('readOnlyNotice')}</p>}
<label className={css.field}>
<span className={css.fieldLabel}>{t('composition')}</span>
<textarea
className={css.code}
value={draft.content}
readOnly={!draft.writable}
spellCheck={false}
rows={16}
onChange={(event) => { actions.setContent(event.target.value) }}
/>
</label>
{message === null ? null : <p className={css.error} role="alert">{message}</p>}
<div className={css.editorActions}>
<Button variant="outline" disabled={draft.saving} onClick={() => { actions.close() }}>
{draft.writable ? t('cancel') : t('close')}
</Button>
{draft.writable
? (
<Button
disabled={draft.saving || blocker !== undefined}
onClick={() => { void actions.save() }}
>
{draft.saving ? t('saving') : t('save')}
</Button>
)
: null}
</div>
</div>
)
}
/**
* Render the Agent presets section content column.
* @param props - composed slot props.
* @returns the section, or null when the deployment composes no presets.
*/
export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {
const { useAgentPresetSection, t, load } = props
const state = useAgentPresetSection(snapshot => snapshot)
useEffect(() => {
void load()
}, [load])
// A deployment that composes no presets has nothing to manage: every
// session shares the host composition and the page would be an empty list.
if (state.status === 'unavailable') return null
if (state.status === 'error') {
/* v8 ignore next -- an error status always carries text; the fallback satisfies the nullable type */
const detail = state.error ?? ''
return (
<div className={css.section}>
<p className={css.error} role="alert">{`${t('error')} ${detail}`}</p>
<button type="button" className={css.secondaryButton} onClick={() => { void load() }}>
{t('retry')}
</button>
</div>
)
}
const { draft } = state
const blocker = draft === null ? undefined : draftBlocker(draft, state.rows)
const editorActions = { close: props.close, save: props.save, setContent: props.setContent, setId: props.setId }
return (
<div className={css.section}>
<h2 className={css.title}>{t('nav')}</h2>
<p className={css.intro}>{t('sectionIntro')}</p>
{state.error === null ? null : <p className={css.error} role="alert">{state.error}</p>}
<ul className={css.rows}>
{state.rows.map(row => (
<li key={row.id} className={css.rowCard}>
<div className={css.rowHead}>
<span className={css.rowName}>{row.id}</span>
<span className={css.badge}>{row.trust === 'user' ? t('userTrust') : t('builtIn')}</span>
{row.isDefault ? <span className={css.defaultBadge}>{t('defaultBadge')}</span> : null}
<span className={css.rowActions}>
{row.isDefault
? null
: (
<button
type="button"
className={css.secondaryButton}
onClick={() => { void props.makeDefault(row.id) }}
>
{t('setDefault')}
</button>
)}
<button
type="button"
className={css.secondaryButton}
onClick={() => { void props.open(row.id) }}
>
{row.trust === 'user' ? t('edit') : t('view')}
</button>
{state.authorable
? (
<button
type="button"
className={css.secondaryButton}
onClick={() => { void props.createFrom(row.id) }}
>
{t('duplicate')}
</button>
)
: null}
{row.trust === 'user'
? (
<button
type="button"
className={css.dangerButton}
onClick={() => { props.confirmDelete(row.id) }}
>
{t('delete')}
</button>
)
: null}
</span>
</div>
{draft !== null && !draft.creating && draft.source === row.id
? <Editor draft={draft} blocker={blocker} t={t} actions={editorActions} />
: null}
</li>
))}
</ul>
{draft !== null && draft.creating
? (
<div className={css.addCard}>
<Editor draft={draft} blocker={blocker} t={t} actions={editorActions} />
</div>
)
: (
<button
type="button"
className={css.addButton}
disabled={!state.authorable || state.rows.length === 0}
onClick={() => { void props.createFrom() }}
>
{`+ ${t('newPreset')}`}
</button>
)}
<Modal
open={state.pendingDelete !== null}
onClose={() => { props.confirmDelete(null) }}
title={t('deleteTitle')}
closeLabel={t('close')}
description={t('deleteDescription')}
className={css.deleteDialog as string}
footer={(
<>
<Button
variant="outline"
autoFocus
disabled={state.deleting}
onClick={() => { props.confirmDelete(null) }}
>
{t('cancel')}
</Button>
<Button
variant="outline"
className={css.deleteConfirm}
disabled={state.deleting}
onClick={() => { void props.remove() }}
>
{state.deleting ? t('deleting') : t('deleteConfirm')}
</Button>
</>
)}
/>
</div>
)
}

View File

@@ -1,31 +1,41 @@
/**
* Agent-preset surface plugin, browser half — one General-settings row that
* writes the default preset for sessions created later.
* Agent-preset surface plugin, browser half — three surfaces over one roster:
* a General-settings row for the default preset, a composer seat for the
* session about to start, and a settings section that authors the compositions
* themselves.
*
* A running session keeps the composition it began with (the host refuses to
* adopt an existing session under a different preset), so this row is a
* new-session preference rather than a live switch. Per-session choice at
* creation time belongs to the session-start surface, which reads the same
* roster.
* adopt an existing session under a different preset), so the General row is
* a new-session preference rather than a live switch, and the seat stops
* offering a choice once the conversation starts.
*/
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
import type {} from '@deepseek-ai/dsh-client-locale/client'
// Type-only: pulls the settings shell's SlotMap merge (the 'settings.section' entry).
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import { AgentPresetRow } from './AgentPresetRow.tsx'
import type { AgentPresetRowInjected } from './AgentPresetRow.tsx'
import { AgentPresetSeat } from './AgentPresetSeat.tsx'
import type { AgentPresetSeatInjected } from './AgentPresetSeat.tsx'
import { AgentPresetSection } from './AgentPresetSection.tsx'
import type { AgentPresetSectionInjected } from './AgentPresetSection.tsx'
import { AgentPresetSeatController } from './seat-store.ts'
import { AgentPresetSectionController } from './section-store.ts'
import { en, zh } from './locales.ts'
import { AGENT_PRESET_SETTINGS_NS, AgentPresetSettingsController } from './settings-store.ts'
export type { AgentPresetRowInjected, AgentPresetRowProps } from './AgentPresetRow.tsx'
export type { AgentPresetSeatInjected, AgentPresetSeatProps } from './AgentPresetSeat.tsx'
export type { AgentPresetSectionInjected, AgentPresetSectionProps } from './AgentPresetSection.tsx'
export type { AgentPresetSeatState } from './seat-store.ts'
export {
draftBlocker, type AgentPresetSectionState, type PresetDraft, type PresetRow,
} from './section-store.ts'
export type { AgentPresetOption, AgentPresetSettingsState } from './settings-store.ts'
export { AGENT_PRESET_SETTINGS_NS } from './settings-store.ts'
export { AGENT_PRESET_SETTINGS_NS, writeDefaultPreset } from './settings-store.ts'
/** Required services (cordis fiber inject). */
export const inject = ['slots', 'locale', 'connection']
@@ -35,7 +45,9 @@ export const inject = ['slots', 'locale', 'connection']
* @param ctx - the browser plugin context.
*/
export function apply(ctx: ClientContext): void {
const controller = new AgentPresetSettingsController((ctx.get('connection') as ConnectionHandle).api)
const { api } = ctx.get('connection') as ConnectionHandle
const controller = new AgentPresetSettingsController(api)
const section = new AgentPresetSectionController(api)
ctx.effect(() => ctx.locale.register('settings.agentPreset', { zh, en }), 'ui-agent-preset: settings row dictionaries')
@@ -51,6 +63,9 @@ export function apply(ctx: ClientContext): void {
const refresh = (ns?: string): void => {
if (ns !== undefined && ns !== AGENT_PRESET_SETTINGS_NS) return
void controller.load()
// The section reads the same roster and marks the same default, so a
// change made from either surface converges both.
if (section.store.getSnapshot().status !== 'idle') void section.load()
}
const disposers = [
ctx.on('settings/changed', refresh),
@@ -90,6 +105,20 @@ export function apply(ctx: ClientContext): void {
}, AgentPresetSeat), 'ui-agent-preset: composer seat registration')
})
const sectionInjected = (): AgentPresetSectionInjected => ({
hooks: { agentPresetSection: section.store },
load: () => section.load(),
open: (id: string) => section.open(id),
createFrom: (from?: string) => section.createFrom(from),
close: () => { section.close() },
setId: (id: string) => { section.setId(id) },
setContent: (content: string) => { section.setContent(content) },
save: () => section.save(),
confirmDelete: (id: string | null) => { section.confirmDelete(id) },
remove: () => section.remove(),
makeDefault: (id: string) => section.makeDefault(id),
})
ctx.slots.inject('settings.general.item', () => ctx.slots.register({
name: 'settings.general.item',
id: 'agent-preset',
@@ -97,4 +126,14 @@ export function apply(ctx: ClientContext): void {
locale: 'settings.agentPreset',
inject: injected,
}, AgentPresetRow))
// Ordered after Models: choosing a model is routine, and composing an
// agent is the deployment-shaping act behind it.
ctx.slots.inject('settings.section', () => ctx.slots.register({
name: 'settings.section',
id: 'agent-presets',
order: 20,
label: () => ctx.locale.bind('settings.agentPreset')('nav'),
locale: 'settings.agentPreset',
inject: sectionInjected,
}, AgentPresetSection))
}

View File

@@ -1,8 +1,13 @@
/** Locale bundles for the agent-preset General-settings row. */
/** Locale bundles for the agent-preset settings row, composer seat, and management section. */
/** Locale keys this row renders. */
/** Locale keys these surfaces render. */
export type AgentPresetSettingsKey =
| 'title' | 'description' | 'loading' | 'error' | 'userTrust' | 'seatHint' | 'lockedHint'
| 'nav' | 'sectionIntro' | 'builtIn' | 'defaultBadge' | 'setDefault' | 'edit' | 'view'
| 'duplicate' | 'delete' | 'newPreset' | 'presetName' | 'presetNamePlaceholder' | 'copyOf'
| 'composition' | 'readOnlyNotice' | 'save' | 'saving' | 'cancel' | 'close' | 'retry'
| 'idRequired' | 'idInvalid' | 'idTaken'
| 'deleteTitle' | 'deleteDescription' | 'deleteConfirm' | 'deleting'
/** English copy. */
export const en: Record<AgentPresetSettingsKey, string> = {
@@ -13,6 +18,36 @@ export const en: Record<AgentPresetSettingsKey, string> = {
userTrust: 'Local',
seatHint: 'Agent preset for this session — switchable until you send the first message',
lockedHint: 'This session\'s agent preset is fixed once the conversation starts',
nav: 'Agent presets',
sectionIntro:
'A preset is the plugin composition one session\'s agent runs — its tools, prompt, and capabilities. '
+ 'Built-in presets are read-only; duplicate one to make your own.',
builtIn: 'Built-in',
defaultBadge: 'Default',
setDefault: 'Set as default',
edit: 'Edit',
view: 'View',
duplicate: 'Duplicate',
delete: 'Delete',
newPreset: 'New preset',
presetName: 'Preset name',
presetNamePlaceholder: 'my-agent',
copyOf: 'Copied from',
composition: 'Composition (cordis.yml)',
readOnlyNotice: 'This preset ships with the deployment and cannot be edited. Duplicate it to make your own.',
save: 'Save',
saving: 'Saving…',
cancel: 'Cancel',
close: 'Close',
retry: 'Retry',
idRequired: 'Name the preset.',
idInvalid: 'Use lowercase letters, digits, and hyphens, starting with a letter or digit.',
idTaken: 'A preset with this name already exists.',
deleteTitle: 'Delete this preset?',
deleteDescription:
'The composition file is deleted. Sessions already running on it keep working; new sessions cannot select it.',
deleteConfirm: 'Delete',
deleting: 'Deleting…',
}
/** Simplified Chinese copy. */
@@ -24,4 +59,31 @@ export const zh: Record<AgentPresetSettingsKey, string> = {
userTrust: '本地',
seatHint: '本会话的 agent preset —— 发送第一条消息前可切换',
lockedHint: '会话开始后,其 agent preset 即固定',
nav: 'Agent preset',
sectionIntro: 'preset 即一个会话的 agent 所运行的插件组装 —— 它的工具、提示词与能力。内置 preset 只读;复制一份即可改成自己的。',
builtIn: '内置',
defaultBadge: '默认',
setDefault: '设为默认',
edit: '编辑',
view: '查看',
duplicate: '复制',
delete: '删除',
newPreset: '新建 preset',
presetName: 'preset 名称',
presetNamePlaceholder: 'my-agent',
copyOf: '复制自',
composition: '组装cordis.yml',
readOnlyNotice: '该 preset 随部署提供,不可编辑。复制一份即可改成自己的。',
save: '保存',
saving: '正在保存…',
cancel: '取消',
close: '关闭',
retry: '重试',
idRequired: '请填写 preset 名称。',
idInvalid: '只能使用小写字母、数字与连字符,且以字母或数字开头。',
idTaken: '同名 preset 已存在。',
deleteTitle: '删除该 preset',
deleteDescription: '组装文件将被删除。已在其上运行的会话不受影响;新会话将无法再选择它。',
deleteConfirm: '删除',
deleting: '正在删除…',
}

View File

@@ -11,6 +11,7 @@ import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
import {
createSnapshotStore, type SessionId, type SnapshotStore,
} from '@deepseek-ai/dsh-client-runtime/client'
import { messageOf } from './settings-store.ts'
import type { AgentPresetOption } from './settings-store.ts'
/** Composer-seat snapshot for one session. */
@@ -69,7 +70,7 @@ export class AgentPresetSeatController {
error: null,
})
} catch (error) {
this.set({ error: error instanceof Error ? error.message : String(error) })
this.set({ error: messageOf(error) })
}
}
@@ -90,11 +91,7 @@ export class AgentPresetSeatController {
}
this.set({ busy: false, current: response.result.value.agentPreset })
} catch (error) {
this.set({
busy: false,
current: before.current,
error: error instanceof Error ? error.message : String(error),
})
this.set({ busy: false, current: before.current, error: messageOf(error) })
}
}
}

View File

@@ -0,0 +1,292 @@
/**
* Agent-preset management controller: the roster as a list, and one
* composition open in the editor at a time.
*
* The host stays the single fact source. Every mutation writes through the
* wire and the page re-reads the roster afterwards, because a save can change
* more than the row it targeted — creating a preset adds one, and the shape
* check the host applies is what decides whether the text landed at all.
*/
import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { messageOf, writeDefaultPreset } from './settings-store.ts'
/** Ids a preset directory may be named, mirroring the host's own rule. */
const PRESET_ID = /^[a-z0-9][a-z0-9-]*$/
/** One preset row the page renders. */
export interface PresetRow {
/** Preset id, also its display name and directory name. */
id: string
/** Whether the preset ships with the deployment or was authored locally. */
trust: 'system' | 'user'
/** Whether a session that names no preset gets this one. */
isDefault: boolean
}
/** The composition currently open in the editor. */
export interface PresetDraft {
/** Preset the draft saves to; empty until a new one is named. */
id: string
/**
* The preset the text came from, shown while a new preset is unnamed so
* the editor can say what it is a copy of.
*/
source: string
/** Whether saving creates a preset rather than replacing one. */
creating: boolean
/** Composition text being edited. */
content: string
/** Whether this draft can be saved at all — a shipped preset opens read-only. */
writable: boolean
/** Whether a save is in flight. */
saving: boolean
/** The last save failure, cleared by the next edit. */
error: string | null
}
/** Page snapshot. */
export interface AgentPresetSectionState {
status: 'idle' | 'loading' | 'ready' | 'unavailable' | 'error'
/** Whole-load failure text; a save failure stays on the draft. */
error: string | null
/** Whether the deployment configures a root new presets can be written to. */
authorable: boolean
/** Every preset the deployment currently supplies. */
rows: readonly PresetRow[]
/** The open editor, or null when the page is just the list. */
draft: PresetDraft | null
/** The preset awaiting delete confirmation. */
pendingDelete: string | null
/** Whether a delete is in flight. */
deleting: boolean
}
const INITIAL: AgentPresetSectionState = {
status: 'idle',
error: null,
authorable: false,
rows: [],
draft: null,
pendingDelete: null,
deleting: false,
}
/**
* Why this draft cannot be saved yet, as a locale key, or undefined when it
* can. Client-side only: the host re-checks both the id and the composition
* shape, and its answer is what the editor reports on failure.
* @param draft - the open draft.
* @param rows - the roster, for the collision check.
* @returns the blocking reason's locale key, or undefined when saveable.
*/
export function draftBlocker(
draft: PresetDraft,
rows: readonly PresetRow[],
): 'idRequired' | 'idInvalid' | 'idTaken' | undefined {
if (!draft.creating) return undefined
if (draft.id === '') return 'idRequired'
if (!PRESET_ID.test(draft.id)) return 'idInvalid'
// Replacing an existing preset is what Edit is for; a create that lands on
// a name already in use would overwrite something the user did not open.
if (rows.some(row => row.id === draft.id)) return 'idTaken'
return undefined
}
/** Reads the roster and drives the composition editor. */
export class AgentPresetSectionController {
/** Page snapshot the renderer subscribes to. */
readonly store: SnapshotStore<AgentPresetSectionState> = createSnapshotStore(INITIAL)
constructor(private readonly api: Pick<IApiClient, 'agentPresets' | 'settings'>) {}
private set(patch: Partial<AgentPresetSectionState>): void {
this.store.set({ ...this.store.getSnapshot(), ...patch })
}
private patchDraft(patch: Partial<PresetDraft>): void {
const { draft } = this.store.getSnapshot()
if (draft === null) return
this.set({ draft: { ...draft, ...patch } })
}
/**
* Load the roster. An empty roster means the deployment composes no
* presets, which is a valid deployment rather than a failure — the section
* reports `unavailable` and renders nothing.
* @returns once the snapshot reflects the host.
*/
async load(): Promise<void> {
if (this.store.getSnapshot().status === 'loading') return
this.set({ status: 'loading', error: null })
try {
const response = await this.api.agentPresets.list({})
if (!response.result.ok) {
this.set({ status: 'error', error: response.result.error.message })
return
}
const { presets, authorable } = response.result.value
if (presets.length === 0) {
this.set({ status: 'unavailable', rows: [], authorable, draft: null })
return
}
this.set({ status: 'ready', error: null, authorable, rows: presets.map(preset => ({ ...preset })) })
} catch (error) {
this.set({ status: 'error', error: messageOf(error) })
}
}
/**
* Open one preset's composition in the editor.
*
* A shipped preset opens read-only rather than not opening: it is the
* known-good composition a local one is written against, so being able to
* read it is the point.
* @param id - the preset to open.
* @returns once the composition loaded or the failure is on the page.
*/
async open(id: string): Promise<void> {
await this.openDraft(id, false)
}
/**
* Open a copy of one preset as a new, unnamed preset. With no argument the
* copy is taken from the default preset, which is the composition a new
* session gets and therefore the one worth starting from.
* @param from - the preset to copy, or undefined for the default.
* @returns once the composition loaded or the failure is on the page.
*/
async createFrom(from?: string): Promise<void> {
const source = from ?? this.store.getSnapshot().rows.find(row => row.isDefault)?.id
/* v8 ignore next -- the section only renders with a roster, and every roster marks a default */
if (source === undefined) return
await this.openDraft(source, true)
}
private async openDraft(source: string, creating: boolean): Promise<void> {
this.set({ error: null })
try {
const response = await this.api.agentPresets.read({ agentPreset: source })
if (!response.result.ok) {
this.set({ error: response.result.error.message })
return
}
const { content, writable } = response.result.value
this.set({
draft: {
id: creating ? '' : source,
source,
creating,
content,
// A copy is always writable: it lands in the local root regardless of
// where the text came from.
writable: creating || writable,
saving: false,
error: null,
},
})
} catch (error) {
this.set({ error: messageOf(error) })
}
}
/** Close the editor, discarding whatever was typed. */
close(): void {
this.set({ draft: null })
}
/**
* Name the preset a new draft saves to.
* @param id - the id typed into the editor.
*/
setId(id: string): void {
this.patchDraft({ id, error: null })
}
/**
* Replace the draft's composition text.
* @param content - the text in the editor.
*/
setContent(content: string): void {
this.patchDraft({ content, error: null })
}
/**
* Save the open draft, then re-read the roster.
*
* The host shape-checks the text, so a composition that could never load is
* refused here rather than at the next session that selects it.
* @returns once the write settled and the page reflects it.
*/
async save(): Promise<void> {
const draft = this.store.getSnapshot().draft
if (draft === null || draft.saving || !draft.writable) return
if (draftBlocker(draft, this.store.getSnapshot().rows) !== undefined) return
this.patchDraft({ saving: true, error: null })
try {
const response = await this.api.agentPresets.write({ agentPreset: draft.id, content: draft.content })
if (!response.result.ok) {
this.patchDraft({ saving: false, error: response.result.error.message })
return
}
this.set({ draft: null })
await this.load()
} catch (error) {
this.patchDraft({ saving: false, error: messageOf(error) })
}
}
/**
* Ask for confirmation before deleting one preset.
* @param id - the preset to delete, or null to dismiss the confirmation.
*/
confirmDelete(id: string | null): void {
if (this.store.getSnapshot().deleting) return
this.set({ pendingDelete: id })
}
/**
* Delete the preset awaiting confirmation, then re-read the roster.
*
* A session already composed from it keeps running: its composition was
* mounted at creation and nothing re-reads the file.
* @returns once the delete settled and the page reflects it.
*/
async remove(): Promise<void> {
const { pendingDelete, deleting, draft } = this.store.getSnapshot()
if (pendingDelete === null || deleting) return
this.set({ deleting: true, error: null })
try {
const response = await this.api.agentPresets.remove({ agentPreset: pendingDelete })
if (!response.result.ok) {
this.set({ deleting: false, pendingDelete: null, error: response.result.error.message })
return
}
this.set({
deleting: false,
pendingDelete: null,
// The editor cannot stay open on a file that no longer exists.
draft: draft?.id === pendingDelete && !draft.creating ? null : draft,
})
await this.load()
} catch (error) {
this.set({ deleting: false, pendingDelete: null, error: messageOf(error) })
}
}
/**
* Make one preset the default for sessions created later. Running sessions
* keep the composition they began with, so this never disturbs work.
* @param id - the preset to make default.
* @returns once the write settled and the roster was re-read.
*/
async makeDefault(id: string): Promise<void> {
const failure = await writeDefaultPreset(this.api, id)
if (failure !== undefined) {
this.set({ error: failure })
return
}
await this.load()
}
}

View File

@@ -13,6 +13,42 @@ import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client
/** The agent-preset settings namespace on the host wire. */
export const AGENT_PRESET_SETTINGS_NS = 'agent-presets'
/**
* Human text for a rejected wire call. A transport failure rejects with an
* Error; a host or a runtime can reject with anything, and the surface still
* has to say something.
* @param error - the rejection value.
* @returns the message to show.
*/
export function messageOf(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
/**
* Persist one preset as the default for sessions created later.
*
* The default is a settings field rather than a preset property, so both the
* General row and the management section write it here — one home for which
* namespace and field the host resolves at session creation.
* @param api - the settings wire face.
* @param id - the preset to make default.
* @returns the failure message, or undefined once the write landed.
*/
export async function writeDefaultPreset(
api: Pick<IApiClient, 'settings'>,
id: string,
): Promise<string | undefined> {
let response
try {
response = await api.settings.update({ ns: AGENT_PRESET_SETTINGS_NS, patch: { default: id } })
} catch (error) {
// The transport rejected rather than answering; the caller must be able to
// say so instead of the row silently snapping back.
return messageOf(error)
}
return response.result.ok ? undefined : response.result.error.message
}
/** One selectable preset. */
export interface AgentPresetOption {
/** Preset id, written to Settings and shown as the label. */
@@ -65,7 +101,8 @@ export class AgentPresetSettingsController {
return
}
const presets = response.result.value.presets
if (presets.length === 0) {
const [first] = presets
if (first === undefined) {
this.set({ status: 'unavailable', options: [], currentValue: '' })
return
}
@@ -73,10 +110,12 @@ export class AgentPresetSettingsController {
status: 'ready',
error: null,
options: presets.map(preset => ({ id: preset.id, trust: preset.trust })),
currentValue: presets.find(preset => preset.isDefault)?.id ?? presets[0]?.id ?? '',
// A roster can mark nothing default: settings can name a preset that
// was since deleted, and the picker still has to show something.
currentValue: presets.find(preset => preset.isDefault)?.id ?? first.id,
})
} catch (error) {
this.set({ status: 'error', error: error instanceof Error ? error.message : String(error) })
this.set({ status: 'error', error: messageOf(error) })
}
}
@@ -91,24 +130,13 @@ export class AgentPresetSettingsController {
const before = this.store.getSnapshot()
if (before.status === 'saving' || id === before.currentValue) return
this.set({ status: 'saving', error: null, currentValue: id })
try {
const response = await this.api.settings.update({
ns: AGENT_PRESET_SETTINGS_NS,
patch: { default: id },
})
if (!response.result.ok) {
this.set({ status: 'ready', currentValue: before.currentValue, error: response.result.error.message })
return
}
// Re-read rather than trust the patch: the host resolves the default
// through the same roster the row displays.
await this.load()
} catch (error) {
this.set({
status: 'ready',
currentValue: before.currentValue,
error: error instanceof Error ? error.message : String(error),
})
const failure = await writeDefaultPreset(this.api, id)
if (failure !== undefined) {
this.set({ status: 'ready', currentValue: before.currentValue, error: failure })
return
}
// Re-read rather than trust the patch: the host resolves the default
// through the same roster the row displays.
await this.load()
}
}

View File

@@ -0,0 +1,241 @@
/**
* Registration: the General row, the settings section, and the per-session
* composer seat all come from one apply, and each defers until the slot it
* fills has been declared. A pushed settings change refreshes the surfaces
* that are already showing, so a default set from one converges the other.
*/
import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-agent-preset/client'
import { AgentPresetRow } from '../src/client/AgentPresetRow.tsx'
import type { AgentPresetRowInjected } from '../src/client/AgentPresetRow.tsx'
import { AgentPresetSection } from '../src/client/AgentPresetSection.tsx'
import type { AgentPresetSectionInjected } from '../src/client/AgentPresetSection.tsx'
import { AgentPresetSeat } from '../src/client/AgentPresetSeat.tsx'
import type { AgentPresetSeatInjected } from '../src/client/AgentPresetSeat.tsx'
// The service reads its initial locale from the browser; these specs assert
// the shipped Chinese copy, so they state the browser they assume.
usePinnedBrowserLanguages('zh-CN')
const ROSTER = {
rpcId: 'r',
result: { ok: true as const, value: { presets: [{ id: 'standard', trust: 'system', isDefault: true }], authorable: true } },
}
async function bench() {
const ctx = new Context()
await ctx.plugin(SlotsService).await()
const locale = new LocaleService(ctx)
ctx.provide('locale', locale)
const calls: string[] = []
ctx.provide('connection', {
api: {
agentPresets: {
list: () => { calls.push('list'); return Promise.resolve(ROSTER) },
read: () => Promise.resolve({
rpcId: 'r',
result: { ok: true as const, value: { agentPreset: 'standard', trust: 'system', content: '', writable: false } },
}),
write: () => Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: { agentPreset: 'standard' } } }),
remove: () => Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: {} } }),
select: () => Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: { agentPreset: 'standard' } } }),
},
settings: {
update: (payload: { patch: unknown }) => { calls.push(`settings:${JSON.stringify(payload.patch)}`); return Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: {} } }) },
},
},
} as never)
return { ctx, slots: ctx.get('slots') as SlotsService, calls }
}
function declareRoot(slots: SlotsService): () => void {
return slots.register({
name: 'root',
children: {
'settings.general.item': { kind: 'list', scope: 'root' },
'settings.section': { kind: 'list', scope: 'root' },
conversation: { kind: 'single', scope: 'root' },
},
} as never, () => null)
}
/** The composer's own declaration, which the seat registration waits for. */
function declareComposer(slots: SlotsService): () => void {
return slots.register({
name: 'conversation',
children: { 'conversation.input.agentPreset': { kind: 'single', scope: 'session' } },
} as never, () => null)
}
describe('ui-agent-preset apply', () => {
it('declares the services it uses', () => {
expect(inject).toEqual(['slots', 'locale', 'connection'])
})
it('registers the General row and the settings section', async () => {
const { ctx, slots } = await bench()
declareRoot(slots)
await ctx.plugin({ inject: [...inject], apply }).await()
const row = slots.entries('settings.general.item')[0]!
expect(row.component).toBe(AgentPresetRow)
expect(row.options).toMatchObject({ id: 'agent-preset', order: -25 })
const section = slots.entries('settings.section')[0]!
expect(section.component).toBe(AgentPresetSection)
expect(section.options).toMatchObject({ id: 'agent-presets', order: 20 })
// The nav label is a locale-following thunk; owners resolve it at read time.
expect(resolveSlotLabel(section.options.label)).toBe('Agent preset')
})
it('registers into a declaration that arrives after apply', async () => {
const { ctx, slots } = await bench()
await ctx.plugin({ inject: [...inject], apply }).await()
declareRoot(slots)
await vi.waitFor(() => { expect(slots.entries('settings.section')).toHaveLength(1) })
})
it('hands each surface its own store and actions', async () => {
const { ctx, slots } = await bench()
declareRoot(slots)
await ctx.plugin({ inject: [...inject], apply }).await()
const row = (slots.entries('settings.general.item')[0]!.inject as unknown as () => AgentPresetRowInjected)()
const section = (slots.entries('settings.section')[0]!.inject as unknown as () => AgentPresetSectionInjected)()
expect(row.hooks.agentPreset).not.toBe(section.hooks.agentPresetSection)
// Each thunk reaches its own controller: the row's load fills the row's
// store, and the section's default write does not go through the row.
await row.load()
await row.select('standard')
await section.makeDefault('standard')
expect(row.hooks.agentPreset.getSnapshot().options).toEqual([{ id: 'standard', trust: 'system' }])
expect(section.hooks.agentPresetSection.getSnapshot().rows)
.toEqual([{ id: 'standard', trust: 'system', isDefault: true }])
})
it('routes the section actions to one controller', async () => {
const { ctx, slots } = await bench()
declareRoot(slots)
await ctx.plugin({ inject: [...inject], apply }).await()
const section = (slots.entries('settings.section')[0]!.inject as unknown as () => AgentPresetSectionInjected)()
await section.load()
section.setId('mine')
section.setContent('- id: x\n')
section.confirmDelete('mine')
section.close()
await Promise.all([section.open('standard'), section.createFrom(), section.save(), section.remove()])
// One controller behind every action: the delete the section confirmed is
// the one its remove() sees.
expect(section.hooks.agentPresetSection.getSnapshot().rows).toHaveLength(1)
})
it('refreshes a showing surface when its namespace changes, and ignores others', async () => {
const { ctx, slots, calls } = await bench()
declareRoot(slots)
await ctx.plugin({ inject: [...inject], apply }).await()
const section = (slots.entries('settings.section')[0]!.inject as unknown as () => AgentPresetSectionInjected)()
await section.load()
const before = calls.length
ctx.emit('settings/changed', 'agent-presets')
await vi.waitFor(() => { expect(calls.length).toBe(before + 2) })
const afterRelevant = calls.length
ctx.emit('settings/changed', 'llm-deepseek')
await Promise.resolve()
// Both surfaces re-read on their own namespace; an unrelated one moves
// neither, so this rules out a blanket refresh on every settings write.
expect(calls.length).toBe(afterRelevant)
})
it('re-reads both surfaces when the connection comes back', async () => {
const { ctx, slots, calls } = await bench()
declareRoot(slots)
await ctx.plugin({ inject: [...inject], apply }).await()
const section = (slots.entries('settings.section')[0]!.inject as unknown as () => AgentPresetSectionInjected)()
await section.load()
const before = calls.length
ctx.emit('connection/reset')
// A reconnect can land on a host whose roster changed under the browser.
await vi.waitFor(() => { expect(calls.length).toBe(before + 2) })
})
it('leaves the section alone until it has been opened once', async () => {
const { ctx, slots, calls } = await bench()
declareRoot(slots)
await ctx.plugin({ inject: [...inject], apply }).await()
const before = calls.length
ctx.emit('settings/changed', 'agent-presets')
await vi.waitFor(() => { expect(calls.length).toBeGreaterThan(before) })
// Only the General row reloads: a section nobody opened has nothing to
// converge, and reading the roster for it would be a wasted round trip.
expect(calls.length - before).toBe(1)
})
it('gives each session its own seat controller and drops its registrations on disposal', async () => {
const { ctx, slots } = await bench()
declareRoot(slots)
const conversation = declareComposer(slots)
ctx.provide('conversation', {} as never)
ctx.provide('sessions', { list: { getSnapshot: () => ({ byId: { s1: { blank: true, agentPreset: 'standard' } } }) } } as never)
const fiber = ctx.plugin({ inject: [...inject, 'conversation', 'sessions'], apply })
await fiber.await()
const seat = slots.entries('conversation.input.agentPreset')[0]!
expect(seat.component).toBe(AgentPresetSeat)
const make = seat.inject as unknown as (id: string) => AgentPresetSeatInjected
// Same session, same controller; a different session gets its own, because
// "may it still switch" is a per-session fact.
expect(make('s1').hooks.agentPresetSeat).toBe(make('s1').hooks.agentPresetSeat)
expect(make('s2').hooks.agentPresetSeat).not.toBe(make('s1').hooks.agentPresetSeat)
await fiber.dispose()
expect(slots.entries('conversation.input.agentPreset')).toHaveLength(0)
expect(slots.entries('settings.section')).toHaveLength(0)
conversation()
})
it('reads a seat\'s session state through the live session list', async () => {
const { ctx, slots } = await bench()
declareRoot(slots)
declareComposer(slots)
ctx.provide('conversation', {} as never)
const byId: Record<string, { blank: boolean; agentPreset?: string }> = {}
ctx.provide('sessions', { list: { getSnapshot: () => ({ byId }) } } as never)
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions'], apply }).await()
const make = slots.entries('conversation.input.agentPreset')[0]!
.inject as unknown as (id: string) => AgentPresetSeatInjected
const seat = make('s1')
await seat.load()
const unknownSession = seat.hooks.agentPresetSeat.getSnapshot().switchable
// A session created before presets existed records none; the seat then
// shows the roster default rather than an empty control.
byId['s1'] = { blank: true }
await seat.load()
expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('standard')
byId['s1'] = { blank: true, agentPreset: 'standard' }
await seat.load()
// A session the list has not caught up to offers no switch rather than
// guessing that it is blank.
expect(unknownSession).toBe(false)
expect(seat.hooks.agentPresetSeat.getSnapshot()).toMatchObject({ switchable: true, current: 'standard' })
await make('s1').select('standard')
})
})

View File

@@ -0,0 +1,232 @@
// @vitest-environment jsdom
/**
* The two picker surfaces: the General-settings row that names the default for
* later sessions, and the composer seat that names this one's — the seat stops
* being a control once the conversation starts, because the history from there
* on was produced under the preset's tools.
*/
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { AgentPresetRow } from '../src/client/AgentPresetRow.tsx'
import type { AgentPresetRowProps } from '../src/client/AgentPresetRow.tsx'
import { AgentPresetSeat } from '../src/client/AgentPresetSeat.tsx'
import type { AgentPresetSeatProps } from '../src/client/AgentPresetSeat.tsx'
import type { AgentPresetSettingsState } from '../src/client/settings-store.ts'
import type { AgentPresetSeatState } from '../src/client/seat-store.ts'
import { en } from '../src/client/locales.ts'
afterEach(cleanup)
const ROW_READY: AgentPresetSettingsState = {
status: 'ready',
error: null,
writable: true,
currentValue: 'standard',
options: [{ id: 'standard', trust: 'system' }, { id: 'mine', trust: 'user' }],
}
const SEAT_READY: AgentPresetSeatState = {
current: 'standard',
options: [{ id: 'standard', trust: 'system' }, { id: 'mine', trust: 'user' }],
switchable: true,
busy: false,
error: null,
}
function renderRow(state: Partial<AgentPresetSettingsState> = {}) {
const store = createSnapshotStore<AgentPresetSettingsState>({ ...ROW_READY, ...state })
const actions = { load: vi.fn(() => Promise.resolve()), select: vi.fn(() => Promise.resolve()) }
render(<AgentPresetRow {...({
...actions,
useAgentPreset: bindSnapshotSelector(store),
t: (key: keyof typeof en) => en[key],
} as unknown as AgentPresetRowProps)} />)
return actions
}
function renderSeat(state: Partial<AgentPresetSeatState> = {}, locked = false) {
const store = createSnapshotStore<AgentPresetSeatState>({ ...SEAT_READY, ...state })
const actions = { load: vi.fn(() => Promise.resolve()), select: vi.fn(() => Promise.resolve()) }
render(<AgentPresetSeat {...({
...actions,
locked,
useAgentPresetSeat: bindSnapshotSelector(store),
t: (key: keyof typeof en) => en[key],
} as unknown as AgentPresetSeatProps)} />)
return actions
}
describe('the General-settings row', () => {
it('reads the roster once and shows the current default', async () => {
const actions = renderRow()
await waitFor(() => { expect(actions.load).toHaveBeenCalledTimes(1) })
expect(screen.getByRole('button').textContent).toContain('standard')
})
it('marks a locally authored option as local', () => {
renderRow()
fireEvent.click(screen.getByRole('button'))
// A local preset is exactly as privileged as the plugins it names, so the
// list says which rows are local rather than presenting all as vetted.
expect(screen.getByText(`mine · ${en.userTrust}`)).toBeTruthy()
// The shipped one carries no marker; only local rows are called out.
expect(screen.getAllByText('standard')).toHaveLength(2)
})
it('writes the picked preset and closes the menu', () => {
const actions = renderRow()
fireEvent.click(screen.getByRole('button'))
fireEvent.click(screen.getByText(`mine · ${en.userTrust}`))
expect(actions.select).toHaveBeenCalledWith('mine')
expect(screen.getByRole('button').getAttribute('aria-expanded')).toBe('false')
})
it('closes on an outside dismissal', () => {
renderRow()
fireEvent.click(screen.getByRole('button'))
fireEvent.keyDown(document, { key: 'Escape' })
expect(screen.getByRole('button').getAttribute('aria-expanded')).toBe('false')
})
it('says it is loading before the roster answers', () => {
renderRow({ status: 'loading', currentValue: '' })
expect(screen.getByRole('button').textContent).toContain(en.loading)
expect(screen.getByRole('button')).toHaveProperty('disabled', true)
})
it('shows a failure in place of the description', () => {
renderRow({ error: 'roster unavailable' })
expect(screen.getByRole('alert').textContent).toBe('roster unavailable')
})
it('renders nothing when the deployment composes no presets', () => {
const { container } = render(<AgentPresetRow {...({
load: vi.fn(() => Promise.resolve()),
select: vi.fn(() => Promise.resolve()),
useAgentPreset: bindSnapshotSelector(
createSnapshotStore<AgentPresetSettingsState>({ ...ROW_READY, status: 'unavailable', options: [] })),
t: (key: keyof typeof en) => en[key],
} as unknown as AgentPresetRowProps)} />)
expect(container.firstChild).toBeNull()
})
it('closes and locks the menu when the settings turn read-only', () => {
const store = createSnapshotStore<AgentPresetSettingsState>(ROW_READY)
render(<AgentPresetRow {...({
load: vi.fn(() => Promise.resolve()),
select: vi.fn(() => Promise.resolve()),
useAgentPreset: bindSnapshotSelector(store),
t: (key: keyof typeof en) => en[key],
} as unknown as AgentPresetRowProps)} />)
fireEvent.click(screen.getByRole('button'))
act(() => { store.set({ ...ROW_READY, writable: false }) })
expect(screen.getByRole('button').getAttribute('aria-expanded')).toBe('false')
expect(screen.getByRole('button')).toHaveProperty('disabled', true)
})
})
describe('the composer seat', () => {
it('reads the roster once and offers the session\'s preset', async () => {
const actions = renderSeat()
await waitFor(() => { expect(actions.load).toHaveBeenCalledTimes(1) })
expect(screen.getByRole('button').textContent).toContain('standard')
expect(screen.getByRole('button').getAttribute('title')).toBe(en.seatHint)
})
it('switches the session and closes the menu', () => {
const actions = renderSeat()
fireEvent.click(screen.getByRole('button'))
fireEvent.click(screen.getByText(`mine · ${en.userTrust}`))
expect(actions.select).toHaveBeenCalledWith('mine')
expect(screen.getByRole('button').getAttribute('aria-expanded')).toBe('false')
})
it('becomes a plain label once the conversation has started', () => {
renderSeat({ switchable: false })
// Not a disabled menu: that would suggest the preset could still change.
expect(screen.queryByRole('button')).toBeNull()
expect(screen.getByTitle(en.lockedHint).textContent).toBe('standard')
})
it('closes an open menu the moment the session stops being blank', () => {
const store = createSnapshotStore<AgentPresetSeatState>(SEAT_READY)
render(<AgentPresetSeat {...({
load: vi.fn(() => Promise.resolve()),
select: vi.fn(() => Promise.resolve()),
locked: false,
useAgentPresetSeat: bindSnapshotSelector(store),
t: (key: keyof typeof en) => en[key],
} as unknown as AgentPresetSeatProps)} />)
fireEvent.click(screen.getByRole('button'))
act(() => { store.set({ ...SEAT_READY, switchable: false }) })
expect(screen.getByTitle(en.lockedHint)).toBeTruthy()
})
it('disables the trigger while the composer or a switch is busy', () => {
renderSeat({ busy: true })
expect(screen.getByRole('button')).toHaveProperty('disabled', true)
cleanup()
renderSeat({}, true)
expect(screen.getByRole('button')).toHaveProperty('disabled', true)
})
it('shows a failed switch on the trigger', () => {
renderSeat({ error: 'session has already started' })
expect(screen.getByRole('button').getAttribute('title')).toBe('session has already started')
})
it('renders nothing before the roster arrives or when there is none', () => {
const empty = render(<AgentPresetSeat {...({
load: vi.fn(() => Promise.resolve()),
select: vi.fn(() => Promise.resolve()),
locked: false,
useAgentPresetSeat: bindSnapshotSelector(
createSnapshotStore<AgentPresetSeatState>({ ...SEAT_READY, options: [] })),
t: (key: keyof typeof en) => en[key],
} as unknown as AgentPresetSeatProps)} />)
expect(empty.container.firstChild).toBeNull()
cleanup()
const unresolved = render(<AgentPresetSeat {...({
load: vi.fn(() => Promise.resolve()),
select: vi.fn(() => Promise.resolve()),
locked: false,
useAgentPresetSeat: bindSnapshotSelector(
createSnapshotStore<AgentPresetSeatState>({ ...SEAT_READY, current: '' })),
t: (key: keyof typeof en) => en[key],
} as unknown as AgentPresetSeatProps)} />)
expect(unresolved.container.firstChild).toBeNull()
})
it('closes on an outside dismissal', () => {
renderSeat()
fireEvent.click(screen.getByRole('button'))
fireEvent.keyDown(document, { key: 'Escape' })
expect(screen.getByRole('button').getAttribute('aria-expanded')).toBe('false')
})
})

View File

@@ -0,0 +1,25 @@
/** The package's node half: an empty host body and an explained empty invariant companion. */
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import InvariantService from '@deepseek-ai/dsh-invariants'
import * as AgentPresetInvariant from '@deepseek-ai/dsh-client-ui-agent-preset/invariant'
describe('invariant companion', () => {
it('reserves package ownership with an empty installer', async () => {
const ctx = new Context()
await ctx.plugin(InvariantService, { enabled: true })
await expect(ctx.plugin(AgentPresetInvariant).await()).resolves.toBeDefined()
})
it('has an empty node half', async () => {
const { apply } = await import('@deepseek-ai/dsh-client-ui-agent-preset')
// The host body exists only so the plugin appears in the host cordis.yml;
// every surface this package ships lives in the browser half.
apply()
expect(typeof apply).toBe('function')
})
})

View File

@@ -0,0 +1,551 @@
/**
* The agent-preset management controller: it holds one draft at a time, opens
* a shipped preset read-only, treats "new" as a copy of an existing
* composition, and re-reads the roster after every mutation because a save can
* change more than the row it targeted.
*/
import { describe, expect, it } from 'vitest'
import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
import { AgentPresetSectionController, draftBlocker } from '../src/client/section-store.ts'
import type { PresetDraft, PresetRow } from '../src/client/section-store.ts'
interface FakePreset { trust: 'system' | 'user'; content: string }
interface Recorded { method: string; payload: unknown }
interface FakeOptions {
/** Every call the controller made, in order. */
calls?: Recorded[]
/** Reject `list` with this message. */
failList?: string
/** Reject `read` with this message. */
failRead?: string
/** Reject `write` with this message. */
failWrite?: string
/** Reject `remove` with this message. */
failRemove?: string
/** Reject `settings.update` with this message. */
failSettings?: string
/** Throw from `list` rather than answering, as a dead transport does. */
throwList?: boolean
/** Whether the deployment configures a writable root. */
authorable?: boolean
/** Hold `remove` until this resolves, to observe the in-flight state. */
holdRemove?: Promise<void>
}
const ok = (value: unknown) => Promise.resolve({ rpcId: 'r', result: { ok: true as const, value } })
const fail = (message: string) =>
Promise.resolve({ rpcId: 'r', result: { ok: false as const, error: { code: 'internal', message, details: {} } } })
/**
* A wire face over an in-memory preset store: writes land, so the roster the
* controller re-reads after a save is the one the save produced.
* @param presets - the starting compositions by id.
* @param defaultId - the preset a session with no choice gets.
* @param options - failure injection and call recording.
* @returns the fake client.
*/
function fakeApi(
presets: Map<string, FakePreset>,
defaultId: { id: string },
options: FakeOptions = {},
): Pick<IApiClient, 'agentPresets' | 'settings'> {
const record = (method: string, payload: unknown): void => { options.calls?.push({ method, payload }) }
return {
agentPresets: {
list: () => {
record('list', {})
if (options.throwList === true) return Promise.reject(new Error('socket closed'))
if (options.failList !== undefined) return fail(options.failList)
return ok({
presets: [...presets].map(([id, preset]) => ({
id, trust: preset.trust, isDefault: id === defaultId.id,
})),
authorable: options.authorable ?? true,
})
},
read: (payload: { agentPreset: string }) => {
record('read', payload)
if (options.failRead !== undefined) return fail(options.failRead)
const preset = presets.get(payload.agentPreset)
/* v8 ignore next -- every test reads an id the fake store holds */
if (preset === undefined) return fail(`unknown preset ${payload.agentPreset}`)
return ok({
agentPreset: payload.agentPreset,
trust: preset.trust,
content: preset.content,
writable: preset.trust === 'user',
})
},
write: (payload: { agentPreset: string; content: string }) => {
record('write', payload)
if (options.failWrite !== undefined) return fail(options.failWrite)
presets.set(payload.agentPreset, { trust: 'user', content: payload.content })
return ok({ agentPreset: payload.agentPreset })
},
remove: async (payload: { agentPreset: string }) => {
record('remove', payload)
await options.holdRemove
if (options.failRemove !== undefined) return await fail(options.failRemove)
presets.delete(payload.agentPreset)
return await ok({})
},
},
settings: {
update: (payload: { ns: string; patch: { default?: string } }) => {
record('settings.update', payload)
if (options.failSettings !== undefined) return fail(options.failSettings)
/* v8 ignore next -- the controller only ever patches `default` */
defaultId.id = payload.patch.default ?? defaultId.id
return ok({})
},
},
} as unknown as Pick<IApiClient, 'agentPresets' | 'settings'>
}
function seed(): Map<string, FakePreset> {
return new Map<string, FakePreset>([
['standard', { trust: 'system', content: '- id: tool-bash\n' }],
['mine', { trust: 'user', content: '- id: tool-read\n' }],
])
}
function harness(options: FakeOptions = {}) {
const presets = seed()
const defaultId = { id: 'standard' }
const calls: Recorded[] = []
const controller = new AgentPresetSectionController(
fakeApi(presets, defaultId, { ...options, calls: options.calls ?? calls }),
)
return { controller, presets, defaultId, calls }
}
function draftOf(controller: AgentPresetSectionController): PresetDraft {
const { draft } = controller.store.getSnapshot()
if (draft === null) throw new Error('expected an open draft')
return draft
}
describe('loading the roster', () => {
it('reports the presets, their trust, the default, and whether authoring is possible', async () => {
const { controller } = harness()
await controller.load()
const state = controller.store.getSnapshot()
expect(state.status).toBe('ready')
expect(state.authorable).toBe(true)
expect(state.rows).toEqual([
{ id: 'standard', trust: 'system', isDefault: true },
{ id: 'mine', trust: 'user', isDefault: false },
])
})
it('treats an empty roster as a deployment that composes no presets', async () => {
const presets = new Map<string, FakePreset>()
const controller = new AgentPresetSectionController(fakeApi(presets, { id: '' }, { authorable: false }))
await controller.load()
// Not an error: every session then shares the host composition, and the
// section renders nothing rather than an empty management page.
expect(controller.store.getSnapshot().status).toBe('unavailable')
expect(controller.store.getSnapshot().authorable).toBe(false)
})
it('surfaces a rejected roster call', async () => {
const { controller } = harness({ failList: 'roster unavailable' })
await controller.load()
expect(controller.store.getSnapshot()).toMatchObject({ status: 'error', error: 'roster unavailable' })
})
it('surfaces a transport that rejects rather than answering', async () => {
const { controller } = harness({ throwList: true })
await controller.load()
expect(controller.store.getSnapshot()).toMatchObject({ status: 'error', error: 'socket closed' })
})
it('ignores a load while one is already in flight', async () => {
const { controller, calls } = harness()
await Promise.all([controller.load(), controller.load()])
expect(calls.filter(call => call.method === 'list')).toHaveLength(1)
})
})
describe('opening a composition', () => {
it('opens a locally authored preset for editing', async () => {
const { controller } = harness()
await controller.load()
await controller.open('mine')
expect(draftOf(controller)).toMatchObject({
id: 'mine', source: 'mine', creating: false, content: '- id: tool-read\n', writable: true,
})
})
it('opens a shipped preset read-only', async () => {
const { controller } = harness()
await controller.load()
await controller.open('standard')
// Readable on purpose: it is the known-good composition a local preset is
// written against, and duplicating it is how authoring starts.
expect(draftOf(controller)).toMatchObject({ writable: false, content: '- id: tool-bash\n' })
})
it('surfaces a rejected read on the page rather than opening an empty editor', async () => {
const { controller } = harness({ failRead: 'permission denied' })
await controller.load()
await controller.open('mine')
expect(controller.store.getSnapshot()).toMatchObject({ draft: null, error: 'permission denied' })
})
it('surfaces a transport failure on the page', async () => {
const presets = seed()
const api = fakeApi(presets, { id: 'standard' })
const controller = new AgentPresetSectionController({
...api,
agentPresets: { ...api.agentPresets, read: () => Promise.reject(new Error('socket closed')) },
})
await controller.load()
await controller.open('mine')
expect(controller.store.getSnapshot()).toMatchObject({ draft: null, error: 'socket closed' })
})
it('closes the editor without writing anything', async () => {
const { controller, calls } = harness()
await controller.load()
await controller.open('mine')
controller.setContent('- id: changed\n')
controller.close()
expect(controller.store.getSnapshot().draft).toBeNull()
expect(calls.some(call => call.method === 'write')).toBe(false)
})
})
describe('creating a preset', () => {
it('copies the default composition when no source is named', async () => {
const { controller } = harness()
await controller.load()
await controller.createFrom()
// The default is the composition a new session gets, so it is the one
// worth starting from — and a copy is writable wherever it came from.
expect(draftOf(controller)).toMatchObject({ id: '', source: 'standard', creating: true, writable: true })
expect(draftOf(controller).content).toBe('- id: tool-bash\n')
})
it('copies a named preset', async () => {
const { controller } = harness()
await controller.load()
await controller.createFrom('mine')
expect(draftOf(controller)).toMatchObject({ source: 'mine', creating: true, content: '- id: tool-read\n' })
})
it('does nothing before the roster loaded', async () => {
const { controller, calls } = harness()
await controller.createFrom()
expect(controller.store.getSnapshot().draft).toBeNull()
expect(calls).toHaveLength(0)
})
})
describe('the save blocker', () => {
const base: PresetDraft = {
id: '', source: 'standard', creating: true, content: '', writable: true, saving: false, error: null,
}
const rows: readonly PresetRow[] = [{ id: 'mine', trust: 'user', isDefault: false }]
it('never blocks an edit of an existing preset', () => {
expect(draftBlocker({ ...base, id: 'mine', creating: false }, rows)).toBeUndefined()
})
it('requires a name', () => {
expect(draftBlocker(base, rows)).toBe('idRequired')
})
it.each(['Upper', 'has space', '-leading', 'a/b', '../escape'])('rejects the unusable id %j', (id) => {
// The id becomes a directory name, so the client mirrors the host's own
// containment rule instead of letting the save round-trip to find out.
expect(draftBlocker({ ...base, id }, rows)).toBe('idInvalid')
})
it('rejects a name already in use', () => {
// Replacing is what Edit is for; a create landing on an existing name
// would overwrite a preset the user never opened.
expect(draftBlocker({ ...base, id: 'mine' }, rows)).toBe('idTaken')
})
it('accepts an unused, containable id', () => {
expect(draftBlocker({ ...base, id: 'my-agent2' }, rows)).toBeUndefined()
})
})
describe('saving', () => {
it('creates the preset and re-reads the roster', async () => {
const { controller, presets } = harness()
await controller.load()
await controller.createFrom()
controller.setId('my-agent')
controller.setContent('- id: tool-web-search\n')
await controller.save()
expect(presets.get('my-agent')).toEqual({ trust: 'user', content: '- id: tool-web-search\n' })
expect(controller.store.getSnapshot().draft).toBeNull()
expect(controller.store.getSnapshot().rows.map(row => row.id)).toContain('my-agent')
})
it('replaces an existing composition', async () => {
const { controller, presets } = harness()
await controller.load()
await controller.open('mine')
controller.setContent('- id: tool-edit\n')
await controller.save()
expect(presets.get('mine')?.content).toBe('- id: tool-edit\n')
})
it('refuses to write a blocked draft', async () => {
const { controller, calls } = harness()
await controller.load()
await controller.createFrom()
await controller.save()
expect(calls.some(call => call.method === 'write')).toBe(false)
expect(controller.store.getSnapshot().draft).not.toBeNull()
})
it('refuses to write a read-only draft', async () => {
const { controller, calls } = harness()
await controller.load()
await controller.open('standard')
await controller.save()
expect(calls.some(call => call.method === 'write')).toBe(false)
})
it('does nothing without an open draft', async () => {
const { controller, calls } = harness()
await controller.load()
await controller.save()
expect(calls.some(call => call.method === 'write')).toBe(false)
})
it('keeps the draft open and reports a rejected save', async () => {
const { controller } = harness({ failWrite: 'composition is not an entry list' })
await controller.load()
await controller.open('mine')
await controller.save()
// The text stays in the editor: it is the only copy, and the message says
// what to fix.
expect(draftOf(controller)).toMatchObject({ saving: false, error: 'composition is not an entry list' })
})
it('reports a transport that rejects mid-save', async () => {
const presets = seed()
const api = fakeApi(presets, { id: 'standard' })
const controller = new AgentPresetSectionController({
...api,
agentPresets: { ...api.agentPresets, write: () => Promise.reject(new Error('socket closed')) },
})
await controller.load()
await controller.open('mine')
await controller.save()
expect(draftOf(controller)).toMatchObject({ saving: false, error: 'socket closed' })
})
it('ignores a second save while one is in flight', async () => {
const { controller, calls } = harness()
await controller.load()
await controller.open('mine')
await Promise.all([controller.save(), controller.save()])
expect(calls.filter(call => call.method === 'write')).toHaveLength(1)
})
it('clears a save failure when the text changes', async () => {
const { controller } = harness({ failWrite: 'invalid' })
await controller.load()
await controller.open('mine')
await controller.save()
controller.setContent('- id: fixed\n')
expect(draftOf(controller).error).toBeNull()
})
it('ignores an edit with no draft open', () => {
const { controller } = harness()
controller.setId('x')
controller.setContent('y')
expect(controller.store.getSnapshot().draft).toBeNull()
})
})
describe('deleting', () => {
it('deletes the confirmed preset and re-reads the roster', async () => {
const { controller, presets } = harness()
await controller.load()
controller.confirmDelete('mine')
await controller.remove()
expect(presets.has('mine')).toBe(false)
expect(controller.store.getSnapshot()).toMatchObject({ pendingDelete: null, deleting: false })
expect(controller.store.getSnapshot().rows.map(row => row.id)).toEqual(['standard'])
})
it('closes an editor open on the deleted preset', async () => {
const { controller } = harness()
await controller.load()
await controller.open('mine')
controller.confirmDelete('mine')
await controller.remove()
// The file is gone; leaving its text in an editor whose Save would
// resurrect it is worse than closing.
expect(controller.store.getSnapshot().draft).toBeNull()
})
it('leaves a copy-in-progress open when its source is deleted', async () => {
const { controller } = harness()
await controller.load()
await controller.createFrom('mine')
controller.setId('mine')
controller.confirmDelete('mine')
await controller.remove()
// The draft is a new preset that happens to be named after the one just
// deleted; its text is unsaved work.
expect(draftOf(controller)).toMatchObject({ id: 'mine', creating: true })
})
it('dismisses the confirmation without deleting', async () => {
const { controller, calls, presets } = harness()
await controller.load()
controller.confirmDelete('mine')
controller.confirmDelete(null)
await controller.remove()
expect(calls.some(call => call.method === 'remove')).toBe(false)
expect(presets.has('mine')).toBe(true)
})
it('reports a refused delete on the page', async () => {
const { controller } = harness({ failRemove: 'it ships with the deployment' })
await controller.load()
controller.confirmDelete('standard')
await controller.remove()
expect(controller.store.getSnapshot()).toMatchObject({
pendingDelete: null, deleting: false, error: 'it ships with the deployment',
})
})
it('reports a transport that rejects mid-delete', async () => {
const presets = seed()
const api = fakeApi(presets, { id: 'standard' })
const controller = new AgentPresetSectionController({
...api,
agentPresets: { ...api.agentPresets, remove: () => Promise.reject(new Error('socket closed')) },
})
await controller.load()
controller.confirmDelete('mine')
await controller.remove()
expect(controller.store.getSnapshot()).toMatchObject({ deleting: false, error: 'socket closed' })
})
it('ignores a second delete while one is in flight', async () => {
const { controller, calls } = harness()
await controller.load()
controller.confirmDelete('mine')
await Promise.all([controller.remove(), controller.remove()])
expect(calls.filter(call => call.method === 'remove')).toHaveLength(1)
})
it('ignores a confirmation change while a delete is in flight', async () => {
let release = (): void => {}
const held = new Promise<void>((resolve) => { release = resolve })
const presets = seed()
const controller = new AgentPresetSectionController(
fakeApi(presets, { id: 'standard' }, { holdRemove: held }),
)
await controller.load()
controller.confirmDelete('mine')
const pending = controller.remove()
// Dismissing mid-flight cannot un-delete the file, so the confirmation
// stays put rather than the page claiming nothing is happening.
controller.confirmDelete(null)
expect(controller.store.getSnapshot().pendingDelete).toBe('mine')
release()
await pending
expect(presets.has('mine')).toBe(false)
})
})
describe('the default preset', () => {
it('writes the settings field and re-reads the roster', async () => {
const { controller, calls, defaultId } = harness()
await controller.load()
await controller.makeDefault('mine')
expect(calls.find(call => call.method === 'settings.update')?.payload)
.toEqual({ ns: 'agent-presets', patch: { default: 'mine' } })
expect(defaultId.id).toBe('mine')
expect(controller.store.getSnapshot().rows.find(row => row.isDefault)?.id).toBe('mine')
})
it('reports a refused write and leaves the roster alone', async () => {
const { controller, defaultId } = harness({ failSettings: 'settings are read-only' })
await controller.load()
await controller.makeDefault('mine')
expect(controller.store.getSnapshot().error).toBe('settings are read-only')
expect(defaultId.id).toBe('standard')
})
})

View File

@@ -0,0 +1,260 @@
// @vitest-environment jsdom
/**
* The management section's rendering rules: which actions a row offers depends
* on its trust and whether it is the default, a shipped composition opens
* without a Save, and a draft the host would refuse is blocked before it is
* sent.
*/
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { AgentPresetSection } from '../src/client/AgentPresetSection.tsx'
import type { AgentPresetSectionProps } from '../src/client/AgentPresetSection.tsx'
import type { AgentPresetSectionState } from '../src/client/section-store.ts'
import { en } from '../src/client/locales.ts'
afterEach(cleanup)
const READY: AgentPresetSectionState = {
status: 'ready',
error: null,
authorable: true,
rows: [
{ id: 'standard', trust: 'system', isDefault: true },
{ id: 'mine', trust: 'user', isDefault: false },
],
draft: null,
pendingDelete: null,
deleting: false,
}
/**
* Render the section over a fixed snapshot, with every action a spy.
* @param state - the snapshot to render.
* @returns the spies, so a test can assert what a click reached.
*/
function renderSection(state: Partial<AgentPresetSectionState> = {}) {
const store = createSnapshotStore<AgentPresetSectionState>({ ...READY, ...state })
const actions = {
load: vi.fn(() => Promise.resolve()),
open: vi.fn(() => Promise.resolve()),
createFrom: vi.fn(() => Promise.resolve()),
close: vi.fn(),
setId: vi.fn(),
setContent: vi.fn(),
save: vi.fn(() => Promise.resolve()),
confirmDelete: vi.fn(),
remove: vi.fn(() => Promise.resolve()),
makeDefault: vi.fn(() => Promise.resolve()),
}
const props = {
...actions,
useAgentPresetSection: bindSnapshotSelector(store),
t: (key: keyof typeof en) => en[key],
} as unknown as AgentPresetSectionProps
render(<AgentPresetSection {...props} />)
return actions
}
function rowFor(id: string): HTMLElement {
const row = screen.getByText(id).closest('li')
/* v8 ignore next -- every rendered row id resolves to its card */
if (row === null) throw new Error(`no row for ${id}`)
return row
}
describe('the preset list', () => {
it('reads the roster once when it first renders', async () => {
const actions = renderSection()
await waitFor(() => { expect(actions.load).toHaveBeenCalledTimes(1) })
})
it('marks trust and the default, and offers no "set default" on the default row', () => {
renderSection()
const standard = rowFor('standard')
expect(within(standard).getByText(en.builtIn)).toBeTruthy()
expect(within(standard).getByText(en.defaultBadge)).toBeTruthy()
expect(within(standard).queryByText(en.setDefault)).toBeNull()
expect(within(rowFor('mine')).getByText(en.userTrust)).toBeTruthy()
})
it('offers Edit for a local preset and View for a shipped one', () => {
renderSection()
expect(within(rowFor('mine')).getByText(en.edit)).toBeTruthy()
// A shipped composition is readable but not editable, and the label is
// what says so before the editor opens.
expect(within(rowFor('standard')).getByText(en.view)).toBeTruthy()
})
it('offers Delete only for a locally authored preset', () => {
renderSection()
expect(within(rowFor('mine')).getByText(en.delete)).toBeTruthy()
expect(within(rowFor('standard')).queryByText(en.delete)).toBeNull()
})
it('hides duplication and disables creation when nothing is writable', () => {
renderSection({ authorable: false })
expect(screen.queryByText(en.duplicate)).toBeNull()
expect(screen.getByText(`+ ${en.newPreset}`)).toHaveProperty('disabled', true)
})
it('routes the row actions to the controller', () => {
const actions = renderSection()
fireEvent.click(within(rowFor('mine')).getByText(en.setDefault))
fireEvent.click(within(rowFor('mine')).getByText(en.edit))
fireEvent.click(within(rowFor('mine')).getByText(en.duplicate))
fireEvent.click(screen.getByText(`+ ${en.newPreset}`))
expect(actions.makeDefault).toHaveBeenCalledWith('mine')
expect(actions.open).toHaveBeenCalledWith('mine')
expect(actions.createFrom).toHaveBeenCalledWith('mine')
// The bare "new" copies the default, which the controller resolves.
expect(actions.createFrom).toHaveBeenLastCalledWith()
})
it('shows a page-level failure without hiding the list', () => {
renderSection({ error: 'settings are read-only' })
expect(screen.getByRole('alert').textContent).toBe('settings are read-only')
expect(rowFor('mine')).toBeTruthy()
})
it('renders nothing when the deployment composes no presets', () => {
const { container } = render(<AgentPresetSection {...({
useAgentPresetSection: bindSnapshotSelector(
createSnapshotStore<AgentPresetSectionState>({ ...READY, status: 'unavailable', rows: [] })),
t: (key: keyof typeof en) => en[key],
load: vi.fn(() => Promise.resolve()),
} as unknown as AgentPresetSectionProps)} />)
expect(container.firstChild).toBeNull()
})
it('offers a retry when the roster could not be read', () => {
const actions = renderSection({ status: 'error', error: 'roster unavailable' })
expect(screen.getByRole('alert').textContent).toContain('roster unavailable')
fireEvent.click(screen.getByText(en.retry))
expect(actions.load).toHaveBeenCalledTimes(2)
})
})
describe('the composition editor', () => {
const draft = {
id: 'mine', source: 'mine', creating: false, content: '- id: tool-read\n',
writable: true, saving: false, error: null,
}
it('opens under the row it belongs to and edits through the controller', () => {
const actions = renderSection({ draft })
const editor = within(rowFor('mine')).getByRole('textbox')
expect(editor).toHaveProperty('value', '- id: tool-read\n')
fireEvent.change(editor, { target: { value: '- id: tool-edit\n' } })
expect(actions.setContent).toHaveBeenCalledWith('- id: tool-edit\n')
})
it('saves and cancels through the controller', () => {
const actions = renderSection({ draft })
fireEvent.click(screen.getByText(en.save))
fireEvent.click(screen.getByText(en.cancel))
expect(actions.save).toHaveBeenCalledTimes(1)
expect(actions.close).toHaveBeenCalledTimes(1)
})
it('reports a save in flight and blocks a second click', () => {
const actions = renderSection({ draft: { ...draft, saving: true } })
fireEvent.click(screen.getByText(en.saving))
expect(actions.save).not.toHaveBeenCalled()
})
it('shows a shipped composition read-only, with no way to save it', () => {
renderSection({ draft: { ...draft, id: 'standard', source: 'standard', writable: false } })
expect(screen.getByRole('textbox')).toHaveProperty('readOnly', true)
expect(screen.getByText(en.readOnlyNotice)).toBeTruthy()
expect(screen.queryByText(en.save)).toBeNull()
expect(screen.getByText(en.close)).toBeTruthy()
})
it('names a new preset and says what it was copied from', () => {
const actions = renderSection({
draft: { ...draft, id: '', source: 'standard', creating: true },
})
expect(screen.getByText(`${en.copyOf} standard`)).toBeTruthy()
fireEvent.change(screen.getByPlaceholderText(en.presetNamePlaceholder), { target: { value: 'my-agent' } })
expect(actions.setId).toHaveBeenCalledWith('my-agent')
})
it('blocks a save the host would refuse, and says why', () => {
const actions = renderSection({
draft: { ...draft, id: 'Upper Case', source: 'standard', creating: true },
})
expect(screen.getByRole('alert').textContent).toBe(en.idInvalid)
fireEvent.click(screen.getByText(en.save))
// Disabled rather than round-tripping: the id is a directory name and the
// rule is the host's own.
expect(actions.save).not.toHaveBeenCalled()
})
it('shows the host\'s refusal instead of the local blocker', () => {
renderSection({ draft: { ...draft, error: 'composition is not an entry list' } })
expect(screen.getByRole('alert').textContent).toBe('composition is not an entry list')
})
})
describe('deleting a preset', () => {
it('asks before deleting', () => {
const actions = renderSection()
fireEvent.click(within(rowFor('mine')).getByText(en.delete))
expect(actions.confirmDelete).toHaveBeenCalledWith('mine')
})
it('confirms and dismisses through the controller', () => {
const actions = renderSection({ pendingDelete: 'mine' })
const dialog = screen.getByRole('dialog')
fireEvent.click(within(dialog).getByText(en.deleteConfirm))
fireEvent.click(within(dialog).getByText(en.cancel))
expect(actions.remove).toHaveBeenCalledTimes(1)
expect(actions.confirmDelete).toHaveBeenLastCalledWith(null)
})
it('dismisses the confirmation on Escape', () => {
const actions = renderSection({ pendingDelete: 'mine' })
fireEvent.keyDown(document, { key: 'Escape' })
expect(actions.confirmDelete).toHaveBeenCalledWith(null)
})
it('reports a delete in flight', () => {
const actions = renderSection({ pendingDelete: 'mine', deleting: true })
fireEvent.click(within(screen.getByRole('dialog')).getByText(en.deleting))
expect(actions.remove).not.toHaveBeenCalled()
})
})

View File

@@ -8,7 +8,7 @@
import { describe, expect, it } from 'vitest'
import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
import {
AGENT_PRESET_SETTINGS_NS, AgentPresetSettingsController,
AGENT_PRESET_SETTINGS_NS, AgentPresetSettingsController, messageOf,
} from '../src/client/settings-store.ts'
import { AgentPresetSeatController } from '../src/client/seat-store.ts'
@@ -17,7 +17,7 @@ interface Recorded { ns: string; patch: unknown }
/** A client whose roster and write outcome the test controls. */
function fakeApi(
presets: { id: string; trust: 'system' | 'user'; isDefault: boolean }[],
options: { writes?: Recorded[]; failWrite?: string; failList?: string } = {},
options: { writes?: Recorded[]; failWrite?: string; failList?: string; failWriteWith?: Error } = {},
): IApiClient {
return {
agentPresets: {
@@ -28,6 +28,7 @@ function fakeApi(
settings: {
update: (payload: { ns: string; patch: unknown }) => {
options.writes?.push({ ns: payload.ns, patch: payload.patch })
if (options.failWriteWith !== undefined) return Promise.reject(options.failWriteWith)
if (options.failWrite !== undefined) {
return Promise.resolve({ rpcId: 'r', result: { ok: false as const, error: { code: 'internal', message: options.failWrite, details: {} } } })
}
@@ -127,12 +128,18 @@ describe('the composer seat controller', () => {
function seat(
presets: { id: string; trust: 'system' | 'user'; isDefault: boolean }[],
summary: { blank: boolean; agentPreset?: string } | undefined,
options: { writes?: Recorded[]; failSelect?: string } = {},
options: { writes?: Recorded[]; failSelect?: string; failList?: string; throwOn?: 'list' | 'select' } = {},
): AgentPresetSeatController {
const api = {
agentPresets: {
list: () => Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: { presets } } }),
list: () => {
if (options.throwOn === 'list') return Promise.reject(new Error('socket closed'))
return Promise.resolve(options.failList === undefined
? { rpcId: 'r', result: { ok: true as const, value: { presets } } }
: { rpcId: 'r', result: { ok: false as const, error: { code: 'internal', message: options.failList, details: {} } } })
},
select: (payload: { agentPreset: string }) => {
if (options.throwOn === 'select') return Promise.reject(new Error('socket closed'))
options.writes?.push({ ns: 'select', patch: payload.agentPreset })
return Promise.resolve(options.failSelect === undefined
? { rpcId: 'r', result: { ok: true as const, value: { agentPreset: payload.agentPreset } } }
@@ -209,6 +216,89 @@ describe('the composer seat controller', () => {
expect(state.error).toBe('already started')
})
it('shows the first preset when the roster marks none default', async () => {
// Settings can name a preset that was since deleted; the picker still has
// to show something rather than an empty control.
const controller = new AgentPresetSettingsController(fakeApi([
{ id: 'standard', trust: 'system', isDefault: false },
{ id: 'mine', trust: 'user', isDefault: false },
]))
await controller.load()
expect(controller.store.getSnapshot().currentValue).toBe('standard')
})
it('ignores a load while one is already in flight', async () => {
const writes: Recorded[] = []
const controller = new AgentPresetSettingsController(fakeApi(
[{ id: 'standard', trust: 'system', isDefault: true }], { writes }))
await Promise.all([controller.load(), controller.load()])
expect(controller.store.getSnapshot().status).toBe('ready')
})
it('reads an Error\'s message and stringifies anything else', () => {
// A transport rejects with an Error, but a host or a runtime can reject
// with anything and the surface still has to say something.
expect(messageOf(new Error('boom'))).toBe('boom')
expect(messageOf({ code: 7 })).toBe('[object Object]')
})
it('reports a transport that rejects rather than answering', async () => {
const controller = new AgentPresetSettingsController({
agentPresets: { list: () => Promise.reject(new Error('socket closed')) },
} as unknown as IApiClient)
await controller.load()
expect(controller.store.getSnapshot()).toMatchObject({ status: 'error', error: 'socket closed' })
})
it('reports a transport that rejects mid-write and keeps the old default showing', async () => {
const controller = new AgentPresetSettingsController(fakeApi([
{ id: 'standard', trust: 'system', isDefault: true },
{ id: 'mine', trust: 'user', isDefault: false },
], { failWriteWith: new Error('socket closed') }))
await controller.load()
await controller.select('mine')
// The value snaps back because the host never took it; a picker still
// showing "mine" would be claiming a default that does not exist.
expect(controller.store.getSnapshot()).toMatchObject({ currentValue: 'standard', error: 'socket closed' })
})
it('reports a refused roster read without emptying the seat', async () => {
const controller = seat(ROSTER, { blank: true }, { failList: 'host down' })
await controller.load()
// The seat keeps whatever it last showed rather than claiming this session
// has no preset; the message says why it could not refresh.
expect(controller.store.getSnapshot()).toMatchObject({ error: 'host down', options: [] })
})
it('reports a transport that rejects the roster read', async () => {
const controller = seat(ROSTER, { blank: true }, { throwOn: 'list' })
await controller.load()
expect(controller.store.getSnapshot().error).toBe('socket closed')
})
it('restores the previous preset when the switch never reaches the host', async () => {
const controller = seat(ROSTER, { blank: true, agentPreset: 'standard' }, { throwOn: 'select' })
await controller.load()
await controller.select('core-web')
// Showing `core-web` after a failed switch would claim a composition the
// session never got.
expect(controller.store.getSnapshot()).toMatchObject({ current: 'standard', busy: false, error: 'socket closed' })
})
it('reports no options when the session is unknown to the list yet', async () => {
const controller = seat([], undefined)

View File

@@ -20,15 +20,24 @@
{
"path": "../runtime"
},
{
"path": "../test-runtime"
},
{
"path": "../ui-conversation"
},
{
"path": "../ui-primitives"
},
{
"path": "../ui-settings"
},
{
"path": "../ui-slots"
},
{
"path": "../web-react"
},
{
"path": "../../support/invariants"
}