Improve Assistant model command search

This commit is contained in:
rcourtman
2026-06-07 19:08:28 +01:00
parent edabb6c059
commit 4e270cfd50
8 changed files with 80 additions and 17 deletions
@@ -119,7 +119,13 @@ leave the transcript without exposing hidden provider/tool metadata.
must still use the currently selected route until the operator explicitly
chooses a different route. Same-model configured-provider alternatives remain
visible one-click route changes and failed-turn retry choices, not automatic
send-time recovery or hidden provider fallback.
send-time recovery or hidden provider fallback. The OpenCode reference
behavior for this slice is retries on the selected provider/model, not
silent fallback to another provider or route; Pulse must adapt that as
explicit route choice plus visible route search/recovery. The Assistant
`/model` command therefore has two owned paths: an exact `provider:model-id`
argument selects that route directly, while a partial argument opens the
shared model picker with the search text prefilled.
Assistant model-selection defaults are settings-owned: the drawer may persist
explicit model selections only for concrete session IDs, while blank-session
chat defaults must flow from `/api/settings/ai` `chat_model` or `model`.
@@ -1560,6 +1560,11 @@ provider-local form fork. The shared provider configuration section must render
provider-specific controls from `aiSettingsModel.ts` `extraFields`, including
Ollama `keep_alive`, so Assistant and Patrol keep one settings shape across
labeling, help affordances, helper copy, and persistence binding.
The shared AI model picker owns model route search and presentation for
Assistant surfaces. External open requests may seed an initial search query,
but filtering, current/default route badges, recent routes, and custom route
selection must remain inside `AIModelPicker`; callers should not duplicate that
logic in command handlers or feature-local model selectors.
The Patrol alert-trigger severity selector under
`frontend-modern/src/features/patrol/` is built on the shared `FormSelect`
@@ -18,6 +18,7 @@ export interface ModelSelectorProps {
isLoading?: boolean;
error?: string;
openRequest?: number;
initialSearchQuery?: string;
onModelSelect: (modelId: string) => void;
onRefresh?: () => void;
}
@@ -91,6 +92,7 @@ export const ModelSelector: Component<ModelSelectorProps> = (props) => {
isLoading={props.isLoading}
error={props.error}
openRequest={props.openRequest}
initialSearchQuery={props.initialSearchQuery}
onRefresh={props.onRefresh}
align="left"
buttonClass="flex flex-shrink-0 items-center gap-1.5 rounded-md border border-border bg-surface px-2.5 py-1.5 text-[11px] text-muted transition-colors hover:border-border hover:text-base-content"
@@ -35,6 +35,7 @@ const {
models: ModelInfo[];
recentModelIds?: string[];
openRequest?: number;
initialSearchQuery?: string;
onModelSelect?: (modelId: string) => void;
}> = [];
const mockChat = {
@@ -302,6 +303,7 @@ vi.mock('../ModelSelector', () => ({
models: ModelInfo[];
recentModelIds?: string[];
openRequest?: number;
initialSearchQuery?: string;
onModelSelect?: (modelId: string) => void;
}) => {
mockModelSelectorProps.push(props);
@@ -311,6 +313,7 @@ vi.mock('../ModelSelector', () => ({
data-selected={props.selectedModel}
data-count={props.models.length}
data-open-request={String(props.openRequest || 0)}
data-initial-search={props.initialSearchQuery || ''}
data-recent-models={(props.recentModelIds || []).join('|')}
/>
);
@@ -1763,7 +1766,7 @@ describe('AIChat', () => {
expect(screen.getByRole('dialog', { name: 'Assistant commands' })).toBeInTheDocument();
expect(screen.getByRole('option', { name: /\/models/ })).toHaveTextContent(
'Choose or set the model route (/model provider:model-id)',
'Open model search or set a route (/model qwen or /model provider:model-id)',
);
expect(screen.getByRole('option', { name: /\/status/ })).toHaveTextContent(
'Check the selected model route',
@@ -1969,7 +1972,7 @@ describe('AIChat', () => {
await waitFor(() => expect(textarea.value).toBe(''));
});
it('keeps invalid /model route drafts editable instead of sending them', async () => {
it('opens model search for partial /model route text instead of sending it', async () => {
renderChat();
const textarea = screen.getByPlaceholderText(
'Ask about your infrastructure...',
@@ -1980,10 +1983,15 @@ describe('AIChat', () => {
expect(mockChat.setModel).not.toHaveBeenCalled();
expect(mockChat.sendMessage).not.toHaveBeenCalled();
expect(mockNotificationStore.error).toHaveBeenCalledWith(
'Use /model provider:model-id, /model default, /model next, or /model previous.',
await waitFor(() => {
expect(screen.getByTestId('model-selector')).toHaveAttribute('data-open-request', '1');
});
expect(screen.getByTestId('model-selector')).toHaveAttribute(
'data-initial-search',
'qwen3.7-plus',
);
expect(textarea.value).toBe('/model qwen3.7-plus');
expect(mockNotificationStore.error).not.toHaveBeenCalled();
await waitFor(() => expect(textarea.value).toBe(''));
});
it('checks selected provider status from /status without sending a provider prompt', async () => {
@@ -53,7 +53,7 @@ export const ASSISTANT_SLASH_COMMANDS: AssistantSlashCommand[] = [
name: 'models',
aliases: ['model', 'mo'],
action: 'models',
description: 'Choose or set the model route (/model provider:model-id)',
description: 'Open model search or set a route (/model qwen or /model provider:model-id)',
},
{
name: 'providers',
@@ -198,8 +198,6 @@ const STRUCTURED_PATROL_CONTEXT_TARGETS = new Set(['patrol-configuration', 'patr
const STRUCTURED_RESOURCE_CONTEXT_HANDOFF_KINDS = new Set(['resource_context']);
const AI_CHAT_CYCLE_RECENT_MODEL_LABEL = 'Cycle recent Assistant model';
const AI_CHAT_CONTROL_LEVEL_ORDER: AIControlLevel[] = ['read_only', 'controlled', 'autonomous'];
const AI_CHAT_MODEL_SLASH_HELP =
'Use /model provider:model-id, /model default, /model next, or /model previous.';
const AI_CHAT_COMPACT_SESSION_LABEL = 'Compact session';
const AI_CHAT_COMPACT_SESSION_EMPTY_MESSAGE = 'No Assistant session to compact';
const AI_CHAT_COMPACT_SESSION_LOADING_MESSAGE =
@@ -691,6 +689,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
const controlModeOptionRefs = new Map<AIControlLevel, HTMLButtonElement>();
let sessionSearchRequestId = 0;
const [modelSelectorOpenRequest, setModelSelectorOpenRequest] = createSignal(0);
const [modelSelectorInitialSearch, setModelSelectorInitialSearch] = createSignal('');
const [defaultModel, setDefaultModel] = createSignal('');
const [chatOverrideModel, setChatOverrideModel] = createSignal('');
const [providerReadiness, setProviderReadiness] = createSignal<ChatProviderReadinessState>({
@@ -2067,6 +2066,11 @@ export const AIChat: Component<AIChatProps> = (props) => {
}
};
const openModelSelector = (initialSearch = '') => {
setModelSelectorInitialSearch(initialSearch.trim());
setModelSelectorOpenRequest((value) => value + 1);
};
const recentModelRouteByDirection = (direction: 1 | -1) =>
getNextAssistantRecentModelRoute({
currentModel: selectedChatModel(),
@@ -2093,7 +2097,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
setShowCommandHelp(false);
setSessionRefreshLoading(false);
resetSessionSearch();
setModelSelectorOpenRequest((value) => value + 1);
openModelSelector();
return true;
}
@@ -2138,9 +2142,9 @@ export const AIChat: Component<AIChatProps> = (props) => {
}
if (!isAssistantExplicitModelRoute(target)) {
notificationStore.error(AI_CHAT_MODEL_SLASH_HELP);
openModelSelector(target);
focusComposer();
return false;
return true;
}
selectModel(target);
@@ -2153,7 +2157,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
};
const openModelSelectorFromError = () => {
setModelSelectorOpenRequest((value) => value + 1);
openModelSelector();
};
const getFailedTurnModelRouteAlternative = (message: ChatMessage) => {
@@ -2878,7 +2882,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
setShowCommandHelp(false);
setSessionRefreshLoading(false);
resetSessionSearch();
setModelSelectorOpenRequest((value) => value + 1);
openModelSelector();
break;
case 'providers':
openAssistantProviderSettings();
@@ -4562,6 +4566,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
isLoading={aiRuntimeModelsLoading()}
error={aiRuntimeModelsError()}
openRequest={modelSelectorOpenRequest()}
initialSearchQuery={modelSelectorInitialSearch()}
onModelSelect={selectModel}
onRefresh={() => loadModels(true)}
/>
@@ -57,6 +57,7 @@ export interface AIModelPickerProps {
error?: string;
onRefresh?: () => void;
openRequest?: number;
initialSearchQuery?: string;
align?: 'left' | 'right';
buttonClass?: string;
buttonLabelClass?: string;
@@ -390,9 +391,9 @@ export const AIModelPicker: Component<AIModelPickerProps> = (props) => {
queueMicrotask(() => searchInputRef?.focus());
};
const openPicker = () => {
const openPicker = (initialSearchQuery = '') => {
updateDropdownPosition();
setSearchQuery('');
setSearchQuery(initialSearchQuery.trim());
setIsOpen(true);
focusSearchInput();
};
@@ -564,7 +565,7 @@ export const AIModelPicker: Component<AIModelPickerProps> = (props) => {
return;
}
lastOpenRequest = request;
queueMicrotask(openPicker);
queueMicrotask(() => openPicker(props.initialSearchQuery));
});
const hasVisibleListOptions = createMemo(
@@ -1,5 +1,6 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-library';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createSignal } from 'solid-js';
import { AIModelPicker } from '@/components/shared/AIModelPicker';
import type { ModelInfo } from '@/types/ai';
@@ -200,6 +201,41 @@ describe('AIModelPicker', () => {
).toBeInTheDocument();
});
it('prefills search when opened by an external request', async () => {
const [openRequest, setOpenRequest] = createSignal(0);
const [initialSearchQuery, setInitialSearchQuery] = createSignal('');
render(() => (
<>
<button
type="button"
onClick={() => {
setInitialSearchQuery('gpt');
setOpenRequest((value) => value + 1);
}}
>
Open model search
</button>
<AIModelPicker
models={models}
selectedModel=""
onModelSelect={vi.fn()}
title="Select shared default model"
openRequest={openRequest()}
initialSearchQuery={initialSearchQuery()}
/>
</>
));
fireEvent.click(screen.getByRole('button', { name: 'Open model search' }));
const searchInput = (await screen.findByPlaceholderText(
'Search or enter model ID',
)) as HTMLInputElement;
expect(searchInput.value).toBe('gpt');
expect(screen.getByText('GPT-5.1 Mini')).toBeInTheDocument();
expect(screen.queryByText('MiniMax: MiniMax M2.5 via OpenRouter')).not.toBeInTheDocument();
});
it('moves keyboard focus from search through model options', async () => {
render(() => (
<AIModelPicker