fix(web): handle preset settings updates and empty choices
This commit is contained in:
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 } },
|
||||
|
||||
Reference in new issue
Block a user