fix(web): handle preset settings updates and empty choices

This commit is contained in:
ZiyaZhang committed 2026-09-28 23:43:21 -07:00
1 parent 737691913f
commit 68eb79d9e1
4 files changed
+75 -13

No files matched your search

@@ -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 (
<>
<Menu
open={open}
open={open && options.length > 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) }}
>
<IconAgentPresetOutlineRegular className={introducing ? `${css.seatIcon} ${css.introIcon}` : css.seatIcon} />
@@ -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.
@@ -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<undefined>()
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: [
@@ -69,7 +69,7 @@ function renderSeat(
t: translate,
} as AgentPresetSeatProps
render(<AgentPresetSeat {...props} />)
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<HTMLButtonElement>('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<HTMLButtonElement>('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 } },