From 9be292f1e678437644396b47b3410b433ba3433f Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 1 Jul 2026 06:40:20 -0700 Subject: [PATCH] fix(desktop): make MoA preset selection persistent, not one-shot (#54670) (#56417) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MoA preset section in the composer model dropdown presented presets like persistent model selections, but selecting one dispatched the one-shot `/moa` command (command.dispatch name=moa) — it ran a single turn through MoA and then silently reverted to the prior model. The user saw MoA context for one message, then it vanished with no indication. Route MoA preset selection through the same persistent path real provider selections use: onSelectModel({ model: preset, provider: 'moa' }) → config.set model=" --provider moa" → the gateway's switch_model. The check mark now reflects the real current selection (currentProvider === 'moa' && currentModel === preset) instead of transient local state, and the now-unused activeMoaPreset state is removed. Tests: new model-menu-panel.test.tsx (2) — selecting a preset calls onSelectModel with provider 'moa' (persistent), and the check renders on the active preset. tsc -b clean. --- .../src/app/shell/model-menu-panel.test.tsx | 92 +++++++++++++++++++ .../src/app/shell/model-menu-panel.tsx | 48 ++++++---- 2 files changed, 120 insertions(+), 20 deletions(-) create mode 100644 apps/desktop/src/app/shell/model-menu-panel.test.tsx diff --git a/apps/desktop/src/app/shell/model-menu-panel.test.tsx b/apps/desktop/src/app/shell/model-menu-panel.test.tsx new file mode 100644 index 00000000000..9cfb0998340 --- /dev/null +++ b/apps/desktop/src/app/shell/model-menu-panel.test.tsx @@ -0,0 +1,92 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { cleanup, findByText, fireEvent, render } from '@testing-library/react' +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +import { DropdownMenu, DropdownMenuContent } from '@/components/ui/dropdown-menu' +import { $activeSessionId, $currentModel, $currentProvider } from '@/store/session' + +import { ModelMenuPanel } from './model-menu-panel' + +// Radix calls these on open; jsdom doesn't implement them. +beforeAll(() => { + Element.prototype.scrollIntoView = vi.fn() + Element.prototype.hasPointerCapture = vi.fn(() => false) + Element.prototype.releasePointerCapture = vi.fn() +}) + +const getMoaModels = vi.fn() +const getGlobalModelOptions = vi.fn() + +vi.mock('@/hermes', () => ({ + getGlobalModelOptions: (...args: unknown[]) => getGlobalModelOptions(...args), + getMoaModels: (...args: unknown[]) => getMoaModels(...args) +})) + +function moaPreset() { + return { + aggregator: { provider: 'deepseek', model: 'deepseek-v4-pro' }, + aggregator_temperature: 0.7, + enabled: true, + max_tokens: 4096, + reference_models: [{ provider: 'zai', model: 'glm-5.2' }], + reference_temperature: 0.7 + } +} + +beforeEach(() => { + $activeSessionId.set('runtime-1') + $currentModel.set('') + $currentProvider.set('') + getGlobalModelOptions.mockResolvedValue({ providers: [] }) + getMoaModels.mockResolvedValue({ + default_preset: 'default', + active_preset: 'default', + presets: { default: moaPreset(), BeastMode: moaPreset() } + }) +}) + +afterEach(() => { + cleanup() + vi.clearAllMocks() +}) + +function renderPanel(onSelectModel = vi.fn()) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + render( + + + + + + + + ) + + return onSelectModel +} + +describe('ModelMenuPanel MoA presets', () => { + it('selecting a MoA preset switches PERSISTENTLY via onSelectModel (not the one-shot dispatch)', async () => { + const onSelectModel = renderPanel() + + // moaOptions is async (useQuery) — wait for the preset row to mount. + const row = await findByText(document.body, 'MoA: BeastMode') + fireEvent.click(row) + + // #54670: must route through the persistent model-switch path + // (config.set model=" --provider moa"), i.e. onSelectModel with + // provider 'moa', NOT a one-shot command.dispatch that reverts after a turn. + expect(onSelectModel).toHaveBeenCalledWith({ model: 'BeastMode', provider: 'moa' }) + }) + + it('shows the check on the preset that matches the current moa selection', async () => { + $currentProvider.set('moa') + $currentModel.set('BeastMode') + renderPanel() + + const row = await findByText(document.body, 'MoA: BeastMode') + // The check codicon renders as a sibling within the same row item. + const item = row.closest('[role="menuitem"]') ?? row.parentElement + expect(item?.querySelector('.codicon-check')).not.toBeNull() + }) +}) diff --git a/apps/desktop/src/app/shell/model-menu-panel.tsx b/apps/desktop/src/app/shell/model-menu-panel.tsx index b26ecb64ade..1d1d620a067 100644 --- a/apps/desktop/src/app/shell/model-menu-panel.tsx +++ b/apps/desktop/src/app/shell/model-menu-panel.tsx @@ -69,7 +69,6 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model const [search, setSearch] = useState('') const [refreshing, setRefreshing] = useState(false) const queryClient = useQueryClient() - const [activeMoaPreset, setActiveMoaPreset] = useState('') // Reactive session state is read from the stores here (not drilled in), so // toggling effort/fast/model re-renders this panel in place without forcing // the parent to rebuild the menu content (which would close the dropdown). @@ -180,13 +179,18 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model ) } - const toggleMoaPreset = async (preset: string) => { + // Selecting a MoA preset switches the session to it PERSISTENTLY, using the + // same path real provider selections use (config.set model=" + // --provider moa" via onSelectModel → the gateway's persistent switch_model). + // Previously this dispatched the one-shot `/moa` command, which ran a single + // turn through MoA and then silently reverted to the prior model (#54670) — + // the dropdown presented presets like persistent selections but they weren't. + const selectMoaPreset = async (preset: string) => { if (!activeSessionId) { return } - await requestGateway('command.dispatch', { name: 'moa', arg: preset, session_id: activeSessionId }) - setActiveMoaPreset(current => (current === preset ? '' : preset)) + await switchTo(preset, 'moa') } const groups = useMemo( @@ -321,22 +325,26 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model {moaOptions.data && Object.keys(moaOptions.data.presets ?? {}).length > 0 ? ( <> MoA presets - {Object.keys(moaOptions.data.presets).map(preset => ( - { - event.preventDefault() - void toggleMoaPreset(preset) - }} - > - MoA: {preset} - {activeMoaPreset === preset ? ( - - ) : null} - - ))} + {Object.keys(moaOptions.data.presets).map(preset => { + const isCurrentMoa = currentProvider === 'moa' && currentModel === preset + + return ( + { + event.preventDefault() + void selectMoaPreset(preset) + }} + > + MoA: {preset} + {isCurrentMoa ? ( + + ) : null} + + ) + })} ) : null}