diff --git a/apps/desktop/src/app/shell/model-catalog-menu.test.tsx b/apps/desktop/src/app/shell/model-catalog-menu.test.tsx new file mode 100644 index 0000000000000..27c6c1880705d --- /dev/null +++ b/apps/desktop/src/app/shell/model-catalog-menu.test.tsx @@ -0,0 +1,108 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +import { DropdownMenu, DropdownMenuContent } from '@/components/ui/dropdown-menu' +import { + $modelVisibilityOpen, + $visibleModels, + modelVisibilityKey, + setModelVisibilityOpen, + setVisibleModels +} from '@/store/model-visibility' + +import { ModelCatalogMenu, type ModelMenuController } from './model-catalog-menu' + +// 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 getGlobalModelOptions = vi.fn() + +vi.mock('@/hermes', () => ({ + getGlobalModelOptions: (...args: unknown[]) => getGlobalModelOptions(...args), + setApiRequestProfile: vi.fn() +})) + +beforeEach(() => { + $visibleModels.set(null) + setModelVisibilityOpen(false) + getGlobalModelOptions.mockResolvedValue({ + providers: [{ models: ['gemini-3.1-pro', 'gemini-2.5-flash'], name: 'Google', slug: 'google' }] + }) +}) + +afterEach(() => { + cleanup() + vi.clearAllMocks() +}) + +// A minimal controller — these tests are about the CATALOG's own behaviour +// (what it lists, what it offers), not about what any host does with a pick. +function renderMenu() { + const select = vi.fn() + + const controller: ModelMenuController = { + applyPreset: vi.fn(), + current: { effort: '', fast: false, model: '', provider: '' }, + presetFor: () => ({}), + select, + setOptions: vi.fn() + } + + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + + render( + + + + + + + + ) + + return select +} + +// Curation is ONE global preference, so it belongs to the catalog rather than +// to whichever surface mounted it. If a host had to opt in, the composer and +// the kanban board would end up disagreeing about what "my models" means — +// which is exactly the drift extracting this component was meant to prevent. +describe('the catalog owns model curation', () => { + it('honours the stored Edit Models shortlist', async () => { + setVisibleModels(new Set([modelVisibilityKey('google', 'gemini-2.5-flash')])) + + renderMenu() + + await screen.findByText(/Gemini 2\.5 Flash/i) + expect(screen.queryByText(/Gemini 3\.1 Pro/i)).toBeNull() + }) + + it('still finds a hidden model by search — curation narrows the default view, not the catalog', async () => { + setVisibleModels(new Set([modelVisibilityKey('google', 'gemini-2.5-flash')])) + + renderMenu() + await screen.findByText(/Gemini 2\.5 Flash/i) + + const input = screen.getByRole('textbox', { name: 'Search models' }) + + fireEvent.change(input, { target: { value: 'gemini-3.1' } }) + + await vi.waitFor(() => { + expect(screen.queryByText(/Gemini 3\.1 Pro/i)).not.toBeNull() + }) + }) + + it('offers Edit Models without the host wiring it up', async () => { + renderMenu() + await screen.findByText(/Gemini 3\.1 Pro/i) + + fireEvent.click(screen.getByText('Edit Models…')) + + expect($modelVisibilityOpen.get()).toBe(true) + }) +}) diff --git a/apps/desktop/src/app/shell/model-catalog-menu.tsx b/apps/desktop/src/app/shell/model-catalog-menu.tsx new file mode 100644 index 0000000000000..782bf579d80db --- /dev/null +++ b/apps/desktop/src/app/shell/model-catalog-menu.tsx @@ -0,0 +1,589 @@ +import { useStore } from '@nanostores/react' +import { useQuery } from '@tanstack/react-query' +import { createContext, type ReactNode, useContext, useEffect, useMemo, useRef, useState } from 'react' + +import { Codicon } from '@/components/ui/codicon' +import { DisclosureCaret } from '@/components/ui/disclosure-caret' +import { + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + dropdownMenuRow, + DropdownMenuSearch, + dropdownMenuSectionLabel, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubTrigger +} from '@/components/ui/dropdown-menu' +import { HighlightMatches } from '@/components/ui/highlight-matches' +import { usePointerQuiet } from '@/components/ui/keyboard-first' +import { Skeleton } from '@/components/ui/skeleton' +import type { HermesGateway } from '@/hermes' +import { useI18n } from '@/i18n' +import { modelOptionsQueryKey, requestModelOptions } from '@/lib/model-options' +import { displayModelName, modelDisplayParts } from '@/lib/model-status-label' +import { DEFAULT_REASONING_EFFORT, reasoningEffortLabel } from '@/lib/reasoning-effort' +import { normalize } from '@/lib/text' +import { cn } from '@/lib/utils' +import { + $visibleModels, + collapseModelFamilies, + DEFAULT_VISIBLE_PER_PROVIDER, + effectiveVisibleKeys, + type ModelFamily, + modelVisibilityKey, + setModelVisibilityOpen +} from '@/store/model-visibility' +import { $collapsedProviders, toggleCollapsedProvider } from '@/store/provider-collapse' +import { $defaultReasoningEffort } from '@/store/session' +import type { ModelOptionProvider, ModelOptionsResponse } from '@/types/hermes' + +import { type FastControl, ModelEditSubmenu, resolveFastControl } from './model-edit-submenu' + +// Lets the host dropdown (model-pill, a kanban field trigger, …) hand the panel +// a way to dismiss itself so clicking a model row commits + closes, while the +// hover-revealed edit submenu (reasoning/fast) stays open to play with (its +// items preventDefault on select). +export const ModelMenuCloseContext = createContext<() => void>(() => {}) + +/** One model choice, everything a caller needs to act on a selection. + * `effort` is '' for "inherit the default" and 'none' for thinking off. */ +export interface ModelChoice { + effort: string + fast: boolean + model: string + provider: string +} + +/** + * What a surface DOES with the catalog. The menu renders and navigates; the + * controller owns meaning — the composer writes through to a live session, + * the kanban override just holds a value in dialog state. + * + * `presetFor` supplies the remembered settings shown on a non-active row. + * Returning `{}` is fine — the row then shows Hermes' defaults. + */ +export interface ModelMenuController { + /** Restore a model's remembered settings after it is selected. Separate from + * `setOptions` because it is one atomic "apply this model's preset" write, + * not a user editing one control — surfaces that write through to a session + * need to batch it. Values are already capability-gated by the menu. */ + applyPreset: (preset: { effort?: string; fast?: boolean }, row: { model: string; provider: string }) => void + current: ModelChoice + presetFor: (provider: string, model: string) => { effort?: string; fast?: boolean } + /** Commit a model row. Return false to abort (a failed session switch). */ + select: (model: string, provider: string) => Promise | void + /** Edit ONE option on a row. `isActive` says whether it's the current model. */ + setOptions: ( + patch: { effort?: string; fast?: boolean }, + row: { isActive: boolean; model: string; provider: string } + ) => void +} + +interface ModelCatalogMenuProps { + controller: ModelMenuController + /** Rows appended under the catalog (Refresh Models, Edit Models, …). */ + footer?: ReactNode + gateway?: HermesGateway + /** Render the virtual `moa` provider's presets as a selectable section. + * Off for override surfaces, where a MoA preset isn't a worker model. */ + includeMoa?: boolean + profile?: string + /** Session whose catalog to fetch. A live session's catalog can differ from + * the profile-global one, and the app invalidates the SESSION-scoped query + * key on model changes — a surface bound to a session must pass it or its + * menu goes stale. Detached surfaces (per-task overrides) omit it. */ + sessionId?: null | string +} + +interface ProviderGroup { + families: ModelFamily[] + provider: ModelOptionProvider +} + +/** + * THE model catalog menu: searchable, provider-grouped, `-fast` families + * collapsed to one row, per-row hover submenu for thinking/effort/fast, full + * keyboard selection. Shared verbatim by the composer's model pill and by + * plugin surfaces that pick a model without a session behind it — so the two + * can never drift apart. + */ +export function ModelCatalogMenu({ + controller, + footer, + gateway, + includeMoa = false, + profile = 'default', + sessionId = null +}: ModelCatalogMenuProps) { + const { t } = useI18n() + const copy = t.shell.modelMenu + const closeMenu = useContext(ModelMenuCloseContext) + const [search, setSearch] = useState('') + const collapsedProviders = useStoreCollapsed() + const defaultEffort = useDefaultEffort() + // Which models the user curated in Edit Models. Read HERE rather than taken + // as a prop: it's one global preference, so every surface that shows a + // catalog must show the same shortlist. A per-caller opt-in is how the board + // and the composer would end up disagreeing about what "my models" means. + const visibleModels = useStore($visibleModels) + + const modelOptions = useQuery({ + queryKey: modelOptionsQueryKey(profile, sessionId), + // Gateway-first even with no session: a connected (possibly remote) + // gateway owns the model catalog, including virtual providers the local + // REST fallback can't know about (#53817). + queryFn: (): Promise => requestModelOptions({ gateway, sessionId }) + }) + + const loading = modelOptions.isPending && !modelOptions.data + + const error = modelOptions.error + ? modelOptions.error instanceof Error + ? modelOptions.error.message + : String(modelOptions.error) + : null + + const providers = modelOptions.data?.providers + + // The catalog carries MoA presets as a virtual `moa` provider row. Keep it + // out of the main groups so presets never show up twice. + const moaPresets = useMemo( + () => (includeMoa ? (providers?.find(p => p.slug.toLowerCase() === 'moa')?.models ?? []) : []), + [providers, includeMoa] + ) + + const pickerProviders = useMemo( + () => providers?.filter(provider => provider.slug.toLowerCase() !== 'moa') ?? [], + [providers] + ) + + const current = controller.current + + // Resolve visibility HERE, against the catalog we actually fetched: an empty + // provider list would otherwise resolve to an empty key set that reads as + // "user hid everything" and blanks the menu on first open. + const shownKeys = useMemo( + () => effectiveVisibleKeys(visibleModels, pickerProviders), + [visibleModels, pickerProviders] + ) + + const groups = useMemo( + () => groupModels(pickerProviders, search, { model: current.model, provider: current.provider }, shownKeys), + [pickerProviders, search, current.model, current.provider, shownKeys] + ) + + const q = normalize(search) + + // Presets are searchable rows like everything else — an unfiltered preset + // sitting under zero model matches would otherwise become the "first match" + // Enter commits. + const shownMoaPresets = useMemo( + () => (q ? moaPresets.filter(preset => `moa ${preset}`.toLowerCase().includes(q)) : moaPresets), + [moaPresets, q] + ) + + const selectFamily = async (family: ModelFamily, provider: ModelOptionProvider) => { + const caps = provider.capabilities?.[family.id] + const preset = controller.presetFor(provider.slug, family.id) + + // Variant-fast models (no speed param) express "fast" as a separate `-fast` + // id, so honor the remembered preset by selecting that sibling. Param-fast + // is applied through setOptions below instead. + const variantFast = !(caps?.fast ?? false) && !!family.fastId + const targetId = variantFast && preset.fast === true ? family.fastId! : family.id + + if ((await controller.select(targetId, provider.slug)) === false) { + return + } + + controller.applyPreset( + { + effort: (caps?.reasoning ?? true) ? (preset.effort ?? defaultEffort) : undefined, + fast: (caps?.fast ?? false) ? (preset.fast ?? false) : undefined + }, + { model: family.id, provider: provider.slug } + ) + } + + const selectMoaPreset = async (preset: string) => { + if ((await controller.select(preset, 'moa')) === false) { + return + } + + closeMenu() + } + + // ── Keyboard selection (cmdk semantics on a Radix menu) ─────────────────── + // One flat list mirroring EXACTLY what's rendered (collapse, filter, presets), + // so the selection can never sit on a hidden row. + type KbRow = + | { family: ModelFamily; key: string; kind: 'family'; provider: ModelOptionProvider } + | { key: string; kind: 'moa'; preset: string } + + const kbRows = useMemo( + () => [ + ...groups.flatMap(group => + collapsedProviders.includes(group.provider.slug) && !search + ? [] + : group.families.map((family): KbRow => ({ + family, + key: `${group.provider.slug}:${family.id}`, + kind: 'family', + provider: group.provider + })) + ), + ...shownMoaPresets.map((preset): KbRow => ({ key: `moa:${preset}`, kind: 'moa', preset })) + ], + [groups, collapsedProviders, search, shownMoaPresets] + ) + + const [kbOverride, setKbOverride] = useState(null) + // A parked cursor is not a cursor in use: until the mouse actually moves, + // hover can't take rows out from under the keyboard. + const pointerQuiet = usePointerQuiet() + + const currentKey = current.provider === 'moa' ? `moa:${current.model}` : `${current.provider}:${current.model}` + + const autoIndex = q + ? kbRows.length > 0 + ? 0 + : -1 + : kbRows.findIndex(row => row.key === currentKey || (row.kind === 'family' && row.family.fastId === current.model)) + + const kbIndex = kbOverride !== null && kbOverride < kbRows.length ? kbOverride : autoIndex + const kbActiveKey = kbIndex >= 0 ? kbRows[kbIndex].key : null + + const stepKb = (delta: -1 | 1) => { + if (kbRows.length === 0) { + return + } + + const from = kbIndex >= 0 ? kbIndex : delta === 1 ? -1 : 0 + + setKbOverride((from + delta + kbRows.length) % kbRows.length) + } + + const commitKbRow = () => { + const row = kbIndex >= 0 ? kbRows[kbIndex] : undefined + + if (!row) { + return + } + + if (row.kind === 'moa') { + void selectMoaPreset(row.preset) + + return + } + + if (row.key !== currentKey && row.family.fastId !== current.model) { + void selectFamily(row.family, row.provider) + } + + closeMenu() + } + + // Keep the selected row in view while arrowing through the scrollable list. + const listRef = useRef(null) + + useEffect(() => { + listRef.current?.querySelector('[data-kb-active]')?.scrollIntoView({ block: 'nearest' }) + }, [kbActiveKey]) + + const kbRowProps = (key: string) => { + const active = kbActiveKey === key + + return { + className: cn(dropdownMenuRow, active && 'bg-(--ui-control-active-background) text-foreground'), + ...(active ? { 'data-kb-active': '' } : {}) + } + } + + // Rows are hover-selectable, so they go inert with the pointer. + const quietRows = pointerQuiet && 'pointer-events-none' + + return ( + <> + { + // Claim arrows and Enter from Radix so DOM focus stays in the input + // and Enter commits the highlighted row without a DownArrow first. + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault() + event.stopPropagation() + stepKb(event.key === 'ArrowDown' ? 1 : -1) + } else if (event.key === 'Enter') { + event.preventDefault() + event.stopPropagation() + commitKbRow() + } + }} + onValueChange={value => { + setSearch(value) + setKbOverride(null) + }} + placeholder={copy.search} + value={search} + /> + + + + {loading ? ( + + {Array.from({ length: 4 }, (_, index) => ( + event.preventDefault()} + > + + + ))} + + ) : error ? ( + + {error} + + ) : groups.length === 0 && moaPresets.length === 0 ? ( + + {copy.noModels} + + ) : ( +
+ {groups.map(group => { + const slug = group.provider.slug + + // Collapsed when the user stored it (and not while searching, which + // spans every model regardless of collapse state). + const collapsed = collapsedProviders.includes(slug) && !search + + return ( + + { + event.preventDefault() + toggleCollapsedProvider(slug) + }} + textValue="" + > + + + + + + {!collapsed && + group.families.map(family => { + // The active id may be the base or its -fast sibling; either + // way this one family row represents both. + const activeId = + group.provider.slug === current.provider && + (current.model === family.id || current.model === family.fastId) + ? current.model + : null + + const isCurrent = activeId !== null + const name = modelDisplayParts(family.id).name + const caps = group.provider.capabilities?.[family.id] + + // Effective settings for this row: the live choice when it's + // the active model, otherwise its remembered preset. Row + // label AND submenu read from these so they never disagree. + const preset = controller.presetFor(group.provider.slug, family.id) + const effEffort = isCurrent ? current.effort : (preset.effort ?? '') + const effFast = isCurrent ? current.fast : (preset.fast ?? false) + + const fastControl: FastControl = resolveFastControl( + activeId ?? family.id, + group.provider.models ?? [], + caps?.fast ?? false, + effFast + ) + + const meta = [ + fastControl.kind !== 'none' && fastControl.on ? copy.fast : null, + (caps?.reasoning ?? true) ? reasoningEffortLabel(effEffort || defaultEffort) : null + ] + .filter(Boolean) + .join(' ') + + // Clicking the row commits the model and closes; the edit + // submenu (reasoning/fast) is reached by HOVER, so you can + // tweak those without the click dismissing everything. + const activate = () => { + if (!isCurrent) { + void selectFamily(family, group.provider) + } + + closeMenu() + } + + return ( + + { + if (event.key === 'Enter' || event.key === ' ') { + activate() + } + }} + {...kbRowProps(`${group.provider.slug}:${family.id}`)} + > + + + {meta ? {meta} : null} + + {isCurrent ? ( + + ) : null} + + controller.select(nextModel, group.provider.slug)} + onSetOptions={patch => + controller.setOptions(patch, { + isActive: isCurrent, + model: family.id, + provider: group.provider.slug + }) + } + provider={group.provider.slug} + reasoning={caps?.reasoning ?? true} + /> + + ) + })} + + ) + })} +
+ )} + + {shownMoaPresets.length > 0 ? ( +
+ + MoA presets + {shownMoaPresets.map(preset => { + const isCurrentMoa = current.provider === 'moa' && current.model === preset + + return ( + { + event.preventDefault() + void selectMoaPreset(preset) + }} + {...kbRowProps(`moa:${preset}`)} + > + + MoA: + + {isCurrentMoa ? : null} + + ) + })} +
+ ) : null} + + {/* Curation belongs to the catalog, not to one host: wherever you can + pick a model you can say which models you want, and the shortlist is + the same everywhere because it's one stored preference. It shares the + host footer's group rather than opening a second one, so a host that + contributes rows (the composer's Refresh Models) keeps the single + trailing block it has always rendered. */} + + {footer} + setModelVisibilityOpen(true)} + > + + {copy.editModels} + + + ) +} + +/** Re-exported so callers building a footer row match the catalog's rows. */ +export { dropdownMenuRow } + +// Collapsed we show the user's chosen models (or the curated default); typing +// spans every available model so anything is reachable past the cut. A search +// is itself a narrowing action, so we do NOT cap per-provider matches. +function groupModels( + providers: ModelOptionProvider[], + search: string, + current: { model: string; provider: string }, + visible: Set | null +): ProviderGroup[] { + const q = normalize(search) + const groups: ProviderGroup[] = [] + + for (const provider of providers) { + const allFamilies = collapseModelFamilies(provider.models ?? []) + + if (allFamilies.length === 0) { + continue + } + + const matches = (family: ModelFamily) => + `${family.id} ${family.fastId ?? ''} ${provider.name} ${provider.slug} ${displayModelName(family.id)}` + .toLowerCase() + .includes(q) + + let shown: Set + + if (q) { + // Search spans every family, regardless of visibility. + shown = new Set(allFamilies.filter(matches).map(family => family.id)) + } else if (visible) { + // User has customized which models show — honor their selection exactly. + shown = new Set( + allFamilies.filter(family => visible.has(modelVisibilityKey(provider.slug, family.id))).map(family => family.id) + ) + } else { + shown = new Set(allFamilies.slice(0, DEFAULT_VISIBLE_PER_PROVIDER).map(family => family.id)) + } + + // Always include the active model — but keep every row in the provider's + // stable curated order, so selecting a model can't shuffle the list. While + // SEARCHING the pin is skipped: a query means "show me matches". + const activeId = + !q && provider.slug === current.provider && current.model + ? allFamilies.find(family => family.id === current.model || family.fastId === current.model)?.id + : undefined + + const families = allFamilies.filter(family => shown.has(family.id) || family.id === activeId) + + if (families.length > 0) { + groups.push({ families, provider }) + } + } + + // Stable, logical group order: alphabetical by provider name. (The backend + // floats the current provider first, which would reshuffle on every switch.) + groups.sort((a, b) => a.provider.name.localeCompare(b.provider.name)) + + return groups +} + +// Small hooks kept at the bottom so the component reads top-down. +function useStoreCollapsed(): string[] { + return useStore($collapsedProviders) +} + +function useDefaultEffort(): string { + return useStore($defaultReasoningEffort) || DEFAULT_REASONING_EFFORT +} diff --git a/apps/desktop/src/app/shell/model-edit-submenu.test.tsx b/apps/desktop/src/app/shell/model-edit-submenu.test.tsx index 4e552303b572b..52513c4d00805 100644 --- a/apps/desktop/src/app/shell/model-edit-submenu.test.tsx +++ b/apps/desktop/src/app/shell/model-edit-submenu.test.tsx @@ -1,5 +1,5 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/react' -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' import { DropdownMenu, @@ -7,26 +7,9 @@ import { DropdownMenuSub, DropdownMenuSubTrigger } from '@/components/ui/dropdown-menu' -import type * as HermesApi from '@/hermes' -import { $modelPresets, getModelPreset } from '@/store/model-presets' -import { - $activeSessionId, - $currentFastMode, - $currentReasoningEffort, - getCurrentModelSource, - setCurrentFastMode, - setCurrentModelSource, - setCurrentReasoningEffort -} from '@/store/session' import { type FastControl, ModelEditSubmenu } from './model-edit-submenu' -vi.mock('@/hermes', async importOriginal => { - const actual = await importOriginal() - - return { ...actual, setApiRequestProfile: vi.fn() } -}) - // Radix calls these on open; jsdom doesn't implement them. beforeAll(() => { Element.prototype.scrollIntoView = vi.fn() @@ -34,35 +17,36 @@ beforeAll(() => { Element.prototype.releasePointerCapture = vi.fn() }) -beforeEach(() => { - $modelPresets.set({}) - $activeSessionId.set(null) - setCurrentFastMode(false) - setCurrentModelSource('') - setCurrentReasoningEffort('') -}) - afterEach(() => { cleanup() vi.clearAllMocks() }) // Render the submenu inside an open menu/sub so its content (switches) mounts. -function renderSubmenu(opts: { fastControl: FastControl; reasoning: boolean; requestGateway: () => Promise }) { +function renderSubmenu(opts: { + defaultEffort?: string + effort?: string + fastControl: FastControl + isActive?: boolean + onSelectModel?: (model: string) => void + onSetOptions: (patch: { effort?: string; fast?: boolean }) => void + reasoning: boolean +}) { return render( edit @@ -70,43 +54,72 @@ function renderSubmenu(opts: { fastControl: FastControl; reasoning: boolean; req ) } -// Regression: editing the active row before a live session exists must stay -// preset-only — the gateway's config.set falls back to global config when no -// session matches, so it must not be called. (Caught in the second review.) -describe('ModelEditSubmenu no-session guard', () => { - it('param fast: records explicit off in the draft but skips the gateway without a session', () => { - const requestGateway = vi.fn().mockResolvedValue({}) - setCurrentFastMode(true) - renderSubmenu({ fastControl: { kind: 'param', on: true }, reasoning: false, requestGateway }) +// The submenu is PURE: it reports edits and never writes to a session, a +// preset store, or the gateway. That's the invariant that lets the same +// component drive a live chat session AND a detached per-task override — if it +// ever writes directly again, picking an effort for a kanban card would reach +// over and change the user's live chat. +describe('ModelEditSubmenu reports edits without performing them', () => { + it('param fast: reports the toggle', () => { + const onSetOptions = vi.fn() + renderSubmenu({ fastControl: { kind: 'param', on: true }, onSetOptions, reasoning: false }) fireEvent.click(screen.getByRole('switch')) - expect(getModelPreset('p1', 'm1').fast).toBe(false) - expect($currentFastMode.get()).toBe(false) - expect(getCurrentModelSource()).toBe('manual') - expect(requestGateway).not.toHaveBeenCalled() + expect(onSetOptions).toHaveBeenCalledWith({ fast: false }) }) - it('reasoning: records the preset but skips the gateway without a session', () => { - const requestGateway = vi.fn().mockResolvedValue({}) - renderSubmenu({ fastControl: { kind: 'none' }, reasoning: true, requestGateway }) + it('thinking: toggling off reports the none level', () => { + const onSetOptions = vi.fn() + renderSubmenu({ fastControl: { kind: 'none' }, onSetOptions, reasoning: true }) - // Thinking starts on (medium); toggling it off routes through patchReasoning. + // Thinking starts on (medium); toggling it off reports 'none'. fireEvent.click(screen.getByRole('switch')) - expect(getModelPreset('p1', 'm1').effort).toBe('none') - expect($currentReasoningEffort.get()).toBe('none') - expect(getCurrentModelSource()).toBe('manual') - expect(requestGateway).not.toHaveBeenCalled() + expect(onSetOptions).toHaveBeenCalledWith({ effort: 'none' }) }) - it('param fast: pushes to the gateway once a session is active', async () => { - const requestGateway = vi.fn().mockResolvedValue({}) - $activeSessionId.set('sess1') - renderSubmenu({ fastControl: { kind: 'param', on: false }, reasoning: false, requestGateway }) + it('thinking: toggling back on restores the row level, not the hardcoded default', () => { + const onSetOptions = vi.fn() + renderSubmenu({ defaultEffort: 'high', effort: 'none', fastControl: { kind: 'none' }, onSetOptions, reasoning: true }) fireEvent.click(screen.getByRole('switch')) - expect(requestGateway).toHaveBeenCalledWith('config.set', { key: 'fast', session_id: 'sess1', value: 'fast' }) + expect(onSetOptions).toHaveBeenCalledWith({ effort: 'high' }) + }) + + it('variant fast: swaps the model only when the row is active', () => { + const onSelectModel = vi.fn() + const onSetOptions = vi.fn() + + renderSubmenu({ + fastControl: { baseId: 'm1', fastId: 'm1-fast', kind: 'variant', on: false }, + isActive: false, + onSelectModel, + onSetOptions, + reasoning: false + }) + + fireEvent.click(screen.getByRole('switch')) + + // Inactive rows stay preference-only — no model switch. + expect(onSetOptions).toHaveBeenCalledWith({ fast: true }) + expect(onSelectModel).not.toHaveBeenCalled() + }) + + it('variant fast: active row swaps to the -fast sibling', () => { + const onSelectModel = vi.fn() + const onSetOptions = vi.fn() + + renderSubmenu({ + fastControl: { baseId: 'm1', fastId: 'm1-fast', kind: 'variant', on: false }, + onSelectModel, + onSetOptions, + reasoning: false + }) + + fireEvent.click(screen.getByRole('switch')) + + expect(onSelectModel).toHaveBeenCalledWith('m1-fast') }) }) diff --git a/apps/desktop/src/app/shell/model-edit-submenu.tsx b/apps/desktop/src/app/shell/model-edit-submenu.tsx index 94c241bdf04e6..1d5b5dc598dd7 100644 --- a/apps/desktop/src/app/shell/model-edit-submenu.tsx +++ b/apps/desktop/src/app/shell/model-edit-submenu.tsx @@ -1,6 +1,3 @@ -import { useStore } from '@nanostores/react' - -import { useSessionView } from '@/app/chat/session-view' import { DropdownMenuItem, DropdownMenuLabel, @@ -13,21 +10,7 @@ import { } from '@/components/ui/dropdown-menu' import { Switch } from '@/components/ui/switch' import { useI18n } from '@/i18n' -import { - DEFAULT_REASONING_EFFORT, - isThinkingEnabled, - REASONING_EFFORTS, - resolveReasoningEffort -} from '@/lib/reasoning-effort' -import { setModelPreset } from '@/store/model-presets' -import { notifyError } from '@/store/notifications' -import { - $defaultReasoningEffort, - markComposerSelectionManual, - setCurrentFastMode, - setCurrentReasoningEffort -} from '@/store/session' -import { sessionTileDelegate } from '@/store/session-states' +import { isThinkingEnabled, REASONING_EFFORTS, resolveReasoningEffort } from '@/lib/reasoning-effort' // Hermes' real reasoning levels live in lib/reasoning-effort; `none` is owned // by the Thinking toggle, not the radio. @@ -76,6 +59,9 @@ export function resolveFastControl( } interface ModelEditSubmenuProps { + /** The profile's configured default effort — what an unset row inherits. + * Passed in (not read from a store) so this submenu stays pure. */ + defaultEffort: string /** This row's effective reasoning effort (live for the active model, else its * preset) — the submenu shows and edits from this, never the raw session. */ effort: string @@ -83,15 +69,19 @@ interface ModelEditSubmenuProps { fastControl: FastControl /** Whether this row's model is the active one. */ isActive: boolean - /** This row's model id — edits persist as its global preset. */ + /** This row's model id. */ model: string /** Switch to a specific model id (used to swap base ⇄ -fast variant). */ - onSelectModel: (model: string) => Promise | void - /** This row's provider slug — edits persist as its global preset. */ + onSelectModel: (model: string) => Promise | void + /** Report an option change. This submenu is PURE: it never writes to a + * session, a preset store, or the gateway itself — the owning surface's + * controller decides what an edit means. That's what lets the same submenu + * drive a live chat session and a detached per-task override. */ + onSetOptions: (patch: { effort?: string; fast?: boolean }) => void + /** This row's provider slug. */ provider: string /** Whether this model supports reasoning effort. */ reasoning: boolean - requestGateway: (method: string, params?: Record) => Promise } export function ModelEditSubmenu(props: ModelEditSubmenuProps) { @@ -108,72 +98,26 @@ export function ModelEditSubmenu(props: ModelEditSubmenuProps) { } function ModelEditSubmenuBody({ + defaultEffort, effort, fastControl, isActive, - model, onSelectModel, - provider, - reasoning, - requestGateway + onSetOptions, + reasoning }: ModelEditSubmenuProps) { const { t } = useI18n() const copy = t.shell.modelOptions - const view = useSessionView() - const activeSessionId = useStore(view.$runtimeId) - const touchesPrimary = view.kind === 'primary' - const defaultEffort = useStore($defaultReasoningEffort) || DEFAULT_REASONING_EFFORT const effortValue = resolveReasoningEffort(effort, defaultEffort) const thinkingOn = isThinkingEnabled(effort, defaultEffort) - // Editing always records the model's global preset (keyed by provider::model, - // not per-surface — a tile edit re-applies to that model everywhere); the - // active model also gets it pushed onto its OWN session (primary → globals, - // tile → its slice). Non-active edits stay preset-only — no model switch. - const patchReasoning = async (next: string) => { - setModelPreset(provider, model, { effort: next }) - - if (!isActive) { - return - } - - if (touchesPrimary) { - markComposerSelectionManual() - setCurrentReasoningEffort(next) - } else if (activeSessionId) { - sessionTileDelegate()?.updateSession(activeSessionId, state => ({ ...state, reasoningEffort: next })) - } - - // Preset-only without a session: `isActive` holds for the global/default - // row pre-session, and the gateway's `config.set` falls back to global - // config when none matches — so don't reach it (preset + optimistic store - // are the whole effect). Same guard in applyModelPreset / setFast. - if (!activeSessionId) { - return - } - - try { - await requestGateway('config.set', { key: 'reasoning', session_id: activeSessionId, value: next }) - } catch (err) { - if (touchesPrimary) { - setCurrentReasoningEffort(effort) - } else if (activeSessionId) { - sessionTileDelegate()?.updateSession(activeSessionId, state => ({ ...state, reasoningEffort: effort })) - } - - setModelPreset(provider, model, { effort }) - notifyError(err, copy.updateFailed) - } - } - const setFast = (enabled: boolean) => { if (fastControl.kind === 'variant') { - // Fast is a separate model id. Record the choice on the base model's - // preset (selectFamily picks the `-fast` sibling later when set), and - // only swap models now if this is the active row — inactive edits must - // stay preset-only, same as the param path below. - setModelPreset(provider, fastControl.baseId, { fast: enabled }) + // Fast is a separate model id. Report the choice so the controller can + // record it against the base model, and only swap models now if this is + // the active row — inactive edits stay preference-only. + onSetOptions({ fast: enabled }) if (isActive) { void onSelectModel(enabled ? fastControl.fastId : fastControl.baseId) @@ -183,41 +127,7 @@ function ModelEditSubmenuBody({ } if (fastControl.kind === 'param') { - setModelPreset(provider, model, { fast: enabled }) - - if (!isActive) { - return - } - - if (touchesPrimary) { - markComposerSelectionManual() - setCurrentFastMode(enabled) - } else if (activeSessionId) { - sessionTileDelegate()?.updateSession(activeSessionId, state => ({ ...state, fast: enabled })) - } - - // Preset-only without a session (see patchReasoning). - if (!activeSessionId) { - return - } - void (async () => { - try { - await requestGateway('config.set', { - key: 'fast', - session_id: activeSessionId, - value: enabled ? 'fast' : 'normal' - }) - } catch (err) { - if (touchesPrimary) { - setCurrentFastMode(!enabled) - } else if (activeSessionId) { - sessionTileDelegate()?.updateSession(activeSessionId, state => ({ ...state, fast: !enabled })) - } - - setModelPreset(provider, model, { fast: !enabled }) - notifyError(err, copy.fastFailed) - } - })() + onSetOptions({ fast: enabled }) } } @@ -235,7 +145,7 @@ function ModelEditSubmenuBody({ void patchReasoning(checked ? effortValue || defaultEffort : 'none')} + onCheckedChange={checked => onSetOptions({ effort: checked ? effortValue || defaultEffort : 'none' })} size="xs" /> @@ -250,7 +160,7 @@ function ModelEditSubmenuBody({ <> {copy.effort} - void patchReasoning(value)} value={effortValue}> + onSetOptions({ effort: value })} value={effortValue}> {REASONING_EFFORTS.map(value => ( void>(() => {}) +export { ModelMenuCloseContext } from './model-catalog-menu' export interface ModelSelection { model: string @@ -62,16 +42,15 @@ interface ModelMenuPanelProps { requestGateway: (method: string, params?: Record) => Promise } -interface ProviderGroup { - families: ModelFamily[] - provider: ModelOptionProvider -} - +/** + * The composer's model menu: `ModelCatalogMenu` (the shared renderer) plus the + * controller that gives a selection its meaning HERE — write through to this + * surface's session, remember the pick as a global preset, keep the optimistic + * stores honest, and roll back on a failed gateway write. + */ export function ModelMenuPanel({ gateway, onSelectModel, profile = 'default', requestGateway }: ModelMenuPanelProps) { const { t } = useI18n() const copy = t.shell.modelMenu - const closeMenu = useContext(ModelMenuCloseContext) - const [search, setSearch] = useState('') const [refreshing, setRefreshing] = useState(false) const queryClient = useQueryClient() // Bind to THIS surface's SessionView (primary or tile) so each pane's menu @@ -85,13 +64,15 @@ export function ModelMenuPanel({ gateway, onSelectModel, profile = 'default', re const modelPresets = useStore($modelPresets) const defaultEffort = useStore($defaultReasoningEffort) || DEFAULT_REASONING_EFFORT const visibleModels = useStore($visibleModels) - const collapsedProviders = useStore($collapsedProviders) + const touchesPrimary = view.kind === 'primary' + // Subscribe to the SAME query the menu runs (identical key ⇒ React Query + // dedupes, no second fetch). It must be a live subscription, not a cache + // peek: with no model in the session store yet, currentPickerSelection falls + // back to the catalog's reported current, and a non-reactive read would + // never repaint that fallback once the catalog resolved. const modelOptions = useQuery({ queryKey: modelOptionsQueryKey(profile, activeSessionId), - // Gateway-first even with no session yet: a connected (possibly remote) - // gateway owns the model catalog, including virtual providers like `moa` - // that the local REST fallback can't know about (#53817). queryFn: (): Promise => requestModelOptions({ gateway, sessionId: activeSessionId }) }) @@ -100,42 +81,6 @@ export function ModelMenuPanel({ gateway, onSelectModel, profile = 'default', re modelOptions.data ) - const loading = modelOptions.isPending && !modelOptions.data - - const error = modelOptions.error - ? modelOptions.error instanceof Error - ? modelOptions.error.message - : String(modelOptions.error) - : null - - const providers = modelOptions.data?.providers - - // The catalog carries MoA presets as a virtual `moa` provider row. Render - // them in their dedicated section below and keep the row out of the main - // provider groups so presets don't show up twice. - const moaPresets = useMemo( - () => providers?.find(provider => provider.slug.toLowerCase() === 'moa')?.models ?? [], - [providers] - ) - - const pickerProviders = useMemo( - () => providers?.filter(provider => provider.slug.toLowerCase() !== 'moa') ?? [], - [providers] - ) - - const effectiveVisibleModels = useMemo( - () => effectiveVisibleKeys(visibleModels, pickerProviders), - [visibleModels, pickerProviders] - ) - - // The composer picker never persists the profile default. With a session it - // scopes the switch to that session; with none it's UI state shipped on the - // next session.create (see selectModel). The default lives in Settings → Model. - // Always stamp sessionId from this surface so a tile switch never hits the - // primary (busy) session by accident. - const switchTo = (model: string, provider: string) => - onSelectModel({ model, provider, sessionId: activeSessionId || null }) - // Explicit "Refresh Models": re-fetch the catalog with refresh:true so the // backend busts its 1h provider-model disk cache and re-pulls each provider's // live list. Fixes live-only models (e.g. OpenCode Zen free tier) vanishing @@ -162,448 +107,138 @@ export function ModelMenuPanel({ gateway, onSelectModel, profile = 'default', re } } - // Selecting a model row restores that model's remembered preset onto the - // session (effort/fast), gated by capability. Unset → Hermes defaults. - const selectFamily = async (family: ModelFamily, provider: ModelOptionProvider) => { - const caps = provider.capabilities?.[family.id] - const preset = modelPresets[modelPresetKey(provider.slug, family.id)] ?? {} + // Push a reasoning change onto the session that owns it, with rollback. + const patchReasoning = async (next: string, previous: string, provider: string, model: string) => { + if (touchesPrimary) { + markComposerSelectionManual() + setCurrentReasoningEffort(next) + } else if (activeSessionId) { + sessionTileDelegate()?.updateSession(activeSessionId, state => ({ ...state, reasoningEffort: next })) + } - // Variant-fast models (no speed param) express "fast" as a separate `-fast` - // id, so honor the saved preset by selecting that sibling. Param-fast is - // applied via applyModelPreset below instead. - const variantFast = !(caps?.fast ?? false) && !!family.fastId - const targetId = variantFast && preset.fast === true ? family.fastId! : family.id - - if ((await switchTo(targetId, provider.slug)) === false) { + // Preset-only without a session: the gateway's `config.set` falls back to + // global config when none matches — so don't reach it (preset + optimistic + // store are the whole effect). + if (!activeSessionId) { return } - await applyModelPreset( - { - effort: (caps?.reasoning ?? true) ? (preset.effort ?? defaultEffort) : undefined, - fast: (caps?.fast ?? false) ? (preset.fast ?? false) : undefined - }, - { + try { + await requestGateway('config.set', { key: 'reasoning', session_id: activeSessionId, value: next }) + } catch (err) { + if (touchesPrimary) { + setCurrentReasoningEffort(previous) + } else { + sessionTileDelegate()?.updateSession(activeSessionId, state => ({ ...state, reasoningEffort: previous })) + } + + setModelPreset(provider, model, { effort: previous }) + notifyError(err, t.shell.modelOptions.updateFailed) + } + } + + const patchFast = async (enabled: boolean, provider: string, model: string) => { + if (touchesPrimary) { + markComposerSelectionManual() + setCurrentFastMode(enabled) + } else if (activeSessionId) { + sessionTileDelegate()?.updateSession(activeSessionId, state => ({ ...state, fast: enabled })) + } + + if (!activeSessionId) { + return + } + + try { + await requestGateway('config.set', { + key: 'fast', + session_id: activeSessionId, + value: enabled ? 'fast' : 'normal' + }) + } catch (err) { + if (touchesPrimary) { + setCurrentFastMode(!enabled) + } else { + sessionTileDelegate()?.updateSession(activeSessionId, state => ({ ...state, fast: !enabled })) + } + + setModelPreset(provider, model, { fast: !enabled }) + notifyError(err, t.shell.modelOptions.fastFailed) + } + } + + const controller: ModelMenuController = { + // Selecting a model row restores that model's remembered preset onto the + // session (effort/fast). applyModelPreset owns the batched gateway write. + applyPreset: (preset, row) => { + setModelPreset(row.provider, row.model, preset) + + void applyModelPreset(preset, { failMessage: t.shell.modelOptions.updateFailed, - primary: view.kind === 'primary', + primary: touchesPrimary, request: requestGateway, sessionId: activeSessionId + }) + }, + + current: { + effort: currentReasoningEffort, + fast: currentFastMode, + model: optionsModel, + provider: optionsProvider + }, + + presetFor: (provider, model) => modelPresets[modelPresetKey(provider, model)] ?? {}, + + // The composer picker never persists the profile default. With a session it + // scopes the switch to that session; with none it's UI state shipped on the + // next session.create. Always stamp sessionId from this surface so a tile + // switch never hits the primary (busy) session by accident. + select: (model, provider) => onSelectModel({ model, provider, sessionId: activeSessionId || null }), + + setOptions: (patch, row) => { + // Editing always records the model's global preset (keyed by + // provider::model, not per-surface — a tile edit re-applies to that model + // everywhere); the active model also gets it pushed onto its OWN session. + // Non-active edits stay preset-only — no model switch, no session write. + if (patch.effort !== undefined || patch.fast !== undefined) { + setModelPreset(row.provider, row.model, patch) } - ) - } - // Selecting a MoA preset switches the session to it PERSISTENTLY, using the - // same path real provider selections use (onSelectModel → config.set with - // --session for live sessions → 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. - // No session gate: like regular model rows, a pre-session pick is UI state - // shipped on the next session.create. - const selectMoaPreset = async (preset: string) => { - if ((await switchTo(preset, 'moa')) === false) { - return - } + if (!row.isActive) { + return + } - closeMenu() - } + if (patch.effort !== undefined) { + void patchReasoning(patch.effort, currentReasoningEffort, row.provider, row.model) + } - const groups = useMemo( - () => - groupModels(pickerProviders, search, { model: optionsModel, provider: optionsProvider }, effectiveVisibleModels), - [pickerProviders, search, optionsModel, optionsProvider, effectiveVisibleModels] - ) - - const q = normalize(search) - - // Presets are searchable rows like everything else — an unfiltered preset - // sitting under zero model matches would otherwise become the "first match" - // Enter commits. - const shownMoaPresets = useMemo( - () => (q ? moaPresets.filter(preset => `moa ${preset}`.toLowerCase().includes(q)) : moaPresets), - [moaPresets, q] - ) - - // ── Keyboard selection (cmdk semantics on a Radix menu) ─────────────────── - // One flat list mirroring EXACTLY what's rendered (collapse, filter, presets), - // so the selection can never sit on a hidden row. The selected index is - // derived — current model with no query (Enter = close), first match while - // typing — with an arrow-key override that resets on every keystroke. Focus - // stays in the search input throughout: ⌘⇧M → type → ↑/↓ → Enter. - type KbRow = - | { family: ModelFamily; key: string; kind: 'family'; provider: ModelOptionProvider } - | { key: string; kind: 'moa'; preset: string } - - const kbRows = useMemo( - () => [ - ...groups.flatMap(group => - collapsedProviders.includes(group.provider.slug) && !search - ? [] - : group.families.map((family): KbRow => ({ - family, - key: `${group.provider.slug}:${family.id}`, - kind: 'family', - provider: group.provider - })) - ), - ...shownMoaPresets.map((preset): KbRow => ({ key: `moa:${preset}`, kind: 'moa', preset })) - ], - [groups, collapsedProviders, search, shownMoaPresets] - ) - - const [kbOverride, setKbOverride] = useState(null) - // A parked cursor is not a cursor in use: until the mouse actually moves, - // hover can't take rows out from under the keyboard (rows re-flow beneath it - // as the filter narrows). One real movement hands hover back. - const pointerQuiet = usePointerQuiet() - - const currentKey = optionsProvider === 'moa' ? `moa:${optionsModel}` : `${optionsProvider}:${optionsModel}` - - const autoIndex = q - ? kbRows.length > 0 - ? 0 - : -1 - : kbRows.findIndex(row => row.key === currentKey || (row.kind === 'family' && row.family.fastId === optionsModel)) - - const kbIndex = kbOverride !== null && kbOverride < kbRows.length ? kbOverride : autoIndex - const kbActiveKey = kbIndex >= 0 ? kbRows[kbIndex].key : null - - const stepKb = (delta: -1 | 1) => { - if (kbRows.length === 0) { - return - } - - const from = kbIndex >= 0 ? kbIndex : delta === 1 ? -1 : 0 - - setKbOverride((from + delta + kbRows.length) % kbRows.length) - } - - const commitKbRow = () => { - const row = kbIndex >= 0 ? kbRows[kbIndex] : undefined - - if (!row) { - return - } - - if (row.kind === 'moa') { - void selectMoaPreset(row.preset) - - return - } - - if (row.key !== currentKey && row.family.fastId !== optionsModel) { - void selectFamily(row.family, row.provider) - } - - closeMenu() - } - - // Keep the selected row in view while arrowing through the scrollable list. - const listRef = useRef(null) - - useEffect(() => { - listRef.current?.querySelector('[data-kb-active]')?.scrollIntoView({ block: 'nearest' }) - }, [kbActiveKey]) - - // The keyboard-selected row, styled + tagged for scrollIntoView. Pointer - // suppression is NOT here — it belongs on the containers (below), so one - // class covers every row inside them. - const kbRowProps = (key: string) => { - const active = kbActiveKey === key - - return { - className: cn(dropdownMenuRow, active && 'bg-(--ui-control-active-background) text-foreground'), - ...(active ? { 'data-kb-active': '' } : {}) + if (patch.fast !== undefined) { + void patchFast(patch.fast, row.provider, row.model) + } } } - // Rows are hover-selectable, so they go inert with the pointer (usePointerQuiet). - const quietRows = pointerQuiet && 'pointer-events-none' - return ( - <> - { - // Claim arrows and Enter from Radix so DOM focus stays in the input - // and Enter commits the highlighted row without a DownArrow first - // (VS Code's checked-or-first pattern). - if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + { event.preventDefault() - event.stopPropagation() - stepKb(event.key === 'ArrowDown' ? 1 : -1) - } else if (event.key === 'Enter') { - event.preventDefault() - event.stopPropagation() - commitKbRow() - } - }} - onValueChange={value => { - setSearch(value) - setKbOverride(null) - }} - placeholder={copy.search} - value={search} - /> - - - - {loading ? ( - - {Array.from({ length: 4 }, (_, index) => ( - event.preventDefault()} - > - - - ))} - - ) : error ? ( - - {error} + void refreshModels() + }} + > + + {copy.refreshModels} - ) : groups.length === 0 && moaPresets.length === 0 ? ( - - {copy.noModels} - - ) : ( -
- {groups.map(group => { - const slug = group.provider.slug - - // Collapsed when the user stored it (and not while searching, which - // spans every model regardless of collapse state). - const collapsed = collapsedProviders.includes(slug) && !search - - return ( - - { - event.preventDefault() - toggleCollapsedProvider(slug) - }} - textValue="" - > - - - - - - {!collapsed && - group.families.map(family => { - // The active id may be the base or its -fast sibling; either - // way this one family row represents both. - const activeId = - group.provider.slug === optionsProvider && - (optionsModel === family.id || optionsModel === family.fastId) - ? optionsModel - : null - - const isCurrent = activeId !== null - const name = modelDisplayParts(family.id).name - // Capabilities are looked up against the active/base id; the - // -fast variant carries the same param support as its base. - const caps = group.provider.capabilities?.[family.id] - - // Effective settings for this row: live session state when it's - // the active model, otherwise its remembered preset (Hermes - // defaults when unset). Row label AND submenu read from these so - // they never disagree. - const preset = modelPresets[modelPresetKey(group.provider.slug, family.id)] ?? {} - const effEffort = isCurrent ? currentReasoningEffort : (preset.effort ?? '') - const effFast = isCurrent ? currentFastMode : (preset.fast ?? false) - - const fastControl = resolveFastControl( - activeId ?? family.id, - group.provider.models ?? [], - caps?.fast ?? false, - effFast - ) - - const meta = [ - fastControl.kind !== 'none' && fastControl.on ? copy.fast : null, - (caps?.reasoning ?? true) ? reasoningEffortLabel(effEffort || defaultEffort) : null - ] - .filter(Boolean) - .join(' ') - - // Every row is a hover-Edit submenu trigger. Activating it - // (pointer or keyboard) switches to the family's base model and - // restores its preset; the Fast toggle inside swaps to the -fast - // sibling (or flips the speed param). The sub-trigger has no - // `onSelect`, so wire both click and Enter/Space for keyboard parity. - // Clicking the row commits the model and closes the picker; the - // edit submenu (reasoning/fast) is reached by HOVER, so you can - // still tweak those without the click dismissing everything. - const activate = () => { - if (!isCurrent) { - void selectFamily(family, group.provider) - } - - closeMenu() - } - - return ( - - { - if (event.key === 'Enter' || event.key === ' ') { - activate() - } - }} - {...kbRowProps(`${group.provider.slug}:${family.id}`)} - > - - - {meta ? {meta} : null} - - {isCurrent ? ( - - ) : null} - - switchTo(nextModel, group.provider.slug)} - provider={group.provider.slug} - reasoning={caps?.reasoning ?? true} - requestGateway={requestGateway} - /> - - ) - })} - - ) - })} -
- )} - - - - {shownMoaPresets.length > 0 ? ( -
- MoA presets - {shownMoaPresets.map(preset => { - const isCurrentMoa = optionsProvider === 'moa' && optionsModel === preset - - return ( - { - event.preventDefault() - void selectMoaPreset(preset) - }} - {...kbRowProps(`moa:${preset}`)} - > - - MoA: - - {isCurrentMoa ? : null} - - ) - })} - -
- ) : null} - - { - event.preventDefault() - void refreshModels() - }} - > - - {copy.refreshModels} - - - setModelVisibilityOpen(true)} - > - - {copy.editModels} - - + } + gateway={gateway} + includeMoa + profile={profile} + sessionId={activeSessionId} + /> ) } - -// Collapsed we show the user's chosen models (or the curated default); typing -// spans every available model so anything is reachable past the cut. A search -// is itself a narrowing action, so we do NOT cap per-provider matches — a -// provider serving 19 models (e.g. opencode-go) must show all 19 when the user -// searches for it, not a truncated subset. (#47077 follow-up) - -function groupModels( - providers: ModelOptionProvider[], - search: string, - current: { model: string; provider: string }, - visible: Set | null -): ProviderGroup[] { - const q = normalize(search) - const groups: ProviderGroup[] = [] - - for (const provider of providers) { - const allFamilies = collapseModelFamilies(provider.models ?? []) - - if (allFamilies.length === 0) { - continue - } - - const matches = (family: ModelFamily) => - `${family.id} ${family.fastId ?? ''} ${provider.name} ${provider.slug} ${displayModelName(family.id)}` - .toLowerCase() - .includes(q) - - // Which model ids to show (the active one is always added on top of this). - let shown: Set - - if (q) { - // Search spans every family, regardless of visibility. - shown = new Set(allFamilies.filter(matches).map(family => family.id)) - } else if (visible) { - // User has customized which models show — honor their selection exactly. - shown = new Set( - allFamilies.filter(family => visible.has(modelVisibilityKey(provider.slug, family.id))).map(family => family.id) - ) - } else { - // Default: curated top-N families per provider. - shown = new Set(allFamilies.slice(0, DEFAULT_VISIBLE_PER_PROVIDER).map(family => family.id)) - } - - // Always include the active model — but keep every row in the provider's - // stable curated order (filter `allFamilies`, never reorder), so selecting - // a model can't shuffle the list. While SEARCHING, the pin is skipped: a - // query means "show me matches", and a pinned non-match sitting above them - // reads like the top result (type "grok", see the current Fable first). - const activeId = - !q && provider.slug === current.provider && current.model - ? allFamilies.find(family => family.id === current.model || family.fastId === current.model)?.id - : undefined - - const families = allFamilies.filter(family => shown.has(family.id) || family.id === activeId) - - if (families.length > 0) { - groups.push({ families, provider }) - } - } - - // Stable, logical group order: alphabetical by provider name. (The backend - // floats the current provider first, which would reshuffle on every switch.) - groups.sort((a, b) => a.provider.name.localeCompare(b.provider.name)) - - return groups -} diff --git a/apps/desktop/src/plugins/kanban/board.tsx b/apps/desktop/src/plugins/kanban/board.tsx index 5b069703a255a..2663fe7c86349 100644 --- a/apps/desktop/src/plugins/kanban/board.tsx +++ b/apps/desktop/src/plugins/kanban/board.tsx @@ -78,6 +78,12 @@ import { } from './api' import { BoardSwitcher } from './board-switcher' import { TaskDrawer } from './drawer' +import { + EMPTY_OVERRIDE, + ModelOverrideField, + overrideCreateFields, + type TaskModelOverride +} from './model-override' import { OrchestrationPanel } from './orchestration' import { columnMeta, type KanbanBoard, type KanbanTask, type TaskEstimate } from './types' import { @@ -570,6 +576,7 @@ function NewTaskDialog({ // a path here overrides just this task. Only meaningful for dir/worktree. const [workspacePath, setWorkspacePath] = useState('') const [parent, setParent] = useState('') + const [modelOverride, setModelOverride] = useState(EMPTY_OVERRIDE) const [goalMode, setGoalMode] = useState(false) const [busy, setBusy] = useState(false) const [error, setError] = useState(null) @@ -602,6 +609,7 @@ function NewTaskDialog({ setWorkspaceKind(boardDefaultKind) setWorkspacePath('') setParent('') + setModelOverride(EMPTY_OVERRIDE) setGoalMode(false) setError(null) setBusy(false) @@ -637,6 +645,7 @@ function NewTaskDialog({ title: trimmed, triage: isTriage, workspace_kind: workspaceKind, + ...overrideCreateFields(modelOverride), // Empty → backend inherits the board's default project dir. workspace_path: workspaceKind !== 'scratch' && workspacePath.trim() ? workspacePath.trim() : undefined }) @@ -661,7 +670,15 @@ function NewTaskDialog({ return ( !open && onClose()} open={Boolean(target)}> - + {/* `overflow-visible`: DialogContent publishes ITSELF as the portal + container for popovers opened inside it (dialog-portal-context), and + its default `overflow-y-auto` then crops them at the dialog's edge — + the model menu below is born inside that scroll box. This dialog + already owns a scroller on its body div, so the shell's clip is + redundant here and dropping it is safe. The general fix to + DialogContent is in flight as #75600; when that lands this override + becomes a no-op and can go. */} + {target ? k.newTaskIn(columnLabel(k, target)) : k.newTask} @@ -742,6 +759,11 @@ function NewTaskDialog({ setSkills(event.target.value)} placeholder={k.skillsPlaceholder} value={skills} /> + + + {k.modelHint} + + {parents.length > 0 && (