mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-23 19:57:09 +00:00
Align Assistant model selection with OpenCode
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<ModelSelectorProps> = (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<ModelSelectorProps> = (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<AIModelPickerExtraOption[]>(() => {
|
||||
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<AIModelPickerModelSection[]>(() => {
|
||||
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 (
|
||||
<div class="relative" data-dropdown>
|
||||
<button
|
||||
ref={buttonRef}
|
||||
onClick={handleToggle}
|
||||
class="flex items-center gap-1.5 px-2.5 py-1.5 text-[11px] text-muted hover:text-base-content rounded-md border border-border hover:border-border bg-surface transition-colors"
|
||||
title="Select model for this chat"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="max-w-[90px] sm:max-w-[180px] truncate font-medium">{selectedLabel()}</span>
|
||||
<Show when={isUsingDefault() && props.defaultModelLabel}>
|
||||
<span
|
||||
class="text-[10px] font-normal text-muted"
|
||||
title="Using the configured default model"
|
||||
>
|
||||
default
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={props.isLoading}>
|
||||
<svg class="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="3"
|
||||
/>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
</Show>
|
||||
<svg class="w-3 h-3 " fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<Show when={isOpen()}>
|
||||
<div
|
||||
class="fixed w-80 max-h-96 overflow-hidden bg-surface rounded-md shadow-sm border border-border z-[9999]"
|
||||
style={{ top: `${dropdownPosition().top}px`, right: `${dropdownPosition().right}px` }}
|
||||
>
|
||||
{/* Search bar */}
|
||||
<div class="flex items-center gap-2 px-3 py-2 border-b border-border">
|
||||
<SearchField
|
||||
value={searchQuery()}
|
||||
onChange={setSearchQuery}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Search or enter model ID"
|
||||
class="flex-1"
|
||||
inputClass="py-1.5 text-xs focus:ring-blue-400"
|
||||
inputRef={(el) => {
|
||||
searchInputRef = el;
|
||||
}}
|
||||
/>
|
||||
<Show when={props.onRefresh}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onRefresh?.()}
|
||||
disabled={props.isLoading}
|
||||
class="p-1.5 rounded-md hover:text-base-content hover:bg-surface-hover disabled:opacity-50"
|
||||
title="Refresh models"
|
||||
>
|
||||
<svg
|
||||
class={`w-3.5 h-3.5 ${props.isLoading ? 'animate-spin' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 4v6h6M20 20v-6h-6M5.32 9A7.5 7.5 0 0119 12.5M18.68 15A7.5 7.5 0 015 11.5"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
<Show when={props.error}>
|
||||
<div class="px-3 py-2 text-[11px] text-red-500 border-b border-border">
|
||||
{props.error}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Model list */}
|
||||
<div class="max-h-72 overflow-y-auto py-1">
|
||||
{/* Default option */}
|
||||
<button
|
||||
onClick={() => handleSelect('')}
|
||||
class={`w-full px-3 py-2 text-left text-sm hover:bg-surface-hover ${!props.selectedModel ? 'bg-blue-50 dark:bg-blue-900' : ''}`}
|
||||
>
|
||||
<div class="font-medium text-base-content">Default</div>
|
||||
<div class="text-[11px] text-muted">
|
||||
{props.defaultModelLabel
|
||||
? `Use configured default model (${props.defaultModelLabel})`
|
||||
: 'Use configured default model'}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Chat override option */}
|
||||
<Show when={shouldShowChatOverride()}>
|
||||
<button
|
||||
onClick={() => handleSelect(normalizedChatOverrideModel())}
|
||||
class={`w-full px-3 py-2 text-left text-sm hover:bg-surface-hover ${props.selectedModel === normalizedChatOverrideModel() ? 'bg-blue-50 dark:bg-blue-900' : ''}`}
|
||||
>
|
||||
<div class="font-medium text-base-content">Chat override</div>
|
||||
<div class="text-[11px] text-muted">
|
||||
{props.chatOverrideLabel || formatAIModelRouteLabel(normalizedChatOverrideModel())}
|
||||
</div>
|
||||
</button>
|
||||
</Show>
|
||||
|
||||
{/* Custom model option */}
|
||||
<Show when={showCustomModelOption()}>
|
||||
<button
|
||||
onClick={() => handleSelect(customModelCandidate())}
|
||||
class="w-full px-3 py-2 text-left text-sm hover:bg-surface-hover"
|
||||
>
|
||||
<div class="font-medium text-base-content">Use "{customModelCandidate()}"</div>
|
||||
<div class="text-[11px] text-muted">Custom model ID</div>
|
||||
</button>
|
||||
</Show>
|
||||
|
||||
{/* No results */}
|
||||
<Show when={!props.isLoading && filteredModels().length === 0}>
|
||||
<div class="px-3 py-4 text-center text-[11px] text-muted">
|
||||
{AI_CHAT_MODEL_SELECTOR_EMPTY_STATE}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Grouped models */}
|
||||
<For each={Array.from(groupModelsByProvider(filteredModels()).entries())}>
|
||||
{([provider, providerModels]) => (
|
||||
<>
|
||||
<div class="px-3 py-1.5 text-[11px] font-semibold text-muted bg-surface-alt sticky top-0">
|
||||
{getAIProviderDisplayName(provider) || provider}
|
||||
</div>
|
||||
<For each={providerModels}>
|
||||
{(model) => (
|
||||
<button
|
||||
onClick={() => handleSelect(model.id)}
|
||||
class={`w-full px-3 py-2 text-left text-sm hover:bg-surface-hover ${props.selectedModel === model.id ? 'bg-blue-50 dark:bg-blue-900' : ''}`}
|
||||
>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="font-medium text-base-content">
|
||||
{formatAIModelRouteLabel(model)}
|
||||
</span>
|
||||
</div>
|
||||
<Show when={model.description}>
|
||||
<div class="text-[11px] text-muted line-clamp-2">{model.description}</div>
|
||||
</Show>
|
||||
<Show when={model.name && model.name !== model.id}>
|
||||
<div class="text-[10px] text-muted">{model.id}</div>
|
||||
</Show>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
|
||||
{/* Toggle to show older models */}
|
||||
<Show when={hiddenModelCount() > 0 && !searchQuery().trim()}>
|
||||
<div class="border-t border-border mt-1 pt-1">
|
||||
<button
|
||||
onClick={() => setShowAllModels(!showAllModels())}
|
||||
class="w-full px-3 py-2 text-left text-xs text-muted hover:text-base-content hover:bg-surface-hover flex items-center gap-1.5"
|
||||
>
|
||||
<svg
|
||||
class={`w-3 h-3 transition-transform ${showAllModels() ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
{showAllModels()
|
||||
? 'Hide older models'
|
||||
: `Show ${hiddenModelCount()} older models`}
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<AIModelPicker
|
||||
models={props.models}
|
||||
selectedModel={props.selectedModel}
|
||||
onModelSelect={props.onModelSelect}
|
||||
defaultOption={defaultOption()}
|
||||
extraOptions={extraOptions()}
|
||||
modelSections={modelSections()}
|
||||
emptySelectionLabel={props.defaultModelLabel || 'Default'}
|
||||
selectionBadge={selectionBadge()}
|
||||
title="Select model for this chat"
|
||||
customModelDescription="Custom provider:model route"
|
||||
isLoading={props.isLoading}
|
||||
error={props.error}
|
||||
openRequest={props.openRequest}
|
||||
onRefresh={props.onRefresh}
|
||||
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"
|
||||
buttonLabelClass="max-w-[90px] truncate font-medium sm:max-w-[180px]"
|
||||
dropdownClass="w-80"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 }) => (
|
||||
<div
|
||||
data-testid="model-selector"
|
||||
data-selected={props.selectedModel}
|
||||
data-count={props.models.length}
|
||||
data-open-request={String(props.openRequest || 0)}
|
||||
/>
|
||||
),
|
||||
ModelSelector: (props: {
|
||||
selectedModel: string;
|
||||
models: ModelInfo[];
|
||||
recentModelIds?: string[];
|
||||
openRequest?: number;
|
||||
onModelSelect?: (modelId: string) => void;
|
||||
}) => {
|
||||
mockModelSelectorProps.push(props);
|
||||
return (
|
||||
<div
|
||||
data-testid="model-selector"
|
||||
data-selected={props.selectedModel}
|
||||
data-count={props.models.length}
|
||||
data-open-request={String(props.openRequest || 0)}
|
||||
data-recent-models={(props.recentModelIds || []).join('|')}
|
||||
/>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
|
||||
@@ -343,6 +343,53 @@ describe('ModelSelector', () => {
|
||||
expect(onModelSelect).toHaveBeenCalledWith('openai:gpt-4o');
|
||||
});
|
||||
|
||||
it('shows recent model routes before the provider catalog', () => {
|
||||
render(() => (
|
||||
<ModelSelector
|
||||
models={[
|
||||
...SAMPLE_MODELS,
|
||||
makeModel({
|
||||
id: 'openrouter:deepseek/deepseek-v4-pro',
|
||||
name: 'DeepSeek: DeepSeek V4 Pro',
|
||||
provider: 'openrouter',
|
||||
notable: true,
|
||||
}),
|
||||
]}
|
||||
selectedModel=""
|
||||
recentModelIds={['openrouter:deepseek/deepseek-v4-pro', 'openai:gpt-4o']}
|
||||
onModelSelect={vi.fn()}
|
||||
/>
|
||||
));
|
||||
|
||||
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(() => (
|
||||
<ModelSelector
|
||||
models={SAMPLE_MODELS}
|
||||
selectedModel=""
|
||||
recentModelIds={['openrouter:custom/model']}
|
||||
onModelSelect={vi.fn()}
|
||||
/>
|
||||
));
|
||||
|
||||
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(() => <ModelSelector models={SAMPLE_MODELS} selectedModel="" onModelSelect={vi.fn()} />);
|
||||
|
||||
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(() => <ModelSelector models={SAMPLE_MODELS} selectedModel="" onModelSelect={vi.fn()} />);
|
||||
|
||||
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', () => {
|
||||
|
||||
@@ -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<AIChatProps> = (props) => {
|
||||
const [modelSelections, setModelSelections] =
|
||||
createSignal<Record<string, string>>(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<string>();
|
||||
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<string[]>(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<AIChatProps> = (props) => {
|
||||
const selectModel = (modelId: string) => {
|
||||
chat.setModel(modelId);
|
||||
updateStoredModel(chat.sessionId(), modelId);
|
||||
rememberRecentModel(modelId);
|
||||
};
|
||||
|
||||
const openModelSelectorFromError = () => {
|
||||
@@ -1865,6 +1916,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||
defaultModelLabel={defaultModelLabel()}
|
||||
chatOverrideModel={chatOverrideModel()}
|
||||
chatOverrideLabel={chatOverrideLabel()}
|
||||
recentModelIds={recentModelIds()}
|
||||
isLoading={aiRuntimeModelsLoading()}
|
||||
error={aiRuntimeModelsError()}
|
||||
openRequest={modelSelectorOpenRequest()}
|
||||
|
||||
@@ -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<string, ModelInfo[]> {
|
||||
const grouped = new Map<string, ModelInfo[]>();
|
||||
@@ -78,10 +123,42 @@ export const AIModelPicker: Component<AIModelPickerProps> = (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<string>();
|
||||
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<AIModelPickerProps> = (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<AIModelPickerProps> = (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<AIModelPickerProps> = (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<AIModelPickerProps> = (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<AIModelPickerProps> = (props) => {
|
||||
>
|
||||
<MonitorIcon class="h-3.5 w-3.5 shrink-0" />
|
||||
<span class={props.buttonLabelClass || DEFAULT_LABEL_CLASS}>{selectedLabel()}</span>
|
||||
<Show when={props.selectionBadge}>
|
||||
<span class="text-[10px] font-normal text-muted">{props.selectionBadge}</span>
|
||||
</Show>
|
||||
<Show when={props.isLoading}>
|
||||
<RefreshCwIcon class="h-3 w-3 shrink-0 animate-spin" />
|
||||
</Show>
|
||||
@@ -264,6 +377,9 @@ export const AIModelPicker: Component<AIModelPickerProps> = (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;
|
||||
}}
|
||||
/>
|
||||
<Show when={props.onRefresh}>
|
||||
<button
|
||||
@@ -302,6 +418,21 @@ export const AIModelPicker: Component<AIModelPickerProps> = (props) => {
|
||||
</button>
|
||||
</Show>
|
||||
|
||||
<For each={extraOptions()}>
|
||||
{(option) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSelect(option.id)}
|
||||
class={`w-full px-3 py-2 text-left text-sm hover:bg-surface-hover ${selectedModel() === option.id ? 'bg-blue-50 dark:bg-blue-900' : ''}`}
|
||||
>
|
||||
<div class="font-medium text-base-content">{option.label}</div>
|
||||
<Show when={option.description}>
|
||||
<div class="text-[11px] text-muted">{option.description}</div>
|
||||
</Show>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
|
||||
<Show when={showCustomModelOption()}>
|
||||
<button
|
||||
type="button"
|
||||
@@ -315,12 +446,49 @@ export const AIModelPicker: Component<AIModelPickerProps> = (props) => {
|
||||
</button>
|
||||
</Show>
|
||||
|
||||
<Show when={!props.isLoading && filteredModels().length === 0}>
|
||||
<Show
|
||||
when={
|
||||
!props.isLoading &&
|
||||
filteredModels().length === 0 &&
|
||||
(Boolean(searchQuery().trim()) || !hasVisibleListOptions())
|
||||
}
|
||||
>
|
||||
<div class="px-3 py-4 text-center text-[11px] text-muted">
|
||||
{props.emptyState || DEFAULT_EMPTY_STATE}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!searchQuery().trim()}>
|
||||
<For each={modelSections()}>
|
||||
{(section) => (
|
||||
<>
|
||||
<div class="sticky top-0 bg-surface-alt px-3 py-1.5 text-[11px] font-semibold text-muted">
|
||||
{section.title}
|
||||
</div>
|
||||
<For each={section.models}>
|
||||
{(entry) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSelect(entry.id)}
|
||||
class={`w-full px-3 py-2 text-left text-sm hover:bg-surface-hover ${selectedModel() === entry.id ? 'bg-blue-50 dark:bg-blue-900' : ''}`}
|
||||
>
|
||||
<div class="font-medium text-base-content">{modelRouteLabel(entry)}</div>
|
||||
<Show when={modelRouteDescription(entry)}>
|
||||
<div class="line-clamp-2 text-[11px] text-muted">
|
||||
{modelRouteDescription(entry)}
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={modelRouteSecondaryId(entry)}>
|
||||
{(modelId) => <div class="text-[10px] text-muted">{modelId()}</div>}
|
||||
</Show>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
|
||||
<For each={Array.from(groupModelsByProvider(filteredModels()).entries())}>
|
||||
{([provider, providerModels]) => (
|
||||
<>
|
||||
|
||||
@@ -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(() => (
|
||||
<AIModelPicker
|
||||
models={models}
|
||||
selectedModel=""
|
||||
onModelSelect={vi.fn()}
|
||||
title="Select shared default model"
|
||||
modelSections={[
|
||||
{
|
||||
title: 'Recent',
|
||||
modelIds: ['openrouter:minimax/minimax-m2.5', 'openrouter:custom/model'],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
));
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user