diff --git a/packages/client/ui-agent-preset/README.i18n.yaml b/packages/client/ui-agent-preset/README.i18n.yaml
index 80d6a464cc..0e6ce1245b 100644
--- a/packages/client/ui-agent-preset/README.i18n.yaml
+++ b/packages/client/ui-agent-preset/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-agent-preset/README.md
-README.md: 6261984835a70b6b669545acc8f9bfafc93ab64d
-README.zh.md: fb860812b1646f6b1912e4aedf56b00696957352
+README.md: c9aa370ebb7612cdc18bbe3d63c85cbb7097f69b
+README.zh.md: 707745c547f2172abb4d11cda09f3de1c5c1ec78
diff --git a/packages/client/ui-agent-preset/README.md b/packages/client/ui-agent-preset/README.md
index 6261984835..c9aa370ebb 100644
--- a/packages/client/ui-agent-preset/README.md
+++ b/packages/client/ui-agent-preset/README.md
@@ -32,7 +32,7 @@ The row re-reads on `settings/changed` for its own namespace and on `connection/
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 shipped preset opens read-only. It is the known-good composition a local one is written against, so reading it is the point and overwriting it is not — the deployment's copy is what a broken local preset is compared against. Authoring therefore starts by duplicating: **New preset** copies the current default, and **Duplicate** copies any row, because a copy always lands in the local root regardless of where the text came from.
+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.
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.
diff --git a/packages/client/ui-agent-preset/README.zh.md b/packages/client/ui-agent-preset/README.zh.md
index fb860812b1..707745c547 100644
--- a/packages/client/ui-agent-preset/README.zh.md
+++ b/packages/client/ui-agent-preset/README.zh.md
@@ -32,7 +32,7 @@ chip 以部署默认值打开,其选择是**暂存**的——该界面先于
第四个表层,独立的设置页(`settings.section`,id 为 `agent-presets`,排在「模型」之后——选模型是日常操作,而组装 agent 是它背后那件塑造部署形态的事):名单以行呈现,同一时刻有一份组装在 YAML 编辑器中打开。
-随部署提供的 preset 以只读方式打开。它是本地 preset 据以编写的已知良好组装,因此能读到它正是意义所在,而覆写它则不是——部署自带的那一份正是用来对照有问题的本地 preset 的。因此创作从复制开始:**新建 preset** 复制当前默认值,**复制**则复制任意一行;无论文本来自何处,副本总是落在本地根目录,所以副本总是可写的。
+随部署提供的 preset 以只读方式打开。它是本地 preset 据以编写的已知良好组装,因此能读到它正是意义所在,而覆写它则不是——部署自带的那一份正是用来对照有问题的本地 preset 的。**复制**复制任意一行;无论文本来自何处,副本总是落在本地根目录,所以副本总是可写的。**新建 preset** 则从空白开始——复制这件事已经由被复制那一行自己提供,而一份没人指名的组装,只会让作者先认出它不是自己想要的、再把它删掉。
id 会成为目录名,因此编辑器复刻宿主自身的约束规则(`[a-z0-9][a-z0-9-]*`),并拒绝已被占用的名称——新建若落在已存在的名称上,就会覆盖用户从未打开过的 preset。这两项检查只是便利:宿主会连同组装的形状一起重新校验,失败时编辑器报告的正是宿主的答复。能解析的保存也仅仅是保存;引用了不存在插件的组装,会在下一个选择它的会话处失败。
diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx
index da9eac9f39..dbd6ff05d0 100644
--- a/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx
+++ b/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx
@@ -82,7 +82,9 @@ function Editor({ draft, blocker, t, actions }: EditorProps): ReactNode {
placeholder={t('presetIdPlaceholder')}
onChange={(event) => { actions.setId(event.target.value) }}
/>
- {`${t('copyOf')} ${draft.source}`}
+ {draft.source === undefined
+ ? null
+ : {`${t('copyOf')} ${draft.source}`}}
)
: null}
@@ -195,8 +197,8 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {
{draft.creating
- ? `${t('newPreset')} · ${t('copyOf')} ${draft.source}`
- : `${draft.writable ? t('edit') : t('view')} · ${draft.name === '' ? draft.source : draft.name}`}
+ ? (draft.source === undefined ? t('newPreset') : `${t('newPreset')} · ${t('copyOf')} ${draft.source}`)
+ : `${draft.writable ? t('edit') : t('view')} · ${draft.name === '' ? draft.id : draft.name}`}
diff --git a/packages/client/ui-agent-preset/src/client/section-store.ts b/packages/client/ui-agent-preset/src/client/section-store.ts
index f57c502f52..15adb17e60 100644
--- a/packages/client/ui-agent-preset/src/client/section-store.ts
+++ b/packages/client/ui-agent-preset/src/client/section-store.ts
@@ -35,9 +35,10 @@ export interface PresetDraft {
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.
+ * the editor can say what it is a copy of. Absent on a preset started
+ * blank, which is a copy of nothing.
*/
- source: string
+ source?: string
/** Whether saving creates a preset rather than replacing one. */
creating: boolean
/** Composition text being edited. */
@@ -169,17 +170,32 @@ export class AgentPresetSectionController {
}
/**
- * Open a copy of one preset as a new, unnamed preset. With no argument the
- * copy is taken from the default preset, which is the composition a new
- * session gets and therefore the one worth starting from.
- * @param from - the preset to copy, or undefined for the default.
+ * 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 {
- const source = from ?? this.store.getSnapshot().rows.find(row => row.isDefault)?.id
- /* v8 ignore next -- the section only renders with a roster, and every roster marks a default */
- if (source === undefined) return
- await this.openDraft(source, true)
+ 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 {
diff --git a/packages/client/ui-agent-preset/tests/section-store.spec.ts b/packages/client/ui-agent-preset/tests/section-store.spec.ts
index 67672b3ef1..31d2f80378 100644
--- a/packages/client/ui-agent-preset/tests/section-store.spec.ts
+++ b/packages/client/ui-agent-preset/tests/section-store.spec.ts
@@ -239,16 +239,18 @@ describe('opening a composition', () => {
})
describe('creating a preset', () => {
- it('copies the default composition when no source is named', async () => {
- const { controller } = harness()
+ it('starts blank when no source is named', async () => {
+ const { controller, calls } = harness()
await controller.load()
+ const before = calls.length
await controller.createFrom()
- // The default is the composition a new session gets, so it is the one
- // worth starting from — and a copy is writable wherever it came from.
- expect(draftOf(controller)).toMatchObject({ id: '', source: 'standard', creating: true, writable: true })
- expect(draftOf(controller).content).toBe('- id: tool-bash\n')
+ // 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 () => {
@@ -260,12 +262,12 @@ describe('creating a preset', () => {
expect(draftOf(controller)).toMatchObject({ source: 'mine', creating: true, content: '- id: tool-read\n' })
})
- it('does nothing before the roster loaded', async () => {
+ it('opens the blank editor without the roster, which it no longer reads', async () => {
const { controller, calls } = harness()
await controller.createFrom()
- expect(controller.store.getSnapshot().draft).toBeNull()
+ expect(draftOf(controller)).toMatchObject({ id: '', creating: true, content: '' })
expect(calls).toHaveLength(0)
})
})