diff --git a/docs/release-control/v6/internal/status.json b/docs/release-control/v6/internal/status.json index dda84d98c..2b2d32cb5 100644 --- a/docs/release-control/v6/internal/status.json +++ b/docs/release-control/v6/internal/status.json @@ -3007,6 +3007,20 @@ "-count=1" ] }, + { + "id": "assistant-model-selector-workflow-tests", + "run": [ + "npm", + "--prefix", + "frontend-modern", + "test", + "--", + "src/components/shared/__tests__/AIModelPicker.test.tsx", + "src/components/AI/Chat/__tests__/ModelSelector.test.tsx", + "src/components/AI/Chat/__tests__/AIChat.test.tsx", + "src/components/shared/__tests__/SearchField.test.tsx" + ] + }, { "id": "assistant-neutral-session-context-tests", "run": [ @@ -3137,6 +3151,11 @@ "path": "frontend-modern/src/components/AI/Chat/__tests__/MessageItem.test.tsx", "kind": "file" }, + { + "repo": "pulse", + "path": "frontend-modern/src/components/AI/Chat/__tests__/ModelSelector.test.tsx", + "kind": "file" + }, { "repo": "pulse", "path": "frontend-modern/src/components/AI/Chat/__tests__/useChat.test.ts", @@ -3162,6 +3181,11 @@ "path": "frontend-modern/src/components/AI/Chat/MessageItem.tsx", "kind": "file" }, + { + "repo": "pulse", + "path": "frontend-modern/src/components/AI/Chat/ModelSelector.tsx", + "kind": "file" + }, { "repo": "pulse", "path": "frontend-modern/src/components/AI/Chat/types.ts", @@ -3182,6 +3206,26 @@ "path": "frontend-modern/src/components/Alerts/incidentAssistantHandoffModel.ts", "kind": "file" }, + { + "repo": "pulse", + "path": "frontend-modern/src/components/shared/__tests__/AIModelPicker.test.tsx", + "kind": "file" + }, + { + "repo": "pulse", + "path": "frontend-modern/src/components/shared/__tests__/SearchField.test.tsx", + "kind": "file" + }, + { + "repo": "pulse", + "path": "frontend-modern/src/components/shared/AIModelPicker.tsx", + "kind": "file" + }, + { + "repo": "pulse", + "path": "frontend-modern/src/components/shared/useSearchFieldState.ts", + "kind": "file" + }, { "repo": "pulse", "path": "frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts", @@ -6767,11 +6811,11 @@ { "id": "codex-lane-followup-architecture-post-rc-canonicalization", "agent_id": "codex", - "summary": "Keep Assistant status on the active tool", + "summary": "Align Assistant model selection with OpenCode source workflow", "target_id": "v6-product-lane-expansion", "claimed_at": "2026-06-05T21:57:48Z", - "heartbeat_at": "2026-06-06T01:23:29Z", - "expires_at": "2026-06-06T03:23:29Z", + "heartbeat_at": "2026-06-06T01:33:49Z", + "expires_at": "2026-06-06T03:33:49Z", "work_item": { "kind": "lane-followup", "id": "architecture-post-rc-canonicalization" diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 522684a1a..c959282bb 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -205,9 +205,24 @@ runtime cost control, and shared AI transport surfaces. or questions begin, stale workflow text must clear so the row does not keep saying it is waiting on a phase that has already been superseded. OpenCode-parity Assistant UX work must reference OpenCode's actual source - implementation for message parts, tool-state mutation, and progress - rendering before changing Pulse behavior; parity means adapting the proven - interaction model, not guessing from screenshots or observed behavior alone. + implementation for message parts, tool-state mutation, progress rendering, + and model/session selection before changing Pulse behavior; parity means + adapting the proven interaction model, not guessing from screenshots or + observed behavior alone. + The referenced OpenCode source at fetched `origin/dev` commit + `9ed17da55ab1f7360cc0e01075f763e27fa899e9` builds the model dialog from + provider metadata, favorites, recent models, and provider sections in + `packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx`, keeps + structured `{ providerID, modelID }` current/recent/favorite state with + provider-catalog validation in + `packages/opencode/src/cli/cmd/tui/context/local.tsx`, and formats the + effective model label from provider/model metadata in + `packages/opencode/src/cli/cmd/run/variant.shared.ts`. Pulse's Assistant + drawer adapts that selector workflow by keeping explicit recent model routes + above the provider catalog, routing chat-specific default/override options + through the shared model picker, preserving selected older models in the + visible list, and accepting custom model entries only when they are explicit + `provider:model` routes that the backend chat stream can execute. The referenced OpenCode source at fetched `origin/dev` commit `fa2b63f850fc0a23bec2bdff9e660450d3fe7913` keeps prompt/footer status visible only while the session is non-idle in diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 9937b633f..3a3a4e2f3 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -1004,6 +1004,12 @@ not a replacement status card, CTA band, or page-local nested card. plain select options. The picker must also constrain its dropdown and internal result list to the available viewport height so settings model catalogs remain usable on mobile and tablet layouts with bottom navigation. + Chat-owned selectors must reuse this shared picker instead of carrying a + parallel dropdown implementation. Recent/priority model sections, external + open-and-focus requests, selected older model visibility, route labels, and + explicit `provider:model` custom-route validation belong to the shared + picker so Assistant, settings, and future model-selection surfaces do not + drift apart. Gateway-routed model choices must not look like direct-provider choices: the shared picker, System AI settings status strip, and per-surface inherited-default descriptions must render OpenRouter-hosted provider @@ -2294,7 +2300,11 @@ Escape clear/blur behavior and input-ref lifecycle, and `frontend-modern/src/components/shared/searchFieldModel.ts` owns clear/shortcut visibility rules plus trailing-control padding policy. Future search-field work should extend those owners instead of pushing event behavior or layout policy -back into the shared shell. +back into the shared shell. Forwarded keyboard and blur events must preserve +native browser event getters while normalizing `currentTarget` and `target`; +shared search-field wrappers must not proxy native event properties through a +receiver that can break `KeyboardEvent`/`FocusEvent` getters in live browser +surfaces. The shared search input now follows that same owner split. `frontend-modern/src/components/shared/SearchInput.tsx` stays the render shell, `frontend-modern/src/components/shared/useSearchInputState.ts` owns input-ref diff --git a/frontend-modern/src/components/AI/Chat/ModelSelector.tsx b/frontend-modern/src/components/AI/Chat/ModelSelector.tsx index b6eb3e328..61c6aa78d 100644 --- a/frontend-modern/src/components/AI/Chat/ModelSelector.tsx +++ b/frontend-modern/src/components/AI/Chat/ModelSelector.tsx @@ -1,20 +1,10 @@ +import { Component, createMemo } from 'solid-js'; import { - Component, - For, - Show, - createEffect, - createSignal, - createMemo, - onMount, - onCleanup, -} from 'solid-js'; -import { getProviderFromModelId, groupModelsByProvider } from '../aiChatUtils'; -import { SearchField } from '@/components/shared/SearchField'; -import { - formatAIModelRouteLabel, - getAIProviderDisplayName, -} from '@/utils/aiProviderPresentation'; -import { AI_CHAT_MODEL_SELECTOR_EMPTY_STATE } from '@/utils/aiChatPresentation'; + AIModelPicker, + type AIModelPickerExtraOption, + type AIModelPickerModelSection, +} from '@/components/shared/AIModelPicker'; +import { formatAIModelRouteLabel } from '@/utils/aiProviderPresentation'; import type { ModelInfo } from './types'; export interface ModelSelectorProps { @@ -24,6 +14,7 @@ export interface ModelSelectorProps { defaultModelLabel?: string; chatOverrideModel?: string; chatOverrideLabel?: string; + recentModelIds?: string[]; isLoading?: boolean; error?: string; openRequest?: number; @@ -32,108 +23,13 @@ export interface ModelSelectorProps { } /** - * Reusable model selector dropdown with notable model filtering. - * Shows only recent/notable models by default with a toggle to reveal older models. + * Chat-specific model selector wrapper. + * + * OpenCode's DialogModel keeps recent models above the provider catalog while + * the shared picker owns filtering, route labels, current-selection visibility, + * and dropdown mechanics. */ export const ModelSelector: Component = (props) => { - const [isOpen, setIsOpen] = createSignal(false); - const [showAllModels, setShowAllModels] = createSignal(false); - const [searchQuery, setSearchQuery] = createSignal(''); - const [dropdownPosition, setDropdownPosition] = createSignal({ top: 0, right: 0 }); - let buttonRef: HTMLButtonElement | undefined; - let searchInputRef: HTMLInputElement | undefined; - let lastOpenRequest = props.openRequest || 0; - - // Filter models by notable status (show only recent/notable models by default) - const notableFilteredModels = createMemo(() => { - if (showAllModels()) { - return props.models; - } - const notable = props.models.filter((m) => m.notable); - return notable.length > 0 ? notable : props.models; - }); - - // Count hidden (older) models - const hiddenModelCount = createMemo(() => { - const notable = props.models.filter((m) => m.notable); - return props.models.length - notable.length; - }); - - // Apply search filter - search ALL models (not just notable) when there's a query - const filteredModels = createMemo(() => { - const query = searchQuery().trim().toLowerCase(); - if (!query) return notableFilteredModels(); - const baseModels = props.models; - return baseModels.filter((model) => { - const provider = getProviderFromModelId(model.id); - const providerName = getAIProviderDisplayName(provider) || provider; - const modelName = model.name || ''; - return ( - model.id.toLowerCase().includes(query) || - modelName.toLowerCase().includes(query) || - (model.description || '').toLowerCase().includes(query) || - provider.toLowerCase().includes(query) || - providerName.toLowerCase().includes(query) - ); - }); - }); - - // Check if typed query matches any model - const customModelCandidate = createMemo(() => searchQuery().trim()); - const showCustomModelOption = createMemo(() => { - const candidate = customModelCandidate(); - if (!candidate) return false; - return !props.models.some((model) => model.id === candidate); - }); - - const updateDropdownPosition = () => { - if (buttonRef) { - const rect = buttonRef.getBoundingClientRect(); - setDropdownPosition({ - top: rect.bottom + 4, // 4px gap (mt-1) - right: window.innerWidth - rect.right, - }); - } - }; - - const handleToggle = () => { - if (isOpen()) { - setIsOpen(false); - } else { - updateDropdownPosition(); - setIsOpen(true); - queueMicrotask(() => searchInputRef?.focus()); - } - }; - - const handleSelect = (modelId: string) => { - props.onModelSelect(modelId); - setIsOpen(false); - setSearchQuery(''); - }; - - createEffect(() => { - const request = props.openRequest || 0; - if (request <= 0 || request === lastOpenRequest) return; - lastOpenRequest = request; - queueMicrotask(() => { - updateDropdownPosition(); - setSearchQuery(''); - setIsOpen(true); - queueMicrotask(() => searchInputRef?.focus()); - }); - }); - - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key !== 'Enter') return; - e.preventDefault(); - const candidate = customModelCandidate(); - if (candidate) { - handleSelect(candidate); - } - }; - - const isUsingDefault = createMemo(() => !props.selectedModel?.trim()); const normalizedChatOverrideModel = createMemo(() => props.chatOverrideModel?.trim() || ''); const shouldShowChatOverride = createMemo(() => { const override = normalizedChatOverrideModel(); @@ -145,247 +41,60 @@ export const ModelSelector: Component = (props) => { return true; }); - const selectedLabel = createMemo(() => { - const selected = props.selectedModel?.trim(); - if (!selected) { - return props.defaultModelLabel || 'Default'; - } - const match = props.models.find((model) => model.id === selected); - if (match) return formatAIModelRouteLabel(match); - return formatAIModelRouteLabel(selected); + const defaultOption = createMemo(() => ({ + label: 'Default', + description: props.defaultModelLabel + ? `Use configured default model (${props.defaultModelLabel})` + : 'Use configured default model', + })); + + const extraOptions = createMemo(() => { + if (!shouldShowChatOverride()) return []; + const override = normalizedChatOverrideModel(); + return [ + { + id: override, + label: 'Chat override', + description: props.chatOverrideLabel || formatAIModelRouteLabel(override), + }, + ]; }); - // Click outside handler - onMount(() => { - const handleClickOutside = (e: MouseEvent) => { - if ( - isOpen() && - buttonRef && - !buttonRef.closest('[data-dropdown]')?.contains(e.target as Node) - ) { - // Check if we are clicking inside the dropdown content itself (which isn't buttonRef) - // The dropdown content is rendered in a portal or just absolutely positioned? - // In this file, it's absolutely positioned as a sibling to the button, wrapped in the same parent div. - // The parent div has [data-dropdown]. - // So checking if e.target is inside the closest [data-dropdown] works for both button and content. - const dropdownContainer = buttonRef.closest('[data-dropdown]'); - if (dropdownContainer && !dropdownContainer.contains(e.target as Node)) { - setIsOpen(false); - } - } - }; - document.addEventListener('click', handleClickOutside); - onCleanup(() => document.removeEventListener('click', handleClickOutside)); + const modelSections = createMemo(() => { + const recentModelIds = props.recentModelIds || []; + if (recentModelIds.length === 0) return []; + return [ + { + title: 'Recent', + modelIds: recentModelIds, + }, + ]; + }); + + const selectionBadge = createMemo(() => { + if ((props.selectedModel || '').trim()) return ''; + return props.defaultModelLabel ? 'default' : ''; }); return ( -
- - - -
- {/* Search bar */} -
- { - searchInputRef = el; - }} - /> - - - -
- - {/* Error message */} - -
- {props.error} -
-
- - {/* Model list */} -
- {/* Default option */} - - - {/* Chat override option */} - - - - - {/* Custom model option */} - - - - - {/* No results */} - -
- {AI_CHAT_MODEL_SELECTOR_EMPTY_STATE} -
-
- - {/* Grouped models */} - - {([provider, providerModels]) => ( - <> -
- {getAIProviderDisplayName(provider) || provider} -
- - {(model) => ( - - )} - - - )} -
- - {/* Toggle to show older models */} - 0 && !searchQuery().trim()}> -
- -
-
-
-
-
-
+ ); }; diff --git a/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx b/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx index 8c9755510..b8e0b2e4d 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx +++ b/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx @@ -16,6 +16,7 @@ const { mockResources, mockWebSocketState, mockChatMessagesProps, + mockModelSelectorProps, } = vi.hoisted(() => { const mockChatMessagesProps: Array<{ messages: ChatMessage[]; @@ -24,6 +25,13 @@ const { getModelRouteAlternative?: (message: ChatMessage) => ModelRouteRecoveryOption | null; onUseModelRoute?: (modelId: string, messageId?: string) => void; }> = []; + const mockModelSelectorProps: Array<{ + selectedModel: string; + models: ModelInfo[]; + recentModelIds?: string[]; + openRequest?: number; + onModelSelect?: (modelId: string) => void; + }> = []; const mockChat = { messages: vi.fn((): ChatMessage[] => []), isLoading: vi.fn(() => false), @@ -180,6 +188,7 @@ const { mockResources, mockWebSocketState, mockChatMessagesProps, + mockModelSelectorProps, }; }); @@ -238,14 +247,24 @@ vi.mock('../ChatMessages', () => ({ })); vi.mock('../ModelSelector', () => ({ - ModelSelector: (props: { selectedModel: string; models: ModelInfo[]; openRequest?: number }) => ( -
- ), + ModelSelector: (props: { + selectedModel: string; + models: ModelInfo[]; + recentModelIds?: string[]; + openRequest?: number; + onModelSelect?: (modelId: string) => void; + }) => { + mockModelSelectorProps.push(props); + return ( +
+ ); + }, })); vi.mock('../MentionAutocomplete', () => ({ @@ -350,6 +369,7 @@ async function waitForProviderCheckSettled() { beforeEach(() => { vi.clearAllMocks(); mockChatMessagesProps.length = 0; + mockModelSelectorProps.length = 0; setViewportWidth(1440); resetAIRuntimeState(); mockAiChatStore.isOpenSignal.mockReturnValue(true); @@ -2637,11 +2657,57 @@ describe('AIChat', () => { renderChat(); await waitFor(() => { - expect(mockChat.setModel).toHaveBeenCalledWith( + expect(mockChat.setModel).toHaveBeenCalledWith('openrouter:deepseek/deepseek-v4-pro'); + }); + }); + + it('passes stored recent model routes to the model selector', () => { + localStorage.setItem( + 'pulse:ai_chat_recent_models', + JSON.stringify([ + 'openrouter:deepseek/deepseek-v4-pro', + 'plain-model-name', + 'openai:gpt-4o', + 'openrouter:deepseek/deepseek-v4-pro', + ]), + ); + + renderChat(); + + expect(screen.getByTestId('model-selector')).toHaveAttribute( + 'data-recent-models', + 'openrouter:deepseek/deepseek-v4-pro|openai:gpt-4o', + ); + }); + + it('records selected explicit model routes as recents', async () => { + renderChat(); + + mockModelSelectorProps[mockModelSelectorProps.length - 1].onModelSelect?.( + 'openrouter:deepseek/deepseek-v4-pro', + ); + + expect(mockChat.setModel).toHaveBeenCalledWith('openrouter:deepseek/deepseek-v4-pro'); + expect(localStorage.getItem('pulse:ai_chat_recent_models')).toBe( + JSON.stringify(['openrouter:deepseek/deepseek-v4-pro']), + ); + + await waitFor(() => { + expect(screen.getByTestId('model-selector')).toHaveAttribute( + 'data-recent-models', 'openrouter:deepseek/deepseek-v4-pro', ); }); }); + + it('does not record non-routed model strings as recents', () => { + renderChat(); + + mockModelSelectorProps[mockModelSelectorProps.length - 1].onModelSelect?.('plain-model-name'); + + expect(mockChat.setModel).toHaveBeenCalledWith('plain-model-name'); + expect(localStorage.getItem('pulse:ai_chat_recent_models')).toBeNull(); + }); }); // ── Autonomous banner ──────────────────────────────────────────────── @@ -2989,9 +3055,9 @@ describe('AIChat', () => { }, ]); renderChat(); - expect( - screen.getByLabelText('Assistant active turn status'), - ).toHaveTextContent('Planning governed action and safety checks before execution. · exec'); + expect(screen.getByLabelText('Assistant active turn status')).toHaveTextContent( + 'Planning governed action and safety checks before execution. · exec', + ); expect(screen.queryByText('Generating response...')).not.toBeInTheDocument(); }); diff --git a/frontend-modern/src/components/AI/Chat/__tests__/ModelSelector.test.tsx b/frontend-modern/src/components/AI/Chat/__tests__/ModelSelector.test.tsx index e8d0eee11..6c1aab0ed 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/ModelSelector.test.tsx +++ b/frontend-modern/src/components/AI/Chat/__tests__/ModelSelector.test.tsx @@ -343,6 +343,53 @@ describe('ModelSelector', () => { expect(onModelSelect).toHaveBeenCalledWith('openai:gpt-4o'); }); + it('shows recent model routes before the provider catalog', () => { + render(() => ( + + )); + + fireEvent.click(screen.getByTitle('Select model for this chat')); + + const recentHeader = screen.getByText('Recent'); + const deepSeekRecent = screen.getByText('DeepSeek: DeepSeek V4 Pro via OpenRouter'); + const anthropicHeader = screen.getByText('Anthropic'); + expect( + recentHeader.compareDocumentPosition(deepSeekRecent) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + expect( + deepSeekRecent.compareDocumentPosition(anthropicHeader) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + }); + + it('keeps explicit recent custom routes available when they are not in the catalog', () => { + render(() => ( + + )); + + fireEvent.click(screen.getByTitle('Select model for this chat')); + + expect(screen.getByText('Custom: Model via OpenRouter')).toBeInTheDocument(); + expect(screen.getByText('Recent custom model route')).toBeInTheDocument(); + }); + // --- Notable Model Filtering --- it('shows only notable models by default', () => { @@ -492,17 +539,28 @@ describe('ModelSelector', () => { expect(screen.queryByText(/older models/)).not.toBeInTheDocument(); }); - // --- Custom Model ID --- + // --- Custom Model Route --- - it('shows custom model option when search query does not match any model ID', () => { + it('shows custom model option when an explicit route does not match any model ID', () => { + render(() => ); + + fireEvent.click(screen.getByTitle('Select model for this chat')); + const searchInput = screen.getByPlaceholderText('Search or enter model ID'); + fireEvent.input(searchInput, { target: { value: 'openrouter:custom/model' } }); + + expect(screen.getByText('Use "openrouter:custom/model"')).toBeInTheDocument(); + expect(screen.getByText('Custom provider:model route')).toBeInTheDocument(); + }); + + it('does not offer a custom model option for plain search terms', () => { render(() => ); fireEvent.click(screen.getByTitle('Select model for this chat')); const searchInput = screen.getByPlaceholderText('Search or enter model ID'); fireEvent.input(searchInput, { target: { value: 'my-custom-model' } }); - expect(screen.getByText('Use "my-custom-model"')).toBeInTheDocument(); - expect(screen.getByText('Custom model ID')).toBeInTheDocument(); + expect(screen.queryByText('Use "my-custom-model"')).not.toBeInTheDocument(); + expect(screen.getByText('No matching models.')).toBeInTheDocument(); }); it('does not show custom model option when query exactly matches an existing model ID', () => { @@ -512,7 +570,7 @@ describe('ModelSelector', () => { const searchInput = screen.getByPlaceholderText('Search or enter model ID'); fireEvent.input(searchInput, { target: { value: 'anthropic:claude-sonnet-4' } }); - expect(screen.queryByText('Custom model ID')).not.toBeInTheDocument(); + expect(screen.queryByText('Custom provider:model route')).not.toBeInTheDocument(); }); it('selects custom model when clicked', () => { @@ -523,10 +581,10 @@ describe('ModelSelector', () => { fireEvent.click(screen.getByTitle('Select model for this chat')); const searchInput = screen.getByPlaceholderText('Search or enter model ID'); - fireEvent.input(searchInput, { target: { value: 'my-custom-model' } }); - fireEvent.click(screen.getByText('Use "my-custom-model"')); + fireEvent.input(searchInput, { target: { value: 'openrouter:custom/model' } }); + fireEvent.click(screen.getByText('Use "openrouter:custom/model"')); - expect(onModelSelect).toHaveBeenCalledWith('my-custom-model'); + expect(onModelSelect).toHaveBeenCalledWith('openrouter:custom/model'); }); it('selects custom model on Enter key', () => { @@ -537,10 +595,10 @@ describe('ModelSelector', () => { fireEvent.click(screen.getByTitle('Select model for this chat')); const searchInput = screen.getByPlaceholderText('Search or enter model ID'); - fireEvent.input(searchInput, { target: { value: 'my-custom-model' } }); + fireEvent.input(searchInput, { target: { value: 'openrouter:custom/model' } }); fireEvent.keyDown(searchInput, { key: 'Enter' }); - expect(onModelSelect).toHaveBeenCalledWith('my-custom-model'); + expect(onModelSelect).toHaveBeenCalledWith('openrouter:custom/model'); }); it('does not submit on non-Enter keys', () => { @@ -662,9 +720,8 @@ describe('ModelSelector', () => { fireEvent.click(screen.getByTitle('Select model for this chat')); // GPT-4o appears twice: in the top button label and in the dropdown list. - // Find the one inside the dropdown's model list (which has font-medium class in a flex container). const allGpt4o = screen.getAllByText('GPT-4o'); - const dropdownEntry = allGpt4o.find((el) => el.closest('.max-h-72') !== null); + const dropdownEntry = allGpt4o.find((el) => el.closest('[role="listbox"]') !== null); expect(dropdownEntry).toBeDefined(); const modelButton = dropdownEntry!.closest('button'); expect(modelButton?.className).toContain('bg-blue-50'); @@ -732,7 +789,7 @@ describe('ModelSelector', () => { expect(screen.getByPlaceholderText('Search or enter model ID')).toBeInTheDocument(); // Simulate click outside the dropdown container - fireEvent.click(screen.getByTestId('outside')); + fireEvent.mouseDown(screen.getByTestId('outside')); expect(screen.queryByPlaceholderText('Search or enter model ID')).not.toBeInTheDocument(); }); @@ -747,11 +804,11 @@ describe('ModelSelector', () => { fireEvent.click(screen.getByTitle('Select model for this chat')); const searchInput = screen.getByPlaceholderText('Search or enter model ID'); - fireEvent.input(searchInput, { target: { value: ' my-model ' } }); + fireEvent.input(searchInput, { target: { value: ' openrouter:custom/model ' } }); fireEvent.keyDown(searchInput, { key: 'Enter' }); // customModelCandidate trims the search query - expect(onModelSelect).toHaveBeenCalledWith('my-model'); + expect(onModelSelect).toHaveBeenCalledWith('openrouter:custom/model'); }); it('does not submit on Enter when search query is empty', () => { diff --git a/frontend-modern/src/components/AI/Chat/index.tsx b/frontend-modern/src/components/AI/Chat/index.tsx index 82a1627cf..e3059ac87 100644 --- a/frontend-modern/src/components/AI/Chat/index.tsx +++ b/frontend-modern/src/components/AI/Chat/index.tsx @@ -86,10 +86,12 @@ import type { import { formatIdentifierLabel } from '@/utils/textPresentation'; const MODEL_SESSION_STORAGE_KEY = 'pulse:ai_chat_models_by_session'; +const MODEL_RECENT_STORAGE_KEY = 'pulse:ai_chat_recent_models'; const PROMPT_HISTORY_STORAGE_KEY = 'pulse:ai_chat_prompt_history'; const DEFAULT_SESSION_KEY = '__default__'; const AI_CHAT_MIN_DOCKED_VIEWPORT_WIDTH = 1200; const AI_CHAT_PROMPT_HISTORY_LIMIT = 100; +const AI_CHAT_RECENT_MODEL_LIMIT = 8; const STRUCTURED_PATROL_CONTEXT_TARGETS = new Set(['patrol-configuration', 'patrol-run']); const STRUCTURED_RESOURCE_CONTEXT_HANDOFF_KINDS = new Set(['resource_context']); @@ -672,6 +674,54 @@ export const AIChat: Component = (props) => { const [modelSelections, setModelSelections] = createSignal>(initialModelSelections); + const loadRecentModelIds = (): string[] => { + try { + const raw = localStorage.getItem(MODEL_RECENT_STORAGE_KEY); + const parsed = raw ? JSON.parse(raw) : []; + if (!Array.isArray(parsed)) return []; + const seen = new Set(); + const recentModelIds: string[] = []; + for (const value of parsed) { + const modelId = typeof value === 'string' ? value.trim() : ''; + if (!modelId || !modelId.includes(':') || seen.has(modelId)) continue; + seen.add(modelId); + recentModelIds.push(modelId); + if (recentModelIds.length >= AI_CHAT_RECENT_MODEL_LIMIT) break; + } + return recentModelIds; + } catch (error) { + logger.warn('[AIChat] Failed to read recent models:', error); + return []; + } + }; + + const persistRecentModelIds = (modelIds: string[]) => { + try { + if (modelIds.length > 0) { + localStorage.setItem(MODEL_RECENT_STORAGE_KEY, JSON.stringify(modelIds)); + } else { + localStorage.removeItem(MODEL_RECENT_STORAGE_KEY); + } + } catch (error) { + logger.warn('[AIChat] Failed to persist recent models:', error); + } + }; + + const [recentModelIds, setRecentModelIds] = createSignal(loadRecentModelIds()); + + const rememberRecentModel = (modelId: string) => { + const normalizedModelId = modelId.trim(); + if (!normalizedModelId || !normalizedModelId.includes(':')) return; + setRecentModelIds((prev) => { + const next = [ + normalizedModelId, + ...prev.filter((candidate) => candidate !== normalizedModelId), + ].slice(0, AI_CHAT_RECENT_MODEL_LIMIT); + persistRecentModelIds(next); + return next; + }); + }; + const getStoredModel = (sessionId: string) => { const key = sessionId.trim(); if (!key) return ''; @@ -957,6 +1007,7 @@ export const AIChat: Component = (props) => { const selectModel = (modelId: string) => { chat.setModel(modelId); updateStoredModel(chat.sessionId(), modelId); + rememberRecentModel(modelId); }; const openModelSelectorFromError = () => { @@ -1865,6 +1916,7 @@ export const AIChat: Component = (props) => { defaultModelLabel={defaultModelLabel()} chatOverrideModel={chatOverrideModel()} chatOverrideLabel={chatOverrideLabel()} + recentModelIds={recentModelIds()} isLoading={aiRuntimeModelsLoading()} error={aiRuntimeModelsError()} openRequest={modelSelectorOpenRequest()} diff --git a/frontend-modern/src/components/shared/AIModelPicker.tsx b/frontend-modern/src/components/shared/AIModelPicker.tsx index 62163a32f..a0cf50042 100644 --- a/frontend-modern/src/components/shared/AIModelPicker.tsx +++ b/frontend-modern/src/components/shared/AIModelPicker.tsx @@ -1,4 +1,13 @@ -import { Component, For, Show, createMemo, createSignal, onCleanup, onMount } from 'solid-js'; +import { + Component, + For, + Show, + createEffect, + createMemo, + createSignal, + onCleanup, + onMount, +} from 'solid-js'; import ChevronDownIcon from 'lucide-solid/icons/chevron-down'; import MonitorIcon from 'lucide-solid/icons/monitor'; import RefreshCwIcon from 'lucide-solid/icons/refresh-cw'; @@ -16,12 +25,27 @@ type AIModelPickerDefaultOption = { description?: string; }; +export type AIModelPickerExtraOption = { + id: string; + label: string; + description?: string; + hidden?: boolean; +}; + +export type AIModelPickerModelSection = { + title: string; + modelIds: string[]; +}; + export interface AIModelPickerProps { models: ModelInfo[]; selectedModel: string; onModelSelect: (modelId: string) => void; defaultOption?: AIModelPickerDefaultOption; + extraOptions?: AIModelPickerExtraOption[]; + modelSections?: AIModelPickerModelSection[]; emptySelectionLabel?: string; + selectionBadge?: string; title?: string; searchPlaceholder?: string; emptyState?: string; @@ -30,6 +54,7 @@ export interface AIModelPickerProps { isLoading?: boolean; error?: string; onRefresh?: () => void; + openRequest?: number; align?: 'left' | 'right'; buttonClass?: string; buttonLabelClass?: string; @@ -49,6 +74,26 @@ const MOBILE_BOTTOM_CLEARANCE = 88; const DESKTOP_BOTTOM_CLEARANCE = 16; const SEARCH_HEADER_HEIGHT = 52; const ERROR_ROW_HEIGHT = 36; +const CUSTOM_RECENT_MODEL_DESCRIPTION = 'Recent custom model route'; + +type ResolvedModelRoute = { + id: string; + model?: ModelInfo; +}; + +const isExplicitModelRoute = (modelId: string) => modelId.includes(':'); + +const modelRouteLabel = (entry: ResolvedModelRoute) => + entry.model ? formatAIModelRouteLabel(entry.model) : formatAIModelRouteLabel(entry.id); + +const modelRouteDescription = (entry: ResolvedModelRoute) => + entry.model?.description || (!entry.model ? CUSTOM_RECENT_MODEL_DESCRIPTION : ''); + +const modelRouteSecondaryId = (entry: ResolvedModelRoute) => { + const model = entry.model; + if (!model?.name || model.name === model.id) return ''; + return model.id; +}; function groupModelsByProvider(models: ModelInfo[]): Map { const grouped = new Map(); @@ -78,10 +123,42 @@ export const AIModelPicker: Component = (props) => { }); let containerRef: HTMLDivElement | undefined; let buttonRef: HTMLButtonElement | undefined; + let searchInputRef: HTMLInputElement | undefined; + let lastOpenRequest = props.openRequest || 0; const selectedModel = createMemo(() => props.selectedModel?.trim() || ''); + const modelsById = createMemo(() => new Map(props.models.map((model) => [model.id, model]))); const notableModels = createMemo(() => props.models.filter((model) => model.notable)); const shouldFilterToNotable = createMemo(() => notableModels().length > 0); + const extraOptions = createMemo(() => + (props.extraOptions || []).filter((option) => option.id.trim() && !option.hidden), + ); + const modelSections = createMemo(() => { + const seen = new Set(); + return (props.modelSections || []) + .map((section) => { + const models = section.modelIds.flatMap((modelId): ResolvedModelRoute[] => { + const id = modelId.trim(); + if (!id || seen.has(id)) { + return []; + } + const model = modelsById().get(id); + if (!model && !isExplicitModelRoute(id)) { + return []; + } + seen.add(id); + return [{ id, model }]; + }); + return { + title: section.title, + models, + }; + }) + .filter((section) => section.models.length > 0); + }); + const sectionModelIds = createMemo( + () => new Set(modelSections().flatMap((section) => section.models.map((model) => model.id))), + ); const visibleUnsearchedModels = createMemo(() => { if (showAllModels() || !shouldFilterToNotable()) { @@ -97,13 +174,17 @@ export const AIModelPicker: Component = (props) => { return 0; } const selected = selectedModel(); - return props.models.filter((model) => !model.notable && model.id !== selected).length; + const sectionIds = sectionModelIds(); + return props.models.filter( + (model) => !model.notable && model.id !== selected && !sectionIds.has(model.id), + ).length; }); const filteredModels = createMemo(() => { const query = searchQuery().trim().toLowerCase(); if (!query) { - return visibleUnsearchedModels(); + const sectionIds = sectionModelIds(); + return visibleUnsearchedModels().filter((model) => !sectionIds.has(model.id)); } return props.models.filter((model) => { const provider = model.provider?.trim() || getProviderFromModelId(model.id); @@ -132,7 +213,7 @@ export const AIModelPicker: Component = (props) => { if (!candidate) { return false; } - if (!candidate.includes(':')) { + if (!isExplicitModelRoute(candidate)) { return false; } return !props.models.some((model) => model.id === candidate); @@ -186,14 +267,26 @@ export const AIModelPicker: Component = (props) => { setSearchQuery(''); }; + const focusSearchInput = () => { + queueMicrotask(() => searchInputRef?.focus()); + }; + + const openPicker = () => { + updateDropdownPosition(); + setSearchQuery(''); + setIsOpen(true); + focusSearchInput(); + }; + const handleToggle = () => { if (props.disabled) { return; } if (!isOpen()) { - updateDropdownPosition(); + openPicker(); + return; } - setIsOpen(!isOpen()); + closePicker(); }; const handleSelect = (modelId: string) => { @@ -212,6 +305,23 @@ export const AIModelPicker: Component = (props) => { } }; + createEffect(() => { + const request = props.openRequest || 0; + if (request <= 0 || request === lastOpenRequest) { + return; + } + lastOpenRequest = request; + queueMicrotask(openPicker); + }); + + const hasVisibleListOptions = createMemo( + () => + Boolean(props.defaultOption) || + extraOptions().length > 0 || + modelSections().length > 0 || + showCustomModelOption(), + ); + onMount(() => { const handlePointerDown = (event: MouseEvent) => { if (isOpen() && containerRef && !containerRef.contains(event.target as Node)) { @@ -245,6 +355,9 @@ export const AIModelPicker: Component = (props) => { > {selectedLabel()} + + {props.selectionBadge} + @@ -264,6 +377,9 @@ export const AIModelPicker: Component = (props) => { placeholder={props.searchPlaceholder || 'Search or enter model ID'} class="flex-1" inputClass="py-1.5 text-xs focus:ring-blue-400" + inputRef={(el) => { + searchInputRef = el; + }} /> + + {(option) => ( + + )} + + - +
{props.emptyState || DEFAULT_EMPTY_STATE}
+ + + {(section) => ( + <> +
+ {section.title} +
+ + {(entry) => ( + + )} + + + )} +
+
+ {([provider, providerModels]) => ( <> diff --git a/frontend-modern/src/components/shared/__tests__/AIModelPicker.test.tsx b/frontend-modern/src/components/shared/__tests__/AIModelPicker.test.tsx index 9e88f7389..06a0692a9 100644 --- a/frontend-modern/src/components/shared/__tests__/AIModelPicker.test.tsx +++ b/frontend-modern/src/components/shared/__tests__/AIModelPicker.test.tsx @@ -214,4 +214,28 @@ describe('AIModelPicker', () => { expect(onModelSelect).toHaveBeenCalledWith('openrouter:minimax/minimax-m2.5'); }); + + it('renders priority model sections above provider groups and removes duplicate rows', () => { + render(() => ( + + )); + + fireEvent.click(screen.getByTitle('Select shared default model')); + + expect(screen.getByText('Recent')).toBeInTheDocument(); + expect(screen.getByText('Custom: Model via OpenRouter')).toBeInTheDocument(); + expect(screen.getByText('Recent custom model route')).toBeInTheDocument(); + expect(screen.getAllByText('MiniMax: MiniMax M2.5 via OpenRouter')).toHaveLength(1); + }); }); diff --git a/frontend-modern/src/components/shared/__tests__/SearchField.test.tsx b/frontend-modern/src/components/shared/__tests__/SearchField.test.tsx index b9cec2dd4..9adc8f3d5 100644 --- a/frontend-modern/src/components/shared/__tests__/SearchField.test.tsx +++ b/frontend-modern/src/components/shared/__tests__/SearchField.test.tsx @@ -14,7 +14,9 @@ describe('SearchField', () => { it('keeps search field on shell, runtime, and model owners', () => { expect(searchFieldSource).toContain('useSearchFieldState'); expect(searchFieldSource).not.toContain('let inputEl: HTMLInputElement'); - expect(searchFieldSource).not.toContain("if (props.hasTrailingControls) return 'pr-14 sm:pr-20'"); + expect(searchFieldSource).not.toContain( + "if (props.hasTrailingControls) return 'pr-14 sm:pr-20'", + ); expect(searchFieldSource).not.toContain("if (e.key === 'Escape'"); expect(searchFieldStateSource).toContain('export function useSearchFieldState'); @@ -92,7 +94,7 @@ describe('SearchField', () => { }); it('invokes explicit keyboard and blur handlers with the input event target', async () => { - const onKeyDown = vi.fn(); + const onKeyDown = vi.fn((event: KeyboardEvent) => event.key); const onBlur = vi.fn(); render(() => ( @@ -111,6 +113,7 @@ describe('SearchField', () => { expect(onKeyDown).toHaveBeenCalledTimes(1); expect(onKeyDown.mock.calls[0][0].currentTarget).toBe(input); + expect(onKeyDown.mock.results[0].value).toBe('Enter'); expect(onBlur).toHaveBeenCalledTimes(1); expect(onBlur.mock.calls[0][0].currentTarget).toBe(input); }); diff --git a/frontend-modern/src/components/shared/useSearchFieldState.ts b/frontend-modern/src/components/shared/useSearchFieldState.ts index 46e82957e..374afb2ae 100644 --- a/frontend-modern/src/components/shared/useSearchFieldState.ts +++ b/frontend-modern/src/components/shared/useSearchFieldState.ts @@ -33,10 +33,10 @@ export function useSearchFieldState(options: SearchFieldStateOptions) { const normalizedTarget = event.target as Element; return new Proxy(event, { - get(eventTarget, prop, receiver) { + get(eventTarget, prop) { if (prop === 'currentTarget') return currentTarget; if (prop === 'target') return normalizedTarget; - return Reflect.get(eventTarget, prop, receiver); + return Reflect.get(eventTarget, prop); }, }); };