feat(web): draft a custom preset in a creator-mode session

The settings section gains the conversational authoring entry beside
copying: a dashed add-card (the Models page's affordance) that stages the
self-referential cordis preset and starts a new session on it, closing the
settings panel through the shell's new section owner-prop `close` — the
one shell affordance a section receives, for flows that leave settings.

The seat learns the difference between picking and staging: `select()`'s
immediate apply meets the still-current running session and drops the
stage as unservable, so the entry uses `stage()` and leaves the apply to
the list-change applier that fires when the started session becomes
current. `load()` stops regressing the display when its reply lands after
an applied stage was consumed — staged pick first, then the composition
the current session already carries, then the deployment default.

The authoring lane connects a workspace and drives the gesture to a
composed host session; the section goldens gain the entry.
This commit is contained in:
Yichen Jiang
2026-08-09 00:19:18 +08:00
parent 564337997a
commit 43e93c6a67
16 changed files with 288 additions and 23 deletions

View File

@@ -329,3 +329,31 @@
.deleteConfirm:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover-danger);
}
/* The conversational authoring entry, after the card grid in the spot the
create button vacated. Dashed like the Models page's add affordances: it
reads as a place a preset will appear, not a command. */
.creatorButton {
align-self: stretch;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
height: 44px;
border: 1px dashed var(--dsw-alias-border-l3);
border-radius: 12px;
font: inherit;
font-size: 13px;
background: none;
color: inherit;
cursor: pointer;
}
.creatorButton:hover:not(:disabled) {
background: var(--dsw-alias-bg-layer-1);
}
.creatorButton:disabled {
opacity: 0.5;
cursor: default;
}

View File

@@ -13,7 +13,7 @@
import { useEffect } from 'react'
import type { ReactNode } from 'react'
import {
Button, IconBrowseOutline16, IconCopyOutline16, IconFolderOpen16, IconTrashOutline16, Modal,
Button, IconBrowseOutline16, IconCopyOutline16, IconFolderOpenOutline16, IconPlusOutline16, 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'
@@ -45,6 +45,12 @@ export interface AgentPresetSectionInjected {
confirmCopy: () => Promise<void>
/** Open one preset's directory, or reveal its path where there is no desktop. */
openLocation: (id: string) => Promise<void>
/**
* Stage the self-referential preset and start a new session on it — the
* guided way to author a preset, beside copying. Absent when the surface
* is composed without the conversation flow to land the session in.
*/
startCreatorDraft?: () => void
/** Ask for delete confirmation, or dismiss it with null. */
confirmDelete: (id: string | null) => void
/** Delete the preset awaiting confirmation. */
@@ -222,7 +228,7 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {
aria-label={`${state.hasDocument ? t('openLocation') : t('showLocation')}: ${row.name ?? row.id}`}
onClick={() => { void props.openLocation(row.id) }}
>
<IconFolderOpen16 />
<IconFolderOpenOutline16 />
</button>
)}
<button
@@ -263,6 +269,29 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {
</section>
)
})}
{/* The guided alternative to copying: the self-referential preset can
read this very composition and author a new one in conversation.
Offered only where that preset is actually on the roster and a
session can be landed; without a writable root the draft could
never be discovered, so the reason rides the disabled button. */}
{props.startCreatorDraft !== undefined && state.rows.some(row => row.id === 'cordis')
? (
<button
type="button"
className={css.creatorButton}
disabled={!state.authorable}
title={state.authorable ? undefined : t('duplicateUnavailable')}
onClick={() => {
props.startCreatorDraft?.()
props.close()
}}
>
{/* Same glyph as the Models page's add affordances. */}
<IconPlusOutline16 size={14} />
{t('creatorDraft')}
</button>
)
: null}
<CopyDialog
state={state}
t={t}

View File

