diff --git a/frontend/src/app/pages/AgentChat/ChatInput/draftStore.ts b/frontend/src/app/pages/AgentChat/ChatInput/draftStore.ts new file mode 100644 index 00000000..9186b787 --- /dev/null +++ b/frontend/src/app/pages/AgentChat/ChatInput/draftStore.ts @@ -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(); +// 200ms debounce coalesces fast typing; innerHTML reads do full DOM serialization. +const _draftDebounceTimers = new Map>(); +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 !== '
') _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, 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 + }, []); +} diff --git a/frontend/src/app/pages/AgentChat/ChatInput/helpers.ts b/frontend/src/app/pages/AgentChat/ChatInput/helpers.ts new file mode 100644 index 00000000..04bdc243 --- /dev/null +++ b/frontend/src/app/pages/AgentChat/ChatInput/helpers.ts @@ -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('/'); +} diff --git a/frontend/src/app/pages/AgentChat/ChatInput/modeConfig.tsx b/frontend/src/app/pages/AgentChat/ChatInput/modeConfig.tsx new file mode 100644 index 00000000..cd8650c0 --- /dev/null +++ b/frontend/src/app/pages/AgentChat/ChatInput/modeConfig.tsx @@ -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 = { + smart_toy: , + question_answer: , + map: , + category: , + tune: , +}; + +export const FALLBACK_MODE_BASE = { label: 'Agent', icon: ICON_MAP.smart_toy }; diff --git a/frontend/src/app/pages/AgentChat/ChatInput/modelPicker.ts b/frontend/src/app/pages/AgentChat/ChatInput/modelPicker.ts new file mode 100644 index 00000000..305c76e9 --- /dev/null +++ b/frontend/src/app/pages/AgentChat/ChatInput/modelPicker.ts @@ -0,0 +1,113 @@ +// Mirrors SubscriptionCard colors in Settings. +export const PROVIDER_COLORS: Record = { + 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(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(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); + }); +} diff --git a/frontend/src/app/pages/AgentChat/ChatInput/modelTooltip.tsx b/frontend/src/app/pages/AgentChat/ChatInput/modelTooltip.tsx new file mode 100644 index 00000000..7a93f111 --- /dev/null +++ b/frontend/src/app/pages/AgentChat/ChatInput/modelTooltip.tsx @@ -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 ( + + {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 ( + + ); + })} + + ); + }; + 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 ( + + + {opt.label} + + + Intelligence + Speed + {billingKind === 'subscription' ? null : ( + <> + Cost + {billingKind === 'free' + ? Free + : } + + )} + Context + + {(opt.context_window ?? 0).toLocaleString()} + + {billingKind === 'paid' && (opt.input_cost_per_1m || opt.output_cost_per_1m) ? ( + <> + Pricing + + ${opt.input_cost_per_1m?.toFixed(2)}/M in · ${opt.output_cost_per_1m?.toFixed(2)}/M out + + + ) : null} + {capabilities && ( + <> + Capabilities + {capabilities} + + )} + + + ); + }, [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 }; +} diff --git a/frontend/src/app/pages/AgentChat/ChatInput/slashCommands.ts b/frontend/src/app/pages/AgentChat/ChatInput/slashCommands.ts new file mode 100644 index 00000000..fb04efd9 --- /dev/null +++ b/frontend/src/app/pages/AgentChat/ChatInput/slashCommands.ts @@ -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 { + const headers: Record = { '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; +} diff --git a/frontend/src/app/pages/AgentChat/ChatInput/types.ts b/frontend/src/app/pages/AgentChat/ChatInput/types.ts new file mode 100644 index 00000000..615f8fee --- /dev/null +++ b/frontend/src/app/pages/AgentChat/ChatInput/types.ts @@ -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; +} diff --git a/frontend/src/app/pages/AgentChat/ChatInput/useChatInputModel.ts b/frontend/src/app/pages/AgentChat/ChatInput/useChatInputModel.ts new file mode 100644 index 00000000..71e1f6d9 --- /dev/null +++ b/frontend/src/app/pages/AgentChat/ChatInput/useChatInputModel.ts @@ -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 = []; + const grouped: Record = {}; + 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(() => { + 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 }; +} diff --git a/frontend/src/app/pages/AgentChat/ChatInput/useModelPicker.ts b/frontend/src/app/pages/AgentChat/ChatInput/useModelPicker.ts new file mode 100644 index 00000000..30313cae --- /dev/null +++ b/frontend/src/app/pages/AgentChat/ChatInput/useModelPicker.ts @@ -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; + grouped: Record; +} + +export function useModelPicker( + allModelOptions: AllModelOptions, + model: string, + modelAnchor: HTMLElement | null, +) { + const [modelSearch, setModelSearch] = useState(''); + const modelSearchRef = useRef(null); + + const [recentModels, setRecentModels] = useState( + () => readLS(LS_RECENT_MODELS, []).slice(0, RECENT_MODELS_MAX), + ); + const [recentSearches, setRecentSearches] = useState(() => readLS(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({ + reasoning: false, subscription: false, apiKey: false, + }); + + const [ctxIdx, setCtxIdx] = useState(0); + const [costIdx, setCostIdx] = useState(0); + + const [filtersExpanded, setFiltersExpanded] = useState( + () => readLS(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>( + () => readLS>(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> = {}; + 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;