mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-14 08:10:26 +00:00
Align Assistant model selection with OpenCode
This commit is contained in:
@@ -3007,6 +3007,20 @@
|
|||||||
"-count=1"
|
"-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",
|
"id": "assistant-neutral-session-context-tests",
|
||||||
"run": [
|
"run": [
|
||||||
@@ -3137,6 +3151,11 @@
|
|||||||
"path": "frontend-modern/src/components/AI/Chat/__tests__/MessageItem.test.tsx",
|
"path": "frontend-modern/src/components/AI/Chat/__tests__/MessageItem.test.tsx",
|
||||||
"kind": "file"
|
"kind": "file"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"repo": "pulse",
|
||||||
|
"path": "frontend-modern/src/components/AI/Chat/__tests__/ModelSelector.test.tsx",
|
||||||
|
"kind": "file"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"repo": "pulse",
|
"repo": "pulse",
|
||||||
"path": "frontend-modern/src/components/AI/Chat/__tests__/useChat.test.ts",
|
"path": "frontend-modern/src/components/AI/Chat/__tests__/useChat.test.ts",
|
||||||
@@ -3162,6 +3181,11 @@
|
|||||||
"path": "frontend-modern/src/components/AI/Chat/MessageItem.tsx",
|
"path": "frontend-modern/src/components/AI/Chat/MessageItem.tsx",
|
||||||
"kind": "file"
|
"kind": "file"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"repo": "pulse",
|
||||||
|
"path": "frontend-modern/src/components/AI/Chat/ModelSelector.tsx",
|
||||||
|
"kind": "file"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"repo": "pulse",
|
"repo": "pulse",
|
||||||
"path": "frontend-modern/src/components/AI/Chat/types.ts",
|
"path": "frontend-modern/src/components/AI/Chat/types.ts",
|
||||||
@@ -3182,6 +3206,26 @@
|
|||||||
"path": "frontend-modern/src/components/Alerts/incidentAssistantHandoffModel.ts",
|
"path": "frontend-modern/src/components/Alerts/incidentAssistantHandoffModel.ts",
|
||||||
"kind": "file"
|
"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",
|
"repo": "pulse",
|
||||||
"path": "frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts",
|
"path": "frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts",
|
||||||
@@ -6767,11 +6811,11 @@
|
|||||||
{
|
{
|
||||||
"id": "codex-lane-followup-architecture-post-rc-canonicalization",
|
"id": "codex-lane-followup-architecture-post-rc-canonicalization",
|
||||||
"agent_id": "codex",
|
"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",
|
"target_id": "v6-product-lane-expansion",
|
||||||
"claimed_at": "2026-06-05T21:57:48Z",
|
"claimed_at": "2026-06-05T21:57:48Z",
|
||||||
"heartbeat_at": "2026-06-06T01:23:29Z",
|
"heartbeat_at": "2026-06-06T01:33:49Z",
|
||||||
"expires_at": "2026-06-06T03:23:29Z",
|
"expires_at": "2026-06-06T03:33:49Z",
|
||||||
"work_item": {
|
"work_item": {
|
||||||
"kind": "lane-followup",
|
"kind": "lane-followup",
|
||||||
"id": "architecture-post-rc-canonicalization"
|
"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
|
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.
|
saying it is waiting on a phase that has already been superseded.
|
||||||
OpenCode-parity Assistant UX work must reference OpenCode's actual source
|
OpenCode-parity Assistant UX work must reference OpenCode's actual source
|
||||||
implementation for message parts, tool-state mutation, and progress
|
implementation for message parts, tool-state mutation, progress rendering,
|
||||||
rendering before changing Pulse behavior; parity means adapting the proven
|
and model/session selection before changing Pulse behavior; parity means
|
||||||
interaction model, not guessing from screenshots or observed behavior alone.
|
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
|
The referenced OpenCode source at fetched `origin/dev` commit
|
||||||
`fa2b63f850fc0a23bec2bdff9e660450d3fe7913` keeps prompt/footer status visible
|
`fa2b63f850fc0a23bec2bdff9e660450d3fe7913` keeps prompt/footer status visible
|
||||||
only while the session is non-idle in
|
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
|
plain select options. The picker must also constrain its dropdown and
|
||||||
internal result list to the available viewport height so settings model
|
internal result list to the available viewport height so settings model
|
||||||
catalogs remain usable on mobile and tablet layouts with bottom navigation.
|
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:
|
Gateway-routed model choices must not look like direct-provider choices:
|
||||||
the shared picker, System AI settings status strip, and per-surface
|
the shared picker, System AI settings status strip, and per-surface
|
||||||
inherited-default descriptions must render OpenRouter-hosted provider
|
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
|
`frontend-modern/src/components/shared/searchFieldModel.ts` owns clear/shortcut
|
||||||
visibility rules plus trailing-control padding policy. Future search-field work
|
visibility rules plus trailing-control padding policy. Future search-field work
|
||||||
should extend those owners instead of pushing event behavior or layout policy
|
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.
|
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/SearchInput.tsx` stays the render shell,
|
||||||
`frontend-modern/src/components/shared/useSearchInputState.ts` owns input-ref
|
`frontend-modern/src/components/shared/useSearchInputState.ts` owns input-ref
|
||||||
|
|||||||
@@ -1,20 +1,10 @@
|
|||||||
|
import { Component, createMemo } from 'solid-js';
|
||||||
import {
|
import {
|
||||||
Component,
|
AIModelPicker,
|
||||||
For,
|
type AIModelPickerExtraOption,
|
||||||
Show,
|
type AIModelPickerModelSection,
|
||||||
createEffect,
|
} from '@/components/shared/AIModelPicker';
|
||||||
createSignal,
|
import { formatAIModelRouteLabel } from '@/utils/aiProviderPresentation';
|
||||||
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';
|
|
||||||
import type { ModelInfo } from './types';
|
import type { ModelInfo } from './types';
|
||||||
|
|
||||||
export interface ModelSelectorProps {
|
export interface ModelSelectorProps {
|
||||||
@@ -24,6 +14,7 @@ export interface ModelSelectorProps {
|
|||||||
defaultModelLabel?: string;
|
defaultModelLabel?: string;
|
||||||
chatOverrideModel?: string;
|
chatOverrideModel?: string;
|
||||||
chatOverrideLabel?: string;
|
chatOverrideLabel?: string;
|
||||||
|
recentModelIds?: string[];
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
openRequest?: number;
|
openRequest?: number;
|
||||||
@@ -32,108 +23,13 @@ export interface ModelSelectorProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reusable model selector dropdown with notable model filtering.
|
* Chat-specific model selector wrapper.
|
||||||
* Shows only recent/notable models by default with a toggle to reveal older models.
|
*
|
||||||
|
* 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) => {
|
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 normalizedChatOverrideModel = createMemo(() => props.chatOverrideModel?.trim() || '');
|
||||||
const shouldShowChatOverride = createMemo(() => {
|
const shouldShowChatOverride = createMemo(() => {
|
||||||
const override = normalizedChatOverrideModel();
|
const override = normalizedChatOverrideModel();
|
||||||
@@ -145,247 +41,60 @@ export const ModelSelector: Component<ModelSelectorProps> = (props) => {
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
const selectedLabel = createMemo(() => {
|
const defaultOption = createMemo(() => ({
|
||||||
const selected = props.selectedModel?.trim();
|
label: 'Default',
|
||||||
if (!selected) {
|
description: props.defaultModelLabel
|
||||||
return props.defaultModelLabel || 'Default';
|
? `Use configured default model (${props.defaultModelLabel})`
|
||||||
}
|
: 'Use configured default model',
|
||||||
const match = props.models.find((model) => model.id === selected);
|
}));
|
||||||
if (match) return formatAIModelRouteLabel(match);
|
|
||||||
return formatAIModelRouteLabel(selected);
|
const extraOptions = createMemo<AIModelPickerExtraOption[]>(() => {
|
||||||
|
if (!shouldShowChatOverride()) return [];
|
||||||
|
const override = normalizedChatOverrideModel();
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: override,
|
||||||
|
label: 'Chat override',
|
||||||
|
description: props.chatOverrideLabel || formatAIModelRouteLabel(override),
|
||||||
|
},
|
||||||
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
// Click outside handler
|
const modelSections = createMemo<AIModelPickerModelSection[]>(() => {
|
||||||
onMount(() => {
|
const recentModelIds = props.recentModelIds || [];
|
||||||
const handleClickOutside = (e: MouseEvent) => {
|
if (recentModelIds.length === 0) return [];
|
||||||
if (
|
return [
|
||||||
isOpen() &&
|
{
|
||||||
buttonRef &&
|
title: 'Recent',
|
||||||
!buttonRef.closest('[data-dropdown]')?.contains(e.target as Node)
|
modelIds: recentModelIds,
|
||||||
) {
|
},
|
||||||
// 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].
|
const selectionBadge = createMemo(() => {
|
||||||
// So checking if e.target is inside the closest [data-dropdown] works for both button and content.
|
if ((props.selectedModel || '').trim()) return '';
|
||||||
const dropdownContainer = buttonRef.closest('[data-dropdown]');
|
return props.defaultModelLabel ? 'default' : '';
|
||||||
if (dropdownContainer && !dropdownContainer.contains(e.target as Node)) {
|
|
||||||
setIsOpen(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener('click', handleClickOutside);
|
|
||||||
onCleanup(() => document.removeEventListener('click', handleClickOutside));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="relative" data-dropdown>
|
<AIModelPicker
|
||||||
<button
|
models={props.models}
|
||||||
ref={buttonRef}
|
selectedModel={props.selectedModel}
|
||||||
onClick={handleToggle}
|
onModelSelect={props.onModelSelect}
|
||||||
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"
|
defaultOption={defaultOption()}
|
||||||
title="Select model for this chat"
|
extraOptions={extraOptions()}
|
||||||
>
|
modelSections={modelSections()}
|
||||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
emptySelectionLabel={props.defaultModelLabel || 'Default'}
|
||||||
<path
|
selectionBadge={selectionBadge()}
|
||||||
stroke-linecap="round"
|
title="Select model for this chat"
|
||||||
stroke-linejoin="round"
|
customModelDescription="Custom provider:model route"
|
||||||
stroke-width="2"
|
isLoading={props.isLoading}
|
||||||
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"
|
error={props.error}
|
||||||
/>
|
openRequest={props.openRequest}
|
||||||
</svg>
|
onRefresh={props.onRefresh}
|
||||||
<span class="max-w-[90px] sm:max-w-[180px] truncate font-medium">{selectedLabel()}</span>
|
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"
|
||||||
<Show when={isUsingDefault() && props.defaultModelLabel}>
|
buttonLabelClass="max-w-[90px] truncate font-medium sm:max-w-[180px]"
|
||||||
<span
|
dropdownClass="w-80"
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const {
|
|||||||
mockResources,
|
mockResources,
|
||||||
mockWebSocketState,
|
mockWebSocketState,
|
||||||
mockChatMessagesProps,
|
mockChatMessagesProps,
|
||||||
|
mockModelSelectorProps,
|
||||||
} = vi.hoisted(() => {
|
} = vi.hoisted(() => {
|
||||||
const mockChatMessagesProps: Array<{
|
const mockChatMessagesProps: Array<{
|
||||||
messages: ChatMessage[];
|
messages: ChatMessage[];
|
||||||
@@ -24,6 +25,13 @@ const {
|
|||||||
getModelRouteAlternative?: (message: ChatMessage) => ModelRouteRecoveryOption | null;
|
getModelRouteAlternative?: (message: ChatMessage) => ModelRouteRecoveryOption | null;
|
||||||
onUseModelRoute?: (modelId: string, messageId?: string) => void;
|
onUseModelRoute?: (modelId: string, messageId?: string) => void;
|
||||||
}> = [];
|
}> = [];
|
||||||
|
const mockModelSelectorProps: Array<{
|
||||||
|
selectedModel: string;
|
||||||
|
models: ModelInfo[];
|
||||||
|
recentModelIds?: string[];
|
||||||
|
openRequest?: number;
|
||||||
|
onModelSelect?: (modelId: string) => void;
|
||||||
|
}> = [];
|
||||||
const mockChat = {
|
const mockChat = {
|
||||||
messages: vi.fn((): ChatMessage[] => []),
|
messages: vi.fn((): ChatMessage[] => []),
|
||||||
isLoading: vi.fn(() => false),
|
isLoading: vi.fn(() => false),
|
||||||
@@ -180,6 +188,7 @@ const {
|
|||||||
mockResources,
|
mockResources,
|
||||||
mockWebSocketState,
|
mockWebSocketState,
|
||||||
mockChatMessagesProps,
|
mockChatMessagesProps,
|
||||||
|
mockModelSelectorProps,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -238,14 +247,24 @@ vi.mock('../ChatMessages', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../ModelSelector', () => ({
|
vi.mock('../ModelSelector', () => ({
|
||||||
ModelSelector: (props: { selectedModel: string; models: ModelInfo[]; openRequest?: number }) => (
|
ModelSelector: (props: {
|
||||||
<div
|
selectedModel: string;
|
||||||
data-testid="model-selector"
|
models: ModelInfo[];
|
||||||
data-selected={props.selectedModel}
|
recentModelIds?: string[];
|
||||||
data-count={props.models.length}
|
openRequest?: number;
|
||||||
data-open-request={String(props.openRequest || 0)}
|
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', () => ({
|
vi.mock('../MentionAutocomplete', () => ({
|
||||||
@@ -350,6 +369,7 @@ async function waitForProviderCheckSettled() {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mockChatMessagesProps.length = 0;
|
mockChatMessagesProps.length = 0;
|
||||||
|
mockModelSelectorProps.length = 0;
|
||||||
setViewportWidth(1440);
|
setViewportWidth(1440);
|
||||||
resetAIRuntimeState();
|
resetAIRuntimeState();
|
||||||
mockAiChatStore.isOpenSignal.mockReturnValue(true);
|
mockAiChatStore.isOpenSignal.mockReturnValue(true);
|
||||||
@@ -2637,11 +2657,57 @@ describe('AIChat', () => {
|
|||||||
renderChat();
|
renderChat();
|
||||||
|
|
||||||
await waitFor(() => {
|
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',
|
'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 ────────────────────────────────────────────────
|
// ── Autonomous banner ────────────────────────────────────────────────
|
||||||
@@ -2989,9 +3055,9 @@ describe('AIChat', () => {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
renderChat();
|
renderChat();
|
||||||
expect(
|
expect(screen.getByLabelText('Assistant active turn status')).toHaveTextContent(
|
||||||
screen.getByLabelText('Assistant active turn status'),
|
'Planning governed action and safety checks before execution. · exec',
|
||||||
).toHaveTextContent('Planning governed action and safety checks before execution. · exec');
|
);
|
||||||
expect(screen.queryByText('Generating response...')).not.toBeInTheDocument();
|
expect(screen.queryByText('Generating response...')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -343,6 +343,53 @@ describe('ModelSelector', () => {
|
|||||||
expect(onModelSelect).toHaveBeenCalledWith('openai:gpt-4o');
|
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 ---
|
// --- Notable Model Filtering ---
|
||||||
|
|
||||||
it('shows only notable models by default', () => {
|
it('shows only notable models by default', () => {
|
||||||
@@ -492,17 +539,28 @@ describe('ModelSelector', () => {
|
|||||||
expect(screen.queryByText(/older models/)).not.toBeInTheDocument();
|
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()} />);
|
render(() => <ModelSelector models={SAMPLE_MODELS} selectedModel="" onModelSelect={vi.fn()} />);
|
||||||
|
|
||||||
fireEvent.click(screen.getByTitle('Select model for this chat'));
|
fireEvent.click(screen.getByTitle('Select model for this chat'));
|
||||||
const searchInput = screen.getByPlaceholderText('Search or enter model ID');
|
const searchInput = screen.getByPlaceholderText('Search or enter model ID');
|
||||||
fireEvent.input(searchInput, { target: { value: 'my-custom-model' } });
|
fireEvent.input(searchInput, { target: { value: 'my-custom-model' } });
|
||||||
|
|
||||||
expect(screen.getByText('Use "my-custom-model"')).toBeInTheDocument();
|
expect(screen.queryByText('Use "my-custom-model"')).not.toBeInTheDocument();
|
||||||
expect(screen.getByText('Custom model ID')).toBeInTheDocument();
|
expect(screen.getByText('No matching models.')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not show custom model option when query exactly matches an existing model ID', () => {
|
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');
|
const searchInput = screen.getByPlaceholderText('Search or enter model ID');
|
||||||
fireEvent.input(searchInput, { target: { value: 'anthropic:claude-sonnet-4' } });
|
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', () => {
|
it('selects custom model when clicked', () => {
|
||||||
@@ -523,10 +581,10 @@ describe('ModelSelector', () => {
|
|||||||
|
|
||||||
fireEvent.click(screen.getByTitle('Select model for this chat'));
|
fireEvent.click(screen.getByTitle('Select model for this chat'));
|
||||||
const searchInput = screen.getByPlaceholderText('Search or enter model ID');
|
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.click(screen.getByText('Use "my-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', () => {
|
it('selects custom model on Enter key', () => {
|
||||||
@@ -537,10 +595,10 @@ describe('ModelSelector', () => {
|
|||||||
|
|
||||||
fireEvent.click(screen.getByTitle('Select model for this chat'));
|
fireEvent.click(screen.getByTitle('Select model for this chat'));
|
||||||
const searchInput = screen.getByPlaceholderText('Search or enter model ID');
|
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' });
|
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', () => {
|
it('does not submit on non-Enter keys', () => {
|
||||||
@@ -662,9 +720,8 @@ describe('ModelSelector', () => {
|
|||||||
fireEvent.click(screen.getByTitle('Select model for this chat'));
|
fireEvent.click(screen.getByTitle('Select model for this chat'));
|
||||||
|
|
||||||
// GPT-4o appears twice: in the top button label and in the dropdown list.
|
// 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 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();
|
expect(dropdownEntry).toBeDefined();
|
||||||
const modelButton = dropdownEntry!.closest('button');
|
const modelButton = dropdownEntry!.closest('button');
|
||||||
expect(modelButton?.className).toContain('bg-blue-50');
|
expect(modelButton?.className).toContain('bg-blue-50');
|
||||||
@@ -732,7 +789,7 @@ describe('ModelSelector', () => {
|
|||||||
expect(screen.getByPlaceholderText('Search or enter model ID')).toBeInTheDocument();
|
expect(screen.getByPlaceholderText('Search or enter model ID')).toBeInTheDocument();
|
||||||
|
|
||||||
// Simulate click outside the dropdown container
|
// 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();
|
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'));
|
fireEvent.click(screen.getByTitle('Select model for this chat'));
|
||||||
const searchInput = screen.getByPlaceholderText('Search or enter model ID');
|
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' });
|
fireEvent.keyDown(searchInput, { key: 'Enter' });
|
||||||
|
|
||||||
// customModelCandidate trims the search query
|
// 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', () => {
|
it('does not submit on Enter when search query is empty', () => {
|
||||||
|
|||||||
@@ -86,10 +86,12 @@ import type {
|
|||||||
import { formatIdentifierLabel } from '@/utils/textPresentation';
|
import { formatIdentifierLabel } from '@/utils/textPresentation';
|
||||||
|
|
||||||
const MODEL_SESSION_STORAGE_KEY = 'pulse:ai_chat_models_by_session';
|
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 PROMPT_HISTORY_STORAGE_KEY = 'pulse:ai_chat_prompt_history';
|
||||||
const DEFAULT_SESSION_KEY = '__default__';
|
const DEFAULT_SESSION_KEY = '__default__';
|
||||||
const AI_CHAT_MIN_DOCKED_VIEWPORT_WIDTH = 1200;
|
const AI_CHAT_MIN_DOCKED_VIEWPORT_WIDTH = 1200;
|
||||||
const AI_CHAT_PROMPT_HISTORY_LIMIT = 100;
|
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_PATROL_CONTEXT_TARGETS = new Set(['patrol-configuration', 'patrol-run']);
|
||||||
const STRUCTURED_RESOURCE_CONTEXT_HANDOFF_KINDS = new Set(['resource_context']);
|
const STRUCTURED_RESOURCE_CONTEXT_HANDOFF_KINDS = new Set(['resource_context']);
|
||||||
|
|
||||||
@@ -672,6 +674,54 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
|||||||
const [modelSelections, setModelSelections] =
|
const [modelSelections, setModelSelections] =
|
||||||
createSignal<Record<string, string>>(initialModelSelections);
|
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 getStoredModel = (sessionId: string) => {
|
||||||
const key = sessionId.trim();
|
const key = sessionId.trim();
|
||||||
if (!key) return '';
|
if (!key) return '';
|
||||||
@@ -957,6 +1007,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
|||||||
const selectModel = (modelId: string) => {
|
const selectModel = (modelId: string) => {
|
||||||
chat.setModel(modelId);
|
chat.setModel(modelId);
|
||||||
updateStoredModel(chat.sessionId(), modelId);
|
updateStoredModel(chat.sessionId(), modelId);
|
||||||
|
rememberRecentModel(modelId);
|
||||||
};
|
};
|
||||||
|
|
||||||
const openModelSelectorFromError = () => {
|
const openModelSelectorFromError = () => {
|
||||||
@@ -1865,6 +1916,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
|||||||
defaultModelLabel={defaultModelLabel()}
|
defaultModelLabel={defaultModelLabel()}
|
||||||
chatOverrideModel={chatOverrideModel()}
|
chatOverrideModel={chatOverrideModel()}
|
||||||
chatOverrideLabel={chatOverrideLabel()}
|
chatOverrideLabel={chatOverrideLabel()}
|
||||||
|
recentModelIds={recentModelIds()}
|
||||||
isLoading={aiRuntimeModelsLoading()}
|
isLoading={aiRuntimeModelsLoading()}
|
||||||
error={aiRuntimeModelsError()}
|
error={aiRuntimeModelsError()}
|
||||||
openRequest={modelSelectorOpenRequest()}
|
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 ChevronDownIcon from 'lucide-solid/icons/chevron-down';
|
||||||
import MonitorIcon from 'lucide-solid/icons/monitor';
|
import MonitorIcon from 'lucide-solid/icons/monitor';
|
||||||
import RefreshCwIcon from 'lucide-solid/icons/refresh-cw';
|
import RefreshCwIcon from 'lucide-solid/icons/refresh-cw';
|
||||||
@@ -16,12 +25,27 @@ type AIModelPickerDefaultOption = {
|
|||||||
description?: string;
|
description?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AIModelPickerExtraOption = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
hidden?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AIModelPickerModelSection = {
|
||||||
|
title: string;
|
||||||
|
modelIds: string[];
|
||||||
|
};
|
||||||
|
|
||||||
export interface AIModelPickerProps {
|
export interface AIModelPickerProps {
|
||||||
models: ModelInfo[];
|
models: ModelInfo[];
|
||||||
selectedModel: string;
|
selectedModel: string;
|
||||||
onModelSelect: (modelId: string) => void;
|
onModelSelect: (modelId: string) => void;
|
||||||
defaultOption?: AIModelPickerDefaultOption;
|
defaultOption?: AIModelPickerDefaultOption;
|
||||||
|
extraOptions?: AIModelPickerExtraOption[];
|
||||||
|
modelSections?: AIModelPickerModelSection[];
|
||||||
emptySelectionLabel?: string;
|
emptySelectionLabel?: string;
|
||||||
|
selectionBadge?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
searchPlaceholder?: string;
|
searchPlaceholder?: string;
|
||||||
emptyState?: string;
|
emptyState?: string;
|
||||||
@@ -30,6 +54,7 @@ export interface AIModelPickerProps {
|
|||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
onRefresh?: () => void;
|
onRefresh?: () => void;
|
||||||
|
openRequest?: number;
|
||||||
align?: 'left' | 'right';
|
align?: 'left' | 'right';
|
||||||
buttonClass?: string;
|
buttonClass?: string;
|
||||||
buttonLabelClass?: string;
|
buttonLabelClass?: string;
|
||||||
@@ -49,6 +74,26 @@ const MOBILE_BOTTOM_CLEARANCE = 88;
|
|||||||
const DESKTOP_BOTTOM_CLEARANCE = 16;
|
const DESKTOP_BOTTOM_CLEARANCE = 16;
|
||||||
const SEARCH_HEADER_HEIGHT = 52;
|
const SEARCH_HEADER_HEIGHT = 52;
|
||||||
const ERROR_ROW_HEIGHT = 36;
|
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[]> {
|
function groupModelsByProvider(models: ModelInfo[]): Map<string, ModelInfo[]> {
|
||||||
const grouped = new Map<string, ModelInfo[]>();
|
const grouped = new Map<string, ModelInfo[]>();
|
||||||
@@ -78,10 +123,42 @@ export const AIModelPicker: Component<AIModelPickerProps> = (props) => {
|
|||||||
});
|
});
|
||||||
let containerRef: HTMLDivElement | undefined;
|
let containerRef: HTMLDivElement | undefined;
|
||||||
let buttonRef: HTMLButtonElement | undefined;
|
let buttonRef: HTMLButtonElement | undefined;
|
||||||
|
let searchInputRef: HTMLInputElement | undefined;
|
||||||
|
let lastOpenRequest = props.openRequest || 0;
|
||||||
|
|
||||||
const selectedModel = createMemo(() => props.selectedModel?.trim() || '');
|
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 notableModels = createMemo(() => props.models.filter((model) => model.notable));
|
||||||
const shouldFilterToNotable = createMemo(() => notableModels().length > 0);
|
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(() => {
|
const visibleUnsearchedModels = createMemo(() => {
|
||||||
if (showAllModels() || !shouldFilterToNotable()) {
|
if (showAllModels() || !shouldFilterToNotable()) {
|
||||||
@@ -97,13 +174,17 @@ export const AIModelPicker: Component<AIModelPickerProps> = (props) => {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
const selected = selectedModel();
|
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 filteredModels = createMemo(() => {
|
||||||
const query = searchQuery().trim().toLowerCase();
|
const query = searchQuery().trim().toLowerCase();
|
||||||
if (!query) {
|
if (!query) {
|
||||||
return visibleUnsearchedModels();
|
const sectionIds = sectionModelIds();
|
||||||
|
return visibleUnsearchedModels().filter((model) => !sectionIds.has(model.id));
|
||||||
}
|
}
|
||||||
return props.models.filter((model) => {
|
return props.models.filter((model) => {
|
||||||
const provider = model.provider?.trim() || getProviderFromModelId(model.id);
|
const provider = model.provider?.trim() || getProviderFromModelId(model.id);
|
||||||
@@ -132,7 +213,7 @@ export const AIModelPicker: Component<AIModelPickerProps> = (props) => {
|
|||||||
if (!candidate) {
|
if (!candidate) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (!candidate.includes(':')) {
|
if (!isExplicitModelRoute(candidate)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return !props.models.some((model) => model.id === candidate);
|
return !props.models.some((model) => model.id === candidate);
|
||||||
@@ -186,14 +267,26 @@ export const AIModelPicker: Component<AIModelPickerProps> = (props) => {
|
|||||||
setSearchQuery('');
|
setSearchQuery('');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const focusSearchInput = () => {
|
||||||
|
queueMicrotask(() => searchInputRef?.focus());
|
||||||
|
};
|
||||||
|
|
||||||
|
const openPicker = () => {
|
||||||
|
updateDropdownPosition();
|
||||||
|
setSearchQuery('');
|
||||||
|
setIsOpen(true);
|
||||||
|
focusSearchInput();
|
||||||
|
};
|
||||||
|
|
||||||
const handleToggle = () => {
|
const handleToggle = () => {
|
||||||
if (props.disabled) {
|
if (props.disabled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!isOpen()) {
|
if (!isOpen()) {
|
||||||
updateDropdownPosition();
|
openPicker();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
setIsOpen(!isOpen());
|
closePicker();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelect = (modelId: string) => {
|
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(() => {
|
onMount(() => {
|
||||||
const handlePointerDown = (event: MouseEvent) => {
|
const handlePointerDown = (event: MouseEvent) => {
|
||||||
if (isOpen() && containerRef && !containerRef.contains(event.target as Node)) {
|
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" />
|
<MonitorIcon class="h-3.5 w-3.5 shrink-0" />
|
||||||
<span class={props.buttonLabelClass || DEFAULT_LABEL_CLASS}>{selectedLabel()}</span>
|
<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}>
|
<Show when={props.isLoading}>
|
||||||
<RefreshCwIcon class="h-3 w-3 shrink-0 animate-spin" />
|
<RefreshCwIcon class="h-3 w-3 shrink-0 animate-spin" />
|
||||||
</Show>
|
</Show>
|
||||||
@@ -264,6 +377,9 @@ export const AIModelPicker: Component<AIModelPickerProps> = (props) => {
|
|||||||
placeholder={props.searchPlaceholder || 'Search or enter model ID'}
|
placeholder={props.searchPlaceholder || 'Search or enter model ID'}
|
||||||
class="flex-1"
|
class="flex-1"
|
||||||
inputClass="py-1.5 text-xs focus:ring-blue-400"
|
inputClass="py-1.5 text-xs focus:ring-blue-400"
|
||||||
|
inputRef={(el) => {
|
||||||
|
searchInputRef = el;
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<Show when={props.onRefresh}>
|
<Show when={props.onRefresh}>
|
||||||
<button
|
<button
|
||||||
@@ -302,6 +418,21 @@ export const AIModelPicker: Component<AIModelPickerProps> = (props) => {
|
|||||||
</button>
|
</button>
|
||||||
</Show>
|
</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()}>
|
<Show when={showCustomModelOption()}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -315,12 +446,49 @@ export const AIModelPicker: Component<AIModelPickerProps> = (props) => {
|
|||||||
</button>
|
</button>
|
||||||
</Show>
|
</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">
|
<div class="px-3 py-4 text-center text-[11px] text-muted">
|
||||||
{props.emptyState || DEFAULT_EMPTY_STATE}
|
{props.emptyState || DEFAULT_EMPTY_STATE}
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</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())}>
|
<For each={Array.from(groupModelsByProvider(filteredModels()).entries())}>
|
||||||
{([provider, providerModels]) => (
|
{([provider, providerModels]) => (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -214,4 +214,28 @@ describe('AIModelPicker', () => {
|
|||||||
|
|
||||||
expect(onModelSelect).toHaveBeenCalledWith('openrouter:minimax/minimax-m2.5');
|
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', () => {
|
it('keeps search field on shell, runtime, and model owners', () => {
|
||||||
expect(searchFieldSource).toContain('useSearchFieldState');
|
expect(searchFieldSource).toContain('useSearchFieldState');
|
||||||
expect(searchFieldSource).not.toContain('let inputEl: HTMLInputElement');
|
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(searchFieldSource).not.toContain("if (e.key === 'Escape'");
|
||||||
|
|
||||||
expect(searchFieldStateSource).toContain('export function useSearchFieldState');
|
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 () => {
|
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();
|
const onBlur = vi.fn();
|
||||||
|
|
||||||
render(() => (
|
render(() => (
|
||||||
@@ -111,6 +113,7 @@ describe('SearchField', () => {
|
|||||||
|
|
||||||
expect(onKeyDown).toHaveBeenCalledTimes(1);
|
expect(onKeyDown).toHaveBeenCalledTimes(1);
|
||||||
expect(onKeyDown.mock.calls[0][0].currentTarget).toBe(input);
|
expect(onKeyDown.mock.calls[0][0].currentTarget).toBe(input);
|
||||||
|
expect(onKeyDown.mock.results[0].value).toBe('Enter');
|
||||||
expect(onBlur).toHaveBeenCalledTimes(1);
|
expect(onBlur).toHaveBeenCalledTimes(1);
|
||||||
expect(onBlur.mock.calls[0][0].currentTarget).toBe(input);
|
expect(onBlur.mock.calls[0][0].currentTarget).toBe(input);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -33,10 +33,10 @@ export function useSearchFieldState(options: SearchFieldStateOptions) {
|
|||||||
const normalizedTarget = event.target as Element;
|
const normalizedTarget = event.target as Element;
|
||||||
|
|
||||||
return new Proxy(event, {
|
return new Proxy(event, {
|
||||||
get(eventTarget, prop, receiver) {
|
get(eventTarget, prop) {
|
||||||
if (prop === 'currentTarget') return currentTarget;
|
if (prop === 'currentTarget') return currentTarget;
|
||||||
if (prop === 'target') return normalizedTarget;
|
if (prop === 'target') return normalizedTarget;
|
||||||
return Reflect.get(eventTarget, prop, receiver);
|
return Reflect.get(eventTarget, prop);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user