diff --git a/apps/web/tests/agent-preset-authoring.e2e.ts b/apps/web/tests/agent-preset-authoring.e2e.ts index 2bf90b2105..62ee74be90 100644 --- a/apps/web/tests/agent-preset-authoring.e2e.ts +++ b/apps/web/tests/agent-preset-authoring.e2e.ts @@ -20,7 +20,7 @@ import { captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts' +import { ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/agent-preset-authoring', import.meta.url)) const SECTION_EXPECTED = join(SNAPSHOT_DIR, 'section.expected.md') @@ -174,6 +174,39 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => { expect(await dialog.getByText('标准模式').count()).toBeGreaterThan(0) }, 60_000) + it('starts a creator-mode session from the section', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-preset-authoring-creator')) + // Without a workspace the flow only stages (there is no session to land + // in until one is connected); connect first so the gesture carries all + // the way to a composed host session. + await settingsDialog().getByRole('button', { name: '关闭' }).last().click() + await connectFreshWorkspaceZh(page, scaffold.workspaceCwd) + await page.getByRole('button', { name: '设置', exact: true }).click() + const dialog = settingsDialog() + await dialog.waitFor({ timeout: 10_000 }) + await dialog.getByRole('button', { name: 'Agent 预设' }).click() + await dialog.getByRole('button', { name: '用「创造模式」创作自定义预设' }).click() + + // Leaving settings is part of the gesture: the flow lands on the + // new-session screen with the self-referential preset staged, and the + // blank session the flow produces composes from it on the host. + await dialog.waitFor({ state: 'detached', timeout: 10_000 }) + await page.getByRole('button', { name: '创造模式' }).waitFor({ timeout: 10_000 }) + await expect.poll(async () => { + const response = await fetch(`${scaffold.baseUrl}/api/session.list`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + type: 'client-request', rpcId: 'creator-draft-stage', method: 'session.list', payload: {}, + }), + }) + const body = await response.json() as { + result: { value?: { sessions: unknown[] } } + } + return JSON.stringify(body.result.value?.sessions ?? body.result) + }, { timeout: 15_000 }).toContain('"agentPreset":"cordis"') + }, 60_000) + it('drove every surface without a page error or a stream warning', () => { expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) diff --git a/apps/web/tests/snapshots/agent-preset-authoring/created.expected.md b/apps/web/tests/snapshots/agent-preset-authoring/created.expected.md index 46ab77a945..60ddeb1543 100644 --- a/apps/web/tests/snapshots/agent-preset-authoring/created.expected.md +++ b/apps/web/tests/snapshots/agent-preset-authoring/created.expected.md @@ -76,3 +76,6 @@ - paragraph: - text: 预设文件: - code: {{presetRoot}}/my-agent + - button "用「创造模式」创作自定义预设": + - img + - text: 用「创造模式」创作自定义预设 diff --git a/apps/web/tests/snapshots/agent-preset-authoring/section.expected.md b/apps/web/tests/snapshots/agent-preset-authoring/section.expected.md index 61df1d7b5a..ffdf835a78 100644 --- a/apps/web/tests/snapshots/agent-preset-authoring/section.expected.md +++ b/apps/web/tests/snapshots/agent-preset-authoring/section.expected.md @@ -58,3 +58,6 @@ - 'button "复制: 创造模式"': - img - text: 复制 + - button "用「创造模式」创作自定义预设": + - img + - text: 用「创造模式」创作自定义预设 diff --git a/packages/client/ui-agent-preset/README.i18n.yaml b/packages/client/ui-agent-preset/README.i18n.yaml index 00d6c41704..96c7a429f7 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: c43fbff7846153911d0dc5bd85935470b35dcbff -README.zh.md: 1fca73a5efe873cd92afb9881fbc41d5b3b42d43 +README.md: b3537da1a9607345a2d4edb73a7fa9a1f939f44f +README.zh.md: e0a1db85d3c2762660ebc3579e7a25e98a272390 diff --git a/packages/client/ui-agent-preset/README.md b/packages/client/ui-agent-preset/README.md index c43fbff784..b3537da1a9 100644 --- a/packages/client/ui-agent-preset/README.md +++ b/packages/client/ui-agent-preset/README.md @@ -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. diff --git a/packages/client/ui-agent-preset/README.zh.md b/packages/client/ui-agent-preset/README.zh.md index 1fca73a5ef..e0a1db85d3 100644 --- a/packages/client/ui-agent-preset/README.zh.md +++ b/packages/client/ui-agent-preset/README.zh.md @@ -36,6 +36,8 @@ chip 以部署默认值打开,其选择是**暂存**的——该界面先于 随附 preset 在只读查看器中打开。它是副本据以出发的已知良好组装,因此能读到它正是意义所在;它不提供位置也不提供删除——它的安装目录会被升级覆盖,不归用户管理。开篇引导语承担了从前创建按钮所暗示的信息:想从最小的骨架开始,就复制极简模式(31 行,对照 standard 的 233 行)。 +复制旁边是对话式入口:名单携带自指的 `cordis` preset 时,一张虚线添加卡(模型页的同款样式)会暂存它并开启新会话——分区经外壳的 owner-prop `close` 关闭设置面板,新会话 chip 自己的应用器负责组装工作区流程产出的空白会话。seat 会防止晚到的名单加载回退显示:暂存选择优先,其次是当前会话已携带的组装,最后才是部署默认值。 + 对话框复刻宿主自身的约束规则(`[a-z0-9][a-z0-9-]*`),并拒绝已被占用的名称——复制从不覆写。这两项检查只是便利:宿主会重新校验,失败时对话框报告的正是宿主的答复。 删除会移除整个 preset 目录。已据其组装的会话继续运行——组装在会话创建时挂载一次,此后没有任何东西会重新读取该文件。 diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css b/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css index 8851ae4efe..ed8a860078 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css @@ -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; +} diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx index 8e4557dbf3..9c0861694b 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx @@ -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 /** Open one preset's directory, or reveal its path where there is no desktop. */ openLocation: (id: string) => Promise + /** + * 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) }} > - + )} + ) + : null} { 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), diff --git a/packages/client/ui-agent-preset/src/client/locales.ts b/packages/client/ui-agent-preset/src/client/locales.ts index aee01bce87..122b22024c 100644 --- a/packages/client/ui-agent-preset/src/client/locales.ts +++ b/packages/client/ui-agent-preset/src/client/locales.ts @@ -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 = { + '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 = { copyIntro: '整个预设会在本机复制一份。标识符将成为目录名,事后无法更改;其余内容之后直接在预设自己的文件里编辑。', create: '创建', creating: '正在创建…', + creatorDraft: '用「创造模式」创作自定义预设', openLocation: '打开目录', showLocation: '查看路径', revealedPathLabel: '预设文件:', diff --git a/packages/client/ui-agent-preset/src/client/seat-store.ts b/packages/client/ui-agent-preset/src/client/seat-store.ts index 8c200dd32f..27a414e4a3 100644 --- a/packages/client/ui-agent-preset/src/client/seat-store.ts +++ b/packages/client/ui-agent-preset/src/client/seat-store.ts @@ -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 { 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() } /** diff --git a/packages/client/ui-agent-preset/tests/apply.spec.ts b/packages/client/ui-agent-preset/tests/apply.spec.ts index a022f3e71b..6272501183 100644 --- a/packages/client/ui-agent-preset/tests/apply.spec.ts +++ b/packages/client/ui-agent-preset/tests/apply.spec.ts @@ -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 + } = { 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() + }) }) diff --git a/packages/client/ui-agent-preset/tests/section.spec.tsx b/packages/client/ui-agent-preset/tests/section.spec.tsx index 6496f86591..cd365667bd 100644 --- a/packages/client/ui-agent-preset/tests/section.spec.tsx +++ b/packages/client/ui-agent-preset/tests/section.spec.tsx @@ -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 = {}) { +function renderSection( + state: Partial = {}, + options: { creator?: boolean } = {}, +) { const store = createSnapshotStore({ ...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' }) diff --git a/packages/client/ui-settings-general/tests/components.spec.tsx b/packages/client/ui-settings-general/tests/components.spec.tsx index 447dd7e9c7..874bd44630 100644 --- a/packages/client/ui-settings-general/tests/components.spec.tsx +++ b/packages/client/ui-settings-general/tests/components.spec.tsx @@ -46,7 +46,7 @@ describe('GeneralSection', () => { const renderSlot = vi.fn( ((key: string) =>
) as GeneralSectionComponentProps['renderSlot'], ) - const props: GeneralSectionComponentProps = { ...kit, renderSlot } + const props: GeneralSectionComponentProps = { ...kit, renderSlot, close: vi.fn() } const view = render() return { view, renderSlot } } diff --git a/packages/client/ui-settings/src/client/SettingsRoot.tsx b/packages/client/ui-settings/src/client/SettingsRoot.tsx index a79d31bfc1..54e0e0dbb7 100644 --- a/packages/client/ui-settings/src/client/SettingsRoot.tsx +++ b/packages/client/ui-settings/src/client/SettingsRoot.tsx @@ -87,7 +87,7 @@ function SettingsPanel({ rows, renderSlot, activeId, onSelect, onClose }: PanelP
- {active !== undefined && renderSlot('settings.section', {}, { only: active })} + {active !== undefined && renderSlot('settings.section', { close: onClose }, { only: active })}
diff --git a/packages/client/ui-settings/src/client/contract/slots.ts b/packages/client/ui-settings/src/client/contract/slots.ts index 8516ce8860..e5810e8665 100644 --- a/packages/client/ui-settings/src/client/contract/slots.ts +++ b/packages/client/ui-settings/src/client/contract/slots.ts @@ -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. */