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:
@@ -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: c43fbff7846153911d0dc5bd85935470b35dcbff
|
||||
README.zh.md: 1fca73a5efe873cd92afb9881fbc41d5b3b42d43
|
||||
README.md: b3537da1a9607345a2d4edb73a7fa9a1f939f44f
|
||||
README.zh.md: e0a1db85d3c2762660ebc3579e7a25e98a272390
|
||||
|
||||
@@ -36,6 +36,8 @@ The browser edits no composition text. Editing YAML in a web textarea was a weak
|
||||
|
||||
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).
|
||||
|
||||
Beside copying sits the conversational entry: when the roster carries the self-referential `cordis` preset, a dashed add-card (the Models page's affordance) stages it and starts a new session — the section closes the settings panel through the shell's owner-prop `close` and the new-session chip's own applier composes the blank session the workspace flow produces. The seat keeps a late roster load from regressing the display: staged pick first, then the composition the current session already carries, then the deployment default.
|
||||
|
||||
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.
|
||||
|
||||
@@ -36,6 +36,8 @@ chip 以部署默认值打开,其选择是**暂存**的——该界面先于
|
||||
|
||||
随附 preset 在只读查看器中打开。它是副本据以出发的已知良好组装,因此能读到它正是意义所在;它不提供位置也不提供删除——它的安装目录会被升级覆盖,不归用户管理。开篇引导语承担了从前创建按钮所暗示的信息:想从最小的骨架开始,就复制极简模式(31 行,对照 standard 的 233 行)。
|
||||
|
||||
复制旁边是对话式入口:名单携带自指的 `cordis` preset 时,一张虚线添加卡(模型页的同款样式)会暂存它并开启新会话——分区经外壳的 owner-prop `close` 关闭设置面板,新会话 chip 自己的应用器负责组装工作区流程产出的空白会话。seat 会防止晚到的名单加载回退显示:暂存选择优先,其次是当前会话已携带的组装,最后才是部署默认值。
|
||||
|
||||
对话框复刻宿主自身的约束规则(`[a-z0-9][a-z0-9-]*`),并拒绝已被占用的名称——复制从不覆写。这两项检查只是便利:宿主会重新校验,失败时对话框报告的正是宿主的答复。
|
||||
|
||||
删除会移除整个 preset 目录。已据其组装的会话继续运行——组装在会话创建时挂载一次,此后没有任何东西会重新读取该文件。
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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: '预设文件:',
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -139,6 +139,15 @@ function declareConversation(slots: SlotsService): () => void {
|
||||
} as never, () => null)
|
||||
}
|
||||
|
||||
/** A workspaces double recording new-session starts. */
|
||||
function workspacesDouble() {
|
||||
const starts: unknown[] = []
|
||||
return {
|
||||
starts,
|
||||
startSession: (workspaceId?: unknown) => { starts.push(workspaceId ?? null) },
|
||||
}
|
||||
}
|
||||
|
||||
/** A sessions double whose list can be moved and whose changes are pushed. */
|
||||
function sessionsDouble(state: {
|
||||
current?: string
|
||||
@@ -287,7 +296,8 @@ describe('ui-agent-preset apply', () => {
|
||||
const conversation = declareConversation(slots)
|
||||
ctx.provide('conversation', {} as never)
|
||||
ctx.provide('sessions', sessionsDouble({ byId: {} }) as never)
|
||||
const fiber = ctx.plugin({ inject: [...inject, 'conversation', 'sessions'], apply })
|
||||
ctx.provide('workspaces', workspacesDouble() as never)
|
||||
const fiber = ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'workspaces'], apply })
|
||||
await fiber.await()
|
||||
|
||||
const chip = slots.entries('conversation.hero.agentPreset')[0]!
|
||||
@@ -308,7 +318,8 @@ describe('ui-agent-preset apply', () => {
|
||||
const conversation = declareConversation(slots)
|
||||
ctx.provide('conversation', {} as never)
|
||||
ctx.provide('sessions', sessionsDouble({ byId: {} }) as never)
|
||||
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions'], apply }).await()
|
||||
ctx.provide('workspaces', workspacesDouble() as never)
|
||||
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'workspaces'], apply }).await()
|
||||
|
||||
const chip = slots.entries('conversation.hero.agentPreset')[0]!
|
||||
const seat = (chip.inject as unknown as () => AgentPresetSeatInjected)()
|
||||
@@ -339,7 +350,8 @@ describe('ui-agent-preset apply', () => {
|
||||
const conversation = declareConversation(slots)
|
||||
ctx.provide('conversation', {} as never)
|
||||
ctx.provide('sessions', sessionsDouble({ byId: {} }) as never)
|
||||
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions'], apply }).await()
|
||||
ctx.provide('workspaces', workspacesDouble() as never)
|
||||
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'workspaces'], apply }).await()
|
||||
|
||||
const chip = slots.entries('conversation.hero.agentPreset')[0]!
|
||||
const seat = (chip.inject as unknown as () => AgentPresetSeatInjected)()
|
||||
@@ -373,7 +385,8 @@ describe('ui-agent-preset apply', () => {
|
||||
} = { byId: {} }
|
||||
const sessions = sessionsDouble(state)
|
||||
ctx.provide('sessions', sessions as never)
|
||||
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions'], apply }).await()
|
||||
ctx.provide('workspaces', workspacesDouble() as never)
|
||||
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'workspaces'], apply }).await()
|
||||
const chip = (slots.entries('conversation.hero.agentPreset')[0]!
|
||||
.inject as unknown as () => AgentPresetSeatInjected)()
|
||||
|
||||
@@ -400,7 +413,8 @@ describe('ui-agent-preset apply', () => {
|
||||
byId: { s1: { id: 's1', blank: true } },
|
||||
})
|
||||
ctx.provide('sessions', sessions as never)
|
||||
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions'], apply }).await()
|
||||
ctx.provide('workspaces', workspacesDouble() as never)
|
||||
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'workspaces'], apply }).await()
|
||||
const chip = (slots.entries('conversation.hero.agentPreset')[0]!
|
||||
.inject as unknown as () => AgentPresetSeatInjected)()
|
||||
|
||||
@@ -423,7 +437,8 @@ describe('ui-agent-preset apply', () => {
|
||||
}
|
||||
const sessions = sessionsDouble(state)
|
||||
ctx.provide('sessions', sessions as never)
|
||||
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions'], apply }).await()
|
||||
ctx.provide('workspaces', workspacesDouble() as never)
|
||||
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'workspaces'], apply }).await()
|
||||
const chip = (slots.entries('conversation.hero.agentPreset')[0]!
|
||||
.inject as unknown as () => AgentPresetSeatInjected)()
|
||||
|
||||
@@ -445,7 +460,8 @@ describe('ui-agent-preset apply', () => {
|
||||
declareConversation(slots)
|
||||
ctx.provide('conversation', {} as never)
|
||||
ctx.provide('sessions', sessionsDouble({ byId: {} }) as never)
|
||||
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions'], apply }).await()
|
||||
ctx.provide('workspaces', workspacesDouble() as never)
|
||||
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'workspaces'], apply }).await()
|
||||
const label = (slots.entries('conversation.session.header.actions')[0]!
|
||||
.inject as unknown as () => AgentPresetLabelInjected)()
|
||||
const row = (slots.entries('settings.general.item')[0]!
|
||||
@@ -458,4 +474,73 @@ describe('ui-agent-preset apply', () => {
|
||||
expect(label.hooks.agentPresets).toBe(row.hooks.agentPreset)
|
||||
expect(label.hooks.agentPresets.getSnapshot().options).toEqual([{ id: 'standard', trust: 'system' }])
|
||||
})
|
||||
|
||||
it('stages the creator preset and starts a session from the section', async () => {
|
||||
const { ctx, slots } = await bench()
|
||||
declareRoot(slots)
|
||||
const conversation = declareConversation(slots)
|
||||
ctx.provide('conversation', {} as never)
|
||||
ctx.provide('sessions', sessionsDouble({ byId: {} }) as never)
|
||||
const workspaces = workspacesDouble()
|
||||
ctx.provide('workspaces', workspaces as never)
|
||||
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'workspaces'], apply }).await()
|
||||
const section = (slots.entries('settings.section')[0]!.inject as unknown as () => AgentPresetSectionInjected)()
|
||||
const seat = (slots.entries('conversation.hero.agentPreset')[0]!
|
||||
.inject as unknown as () => AgentPresetSeatInjected)()
|
||||
|
||||
section.startCreatorDraft?.()
|
||||
|
||||
// The pick is staged on the chip's own controller — the session the
|
||||
// workspace start produces is what the stage lands on — and exactly one
|
||||
// new-session flow began.
|
||||
expect(section.startCreatorDraft).toBeDefined()
|
||||
expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('cordis')
|
||||
expect(workspaces.starts).toHaveLength(1)
|
||||
conversation()
|
||||
})
|
||||
|
||||
it('keeps the applied composition when the roster load lands late', async () => {
|
||||
const { ctx, slots, calls } = await bench()
|
||||
declareRoot(slots)
|
||||
const conversation = declareConversation(slots)
|
||||
ctx.provide('conversation', {} as never)
|
||||
const state: {
|
||||
current?: string
|
||||
byId: Record<string, { id: string; blank: boolean; agentPreset?: string }>
|
||||
} = { byId: {} }
|
||||
const sessions = sessionsDouble(state)
|
||||
ctx.provide('sessions', sessions as never)
|
||||
ctx.provide('workspaces', workspacesDouble() as never)
|
||||
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'workspaces'], apply }).await()
|
||||
const section = (slots.entries('settings.section')[0]!.inject as unknown as () => AgentPresetSectionInjected)()
|
||||
const seat = (slots.entries('conversation.hero.agentPreset')[0]!
|
||||
.inject as unknown as () => AgentPresetSeatInjected)()
|
||||
|
||||
section.startCreatorDraft?.()
|
||||
state.current = 's1'
|
||||
state.byId['s1'] = { id: 's1', blank: true }
|
||||
sessions.notify()
|
||||
await vi.waitFor(() => { expect(calls).toContain('select:cordis') })
|
||||
|
||||
// The chip mounts with the flow's session, so its roster load can land
|
||||
// AFTER the stage was consumed; the session's own composition is what
|
||||
// the display must keep — not the deployment default.
|
||||
state.byId['s1'] = { id: 's1', blank: true, agentPreset: 'cordis' }
|
||||
await seat.load()
|
||||
|
||||
expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('cordis')
|
||||
conversation()
|
||||
})
|
||||
|
||||
it('offers no creator draft while the conversation flow is absent', async () => {
|
||||
const { ctx, slots } = await bench()
|
||||
declareRoot(slots)
|
||||
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
|
||||
// No conversation scope mounted: the face omits the affordance and the
|
||||
// section hides its button rather than staging into nowhere.
|
||||
const section = (slots.entries('settings.section')[0]!.inject as unknown as () => AgentPresetSectionInjected)()
|
||||
expect(section.startCreatorDraft).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -38,10 +38,16 @@ const READY: AgentPresetSectionState = {
|
||||
* @param state - the snapshot to render.
|
||||
* @returns the spies, so a test can assert what a click reached.
|
||||
*/
|
||||
function renderSection(state: Partial<AgentPresetSectionState> = {}) {
|
||||
function renderSection(
|
||||
state: Partial<AgentPresetSectionState> = {},
|
||||
options: { creator?: boolean } = {},
|
||||
) {
|
||||
const store = createSnapshotStore<AgentPresetSectionState>({ ...READY, ...state })
|
||||
const actions = {
|
||||
load: vi.fn(() => Promise.resolve()),
|
||||
// The shell-owned section affordance (SettingsSectionOwnerProps.close).
|
||||
close: vi.fn(),
|
||||
...options.creator === false ? {} : { startCreatorDraft: vi.fn() },
|
||||
view: vi.fn(() => Promise.resolve()),
|
||||
closeView: vi.fn(),
|
||||
beginCopy: vi.fn(),
|
||||
@@ -196,6 +202,40 @@ describe('the preset list', () => {
|
||||
expect(actions.view).toHaveBeenCalledWith('standard')
|
||||
})
|
||||
|
||||
it('starts a creator-mode draft session and leaves settings', () => {
|
||||
const actions = renderSection({
|
||||
rows: [...READY.rows, { id: 'cordis', trust: 'system', isDefault: false, name: '创造模式' }],
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: en.creatorDraft }))
|
||||
|
||||
expect(actions.startCreatorDraft).toHaveBeenCalledTimes(1)
|
||||
// Leaving settings is part of the gesture: the flow lands in the new
|
||||
// session, not behind the modal.
|
||||
expect(actions.close).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('hides the creator entry without the flow or the preset, disables it without a root', () => {
|
||||
renderSection()
|
||||
expect(screen.queryByRole('button', { name: en.creatorDraft })).toBeNull()
|
||||
cleanup()
|
||||
|
||||
renderSection({
|
||||
rows: [...READY.rows, { id: 'cordis', trust: 'system', isDefault: false, name: '创造模式' }],
|
||||
}, { creator: false })
|
||||
expect(screen.queryByRole('button', { name: en.creatorDraft })).toBeNull()
|
||||
cleanup()
|
||||
|
||||
const actions = renderSection({
|
||||
authorable: false,
|
||||
rows: [...READY.rows, { id: 'cordis', trust: 'system', isDefault: false, name: '创造模式' }],
|
||||
})
|
||||
const disabled = screen.getByRole('button', { name: en.creatorDraft })
|
||||
expect(disabled).toHaveProperty('disabled', true)
|
||||
fireEvent.click(disabled)
|
||||
expect(actions.startCreatorDraft).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows a page-level failure without hiding the list', () => {
|
||||
renderSection({ error: 'settings are read-only' })
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ describe('GeneralSection', () => {
|
||||
const renderSlot = vi.fn(
|
||||
((key: string) => <div data-testid={`slot-${key}`} />) as GeneralSectionComponentProps['renderSlot'],
|
||||
)
|
||||
const props: GeneralSectionComponentProps = { ...kit, renderSlot }
|
||||
const props: GeneralSectionComponentProps = { ...kit, renderSlot, close: vi.fn() }
|
||||
const view = render(<GeneralSection {...props} />)
|
||||
return { view, renderSlot }
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ function SettingsPanel({ rows, renderSlot, activeId, onSelect, onClose }: PanelP
|
||||
</button>
|
||||
</div>
|
||||
<div className={css.options}>
|
||||
{active !== undefined && renderSlot('settings.section', {}, { only: active })}
|
||||
{active !== undefined && renderSlot('settings.section', { close: onClose }, { only: active })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -83,12 +83,14 @@ export interface SettingsHeaderOwnerProps {
|
||||
|
||||
/**
|
||||
* Owner share of a settings section entry. The shell owns modal visibility
|
||||
* and navigation; sections receive nothing but the render site (their data
|
||||
* arrives through their own inject faces and stores).
|
||||
* and navigation; a section's data arrives through its own inject faces and
|
||||
* stores. `close` is the one shell affordance a section receives, for flows
|
||||
* that leave settings altogether (starting a session from a section) — the
|
||||
* onboarding coordinator's `openSection`/`complete` precedent, inverted.
|
||||
*/
|
||||
export interface SettingsSectionOwnerProps {
|
||||
/** Marker field: section owner props are intentionally empty for now. */
|
||||
children?: never
|
||||
/** Close the settings panel (the shell owns the open state). */
|
||||
close: () => void
|
||||
}
|
||||
|
||||
/** Owner share of the currently active settings-backed onboarding step. */
|
||||
|
||||
Reference in New Issue
Block a user