refactor(agent-presets,web): copy-only preset authoring with a path to the files
The web YAML editor is gone. agentPreset.write (arbitrary composition
text) became agentPreset.copy { from, agentPreset, name? }: a host-side
whole-directory copy of ids the host resolves itself — symlinks
dereferenced, modes re-tightened to owner-only with owner-execute kept,
metadata rewritten to keep the source's description but never its name or
roster order. No composition text or path crosses the wire in either
authoring direction, and the entryListSchema/!!js concern dissolves with
assertComposition itself.
The settings section becomes: a read-only viewer over shipped
compositions, a copy dialog (id + optional display name) as the only
create entry, delete for custom rows, and a location action leading into
the preset's own files — agentPreset.openDocument { agentPreset } resolves
the directory host-side and opens it natively, or answers
{ opened: false, path } for the row to show as text where the deployment
has no desktop. agentPreset.list reports hasDocument beside authorable;
the gateway's nativeOpen config pins the capability where
canOpenNativePath platform detection would mislead. The privileged set is
now read/copy/openDocument/remove.
With files as the only composition editor, standing mounts grew
stamp-keyed generations: ensureStanding compares the composition file's
mtime+size and starts the next generation for later sessions, while every
joined session keeps the generation it runs on.
New keyless web lane (agent-preset-authoring, overlay pins
nativeOpen: false so goldens render one branch on every platform) drives
view/copy/reveal/delete end to end; the real-composition CLI e2e switches
to copy semantics.
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/connection/README.md
|
||||
README.md: e597d047ab36cf34ac1bce041e162a42c01dc58a
|
||||
README.zh.md: 9442633526266c981aa7b39942e9c0a9b34dd161
|
||||
README.md: da2cb781de6726596d1003ac9c2756b6113afc19
|
||||
README.zh.md: 87998d73ae5b768e4c3ded2967ab972fbc872c9f
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + current-page loopback state + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The browser carrier uses HTTP POST for unary and respond operations and opens one downlink-only WebSocket each for `events.mux` and `events.host`; the in-process carrier satisfies the same two-stream abstraction. The Host half owns the single `/api` route and its Fetch bridge; a registered TypeRT interceptor claims its Remote endpoints before the API Proxy fallback. Loopback hostname classification stays package-internal: the `/api` Host fence and WebSocket upgrades use it directly, while other client plugins consume the derived `ctx.connection.isLoopback` state. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`openDocument`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`; reads and native actions included, since describing returns the exposed configuration, opening acts on the Host desktop, and probing an arbitrary reference reports where a credential comes from — and the agent-preset authoring plane, `agentPreset.read`/`write`/`remove`, since a composition names the plugins a session runs, so reading one is reconnaissance and writing one is arbitrary capability; `agentPreset.list` and `agentPreset.select` stay out — the roster carries only ids and trust, and choosing a preset grants nothing `session.create`'s own `agentPreset` did not, over a default that already carries bash) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform carriers and ConnectionController loop are package-internal; apply selects and drives them. The downlink boundary is documented in the [WebSocket downlink carrier Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md); the protocol contract is api-contracts v3 §3.
|
||||
Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + current-page loopback state + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The browser carrier uses HTTP POST for unary and respond operations and opens one downlink-only WebSocket each for `events.mux` and `events.host`; the in-process carrier satisfies the same two-stream abstraction. The Host half owns the single `/api` route and its Fetch bridge; a registered TypeRT interceptor claims its Remote endpoints before the API Proxy fallback. Loopback hostname classification stays package-internal: the `/api` Host fence and WebSocket upgrades use it directly, while other client plugins consume the derived `ctx.connection.isLoopback` state. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`openDocument`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`; reads and native actions included, since describing returns the exposed configuration, opening acts on the Host desktop, and probing an arbitrary reference reports where a credential comes from — and the agent-preset authoring plane, `agentPreset.read`/`copy`/`openDocument`/`remove`, since a composition names the plugins a session runs, so reading one is reconnaissance, and copy/remove/openDocument manage the roster and drive the host desktop (authoring is copy-only, so none of them accepts composition text or a path); `agentPreset.list` and `agentPreset.select` stay out — the roster carries only ids and trust, and choosing a preset grants nothing `session.create`'s own `agentPreset` did not, over a default that already carries bash) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform carriers and ConnectionController loop are package-internal; apply selects and drives them. The downlink boundary is documented in the [WebSocket downlink carrier Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md); the protocol contract is api-contracts v3 §3.
|
||||
|
||||
## /api browser-trust fence
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。浏览器载体以 HTTP POST 发送 unary/respond,并为 `events.mux` 与 `events.host` 各开一条只下行的 WebSocket;进程内载体满足同一双流抽象。Host half 持有唯一 `/api` route 及其 Fetch bridge;已注册的 TypeRT interceptor 会先认领自己的 Remote endpoint,未认领请求再回退 API Proxy。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 与 WebSocket upgrade 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`;读取与原生操作也在内,因为 describe 会返回已暴露的配置、打开操作会作用于 Host 桌面,而探测任意引用会报出某条凭据来自何处——以及 agent preset 的创作面 `agentPreset.read`/`write`/`remove`,因为组装指明了一个会话所运行的插件,读取它是侦察,写入它是任意能力;`agentPreset.list` 与 `agentPreset.select` 不在其中——名单只携带 id 与信任级别,而选择一个 preset 并不比 `session.create` 自带的 `agentPreset` 多给任何能力,何况默认 preset 本就带着 bash)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台载体与 ConnectionController 循环属于包内部;apply 负责选择并驱动它们。下行边界见 [WebSocket 下行载体 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md);协议契约见 api-contracts v3 §3。
|
||||
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。浏览器载体以 HTTP POST 发送 unary/respond,并为 `events.mux` 与 `events.host` 各开一条只下行的 WebSocket;进程内载体满足同一双流抽象。Host half 持有唯一 `/api` route 及其 Fetch bridge;已注册的 TypeRT interceptor 会先认领自己的 Remote endpoint,未认领请求再回退 API Proxy。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 与 WebSocket upgrade 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`;读取与原生操作也在内,因为 describe 会返回已暴露的配置、打开操作会作用于 Host 桌面,而探测任意引用会报出某条凭据来自何处——以及 agent preset 的创作面 `agentPreset.read`/`copy`/`openDocument`/`remove`,因为组装指明了一个会话所运行的插件,读取它是侦察,而 copy/remove/openDocument 管理名单并驱动宿主桌面(创作只有复制一种写入,因此这些方法都不接收组装文本或路径);`agentPreset.list` 与 `agentPreset.select` 不在其中——名单只携带 id 与信任级别,而选择一个 preset 并不比 `session.create` 自带的 `agentPreset` 多给任何能力,何况默认 preset 本就带着 bash)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台载体与 ConnectionController 循环属于包内部;apply 负责选择并驱动它们。下行边界见 [WebSocket 下行载体 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md);协议契约见 api-contracts v3 §3。
|
||||
|
||||
## /api 浏览器信任栅栏
|
||||
|
||||
|
||||
@@ -2464,6 +2464,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||
isDefault: id === fixtureDefaultPreset,
|
||||
})),
|
||||
authorable: true,
|
||||
hasDocument: true,
|
||||
}),
|
||||
select: (request) => {
|
||||
fixtureDefaultPreset = request.payload.agentPreset
|
||||
@@ -2483,21 +2484,42 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||
agentPreset,
|
||||
trust: preset.trust,
|
||||
content: preset.content,
|
||||
writable: preset.trust === 'user',
|
||||
})
|
||||
},
|
||||
write: (request) => {
|
||||
const { agentPreset, content } = request.payload
|
||||
copy: (request) => {
|
||||
const { from, agentPreset } = request.payload
|
||||
const source = fixturePresets.get(from)
|
||||
if (source === undefined) {
|
||||
return err(request, {
|
||||
code: 'agent-preset-not-found',
|
||||
message: `unknown agent preset "${from}"`,
|
||||
details: { agentPreset: from, available: [...fixturePresets.keys()] },
|
||||
})
|
||||
}
|
||||
if (fixturePresets.has(agentPreset)) {
|
||||
return err(request, {
|
||||
code: 'agent-preset-invalid',
|
||||
message: `agent preset "${agentPreset}" already exists`,
|
||||
details: { agentPreset, reason: 'already exists' },
|
||||
})
|
||||
}
|
||||
fixturePresets.set(agentPreset, { trust: 'user', content: source.content })
|
||||
return ok(request, { agentPreset })
|
||||
},
|
||||
// Native opens are deterministic no-op successes in this fixture, so the
|
||||
// open-directory affordance renders and the path-text fallback stays a
|
||||
// component-test concern.
|
||||
openDocument: (request) => {
|
||||
const { agentPreset } = request.payload
|
||||
const existing = fixturePresets.get(agentPreset)
|
||||
if (existing?.trust === 'system') {
|
||||
if (existing === undefined || existing.trust === 'system') {
|
||||
return err(request, {
|
||||
code: 'agent-preset-read-only',
|
||||
message: `agent preset "${agentPreset}" ships with the deployment`,
|
||||
details: { agentPreset, reason: 'it ships with the deployment' },
|
||||
})
|
||||
}
|
||||
fixturePresets.set(agentPreset, { trust: 'user', content })
|
||||
return ok(request, { agentPreset })
|
||||
return ok(request, { opened: true as const })
|
||||
},
|
||||
remove: (request) => {
|
||||
const { agentPreset } = request.payload
|
||||
@@ -2835,7 +2857,8 @@ export class FixtureApiClient extends AbstractApiClient {
|
||||
case 'agentPreset.list': return this.api.agentPresets.list(request)
|
||||
case 'agentPreset.select': return this.api.agentPresets.select(request)
|
||||
case 'agentPreset.read': return this.api.agentPresets.read(request)
|
||||
case 'agentPreset.write': return this.api.agentPresets.write(request)
|
||||
case 'agentPreset.copy': return this.api.agentPresets.copy(request)
|
||||
case 'agentPreset.openDocument': return this.api.agentPresets.openDocument(request, new AbortController().signal)
|
||||
case 'agentPreset.remove': return this.api.agentPresets.remove(request)
|
||||
case 'goal.create': return this.api.goals.create(request)
|
||||
case 'goal.edit': return this.api.goals.edit(request)
|
||||
|
||||
@@ -67,8 +67,10 @@ export const Config: z<ConnectionConfig> = z.object({
|
||||
*/
|
||||
const PRIVILEGED_METHODS = new Set([
|
||||
// A preset composition names the plugins a session runs, so reading one is
|
||||
// reconnaissance and writing one is arbitrary capability — strictly more than
|
||||
// the settings document beside it.
|
||||
// reconnaissance; copy and remove rearrange what the deployment offers, and
|
||||
// openDocument drives the host desktop — all more than the roster beside
|
||||
// them. (Authoring is copy-only, so no method here accepts composition text
|
||||
// or a path; the pin is about who may manage the roster at all.)
|
||||
//
|
||||
// CHOOSING one is not pinned, and `agentPreset.list` is not either. Picking a
|
||||
// preset looks like escalation — one of them mounts the toolset that edits the
|
||||
@@ -79,7 +81,8 @@ const PRIVILEGED_METHODS = new Set([
|
||||
// any caller that may start a session at all can already run commands as this
|
||||
// process. Pinning the switch would be a fence beside an open gate.
|
||||
'agentPreset.read',
|
||||
'agentPreset.write',
|
||||
'agentPreset.copy',
|
||||
'agentPreset.openDocument',
|
||||
'agentPreset.remove',
|
||||
'host.pickDirectory',
|
||||
'host.openPath',
|
||||
|
||||
@@ -169,15 +169,17 @@ export class FakeApiClient implements IApiClient {
|
||||
}
|
||||
|
||||
readonly agentPresets: IApiClient['agentPresets'] = {
|
||||
list: (payload: unknown) => this.record('agentPreset.list', payload, Promise.resolve(ok({ presets: [], authorable: false }))),
|
||||
list: (payload: unknown) => this.record('agentPreset.list', payload, Promise.resolve(ok({ presets: [], authorable: false, hasDocument: false }))),
|
||||
select: (payload: { agentPreset: string }) =>
|
||||
this.record('agentPreset.select', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))),
|
||||
read: (payload: { agentPreset: string }) =>
|
||||
this.record('agentPreset.read', payload, Promise.resolve(ok({
|
||||
agentPreset: payload.agentPreset, trust: 'user' as const, content: '', writable: true,
|
||||
agentPreset: payload.agentPreset, trust: 'user' as const, content: '',
|
||||
}))),
|
||||
write: (payload: { agentPreset: string }) =>
|
||||
this.record('agentPreset.write', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))),
|
||||
copy: (payload: { agentPreset: string }) =>
|
||||
this.record('agentPreset.copy', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))),
|
||||
openDocument: (payload: { agentPreset: string }) =>
|
||||
this.record('agentPreset.openDocument', payload, Promise.resolve(ok({ opened: true as const }))),
|
||||
remove: (payload: { agentPreset: string }) =>
|
||||
this.record('agentPreset.remove', payload, Promise.resolve(ok({}))),
|
||||
}
|
||||
|
||||
@@ -160,8 +160,9 @@ describe('connection node half', () => {
|
||||
'credentials.describe', 'credentials.set', 'credentials.unset',
|
||||
'llm.discoverModels',
|
||||
// A composition names the plugins a session runs: reading one is
|
||||
// reconnaissance and writing one is arbitrary capability.
|
||||
'agentPreset.read', 'agentPreset.write', 'agentPreset.remove',
|
||||
// reconnaissance, and copy/remove/openDocument manage the roster and
|
||||
// drive the host desktop.
|
||||
'agentPreset.read', 'agentPreset.copy', 'agentPreset.openDocument', 'agentPreset.remove',
|
||||
]) {
|
||||
const denied = fakeResponse()
|
||||
await routes[0]!.handler(
|
||||
@@ -455,7 +456,7 @@ describe('connection node half over a real HTTP server', () => {
|
||||
// Carries a draft credential and turns the host into a fetcher for a
|
||||
// URL the caller picked: an anonymous LAN caller must not reach it.
|
||||
'llm.discoverModels',
|
||||
'agentPreset.read', 'agentPreset.write', 'agentPreset.remove',
|
||||
'agentPreset.read', 'agentPreset.copy', 'agentPreset.openDocument', 'agentPreset.remove',
|
||||
]) {
|
||||
expect([method, await call(port, method, 'harness.example')]).toEqual([method, 403])
|
||||
}
|
||||
|
||||
@@ -204,15 +204,17 @@ export class FakeApiClient implements IApiClient {
|
||||
}
|
||||
|
||||
readonly agentPresets: IApiClient['agentPresets'] = {
|
||||
list: (payload: unknown) => this.record('agentPreset.list', payload, Promise.resolve(ok({ presets: [], authorable: false }))),
|
||||
list: (payload: unknown) => this.record('agentPreset.list', payload, Promise.resolve(ok({ presets: [], authorable: false, hasDocument: false }))),
|
||||
select: (payload: { agentPreset: string }) =>
|
||||
this.record('agentPreset.select', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))),
|
||||
read: (payload: { agentPreset: string }) =>
|
||||
this.record('agentPreset.read', payload, Promise.resolve(ok({
|
||||
agentPreset: payload.agentPreset, trust: 'user' as const, content: '', writable: true,
|
||||
agentPreset: payload.agentPreset, trust: 'user' as const, content: '',
|
||||
}))),
|
||||
write: (payload: { agentPreset: string }) =>
|
||||
this.record('agentPreset.write', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))),
|
||||
copy: (payload: { agentPreset: string }) =>
|
||||
this.record('agentPreset.copy', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))),
|
||||
openDocument: (payload: { agentPreset: string }) =>
|
||||
this.record('agentPreset.openDocument', payload, Promise.resolve(ok({ opened: true as const }))),
|
||||
remove: (payload: { agentPreset: string }) =>
|
||||
this.record('agentPreset.remove', payload, Promise.resolve(ok({}))),
|
||||
}
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-agent-preset/README.md
|
||||
README.md: c9aa370ebb7612cdc18bbe3d63c85cbb7097f69b
|
||||
README.zh.md: 707745c547f2172abb4d11cda09f3de1c5c1ec78
|
||||
README.md: c43fbff7846153911d0dc5bd85935470b35dcbff
|
||||
README.zh.md: 1fca73a5efe873cd92afb9881fbc41d5b3b42d43
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The agent-preset surfaces: a General-settings row choosing which [preset](../../preset/agent-presets/README.md) new sessions are composed from, a chip on the new-session screen choosing the next session's, a read-only label in the session header, and a settings section that authors the compositions themselves.
|
||||
The agent-preset surfaces: a General-settings row choosing which [preset](../../preset/agent-presets/README.md) new sessions are composed from, a chip on the new-session screen choosing the next session's, a read-only label in the session header, and a settings section that manages the roster — copy, delete, default, and the way into a preset's own files.
|
||||
|
||||
## Why it is a new-session preference
|
||||
|
||||
@@ -30,21 +30,23 @@ The row re-reads on `settings/changed` for its own namespace and on `connection/
|
||||
|
||||
## The management section
|
||||
|
||||
A fourth surface, its own settings page (`settings.section` id `agent-presets`, ordered after Models — choosing a model is routine, composing an agent is the deployment-shaping act behind it): the roster as rows, and one composition open in a YAML editor at a time.
|
||||
A fourth surface, its own settings page (`settings.section` id `agent-presets`, ordered after Models — choosing a model is routine, composing an agent is the deployment-shaping act behind it): the roster as cards, a copy dialog as the only way a preset is created, and a read-only viewer over the shipped compositions.
|
||||
|
||||
A shipped preset opens read-only. It is the known-good composition a local one is written against, so reading it is the point and overwriting it is not — the deployment's copy is what a broken local preset is compared against. **Duplicate** copies any row, because a copy always lands in the local root regardless of where the text came from; **New preset** starts blank, since copying is already offered on the row being copied and a composition nobody named is text the author has to recognise as unwanted before deleting it.
|
||||
The browser edits no composition text. Editing YAML in a web textarea was a weak surface (no completion, no highlighting, no diff), so a new preset is a host-side copy of an existing one — the dialog collects an id (it becomes the directory name, which is why it must be named up front and cannot change later) and an optional display name, and `{ from, id, name? }` is all that crosses the wire. Everything else — description, composition, skills — is edited in the preset's own files, and the page's other job is getting the user TO those files: the copy completes by opening the new directory, and every custom row keeps a location action. Where the host has no desktop opener (`hasDocument: false` on the roster; remote and container deployments), the same actions answer the directory as text on the row instead of offering a button that would spawn into nothing.
|
||||
|
||||
An id becomes a directory name, so the editor mirrors the host's own containment rule (`[a-z0-9][a-z0-9-]*`) and refuses a name already in use — a create landing on an existing name would overwrite a preset the user never opened. Both checks are conveniences: the host re-applies them, along with the composition's shape, and its answer is what the editor reports on failure. A save that parses is still only a save; a composition naming a plugin that does not exist fails at the next session that selects it.
|
||||
A shipped preset opens in the read-only viewer. It is the known-good composition a copy starts from, so reading it is the point; it offers no location and no delete — its install is overwritten by upgrades and is not the user's to manage. The intro carries the guidance a create button used to imply: to start from the smallest skeleton, duplicate 极简模式 (31 lines against standard's 233).
|
||||
|
||||
Deleting removes the file. Sessions already composed from it keep running — a composition is mounted once at session creation and nothing re-reads the file.
|
||||
The dialog mirrors the host's own containment rule (`[a-z0-9][a-z0-9-]*`) and refuses a name already in use — a copy never overwrites. Both checks are conveniences: the host re-applies them and its answer is what the dialog reports on failure.
|
||||
|
||||
Deleting removes the preset directory. Sessions already composed from it keep running — a composition is mounted once at session creation and nothing re-reads the file.
|
||||
|
||||
Setting the default writes the `agent-presets` settings namespace, which the host exposes to configuration clients ([`dsh-apiproxy`](../../host/apiproxy/README.md) keeps an explicit allowlist — a namespace outside it makes a picker move and then silently forget).
|
||||
|
||||
`agentPreset.read`, `write`, `remove`, and `select` are loopback-pinned ([`dsh-client-connection`](../connection/README.md)): a composition names the plugins a session runs, so reading one is reconnaissance and writing one is arbitrary capability. `agentPreset.list` is not — it carries ids and trust, and a LAN client's picker needs it.
|
||||
`agentPreset.read`, `copy`, `openDocument`, and `remove` are loopback-pinned ([`dsh-client-connection`](../connection/README.md)): a composition names the plugins a session runs, so reading one is reconnaissance, and the rest manage the roster and drive the host desktop. `agentPreset.list` is not — it carries ids, trust, and the two path-free capability flags, and a LAN client's picker needs it.
|
||||
|
||||
## When the surfaces are absent
|
||||
|
||||
A deployment that composes no presets answers with an empty roster, and the row, the chip, the label, and the section all render nothing — every session then shares the host composition, and there is nothing to choose between or manage. A deployment that configures no writable root answers `authorable: false`, and the section stays a read-only browser: the rows still open, but creating is offered nowhere rather than through a button whose save always fails.
|
||||
A deployment that composes no presets answers with an empty roster, and the row, the chip, the label, and the section all render nothing — every session then shares the host composition, and there is nothing to choose between or manage. A deployment that configures no writable root answers `authorable: false`, and the section stays a read-only browser: the shipped compositions still open in the viewer, but every copy action is disabled with the reason as its tooltip rather than offering a dialog whose create always fails.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -56,6 +58,6 @@ No direct invalidation. Changing the default never touches a running session's p
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **A preset without metadata is listed by id** — display text is optional, and a preset that publishes none (every preset authored by duplicating another starts that way) shows its directory name.
|
||||
- **The editor is a plain textarea** — no YAML syntax highlighting, folding, or schema completion; the host's shape check on save is the only validation.
|
||||
- **A saved composition is not mounted** — a preset that parses but names a missing plugin is accepted, and fails at the next session that selects it.
|
||||
- **A preset without metadata is listed by id** — display text is optional, and a copy given no name deliberately falls back to its directory name rather than presenting itself identically to its source.
|
||||
- **A revealed path is display text, not a link** — where the host has no desktop opener the row shows the directory to copy by hand; the browser cannot open a host filesystem location itself.
|
||||
- **Composition edits are invisible to the page** — the files are edited outside the browser and nothing on the wire announces a file change, so the roster re-reads on its own actions, `settings/changed`, and `connection/reset`, not on every disk edit.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
agent preset 的各个表层:General 设置中的一行,用于选择新建会话据以组装的 [preset](../../preset/agent-presets/README.md);新建会话界面上的一枚 chip,用于选择**下一个会话**的 preset;会话标题旁的一个只读标签;以及一个设置页分区,用于创作组装本身。
|
||||
agent preset 的各个表层:General 设置中的一行,用于选择新建会话据以组装的 [preset](../../preset/agent-presets/README.md);新建会话界面上的一枚 chip,用于选择**下一个会话**的 preset;会话标题旁的一个只读标签;以及一个设置页分区,用于管理名单——复制、删除、默认值,以及通往 preset 自身文件的入口。
|
||||
|
||||
## 为什么它是"新建会话"的偏好设置
|
||||
|
||||
@@ -30,21 +30,23 @@ chip 以部署默认值打开,其选择是**暂存**的——该界面先于
|
||||
|
||||
## 管理分区
|
||||
|
||||
第四个表层,独立的设置页(`settings.section`,id 为 `agent-presets`,排在「模型」之后——选模型是日常操作,而组装 agent 是它背后那件塑造部署形态的事):名单以行呈现,同一时刻有一份组装在 YAML 编辑器中打开。
|
||||
第四个表层,独立的设置页(`settings.section`,id 为 `agent-presets`,排在「模型」之后——选模型是日常操作,而组装 agent 是它背后那件塑造部署形态的事):名单以卡片呈现,复制对话框是创建 preset 的唯一入口,随附组装则在只读查看器中展示。
|
||||
|
||||
随部署提供的 preset 以只读方式打开。它是本地 preset 据以编写的已知良好组装,因此能读到它正是意义所在,而覆写它则不是——部署自带的那一份正是用来对照有问题的本地 preset 的。**复制**复制任意一行;无论文本来自何处,副本总是落在本地根目录,所以副本总是可写的。**新建 preset** 则从空白开始——复制这件事已经由被复制那一行自己提供,而一份没人指名的组装,只会让作者先认出它不是自己想要的、再把它删掉。
|
||||
浏览器不再编辑任何组装文本。在网页文本域里编 YAML 是弱功能(无补全、无高亮、无 diff),因此新 preset 是宿主端对既有 preset 的一次复制——对话框只收集一个 id(它将成为目录名,所以必须当场取好、事后无法更改)与一个可选显示名,跨越传输层的只有 `{ from, id, name? }`。其余一切——描述、组装、skills——都在 preset 自己的文件里编辑,而本页的另一职责正是把用户送到那些文件面前:复制以打开新目录作为收尾,每张自定义卡片也保有一个位置操作。宿主没有桌面打开器时(名单上的 `hasDocument: false`;远程与容器部署),同样的操作改为把目录以文本显示在卡片上,而不是提供一个点了没反应的按钮。
|
||||
|
||||
id 会成为目录名,因此编辑器复刻宿主自身的约束规则(`[a-z0-9][a-z0-9-]*`),并拒绝已被占用的名称——新建若落在已存在的名称上,就会覆盖用户从未打开过的 preset。这两项检查只是便利:宿主会连同组装的形状一起重新校验,失败时编辑器报告的正是宿主的答复。能解析的保存也仅仅是保存;引用了不存在插件的组装,会在下一个选择它的会话处失败。
|
||||
随附 preset 在只读查看器中打开。它是副本据以出发的已知良好组装,因此能读到它正是意义所在;它不提供位置也不提供删除——它的安装目录会被升级覆盖,不归用户管理。开篇引导语承担了从前创建按钮所暗示的信息:想从最小的骨架开始,就复制极简模式(31 行,对照 standard 的 233 行)。
|
||||
|
||||
删除会移除该文件。已据其组装的会话继续运行——组装在会话创建时挂载一次,此后没有任何东西会重新读取该文件。
|
||||
对话框复刻宿主自身的约束规则(`[a-z0-9][a-z0-9-]*`),并拒绝已被占用的名称——复制从不覆写。这两项检查只是便利:宿主会重新校验,失败时对话框报告的正是宿主的答复。
|
||||
|
||||
删除会移除整个 preset 目录。已据其组装的会话继续运行——组装在会话创建时挂载一次,此后没有任何东西会重新读取该文件。
|
||||
|
||||
设置默认值写入的是 `agent-presets` settings 命名空间,宿主需将其暴露给配置客户端([`dsh-apiproxy`](../../host/apiproxy/README.md) 维护一份显式白名单——不在其中的命名空间会让选择器动一下然后悄悄忘记)。
|
||||
|
||||
`agentPreset.read`、`write`、`remove` 与 `select` 被固定在环回地址(见 [`dsh-client-connection`](../connection/README.md)):组装指明了一个会话所运行的插件,因此读取它是侦察,写入它是任意能力。`agentPreset.list` 不在其中——它只携带 id 与信任级别,而局域网客户端的选择器需要它。
|
||||
`agentPreset.read`、`copy`、`openDocument` 与 `remove` 被固定在环回地址(见 [`dsh-client-connection`](../connection/README.md)):组装指明了一个会话所运行的插件,因此读取它是侦察,其余几个则管理名单并驱动宿主桌面。`agentPreset.list` 不在其中——它携带 id、信任级别与两个不含路径的能力标志,而局域网客户端的选择器需要它。
|
||||
|
||||
## 何时不显示这些表层
|
||||
|
||||
未组装任何 preset 的部署返回空名单,本行、chip、标签与分区都不渲染任何内容——此时每个会话共用宿主组装,也就无从选择或管理。未配置可写根目录的部署返回 `authorable: false`,分区随之退化为只读浏览:各行仍可打开,但任何位置都不提供"新建",而不是给出一个保存必然失败的按钮。
|
||||
未组装任何 preset 的部署返回空名单,本行、chip、标签与分区都不渲染任何内容——此时每个会话共用宿主组装,也就无从选择或管理。未配置可写根目录的部署返回 `authorable: false`,分区随之退化为只读浏览:随附组装仍可在查看器中打开,但每个复制操作都被禁用并以原因作提示,而不是给出一个创建必然失败的对话框。
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -56,6 +58,6 @@ Indirectly, through the preset a later session is composed from; [`dsh-agent-pre
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **没有元数据的 preset 按 id 列出** —— 展示文本是可选的,未发布任何展示文本的 preset(每个由复制他人而来的 preset 起初都是如此)显示的是它的目录名。
|
||||
- **编辑器是纯文本域** —— 没有 YAML 语法高亮、折叠或 schema 补全;保存时宿主的形状检查是唯一的校验。
|
||||
- **保存的组装不会被挂载** —— 能解析但引用了缺失插件的 preset 会被接受,并在下一个选择它的会话处失败。
|
||||
- **没有元数据的 preset 按 id 列出** —— 展示文本是可选的,未取名的副本刻意回退到目录名,而不是与其来源呈现得一模一样。
|
||||
- **展示的路径是文本,不是链接** —— 宿主没有桌面打开器时,卡片显示目录供手工复制;浏览器自身无法打开宿主文件系统上的位置。
|
||||
- **组装编辑对页面不可见** —— 文件在浏览器之外编辑,传输层不广播文件变动,因此名单只在自身操作、`settings/changed` 与 `connection/reset` 时重读,而非每次磁盘编辑。
|
||||
|
||||
@@ -6,31 +6,6 @@
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
/* Editing form: take the settings column's whole height so the composition
|
||||
gets the space the panel has, instead of a fixed-row box with an empty band
|
||||
under it. The list keeps the default flow — a card grid pinned to the top
|
||||
reads as a list, while a stretched one would drift its rows apart. */
|
||||
.sectionFill {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.sectionFill .editor {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.sectionFill .codeField {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.sectionFill .codeField .code {
|
||||
flex: 1;
|
||||
min-height: 160px;
|
||||
/* The drag handle would fight the flex height it is nested in. */
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
@@ -43,17 +18,6 @@
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.notice {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--dsw-alias-state-warn-label);
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* Cards, not rows: a preset is a thing you pick, and the description is the
|
||||
part that tells them apart — a row would bury it beside the actions. */
|
||||
.group {
|
||||
@@ -199,6 +163,11 @@
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.iconButton:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.iconButton:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
@@ -237,6 +206,28 @@
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
/* Where the host has no desktop opener, the row answers with the directory
|
||||
itself — text to copy, not a control that would spawn into nothing. */
|
||||
.revealedPath {
|
||||
margin: 0;
|
||||
padding: 6px 16px 10px;
|
||||
font-size: 11px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.revealedPath code {
|
||||
font-family: var(--dsw-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
user-select: all;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.revealedPathLabel {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.secondaryButton {
|
||||
border: none;
|
||||
@@ -254,18 +245,11 @@
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
}
|
||||
|
||||
.secondaryButton:disabled,
|
||||
.addButton:disabled {
|
||||
.secondaryButton:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -278,8 +262,7 @@
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.input,
|
||||
.code {
|
||||
.input {
|
||||
box-sizing: border-box;
|
||||
padding: 9px 12px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
@@ -290,18 +273,7 @@
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.code {
|
||||
font-family: var(--dsw-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
||||
line-height: 1.5;
|
||||
resize: vertical;
|
||||
white-space: pre;
|
||||
overflow-wrap: normal;
|
||||
overflow-x: auto;
|
||||
tab-size: 2;
|
||||
}
|
||||
|
||||
.input:focus,
|
||||
.code:focus {
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: var(--dsw-alias-brand-primary);
|
||||
}
|
||||
@@ -310,67 +282,35 @@
|
||||
color: var(--dsw-alias-label-dimmed);
|
||||
}
|
||||
|
||||
/* A shipped composition is drawn a rung up, and it is the one most likely to
|
||||
overflow, so its scroll thumb rebinds to that rung. */
|
||||
.code[readonly] {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
.dialog {
|
||||
width: min(560px, 100%);
|
||||
}
|
||||
|
||||
.dialogFields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* A shipped composition can be long; the dialog scrolls it rather than grow. */
|
||||
.viewerCode {
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
max-height: min(52vh, 480px);
|
||||
overflow: auto;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 10px;
|
||||
background: var(--dsw-alias-bg-layer-2);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-family: var(--dsw-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
white-space: pre;
|
||||
tab-size: 2;
|
||||
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
|
||||
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
|
||||
}
|
||||
|
||||
.editorActions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.editorBar {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
padding-bottom: 4px;
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
.backButton {
|
||||
appearance: none;
|
||||
border: 0;
|
||||
background: none;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.backButton:hover {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.backButton:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-brand-primary);
|
||||
outline-offset: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.editorTitle {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.addButton {
|
||||
align-self: flex-start;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 999px;
|
||||
padding: 8px 16px;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
background: var(--dsw-alias-bg-layer-3);
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
/**
|
||||
* Agent-presets settings section: the roster as rows, and one composition
|
||||
* open in a YAML editor at a time.
|
||||
* Agent-presets settings section: the roster as cards, a copy dialog as the
|
||||
* only way a preset is created, and a read-only viewer over the shipped
|
||||
* compositions.
|
||||
*
|
||||
* A shipped preset opens read-only — it is the known-good composition a local
|
||||
* one is written against — so authoring starts by duplicating one. Deleting a
|
||||
* preset leaves running sessions alone: a composition is mounted once at
|
||||
* session creation and nothing re-reads the file.
|
||||
* The browser edits no composition text — a shipped preset opens read-only to
|
||||
* be READ (it is the known-good composition a copy starts from), and a custom
|
||||
* preset is edited in its own files, which is what the location action leads
|
||||
* to. Deleting a preset leaves running sessions alone: a composition is
|
||||
* mounted once at session creation and nothing re-reads the file.
|
||||
*/
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
Button, IconBrowseOutline16, IconCopyOutline16, IconEditOutline16, IconTrashOutline16, Modal,
|
||||
Button, IconBrowseOutline16, IconCopyOutline16, IconFolderOpen16, IconTrashOutline16, Modal,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { draftBlocker, type AgentPresetSectionState, type PresetDraft } from './section-store.ts'
|
||||
import { draftBlocker, type AgentPresetSectionState } from './section-store.ts'
|
||||
import type { AgentPresetSettingsKey } from './locales.ts'
|
||||
import css from './AgentPresetSection.module.css'
|
||||
|
||||
@@ -27,22 +29,22 @@ export interface AgentPresetSectionInjected {
|
||||
}
|
||||
/** Read the roster; called once when the section first renders. */
|
||||
load: () => Promise<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
|
||||
/** Rename the draft. */
|
||||
setName: (name: string) => void
|
||||
/** Replace the draft's description. */
|
||||
setDescription: (description: string) => void
|
||||
/** Save the open draft. */
|
||||
save: () => Promise<void>
|
||||
/** Open one shipped preset's composition in the read-only viewer. */
|
||||
view: (id: string) => Promise<void>
|
||||
/** Close the read-only viewer. */
|
||||
closeView: () => void
|
||||
/** Open the copy dialog over one preset. */
|
||||
beginCopy: (from: string) => void
|
||||
/** Close the copy dialog, discarding the draft. */
|
||||
cancelCopy: () => void
|
||||
/** Name the preset the copy creates. */
|
||||
setCopyId: (id: string) => void
|
||||
/** Name the copy's display name. */
|
||||
setCopyName: (name: string) => void
|
||||
/** Submit the copy. */
|
||||
confirmCopy: () => Promise<void>
|
||||
/** Open one preset's directory, or reveal its path where there is no desktop. */
|
||||
openLocation: (id: string) => Promise<void>
|
||||
/** Ask for delete confirmation, or dismiss it with null. */
|
||||
confirmDelete: (id: string | null) => void
|
||||
/** Delete the preset awaiting confirmation. */
|
||||
@@ -57,93 +59,73 @@ export type AgentPresetSectionProps =
|
||||
& PropsLocale<'settings.agentPreset'>
|
||||
& InjectFace<AgentPresetSectionInjected>
|
||||
|
||||
/** Editor sub-view props: the draft plus the actions that mutate it. */
|
||||
interface EditorProps {
|
||||
draft: PresetDraft
|
||||
blocker: ReturnType<typeof draftBlocker>
|
||||
/** Copy-dialog sub-view props: the draft plus the actions that mutate it. */
|
||||
interface CopyDialogProps {
|
||||
state: AgentPresetSectionState
|
||||
t: (key: AgentPresetSettingsKey) => string
|
||||
actions: Pick<AgentPresetSectionInjected,
|
||||
'close' | 'save' | 'setContent' | 'setDescription' | 'setId' | 'setName'>
|
||||
'cancelCopy' | 'confirmCopy' | 'setCopyId' | 'setCopyName'>
|
||||
}
|
||||
|
||||
function Editor({ draft, blocker, t, actions }: EditorProps): ReactNode {
|
||||
const message = draft.error ?? (blocker === undefined ? null : t(blocker))
|
||||
function CopyDialog({ state, t, actions }: CopyDialogProps): ReactNode {
|
||||
const draft = state.copy
|
||||
const blocker = draft === null ? undefined : draftBlocker(draft, state.rows)
|
||||
const message = draft === null ? null : draft.error ?? (blocker === undefined ? null : t(blocker))
|
||||
return (
|
||||
<div className={css.editor}>
|
||||
{draft.creating
|
||||
? (
|
||||
<label className={css.field}>
|
||||
<span className={css.fieldLabel}>{t('presetId')}</span>
|
||||
<input
|
||||
className={css.input}
|
||||
value={draft.id}
|
||||
autoFocus
|
||||
spellCheck={false}
|
||||
placeholder={t('presetIdPlaceholder')}
|
||||
onChange={(event) => { actions.setId(event.target.value) }}
|
||||
/>
|
||||
{draft.source === undefined
|
||||
? null
|
||||
: <span className={css.hint}>{`${t('copyOf')} ${draft.source}`}</span>}
|
||||
</label>
|
||||
)
|
||||
: null}
|
||||
{draft.writable
|
||||
? (
|
||||
<>
|
||||
<Modal
|
||||
open={draft !== null}
|
||||
onClose={() => { actions.cancelCopy() }}
|
||||
title={draft === null ? t('copyTitle') : `${t('copyTitle')} · ${t('copyOf')} ${draft.fromTitle}`}
|
||||
closeLabel={t('close')}
|
||||
description={t('copyIntro')}
|
||||
className={css.dialog as string}
|
||||
footer={(
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={draft?.saving === true}
|
||||
onClick={() => { actions.cancelCopy() }}
|
||||
>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={draft === null || draft.saving || blocker !== undefined}
|
||||
onClick={() => { void actions.confirmCopy() }}
|
||||
>
|
||||
{draft?.saving === true ? t('creating') : t('create')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
{draft === null
|
||||
? null
|
||||
: (
|
||||
<div className={css.dialogFields}>
|
||||
<label className={css.field}>
|
||||
<span className={css.fieldLabel}>{t('presetId')}</span>
|
||||
<input
|
||||
className={css.input}
|
||||
value={draft.id}
|
||||
autoFocus
|
||||
spellCheck={false}
|
||||
placeholder={t('presetIdPlaceholder')}
|
||||
onChange={(event) => { actions.setCopyId(event.target.value) }}
|
||||
/>
|
||||
</label>
|
||||
<label className={css.field}>
|
||||
<span className={css.fieldLabel}>{t('displayName')}</span>
|
||||
<input
|
||||
className={css.input}
|
||||
value={draft.name}
|
||||
spellCheck={false}
|
||||
placeholder={draft.id === '' ? t('displayNamePlaceholder') : draft.id}
|
||||
onChange={(event) => { actions.setName(event.target.value) }}
|
||||
placeholder={t('displayNamePlaceholder')}
|
||||
onChange={(event) => { actions.setCopyName(event.target.value) }}
|
||||
/>
|
||||
</label>
|
||||
<label className={css.field}>
|
||||
<span className={css.fieldLabel}>{t('displayDescription')}</span>
|
||||
<input
|
||||
className={css.input}
|
||||
value={draft.description}
|
||||
placeholder={t('displayDescriptionPlaceholder')}
|
||||
onChange={(event) => { actions.setDescription(event.target.value) }}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
)
|
||||
: null}
|
||||
{draft.writable ? null : <p className={css.notice}>{t('readOnlyNotice')}</p>}
|
||||
<label className={`${css.field} ${css.codeField}`}>
|
||||
<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>}
|
||||
{/* Read-only has nothing to commit or abandon, and leaving is already the
|
||||
back link above — a lone Close button would be a second way out. */}
|
||||
{draft.writable
|
||||
? (
|
||||
<div className={css.editorActions}>
|
||||
<Button variant="outline" disabled={draft.saving} onClick={() => { actions.close() }}>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={draft.saving || blocker !== undefined}
|
||||
onClick={() => { void actions.save() }}
|
||||
>
|
||||
{draft.saving ? t('saving') : t('save')}
|
||||
</Button>
|
||||
{message === null ? null : <p className={css.error} role="alert">{message}</p>}
|
||||
</div>
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -176,40 +158,10 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {
|
||||
)
|
||||
}
|
||||
|
||||
const { draft } = state
|
||||
const blocker = draft === null ? undefined : draftBlocker(draft, state.rows)
|
||||
// Editing replaces the list rather than hanging off the end of it: the form
|
||||
// is tall, and a column of the card grid is far too narrow to hold it.
|
||||
if (draft !== null) {
|
||||
const editorActions = {
|
||||
close: props.close,
|
||||
save: props.save,
|
||||
setContent: props.setContent,
|
||||
setDescription: props.setDescription,
|
||||
setId: props.setId,
|
||||
setName: props.setName,
|
||||
}
|
||||
return (
|
||||
<div className={`${css.section} ${css.sectionFill}`}>
|
||||
<div className={css.editorBar}>
|
||||
<button type="button" className={css.backButton} onClick={() => { props.close() }}>
|
||||
{`← ${t('backToList')}`}
|
||||
</button>
|
||||
<span className={css.editorTitle}>
|
||||
{draft.creating
|
||||
? (draft.source === undefined ? t('newPreset') : `${t('newPreset')} · ${t('copyOf')} ${draft.source}`)
|
||||
: `${draft.writable ? t('edit') : t('view')} · ${draft.name === '' ? draft.id : draft.name}`}
|
||||
</span>
|
||||
</div>
|
||||
<Editor draft={draft} blocker={blocker} t={t} actions={editorActions} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={css.section}>
|
||||
<h2 className={css.title}>{t('nav')}</h2>
|
||||
<p className={css.intro}>{t('sectionIntro')}</p>
|
||||
<p className={css.intro}>{`${t('sectionIntro')} ${t('copyHint')}`}</p>
|
||||
{state.error === null ? null : <p className={css.error} role="alert">{state.error}</p>}
|
||||
{([['system', t('builtInGroup')], ['user', t('customGroup')]] as const).map(([trust, heading]) => {
|
||||
const group = state.rows.filter(row => row.trust === trust)
|
||||
@@ -246,35 +198,50 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {
|
||||
<code className={css.cardId}>{row.id}</code>
|
||||
</button>
|
||||
<div className={css.cardFoot}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
data-tip={row.trust === 'user' ? t('edit') : t('view')}
|
||||
aria-label={row.trust === 'user' ? t('edit') : t('view')}
|
||||
onClick={() => { void props.open(row.id) }}
|
||||
>
|
||||
{row.trust === 'user' ? <IconEditOutline16 /> : <IconBrowseOutline16 />}
|
||||
</button>
|
||||
{state.authorable
|
||||
{/* Shipped presets are the compositions a copy starts
|
||||
from, so READING one is the point; a custom preset is
|
||||
edited in its files instead, which the location action
|
||||
leads to. */}
|
||||
{row.trust === 'system'
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
data-tip={t('duplicate')}
|
||||
aria-label={t('duplicate')}
|
||||
onClick={() => { void props.createFrom(row.id) }}
|
||||
data-tip={t('view')}
|
||||
aria-label={`${t('view')}: ${row.name ?? row.id}`}
|
||||
onClick={() => { void props.view(row.id) }}
|
||||
>
|
||||
<IconCopyOutline16 />
|
||||
<IconBrowseOutline16 />
|
||||
</button>
|
||||
)
|
||||
: null}
|
||||
: (
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
data-tip={state.hasDocument ? t('openLocation') : t('showLocation')}
|
||||
aria-label={`${state.hasDocument ? t('openLocation') : t('showLocation')}: ${row.name ?? row.id}`}
|
||||
onClick={() => { void props.openLocation(row.id) }}
|
||||
>
|
||||
<IconFolderOpen16 />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
disabled={!state.authorable}
|
||||
data-tip={state.authorable ? t('duplicate') : t('duplicateUnavailable')}
|
||||
aria-label={`${t('duplicate')}: ${row.name ?? row.id}`}
|
||||
onClick={() => { props.beginCopy(row.id) }}
|
||||
>
|
||||
<IconCopyOutline16 />
|
||||
</button>
|
||||
{row.trust === 'user'
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
className={`${css.iconButton} ${css.iconDanger}`}
|
||||
data-tip={t('delete')}
|
||||
aria-label={t('delete')}
|
||||
aria-label={`${t('delete')}: ${row.name ?? row.id}`}
|
||||
onClick={() => { props.confirmDelete(row.id) }}
|
||||
>
|
||||
<IconTrashOutline16 />
|
||||
@@ -282,22 +249,47 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
{state.revealedPaths[row.id] === undefined
|
||||
? null
|
||||
: (
|
||||
<p className={css.revealedPath}>
|
||||
<span className={css.revealedPathLabel}>{t('revealedPathLabel')}</span>
|
||||
<code>{state.revealedPaths[row.id]}</code>
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
{(
|
||||
<button
|
||||
type="button"
|
||||
className={css.addButton}
|
||||
disabled={!state.authorable || state.rows.length === 0}
|
||||
onClick={() => { void props.createFrom() }}
|
||||
>
|
||||
{`+ ${t('newPreset')}`}
|
||||
</button>
|
||||
)}
|
||||
<CopyDialog
|
||||
state={state}
|
||||
t={t}
|
||||
actions={{
|
||||
cancelCopy: props.cancelCopy,
|
||||
confirmCopy: props.confirmCopy,
|
||||
setCopyId: props.setCopyId,
|
||||
setCopyName: props.setCopyName,
|
||||
}}
|
||||
/>
|
||||
<Modal
|
||||
open={state.view !== null}
|
||||
onClose={() => { props.closeView() }}
|
||||
title={state.view === null ? '' : `${t('view')} · ${state.view.title}`}
|
||||
closeLabel={t('close')}
|
||||
description={t('composition')}
|
||||
className={css.dialog as string}
|
||||
footer={(
|
||||
<Button variant="outline" autoFocus onClick={() => { props.closeView() }}>
|
||||
{t('close')}
|
||||
</Button>
|
||||
)}
|
||||
>
|
||||
{state.view === null
|
||||
? null
|
||||
: <pre className={css.viewerCode}>{state.view.content}</pre>}
|
||||
</Modal>
|
||||
<Modal
|
||||
open={state.pendingDelete !== null}
|
||||
onClose={() => { props.confirmDelete(null) }}
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
* Agent-preset surface plugin, browser half — four surfaces over one roster:
|
||||
* a General-settings row for the default preset, a chip on the new-session
|
||||
* screen for the session about to start, a read-only label in the session
|
||||
* header, and a settings section that authors the compositions themselves.
|
||||
* header, and a settings section that manages the roster (copy, delete,
|
||||
* default, and the way into a preset's own files).
|
||||
*
|
||||
* A running session keeps the composition it began with (the host refuses to
|
||||
* adopt an existing session under a different preset). That is what splits
|
||||
@@ -36,7 +37,7 @@ export type { AgentPresetSeatInjected, AgentPresetSeatProps } from './AgentPrese
|
||||
export type { AgentPresetSectionInjected, AgentPresetSectionProps } from './AgentPresetSection.tsx'
|
||||
export type { AgentPresetSeatState, SeatSessionSummary } from './seat-store.ts'
|
||||
export {
|
||||
draftBlocker, type AgentPresetSectionState, type PresetDraft, type PresetRow,
|
||||
draftBlocker, type AgentPresetSectionState, type CopyDraft, type PresetRow, type PresetView,
|
||||
} from './section-store.ts'
|
||||
export type { AgentPresetOption, AgentPresetSettingsState } from './settings-store.ts'
|
||||
export { AGENT_PRESET_SETTINGS_NS, writeDefaultPreset } from './settings-store.ts'
|
||||
@@ -158,14 +159,14 @@ export function apply(ctx: ClientContext): void {
|
||||
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) },
|
||||
setName: (name: string) => { section.setName(name) },
|
||||
setDescription: (description: string) => { section.setDescription(description) },
|
||||
save: () => section.save(),
|
||||
view: (id: string) => section.view(id),
|
||||
closeView: () => { section.closeView() },
|
||||
beginCopy: (from: string) => { section.beginCopy(from) },
|
||||
cancelCopy: () => { section.cancelCopy() },
|
||||
setCopyId: (id: string) => { section.setCopyId(id) },
|
||||
setCopyName: (name: string) => { section.setCopyName(name) },
|
||||
confirmCopy: () => section.confirmCopy(),
|
||||
openLocation: (id: string) => section.openLocation(id),
|
||||
confirmDelete: (id: string | null) => { section.confirmDelete(id) },
|
||||
remove: () => section.remove(),
|
||||
makeDefault: (id: string) => section.makeDefault(id),
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
/** Locale keys these surfaces render. */
|
||||
export type AgentPresetSettingsKey =
|
||||
| 'title' | 'description' | 'loading' | 'error' | 'userTrust' | 'seatHint' | 'headerHint'
|
||||
| 'nav' | 'sectionIntro' | 'builtIn' | 'defaultBadge' | 'setDefault' | 'edit' | 'view'
|
||||
| 'duplicate' | 'delete' | 'newPreset' | 'presetId' | 'presetIdPlaceholder' | 'copyOf'
|
||||
| 'displayName' | 'displayNamePlaceholder' | 'displayDescription' | 'displayDescriptionPlaceholder'
|
||||
| 'inUse' | 'noDescription' | 'builtInGroup' | 'customGroup' | 'backToList'
|
||||
| 'composition' | 'readOnlyNotice' | 'save' | 'saving' | 'cancel' | 'close' | 'retry'
|
||||
| 'nav' | 'sectionIntro' | 'copyHint' | 'builtIn' | 'setDefault' | 'view'
|
||||
| 'duplicate' | 'duplicateUnavailable' | 'delete' | 'presetId' | 'presetIdPlaceholder' | 'copyOf'
|
||||
| 'displayName' | 'displayNamePlaceholder'
|
||||
| 'inUse' | 'noDescription' | 'builtInGroup' | 'customGroup'
|
||||
| 'composition' | 'cancel' | 'close' | 'retry'
|
||||
| 'copyTitle' | 'copyIntro' | 'create' | 'creating'
|
||||
| 'openLocation' | 'showLocation' | 'revealedPathLabel'
|
||||
| 'idRequired' | 'idInvalid' | 'idTaken'
|
||||
| 'deleteTitle' | 'deleteDescription' | 'deleteConfirm' | 'deleting'
|
||||
|
||||
@@ -23,40 +25,42 @@ export const en: Record<AgentPresetSettingsKey, string> = {
|
||||
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.',
|
||||
+ 'Duplicate one to make your own, then edit its files directly.',
|
||||
copyHint: 'To start from the smallest skeleton, duplicate Minimal.',
|
||||
builtIn: 'Built-in',
|
||||
defaultBadge: 'Default',
|
||||
setDefault: 'Set as default',
|
||||
edit: 'Edit',
|
||||
view: 'View',
|
||||
duplicate: 'Duplicate',
|
||||
duplicateUnavailable: 'This deployment has no writable preset directory',
|
||||
delete: 'Delete',
|
||||
newPreset: 'New preset',
|
||||
presetId: 'Identifier',
|
||||
presetIdPlaceholder: 'my-agent',
|
||||
displayName: 'Name',
|
||||
displayNamePlaceholder: 'Shown in the picker',
|
||||
displayDescription: 'Description',
|
||||
displayDescriptionPlaceholder: 'One sentence on what this preset is for',
|
||||
displayNamePlaceholder: 'Shown in the picker; defaults to the identifier',
|
||||
inUse: 'In use',
|
||||
backToList: 'All presets',
|
||||
builtInGroup: 'Built-in',
|
||||
customGroup: 'Custom',
|
||||
noDescription: 'No description.',
|
||||
copyOf: 'Copied from',
|
||||
composition: 'Composition (agent.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',
|
||||
copyTitle: 'Duplicate preset',
|
||||
copyIntro:
|
||||
'The whole preset is copied on this machine. The identifier becomes its directory name and cannot '
|
||||
+ 'be changed later; everything else is edited in the preset\'s own files.',
|
||||
create: 'Create',
|
||||
creating: 'Creating…',
|
||||
openLocation: 'Open folder',
|
||||
showLocation: 'Show location',
|
||||
revealedPathLabel: 'Preset files:',
|
||||
idRequired: 'Give the preset an identifier.',
|
||||
idInvalid: 'Use lowercase letters, digits, and hyphens, starting with a letter or digit.',
|
||||
idTaken: 'A preset with this identifier already exists.',
|
||||
deleteTitle: 'Delete this preset?',
|
||||
deleteDescription:
|
||||
'The composition file is deleted. Sessions already running on it keep working; new sessions cannot select it.',
|
||||
'The preset directory is deleted. Sessions already running on it keep working; new sessions cannot select it.',
|
||||
deleteConfirm: 'Delete',
|
||||
deleting: 'Deleting…',
|
||||
}
|
||||
@@ -71,39 +75,39 @@ export const zh: Record<AgentPresetSettingsKey, string> = {
|
||||
seatHint: '即将开始的这个会话所用的 Agent 预设',
|
||||
headerHint: '本会话运行的 Agent 预设,开始时即固定',
|
||||
nav: 'Agent 预设',
|
||||
sectionIntro: '预设即一个会话的 Agent 所运行的插件组装 —— 它的工具、提示词与能力。内置预设只读;复制一份即可改成自己的。',
|
||||
sectionIntro: '预设即一个会话的 Agent 所运行的插件组装 —— 它的工具、提示词与能力。复制一份即可改成自己的,之后直接编辑它的文件。',
|
||||
copyHint: '想从最小的骨架开始,就复制「极简模式」。',
|
||||
builtIn: '内置',
|
||||
defaultBadge: '默认',
|
||||
setDefault: '设为默认',
|
||||
edit: '编辑',
|
||||
view: '查看',
|
||||
duplicate: '复制',
|
||||
duplicateUnavailable: '此部署未配置可写的预设目录',
|
||||
delete: '删除',
|
||||
newPreset: '新建预设',
|
||||
presetId: '标识符',
|
||||
presetIdPlaceholder: 'my-agent',
|
||||
displayName: '名称',
|
||||
displayNamePlaceholder: '选择器中显示的名字',
|
||||
displayDescription: '描述',
|
||||
displayDescriptionPlaceholder: '一句话说明这个预设做什么',
|
||||
displayNamePlaceholder: '选择器中显示的名字,缺省用标识符',
|
||||
inUse: '当前使用',
|
||||
backToList: '全部预设',
|
||||
builtInGroup: '内置',
|
||||
customGroup: '自定义',
|
||||
noDescription: '暂无描述。',
|
||||
copyOf: '复制自',
|
||||
composition: '组装(agent.cordis.yml)',
|
||||
readOnlyNotice: '该预设随部署提供,不可编辑。复制一份即可改成自己的。',
|
||||
save: '保存',
|
||||
saving: '正在保存…',
|
||||
cancel: '取消',
|
||||
close: '关闭',
|
||||
retry: '重试',
|
||||
idRequired: '请填写预设名称。',
|
||||
copyTitle: '复制预设',
|
||||
copyIntro: '整个预设会在本机复制一份。标识符将成为目录名,事后无法更改;其余内容之后直接在预设自己的文件里编辑。',
|
||||
create: '创建',
|
||||
creating: '正在创建…',
|
||||
openLocation: '打开目录',
|
||||
showLocation: '查看路径',
|
||||
revealedPathLabel: '预设文件:',
|
||||
idRequired: '请填写标识符。',
|
||||
idInvalid: '只能使用小写字母、数字与连字符,且以字母或数字开头。',
|
||||
idTaken: '该标识符已被占用。',
|
||||
deleteTitle: '删除该预设?',
|
||||
deleteDescription: '组装文件将被删除。已在其上运行的会话不受影响;新会话将无法再选择它。',
|
||||
deleteDescription: '预设目录将被删除。已在其上运行的会话不受影响;新会话将无法再选择它。',
|
||||
deleteConfirm: '删除',
|
||||
deleting: '正在删除…',
|
||||
}
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
/**
|
||||
* Agent-preset management controller: the roster as a list, and one
|
||||
* composition open in the editor at a time.
|
||||
* Agent-preset management controller: the roster as a list, a copy dialog as
|
||||
* the only way a preset is created, and a read-only viewer over the shipped
|
||||
* compositions.
|
||||
*
|
||||
* The browser edits no composition text. A new preset is a host-side copy of
|
||||
* an existing one (`{ from, id, name? }` is all that crosses the wire), and
|
||||
* everything after creation happens in the preset's own files — which is why
|
||||
* the page's other job is getting the user TO those files: open the directory
|
||||
* where the host has a desktop, show its path where it does not.
|
||||
*
|
||||
* 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.
|
||||
* wire and the page re-reads the roster afterwards, because a copy changes
|
||||
* more than the row it targeted.
|
||||
*/
|
||||
|
||||
import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
|
||||
@@ -29,81 +35,92 @@ export interface PresetRow {
|
||||
isDefault: boolean
|
||||
}
|
||||
|
||||
/** The composition currently open in the editor. */
|
||||
export interface PresetDraft {
|
||||
/** Preset the draft saves to; empty until a new one is named. */
|
||||
/** The copy dialog: a new id and optional display name over a fixed source. */
|
||||
export interface CopyDraft {
|
||||
/** The preset being copied. */
|
||||
from: string
|
||||
/** Display name of the source, for the dialog title. */
|
||||
fromTitle: string
|
||||
/** New preset id being typed; the directory name, so it is required. */
|
||||
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. Absent on a preset started
|
||||
* blank, which is a copy of nothing.
|
||||
*/
|
||||
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
|
||||
/** Display name being edited; empty means the picker falls back to the id. */
|
||||
/** Display name being typed; empty falls back to the id. */
|
||||
name: string
|
||||
/** Description being edited. */
|
||||
description: string
|
||||
/** The last save failure, cleared by the next edit. */
|
||||
/** Whether the copy is in flight. */
|
||||
saving: boolean
|
||||
/** The last copy failure, cleared by the next edit. */
|
||||
error: string | null
|
||||
}
|
||||
|
||||
/** The read-only composition viewer over one shipped preset. */
|
||||
export interface PresetView {
|
||||
/** The preset whose composition is shown. */
|
||||
id: string
|
||||
/** Display name, for the dialog title. */
|
||||
title: string
|
||||
/** Composition text exactly as stored. */
|
||||
content: string
|
||||
}
|
||||
|
||||
/** Page snapshot. */
|
||||
export interface AgentPresetSectionState {
|
||||
status: 'idle' | 'loading' | 'ready' | 'unavailable' | 'error'
|
||||
/** Whole-load failure text; a save failure stays on the draft. */
|
||||
/** Whole-load failure text; a copy failure stays on the dialog. */
|
||||
error: string | null
|
||||
/** Whether the deployment configures a root new presets can be written to. */
|
||||
authorable: boolean
|
||||
/** Whether the host can open a preset directory on a native desktop. */
|
||||
hasDocument: 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 open copy dialog, or null. */
|
||||
copy: CopyDraft | null
|
||||
/** The open read-only viewer, or null. */
|
||||
view: PresetView | null
|
||||
/** The preset awaiting delete confirmation. */
|
||||
pendingDelete: string | null
|
||||
/** Whether a delete is in flight. */
|
||||
deleting: boolean
|
||||
/**
|
||||
* Preset directories shown as text because the host has no desktop opener
|
||||
* — the answer `openDocument` gives instead of opening.
|
||||
*/
|
||||
revealedPaths: Readonly<Record<string, string>>
|
||||
}
|
||||
|
||||
const INITIAL: AgentPresetSectionState = {
|
||||
status: 'idle',
|
||||
error: null,
|
||||
authorable: false,
|
||||
hasDocument: false,
|
||||
rows: [],
|
||||
draft: null,
|
||||
copy: null,
|
||||
view: null,
|
||||
pendingDelete: null,
|
||||
deleting: false,
|
||||
revealedPaths: {},
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Why this copy cannot be submitted yet, as a locale key, or undefined when
|
||||
* it can. Client-side only: the host re-checks the id and its answer is what
|
||||
* the dialog reports on failure.
|
||||
* @param draft - the open copy dialog.
|
||||
* @param rows - the roster, for the collision check.
|
||||
* @returns the blocking reason's locale key, or undefined when saveable.
|
||||
* @returns the blocking reason's locale key, or undefined when submittable.
|
||||
*/
|
||||
export function draftBlocker(
|
||||
draft: PresetDraft,
|
||||
draft: CopyDraft,
|
||||
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.
|
||||
// A copy never overwrites: landing on a name already in use would replace
|
||||
// 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. */
|
||||
/** Reads the roster and drives the copy dialog, viewer, and location reveals. */
|
||||
export class AgentPresetSectionController {
|
||||
/** Page snapshot the renderer subscribes to. */
|
||||
readonly store: SnapshotStore<AgentPresetSectionState> = createSnapshotStore(INITIAL)
|
||||
@@ -113,9 +130,9 @@ export class AgentPresetSectionController {
|
||||
/**
|
||||
* Called after this page changes the roster DIRECTORY, so the other
|
||||
* surfaces reading the same roster re-read it. A settings field moving is
|
||||
* already announced by the host through `settings/changed`; a file written
|
||||
* or deleted here is not, and the new-session chip has no other way to
|
||||
* learn a preset it should offer now exists.
|
||||
* already announced by the host through `settings/changed`; a directory
|
||||
* copied or deleted here is not, and the new-session chip has no other
|
||||
* way to learn a preset it should offer now exists.
|
||||
*/
|
||||
private readonly rosterChanged: () => void = () => {},
|
||||
) {}
|
||||
@@ -124,10 +141,10 @@ export class AgentPresetSectionController {
|
||||
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 } })
|
||||
private patchCopy(patch: Partial<CopyDraft>): void {
|
||||
const { copy } = this.store.getSnapshot()
|
||||
if (copy === null) return
|
||||
this.set({ copy: { ...copy, ...patch } })
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -139,152 +156,136 @@ export class AgentPresetSectionController {
|
||||
async load(): Promise<void> {
|
||||
const roster = await beginRosterRead(this.api, this.store)
|
||||
if (roster === undefined) return
|
||||
const { presets, authorable } = roster
|
||||
const { presets, authorable, hasDocument } = roster
|
||||
if (presets.length === 0) {
|
||||
this.set({ status: 'unavailable', rows: [], authorable, draft: null })
|
||||
// Nothing to manage leaves nothing to keep a dialog open over.
|
||||
this.set({ status: 'unavailable', rows: [], authorable, hasDocument, copy: null, view: null })
|
||||
return
|
||||
}
|
||||
this.set({ status: 'ready', error: null, authorable, rows: presets.map(preset => ({ ...preset })) })
|
||||
// A reveal outlives a reload but not its preset: a path for a row the
|
||||
// roster no longer lists would be a claim about a directory that is gone.
|
||||
const revealed = this.store.getSnapshot().revealedPaths
|
||||
const kept = Object.fromEntries(
|
||||
Object.entries(revealed).filter(([id]) => presets.some(preset => preset.id === id)))
|
||||
this.set({
|
||||
status: 'ready',
|
||||
error: null,
|
||||
authorable,
|
||||
hasDocument,
|
||||
rows: presets.map(preset => ({ ...preset })),
|
||||
revealedPaths: kept,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Open one shipped preset's composition in the read-only viewer.
|
||||
* @param id - the preset to view.
|
||||
* @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 new, unnamed preset. With no argument it starts blank; copying is
|
||||
* its own action, offered on the row being copied, so the two arrive at the
|
||||
* same editor by the route the author actually chose. Starting from some
|
||||
* preset nobody named would put text in the editor that the author has to
|
||||
* recognise as unwanted before deleting it.
|
||||
* @param from - the preset to copy, or undefined to start blank.
|
||||
* @returns once the composition loaded or the failure is on the page.
|
||||
*/
|
||||
async createFrom(from?: string): Promise<void> {
|
||||
if (from === undefined) {
|
||||
this.set({
|
||||
error: null,
|
||||
draft: {
|
||||
id: '',
|
||||
creating: true,
|
||||
content: '',
|
||||
writable: true,
|
||||
name: '',
|
||||
description: '',
|
||||
saving: false,
|
||||
error: null,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
await this.openDraft(from, true)
|
||||
}
|
||||
|
||||
private async openDraft(source: string, creating: boolean): Promise<void> {
|
||||
async view(id: string): Promise<void> {
|
||||
this.set({ error: null })
|
||||
try {
|
||||
const response = await this.api.agentPresets.read({ agentPreset: source })
|
||||
const response = await this.api.agentPresets.read({ agentPreset: id })
|
||||
if (!response.result.ok) {
|
||||
this.set({ error: response.result.error.message })
|
||||
return
|
||||
}
|
||||
const { content, writable, name, description } = 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,
|
||||
// A copy starts from the source's text but must be renamed, or two
|
||||
// rows would present themselves identically.
|
||||
name: creating ? '' : name ?? '',
|
||||
description: description ?? '',
|
||||
saving: false,
|
||||
error: null,
|
||||
},
|
||||
})
|
||||
const { name, content } = response.result.value
|
||||
this.set({ view: { id, title: name ?? id, content } })
|
||||
} catch (error) {
|
||||
this.set({ error: messageOf(error) })
|
||||
}
|
||||
}
|
||||
|
||||
/** Close the editor, discarding whatever was typed. */
|
||||
close(): void {
|
||||
this.set({ draft: null })
|
||||
/** Close the read-only viewer. */
|
||||
closeView(): void {
|
||||
this.set({ view: null })
|
||||
}
|
||||
|
||||
/**
|
||||
* Name the preset a new draft saves to.
|
||||
* @param id - the id typed into the editor.
|
||||
* Open the copy dialog over one preset.
|
||||
* @param from - the preset the copy will start from.
|
||||
*/
|
||||
setId(id: string): void {
|
||||
this.patchDraft({ id, error: null })
|
||||
beginCopy(from: string): void {
|
||||
const row = this.store.getSnapshot().rows.find(candidate => candidate.id === from)
|
||||
this.set({
|
||||
error: null,
|
||||
copy: { from, fromTitle: row?.name ?? from, id: '', name: '', saving: false, error: null },
|
||||
})
|
||||
}
|
||||
|
||||
/** Close the copy dialog, discarding whatever was typed. */
|
||||
cancelCopy(): void {
|
||||
this.set({ copy: null })
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the draft's composition text.
|
||||
* @param content - the text in the editor.
|
||||
* Name the preset the copy creates.
|
||||
* @param id - the id typed into the dialog.
|
||||
*/
|
||||
setContent(content: string): void {
|
||||
this.patchDraft({ content, error: null })
|
||||
setCopyId(id: string): void {
|
||||
this.patchCopy({ id, error: null })
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename the draft.
|
||||
* @param name - the display name typed into the editor.
|
||||
* Name the copy's display name.
|
||||
* @param name - the display name typed into the dialog.
|
||||
*/
|
||||
setName(name: string): void {
|
||||
this.patchDraft({ name, error: null })
|
||||
setCopyName(name: string): void {
|
||||
this.patchCopy({ name, error: null })
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the draft's description.
|
||||
* @param description - the description typed into the editor.
|
||||
* Submit the copy, re-read the roster, then take the user to the new
|
||||
* preset's files — the directory opens where the host has a desktop, and
|
||||
* its path appears on the new row where it does not.
|
||||
* @returns once the copy settled and the page reflects it.
|
||||
*/
|
||||
setDescription(description: string): void {
|
||||
this.patchDraft({ description, 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
|
||||
async confirmCopy(): Promise<void> {
|
||||
const draft = this.store.getSnapshot().copy
|
||||
if (draft === null || draft.saving) return
|
||||
if (draftBlocker(draft, this.store.getSnapshot().rows) !== undefined) return
|
||||
this.patchDraft({ saving: true, error: null })
|
||||
this.patchCopy({ saving: true, error: null })
|
||||
try {
|
||||
const response = await this.api.agentPresets.write({
|
||||
const name = draft.name.trim()
|
||||
const response = await this.api.agentPresets.copy({
|
||||
from: draft.from,
|
||||
agentPreset: draft.id,
|
||||
content: draft.content,
|
||||
name: draft.name,
|
||||
description: draft.description,
|
||||
...name === '' ? {} : { name },
|
||||
})
|
||||
if (!response.result.ok) {
|
||||
this.patchDraft({ saving: false, error: response.result.error.message })
|
||||
this.patchCopy({ saving: false, error: response.result.error.message })
|
||||
return
|
||||
}
|
||||
this.set({ draft: null })
|
||||
this.set({ copy: null })
|
||||
await this.load()
|
||||
this.rosterChanged()
|
||||
// A preset is its files from here on (the dialog collected nothing
|
||||
// else), so landing in them is the completion, not a follow-up.
|
||||
await this.openLocation(draft.id)
|
||||
} catch (error) {
|
||||
this.patchDraft({ saving: false, error: messageOf(error) })
|
||||
this.patchCopy({ saving: false, error: messageOf(error) })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open one preset's directory on the host desktop, or reveal its path on
|
||||
* the row where the deployment has no opener to hand it to.
|
||||
* @param id - the preset whose files the user wants.
|
||||
* @returns once the host answered and the page reflects it.
|
||||
*/
|
||||
async openLocation(id: string): Promise<void> {
|
||||
try {
|
||||
const response = await this.api.agentPresets.openDocument({ agentPreset: id })
|
||||
if (!response.result.ok) {
|
||||
this.set({ error: response.result.error.message })
|
||||
return
|
||||
}
|
||||
if (response.result.value.opened) return
|
||||
const { path } = response.result.value
|
||||
this.set({ revealedPaths: { ...this.store.getSnapshot().revealedPaths, [id]: path } })
|
||||
} catch (error) {
|
||||
this.set({ error: messageOf(error) })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,7 +306,7 @@ export class AgentPresetSectionController {
|
||||
* @returns once the delete settled and the page reflects it.
|
||||
*/
|
||||
async remove(): Promise<void> {
|
||||
const { pendingDelete, deleting, draft } = this.store.getSnapshot()
|
||||
const { pendingDelete, deleting } = this.store.getSnapshot()
|
||||
if (pendingDelete === null || deleting) return
|
||||
this.set({ deleting: true, error: null })
|
||||
try {
|
||||
@@ -314,12 +315,7 @@ export class AgentPresetSectionController {
|
||||
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,
|
||||
})
|
||||
this.set({ deleting: false, pendingDelete: null })
|
||||
await this.load()
|
||||
this.rosterChanged()
|
||||
} catch (error) {
|
||||
|
||||
@@ -81,6 +81,8 @@ export interface RosterValue {
|
||||
presets: readonly RosterPreset[]
|
||||
/** Whether this browser may author presets at all. */
|
||||
authorable: boolean
|
||||
/** Whether the host can open a preset directory on a native desktop. */
|
||||
hasDocument: boolean
|
||||
}
|
||||
|
||||
/** The roster, or the message to show in its place. */
|
||||
|
||||
@@ -27,10 +27,17 @@ usePinnedBrowserLanguages('zh-CN')
|
||||
|
||||
const ROSTER_ONE = {
|
||||
rpcId: 'r',
|
||||
result: { ok: true as const, value: { presets: [{ id: 'standard', trust: 'system', isDefault: true }], authorable: true } },
|
||||
result: {
|
||||
ok: true as const,
|
||||
value: {
|
||||
presets: [{ id: 'standard', trust: 'system', isDefault: true }],
|
||||
authorable: true,
|
||||
hasDocument: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
/** The roster after this browser authored one preset of its own. */
|
||||
/** The roster after this browser copied one preset of its own. */
|
||||
const ROSTER_AUTHORED = {
|
||||
rpcId: 'r',
|
||||
result: {
|
||||
@@ -41,6 +48,7 @@ const ROSTER_AUTHORED = {
|
||||
{ id: 'mine', trust: 'user', isDefault: false },
|
||||
],
|
||||
authorable: true,
|
||||
hasDocument: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -56,6 +64,7 @@ const ROSTER_MOVED = {
|
||||
{ id: 'minimal', trust: 'system', isDefault: true },
|
||||
],
|
||||
authorable: true,
|
||||
hasDocument: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -76,15 +85,19 @@ async function bench() {
|
||||
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 } },
|
||||
result: { ok: true as const, value: { agentPreset: 'standard', trust: 'system', content: '' } },
|
||||
}),
|
||||
write: (payload: { agentPreset: string }) => {
|
||||
calls.push(`write:${payload.agentPreset}`)
|
||||
copy: (payload: { from: string; agentPreset: string }) => {
|
||||
calls.push(`copy:${payload.agentPreset}`)
|
||||
// The host's roster now contains it, which is the whole point of the
|
||||
// write and what every surface must converge on.
|
||||
// copy and what every surface must converge on.
|
||||
ROSTER = ROSTER_AUTHORED
|
||||
return Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: { agentPreset: payload.agentPreset } } })
|
||||
},
|
||||
openDocument: (payload: { agentPreset: string }) => {
|
||||
calls.push(`openDocument:${payload.agentPreset}`)
|
||||
return Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: { opened: true as const } } })
|
||||
},
|
||||
remove: () => Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: {} } }),
|
||||
select: (payload: { agentPreset: string }) => {
|
||||
calls.push(`select:${payload.agentPreset}`)
|
||||
@@ -195,23 +208,29 @@ describe('ui-agent-preset apply', () => {
|
||||
})
|
||||
|
||||
it('routes the section actions to one controller', async () => {
|
||||
const { ctx, slots } = await bench()
|
||||
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()
|
||||
section.setId('mine')
|
||||
section.setContent('- id: x\n')
|
||||
section.setName('我的模式')
|
||||
section.setDescription('只做检索。')
|
||||
section.beginCopy('standard')
|
||||
section.cancelCopy()
|
||||
section.beginCopy('standard')
|
||||
section.setCopyId('mine')
|
||||
section.setCopyName('我的模式')
|
||||
await section.confirmCopy()
|
||||
await section.view('standard')
|
||||
section.closeView()
|
||||
section.confirmDelete('mine')
|
||||
section.close()
|
||||
await Promise.all([section.open('standard'), section.createFrom(), section.save(), section.remove()])
|
||||
await Promise.all([section.openLocation('mine'), 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)
|
||||
// One controller behind every action: the copy the dialog named is the
|
||||
// one the roster re-read reflects, and the delete the section confirmed
|
||||
// is the one its remove() sees.
|
||||
expect(calls).toContain('copy:mine')
|
||||
expect(calls.filter(call => call === 'openDocument:mine').length).toBeGreaterThan(0)
|
||||
expect(section.hooks.agentPresetSection.getSnapshot().rows).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('refreshes a showing surface when its namespace changes, and ignores others', async () => {
|
||||
@@ -329,14 +348,14 @@ describe('ui-agent-preset apply', () => {
|
||||
|
||||
const section = (slots.entries('settings.section')[0]!.inject as unknown as () => AgentPresetSectionInjected)()
|
||||
await section.load()
|
||||
await section.createFrom()
|
||||
section.setId('mine')
|
||||
section.setName('我的模式')
|
||||
await section.save()
|
||||
section.beginCopy('standard')
|
||||
section.setCopyId('mine')
|
||||
section.setCopyName('我的模式')
|
||||
await section.confirmCopy()
|
||||
|
||||
// Authoring writes a file rather than a setting, so nothing on the wire
|
||||
// announces it: a preset authored to be used must appear on the one screen
|
||||
// that starts sessions, without a reload.
|
||||
// Authoring copies a directory rather than writing a setting, so nothing
|
||||
// on the wire announces it: a preset created to be used must appear on
|
||||
// the one screen that starts sessions, without a reload.
|
||||
await vi.waitFor(() => {
|
||||
expect(seat.hooks.agentPresetSeat.getSnapshot().options.map(option => option.id)).toEqual(['standard', 'mine'])
|
||||
})
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
/**
|
||||
* 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.
|
||||
* The agent-preset management controller: a copy dialog is the only way a
|
||||
* preset is created, the shipped compositions open in a read-only viewer, and
|
||||
* the way into a custom preset's files is the location action — opened on a
|
||||
* desktop, revealed as a path where the host has none. Every mutation
|
||||
* re-reads the roster because a copy changes 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'
|
||||
import type { CopyDraft, PresetRow } from '../src/client/section-store.ts'
|
||||
|
||||
interface FakePreset { trust: 'system' | 'user'; content: string }
|
||||
interface FakePreset { trust: 'system' | 'user'; content: string; name?: string }
|
||||
interface Recorded { method: string; payload: unknown }
|
||||
|
||||
interface FakeOptions {
|
||||
@@ -20,16 +21,26 @@ interface FakeOptions {
|
||||
failList?: string
|
||||
/** Reject `read` with this message. */
|
||||
failRead?: string
|
||||
/** Reject `write` with this message. */
|
||||
failWrite?: string
|
||||
/** Reject `copy` with this message. */
|
||||
failCopy?: string
|
||||
/** Reject `openDocument` with this message. */
|
||||
failOpen?: 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
|
||||
/** Throw from `read`, as a dead transport does. */
|
||||
throwRead?: boolean
|
||||
/** Throw from `copy`, as a dead transport does. */
|
||||
throwCopy?: boolean
|
||||
/** Throw from `openDocument`, as a dead transport does. */
|
||||
throwOpen?: boolean
|
||||
/** Whether the deployment configures a writable root. */
|
||||
authorable?: boolean
|
||||
/** Whether the host can open a preset directory on a desktop. */
|
||||
hasDocument?: boolean
|
||||
/** Hold `remove` until this resolves, to observe the in-flight state. */
|
||||
holdRemove?: Promise<void>
|
||||
}
|
||||
@@ -39,8 +50,8 @@ 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.
|
||||
* A wire face over an in-memory preset store: copies land, so the roster the
|
||||
* controller re-reads after a copy is the one the copy 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.
|
||||
@@ -61,12 +72,15 @@ function fakeApi(
|
||||
return ok({
|
||||
presets: [...presets].map(([id, preset]) => ({
|
||||
id, trust: preset.trust, isDefault: id === defaultId.id,
|
||||
...preset.name === undefined ? {} : { name: preset.name },
|
||||
})),
|
||||
authorable: options.authorable ?? true,
|
||||
hasDocument: options.hasDocument ?? true,
|
||||
})
|
||||
},
|
||||
read: (payload: { agentPreset: string }) => {
|
||||
record('read', payload)
|
||||
if (options.throwRead === true) return Promise.reject(new Error('socket closed'))
|
||||
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 */
|
||||
@@ -75,15 +89,31 @@ function fakeApi(
|
||||
agentPreset: payload.agentPreset,
|
||||
trust: preset.trust,
|
||||
content: preset.content,
|
||||
writable: preset.trust === 'user',
|
||||
...preset.name === undefined ? {} : { name: preset.name },
|
||||
})
|
||||
},
|
||||
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 })
|
||||
copy: (payload: { from: string; agentPreset: string; name?: string }) => {
|
||||
record('copy', payload)
|
||||
if (options.throwCopy === true) return Promise.reject(new Error('socket closed'))
|
||||
if (options.failCopy !== undefined) return fail(options.failCopy)
|
||||
const source = presets.get(payload.from)
|
||||
/* v8 ignore next -- every test copies a source the fake store holds */
|
||||
if (source === undefined) return fail(`unknown preset ${payload.from}`)
|
||||
presets.set(payload.agentPreset, {
|
||||
trust: 'user',
|
||||
content: source.content,
|
||||
...payload.name === undefined ? {} : { name: payload.name },
|
||||
})
|
||||
return ok({ agentPreset: payload.agentPreset })
|
||||
},
|
||||
openDocument: (payload: { agentPreset: string }) => {
|
||||
record('openDocument', payload)
|
||||
if (options.throwOpen === true) return Promise.reject(new Error('socket closed'))
|
||||
if (options.failOpen !== undefined) return fail(options.failOpen)
|
||||
return (options.hasDocument ?? true)
|
||||
? ok({ opened: true })
|
||||
: ok({ opened: false, path: `/presets/${payload.agentPreset}` })
|
||||
},
|
||||
remove: async (payload: { agentPreset: string }) => {
|
||||
record('remove', payload)
|
||||
await options.holdRemove
|
||||
@@ -106,7 +136,7 @@ function fakeApi(
|
||||
|
||||
function seed(): Map<string, FakePreset> {
|
||||
return new Map<string, FakePreset>([
|
||||
['standard', { trust: 'system', content: '- id: tool-bash\n' }],
|
||||
['standard', { trust: 'system', content: '- id: tool-bash\n', name: '标准模式' }],
|
||||
['mine', { trust: 'user', content: '- id: tool-read\n' }],
|
||||
])
|
||||
}
|
||||
@@ -115,466 +145,436 @@ function harness(options: FakeOptions = {}) {
|
||||
const presets = seed()
|
||||
const defaultId = { id: 'standard' }
|
||||
const calls: Recorded[] = []
|
||||
let rosterChanges = 0
|
||||
const controller = new AgentPresetSectionController(
|
||||
fakeApi(presets, defaultId, { ...options, calls: options.calls ?? calls }),
|
||||
() => { rosterChanges += 1 },
|
||||
)
|
||||
return { controller, presets, defaultId, calls }
|
||||
return { controller, presets, defaultId, calls, rosterChanges: () => rosterChanges }
|
||||
}
|
||||
|
||||
function draftOf(controller: AgentPresetSectionController): PresetDraft {
|
||||
const { draft } = controller.store.getSnapshot()
|
||||
if (draft === null) throw new Error('expected an open draft')
|
||||
return draft
|
||||
function copyOf(controller: AgentPresetSectionController): CopyDraft {
|
||||
const { copy } = controller.store.getSnapshot()
|
||||
if (copy === null) throw new Error('expected an open copy dialog')
|
||||
return copy
|
||||
}
|
||||
|
||||
describe('loading the roster', () => {
|
||||
it('reports the presets, their trust, the default, and whether authoring is possible', async () => {
|
||||
const { controller } = harness()
|
||||
it('maps the roster onto rows with the capability flags', async () => {
|
||||
const { controller } = harness({ authorable: true, hasDocument: false })
|
||||
|
||||
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 },
|
||||
])
|
||||
expect(state.hasDocument).toBe(false)
|
||||
expect(state.rows.map((row: PresetRow) => row.id)).toEqual(['standard', 'mine'])
|
||||
expect(state.rows[0]).toMatchObject({ trust: 'system', isDefault: true, name: '标准模式' })
|
||||
})
|
||||
|
||||
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 }))
|
||||
it('reports an empty roster as unavailable, not as an error', async () => {
|
||||
const { controller, presets } = harness()
|
||||
presets.clear()
|
||||
|
||||
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 () => {
|
||||
it('keeps one load in flight rather than stacking reads', async () => {
|
||||
const { controller, calls } = harness()
|
||||
|
||||
await Promise.all([controller.load(), controller.load()])
|
||||
|
||||
expect(calls.filter(call => call.method === 'list')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('surfaces a refusal as the page error', async () => {
|
||||
const { controller } = harness({ failList: 'not for you' })
|
||||
|
||||
await controller.load()
|
||||
|
||||
const state = controller.store.getSnapshot()
|
||||
expect(state.status).toBe('error')
|
||||
expect(state.error).toBe('not for you')
|
||||
})
|
||||
|
||||
it('folds a dead transport into the same error surface', async () => {
|
||||
const { controller } = harness({ throwList: true })
|
||||
|
||||
await controller.load()
|
||||
|
||||
expect(controller.store.getSnapshot().status).toBe('error')
|
||||
expect(controller.store.getSnapshot().error).toContain('socket closed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('opening a composition', () => {
|
||||
it('opens a locally authored preset for editing', async () => {
|
||||
describe('the read-only viewer', () => {
|
||||
it('opens a shipped composition under its display name', async () => {
|
||||
const { controller } = harness()
|
||||
await controller.load()
|
||||
|
||||
await controller.open('mine')
|
||||
await controller.view('standard')
|
||||
|
||||
expect(draftOf(controller)).toMatchObject({
|
||||
id: 'mine', source: 'mine', creating: false, content: '- id: tool-read\n', writable: true,
|
||||
expect(controller.store.getSnapshot().view).toEqual({
|
||||
id: 'standard', title: '标准模式', content: '- id: tool-bash\n',
|
||||
})
|
||||
})
|
||||
|
||||
it('opens a shipped preset read-only', async () => {
|
||||
it('falls back to the id when the preset published no name', async () => {
|
||||
const { controller } = harness()
|
||||
await controller.load()
|
||||
|
||||
await controller.open('standard')
|
||||
await controller.view('mine')
|
||||
|
||||
// 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' })
|
||||
expect(controller.store.getSnapshot().view?.title).toBe('mine')
|
||||
})
|
||||
|
||||
it('surfaces a rejected read on the page rather than opening an empty editor', async () => {
|
||||
const { controller } = harness({ failRead: 'permission denied' })
|
||||
it('closes without touching the list', async () => {
|
||||
const { controller } = harness()
|
||||
await controller.load()
|
||||
await controller.view('standard')
|
||||
|
||||
controller.closeView()
|
||||
|
||||
expect(controller.store.getSnapshot().view).toBeNull()
|
||||
expect(controller.store.getSnapshot().rows).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('puts a read refusal on the page rather than opening empty', async () => {
|
||||
const { controller } = harness({ failRead: 'no peeking' })
|
||||
await controller.load()
|
||||
|
||||
await controller.open('mine')
|
||||
await controller.view('standard')
|
||||
|
||||
expect(controller.store.getSnapshot()).toMatchObject({ draft: null, error: 'permission denied' })
|
||||
expect(controller.store.getSnapshot().view).toBeNull()
|
||||
expect(controller.store.getSnapshot().error).toBe('no peeking')
|
||||
})
|
||||
|
||||
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')) },
|
||||
it('folds a dead transport into the same error surface', async () => {
|
||||
const { controller } = harness({ throwRead: true })
|
||||
await controller.load()
|
||||
|
||||
await controller.view('standard')
|
||||
|
||||
expect(controller.store.getSnapshot().error).toContain('socket closed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('the copy dialog', () => {
|
||||
it('opens over the source with its display name in the title', async () => {
|
||||
const { controller } = harness()
|
||||
await controller.load()
|
||||
|
||||
controller.beginCopy('standard')
|
||||
|
||||
expect(copyOf(controller)).toMatchObject({
|
||||
from: 'standard', fromTitle: '标准模式', id: '', name: '', saving: false,
|
||||
})
|
||||
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('starts blank when no source is named', async () => {
|
||||
const { controller, calls } = harness()
|
||||
await controller.load()
|
||||
const before = calls.length
|
||||
|
||||
await controller.createFrom()
|
||||
|
||||
// Copying is its own action on the row being copied, so this one is a copy
|
||||
// of nothing: no source to name, and no read to make.
|
||||
expect(draftOf(controller)).toMatchObject({ id: '', creating: true, writable: true, content: '' })
|
||||
expect(draftOf(controller).source).toBeUndefined()
|
||||
expect(calls).toHaveLength(before)
|
||||
})
|
||||
|
||||
it('copies a named preset', async () => {
|
||||
it('falls back to the source id when it published no name', async () => {
|
||||
const { controller } = harness()
|
||||
await controller.load()
|
||||
|
||||
await controller.createFrom('mine')
|
||||
controller.beginCopy('mine')
|
||||
|
||||
expect(draftOf(controller)).toMatchObject({ source: 'mine', creating: true, content: '- id: tool-read\n' })
|
||||
expect(copyOf(controller).fromTitle).toBe('mine')
|
||||
})
|
||||
|
||||
it('opens the blank editor without the roster, which it no longer reads', async () => {
|
||||
it('cancel discards whatever was typed', async () => {
|
||||
const { controller } = harness()
|
||||
await controller.load()
|
||||
controller.beginCopy('standard')
|
||||
controller.setCopyId('half-typed')
|
||||
|
||||
controller.cancelCopy()
|
||||
|
||||
expect(controller.store.getSnapshot().copy).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores field edits and submits with no dialog open', async () => {
|
||||
const { controller, calls } = harness()
|
||||
await controller.load()
|
||||
|
||||
await controller.createFrom()
|
||||
controller.setCopyId('typed-into-nothing')
|
||||
controller.setCopyName('nameless')
|
||||
await controller.confirmCopy()
|
||||
|
||||
expect(draftOf(controller)).toMatchObject({ id: '', creating: true, content: '' })
|
||||
expect(calls).toHaveLength(0)
|
||||
expect(controller.store.getSnapshot().copy).toBeNull()
|
||||
expect(calls.some(call => call.method === 'copy')).toBe(false)
|
||||
})
|
||||
|
||||
it('typing clears the previous failure', async () => {
|
||||
const { controller } = harness({ failCopy: 'disk full' })
|
||||
await controller.load()
|
||||
controller.beginCopy('standard')
|
||||
controller.setCopyId('my-copy')
|
||||
await controller.confirmCopy()
|
||||
expect(copyOf(controller).error).toBe('disk full')
|
||||
|
||||
controller.setCopyName('renamed')
|
||||
|
||||
expect(copyOf(controller).error).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('the save blocker', () => {
|
||||
const base: PresetDraft = {
|
||||
id: '', source: 'standard', creating: true, content: '', writable: true,
|
||||
name: '', description: '', saving: false, error: null,
|
||||
}
|
||||
const rows: readonly PresetRow[] = [{ id: 'mine', trust: 'user', isDefault: false }]
|
||||
describe('the copy blocker', () => {
|
||||
const rows: PresetRow[] = [
|
||||
{ id: 'standard', trust: 'system', isDefault: true },
|
||||
{ id: 'mine', trust: 'user', isDefault: false },
|
||||
]
|
||||
const draft = (id: string): CopyDraft =>
|
||||
({ from: 'standard', fromTitle: '标准模式', id, name: '', saving: false, error: null })
|
||||
|
||||
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()
|
||||
it('requires an id, a containable shape, and a free name', () => {
|
||||
expect(draftBlocker(draft(''), rows)).toBe('idRequired')
|
||||
expect(draftBlocker(draft('../escape'), rows)).toBe('idInvalid')
|
||||
expect(draftBlocker(draft('Upper'), rows)).toBe('idInvalid')
|
||||
expect(draftBlocker(draft('mine'), rows)).toBe('idTaken')
|
||||
expect(draftBlocker(draft('my-copy'), rows)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('saving', () => {
|
||||
it('creates the preset and re-reads the roster', async () => {
|
||||
const { controller, presets } = harness()
|
||||
describe('submitting a copy', () => {
|
||||
it('copies, re-reads the roster, announces the change, and opens the files', async () => {
|
||||
const { controller, calls, rosterChanges } = harness()
|
||||
await controller.load()
|
||||
await controller.createFrom()
|
||||
controller.setId('my-agent')
|
||||
controller.setContent('- id: tool-web-search\n')
|
||||
controller.beginCopy('standard')
|
||||
controller.setCopyId('my-copy')
|
||||
controller.setCopyName('我的模式')
|
||||
|
||||
await controller.save()
|
||||
await controller.confirmCopy()
|
||||
|
||||
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')
|
||||
const state = controller.store.getSnapshot()
|
||||
expect(state.copy).toBeNull()
|
||||
expect(state.rows.map(row => row.id)).toContain('my-copy')
|
||||
expect(rosterChanges()).toBe(1)
|
||||
expect(calls.find(call => call.method === 'copy')?.payload)
|
||||
.toEqual({ from: 'standard', agentPreset: 'my-copy', name: '我的模式' })
|
||||
// A preset is its files from here on, so landing in them completes the
|
||||
// copy rather than following it.
|
||||
expect(calls.find(call => call.method === 'openDocument')?.payload)
|
||||
.toEqual({ agentPreset: 'my-copy' })
|
||||
})
|
||||
|
||||
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 () => {
|
||||
it('omits an empty name so the copy falls back to its id', async () => {
|
||||
const { controller, calls } = harness()
|
||||
await controller.load()
|
||||
await controller.createFrom()
|
||||
controller.beginCopy('standard')
|
||||
controller.setCopyId('my-copy')
|
||||
controller.setCopyName(' ')
|
||||
|
||||
await controller.save()
|
||||
await controller.confirmCopy()
|
||||
|
||||
expect(calls.some(call => call.method === 'write')).toBe(false)
|
||||
expect(controller.store.getSnapshot().draft).not.toBeNull()
|
||||
expect(calls.find(call => call.method === 'copy')?.payload)
|
||||
.toEqual({ from: 'standard', agentPreset: 'my-copy' })
|
||||
})
|
||||
|
||||
it('refuses to write a read-only draft', async () => {
|
||||
it('reveals the new directory as text where the host has no desktop', async () => {
|
||||
const { controller } = harness({ hasDocument: false })
|
||||
await controller.load()
|
||||
controller.beginCopy('standard')
|
||||
controller.setCopyId('my-copy')
|
||||
|
||||
await controller.confirmCopy()
|
||||
|
||||
expect(controller.store.getSnapshot().revealedPaths['my-copy']).toBe('/presets/my-copy')
|
||||
})
|
||||
|
||||
it('keeps the dialog open with the refusal on it', async () => {
|
||||
const { controller, rosterChanges } = harness({ failCopy: 'id already exists' })
|
||||
await controller.load()
|
||||
controller.beginCopy('standard')
|
||||
controller.setCopyId('my-copy')
|
||||
|
||||
await controller.confirmCopy()
|
||||
|
||||
expect(copyOf(controller)).toMatchObject({ saving: false, error: 'id already exists' })
|
||||
expect(rosterChanges()).toBe(0)
|
||||
})
|
||||
|
||||
it('folds a dead transport into the dialog error', async () => {
|
||||
const { controller } = harness({ throwCopy: true })
|
||||
await controller.load()
|
||||
controller.beginCopy('standard')
|
||||
controller.setCopyId('my-copy')
|
||||
|
||||
await controller.confirmCopy()
|
||||
|
||||
expect(copyOf(controller).error).toContain('socket closed')
|
||||
})
|
||||
|
||||
it('refuses to submit while blocked or already saving', async () => {
|
||||
const { controller, calls } = harness()
|
||||
await controller.load()
|
||||
await controller.open('standard')
|
||||
controller.beginCopy('standard')
|
||||
controller.setCopyId('mine')
|
||||
|
||||
await controller.save()
|
||||
await controller.confirmCopy()
|
||||
|
||||
expect(calls.some(call => call.method === 'write')).toBe(false)
|
||||
expect(calls.some(call => call.method === 'copy')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('does nothing without an open draft', async () => {
|
||||
describe('the location action', () => {
|
||||
it('opens the directory and leaves the page alone on a desktop host', async () => {
|
||||
const { controller, calls } = harness()
|
||||
await controller.load()
|
||||
|
||||
await controller.save()
|
||||
await controller.openLocation('mine')
|
||||
|
||||
expect(calls.some(call => call.method === 'write')).toBe(false)
|
||||
expect(calls.find(call => call.method === 'openDocument')?.payload).toEqual({ agentPreset: 'mine' })
|
||||
expect(controller.store.getSnapshot().revealedPaths).toEqual({})
|
||||
})
|
||||
|
||||
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('carries the display name and description through a save', async () => {
|
||||
const { controller, calls } = harness()
|
||||
await controller.load()
|
||||
await controller.open('mine')
|
||||
|
||||
controller.setName('我的模式')
|
||||
controller.setDescription('只做检索。')
|
||||
await controller.save()
|
||||
|
||||
expect(calls.find(call => call.method === 'write')?.payload)
|
||||
.toMatchObject({ agentPreset: 'mine', name: '我的模式', description: '只做检索。' })
|
||||
})
|
||||
|
||||
it('leaves a copy unnamed so two rows cannot present themselves alike', async () => {
|
||||
const { controller } = harness()
|
||||
it('reveals the path on the row where the host has none', async () => {
|
||||
const { controller } = harness({ hasDocument: false })
|
||||
await controller.load()
|
||||
|
||||
await controller.createFrom('mine')
|
||||
await controller.openLocation('mine')
|
||||
|
||||
// The composition is copied verbatim; the display name is not.
|
||||
expect(draftOf(controller)).toMatchObject({ id: '', name: '' })
|
||||
expect(draftOf(controller).content).toBe('- id: tool-read\n')
|
||||
expect(controller.store.getSnapshot().revealedPaths).toEqual({ mine: '/presets/mine' })
|
||||
})
|
||||
|
||||
it('ignores an edit with no draft open', () => {
|
||||
const { controller } = harness()
|
||||
it('drops a revealed path once its preset leaves the roster', async () => {
|
||||
const { controller, presets } = harness({ hasDocument: false })
|
||||
await controller.load()
|
||||
await controller.openLocation('mine')
|
||||
presets.delete('mine')
|
||||
|
||||
controller.setId('x')
|
||||
controller.setContent('y')
|
||||
controller.setName('n')
|
||||
controller.setDescription('d')
|
||||
await controller.load()
|
||||
|
||||
expect(controller.store.getSnapshot().draft).toBeNull()
|
||||
expect(controller.store.getSnapshot().revealedPaths).toEqual({})
|
||||
})
|
||||
|
||||
it('surfaces a refusal as the page error', async () => {
|
||||
const { controller } = harness({ failOpen: 'not yours' })
|
||||
await controller.load()
|
||||
|
||||
await controller.openLocation('mine')
|
||||
|
||||
expect(controller.store.getSnapshot().error).toBe('not yours')
|
||||
})
|
||||
|
||||
it('folds a dead transport into the same error surface', async () => {
|
||||
const { controller } = harness({ throwOpen: true })
|
||||
await controller.load()
|
||||
|
||||
await controller.openLocation('mine')
|
||||
|
||||
expect(controller.store.getSnapshot().error).toContain('socket closed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleting', () => {
|
||||
it('deletes the confirmed preset and re-reads the roster', async () => {
|
||||
const { controller, presets } = harness()
|
||||
it('asks first, then deletes, re-reads, and announces the change', async () => {
|
||||
const { controller, rosterChanges } = harness()
|
||||
await controller.load()
|
||||
|
||||
controller.confirmDelete('mine')
|
||||
expect(controller.store.getSnapshot().pendingDelete).toBe('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 })
|
||||
const state = controller.store.getSnapshot()
|
||||
expect(state.pendingDelete).toBeNull()
|
||||
expect(state.rows.map(row => row.id)).not.toContain('mine')
|
||||
expect(rosterChanges()).toBe(1)
|
||||
})
|
||||
|
||||
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()])
|
||||
controller.confirmDelete(null)
|
||||
await controller.remove()
|
||||
|
||||
expect(controller.store.getSnapshot().rows.map(row => row.id)).toContain('mine')
|
||||
expect(calls.some(call => call.method === 'remove')).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores a second confirmation while one delete is in flight', async () => {
|
||||
let release = (): void => {}
|
||||
const gate = new Promise<void>((resolve) => { release = resolve })
|
||||
const { controller, calls } = harness({ holdRemove: gate })
|
||||
await controller.load()
|
||||
controller.confirmDelete('mine')
|
||||
const removal = controller.remove()
|
||||
|
||||
controller.confirmDelete('standard')
|
||||
await controller.remove()
|
||||
release()
|
||||
await removal
|
||||
|
||||
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 }),
|
||||
)
|
||||
it('surfaces a refusal and clears the confirmation', async () => {
|
||||
const { controller } = harness({ failRemove: 'shipped preset' })
|
||||
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
|
||||
await controller.remove()
|
||||
|
||||
expect(presets.has('mine')).toBe(false)
|
||||
const state = controller.store.getSnapshot()
|
||||
expect(state.error).toBe('shipped preset')
|
||||
expect(state.pendingDelete).toBeNull()
|
||||
expect(state.deleting).toBe(false)
|
||||
})
|
||||
|
||||
it('folds a dead transport into the same error surface', async () => {
|
||||
const { controller, presets } = harness()
|
||||
await controller.load()
|
||||
presets.clear()
|
||||
const broken = new AgentPresetSectionController({
|
||||
agentPresets: {
|
||||
list: () => Promise.reject(new Error('gone')),
|
||||
remove: () => Promise.reject(new Error('socket closed')),
|
||||
},
|
||||
settings: {},
|
||||
} as unknown as Pick<IApiClient, 'agentPresets' | 'settings'>)
|
||||
broken.confirmDelete('mine')
|
||||
|
||||
await broken.remove()
|
||||
|
||||
expect(broken.store.getSnapshot().error).toContain('socket closed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('a controller with no roster listener', () => {
|
||||
it('completes a delete without anyone to notify', async () => {
|
||||
// The rosterChanged callback is optional wiring, not a requirement: a
|
||||
// page composed without sibling surfaces still deletes cleanly.
|
||||
const presets = seed()
|
||||
const alone = new AgentPresetSectionController(fakeApi(presets, { id: 'standard' }))
|
||||
await alone.load()
|
||||
alone.confirmDelete('mine')
|
||||
|
||||
await alone.remove()
|
||||
|
||||
expect(alone.store.getSnapshot().rows.map(row => row.id)).not.toContain('mine')
|
||||
})
|
||||
})
|
||||
|
||||
describe('the default preset', () => {
|
||||
it('writes the settings field and re-reads the roster', async () => {
|
||||
const { controller, calls, defaultId } = harness()
|
||||
it('writes the setting and re-reads the roster', async () => {
|
||||
const { controller, 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')
|
||||
expect(controller.store.getSnapshot().rows.find(row => row.id === 'mine')?.isDefault).toBe(true)
|
||||
})
|
||||
|
||||
it('reports a refused write and leaves the roster alone', async () => {
|
||||
const { controller, defaultId } = harness({ failSettings: 'settings are read-only' })
|
||||
it('surfaces a settings refusal as the page error', async () => {
|
||||
const { controller } = harness({ failSettings: 'read-only settings' })
|
||||
await controller.load()
|
||||
|
||||
await controller.makeDefault('mine')
|
||||
|
||||
expect(controller.store.getSnapshot().error).toBe('settings are read-only')
|
||||
expect(defaultId.id).toBe('standard')
|
||||
expect(controller.store.getSnapshot().error).toContain('read-only settings')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// @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.
|
||||
* on its trust, a shipped composition opens in a read-only viewer, creation is
|
||||
* a copy dialog that collects an id and an optional name, and the location
|
||||
* action follows the host's desktop capability.
|
||||
*/
|
||||
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
@@ -12,7 +12,7 @@ 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 type { AgentPresetSectionState, CopyDraft } from '../src/client/section-store.ts'
|
||||
import { en } from '../src/client/locales.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
@@ -21,13 +21,16 @@ const READY: AgentPresetSectionState = {
|
||||
status: 'ready',
|
||||
error: null,
|
||||
authorable: true,
|
||||
hasDocument: true,
|
||||
rows: [
|
||||
{ id: 'standard', trust: 'system', isDefault: true, name: '标准模式', description: '完整的编码 agent。' },
|
||||
{ id: 'mine', trust: 'user', isDefault: false },
|
||||
],
|
||||
draft: null,
|
||||
copy: null,
|
||||
view: null,
|
||||
pendingDelete: null,
|
||||
deleting: false,
|
||||
revealedPaths: {},
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -39,14 +42,14 @@ 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(),
|
||||
setName: vi.fn(),
|
||||
setDescription: vi.fn(),
|
||||
save: vi.fn(() => Promise.resolve()),
|
||||
view: vi.fn(() => Promise.resolve()),
|
||||
closeView: vi.fn(),
|
||||
beginCopy: vi.fn(),
|
||||
cancelCopy: vi.fn(),
|
||||
setCopyId: vi.fn(),
|
||||
setCopyName: vi.fn(),
|
||||
confirmCopy: vi.fn(() => Promise.resolve()),
|
||||
openLocation: vi.fn(() => Promise.resolve()),
|
||||
confirmDelete: vi.fn(),
|
||||
remove: vi.fn(() => Promise.resolve()),
|
||||
makeDefault: vi.fn(() => Promise.resolve()),
|
||||
@@ -105,8 +108,6 @@ describe('the preset list', () => {
|
||||
// read-only, the other is the user's own.
|
||||
expect(screen.getByRole('heading', { name: en.builtInGroup })).toBeTruthy()
|
||||
expect(screen.getByRole('heading', { name: en.customGroup })).toBeTruthy()
|
||||
expect(within(rowFor('standard')).getByText(en.builtIn)).toBeTruthy()
|
||||
expect(within(rowFor('mine')).getByText(en.userTrust)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows no group heading for a set nobody has', () => {
|
||||
@@ -115,6 +116,14 @@ describe('the preset list', () => {
|
||||
expect(screen.queryByRole('heading', { name: en.customGroup })).toBeNull()
|
||||
})
|
||||
|
||||
it('leads with the guidance that creation starts from a copy', () => {
|
||||
renderSection()
|
||||
|
||||
// The page has no create button: the intro is what tells a first-time
|
||||
// reader that duplicating a built-in preset IS the way to make one.
|
||||
expect(screen.getByText(new RegExp(en.copyHint))).toBeTruthy()
|
||||
})
|
||||
|
||||
it('picks a preset by clicking its card, and the one in use is inert', () => {
|
||||
const actions = renderSection()
|
||||
|
||||
@@ -127,27 +136,49 @@ describe('the preset list', () => {
|
||||
expect(actions.makeDefault).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('offers Edit for a local preset and View for a shipped one', () => {
|
||||
it('offers View on a shipped row and the location on a custom one', () => {
|
||||
renderSection()
|
||||
|
||||
expect(within(rowFor('mine')).getByRole('button', { name: 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')).getByRole('button', { name: en.view })).toBeTruthy()
|
||||
// A shipped preset is the composition a copy starts from — reading it is
|
||||
// the point. A custom preset is edited in its files, so its row leads
|
||||
// there instead; there is no editor for either.
|
||||
const standard = rowFor('standard')
|
||||
expect(within(standard).getByRole('button', { name: `${en.view}: 标准模式` })).toBeTruthy()
|
||||
expect(within(standard).queryByRole('button', { name: `${en.openLocation}: 标准模式` })).toBeNull()
|
||||
const mine = rowFor('mine')
|
||||
expect(within(mine).getByRole('button', { name: `${en.openLocation}: mine` })).toBeTruthy()
|
||||
expect(within(mine).queryByRole('button', { name: `${en.view}: mine` })).toBeNull()
|
||||
})
|
||||
|
||||
it('offers Delete only for a locally authored preset', () => {
|
||||
renderSection()
|
||||
|
||||
expect(within(rowFor('mine')).getByRole('button', { name: en.delete })).toBeTruthy()
|
||||
expect(within(rowFor('standard')).queryByRole('button', { name: en.delete })).toBeNull()
|
||||
expect(within(rowFor('mine')).getByRole('button', { name: `${en.delete}: mine` })).toBeTruthy()
|
||||
expect(within(rowFor('standard')).queryByRole('button', { name: `${en.delete}: 标准模式` })).toBeNull()
|
||||
})
|
||||
|
||||
it('hides duplication and disables creation when nothing is writable', () => {
|
||||
it('disables duplication when nothing is writable, and says why', () => {
|
||||
renderSection({ authorable: false })
|
||||
|
||||
expect(screen.queryByRole('button', { name: en.duplicate })).toBeNull()
|
||||
expect(screen.getByText(`+ ${en.newPreset}`)).toHaveProperty('disabled', true)
|
||||
const duplicate = within(rowFor('standard')).getByRole('button', { name: `${en.duplicate}: 标准模式` })
|
||||
expect(duplicate).toHaveProperty('disabled', true)
|
||||
expect(duplicate.getAttribute('data-tip')).toBe(en.duplicateUnavailable)
|
||||
})
|
||||
|
||||
it('labels the location by what it will do without a desktop', () => {
|
||||
renderSection({ hasDocument: false })
|
||||
|
||||
expect(within(rowFor('mine')).getByRole('button', { name: `${en.showLocation}: mine` })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows a revealed directory on its row', () => {
|
||||
renderSection({ revealedPaths: { mine: '/home/user/.dsh/.agent-presets/mine' } })
|
||||
|
||||
const mine = rowFor('mine')
|
||||
expect(within(mine).getByText('/home/user/.dsh/.agent-presets/mine')).toBeTruthy()
|
||||
expect(within(mine).getByText(en.revealedPathLabel)).toBeTruthy()
|
||||
// The reveal belongs to its row alone.
|
||||
expect(within(rowFor('standard')).queryByText(en.revealedPathLabel)).toBeNull()
|
||||
})
|
||||
|
||||
it('routes the row actions to the controller', () => {
|
||||
@@ -155,15 +186,14 @@ describe('the preset list', () => {
|
||||
|
||||
// The card body is the control that picks a preset.
|
||||
fireEvent.click(within(rowFor('mine')).getByRole('button', { name: `${en.setDefault}: mine` }))
|
||||
fireEvent.click(within(rowFor('mine')).getByRole('button', { name: en.edit }))
|
||||
fireEvent.click(within(rowFor('mine')).getByRole('button', { name: en.duplicate }))
|
||||
fireEvent.click(screen.getByText(`+ ${en.newPreset}`))
|
||||
fireEvent.click(within(rowFor('mine')).getByRole('button', { name: `${en.openLocation}: mine` }))
|
||||
fireEvent.click(within(rowFor('mine')).getByRole('button', { name: `${en.duplicate}: mine` }))
|
||||
fireEvent.click(within(rowFor('standard')).getByRole('button', { name: `${en.view}: 标准模式` }))
|
||||
|
||||
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()
|
||||
expect(actions.openLocation).toHaveBeenCalledWith('mine')
|
||||
expect(actions.beginCopy).toHaveBeenCalledWith('mine')
|
||||
expect(actions.view).toHaveBeenCalledWith('standard')
|
||||
})
|
||||
|
||||
it('shows a page-level failure without hiding the list', () => {
|
||||
@@ -194,126 +224,97 @@ describe('the preset list', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('the composition editor', () => {
|
||||
const draft = {
|
||||
id: 'mine', source: 'mine', creating: false, content: '- id: tool-read\n',
|
||||
writable: true, name: '我的预设', description: '', saving: false, error: null,
|
||||
describe('the copy dialog', () => {
|
||||
const draft: CopyDraft = {
|
||||
from: 'standard', fromTitle: '标准模式', id: '', name: '', saving: false, error: null,
|
||||
}
|
||||
|
||||
it('opens a blank draft without naming a preset it came from', () => {
|
||||
const { source: _copied, ...blank } = draft
|
||||
renderSection({ draft: { ...blank, id: '', creating: true } })
|
||||
it('names its source and collects only an id and a display name', () => {
|
||||
const actions = renderSection({ copy: draft })
|
||||
|
||||
// "New preset" starts empty — copying is what the per-row Duplicate does,
|
||||
// so a blank draft has no source to name and shows no copied-from hint.
|
||||
expect(screen.getByText(en.newPreset)).toBeTruthy()
|
||||
expect(screen.queryByText(new RegExp(en.copyOf))).toBeNull()
|
||||
const dialog = screen.getByRole('dialog')
|
||||
expect(dialog.getAttribute('aria-label')).toBe(`${en.copyTitle} · ${en.copyOf} 标准模式`)
|
||||
expect(within(dialog).getByText(en.copyIntro)).toBeTruthy()
|
||||
fireEvent.change(within(dialog).getByPlaceholderText(en.presetIdPlaceholder), { target: { value: 'my-agent' } })
|
||||
fireEvent.change(within(dialog).getByPlaceholderText(en.displayNamePlaceholder), { target: { value: '我的模式' } })
|
||||
|
||||
expect(actions.setCopyId).toHaveBeenCalledWith('my-agent')
|
||||
expect(actions.setCopyName).toHaveBeenCalledWith('我的模式')
|
||||
// Nothing else is collected: the description and the composition are
|
||||
// edited in the preset's own files.
|
||||
expect(within(dialog).queryByRole('textbox', { name: /description/i })).toBeNull()
|
||||
})
|
||||
|
||||
it('replaces the list while editing, and returns to it', () => {
|
||||
const actions = renderSection({ draft })
|
||||
it('creates and cancels through the controller', () => {
|
||||
const actions = renderSection({ copy: { ...draft, id: 'my-agent' } })
|
||||
|
||||
// The form is tall and a card column is ~268px: squeezing it into one is
|
||||
// unusable, and hanging it off the end orphans it from the card it edits.
|
||||
expect(screen.queryByRole('heading', { name: en.builtInGroup })).toBeNull()
|
||||
const editor = screen.getByLabelText(en.composition)
|
||||
expect(editor).toHaveProperty('value', '- id: tool-read\n')
|
||||
fireEvent.change(editor, { target: { value: '- id: tool-edit\n' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: `← ${en.backToList}` }))
|
||||
const dialog = screen.getByRole('dialog')
|
||||
fireEvent.click(within(dialog).getByText(en.create))
|
||||
fireEvent.click(within(dialog).getByText(en.cancel))
|
||||
|
||||
expect(actions.setContent).toHaveBeenCalledWith('- id: tool-edit\n')
|
||||
expect(actions.close).toHaveBeenCalledTimes(1)
|
||||
expect(actions.confirmCopy).toHaveBeenCalledTimes(1)
|
||||
expect(actions.cancelCopy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('saves and cancels through the controller', () => {
|
||||
const actions = renderSection({ draft })
|
||||
it('blocks a copy the host would refuse, and says why', () => {
|
||||
const actions = renderSection({ copy: { ...draft, id: 'Upper Case' } })
|
||||
|
||||
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.getByLabelText(en.composition)).toHaveProperty('readOnly', true)
|
||||
expect(screen.getByText(en.readOnlyNotice)).toBeTruthy()
|
||||
expect(screen.queryByText(en.save)).toBeNull()
|
||||
// Nothing to commit or abandon, and the back link above already leaves —
|
||||
// a lone Close button would be a second way out of the same screen.
|
||||
expect(screen.queryByText(en.cancel)).toBeNull()
|
||||
expect(screen.getByRole('button', { name: `← ${en.backToList}` })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('titles the panel by what it is doing', () => {
|
||||
renderSection({ draft })
|
||||
expect(screen.getByText(`${en.edit} · 我的预设`)).toBeTruthy()
|
||||
cleanup()
|
||||
|
||||
// An unnamed draft falls back to what it was copied from.
|
||||
renderSection({ draft: { ...draft, name: '' } })
|
||||
expect(screen.getByText(`${en.edit} · mine`)).toBeTruthy()
|
||||
cleanup()
|
||||
|
||||
renderSection({ draft: { ...draft, writable: false } })
|
||||
expect(screen.getByText(`${en.view} · 我的预设`)).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.presetIdPlaceholder), { target: { value: 'my-agent' } })
|
||||
|
||||
expect(actions.setId).toHaveBeenCalledWith('my-agent')
|
||||
})
|
||||
|
||||
it('edits the display name and description through the controller', () => {
|
||||
const actions = renderSection({ draft })
|
||||
|
||||
fireEvent.change(screen.getByLabelText(en.displayName), { target: { value: '我的模式' } })
|
||||
fireEvent.change(screen.getByLabelText(en.displayDescription), { target: { value: '只做检索。' } })
|
||||
|
||||
expect(actions.setName).toHaveBeenCalledWith('我的模式')
|
||||
expect(actions.setDescription).toHaveBeenCalledWith('只做检索。')
|
||||
})
|
||||
|
||||
it('offers no display fields on a read-only preset', () => {
|
||||
renderSection({ draft: { ...draft, writable: false } })
|
||||
|
||||
// Nothing here can be saved, so an editable name would be a lie.
|
||||
expect(screen.queryByLabelText(en.displayName)).toBeNull()
|
||||
})
|
||||
|
||||
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))
|
||||
const dialog = screen.getByRole('dialog')
|
||||
expect(within(dialog).getByRole('alert').textContent).toBe(en.idInvalid)
|
||||
fireEvent.click(within(dialog).getByText(en.create))
|
||||
|
||||
// Disabled rather than round-tripping: the id is a directory name and the
|
||||
// rule is the host's own.
|
||||
expect(actions.save).not.toHaveBeenCalled()
|
||||
expect(actions.confirmCopy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows the host\'s refusal instead of the local blocker', () => {
|
||||
renderSection({ draft: { ...draft, error: 'composition is not an entry list' } })
|
||||
renderSection({ copy: { ...draft, id: 'my-agent', error: 'already exists' } })
|
||||
|
||||
expect(screen.getByRole('alert').textContent).toBe('composition is not an entry list')
|
||||
expect(within(screen.getByRole('dialog')).getByRole('alert').textContent).toBe('already exists')
|
||||
})
|
||||
|
||||
it('reports a copy in flight and blocks a second click', () => {
|
||||
const actions = renderSection({ copy: { ...draft, id: 'my-agent', saving: true } })
|
||||
|
||||
fireEvent.click(within(screen.getByRole('dialog')).getByText(en.creating))
|
||||
|
||||
expect(actions.confirmCopy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('dismisses on Escape', () => {
|
||||
const actions = renderSection({ copy: draft })
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
|
||||
expect(actions.cancelCopy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the read-only viewer', () => {
|
||||
it('shows the composition text under the preset\'s name', () => {
|
||||
renderSection({ view: { id: 'standard', title: '标准模式', content: '- id: tool-bash\n' } })
|
||||
|
||||
const dialog = screen.getByRole('dialog')
|
||||
expect(dialog.getAttribute('aria-label')).toBe(`${en.view} · 标准模式`)
|
||||
expect(within(dialog).getByText(en.composition)).toBeTruthy()
|
||||
expect(within(dialog).getByText(/tool-bash/).textContent).toBe('- id: tool-bash\n')
|
||||
})
|
||||
|
||||
it('closes through the controller', () => {
|
||||
const actions = renderSection({ view: { id: 'standard', title: '标准模式', content: '- id: x\n' } })
|
||||
|
||||
fireEvent.click(within(screen.getByRole('dialog')).getByText(en.close))
|
||||
|
||||
expect(actions.closeView).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('dismisses on Escape', () => {
|
||||
const actions = renderSection({ view: { id: 'standard', title: '标准模式', content: '- id: x\n' } })
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
|
||||
expect(actions.closeView).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -321,7 +322,7 @@ describe('deleting a preset', () => {
|
||||
it('asks before deleting', () => {
|
||||
const actions = renderSection()
|
||||
|
||||
fireEvent.click(within(rowFor('mine')).getByRole('button', { name: en.delete }))
|
||||
fireEvent.click(within(rowFor('mine')).getByRole('button', { name: `${en.delete}: mine` }))
|
||||
|
||||
expect(actions.confirmDelete).toHaveBeenCalledWith('mine')
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user