diff --git a/packages/client/ui-agent-preset/src/client/index.ts b/packages/client/ui-agent-preset/src/client/index.ts index 9b6a008c55..a3141dcc68 100644 --- a/packages/client/ui-agent-preset/src/client/index.ts +++ b/packages/client/ui-agent-preset/src/client/index.ts @@ -51,7 +51,13 @@ export const inject = ['slots', 'locale', 'connection'] export function apply(ctx: ClientContext): void { const { api } = ctx.get('connection') as ConnectionHandle const controller = new AgentPresetSettingsController(api) - const section = new AgentPresetSectionController(api) + // One roster, four surfaces. The chip is registered in a later scope, so it + // subscribes here rather than being reached from this one. + const rosterReaders = new Set<() => void>() + const section = new AgentPresetSectionController(api, () => { + void controller.load() + for (const read of rosterReaders) read() + }) ctx.effect(() => ctx.locale.register('settings.agentPreset', { zh, en }), 'ui-agent-preset: settings row dictionaries') @@ -119,6 +125,12 @@ export function apply(ctx: ClientContext): void { if (ns !== undefined && ns !== AGENT_PRESET_SETTINGS_NS) return void seat.load() }) + // Authoring writes a FILE, not a setting, so nothing on the wire + // announces it — without this the screen that starts the next session + // keeps offering the roster as it stood when the chip first loaded, and + // a preset authored to be used is missing from the one place it is used. + const readRoster = (): void => { void seat.load() } + rosterReaders.add(readRoster) const chip = scope.slots.register({ name: 'conversation.hero.agentPreset', locale: 'settings.agentPreset', @@ -134,6 +146,7 @@ export function apply(ctx: ClientContext): void { return () => { stop() settingsMoved() + rosterReaders.delete(readRoster) chip() label() } diff --git a/packages/client/ui-agent-preset/src/client/section-store.ts b/packages/client/ui-agent-preset/src/client/section-store.ts index c381da4520..f57c502f52 100644 --- a/packages/client/ui-agent-preset/src/client/section-store.ts +++ b/packages/client/ui-agent-preset/src/client/section-store.ts @@ -107,7 +107,17 @@ export class AgentPresetSectionController { /** Page snapshot the renderer subscribes to. */ readonly store: SnapshotStore = createSnapshotStore(INITIAL) - constructor(private readonly api: Pick) {} + constructor( + private readonly api: Pick, + /** + * 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. + */ + private readonly rosterChanged: () => void = () => {}, + ) {} private set(patch: Partial): void { this.store.set({ ...this.store.getSnapshot(), ...patch }) @@ -265,6 +275,7 @@ export class AgentPresetSectionController { } this.set({ draft: null }) await this.load() + this.rosterChanged() } catch (error) { this.patchDraft({ saving: false, error: messageOf(error) }) } @@ -303,6 +314,7 @@ export class AgentPresetSectionController { draft: draft?.id === pendingDelete && !draft.creating ? null : draft, }) await this.load() + this.rosterChanged() } catch (error) { this.set({ deleting: false, pendingDelete: null, error: messageOf(error) }) } diff --git a/packages/client/ui-agent-preset/tests/apply.spec.ts b/packages/client/ui-agent-preset/tests/apply.spec.ts index d780af9e55..becd5c1d8c 100644 --- a/packages/client/ui-agent-preset/tests/apply.spec.ts +++ b/packages/client/ui-agent-preset/tests/apply.spec.ts @@ -30,6 +30,21 @@ const ROSTER_ONE = { result: { ok: true as const, value: { presets: [{ id: 'standard', trust: 'system', isDefault: true }], authorable: true } }, } +/** The roster after this browser authored one preset of its own. */ +const ROSTER_AUTHORED = { + rpcId: 'r', + result: { + ok: true as const, + value: { + presets: [ + { id: 'standard', trust: 'system', isDefault: true }, + { id: 'mine', trust: 'user', isDefault: false }, + ], + authorable: true, + }, + }, +} + /** The same roster with a second preset carrying the default. */ const ROSTER_MOVED = { rpcId: 'r', @@ -49,7 +64,7 @@ async function bench() { const ctx = new Context() // The host's answer, mutable so a spec can move the default the way the // settings surface does and watch who re-reads it. - let ROSTER: typeof ROSTER_ONE | typeof ROSTER_MOVED = ROSTER_ONE + let ROSTER: typeof ROSTER_ONE | typeof ROSTER_MOVED | typeof ROSTER_AUTHORED = ROSTER_ONE const moveDefault = (): void => { ROSTER = ROSTER_MOVED } await ctx.plugin(SlotsService).await() const locale = new LocaleService(ctx) @@ -63,7 +78,13 @@ async function bench() { rpcId: 'r', result: { ok: true as const, value: { agentPreset: 'standard', trust: 'system', content: '', writable: false } }, }), - write: () => Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: { agentPreset: 'standard' } } }), + write: (payload: { agentPreset: string }) => { + calls.push(`write:${payload.agentPreset}`) + // The host's roster now contains it, which is the whole point of the + // write and what every surface must converge on. + ROSTER = ROSTER_AUTHORED + return Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: { agentPreset: payload.agentPreset } } }) + }, remove: () => Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: {} } }), select: (payload: { agentPreset: string }) => { calls.push(`select:${payload.agentPreset}`) @@ -282,6 +303,35 @@ describe('ui-agent-preset apply', () => { conversation() }) + it('offers a just-authored preset on the new-session chip', async () => { + const { ctx, slots } = await bench() + declareRoot(slots) + 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() + + const chip = slots.entries('conversation.hero.agentPreset')[0]! + const seat = (chip.inject as unknown as () => AgentPresetSeatInjected)() + await seat.load() + expect(seat.hooks.agentPresetSeat.getSnapshot().options.map(option => option.id)).toEqual(['standard']) + + const section = (slots.entries('settings.section')[0]!.inject as unknown as () => AgentPresetSectionInjected)() + await section.load() + await section.createFrom() + section.setId('mine') + section.setName('我的模式') + await section.save() + + // Authoring writes a file rather than a setting, so nothing on the wire + // announces it: a preset authored to be used must appear on the one screen + // that starts sessions, without a reload. + await vi.waitFor(() => { + expect(seat.hooks.agentPresetSeat.getSnapshot().options.map(option => option.id)).toEqual(['standard', 'mine']) + }) + conversation() + }) + it('applies the staged choice to the blank session the flow lands on', async () => { const { ctx, slots, calls } = await bench() declareRoot(slots)