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:
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user