[eric] split: extract ChatInput model picker hook + helpers

This commit is contained in:
ciregenz
2026-05-23 06:07:53 -07:00
parent 3a5a4f7798
commit 6002c06cb1
9 changed files with 636 additions and 0 deletions
@@ -0,0 +1,43 @@
import { useEffect, RefObject } from 'react';
// Module-level draft store keyed by sessionId; survives unmount/remount and preserves skill pills via innerHTML.
const _draftStore = new Map<string, string>();
// 200ms debounce coalesces fast typing; innerHTML reads do full DOM serialization.
const _draftDebounceTimers = new Map<string, ReturnType<typeof setTimeout>>();
const DRAFT_DEBOUNCE_MS = 200;
export function scheduleDraftSave(ownerId: string, getHtml: () => string) {
const existing = _draftDebounceTimers.get(ownerId);
if (existing) clearTimeout(existing);
_draftDebounceTimers.set(ownerId, setTimeout(() => {
_draftDebounceTimers.delete(ownerId);
const html = getHtml();
if (html && html !== '<br>') _draftStore.set(ownerId, html);
else _draftStore.delete(ownerId);
}, DRAFT_DEBOUNCE_MS));
}
export function loadDraft(ownerId: string): string | undefined {
return _draftStore.get(ownerId);
}
export function deleteDraft(ownerId: string) {
_draftStore.delete(ownerId);
}
export function useDraftLoad(editorRef: RefObject<HTMLDivElement>, ownerId: string) {
useEffect(() => {
const saved = _draftStore.get(ownerId);
const editor = editorRef.current;
if (saved && editor && !editor.textContent?.trim()) {
editor.innerHTML = saved;
const range = document.createRange();
range.selectNodeContents(editor);
range.collapse(false);
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(range);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}
@@ -0,0 +1,20 @@
export function formatTokenCount(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
return String(n);
}
// Path basename that works on both POSIX (/Users/x/file.pdf) and Windows
// (C:\Users\x\file.pdf). Splits on either separator; falls back to the
// raw path so empty segments don't yield ''.
export function basename(p: string): string {
if (!p) return '';
const parts = p.split(/[\\/]/).filter(Boolean);
return parts[parts.length - 1] || p;
}
export function pathTail(p: string, n: number): string {
if (!p) return '';
const parts = p.split(/[\\/]/).filter(Boolean);
return parts.slice(-n).join('/');
}
@@ -0,0 +1,16 @@
import React from 'react';
import SmartToyOutlinedIcon from '@mui/icons-material/SmartToyOutlined';
import QuestionAnswerOutlinedIcon from '@mui/icons-material/QuestionAnswerOutlined';
import MapOutlinedIcon from '@mui/icons-material/MapOutlined';
import CategoryOutlinedIcon from '@mui/icons-material/CategoryOutlined';
import TuneOutlinedIcon from '@mui/icons-material/TuneOutlined';
export const ICON_MAP: Record<string, React.ReactNode> = {
smart_toy: <SmartToyOutlinedIcon sx={{ fontSize: 14 }} />,
question_answer: <QuestionAnswerOutlinedIcon sx={{ fontSize: 14 }} />,
map: <MapOutlinedIcon sx={{ fontSize: 14 }} />,
category: <CategoryOutlinedIcon sx={{ fontSize: 14 }} />,
tune: <TuneOutlinedIcon sx={{ fontSize: 14 }} />,
};
export const FALLBACK_MODE_BASE = { label: 'Agent', icon: ICON_MAP.smart_toy };
@@ -0,0 +1,113 @@
// Mirrors SubscriptionCard colors in Settings.
export const PROVIDER_COLORS: Record<string, string> = {
anthropic: '#E8927A',
openai: '#74AA9C',
google: '#4285F4',
gemini: '#4285F4',
xai: '#8B949E',
meta: '#0866FF',
deepseek: '#4D6BFE',
mistral: '#FF7000',
qwen: '#A974FF',
cohere: '#FF7759',
openrouter: '#64748B',
};
export const LS_RECENT_MODELS = 'openswarm.picker.recentModels';
export const LS_RECENT_SEARCHES = 'openswarm.picker.recentSearches';
export const RECENT_MODELS_MAX = 3;
export const RECENT_SEARCHES_MAX = 4;
export const OR_AUTO_COLLAPSE_THRESHOLD = 12;
export const LS_FILTERS_EXPANDED = 'openswarm.picker.filtersExpanded';
export const LS_COLLAPSED_GROUPS = 'openswarm.picker.collapsedGroups';
export const CTX_STEPS = [0, 32_000, 128_000, 200_000, 500_000, 1_000_000];
export const CTX_LABELS = ['Any', '32K+', '128K+', '200K+', '500K+', '1M+'];
export const COST_STEPS = [Infinity, 50, 15, 5, 1, 0];
export const COST_LABELS = ['Any', '≤$50/M', '≤$15/M', '≤$5/M', '≤$1/M', 'Free only'];
export function readLS<T>(key: string, fallback: T): T {
try {
const raw = localStorage.getItem(key);
return raw ? (JSON.parse(raw) as T) : fallback;
} catch {
return fallback;
}
}
export function writeLS(key: string, value: unknown) {
try { localStorage.setItem(key, JSON.stringify(value)); } catch {}
}
// Heuristic tiering for pre-load FALLBACK_MODELS only; backend provides real tiers post-load.
export type Tier = 1 | 2 | 3 | 4 | 5;
const clampTier = (n: number): Tier => Math.max(1, Math.min(5, n)) as Tier;
function _costBucket(out: number): Tier {
if (out < 0.5) return 1;
if (out < 2) return 2;
if (out < 7) return 3;
if (out < 25) return 4;
return 5;
}
export function tierIntelligence(opt: any): Tier {
let tier: number = _costBucket(opt.output_cost_per_1m ?? 0);
if (opt.reasoning) tier += 1;
return clampTier(tier);
}
export function tierSpeed(opt: any): Tier {
let tier: number = 6 - _costBucket(opt.output_cost_per_1m ?? 0);
if (opt.reasoning) tier -= 1;
const lower = String(opt.label || '').toLowerCase();
if (/\b(mini|lite|flash|haiku|nano|small|fast|turbo|micro|tiny)\b/.test(lower)) tier += 1;
if (/\b(opus|ultra|max|xlarge|titan)\b/.test(lower)) tier -= 1;
return clampTier(tier);
}
export function tierCost(opt: any): Tier {
return _costBucket(opt.output_cost_per_1m ?? 0);
}
/** Extract version number from a model label; clamps to <30 to skip param counts like 70B/120B. */
function modelVersion(label: string): number {
const matches = String(label).matchAll(/(\d+(?:\.\d+)?)/g);
let bestVersion = 0;
for (const m of matches) {
const v = parseFloat(m[1]);
if (v >= 0.5 && v < 30 && v > bestVersion) bestVersion = v;
}
return bestVersion;
}
/** Strip versions and route suffixes so "Claude Sonnet 4.6" and 4.5 share one key. */
function modelFamilyKey(label: string): string {
return String(label)
.toLowerCase()
.replace(/\b\d+(?:\.\d+)?\b/g, '')
.replace(/\(api key\)/gi, '')
.replace(/\s+/g, ' ')
.trim();
}
/** Sort: intelligence desc, family asc, version desc, label asc. */
export function sortModelsForPicker<T extends { label: string }>(models: T[]): T[] {
const intelOf = (opt: any): number => {
if (Array.isArray(opt.tiers) && opt.tiers.length === 3) return opt.tiers[0];
return tierIntelligence(opt);
};
return [...models].sort((a: any, b: any) => {
const intelA = intelOf(a);
const intelB = intelOf(b);
if (intelA !== intelB) return intelB - intelA;
const famA = modelFamilyKey(a.label);
const famB = modelFamilyKey(b.label);
if (famA !== famB) return famA.localeCompare(famB);
const verA = modelVersion(a.label);
const verB = modelVersion(b.label);
if (verA !== verB) return verB - verA;
return a.label.localeCompare(b.label);
});
}
@@ -0,0 +1,124 @@
import React, { useCallback, useMemo } from 'react';
import Box from '@mui/material/Box';
import { ClaudeTokens } from '@/shared/styles/claudeTokens';
import { tierIntelligence, tierSpeed, tierCost } from './modelPicker';
export function useModelTooltip(c: ClaudeTokens) {
const buildModelTooltip = useCallback((opt: any): React.ReactNode => {
const [intel, speed, cost] = (Array.isArray(opt.tiers) && opt.tiers.length === 3)
? opt.tiers
: [tierIntelligence(opt), tierSpeed(opt), tierCost(opt)];
const billingKind: 'paid' | 'subscription' | 'free' = opt.billing_kind || (opt.is_free ? 'free' : 'paid');
const Bars = ({ filled, palette }: { filled: number; palette: string[] }) => {
const TOTAL_CELLS = 15;
const filledCells = Math.round((filled / 5) * TOTAL_CELLS);
return (
<Box sx={{ display: 'inline-flex', gap: '1px', alignItems: 'center' }}>
{Array.from({ length: TOTAL_CELLS }, (_, i) => {
const on = i < filledCells;
const colorIdx = on
? Math.min(palette.length - 1, Math.floor((i / Math.max(filledCells - 1, 1)) * (palette.length - 1)))
: 0;
return (
<Box
key={i}
sx={{
width: 5, height: 5,
bgcolor: on ? palette[colorIdx] : c.border.subtle,
opacity: on ? 1 : 0.3,
transformOrigin: 'center',
animation: on
? `pixelPop 0.22s cubic-bezier(0.34, 1.56, 0.64, 1) ${i * 0.018}s both`
: 'none',
'@keyframes pixelPop': {
'0%': { transform: 'scale(0)', opacity: 0 },
'60%': { transform: 'scale(1.2)', opacity: 1 },
'100%': { transform: 'scale(1)', opacity: 1 },
},
}}
/>
);
})}
</Box>
);
};
const INTEL_PALETTE = ['#6D5BBE', '#8870D5', '#A78BFA', '#BFA3FF', '#D5BFFF'];
const SPEED_PALETTE = ['#2DBFAA', '#42D6BF', '#5EEAD4', '#7FF1DF', '#A3F7E9'];
const COST_PALETTE = ['#C7752E', '#DD8A3D', '#F59E0B', '#FAB23C', '#FCC773'];
const capabilities = [
opt.reasoning && 'Reasoning',
'Tools',
billingKind === 'free' && 'Free tier',
billingKind === 'subscription' && 'Subscription',
(opt.context_window ?? 0) >= 1_000_000 && '1M+ context',
].filter(Boolean).join(' · ');
return (
<Box sx={{ fontSize: '0.74rem', lineHeight: 1.55, minWidth: 256 }}>
<Box sx={{
fontWeight: 600, fontSize: '0.85rem', mb: 0.85,
color: c.text.primary,
letterSpacing: '-0.01em',
pb: 0.6,
borderBottom: `1px solid ${c.border.subtle}`,
}}>
{opt.label}
</Box>
<Box sx={{
display: 'grid',
gridTemplateColumns: 'auto 1fr',
columnGap: 1.75, rowGap: 0.5,
alignItems: 'center',
color: c.text.muted,
}}>
<span>Intelligence</span><Bars filled={intel} palette={INTEL_PALETTE} />
<span>Speed</span><Bars filled={speed} palette={SPEED_PALETTE} />
{billingKind === 'subscription' ? null : (
<>
<span>Cost</span>
{billingKind === 'free'
? <Box component="span" sx={{ color: '#10b981', fontWeight: 600 }}>Free</Box>
: <Bars filled={cost} palette={COST_PALETTE} />}
</>
)}
<span>Context</span>
<span style={{ fontVariantNumeric: 'tabular-nums', color: c.text.secondary }}>
{(opt.context_window ?? 0).toLocaleString()}
</span>
{billingKind === 'paid' && (opt.input_cost_per_1m || opt.output_cost_per_1m) ? (
<>
<span>Pricing</span>
<span style={{ fontVariantNumeric: 'tabular-nums', color: c.text.secondary }}>
${opt.input_cost_per_1m?.toFixed(2)}/M in · ${opt.output_cost_per_1m?.toFixed(2)}/M out
</span>
</>
) : null}
{capabilities && (
<>
<span>Capabilities</span>
<span style={{ color: c.text.secondary }}>{capabilities}</span>
</>
)}
</Box>
</Box>
);
}, [c]);
const tooltipSlotProps = useMemo(() => ({
tooltip: {
sx: {
bgcolor: c.bg.elevated,
color: c.text.primary,
border: `1px solid ${c.border.subtle}`,
borderRadius: `${c.radius.md}px`,
boxShadow: '0 12px 32px rgba(0, 0, 0, 0.32)',
padding: '12px 14px',
maxWidth: 340,
fontSize: '0.78rem',
fontFamily: c.font.sans,
},
},
arrow: { sx: { color: c.bg.elevated, '&:before': { border: `1px solid ${c.border.subtle}` } } },
}), [c]);
return { buildModelTooltip, tooltipSlotProps };
}
@@ -0,0 +1,26 @@
import { API_BASE, getAuthToken } from '@/shared/config';
/** Handles /context, /compact, /clear; returns true if intercepted so the prompt isn't sent to the agent. */
export async function handleSlashCommand(cmd: string, sessionId: string): Promise<boolean> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
const tok = (() => { try { return getAuthToken(); } catch { return ''; } })();
if (tok) headers['Authorization'] = `Bearer ${tok}`;
if (cmd === '/context') {
window.dispatchEvent(new CustomEvent('openswarm:context-drawer', { detail: { sessionId, open: true } }));
return true;
}
// /compact and /clear mount at /api/agents/sessions/{id}/..., not under the agents SubApp.
if (cmd === '/compact') {
try {
await fetch(`${API_BASE}/agents/sessions/${sessionId}/compact`, { method: 'POST', headers });
} catch {}
return true;
}
if (cmd === '/clear') {
try {
await fetch(`${API_BASE}/agents/sessions/${sessionId}/clear`, { method: 'POST', headers });
} catch {}
return true;
}
return false;
}
@@ -0,0 +1,22 @@
import React from 'react';
import { ContextPath } from '@/app/components/DirectoryBrowser';
export interface AttachedImage {
data: string;
media_type: string;
preview: string;
// Set when preview uses createObjectURL; handleSend reads via FileReader to avoid retaining base64 in memory.
_file?: File;
}
export interface ForcedToolGroup {
label: string;
tools: string[];
icon?: React.ReactNode;
iconKey?: string;
}
export interface ChatInputHandle {
getConfig: () => { prompt: string; contextPaths: ContextPath[]; forcedTools: ForcedToolGroup[] };
setContent: (prompt: string, contextPaths?: ContextPath[], forcedTools?: ForcedToolGroup[]) => void;
}
@@ -0,0 +1,67 @@
import { useMemo } from 'react';
import { useAppSelector } from '@/shared/hooks';
import { sortModelsForPicker } from './modelPicker';
const FALLBACK_MODELS = [
{ value: 'sonnet', label: 'Claude Sonnet 4.6', context_window: 1_000_000, reasoning: true },
{ value: 'opus', label: 'Claude Opus 4.6', context_window: 1_000_000, reasoning: true },
{ value: 'haiku', label: 'Claude Haiku 4.5', context_window: 200_000, reasoning: true },
];
export function useChatInputModel(model: string) {
const modelsByProvider = useAppSelector((state) => state.models.byProvider);
const modelsLoaded = useAppSelector((state) => state.models.loaded);
const connectionMode = useAppSelector((state) => state.settings.data.connection_mode);
const allModelOptions = useMemo(() => {
if (!modelsLoaded || Object.keys(modelsByProvider).length === 0) {
const key = connectionMode === 'openswarm-pro' ? 'OpenSwarm Pro' : 'Anthropic';
return { flat: FALLBACK_MODELS.map(m => ({ ...m, provider: key })), grouped: { [key]: FALLBACK_MODELS } };
}
const flat: Array<any> = [];
const grouped: Record<string, any[]> = {};
for (const [prov, models] of Object.entries(modelsByProvider)) {
const enriched = models.map((m: any) => ({
value: m.value,
label: m.label,
context_window: m.context_window ?? 200_000,
reasoning: !!m.reasoning,
input_cost_per_1m: m.input_cost_per_1m ?? 0,
output_cost_per_1m: m.output_cost_per_1m ?? 0,
is_free: !!m.is_free,
max_completion_tokens: m.max_completion_tokens ?? null,
tiers: Array.isArray(m.tiers) && m.tiers.length === 3 ? m.tiers : undefined,
billing_kind: m.billing_kind,
}));
grouped[prov] = sortModelsForPicker(enriched);
for (const m of enriched) {
flat.push({ ...m, provider: prov });
}
}
return { flat, grouped };
}, [modelsByProvider, modelsLoaded, connectionMode]);
const currentModelCtx = useMemo(() => {
const m = allModelOptions.flat.find((x: any) => x.value === model) as any;
return (m?.context_window as number) || 200_000;
}, [allModelOptions.flat, model]);
const currentModelApi = useMemo<string>(() => {
const m = allModelOptions.flat.find((x: any) => x.value === model) as any;
return ((m?.api as string) || 'anthropic').toLowerCase();
}, [allModelOptions.flat, model]);
// Mirrors backend agent_manager._resolve_attachments support matrix.
// PDFs: Anthropic, Gemini, OpenRouter (file-parser plugin), and
// OpenAI direct on GPT-5.x non-Codex (anthropic_proxy bypasses
// 9router and POSTs to api.openai.com via anthropic_to_openai.py).
// Images: every provider via 9router image_url translation.
const isCodexModel = typeof model === 'string' && (model.toLowerCase().includes('codex') || model.toLowerCase().startsWith('cx/'));
const pdfSupported = (
['anthropic', 'gemini', 'gemini-cli', 'openrouter'].includes(currentModelApi) ||
(currentModelApi === 'openai' && !isCodexModel)
);
const imageSupported = ['anthropic', 'gemini', 'gemini-cli', 'openai', 'openrouter'].includes(currentModelApi);
return { allModelOptions, currentModelCtx, currentModelApi, pdfSupported, imageSupported };
}
@@ -0,0 +1,205 @@
import { useState, useRef, useCallback, useEffect, useMemo } from 'react';
import { API_BASE } from '@/shared/config';
import {
LS_RECENT_MODELS,
LS_RECENT_SEARCHES,
RECENT_MODELS_MAX,
RECENT_SEARCHES_MAX,
LS_FILTERS_EXPANDED,
LS_COLLAPSED_GROUPS,
CTX_STEPS,
COST_STEPS,
readLS,
writeLS,
} from './modelPicker';
type CapFilters = { reasoning: boolean; subscription: boolean; apiKey: boolean };
interface AllModelOptions {
flat: Array<any>;
grouped: Record<string, any[]>;
}
export function useModelPicker(
allModelOptions: AllModelOptions,
model: string,
modelAnchor: HTMLElement | null,
) {
const [modelSearch, setModelSearch] = useState('');
const modelSearchRef = useRef<HTMLInputElement | null>(null);
const [recentModels, setRecentModels] = useState<string[]>(
() => readLS<string[]>(LS_RECENT_MODELS, []).slice(0, RECENT_MODELS_MAX),
);
const [recentSearches, setRecentSearches] = useState<string[]>(() => readLS<string[]>(LS_RECENT_SEARCHES, []));
const pushRecentModel = useCallback((value: string) => {
setRecentModels((prev) => {
const next = [value, ...prev.filter((v) => v !== value)].slice(0, RECENT_MODELS_MAX);
writeLS(LS_RECENT_MODELS, next);
return next;
});
}, []);
const pushRecentSearch = useCallback((q: string) => {
const trimmed = q.trim();
if (!trimmed) return;
setRecentSearches((prev) => {
const next = [trimmed, ...prev.filter((s) => s !== trimmed)].slice(0, RECENT_SEARCHES_MAX);
writeLS(LS_RECENT_SEARCHES, next);
return next;
});
}, []);
const [capFilters, setCapFilters] = useState<CapFilters>({
reasoning: false, subscription: false, apiKey: false,
});
const [ctxIdx, setCtxIdx] = useState(0);
const [costIdx, setCostIdx] = useState(0);
const [filtersExpanded, setFiltersExpanded] = useState<boolean>(
() => readLS<boolean>(LS_FILTERS_EXPANDED, false),
);
const toggleFilters = useCallback(() => {
setFiltersExpanded((prev) => {
writeLS(LS_FILTERS_EXPANDED, !prev);
return !prev;
});
}, []);
const anyFilterActive = (
capFilters.reasoning || capFilters.subscription || capFilters.apiKey
|| ctxIdx > 0 || costIdx > 0
);
const [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>(
() => readLS<Record<string, boolean>>(LS_COLLAPSED_GROUPS, {}),
);
const toggleGroupCollapse = useCallback((prov: string, currentlyCollapsed: boolean) => {
setCollapsedGroups((prev) => {
const next = { ...prev, [prov]: !currentlyCollapsed };
writeLS(LS_COLLAPSED_GROUPS, next);
return next;
});
}, []);
// Keyed by model value so stale probe results don't display.
const [probeResult, setProbeResult] = useState<{ value: string; ok: boolean; error?: string; latency_ms?: number } | null>(null);
const filteredModelGroups = useMemo(() => {
const q = modelSearch.trim().toLowerCase();
const minCtx = CTX_STEPS[ctxIdx] || 0;
const maxCost = COST_STEPS[costIdx];
const anyCap = (
capFilters.reasoning || capFilters.subscription || capFilters.apiKey
|| ctxIdx > 0 || costIdx > 0
);
const filterFn = (m: any): boolean => {
if (capFilters.reasoning && !m.reasoning) return false;
if (capFilters.subscription || capFilters.apiKey) {
const okSub = capFilters.subscription && m.billing_kind === 'subscription';
const okApi = capFilters.apiKey && m.billing_kind === 'api_key';
if (!okSub && !okApi) return false;
}
if (minCtx > 0 && (m.context_window ?? 0) < minCtx) return false;
// maxCost=0 ("Free only") passes subscription (free to user); paid/api_key excluded regardless of price.
if (maxCost !== Infinity) {
if (maxCost === 0) {
if (m.billing_kind !== 'free' && m.billing_kind !== 'subscription') return false;
} else {
if (
(m.billing_kind === 'paid' || m.billing_kind === 'api_key')
&& (m.output_cost_per_1m ?? 0) > maxCost
) return false;
}
}
return true;
};
if (!q && !anyCap) return allModelOptions.grouped;
const out: Record<string, Array<any>> = {};
for (const [prov, models] of Object.entries(allModelOptions.grouped)) {
const provLower = prov.toLowerCase();
const qMatch = (m: any) =>
!q
|| m.label.toLowerCase().includes(q)
|| m.value.toLowerCase().includes(q)
|| provLower.includes(q);
const matches = (models as any[]).filter((m) => filterFn(m) && qMatch(m));
if (matches.length) out[prov] = matches;
}
return out;
}, [modelSearch, allModelOptions.grouped, capFilters, ctxIdx, costIdx]);
const pickerSummary = useMemo(() => {
let total = 0, free = 0, reasoning = 0, subscription = 0, apiKey = 0, paid = 0, longContext = 0;
for (const ms of Object.values(filteredModelGroups)) {
for (const m of ms as any[]) {
total += 1;
if (m.reasoning) reasoning += 1;
if ((m.context_window ?? 0) >= 1_000_000) longContext += 1;
if (m.billing_kind === 'free') free += 1;
else if (m.billing_kind === 'subscription') subscription += 1;
else if (m.billing_kind === 'api_key') apiKey += 1;
else if (m.billing_kind === 'paid') paid += 1;
}
}
return { total, free, reasoning, subscription, apiKey, paid, longContext };
}, [filteredModelGroups]);
const recentMaterialised = useMemo(() => {
const flatByValue = new Map(allModelOptions.flat.map((m) => [m.value, m]));
return recentModels
.map((v) => flatByValue.get(v))
.filter(Boolean) as typeof allModelOptions.flat;
}, [recentModels, allModelOptions.flat]);
const showRecents = (
!modelSearch.trim()
&& !capFilters.reasoning && !capFilters.subscription && !capFilters.apiKey
&& ctxIdx === 0 && costIdx === 0
&& recentMaterialised.length > 0
);
useEffect(() => {
if (modelAnchor) {
const t = setTimeout(() => modelSearchRef.current?.focus(), 30);
return () => clearTimeout(t);
}
setModelSearch('');
}, [modelAnchor]);
// Debounced 1-token probe surfaces 401/402/etc before send.
useEffect(() => {
if (!model) return;
let cancelled = false;
const t = setTimeout(async () => {
try {
const res = await fetch(`${API_BASE}/agents/probe-model`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model }),
});
if (cancelled) return;
const data = await res.json();
setProbeResult({ value: model, ok: !!data.ok, error: data.error, latency_ms: data.latency_ms });
} catch {}
}, 350);
return () => {
cancelled = true;
clearTimeout(t);
};
}, [model]);
return {
modelSearch, setModelSearch, modelSearchRef,
recentModels, recentSearches,
pushRecentModel, pushRecentSearch,
capFilters, setCapFilters,
ctxIdx, setCtxIdx, costIdx, setCostIdx,
filtersExpanded, toggleFilters, anyFilterActive,
collapsedGroups, toggleGroupCollapse,
probeResult,
filteredModelGroups, pickerSummary,
recentMaterialised, showRecents,
};
}
export type ModelPickerState = ReturnType<typeof useModelPicker>;