diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx index 41f22e12c0..c2036be0d6 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx @@ -93,12 +93,12 @@ export function AgentPresetSeat({ void load() }, [load]) + const options = state.options.filter(option => developerTools || !requiresCodingTools(option)) + // Close a menu whose available choices just changed. useEffect(() => { setOpen(false) - }, [developerTools]) - - const options = state.options.filter(option => developerTools || !requiresCodingTools(option)) + }, [developerTools, options.length]) const chosen = state.options.find(option => option.id === state.current) const chosenText = chosen === undefined ? undefined : presetDisplayText(chosen, t) @@ -152,7 +152,7 @@ export function AgentPresetSeat({ return ( <> 0} onClose={() => { setOpen(false) }} items={options.map((option) => { const text = presetDisplayText(option, t) @@ -193,9 +193,9 @@ export function AgentPresetSeat({ type="button" className={css.seat} aria-haspopup="menu" - aria-expanded={open} + aria-expanded={open && options.length > 0} title={state.error ?? t('seatHint')} - disabled={state.busy} + disabled={state.busy || options.length === 0} onClick={() => { setOpen(value => !value) }} > diff --git a/packages/client/ui-agent-preset/src/client/index.ts b/packages/client/ui-agent-preset/src/client/index.ts index 0ff47445b6..b2f0b1fd87 100644 --- a/packages/client/ui-agent-preset/src/client/index.ts +++ b/packages/client/ui-agent-preset/src/client/index.ts @@ -135,12 +135,14 @@ export function apply(ctx: ClientContext): void { const reconcile = (): void => { requested++ pending ??= Promise.resolve().then(async () => { - for (;;) { - const revision = requested - await section.reconcileCodingTools(codingToolsDisabled) - if (!active || requested === revision) return - } - }).finally(() => { pending = undefined }) + try { + for (;;) { + const revision = requested + await section.reconcileCodingTools(codingToolsDisabled) + if (!active || requested === revision) return + } + } finally { pending = undefined } + }) } // The roster reflects live declarations and the default is a settings field, so // both an external settings edit and a reconnect can move this row. diff --git a/packages/client/ui-agent-preset/tests/apply.client.spec.ts b/packages/client/ui-agent-preset/tests/apply.client.spec.ts index d8b8ffd41a..8eb7a065fa 100644 --- a/packages/client/ui-agent-preset/tests/apply.client.spec.ts +++ b/packages/client/ui-agent-preset/tests/apply.client.spec.ts @@ -22,6 +22,7 @@ import type { AgentPresetSectionInjected } from '../src/client/AgentPresetSectio import { AgentPresetSeat } from '../src/client/AgentPresetSeat.tsx' import type { AgentPresetSeatInjected } from '../src/client/AgentPresetSeat.tsx' import { AgentPresetSeatController } from '../src/client/seat-store.ts' +import { AgentPresetSectionController } from '../src/client/section-store.ts' import { apply as hostApply } from '../src/index.ts' import type { AgentPresetRow } from '@deepseek-ai/dsh-agent-preset-registry/types' @@ -897,6 +898,38 @@ describe('ui-agent-preset apply', () => { await b.ctx.fiber.dispose() }) + it('corrects a hidden default when accepted settings arrive as the previous reconciliation settles', async () => { + const b = await bench({ presets: ROSTER_MOVED.value.presets }) + b.ctx.provide('sessions', sessionsDouble(b.ctx, { byId: {} }) as never) + declareRoot(b.slots) + const answer = await b.ctx.remote.settings.describe() + if (!answer.ok) throw new Error(answer.error.message) + const describe = vi.spyOn(b.ctx.remote.settings, 'describe').mockResolvedValue({ + ...answer, + value: { ...answer.value, namespaces: answer.value.namespaces.map(row => row.ns === 'ui-settings' + ? { ...row, value: { enabled: false } } : row) }, + }) + const disabled = Promise.withResolvers() + const reconcile = vi.spyOn(AgentPresetSectionController.prototype, 'reconcileCodingTools') + .mockImplementationOnce(function (this: AgentPresetSectionController, shouldReset) { + reconcile.mockRestore() + const result = this.reconcileCodingTools(shouldReset) + // The real settings mirror publishes its already-answered read while + // the prior reconciliation's promise is settling. + void result.then(() => { void b.setDeveloperTools(false).then(() => { disabled.resolve(undefined) }, disabled.reject) }) + return result + }) + try { + await b.ctx.plugin({ inject: [...inject], apply }).await() + await disabled.promise + expect(b.calls.filter(call => call.startsWith('settings:'))).toEqual(['settings:{"selectedDefault":"standard"}']) + } finally { + await b.ctx.fiber.dispose() + reconcile.mockRestore() + describe.mockRestore() + } + }) + it.each([['cordis', true], ['mine', true], ['ptc', true], ['minimal', true], ['ptc', false], ['minimal', false]] as const)( 'resets only the default while the current task uses %s (blank: %s)', async (preset, blank) => { const b = await bench({ presets: [ diff --git a/packages/client/ui-agent-preset/tests/components.client.spec.tsx b/packages/client/ui-agent-preset/tests/components.client.spec.tsx index 432c7e5392..006950507e 100644 --- a/packages/client/ui-agent-preset/tests/components.client.spec.tsx +++ b/packages/client/ui-agent-preset/tests/components.client.spec.tsx @@ -69,7 +69,7 @@ function renderSeat( t: translate, } as AgentPresetSeatProps render() - return { ...actions, developerTools } + return { ...actions, developerTools, store } } function renderLabel( @@ -114,6 +114,33 @@ describe('the new-session chip', () => { expect(screen.getByRole('menuitem', { name: /^My workflow/ })).toBeTruthy() }) + it('keeps the current mode visible without opening a picker when all options are hidden', () => { + const actions = renderSeat({ current: 'minimal', options: [{ id: 'ptc' }, { id: 'minimal' }] }, undefined, undefined, false) + const trigger = screen.getByRole('button', { name: en.presetMinimalName }) + expect(trigger.disabled).toBe(true) + fireEvent.click(trigger) + expect(trigger.getAttribute('aria-expanded')).toBe('false') + expect(screen.queryByRole('menu')).toBeNull() + expect(actions.select).not.toHaveBeenCalled() + }) + + it('closes a picker when its last visible option disappears and keeps it closed when options return', () => { + const options = [{ id: 'minimal' }, { id: 'mine' }] + const actions = renderSeat({ current: 'minimal', options }, undefined, undefined, false) + const trigger = screen.getByRole('button', { name: en.presetMinimalName }) + fireEvent.click(trigger) + expect(screen.getByRole('menuitem', { name: /^mine/ })).toBeTruthy() + act(() => { actions.store.set({ ...actions.store.getSnapshot(), options: [{ id: 'ptc' }, { id: 'minimal' }] }) }) + expect(trigger.disabled).toBe(true) + expect(trigger.getAttribute('aria-expanded')).toBe('false') + expect(screen.queryByRole('menu')).toBeNull() + act(() => { actions.store.set({ ...actions.store.getSnapshot(), options }) }) + expect(trigger.disabled).toBe(false) + expect(trigger.getAttribute('aria-expanded')).toBe('false') + expect(screen.queryByRole('menu')).toBeNull() + expect(actions.select).not.toHaveBeenCalled() + }) + it('renders only for a Session retained by the main view', () => { renderSeat({}, undefined, { id: 's1', retainInfo: { referenceCount: 1, retainedBy: { mainView: 1 } },