Merge pull request #76417 from NousResearch/bb/kanban-model-picker
Pick a kanban task's model and thinking depth from the board
This commit is contained in:
commit
97971643ab
|
|
@ -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(
|
||||
<QueryClientProvider client={client}>
|
||||
<DropdownMenu open>
|
||||
<DropdownMenuContent>
|
||||
<ModelCatalogMenu controller={controller} />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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<boolean | void> | 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<ModelOptionsResponse> => 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<KbRow[]>(
|
||||
() => [
|
||||
...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 | number>(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<HTMLDivElement>(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 (
|
||||
<>
|
||||
<DropdownMenuSearch
|
||||
aria-label={copy.search}
|
||||
onKeyDown={event => {
|
||||
// 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}
|
||||
/>
|
||||
|
||||
<DropdownMenuSeparator className="mx-0" />
|
||||
|
||||
{loading ? (
|
||||
<DropdownMenuGroup className="py-1">
|
||||
{Array.from({ length: 4 }, (_, index) => (
|
||||
<DropdownMenuItem
|
||||
className={dropdownMenuRow}
|
||||
disabled
|
||||
key={index}
|
||||
onSelect={event => event.preventDefault()}
|
||||
>
|
||||
<Skeleton className="h-4 w-full" />
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
) : error ? (
|
||||
<DropdownMenuItem className={dropdownMenuRow} disabled>
|
||||
{error}
|
||||
</DropdownMenuItem>
|
||||
) : groups.length === 0 && moaPresets.length === 0 ? (
|
||||
<DropdownMenuItem className={dropdownMenuRow} disabled>
|
||||
{copy.noModels}
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<div className={cn('max-h-[max(150px,30dvh)] overflow-y-auto py-0.5', quietRows)} ref={listRef}>
|
||||
{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 (
|
||||
<DropdownMenuGroup className="py-0.5" key={slug}>
|
||||
<DropdownMenuItem
|
||||
className="group/label flex w-full items-center gap-1 px-2 pb-0.5 pt-0.5 text-[0.625rem] font-semibold uppercase tracking-wider text-(--ui-text-tertiary) cursor-pointer !bg-transparent focus:!bg-transparent"
|
||||
onSelect={event => {
|
||||
event.preventDefault()
|
||||
toggleCollapsedProvider(slug)
|
||||
}}
|
||||
textValue=""
|
||||
>
|
||||
<span className="truncate">
|
||||
<HighlightMatches query={search} text={group.provider.name} />
|
||||
</span>
|
||||
<DisclosureCaret
|
||||
className="shrink-0 text-(--ui-text-tertiary) opacity-0 transition group-hover/label:opacity-100"
|
||||
open={!collapsed}
|
||||
size="0.625rem"
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
{!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 (
|
||||
<DropdownMenuSub key={`${group.provider.slug}:${family.id}`}>
|
||||
<DropdownMenuSubTrigger
|
||||
hideChevron
|
||||
onClick={activate}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
activate()
|
||||
}
|
||||
}}
|
||||
{...kbRowProps(`${group.provider.slug}:${family.id}`)}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
<HighlightMatches query={search} text={name} />
|
||||
{meta ? <span className="text-(--ui-text-tertiary)"> {meta}</span> : null}
|
||||
</span>
|
||||
{isCurrent ? (
|
||||
<Codicon className="ml-auto text-foreground" name="check" size="0.75rem" />
|
||||
) : null}
|
||||
</DropdownMenuSubTrigger>
|
||||
<ModelEditSubmenu
|
||||
defaultEffort={defaultEffort}
|
||||
effort={effEffort}
|
||||
fastControl={fastControl}
|
||||
isActive={isCurrent}
|
||||
model={family.id}
|
||||
onSelectModel={nextModel => 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}
|
||||
/>
|
||||
</DropdownMenuSub>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuGroup>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{shownMoaPresets.length > 0 ? (
|
||||
<div className={cn(quietRows)}>
|
||||
<DropdownMenuSeparator className="mx-0" />
|
||||
<DropdownMenuLabel className={dropdownMenuSectionLabel}>MoA presets</DropdownMenuLabel>
|
||||
{shownMoaPresets.map(preset => {
|
||||
const isCurrentMoa = current.provider === 'moa' && current.model === preset
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={`moa:${preset}`}
|
||||
onSelect={event => {
|
||||
event.preventDefault()
|
||||
void selectMoaPreset(preset)
|
||||
}}
|
||||
{...kbRowProps(`moa:${preset}`)}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
MoA: <HighlightMatches query={search} text={preset} />
|
||||
</span>
|
||||
{isCurrentMoa ? <Codicon className="ml-auto text-foreground" name="check" size="0.75rem" /> : null}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : 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. */}
|
||||
<DropdownMenuSeparator className="mx-0" />
|
||||
{footer}
|
||||
<DropdownMenuItem
|
||||
className={cn(dropdownMenuRow, 'text-(--ui-text-tertiary)')}
|
||||
onSelect={() => setModelVisibilityOpen(true)}
|
||||
>
|
||||
<Codicon name="settings-gear" size="0.75rem" />
|
||||
{copy.editModels}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** 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<string> | 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<string>
|
||||
|
||||
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
|
||||
}
|
||||
|
|
@ -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<typeof HermesApi>()
|
||||
|
||||
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<unknown> }) {
|
||||
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(
|
||||
<DropdownMenu open>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuSub open>
|
||||
<DropdownMenuSubTrigger>edit</DropdownMenuSubTrigger>
|
||||
<ModelEditSubmenu
|
||||
effort="medium"
|
||||
defaultEffort={opts.defaultEffort ?? 'medium'}
|
||||
effort={opts.effort ?? 'medium'}
|
||||
fastControl={opts.fastControl}
|
||||
isActive
|
||||
isActive={opts.isActive ?? true}
|
||||
model="m1"
|
||||
onSelectModel={vi.fn()}
|
||||
onSelectModel={opts.onSelectModel ?? vi.fn()}
|
||||
onSetOptions={opts.onSetOptions}
|
||||
provider="p1"
|
||||
reasoning={opts.reasoning}
|
||||
requestGateway={opts.requestGateway as never}
|
||||
/>
|
||||
</DropdownMenuSub>
|
||||
</DropdownMenuContent>
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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<boolean> | void
|
||||
/** This row's provider slug — edits persist as its global preset. */
|
||||
onSelectModel: (model: string) => Promise<boolean | void> | 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: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
}
|
||||
|
||||
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({
|
|||
<Switch
|
||||
checked={thinkingOn}
|
||||
className="ml-auto"
|
||||
onCheckedChange={checked => void patchReasoning(checked ? effortValue || defaultEffort : 'none')}
|
||||
onCheckedChange={checked => onSetOptions({ effort: checked ? effortValue || defaultEffort : 'none' })}
|
||||
size="xs"
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
|
|
@ -250,7 +160,7 @@ function ModelEditSubmenuBody({
|
|||
<>
|
||||
<DropdownMenuSeparator className="mx-0" />
|
||||
<DropdownMenuLabel className={dropdownMenuSectionLabel}>{copy.effort}</DropdownMenuLabel>
|
||||
<DropdownMenuRadioGroup onValueChange={value => void patchReasoning(value)} value={effortValue}>
|
||||
<DropdownMenuRadioGroup onValueChange={value => onSetOptions({ effort: value })} value={effortValue}>
|
||||
{REASONING_EFFORTS.map(value => (
|
||||
<DropdownMenuRadioItem
|
||||
className={dropdownMenuRow}
|
||||
|
|
|
|||
|
|
@ -1,51 +1,31 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { useSessionView } from '@/app/chat/session-view'
|
||||
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 { DropdownMenuItem, dropdownMenuRow } from '@/components/ui/dropdown-menu'
|
||||
import type { HermesGateway } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { modelOptionsQueryKey, requestModelOptions } from '@/lib/model-options'
|
||||
import { currentPickerSelection, displayModelName, modelDisplayParts } from '@/lib/model-status-label'
|
||||
import { DEFAULT_REASONING_EFFORT, reasoningEffortLabel } from '@/lib/reasoning-effort'
|
||||
import { normalize } from '@/lib/text'
|
||||
import { currentPickerSelection } from '@/lib/model-status-label'
|
||||
import { DEFAULT_REASONING_EFFORT } from '@/lib/reasoning-effort'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $modelPresets, applyModelPreset, modelPresetKey } from '@/store/model-presets'
|
||||
import { $modelPresets, applyModelPreset, modelPresetKey, setModelPreset } from '@/store/model-presets'
|
||||
import { $visibleModels } from '@/store/model-visibility'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
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'
|
||||
$defaultReasoningEffort,
|
||||
markComposerSelectionManual,
|
||||
setCurrentFastMode,
|
||||
setCurrentReasoningEffort
|
||||
} from '@/store/session'
|
||||
import { sessionTileDelegate } from '@/store/session-states'
|
||||
import type { ModelOptionsResponse } from '@/types/hermes'
|
||||
|
||||
import { ModelEditSubmenu, resolveFastControl } from './model-edit-submenu'
|
||||
import { ModelCatalogMenu, type ModelMenuController } from './model-catalog-menu'
|
||||
|
||||
// Lets the host dropdown (model-pill) 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>(() => {})
|
||||
export { ModelMenuCloseContext } from './model-catalog-menu'
|
||||
|
||||
export interface ModelSelection {
|
||||
model: string
|
||||
|
|
@ -62,16 +42,15 @@ interface ModelMenuPanelProps {
|
|||
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
}
|
||||
|
||||
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<ModelOptionsResponse> => 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<KbRow[]>(
|
||||
() => [
|
||||
...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 | number>(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<HTMLDivElement>(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 (
|
||||
<>
|
||||
<DropdownMenuSearch
|
||||
aria-label={copy.search}
|
||||
onKeyDown={event => {
|
||||
// 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') {
|
||||
<ModelCatalogMenu
|
||||
controller={controller}
|
||||
footer={
|
||||
<DropdownMenuItem
|
||||
className={cn(dropdownMenuRow, 'text-(--ui-text-tertiary)')}
|
||||
disabled={refreshing}
|
||||
onSelect={event => {
|
||||
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}
|
||||
/>
|
||||
|
||||
<DropdownMenuSeparator className="mx-0" />
|
||||
|
||||
{loading ? (
|
||||
<DropdownMenuGroup className="py-1">
|
||||
{Array.from({ length: 4 }, (_, index) => (
|
||||
<DropdownMenuItem
|
||||
className={dropdownMenuRow}
|
||||
disabled
|
||||
key={index}
|
||||
onSelect={event => event.preventDefault()}
|
||||
>
|
||||
<Skeleton className="h-4 w-full" />
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
) : error ? (
|
||||
<DropdownMenuItem className={dropdownMenuRow} disabled>
|
||||
{error}
|
||||
void refreshModels()
|
||||
}}
|
||||
>
|
||||
<Codicon className={cn(refreshing && 'animate-spin')} name="sync" size="0.75rem" />
|
||||
{copy.refreshModels}
|
||||
</DropdownMenuItem>
|
||||
) : groups.length === 0 && moaPresets.length === 0 ? (
|
||||
<DropdownMenuItem className={dropdownMenuRow} disabled>
|
||||
{copy.noModels}
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<div className={cn('max-h-[max(150px,30dvh)] overflow-y-auto py-0.5', quietRows)} ref={listRef}>
|
||||
{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 (
|
||||
<DropdownMenuGroup className="py-0.5" key={slug}>
|
||||
<DropdownMenuItem
|
||||
className="group/label flex w-full items-center gap-1 px-2 pb-0.5 pt-0.5 text-[0.625rem] font-semibold uppercase tracking-wider text-(--ui-text-tertiary) cursor-pointer !bg-transparent focus:!bg-transparent"
|
||||
onSelect={event => {
|
||||
event.preventDefault()
|
||||
toggleCollapsedProvider(slug)
|
||||
}}
|
||||
textValue=""
|
||||
>
|
||||
<span className="truncate">
|
||||
<HighlightMatches query={search} text={group.provider.name} />
|
||||
</span>
|
||||
<DisclosureCaret
|
||||
className="shrink-0 text-(--ui-text-tertiary) opacity-0 transition group-hover/label:opacity-100"
|
||||
open={!collapsed}
|
||||
size="0.625rem"
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
{!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 (
|
||||
<DropdownMenuSub key={`${group.provider.slug}:${family.id}`}>
|
||||
<DropdownMenuSubTrigger
|
||||
hideChevron
|
||||
onClick={activate}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
activate()
|
||||
}
|
||||
}}
|
||||
{...kbRowProps(`${group.provider.slug}:${family.id}`)}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
<HighlightMatches query={search} text={name} />
|
||||
{meta ? <span className="text-(--ui-text-tertiary)"> {meta}</span> : null}
|
||||
</span>
|
||||
{isCurrent ? (
|
||||
<Codicon className="ml-auto text-foreground" name="check" size="0.75rem" />
|
||||
) : null}
|
||||
</DropdownMenuSubTrigger>
|
||||
<ModelEditSubmenu
|
||||
effort={effEffort}
|
||||
fastControl={fastControl}
|
||||
isActive={isCurrent}
|
||||
model={family.id}
|
||||
onSelectModel={nextModel => switchTo(nextModel, group.provider.slug)}
|
||||
provider={group.provider.slug}
|
||||
reasoning={caps?.reasoning ?? true}
|
||||
requestGateway={requestGateway}
|
||||
/>
|
||||
</DropdownMenuSub>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuGroup>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DropdownMenuSeparator className="mx-0" />
|
||||
|
||||
{shownMoaPresets.length > 0 ? (
|
||||
<div className={cn(quietRows)}>
|
||||
<DropdownMenuLabel className={dropdownMenuSectionLabel}>MoA presets</DropdownMenuLabel>
|
||||
{shownMoaPresets.map(preset => {
|
||||
const isCurrentMoa = optionsProvider === 'moa' && optionsModel === preset
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={`moa:${preset}`}
|
||||
onSelect={event => {
|
||||
event.preventDefault()
|
||||
void selectMoaPreset(preset)
|
||||
}}
|
||||
{...kbRowProps(`moa:${preset}`)}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
MoA: <HighlightMatches query={search} text={preset} />
|
||||
</span>
|
||||
{isCurrentMoa ? <Codicon className="ml-auto text-foreground" name="check" size="0.75rem" /> : null}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})}
|
||||
<DropdownMenuSeparator className="mx-0" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<DropdownMenuItem
|
||||
className={cn(dropdownMenuRow, 'text-(--ui-text-tertiary)')}
|
||||
disabled={refreshing}
|
||||
onSelect={event => {
|
||||
event.preventDefault()
|
||||
void refreshModels()
|
||||
}}
|
||||
>
|
||||
<Codicon className={cn(refreshing && 'animate-spin')} name="sync" size="0.75rem" />
|
||||
{copy.refreshModels}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
className={cn(dropdownMenuRow, 'text-(--ui-text-tertiary)')}
|
||||
onSelect={() => setModelVisibilityOpen(true)}
|
||||
>
|
||||
<Codicon name="settings-gear" size="0.75rem" />
|
||||
{copy.editModels}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
}
|
||||
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<string> | 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<string>
|
||||
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<TaskModelOverride>(EMPTY_OVERRIDE)
|
||||
const [goalMode, setGoalMode] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<null | string>(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 (
|
||||
<Dialog onOpenChange={open => !open && onClose()} open={Boolean(target)}>
|
||||
<DialogContent className="w-[min(42rem,94vw)] max-w-none">
|
||||
{/* `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. */}
|
||||
<DialogContent className="w-[min(42rem,94vw)] max-w-none overflow-visible">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{target ? k.newTaskIn(columnLabel(k, target)) : k.newTask}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
|
@ -742,6 +759,11 @@ function NewTaskDialog({
|
|||
<Input onChange={event => setSkills(event.target.value)} placeholder={k.skillsPlaceholder} value={skills} />
|
||||
</Field>
|
||||
|
||||
<Field label={k.model}>
|
||||
<ModelOverrideField onChange={setModelOverride} value={modelOverride} />
|
||||
<span className="text-[0.625rem] text-(--ui-text-quaternary)">{k.modelHint}</span>
|
||||
</Field>
|
||||
|
||||
{parents.length > 0 && (
|
||||
<Field label={k.parent}>
|
||||
<Select onValueChange={v => setParent(v === NO_PARENT ? '' : v)} value={parent || NO_PARENT}>
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ import {
|
|||
taskKey,
|
||||
uploadAttachment
|
||||
} from './api'
|
||||
import { ModelOverrideField, overridePatch } from './model-override'
|
||||
import {
|
||||
type Diagnostic,
|
||||
type DiagnosticAction,
|
||||
|
|
@ -770,6 +771,16 @@ export function TaskDrawer({
|
|||
{task.workspace_path}
|
||||
</MetaRow>
|
||||
)}
|
||||
<MetaRow label={k.model}>
|
||||
<ModelOverrideField
|
||||
onChange={next => void mutate(() => patchTask(task.id, overridePatch(next)))()}
|
||||
value={{
|
||||
effort: task.reasoning_effort ?? '',
|
||||
model: task.model_override ?? '',
|
||||
provider: task.provider_override ?? ''
|
||||
}}
|
||||
/>
|
||||
</MetaRow>
|
||||
{task.created_by && <MetaRow label={k.metaCreatedBy}>{task.created_by}</MetaRow>}
|
||||
{ago(task.created_at) && <MetaRow label={k.metaCreated}>{ago(task.created_at)}</MetaRow>}
|
||||
{running && task.worker_pid ? <MetaRow label={k.metaWorkerPid}>{task.worker_pid}</MetaRow> : null}
|
||||
|
|
|
|||
|
|
@ -65,6 +65,10 @@ type KanbanMessages = {
|
|||
workspace: string
|
||||
boardDefaultSuffix: string
|
||||
workspaceOverride: string
|
||||
model: string
|
||||
modelInherit: string
|
||||
modelClear: string
|
||||
modelHint: string
|
||||
workspaceInherit: string
|
||||
workspaceInheritDir: (dir: string) => string
|
||||
workspaceInheritGeneric: string
|
||||
|
|
@ -250,6 +254,10 @@ const en: KanbanMessages = {
|
|||
workspace: 'Workspace',
|
||||
boardDefaultSuffix: ' · board default',
|
||||
workspaceOverride: 'Workspace path (optional override)',
|
||||
model: 'Model',
|
||||
modelInherit: 'Profile default',
|
||||
modelClear: 'Clear model override',
|
||||
modelHint: 'Runs this task on a specific model and thinking depth. Unset uses the assigned profile’s own.',
|
||||
workspaceInherit: 'Inherits the board’s project directory',
|
||||
workspaceInheritDir: dir => `Leave empty to inherit ${dir}`,
|
||||
workspaceInheritGeneric: 'Leave empty to inherit the board’s project directory.',
|
||||
|
|
@ -438,6 +446,10 @@ const ja: KanbanMessages = {
|
|||
workspace: 'ワークスペース',
|
||||
boardDefaultSuffix: '・ボード既定',
|
||||
workspaceOverride: 'ワークスペースパス(任意の上書き)',
|
||||
model: 'モデル',
|
||||
modelInherit: 'プロファイル既定',
|
||||
modelClear: 'モデル指定を解除',
|
||||
modelHint: 'このタスクを特定のモデルと思考深度で実行します。未設定なら担当プロファイルの設定を使用します。',
|
||||
workspaceInherit: 'ボードのプロジェクトディレクトリを継承',
|
||||
workspaceInheritDir: dir => `空欄にすると ${dir} を継承します`,
|
||||
workspaceInheritGeneric: '空欄にするとボードのプロジェクトディレクトリを継承します。',
|
||||
|
|
@ -624,6 +636,10 @@ const zh: KanbanMessages = {
|
|||
workspace: '工作区',
|
||||
boardDefaultSuffix: '・面板默认',
|
||||
workspaceOverride: '工作区路径(可选覆盖)',
|
||||
model: '模型',
|
||||
modelInherit: '配置文件默认',
|
||||
modelClear: '清除模型覆盖',
|
||||
modelHint: '让该任务使用指定的模型与思考深度。未设置时使用所指派配置文件自身的设置。',
|
||||
workspaceInherit: '继承面板的项目目录',
|
||||
workspaceInheritDir: dir => `留空则继承 ${dir}`,
|
||||
workspaceInheritGeneric: '留空则继承面板的项目目录。',
|
||||
|
|
@ -808,6 +824,10 @@ const zhHant: KanbanMessages = {
|
|||
workspace: '工作區',
|
||||
boardDefaultSuffix: '・面板預設',
|
||||
workspaceOverride: '工作區路徑(選填覆寫)',
|
||||
model: '模型',
|
||||
modelInherit: '設定檔預設',
|
||||
modelClear: '清除模型覆寫',
|
||||
modelHint: '讓此任務使用指定的模型與思考深度。未設定時使用所指派設定檔本身的設定。',
|
||||
workspaceInherit: '繼承面板的專案目錄',
|
||||
workspaceInheritDir: dir => `留空則繼承 ${dir}`,
|
||||
workspaceInheritGeneric: '留空則繼承面板的專案目錄。',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,148 @@
|
|||
import { DropdownMenu, DropdownMenuContent, ModelCatalogMenu, type ModelMenuController } from '@hermes/plugin-sdk'
|
||||
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 {
|
||||
EMPTY_OVERRIDE,
|
||||
isInherited,
|
||||
overrideCreateFields,
|
||||
overrideLabel,
|
||||
overridePatch,
|
||||
type TaskModelOverride
|
||||
} from './model-override'
|
||||
|
||||
// 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(() => {
|
||||
getGlobalModelOptions.mockResolvedValue({
|
||||
providers: [{ models: ['gemini-3.1-pro', 'gemini-2.5-flash'], name: 'Google', slug: 'google' }]
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('override label', () => {
|
||||
it('reads as inherited until something is pinned', () => {
|
||||
expect(isInherited(EMPTY_OVERRIDE)).toBe(true)
|
||||
expect(overrideLabel(EMPTY_OVERRIDE, 'Profile default')).toBe('Profile default')
|
||||
})
|
||||
|
||||
it('shows provider: model · Effort', () => {
|
||||
expect(overrideLabel({ effort: 'high', model: 'gemini-3.1-pro', provider: 'google' }, 'x')).toBe(
|
||||
'google: gemini-3.1-pro · High'
|
||||
)
|
||||
})
|
||||
|
||||
it('a depth-only pin is still an override', () => {
|
||||
const depthOnly: TaskModelOverride = { effort: 'ultra', model: '', provider: '' }
|
||||
|
||||
expect(isInherited(depthOnly)).toBe(false)
|
||||
expect(overrideLabel(depthOnly, 'Profile default')).toBe('Profile default · Ultra')
|
||||
})
|
||||
})
|
||||
|
||||
// The override drives the SHARED composer menu through a controller. This is
|
||||
// the seam that keeps the two surfaces identical, so exercise the real
|
||||
// component rather than a stand-in.
|
||||
describe('the shared catalog menu, driven by an override controller', () => {
|
||||
function renderMenu(value: TaskModelOverride = EMPTY_OVERRIDE) {
|
||||
const onChange = vi.fn()
|
||||
|
||||
const controller: ModelMenuController = {
|
||||
applyPreset: (preset, row) => onChange({ effort: preset.effort ?? '', model: row.model, provider: row.provider }),
|
||||
current: { effort: value.effort, fast: false, model: value.model, provider: value.provider },
|
||||
presetFor: () => ({}),
|
||||
select: (model, provider) => onChange({ ...value, model, provider }),
|
||||
setOptions: (patch, row) => {
|
||||
if (patch.effort !== undefined) {
|
||||
onChange({ effort: patch.effort, model: row.model, provider: row.provider })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={client}>
|
||||
<DropdownMenu open>
|
||||
<DropdownMenuContent>
|
||||
<ModelCatalogMenu controller={controller} />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
|
||||
return onChange
|
||||
}
|
||||
|
||||
it('lists the catalog and reports a picked model', async () => {
|
||||
const onChange = renderMenu()
|
||||
|
||||
// Rows render the display name; the controller receives the raw id.
|
||||
fireEvent.click(await screen.findByText(/Gemini 3\.1 Pro/i))
|
||||
|
||||
const picked = onChange.mock.calls.at(-1)![0]
|
||||
|
||||
expect(picked.model).toBe('gemini-3.1-pro')
|
||||
expect(picked.provider).toBe('google')
|
||||
})
|
||||
|
||||
it('never renders MoA presets — a preset is not a worker model', async () => {
|
||||
getGlobalModelOptions.mockResolvedValue({
|
||||
providers: [
|
||||
{ models: ['gemini-3.1-pro'], name: 'Google', slug: 'google' },
|
||||
{ models: ['BeastMode'], name: 'Mixture of Agents', slug: 'moa' }
|
||||
]
|
||||
})
|
||||
|
||||
renderMenu()
|
||||
await screen.findByText(/Gemini 3\.1 Pro/i)
|
||||
|
||||
expect(screen.queryByText(/BeastMode/)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// A PATCH body can't say "set to NULL" with a missing key, so clears are
|
||||
// explicit flags; a create should instead OMIT what the user never touched so
|
||||
// the backend's own defaults still apply.
|
||||
describe('REST field mapping', () => {
|
||||
it('create omits untouched fields', () => {
|
||||
expect(overrideCreateFields(EMPTY_OVERRIDE)).toEqual({})
|
||||
expect(overrideCreateFields({ effort: 'high', model: 'm', provider: 'p' })).toEqual({
|
||||
model_override: 'm',
|
||||
provider_override: 'p',
|
||||
reasoning_effort: 'high'
|
||||
})
|
||||
})
|
||||
|
||||
it('patch clears explicitly', () => {
|
||||
expect(overridePatch(EMPTY_OVERRIDE)).toEqual({ clear_model_override: true, clear_reasoning_effort: true })
|
||||
})
|
||||
|
||||
it('patch can pin depth alone, leaving the model to the profile', () => {
|
||||
expect(overridePatch({ effort: 'ultra', model: '', provider: '' })).toEqual({
|
||||
clear_model_override: true,
|
||||
reasoning_effort: 'ultra'
|
||||
})
|
||||
})
|
||||
|
||||
it('a provider is never sent without its model', () => {
|
||||
expect(overrideCreateFields({ effort: '', model: '', provider: 'google' })).toEqual({})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,170 @@
|
|||
/**
|
||||
* Per-task model override — the board's half of the composer's model picker.
|
||||
*
|
||||
* The MENU is not ours: `ModelCatalogMenu` from the SDK is the same component
|
||||
* the chat composer renders, so search, provider grouping, `-fast` families,
|
||||
* and the thinking/effort submenu behave identically here. What differs is
|
||||
* what a selection MEANS — the controller below holds a detached value instead
|
||||
* of writing to a live session, which is exactly the seam the SDK exposes.
|
||||
*
|
||||
* An unset value means "inherit the assigned profile's own model + effort",
|
||||
* which is what every kanban task did before this existed.
|
||||
*/
|
||||
|
||||
import {
|
||||
Button,
|
||||
cn,
|
||||
Codicon,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
ModelCatalogMenu,
|
||||
ModelMenuCloseContext,
|
||||
type ModelMenuController,
|
||||
reasoningEffortLabel
|
||||
} from '@hermes/plugin-sdk'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { useKanban } from './ui'
|
||||
|
||||
/** A task's model override. Empty strings mean "inherit the profile". */
|
||||
export interface TaskModelOverride {
|
||||
/** '' = profile default, 'none' = thinking off, else a reasoning level. */
|
||||
effort: string
|
||||
model: string
|
||||
provider: string
|
||||
}
|
||||
|
||||
export const EMPTY_OVERRIDE: TaskModelOverride = { effort: '', model: '', provider: '' }
|
||||
|
||||
/** True when nothing is pinned and the worker profile decides everything. */
|
||||
export const isInherited = (value: TaskModelOverride): boolean =>
|
||||
!value.model.trim() && !value.provider.trim() && !value.effort.trim()
|
||||
|
||||
/** The trigger's label: `provider: model · High`, or the inherit copy. */
|
||||
export function overrideLabel(value: TaskModelOverride, inheritCopy: string): string {
|
||||
if (isInherited(value)) {
|
||||
return inheritCopy
|
||||
}
|
||||
|
||||
const model = value.model.trim()
|
||||
const base = model ? (value.provider.trim() ? `${value.provider}: ${model}` : model) : inheritCopy
|
||||
const effort = value.effort.trim() ? reasoningEffortLabel(value.effort) : ''
|
||||
|
||||
return effort ? `${base} · ${effort}` : base
|
||||
}
|
||||
|
||||
/**
|
||||
* The picker itself. Controlled: the caller owns the value, so the New Task
|
||||
* dialog can hold it as form state and the drawer can PATCH on change.
|
||||
*/
|
||||
export function ModelOverrideField({
|
||||
onChange,
|
||||
value
|
||||
}: {
|
||||
onChange: (next: TaskModelOverride) => void
|
||||
value: TaskModelOverride
|
||||
}) {
|
||||
const k = useKanban()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const controller: ModelMenuController = {
|
||||
// Picking a model seeds the depth from what the user last used for it, so
|
||||
// the board behaves like the composer. We only READ presets — a per-task
|
||||
// choice must never rewrite what the composer opens at.
|
||||
applyPreset: (preset, row) =>
|
||||
onChange({
|
||||
effort: preset.effort ?? '',
|
||||
model: row.model,
|
||||
provider: row.provider
|
||||
}),
|
||||
|
||||
current: { effort: value.effort, fast: false, model: value.model, provider: value.provider },
|
||||
|
||||
// Read-only against Hermes' global presets is deliberate: see applyPreset.
|
||||
presetFor: () => ({}),
|
||||
|
||||
select: (model, provider) => {
|
||||
onChange({ ...value, model, provider })
|
||||
},
|
||||
|
||||
// Fast mode is a live-session request parameter, not something the worker
|
||||
// spawn can carry (there is no --fast flag), so the menu is told this
|
||||
// catalog has no fast support and only effort edits arrive here.
|
||||
setOptions: (patch, row) => {
|
||||
if (patch.effort === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
onChange({ effort: patch.effort, model: row.model, provider: row.provider })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu onOpenChange={setOpen} open={open}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
className={cn(
|
||||
'h-8 w-full justify-between gap-2 px-2.5 text-[0.75rem] font-normal',
|
||||
isInherited(value) && 'text-(--ui-text-tertiary)'
|
||||
)}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<span className="min-w-0 truncate">{overrideLabel(value, k.modelInherit)}</span>
|
||||
<span className="flex shrink-0 items-center gap-1">
|
||||
{!isInherited(value) && (
|
||||
<span
|
||||
aria-label={k.modelClear}
|
||||
className="grid size-4 place-items-center rounded text-(--ui-text-tertiary) hover:text-foreground"
|
||||
onClick={event => {
|
||||
// Clear without opening the menu.
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
onChange(EMPTY_OVERRIDE)
|
||||
}}
|
||||
role="button"
|
||||
>
|
||||
<Codicon name="close" size="0.7rem" />
|
||||
</span>
|
||||
)}
|
||||
<Codicon className="opacity-50" name="chevron-down" size="0.7rem" />
|
||||
</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-72 p-0">
|
||||
<ModelMenuCloseContext.Provider value={() => setOpen(false)}>
|
||||
<ModelCatalogMenu controller={controller} />
|
||||
</ModelMenuCloseContext.Provider>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
/** Translate an override into the REST fields the kanban API expects.
|
||||
* Used by both create (POST) and edit (PATCH); the explicit clear flags exist
|
||||
* because `null` in a PATCH body means "field not sent", not "set to NULL". */
|
||||
export function overridePatch(value: TaskModelOverride): Record<string, unknown> {
|
||||
const model = value.model.trim()
|
||||
const effort = value.effort.trim()
|
||||
|
||||
return {
|
||||
...(model
|
||||
? { model_override: model, provider_override: value.provider.trim() || undefined }
|
||||
: { clear_model_override: true }),
|
||||
...(effort ? { reasoning_effort: effort } : { clear_reasoning_effort: true })
|
||||
}
|
||||
}
|
||||
|
||||
/** The create-time shape: omit rather than clear, so the backend's own
|
||||
* defaults apply to fields the user never touched. */
|
||||
export function overrideCreateFields(value: TaskModelOverride): Record<string, unknown> {
|
||||
const model = value.model.trim()
|
||||
const effort = value.effort.trim()
|
||||
|
||||
return {
|
||||
...(model ? { model_override: model } : {}),
|
||||
...(model && value.provider.trim() ? { provider_override: value.provider.trim() } : {}),
|
||||
...(effort ? { reasoning_effort: effort } : {})
|
||||
}
|
||||
}
|
||||
|
|
@ -97,6 +97,11 @@ export interface KanbanAttachment {
|
|||
export interface KanbanTaskFull extends KanbanTask {
|
||||
result?: null | string
|
||||
created_by?: null | string
|
||||
/** Per-task worker overrides. Null/absent = the assigned profile's own
|
||||
* model, provider, and reasoning effort decide. */
|
||||
model_override?: null | string
|
||||
provider_override?: null | string
|
||||
reasoning_effort?: null | string
|
||||
completed_at?: null | number
|
||||
last_failure_error?: null | string
|
||||
workspace_kind?: null | string
|
||||
|
|
|
|||
|
|
@ -122,6 +122,18 @@ export { COMPOSER_AREAS, type ComposerAttachmentProvider, type ComposerMiddlewar
|
|||
|
||||
export { PALETTE_AREA, type PaletteContribution } from '@/app/command-palette/contrib'
|
||||
export { type RouteContribution, ROUTES_AREA, SIDEBAR_NAV_AREA, type SidebarNavContribution } from '@/app/routes'
|
||||
/** THE model catalog menu — the same searchable, provider-grouped, family-
|
||||
* collapsing picker the chat composer uses, including the per-row
|
||||
* thinking/effort/fast submenu. Drive it with a `ModelMenuController`: the
|
||||
* menu renders and navigates, your controller decides what a selection MEANS
|
||||
* (write to a session, hold a per-task override, …). Never fork it — a copy
|
||||
* drifts from the composer the first time either side changes. */
|
||||
export {
|
||||
ModelCatalogMenu,
|
||||
type ModelChoice,
|
||||
ModelMenuCloseContext,
|
||||
type ModelMenuController
|
||||
} from '@/app/shell/model-catalog-menu'
|
||||
export type { StatusbarItem } from '@/app/shell/statusbar-controls'
|
||||
|
||||
export type { TitlebarTool } from '@/app/shell/titlebar-controls'
|
||||
|
|
@ -184,9 +196,6 @@ export { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
|||
export { Textarea } from '@/components/ui/textarea'
|
||||
export { Tip, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
export type { GatewayEventListener } from '@/contrib/events'
|
||||
|
||||
// -- contracts ----------------------------------------------------------------
|
||||
|
||||
export type {
|
||||
HermesPlugin,
|
||||
PluginContext,
|
||||
|
|
@ -194,6 +203,9 @@ export type {
|
|||
PluginRestOptions,
|
||||
PluginStorage
|
||||
} from '@/contrib/plugin'
|
||||
|
||||
// -- contracts ----------------------------------------------------------------
|
||||
|
||||
/** Mount-scoped contribution: while the rendering component is mounted, its
|
||||
* children render in the target area's slot; unmount disposes it. Use for
|
||||
* page-owned chrome (a page's titlebar control leaves with the page) —
|
||||
|
|
@ -228,12 +240,21 @@ export { type KeybindContribution, KEYBINDS_AREA } from '@/lib/keybinds/actions'
|
|||
* authors) + its translucent tag fill — so plugin-rendered identities read
|
||||
* the same hue as everywhere else. */
|
||||
export { profileColor, profileColorSoft } from '@/lib/profile-color'
|
||||
|
||||
export const PANES_AREA = 'panes'
|
||||
/** The shared client itself, for invalidation OUTSIDE React (e.g. a
|
||||
* `ctx.socket` frame invalidating a query). Inside components keep using
|
||||
* `useQueryClient`. */
|
||||
export { queryClient } from '@/lib/query-client'
|
||||
|
||||
export const PANES_AREA = 'panes'
|
||||
/** Hermes' reasoning levels + their compact labels, so a plugin surfacing a
|
||||
* thinking depth uses the same scale and spelling as the rest of the app. */
|
||||
export {
|
||||
DEFAULT_REASONING_EFFORT,
|
||||
REASONING_EFFORT_VALUES,
|
||||
REASONING_EFFORTS,
|
||||
type ReasoningEffort,
|
||||
reasoningEffortLabel
|
||||
} from '@/lib/reasoning-effort'
|
||||
export const STATUSBAR_AREAS = { left: 'statusBar.left', right: 'statusBar.right' } as const
|
||||
export const TITLEBAR_AREAS = { center: 'titleBar.center', left: 'titleBar.left', right: 'titleBar.right' } as const
|
||||
|
||||
|
|
|
|||
19
cli.py
19
cli.py
|
|
@ -4205,6 +4205,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
|
|||
model: str = None,
|
||||
toolsets: List[str] = None,
|
||||
provider: str = None,
|
||||
reasoning: str = None,
|
||||
api_key: str = None,
|
||||
base_url: str = None,
|
||||
max_turns: int = None,
|
||||
|
|
@ -4222,6 +4223,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
|
|||
model: Model to use (default: from env or claude-sonnet)
|
||||
toolsets: List of toolsets to enable (default: all)
|
||||
provider: Inference provider ("auto", "openrouter", "nous", "openai-codex", "zai", "kimi-coding", "minimax", "minimax-cn")
|
||||
reasoning: Reasoning effort override for this run (none|minimal|low|medium|high|xhigh|max|ultra). Wins over config.
|
||||
api_key: API key (default: from environment)
|
||||
base_url: API base URL (default: OpenRouter)
|
||||
max_turns: Maximum tool-calling iterations shared with subagents (default: 500)
|
||||
|
|
@ -4487,6 +4489,20 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
|
|||
# shared chokepoint in hermes_constants (Closes #21256).
|
||||
from hermes_constants import resolve_reasoning_config
|
||||
self.reasoning_config = resolve_reasoning_config(CLI_CONFIG, self.model)
|
||||
# An explicit --reasoning wins over config for this run only (never
|
||||
# persisted). Kanban's dispatcher uses it to pin a task's thinking
|
||||
# depth without touching the worker profile's config.yaml. An
|
||||
# unparseable level is ignored with a warning rather than silently
|
||||
# swapping in the default — same contract as the config path.
|
||||
if reasoning is not None and str(reasoning).strip():
|
||||
_cli_reasoning = _parse_reasoning_config(reasoning)
|
||||
if _cli_reasoning is None:
|
||||
logger.warning(
|
||||
"Unknown --reasoning '%s', keeping the configured level",
|
||||
reasoning,
|
||||
)
|
||||
else:
|
||||
self.reasoning_config = _cli_reasoning
|
||||
self.service_tier = _parse_service_tier_config(
|
||||
CLI_CONFIG["agent"].get("service_tier", "")
|
||||
)
|
||||
|
|
@ -17842,6 +17858,7 @@ def main(
|
|||
skills: str | list[str] | tuple[str, ...] = None,
|
||||
model: str = None,
|
||||
provider: str = None,
|
||||
reasoning: str = None,
|
||||
api_key: str = None,
|
||||
base_url: str = None,
|
||||
max_turns: int = None,
|
||||
|
|
@ -17870,6 +17887,7 @@ def main(
|
|||
skills: Comma-separated or repeated list of skills to preload for the session
|
||||
model: Model to use (default: anthropic/claude-opus-4-20250514)
|
||||
provider: Inference provider ("auto", "openrouter", "nous", "openai-codex", "zai", "kimi-coding", "minimax", "minimax-cn")
|
||||
reasoning: Reasoning effort for this run (none|minimal|low|medium|high|xhigh|max|ultra). Overrides agent.reasoning_effort.
|
||||
api_key: API key for authentication
|
||||
base_url: Base URL for the API
|
||||
max_turns: Maximum tool-calling iterations (default: 60)
|
||||
|
|
@ -17984,6 +18002,7 @@ def main(
|
|||
model=model,
|
||||
toolsets=toolsets_list,
|
||||
provider=provider,
|
||||
reasoning=reasoning,
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
max_turns=max_turns,
|
||||
|
|
|
|||
|
|
@ -147,6 +147,18 @@ def build_top_level_parser():
|
|||
"under model.provider — use `hermes setup` or edit the file to change it."
|
||||
),
|
||||
)
|
||||
_inherited_flag(
|
||||
parser,
|
||||
"--reasoning",
|
||||
default=None,
|
||||
metavar="LEVEL",
|
||||
help=(
|
||||
"Reasoning effort for this invocation: none, minimal, low, medium, "
|
||||
"high, xhigh, max, or ultra. Overrides agent.reasoning_effort in "
|
||||
"config.yaml for this run only; the persistent level lives there "
|
||||
"(or per-model under agent.reasoning_overrides)."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"-t",
|
||||
"--toolsets",
|
||||
|
|
@ -299,6 +311,17 @@ def build_top_level_parser():
|
|||
default=argparse.SUPPRESS,
|
||||
help="Comma-separated toolsets to enable",
|
||||
)
|
||||
_inherited_flag(
|
||||
chat_parser,
|
||||
"--reasoning",
|
||||
default=argparse.SUPPRESS,
|
||||
metavar="LEVEL",
|
||||
help=(
|
||||
"Reasoning effort for this session: none, minimal, low, medium, "
|
||||
"high, xhigh, max, or ultra. Overrides agent.reasoning_effort for "
|
||||
"this run only (same levels as the /reasoning slash command)."
|
||||
),
|
||||
)
|
||||
_inherited_flag(
|
||||
chat_parser,
|
||||
"-s",
|
||||
|
|
|
|||
|
|
@ -133,6 +133,30 @@ VALID_BLOCK_KINDS = {"dependency", "needs_input", "capability", "transient"}
|
|||
# not dispatcher spawn/crash/timeout failures.
|
||||
BLOCK_RECURRENCE_LIMIT = 2
|
||||
VALID_WORKSPACE_KINDS = {"scratch", "worktree", "dir"}
|
||||
|
||||
|
||||
def normalize_reasoning_effort(effort: Optional[str]) -> Optional[str]:
|
||||
"""Normalize a per-task reasoning effort into a storable level.
|
||||
|
||||
Accepts any level in ``hermes_constants.VALID_REASONING_EFFORTS`` plus
|
||||
``"none"`` (thinking disabled), case-insensitively. Empty / None means
|
||||
"inherit the worker profile's own ``agent.reasoning_effort``" and stores
|
||||
NULL. Anything else is rejected rather than silently dropped — a typo'd
|
||||
level must not quietly hand the task back to the profile default.
|
||||
"""
|
||||
from hermes_constants import VALID_REASONING_EFFORTS
|
||||
|
||||
value = str(effort or "").strip().lower()
|
||||
if not value:
|
||||
return None
|
||||
if value == "none" or value in VALID_REASONING_EFFORTS:
|
||||
return value
|
||||
allowed = ", ".join(("none", *VALID_REASONING_EFFORTS))
|
||||
raise ValueError(
|
||||
f"reasoning_effort must be one of {allowed}, got {effort!r}"
|
||||
)
|
||||
|
||||
|
||||
KNOWN_TOOLSET_NAMES = frozenset(name.casefold() for name in get_toolset_names())
|
||||
_IS_WINDOWS = sys.platform == "win32"
|
||||
KANBAN_ATTACHMENT_MAX_BYTES = 25 * 1024 * 1024
|
||||
|
|
@ -929,6 +953,12 @@ class Task:
|
|||
# model (pre-existing behaviour). Solves the "model from provider A,
|
||||
# profile configured for provider B" mismatch class.
|
||||
provider_override: Optional[str] = None
|
||||
# Per-task reasoning effort for the worker (one of
|
||||
# ``hermes_constants.VALID_REASONING_EFFORTS``, or ``"none"`` for thinking
|
||||
# off). When set, the dispatcher passes ``--reasoning <level>`` so the
|
||||
# worker runs at that depth regardless of the profile's
|
||||
# ``agent.reasoning_effort``. NULL = the worker profile's own setting.
|
||||
reasoning_effort: Optional[str] = None
|
||||
# Per-task override for the consecutive-failure circuit breaker.
|
||||
# The value is the failure count at which the breaker trips — e.g.
|
||||
# ``max_retries=1`` blocks on the first failure (zero retries),
|
||||
|
|
@ -1032,6 +1062,11 @@ class Task:
|
|||
if "provider_override" in keys and row["provider_override"]
|
||||
else None
|
||||
),
|
||||
reasoning_effort=(
|
||||
row["reasoning_effort"]
|
||||
if "reasoning_effort" in keys and row["reasoning_effort"]
|
||||
else None
|
||||
),
|
||||
max_retries=(
|
||||
row["max_retries"] if "max_retries" in keys else None
|
||||
),
|
||||
|
|
@ -1200,6 +1235,11 @@ CREATE TABLE IF NOT EXISTS tasks (
|
|||
-- worker resolves the model against the right backend instead of the
|
||||
-- profile's configured provider. NULL = profile provider.
|
||||
provider_override TEXT,
|
||||
-- Per-task reasoning effort for the worker (minimal|low|medium|high|
|
||||
-- xhigh|max|ultra, or 'none' for thinking off). When set, the dispatcher
|
||||
-- passes --reasoning <level> so the worker runs at that depth regardless
|
||||
-- of the profile's agent.reasoning_effort. NULL = profile setting.
|
||||
reasoning_effort TEXT,
|
||||
-- Per-task override for the consecutive-failure circuit breaker.
|
||||
-- The value is the failure count at which the breaker trips — e.g.
|
||||
-- ``max_retries=1`` blocks on the first failure. NULL (the common
|
||||
|
|
@ -2388,6 +2428,13 @@ def _migrate_add_optional_columns(conn: sqlite3.Connection) -> None:
|
|||
conn, "tasks", "provider_override", "provider_override TEXT"
|
||||
)
|
||||
|
||||
if "reasoning_effort" not in cols:
|
||||
# Per-task thinking depth for the worker. NULL = the worker profile's
|
||||
# own agent.reasoning_effort, which is what existing rows were getting.
|
||||
_add_column_if_missing(
|
||||
conn, "tasks", "reasoning_effort", "reasoning_effort TEXT"
|
||||
)
|
||||
|
||||
if "goal_mode" not in cols:
|
||||
# Ralph-style goal loop toggle for the dispatched worker. 0 (the
|
||||
# default) = classic single-shot worker, preserving the behaviour
|
||||
|
|
@ -2851,6 +2898,7 @@ def create_task(
|
|||
max_retries: Optional[int] = None,
|
||||
model_override: Optional[str] = None,
|
||||
provider_override: Optional[str] = None,
|
||||
reasoning_effort: Optional[str] = None,
|
||||
goal_mode: bool = False,
|
||||
goal_max_turns: Optional[int] = None,
|
||||
initial_status: str = "running",
|
||||
|
|
@ -2887,6 +2935,11 @@ def create_task(
|
|||
config — passed to the worker as ``-m <model> [--provider <name>]``.
|
||||
``provider_override`` requires ``model_override``.
|
||||
|
||||
``reasoning_effort`` pins the worker's thinking depth for this task
|
||||
(``minimal``…``ultra``, or ``none`` to disable thinking), passed as
|
||||
``--reasoning <level>``. It is independent of ``model_override``: a task
|
||||
can run the profile's own model at a different depth.
|
||||
|
||||
``project_source_task_id`` is an internal cross-profile fallback for a
|
||||
worker-created child. When the active profile cannot resolve ``project_id``
|
||||
in its own projects.db, a matching canonical project-linked task in this
|
||||
|
|
@ -2895,6 +2948,7 @@ def create_task(
|
|||
"""
|
||||
model_override = (model_override or "").strip() or None
|
||||
provider_override = (provider_override or "").strip() or None
|
||||
reasoning_effort = normalize_reasoning_effort(reasoning_effort)
|
||||
if provider_override and not model_override:
|
||||
raise ValueError("provider_override requires a model_override")
|
||||
assignee = _canonical_assignee(assignee)
|
||||
|
|
@ -3162,8 +3216,9 @@ def create_task(
|
|||
branch_name, project_id, tenant, idempotency_key,
|
||||
max_runtime_seconds,
|
||||
skills, max_retries, model_override, provider_override,
|
||||
reasoning_effort,
|
||||
goal_mode, goal_max_turns, session_id
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
task_id,
|
||||
|
|
@ -3185,6 +3240,7 @@ def create_task(
|
|||
int(max_retries) if max_retries is not None else None,
|
||||
model_override,
|
||||
provider_override,
|
||||
reasoning_effort,
|
||||
1 if goal_mode else 0,
|
||||
int(goal_max_turns) if goal_max_turns is not None else None,
|
||||
session_id,
|
||||
|
|
@ -3427,6 +3483,44 @@ def set_model_override(
|
|||
return True
|
||||
|
||||
|
||||
def set_reasoning_effort(
|
||||
conn: sqlite3.Connection,
|
||||
task_id: str,
|
||||
effort: Optional[str],
|
||||
) -> bool:
|
||||
"""Set (or clear) the per-task reasoning effort.
|
||||
|
||||
``effort=None`` (or empty) clears the override — the worker falls back to
|
||||
its profile's own ``agent.reasoning_effort``. ``"none"`` is a real value,
|
||||
not a clear: it pins thinking OFF for this task.
|
||||
|
||||
Deliberately independent of :func:`set_model_override`: a task may run the
|
||||
profile's own model at a different depth, and clearing a model override
|
||||
must not silently reset the depth the operator chose. Like the model
|
||||
override, it takes effect on the NEXT dispatch, so it is settable on a
|
||||
running task. Returns True on success.
|
||||
"""
|
||||
effort = normalize_reasoning_effort(effort)
|
||||
with write_txn(conn):
|
||||
row = conn.execute(
|
||||
"SELECT status FROM tasks WHERE id = ?", (task_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
return False
|
||||
if row["status"] == "archived":
|
||||
raise RuntimeError(
|
||||
f"cannot set reasoning effort on archived task {task_id}"
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE tasks SET reasoning_effort = ? WHERE id = ?",
|
||||
(effort, task_id),
|
||||
)
|
||||
_append_event(
|
||||
conn, task_id, "reasoning_effort_set", {"reasoning_effort": effort}
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Links
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -9026,6 +9120,11 @@ def _default_spawn(
|
|||
# the classic mis-set that stalls a board).
|
||||
if task.provider_override:
|
||||
cmd.extend(["--provider", task.provider_override])
|
||||
# Per-task thinking depth. Independent of the model override — a task can
|
||||
# run the profile's own model at a different depth — so this is its own
|
||||
# branch, not a nested one.
|
||||
if task.reasoning_effort:
|
||||
cmd.extend(["--reasoning", task.reasoning_effort])
|
||||
worker_toolsets = _resolve_worker_cli_toolsets(env.get("HERMES_HOME"))
|
||||
if worker_toolsets:
|
||||
cmd.extend(["--toolsets", ",".join(worker_toolsets)])
|
||||
|
|
|
|||
|
|
@ -2693,6 +2693,7 @@ def cmd_chat(args):
|
|||
kwargs = {
|
||||
"model": args.model,
|
||||
"provider": getattr(args, "provider", None),
|
||||
"reasoning": getattr(args, "reasoning", None),
|
||||
"toolsets": args.toolsets,
|
||||
"skills": getattr(args, "skills", None),
|
||||
"verbose": getattr(args, "verbose", None),
|
||||
|
|
|
|||
|
|
@ -610,6 +610,9 @@ class CreateTaskBody(BaseModel):
|
|||
goal_max_turns: Optional[int] = None
|
||||
model_override: Optional[str] = None
|
||||
provider_override: Optional[str] = None
|
||||
# Per-task thinking depth (none|minimal|…|ultra). None = inherit the
|
||||
# assigned profile's own agent.reasoning_effort.
|
||||
reasoning_effort: Optional[str] = None
|
||||
# Explicit project link; when omitted, create_task inherits the board's
|
||||
# scoped project (if any) so a project-scoped board anchors every task.
|
||||
project_id: Optional[str] = None
|
||||
|
|
@ -639,6 +642,7 @@ def create_task(payload: CreateTaskBody, board: Optional[str] = Query(None)):
|
|||
goal_max_turns=payload.goal_max_turns,
|
||||
model_override=payload.model_override,
|
||||
provider_override=payload.provider_override,
|
||||
reasoning_effort=payload.reasoning_effort,
|
||||
project_id=payload.project_id,
|
||||
board=board,
|
||||
)
|
||||
|
|
@ -839,6 +843,12 @@ class UpdateTaskBody(BaseModel):
|
|||
model_override: Optional[str] = None
|
||||
provider_override: Optional[str] = None
|
||||
clear_model_override: bool = False
|
||||
# Per-task thinking depth. ``"none"`` is a VALUE (thinking off), not a
|
||||
# clear — use ``clear_reasoning_effort=True`` to fall back to the
|
||||
# profile's own level. Separate from the model clear so dropping a model
|
||||
# override doesn't silently reset the depth the operator chose.
|
||||
reasoning_effort: Optional[str] = None
|
||||
clear_reasoning_effort: bool = False
|
||||
|
||||
|
||||
@router.patch("/tasks/{task_id}")
|
||||
|
|
@ -934,6 +944,19 @@ def update_task(task_id: str, payload: UpdateTaskBody, board: Optional[str] = Qu
|
|||
if not ok:
|
||||
raise HTTPException(status_code=404, detail="task not found")
|
||||
|
||||
# --- reasoning effort ----------------------------------------------
|
||||
if payload.clear_reasoning_effort or payload.reasoning_effort is not None:
|
||||
new_effort = (
|
||||
None if payload.clear_reasoning_effort
|
||||
else payload.reasoning_effort
|
||||
)
|
||||
try:
|
||||
ok = kanban_db.set_reasoning_effort(conn, task_id, new_effort)
|
||||
except (ValueError, RuntimeError) as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
if not ok:
|
||||
raise HTTPException(status_code=404, detail="task not found")
|
||||
|
||||
# --- priority -----------------------------------------------------
|
||||
if payload.priority is not None:
|
||||
with kanban_db.write_txn(conn):
|
||||
|
|
@ -1199,6 +1222,9 @@ class BulkTaskBody(BaseModel):
|
|||
model_override: Optional[str] = None
|
||||
provider_override: Optional[str] = None
|
||||
clear_model_override: bool = False
|
||||
# Bulk thinking-depth override — same semantics as UpdateTaskBody.
|
||||
reasoning_effort: Optional[str] = None
|
||||
clear_reasoning_effort: bool = False
|
||||
|
||||
|
||||
@router.post("/tasks/bulk")
|
||||
|
|
@ -1304,6 +1330,17 @@ def bulk_update(payload: BulkTaskBody, board: Optional[str] = Query(None)):
|
|||
entry.update(ok=False, error="model override refused")
|
||||
except (ValueError, RuntimeError) as e:
|
||||
entry.update(ok=False, error=str(e))
|
||||
if payload.clear_reasoning_effort or payload.reasoning_effort is not None:
|
||||
new_effort = (
|
||||
None if payload.clear_reasoning_effort
|
||||
else payload.reasoning_effort
|
||||
)
|
||||
try:
|
||||
ok = kanban_db.set_reasoning_effort(conn, tid, new_effort)
|
||||
if not ok:
|
||||
entry.update(ok=False, error="reasoning override refused")
|
||||
except (ValueError, RuntimeError) as e:
|
||||
entry.update(ok=False, error=str(e))
|
||||
except Exception as e: # defensive — one bad id shouldn't kill the batch
|
||||
entry.update(ok=False, error=str(e))
|
||||
results.append(entry)
|
||||
|
|
|
|||
|
|
@ -205,3 +205,118 @@ def test_model_options_endpoint_shape(client, monkeypatch):
|
|||
assert "slug" in row and "label" in row and "models" in row
|
||||
assert isinstance(row["models"], list)
|
||||
assert len(row["models"]) >= 1 # empty-model rows are filtered out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-task reasoning effort — the depth half of the board's model picker
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_reasoning_effort_normalizes_and_rejects(conn):
|
||||
tid = kb.create_task(conn, title="t", assignee="worker", reasoning_effort=" HIGH ")
|
||||
assert kb.get_task(conn, tid).reasoning_effort == "high"
|
||||
|
||||
# "none" is a VALUE (thinking off), not a clear.
|
||||
assert kb.set_reasoning_effort(conn, tid, "none")
|
||||
assert kb.get_task(conn, tid).reasoning_effort == "none"
|
||||
|
||||
# Empty clears back to "inherit the profile".
|
||||
assert kb.set_reasoning_effort(conn, tid, "")
|
||||
assert kb.get_task(conn, tid).reasoning_effort is None
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
kb.set_reasoning_effort(conn, tid, "extremely-hard")
|
||||
|
||||
|
||||
def test_reasoning_effort_survives_clearing_the_model(conn):
|
||||
"""Depth and model are independent knobs: dropping a model override must
|
||||
not silently reset the thinking depth the operator chose."""
|
||||
tid = kb.create_task(
|
||||
conn, title="t", assignee="worker",
|
||||
model_override="glm-5", provider_override="openrouter",
|
||||
reasoning_effort="ultra",
|
||||
)
|
||||
assert kb.set_model_override(conn, tid, None)
|
||||
t = kb.get_task(conn, tid)
|
||||
assert t.model_override is None
|
||||
assert t.provider_override is None
|
||||
assert t.reasoning_effort == "ultra"
|
||||
|
||||
|
||||
def test_reasoning_effort_without_a_model_override(conn):
|
||||
"""A task may run the profile's OWN model at a different depth."""
|
||||
tid = kb.create_task(conn, title="t", assignee="worker", reasoning_effort="low")
|
||||
t = kb.get_task(conn, tid)
|
||||
assert t.model_override is None
|
||||
assert t.reasoning_effort == "low"
|
||||
|
||||
|
||||
def test_spawn_passes_reasoning_without_a_model(monkeypatch, tmp_path, conn):
|
||||
tid = kb.create_task(conn, title="t", assignee="elias", reasoning_effort="high")
|
||||
task = kb.get_task(conn, tid)
|
||||
cmd = _spawn_and_capture(monkeypatch, tmp_path, task)
|
||||
assert "-m" not in cmd
|
||||
i = cmd.index("--reasoning")
|
||||
assert cmd[i + 1] == "high"
|
||||
|
||||
|
||||
def test_spawn_omits_reasoning_when_unset(monkeypatch, tmp_path, conn):
|
||||
tid = kb.create_task(conn, title="t", assignee="elias")
|
||||
task = kb.get_task(conn, tid)
|
||||
cmd = _spawn_and_capture(monkeypatch, tmp_path, task)
|
||||
assert "--reasoning" not in cmd
|
||||
|
||||
|
||||
def test_worker_cli_accepts_the_reasoning_flag():
|
||||
"""The dispatcher's --reasoning must be a real flag on the worker's CLI —
|
||||
a spawn arg no parser accepts fails every dispatch."""
|
||||
from hermes_cli._parser import build_top_level_parser
|
||||
|
||||
parser = build_top_level_parser()[0]
|
||||
args = parser.parse_args(["--cli", "chat", "-q", "hi", "--reasoning", "high"])
|
||||
assert args.reasoning == "high"
|
||||
|
||||
|
||||
def test_patch_sets_and_clears_reasoning_effort(client):
|
||||
task = _create(client)
|
||||
r = client.patch(
|
||||
f"/api/plugins/kanban/tasks/{task['id']}",
|
||||
json={"reasoning_effort": "xhigh"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["task"]["reasoning_effort"] == "xhigh"
|
||||
|
||||
r = client.patch(
|
||||
f"/api/plugins/kanban/tasks/{task['id']}",
|
||||
json={"clear_reasoning_effort": True},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["task"]["reasoning_effort"] is None
|
||||
|
||||
|
||||
def test_patch_rejects_an_unknown_level(client):
|
||||
task = _create(client)
|
||||
r = client.patch(
|
||||
f"/api/plugins/kanban/tasks/{task['id']}",
|
||||
json={"reasoning_effort": "bogus"},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_create_accepts_reasoning_effort(client):
|
||||
task = _create(client, reasoning_effort="minimal")
|
||||
assert task["reasoning_effort"] == "minimal"
|
||||
|
||||
|
||||
def test_bulk_reasoning_effort(client):
|
||||
t1 = _create(client)
|
||||
t2 = _create(client)
|
||||
r = client.post(
|
||||
"/api/plugins/kanban/tasks/bulk",
|
||||
json={"ids": [t1["id"], t2["id"]], "reasoning_effort": "max"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert all(entry["ok"] for entry in r.json()["results"])
|
||||
for tid in (t1["id"], t2["id"]):
|
||||
got = client.get(f"/api/plugins/kanban/tasks/{tid}").json()["task"]
|
||||
assert got["reasoning_effort"] == "max"
|
||||
|
|
|
|||
Loading…
Reference in New Issue