mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 02:55:51 +00:00
Improve Assistant model command search
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user