@@ -85,9 +85,16 @@ export function apply(ctx: ClientContext): void {
return () => { for (const dispose of disposers) dispose() }
}, 'ui-agent-preset: settings refresh')
// The settings section's conversational authoring entry: stage the
// self-referential preset and land a new session on it. Bound inside the
// conversation scope below (the seat and the session flow live there) and
// unbound with it, so the section's face reads the current binding per
// render and simply hides the button while no flow exists.
let creatorDraft: (() => void) | undefined
// The new-session chip and the header label: one controller, because the
// staged choice belongs to the flow rather than to any one session.
ctx.inject(['slots', 'conversation', 'sessions'], (scope: ClientContext) => {
ctx.inject(['slots', 'conversation', 'sessions', 'workspaces'], (scope: ClientContext) => {
const api = (scope.get('connection') as ConnectionHandle).api
const seat = new AgentPresetSeatController(api, (): SeatSessionSummary | undefined => {
const state = scope.sessions.list.getSnapshot()
@@ -134,6 +141,14 @@ export function apply(ctx: ClientContext): void {
// a preset authored to be used is missing from the one place it is used.
const readRoster = (): void => { void seat.load() }
rosterReaders.add(readRoster)
// Stage WITHOUT applying — the still-current running session would
// refuse the swap and drop the stage — then start the session it lands
// on: the chip's list-change applier composes the blank session the
// workspace connect produces or reuses.
creatorDraft = () => {
seat.stage('cordis')
scope.workspaces.startSession()
}
const chip = scope.slots.register({
name: 'conversation.hero.agentPreset',
locale: 'settings.agentPreset',
@@ -150,6 +165,7 @@ export function apply(ctx: ClientContext): void {
stop()
settingsMoved()
rosterReaders.delete(readRoster)
creatorDraft = undefined
chip()
label()
}
@@ -167,6 +183,7 @@ export function apply(ctx: ClientContext): void {
setCopyName: (name: string) => { section.setCopyName(name) },
confirmCopy: () => section.confirmCopy(),
openLocation: (id: string) => section.openLocation(id),
...creatorDraft === undefined ? {} : { startCreatorDraft: creatorDraft },
confirmDelete: (id: string | null) => { section.confirmDelete(id) },
remove: () => section.remove(),
makeDefault: (id: string) => section.makeDefault(id),

View File

@@ -8,7 +8,7 @@ export type AgentPresetSettingsKey =
| 'displayName' | 'displayNamePlaceholder'
| 'inUse' | 'noDescription' | 'builtInGroup' | 'customGroup'
| 'composition' | 'cancel' | 'close' | 'retry'
| 'copyTitle' | 'copyIntro' | 'create' | 'creating'
| 'copyTitle' | 'copyIntro' | 'create' | 'creating' | 'creatorDraft'
| 'openLocation' | 'showLocation' | 'revealedPathLabel'
| 'idRequired' | 'idInvalid' | 'idTaken'
| 'deleteTitle' | 'deleteDescription' | 'deleteConfirm' | 'deleting'
@@ -52,6 +52,7 @@ export const en: Record<AgentPresetSettingsKey, string> = {
+ 'be changed later; everything else is edited in the preset\'s own files.',
create: 'Create',
creating: 'Creating…',
creatorDraft: 'Draft a custom preset with Creator mode',
openLocation: 'Open folder',
showLocation: 'Show location',
revealedPathLabel: 'Preset files:',
@@ -100,6 +101,7 @@ export const zh: Record<AgentPresetSettingsKey, string> = {
copyIntro: '整个预设会在本机复制一份。标识符将成为目录名,事后无法更改;其余内容之后直接在预设自己的文件里编辑。',
create: '创建',
creating: '正在创建…',
creatorDraft: '用「创造模式」创作自定义预设',
openLocation: '打开目录',
showLocation: '查看路径',
revealedPathLabel: '预设文件:',

View File

@@ -87,7 +87,13 @@ export class AgentPresetSeatController {
this.fallback = presets.find(preset => preset.isDefault)?.id ?? presets[0]?.id ?? ''
this.set({
options: presetOptions(presets),
current: this.staged ?? this.fallback,
// Staged pick first, then the composition the current session
// already carries, then the deployment default. The middle term is
// what keeps a late-landing load from regressing the display after
// an applied stage was consumed — the chip mounts (and loads) only
// once the flow's session is current, so the reply can arrive after
// apply() already composed it.
current: this.staged ?? this.currentSession()?.agentPreset ?? this.fallback,
error: null,
})
} catch (error) {
@@ -103,9 +109,22 @@ export class AgentPresetSeatController {
*/
async select(id: string): Promise<void> {
if (this.store.getSnapshot().busy) return
this.stage(id)
await this.apply()
}
/**
* Stage a pick WITHOUT the immediate apply, for a flow that starts the
* receiving session after the pick (the settings section's creator entry).
* `select()`'s immediate apply would meet the still-current running session
* and drop the stage as unservable; staging alone leaves it for the
* list-change applier, which fires when the started session becomes
* current.
* @param id - the preset to stage.
*/
stage(id: string): void {
this.staged = id
this.set({ current: id, error: null })
await this.apply()
}
/**