diff --git a/apps/web/src/features/agents-view/components/ModelSelector.tsx b/apps/web/src/features/agents-view/components/ModelSelector.tsx index 4218194d7e5..d144d1828d9 100644 --- a/apps/web/src/features/agents-view/components/ModelSelector.tsx +++ b/apps/web/src/features/agents-view/components/ModelSelector.tsx @@ -4,6 +4,7 @@ import { type ModelRowProps, } from '@core/component/AI/component/input/ModelCatalogPicker'; import { modelLabel } from '@core/component/AI/constant/model-label'; +import { cn } from '@ui'; import type { Component, JSX } from 'solid-js'; export type ModelChoice = { @@ -21,6 +22,9 @@ export function ModelSelector(props: { disabled?: boolean; modelRow?: Component; pending?: boolean; + /** Show only the model's logo, for phone composers. */ + compact?: boolean; + triggerClass?: string; onSelect: (id: string) => void; onCloseAutoFocus?: (event: Event) => void; children?: JSX.Element; @@ -38,7 +42,12 @@ export function ModelSelector(props: { }))} triggerLabel={props.label} placeholder={modelLabel(props.model)} - triggerClass="h-[33.75px] min-w-0 max-w-full gap-[5.625px] rounded-full border-0 bg-transparent hover:bg-hover px-[7.5px] text-base font-normal text-ink-muted light-mode:text-composer-placeholder [&_svg]:size-[15px]" + compact={props.compact} + triggerClass={cn( + 'h-[33.75px] min-w-0 max-w-full gap-[5.625px] rounded-full border-0 bg-transparent hover:bg-hover px-[7.5px] text-base font-normal text-ink-muted light-mode:text-composer-placeholder [&_svg]:size-[15px]', + props.compact && 'size-[34px] shrink-0 justify-center p-0', + props.triggerClass + )} ariaLabel="Model" placement="top-end" disabled={props.disabled} diff --git a/apps/web/src/features/agents-view/core/roster.test.ts b/apps/web/src/features/agents-view/core/roster.test.ts index f3f3b788274..84e410c35a4 100644 --- a/apps/web/src/features/agents-view/core/roster.test.ts +++ b/apps/web/src/features/agents-view/core/roster.test.ts @@ -147,8 +147,9 @@ describe('rosterForAgentPicker and kindForBot', () => { ], }); - it('includes every runtime and excludes only the built-in Macro identity', () => { + it('lists built-in Macro first once and every other runtime after it', () => { expect(rosterForAgentPicker(roster).map((a) => a.id)).toEqual([ + MACRO_PERSONA_ID, CURSOR_BOT_ID, 'bot-in-memory', 'bot-cursor', diff --git a/apps/web/src/features/agents-view/core/roster.ts b/apps/web/src/features/agents-view/core/roster.ts index aee19ae97fe..ceb7e69dd98 100644 --- a/apps/web/src/features/agents-view/core/roster.ts +++ b/apps/web/src/features/agents-view/core/roster.ts @@ -206,14 +206,20 @@ export function buildAgentRoster(input: RosterInput): RosterAgent[] { ]; } -/** Every agent belongs in the picker; Macro is represented by model choices. */ +/** Every agent belongs in the picker, Macro first; its bot aliases would repeat it. */ export function rosterForAgentPicker( roster: readonly RosterAgent[] ): RosterAgent[] { - return roster.filter( - (agent) => - agent.id !== MACRO_PERSONA_ID && !isMacroAgentId(agent.botId ?? agent.id) - ); + return roster + .filter( + (agent) => + agent.id === MACRO_PERSONA_ID || + !isMacroAgentId(agent.botId ?? agent.id) + ) + .sort( + (a, b) => + Number(b.id === MACRO_PERSONA_ID) - Number(a.id === MACRO_PERSONA_ID) + ); } /** diff --git a/apps/web/src/features/agents-view/tour.ts b/apps/web/src/features/agents-view/tour.ts index acd99cf9228..ba3cca9c8e5 100644 --- a/apps/web/src/features/agents-view/tour.ts +++ b/apps/web/src/features/agents-view/tour.ts @@ -36,7 +36,7 @@ export const agentsTour = defineViewTour({ entryLabel: 'Choose Chat or Code to pick an agent', title: 'Choose your agent and model', description: - 'Open this picker to choose an agent for the current mode. In Chat, Macro’s models are listed here too; each agent’s submenu lets you choose a model for that conversation.', + 'Choose the agent for the current mode here, Macro first. The model beside it uses the agent’s default; change it to use another model for your next message.', }, { target: AGENTS_TOUR.agentList, diff --git a/apps/web/src/features/agents-view/views/AgentPicker.tsx b/apps/web/src/features/agents-view/views/AgentPicker.tsx index 9ad53015393..e5da93c272a 100644 --- a/apps/web/src/features/agents-view/views/AgentPicker.tsx +++ b/apps/web/src/features/agents-view/views/AgentPicker.tsx @@ -1,9 +1,6 @@ -import { ModelCatalogMenu } from '@core/component/AI/component/input/ModelCatalogPicker'; -import { ModelIcon } from '@core/component/AI/component/ProviderIcon'; import { modelLabel } from '@core/component/AI/constant/model-label'; import { isTouchDevice } from '@core/mobile/isTouchDevice'; import CaretDownIcon from '@icon/system/regular/caret-down.svg'; -import CaretRightIcon from '@icon/system/regular/caret-right.svg'; import PlusIcon from '@icon/system/regular/plus.svg'; import { buttonClasses, cn, Dropdown } from '@ui'; import { tourTarget } from '@ui/components/Tour'; @@ -15,23 +12,25 @@ import type { } from '../../block-agent/state/session-config'; import { AgentIcon } from '../components/AgentGlyph'; import { AgentPickerIdentity } from '../components/AgentPickerIdentity'; +import { ModelSelector } from '../components/ModelSelector'; import { agentPickerGroups } from '../core/agent-picker-groups'; -import { MACRO_PERSONA_ID, type RosterAgent } from '../core/roster'; +import type { RosterAgent } from '../core/roster'; import { createComposerModels } from '../queries/composer-models'; import { AGENTS_TOUR } from '../tour'; import { MobileAgentPicker } from './MobileAgentPicker'; -/** Agent selection with a per-message model catalog in each submenu. */ +/** The agent to run, then a model control that overrides its default for one send. */ export type AgentPickerProps = { agents: RosterAgent[]; selected?: RosterAgent; modelOverride?: string; loading: boolean; triggerClass?: string; - /** Configuration forms also show the saved model name on mobile. */ + /** Configuration forms also show the agent and model names on mobile. */ showModelLabel?: boolean; effortLabel?: string; effortSelection?: EffortSelection; + /** An agent alone means its default model; `model` is the model control's override. */ onSelect: (agent: RosterAgent, model?: string) => void; /** Supply only when the host can persist and apply effort selections. */ onSelectEffort?: ( @@ -46,314 +45,171 @@ export type AgentPickerProps = { export function AgentPicker(props: AgentPickerProps) { return ( - }> - - +
+ }> + + + + {(agent) => } + +
+ ); +} + +/** The selected agent's harness models; a pick never changes the agent or its saved default. */ +function AgentModelOverride(props: AgentPickerProps & { agent: RosterAgent }) { + const catalog = createComposerModels(() => props.agent); + const model = () => + props.modelOverride ?? props.agent.defaultModel ?? catalog.currentModel(); + const label = () => + [ + modelLabel( + model(), + catalog.models().find((option) => option.id === model())?.name + ), + props.effortLabel, + ] + .filter(Boolean) + .join(' · '); + return ( + ({ + id: option.id, + name: option.name, + description: option.description ?? undefined, + group: option.group ?? undefined, + }))} + disabled={Boolean(props.agent.unavailableReason)} + onCloseAutoFocus={props.onCloseAutoFocus} + onSelect={(id) => props.onSelect(props.agent, id)} + modelRow={ + props.onSelectEffort + ? (row) => ( + + props.onSelectEffort?.(props.agent, row.option.id, effort) + } + /> + ) + : undefined + } + emptyMessage={catalog.message()} + /> ); } function DesktopAgentPicker(props: AgentPickerProps) { const [open, setOpen] = createSignal(false); - const catalog = createComposerModels(() => props.selected); - const macro = () => - props.agents.find((agent) => agent.id === MACRO_PERSONA_ID); - const macroCatalog = createComposerModels(macro); const groups = () => agentPickerGroups(props.agents); - const rawModel = () => props.selected?.id === MACRO_PERSONA_ID; - const model = () => - props.modelOverride ?? - props.selected?.defaultModel ?? - catalog.currentModel(); - const baseLabel = () => - modelLabel( - model(), - catalog.models().find((option) => option.id === model())?.name - ); - const label = () => - [baseLabel(), props.effortLabel].filter(Boolean).join(' · '); - const choose = (agent: RosterAgent, model?: string) => { - props.onSelect(agent, model); - setOpen(false); - }; - const chooseEffort = ( - agent: RosterAgent, - model: string, - effort: EffortChoice - ) => { - props.onSelectEffort?.(agent, model, effort); - setOpen(false); - }; return ( - - + Choose agent + + } + > + {(agent) => ( + <> + - Choose agent + {agent().name} - } - > - {(agent) => ( - <> - - - {agent().name} - - - )} - - - - + + )} - {label()} event.stopPropagation()} onMouseDown={(event: MouseEvent) => event.stopPropagation()} > -
-
- - {(agent) => ( - ({ - id: option.id, - label: modelLabel(option.id, option.name), - description: option.description ?? undefined, - group: option.group ?? undefined, - }))} - onSelect={(id) => choose(agent(), id)} - modelRow={ - props.onSelectEffort - ? (row) => ( - - chooseEffort(agent(), row.option.id, effort) - } - /> - ) - : undefined - } - emptyMessage={macroCatalog.message()} - /> - )} - - - {(group) => ( - - - {group.label} - - - {(agent) => ( - + + {(group) => ( + + + {group.label} + + + {(agent) => ( + { + if (agent.unavailableReason) props.onConnect(agent); + else props.onSelect(agent); + }} + > + choose(agent, model)} - onSelectEffort={ - props.onSelectEffort - ? (model, effort) => - chooseEffort(agent, model, effort) - : undefined - } - onConnect={() => { - setOpen(false); - props.onConnect(agent); - }} /> - )} - - - )} - - -

- No agents available -

-
- -
- Loading agents… -
-
- - { - setOpen(false); - props.onCreate(); - }} - > - - Create agent - - -
+ + )} + + + )} + + +

+ No agents available +

+
+ +
+ Loading agents… +
+
+ + + + Create agent + +
); } - -function AgentPickerRow(props: { - agent: RosterAgent; - selected: boolean; - modelOverride?: string; - effortSelection?: EffortSelection; - onSelect: (model?: string) => void; - onSelectEffort?: (model: string, effort: EffortChoice) => void; - onConnect: () => void; -}) { - const [open, setOpen] = createSignal(false); - const identity = () => ( - - ); - return ( -
- - {identity()} - - } - > - - { - if (!isTouchDevice()) props.onSelect(); - }} - onKeyDown={(event) => { - if (event.key !== 'Enter' && event.key !== ' ') return; - event.preventDefault(); - event.stopPropagation(); - props.onSelect(); - }} - > - {identity()} - - - event.preventDefault()} - onPointerDown={(event: PointerEvent) => event.stopPropagation()} - onMouseDown={(event: MouseEvent) => event.stopPropagation()} - > - - - - - - -
- ); -} - -function AgentModels(props: { - agent: RosterAgent; - modelOverride?: string; - effortSelection?: EffortSelection; - onSelect: (model?: string) => void; - onSelectEffort?: (model: string, effort: EffortChoice) => void; -}) { - const catalog = createComposerModels(() => props.agent); - const defaultModel = () => props.agent.defaultModel ?? catalog.currentModel(); - return ( - ({ - id: option.id, - label: modelLabel(option.id, option.name), - description: option.description ?? undefined, - group: option.group ?? undefined, - }))} - onSelect={props.onSelect} - modelRow={ - props.onSelectEffort - ? (row) => ( - - props.onSelectEffort?.(row.option.id, effort) - } - /> - ) - : undefined - } - emptyMessage={catalog.message()} - /> - ); -} diff --git a/apps/web/src/features/agents-view/views/MobileAgentPicker.tsx b/apps/web/src/features/agents-view/views/MobileAgentPicker.tsx index d9062d75dff..ebd5a00b5f7 100644 --- a/apps/web/src/features/agents-view/views/MobileAgentPicker.tsx +++ b/apps/web/src/features/agents-view/views/MobileAgentPicker.tsx @@ -1,92 +1,34 @@ import { MobileDrawer } from '@components/app/mobile/MobileDrawer'; -import { - buildModelCatalog, - matchesModelQuery, -} from '@core/component/AI/component/input/modelCatalog'; -import { ModelIcon } from '@core/component/AI/component/ProviderIcon'; -import { modelLabel } from '@core/component/AI/constant/model-label'; -import ArrowLeft from '@icon/system/regular/arrow-left.svg'; -import CaretRight from '@icon/system/regular/caret-right.svg'; -import Check from '@icon/system/regular/check.svg'; import MagnifyingGlass from '@icon/system/regular/magnifying-glass.svg'; import Plus from '@icon/system/regular/plus.svg'; -import SquaresFour from '@icon/system/regular/squares-four.svg'; import { Button } from '@ui'; -import { createMemo, createSignal, For, Show } from 'solid-js'; +import { createSignal, For, Show } from 'solid-js'; import { AgentIcon } from '../components/AgentGlyph'; import { AgentPickerIdentity } from '../components/AgentPickerIdentity'; import { agentPickerGroups } from '../core/agent-picker-groups'; -import { MACRO_PERSONA_ID, type RosterAgent } from '../core/roster'; -import { createComposerModels } from '../queries/composer-models'; +import type { RosterAgent } from '../core/roster'; import type { AgentPickerProps } from './AgentPicker'; -/** A single sheet drills into models without hover menus or offscreen submenus. */ +/** A bottom sheet of agents; the composer's model control sits beside its trigger. */ export function MobileAgentPicker(props: AgentPickerProps) { const [open, setOpen] = createSignal(false); const [query, setQuery] = createSignal(''); - const [showAll, setShowAll] = createSignal(false); - let searchInput: HTMLInputElement | undefined; - const [browsing, setBrowsing] = createSignal(); - const macro = () => - props.agents.find((agent) => agent.id === MACRO_PERSONA_ID); - const catalog = createComposerModels(() => browsing() ?? macro()); - const model = () => props.modelOverride ?? props.selected?.defaultModel; - const label = () => - props.selected?.id === MACRO_PERSONA_ID - ? modelLabel(model()) - : (props.selected?.name ?? 'Choose agent'); const matches = (text: string) => text.toLocaleLowerCase().includes(query().trim().toLocaleLowerCase()); - const modelSections = createMemo(() => { - const options = catalog.models().map((option) => ({ - id: option.id, - label: modelLabel(option.id, option.name), - group: option.group ?? undefined, - })); - const { frontier, providers } = buildModelCatalog(options, true); - const search = query().trim().toLowerCase(); - return ( - showAll() || search || frontier.length === 0 - ? providers - : [{ label: 'Suggested', options: frontier }] - ) - .map((section) => ({ - label: section.label, - options: section.options.filter((option) => - matchesModelQuery(option, search) - ), - })) - .filter((section) => section.options.length > 0); - }); const groups = () => agentPickerGroups( props.agents.filter((agent) => matches(`${agent.name} ${agent.runtime.label}`) ) ); - const searchLabel = () => - browsing() || showAll() - ? 'Search models' - : macro() - ? 'Search agents and models' - : 'Search agents'; const changeOpen = (next: boolean) => { setOpen(next); - if (!next) { - setQuery(''); - setBrowsing(undefined); - setShowAll(false); - } + if (!next) setQuery(''); }; - const choose = (agent: RosterAgent, model?: string) => { - props.onSelect(agent, model); + const choose = (agent: RosterAgent) => { changeOpen(false); - }; - const browse = (agent?: RosterAgent) => { - setQuery(''); - setBrowsing(agent); - setShowAll(false); - searchInput?.focus(); + if (agent.unavailableReason) props.onConnect(agent); + else props.onSelect(agent); }; return ( event.preventDefault()} > {/* Icon only on a phone; the title and the sheet name the choice. */} - } - > + {(agent) => } - {modelLabel(model())} + + {props.selected?.name ?? 'Choose agent'} + - +
- - - -

- {showAll() - ? 'All models' - : (browsing()?.name ?? - (macro() ? 'Agents and models' : 'Choose an agent'))} -

- - {(agent) => ( - - )} - +

Choose an agent

setQuery(event.currentTarget.value)} class="h-full min-w-0 w-full border-0 bg-transparent p-0 text-base outline-none placeholder:text-ink-extra-muted" />
- - - {(section) => ( -
- - {section.label} - - - - {(option) => ( - { - const agent = browsing() ?? macro(); - if (agent) choose(agent, option.id); - }} - > - - - {option.label} - - - - - - )} - - -
- )} -
- section.label === 'Suggested' - ) - } - > - - { - setShowAll(true); - searchInput?.focus(); - }} + + {(group) => ( +
+ + {group.label} + + - - More models - - - - - -

- {query() ? 'No matching models' : catalog.message()} -

-
+ + {(agent) => ( + choose(agent)} + > + + + )} + + +
+ )} +
+ +

+ {query() ? 'No matching agents' : 'No agents available'} +

- - - {(group) => ( -
- - {group.label} - - - - {(agent) => ( -
- { - if (agent.unavailableReason) { - changeOpen(false); - props.onConnect(agent); - } else choose(agent); - }} - > - - - - - -
- )} -
-
-
- )} -
- -

- {query() ? 'No matching agents' : 'No agents available'} -

-
- -

- Loading agents… -

-
+ +

+ Loading agents… +