diff --git a/frontend/src/app/pages/AgentChat/ChatInput.tsx b/frontend/src/app/pages/AgentChat/ChatInput.tsx index 4e5b5992..44cc062f 100644 --- a/frontend/src/app/pages/AgentChat/ChatInput.tsx +++ b/frontend/src/app/pages/AgentChat/ChatInput.tsx @@ -1,99 +1,24 @@ import React, { useState, useRef, useCallback, useEffect, useMemo, forwardRef, useImperativeHandle } from 'react'; -import Box from '@mui/material/Box'; -import Menu from '@mui/material/Menu'; -import MenuItem from '@mui/material/MenuItem'; -import ListItemIcon from '@mui/material/ListItemIcon'; -import ListItemText from '@mui/material/ListItemText'; -import Typography from '@mui/material/Typography'; -import IconButton from '@mui/material/IconButton'; -import Tooltip from '@mui/material/Tooltip'; -import Chip from '@mui/material/Chip'; -import Snackbar from '@mui/material/Snackbar'; -import Alert from '@mui/material/Alert'; -import InputBase from '@mui/material/InputBase'; -import SearchIcon from '@mui/icons-material/Search'; -import KeyboardArrowRightIcon from '@mui/icons-material/KeyboardArrowRight'; -import AccessTimeIcon from '@mui/icons-material/AccessTime'; -import Slider from '@mui/material/Slider'; -import Collapse from '@mui/material/Collapse'; -import MicNoneOutlinedIcon from '@mui/icons-material/MicNoneOutlined'; -import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; -import StopIcon from '@mui/icons-material/Stop'; -import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; -import PsychologyOutlinedIcon from '@mui/icons-material/PsychologyOutlined'; -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'; -import CloseIcon from '@mui/icons-material/Close'; -import FolderOpenIcon from '@mui/icons-material/FolderOpen'; -import InsertDriveFileOutlinedIcon from '@mui/icons-material/InsertDriveFileOutlined'; -import Modal from '@mui/material/Modal'; -import CircularProgress from '@mui/material/CircularProgress'; -import AttachFileIcon from '@mui/icons-material/AttachFile'; -import AdsClickIcon from '@mui/icons-material/AdsClick'; -import CommandPicker, { CommandPickerItem, getToolGroupIcon } from '@/app/components/CommandPicker'; -import { useElementSelection, SelectedElement } from '@/app/components/ElementSelectionContext'; +import { useElementSelection } from '@/app/components/ElementSelectionContext'; import { onboardingBus } from '@/app/components/Onboarding/eventBus'; -import { getClipboardCards, clearClipboard } from '@/shared/dashboardClipboard'; -import { getWebview } from '@/shared/browserRegistry'; -import { API_BASE, getAuthToken } from '@/shared/config'; - -/** Handles /context, /compact, /clear; returns true if intercepted so the prompt isn't sent to the agent. */ -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; -} import { ContextPath } from '@/app/components/DirectoryBrowser'; -import { - SKILL_PILL_ATTR, - AttachedSkill, - createSkillPillElement, - serializeEditorContent, - detectEditorTrigger, - TriggerState, - EMPTY_TRIGGER, -} from '@/app/components/richEditorUtils'; +import { serializeEditorContent, AttachedSkill } from '@/app/components/richEditorUtils'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; import { fetchModes } from '@/shared/state/modesSlice'; -import { clearSessionMessages } from '@/shared/state/agentsSlice'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import { useChatInputModel } from './ChatInput/useChatInputModel'; +import { useDraftLoad, deleteDraft, loadDraft } from './ChatInput/draftStore'; +import { handleSlashCommand } from './ChatInput/slashCommands'; +import { materializeImages, appendSelectedElements, computeSendBlock } from './ChatInput/sendHelpers'; +import { useImageAttachments } from './ChatInput/useImageAttachments'; +import { useContextFiles } from './ChatInput/useContextFiles'; +import { useModelPicker } from './ChatInput/useModelPicker'; +import { useEditorHandlers } from './ChatInput/useEditorHandlers'; +import { ChatInputView } from './ChatInput/ChatInputView'; +import { ICON_MAP, FALLBACK_MODE_BASE } from './ChatInput/modeConfig'; +import { AttachedImage, ForcedToolGroup, ChatInputHandle } from './ChatInput/types'; -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 type { AttachedImage, ForcedToolGroup, ChatInputHandle }; export type { AttachedSkill } from '@/app/components/richEditorUtils'; interface Props { @@ -117,197 +42,6 @@ interface Props { onThinkingLevelChange?: (level: 'off' | 'low' | 'medium' | 'high' | 'auto') => void; } -export interface ChatInputHandle { - getConfig: () => { prompt: string; contextPaths: ContextPath[]; forcedTools: ForcedToolGroup[] }; - setContent: (prompt: string, contextPaths?: ContextPath[], forcedTools?: ForcedToolGroup[]) => void; -} - -// 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; -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)); -} - -const ICON_MAP: Record = { - smart_toy: , - question_answer: , - map: , - category: , - tune: , -}; - -const FALLBACK_MODE_BASE = { label: 'Agent', icon: ICON_MAP.smart_toy }; - -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 }, -]; - -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 ''. -function basename(p: string): string { - if (!p) return ''; - const parts = p.split(/[\\/]/).filter(Boolean); - return parts[parts.length - 1] || p; -} -function pathTail(p: string, n: number): string { - if (!p) return ''; - const parts = p.split(/[\\/]/).filter(Boolean); - return parts.slice(-n).join('/'); -} - -const ContextRing: React.FC<{ used: number; limit: number; accentColor: string; trackColor: string }> = ({ used, limit, accentColor, trackColor }) => { - if (used === 0) return null; - const pct = Math.min((used / limit) * 100, 100); - const size = 20; - const strokeWidth = 2; - const radius = (size - strokeWidth) / 2; - const circumference = 2 * Math.PI * radius; - const dashOffset = circumference * (1 - pct / 100); - const tooltip = `${pct.toFixed(1)}% \u00B7 ${formatTokenCount(used)} / ${formatTokenCount(limit)} context used`; - - return ( - - - - - - - - - ); -}; - -// Mirrors SubscriptionCard colors in Settings. -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', -}; - -const LS_RECENT_MODELS = 'openswarm.picker.recentModels'; -const LS_RECENT_SEARCHES = 'openswarm.picker.recentSearches'; -const RECENT_MODELS_MAX = 3; -const RECENT_SEARCHES_MAX = 4; -const OR_AUTO_COLLAPSE_THRESHOLD = 12; - -function readLS(key: string, fallback: T): T { - try { - const raw = localStorage.getItem(key); - return raw ? (JSON.parse(raw) as T) : fallback; - } catch { - return fallback; - } -} - -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. -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; -} - -function tierIntelligence(opt: any): Tier { - let tier: number = _costBucket(opt.output_cost_per_1m ?? 0); - if (opt.reasoning) tier += 1; - return clampTier(tier); -} - -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); -} - -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. */ -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); - }); -} - const ChatInput = forwardRef(({ onSend, disabled, mode, onModeChange, model, onModelChange, provider, onProviderChange, isRunning, onStop, autoRunMode, contextEstimate, embedded, autoFocus, sessionId, queueLength = 0, thinkingLevel = 'auto', onThinkingLevelChange }, ref) => { const c = useClaudeTokens(); const editorRef = useRef(null); @@ -323,357 +57,48 @@ const ChatInput = forwardRef(({ onSend, disabled, mode, if (autoFocus) editorRef.current?.focus(); }, [autoFocus]); - 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 - }, []); + useDraftLoad(editorRef, ownerId); - const [hasContent, setHasContent] = useState(() => !!_draftStore.get(ownerId)); + const [hasContent, setHasContent] = useState(() => !!loadDraft(ownerId)); const [attachedSkills, setAttachedSkills] = useState>({}); const attachedSkillsRef = useRef(attachedSkills); attachedSkillsRef.current = attachedSkills; - const [picker, setPicker] = useState(EMPTY_TRIGGER); const skills = useAppSelector((state) => state.skills.items); const modesMap = useAppSelector((state) => state.modes.items); const modesArr = useMemo(() => Object.values(modesMap), [modesMap]); - const modelsByProvider = useAppSelector((state) => state.models.byProvider); - const modelsLoaded = useAppSelector((state) => state.models.loaded); - const connectionMode = useAppSelector((state) => state.settings.data.connection_mode); - const toolItems = useAppSelector((state) => state.tools.items); const sessionFrameworkOverhead = useAppSelector((state) => sessionId ? (state.agents.sessions[sessionId]?.framework_overhead_tokens ?? 0) : 0, ); - - 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 { allModelOptions, currentModelCtx, pdfSupported, imageSupported } = useChatInputModel(model); useEffect(() => { if (modesArr.length === 0) dispatch(fetchModes()); }, [dispatch, modesArr.length]); - const [modelSearch, setModelSearch] = useState(''); - const modelSearchRef = useRef(null); + const [modeAnchor, setModeAnchor] = useState(null); + const [modelAnchor, setModelAnchor] = useState(null); + const [thinkingAnchor, setThinkingAnchor] = useState(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 picker = useModelPicker(allModelOptions, model, modelAnchor); + const { images, setImages, addImageFiles, removeImage, lightboxSrc, setLightboxSrc } = useImageAttachments(); - type CapFilters = { reasoning: boolean; subscription: boolean; apiKey: boolean }; - const [capFilters, setCapFilters] = useState({ - reasoning: false, subscription: false, apiKey: false, - }); - - const CTX_STEPS = [0, 32_000, 128_000, 200_000, 500_000, 1_000_000]; - const CTX_LABELS = ['Any', '32K+', '128K+', '200K+', '500K+', '1M+']; - const COST_STEPS = [Infinity, 50, 15, 5, 1, 0]; - const COST_LABELS = ['Any', '≤$50/M', '≤$15/M', '≤$5/M', '≤$1/M', 'Free only']; - const [ctxIdx, setCtxIdx] = useState(0); - const [costIdx, setCostIdx] = useState(0); - - const LS_FILTERS_EXPANDED = 'openswarm.picker.filtersExpanded'; - 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 LS_COLLAPSED_GROUPS = 'openswarm.picker.collapsedGroups'; - 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 - ); - - 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]); - - const [images, setImages] = useState([]); - // Ref so unmount cleanup revokes the latest blob: preview URLs. - const imagesRef = useRef(images); - imagesRef.current = images; - useEffect(() => () => { - for (const img of imagesRef.current) { - if (img.preview?.startsWith('blob:')) { - try { URL.revokeObjectURL(img.preview); } catch {} - } - } - }, []); - const [lightboxSrc, setLightboxSrc] = useState(null); - const [isDragOver, setIsDragOver] = useState(false); - const [isUploading, setIsUploading] = useState(false); - const [contextPaths, setContextPaths] = useState([]); - const [forcedTools, setForcedTools] = useState([]); - const [copiedPathIdx, setCopiedPathIdx] = useState(null); - const [oversizeQueue, setOversizeQueue] = useState>([]); - const [summarizingPath, setSummarizingPath] = useState(null); - const [summarizeError, setSummarizeError] = useState(null); - const [sendBlock, setSendBlock] = useState(null); + const { + isUploading, + contextPaths, setContextPaths, + forcedTools, setForcedTools, + copiedPathIdx, setCopiedPathIdx, + oversizeQueue, + summarizingPath, + summarizeError, setSummarizeError, + sendBlock, setSendBlock, + uploadAndAttachFiles, + detachOversize, + summarizeOversize, + pendingPayloadEstimate, + pendingKinds, + } = useContextFiles(currentModelCtx, model, contextEstimate, sessionFrameworkOverhead); useImperativeHandle(ref, () => ({ getConfig: () => { @@ -692,168 +117,6 @@ const ChatInput = forwardRef(({ onSend, disabled, mode, }, }), [contextPaths, forcedTools]); - const [modeAnchor, setModeAnchor] = useState(null); - const [modelAnchor, setModelAnchor] = useState(null); - const [thinkingAnchor, setThinkingAnchor] = useState(null); - - 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]); - - const currentMode = modesMap[mode]; - const FALLBACK_MODE = { ...FALLBACK_MODE_BASE, color: c.accent.primary }; - const modeConf = currentMode - ? { label: currentMode.name, icon: ICON_MAP[currentMode.icon] || ICON_MAP.smart_toy, color: currentMode.color } - : FALLBACK_MODE; - - const updateHasContent = useCallback(() => { - const editor = editorRef.current; - if (!editor) return; - const text = (editor.textContent || '').replace(/\u200B/g, ''); - const hasPills = editor.querySelector(`[${SKILL_PILL_ATTR}]`) !== null; - setHasContent(text.trim().length > 0 || hasPills); - }, []); - - const syncAttachedSkills = useCallback(() => { - const editor = editorRef.current; - if (!editor) return; - const pillIds = new Set( - Array.from(editor.querySelectorAll(`[${SKILL_PILL_ATTR}]`)) - .map((el) => el.getAttribute(SKILL_PILL_ATTR)) - .filter(Boolean) as string[], - ); - setAttachedSkills((prev) => { - const prevKeys = Object.keys(prev); - if (prevKeys.length === pillIds.size && prevKeys.every((k) => pillIds.has(k))) return prev; - const next: Record = {}; - for (const [id, skill] of Object.entries(prev)) { - if (pillIds.has(id)) next[id] = skill; - } - return next; - }); - }, []); - - const removeSkillPill = useCallback((skillId: string) => { - const editor = editorRef.current; - if (!editor) return; - const pill = editor.querySelector(`[${SKILL_PILL_ATTR}="${skillId}"]`); - if (pill) pill.remove(); - setAttachedSkills((prev) => { - const { [skillId]: _, ...rest } = prev; - return rest; - }); - const text = (editor.textContent || '').replace(/\u200B/g, ''); - const hasPills = editor.querySelector(`[${SKILL_PILL_ATTR}]`) !== null; - setHasContent(text.trim().length > 0 || hasPills); - editor.focus(); - }, []); - - const addImageFiles = useCallback((files: FileList | File[]) => { - // Preview via blob: URL; base64 only materializes at send (saves ~2.7MB JS heap per attachment). - Array.from(files).forEach((file) => { - if (!file.type.startsWith('image/')) return; - const previewUrl = URL.createObjectURL(file); - setImages((prev) => [ - ...prev, - { data: '', media_type: file.type, preview: previewUrl, _file: file } as AttachedImage, - ]); - }); - }, []); - - 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 route natively on Anthropic + Gemini (via anthropic-proxy - // document→image rewrite); refused on OpenAI/OpenRouter/custom until - // we land file-parser plugin / type:file translation. - // 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); - - const pendingPayloadEstimate = useMemo(() => { - const history = Math.max(0, contextEstimate?.used ?? 0); - const filesSum = contextPaths.reduce((acc, cp) => acc + (cp.tokens || 0), 0); - return history + (sessionFrameworkOverhead || 0) + filesSum; - }, [contextEstimate, contextPaths, sessionFrameworkOverhead]); - - const pendingKinds = useMemo(() => { - const set = new Set(); - for (const cp of contextPaths) { - if (cp.kind) set.add(cp.kind); - } - return set; - }, [contextPaths]); - - const uploadAndAttachFiles = useCallback(async (files: File[]) => { - if (files.length === 0) return; - setIsUploading(true); - try { - const formData = new FormData(); - files.forEach((f) => formData.append('files', f)); - const resp = await fetch(`${API_BASE}/settings/upload-files`, { - method: 'POST', - body: formData, - }); - if (!resp.ok) throw new Error('Upload failed'); - const data = await resp.json(); - const halfCap = Math.floor(currentModelCtx * 0.5); - const oversize: Array<{ path: string; name: string; tokens: number }> = []; - const newPaths: ContextPath[] = (data.files || []).map((f: { path: string; name?: string; tokens?: number; kind?: 'text' | 'pdf' | 'image' | 'binary'; media_type?: string }) => { - const t = typeof f.tokens === 'number' ? f.tokens : 0; - if (t > halfCap) oversize.push({ path: f.path, name: f.name || basename(f.path) || 'file', tokens: t }); - return { path: f.path, type: 'file' as const, tokens: t, kind: f.kind, media_type: f.media_type }; - }); - setContextPaths((prev) => [...prev, ...newPaths]); - if (oversize.length > 0) setOversizeQueue((q) => [...q, ...oversize]); - } catch (err) { - console.error('File upload failed:', err); - } finally { - setIsUploading(false); - } - }, [currentModelCtx]); - const handleSend = useCallback(async () => { const editor = editorRef.current; if (!editor || disabled) return; @@ -863,35 +126,12 @@ const ChatInput = forwardRef(({ onSend, disabled, mode, let trimmed = serialized.trim(); if (!trimmed) return; - // Pre-send dry-run guard. Sums every known component of next-turn input - // (history estimate from props, system prompt, framework/MCP overhead - // last reported by the API, attached file token estimates, and the - // prompt itself). If the sum exceeds 95% of the model's window, block - // the send and surface a banner with concrete recovery actions instead - // of round-tripping to a doomed API call. Conservative on purpose: - // tokenizers differ across providers (char/4 is rough), so we leave - // 5% headroom plus the API's own response budget. - { - const win = currentModelCtx; - const history = Math.max(0, contextEstimate?.used ?? 0); - const filesSum = contextPaths.reduce((acc, cp) => acc + (cp.tokens || 0), 0); - const promptTokens = Math.ceil(trimmed.length / 4); - const framework = sessionFrameworkOverhead || 0; - const systemTokens = 0; - const estimate = history + framework + filesSum + promptTokens + systemTokens; - if (win > 0 && estimate > Math.floor(win * 0.95)) { - let largest: { path: string; tokens: number } | undefined; - for (const cp of contextPaths) { - if ((cp.tokens || 0) > (largest?.tokens || 0)) largest = { path: cp.path, tokens: cp.tokens || 0 }; - } - setSendBlock({ - estimate, window: win, - history, system: systemTokens, framework, files: filesSum, prompt: promptTokens, - largestFile: largest, - }); - return; - } - } + const block = computeSendBlock({ + trimmed, currentModelCtx, + historyUsed: contextEstimate?.used ?? 0, + contextPaths, sessionFrameworkOverhead, + }); + if (block) { setSendBlock(block); return; } onboardingBus.emit('chat:message_sent'); if (window.location.hash.includes('/apps/')) { @@ -903,86 +143,15 @@ const ChatInput = forwardRef(({ onSend, disabled, mode, const handled = await handleSlashCommand(cmd, sessionId); if (handled) { editor.innerHTML = ''; - _draftStore.delete(ownerId); + deleteDraft(ownerId); setHasContent(false); return; } } const selectedEls = elementSelection?.elementsByOwner?.[ownerId] ?? []; - let allImages: Array<{ data: string; media_type: string }> = []; - if (images.length > 0) { - allImages = await Promise.all(images.map(async (img) => { - if (img.data) return { data: img.data, media_type: img.media_type }; - if (img._file) { - const base64 = await new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => { - const r = reader.result as string; - resolve(r.split(',')[1] ?? ''); - }; - reader.onerror = () => reject(reader.error || new Error('FileReader failed')); - reader.readAsDataURL(img._file!); - }); - return { data: base64, media_type: img.media_type }; - } - return { data: '', media_type: img.media_type }; - })); - allImages = allImages.filter((i) => i.data); - } - - if (selectedEls.length > 0) { - const lines: string[] = ['\n\n---\nSelected UI Elements:\n']; - for (let i = 0; i < selectedEls.length; i++) { - const el = selectedEls[i]; - - if (el.semanticType === 'browser-card' && el.semanticData?.selectId) { - const wv = getWebview(el.semanticData.selectId as string); - const url = wv ? (el.semanticData.url || wv.getURL()) : (el.semanticData.url || ''); - const title = wv ? (el.semanticData.name || wv.getTitle()) : (el.semanticLabel || ''); - lines.push(`${i + 1}. [Browser Card] ${title}`); - lines.push(` browser_id: ${el.semanticData.selectId}`); - if (url) lines.push(` URL: ${url}`); - lines.push(` (Use BrowserAgent with this browser_id to interact with it, or CreateBrowserAgent for a new browser)`); - } else if (el.semanticType && el.semanticData) { - const typeLabel = { - 'agent-card': 'Agent Card', - 'message': 'Message', - 'tool-call': 'Tool Call', - 'tool-group': 'Tool Group', - 'view-card': 'App Card', - 'browser-card': 'Browser Card', - 'dom-element': 'Element', - }[el.semanticType] || el.semanticType; - lines.push(`${i + 1}. [${typeLabel}] ${el.semanticLabel || ''}`); - const { selectId, ...rest } = el.semanticData; - if (selectId) lines.push(` ID: ${selectId}`); - const metaStr = Object.entries(rest) - .filter(([, v]) => v != null) - .map(([k, v]) => `${k}: ${typeof v === 'string' ? v : JSON.stringify(v)}`) - .join(', '); - if (metaStr) lines.push(` ${metaStr}`); - if (el.semanticType === 'agent-card' && selectId) { - lines.push(` (Use InvokeAgent with session_id "${selectId}" to query this agent with full conversation context)`); - } - } else { - const styleStr = Object.entries(el.computedStyles) - .map(([k, v]) => `${k}: ${v}`) - .join('; '); - lines.push(`${i + 1}. \`${el.selectorPath}\` (${el.tagName.toLowerCase()})`); - lines.push(` Selector: ${el.selectorPath}`); - lines.push(` HTML: ${el.outerHTML.length > 500 ? el.outerHTML.slice(0, 500) + '...' : el.outerHTML}`); - if (styleStr) lines.push(` Key styles: ${styleStr}`); - } - lines.push(''); - - if (el.screenshot) { - const base64 = el.screenshot.replace(/^data:image\/\w+;base64,/, ''); - allImages.push({ data: base64, media_type: 'image/png' }); - } - } - trimmed += lines.join('\n'); - } + const allImages = await materializeImages(images); + trimmed = appendSelectedElements(trimmed, selectedEls, allImages); const sendImages = allImages.length > 0 ? allImages : undefined; const allForcedToolNames = forcedTools.flatMap((ft) => ft.tools); @@ -1002,7 +171,7 @@ const ChatInput = forwardRef(({ onSend, disabled, mode, browserIds.length > 0 ? browserIds : undefined, ); editor.innerHTML = ''; - _draftStore.delete(ownerId); + deleteDraft(ownerId); for (const img of images) { if (img.preview?.startsWith('blob:')) { try { URL.revokeObjectURL(img.preview); } catch {} @@ -1016,1698 +185,102 @@ const ChatInput = forwardRef(({ onSend, disabled, mode, elementSelection?.clearOwnerElements(ownerId); }, [disabled, images, contextPaths, forcedTools, onSend, elementSelection, ownerId]); - const detectTrigger = useCallback(() => { - const result = detectEditorTrigger(); - if (result) { - setPicker(result); - } else { - // Bailout when already hidden; otherwise spreading a new object re-renders all of ChatInput on every keystroke (~199ms input delay). - setPicker((p) => p.visible ? { ...p, visible: false } : p); - } - }, []); + const { + picker: editorPicker, setPicker, + isDragOver, + handleInput, handleEditorClick, handlePickerSelect, handleKeyDown, handlePaste, + handleDragOver, handleDragLeave, handleDrop, + } = useEditorHandlers({ + editorRef, generalFileInputRef, ownerId, sessionId, autoRunMode, c, skills, + elementSelection, setHasContent, setAttachedSkills, setForcedTools, onModeChange, + addImageFiles, uploadAndAttachFiles, handleSend, + }); - // Set by handlePaste before the synthetic input fires so handleInput skips post-input scans paste can't invalidate. - const justPastedRef = useRef(false); - - const handleInput = useCallback(() => { - if (justPastedRef.current) { - justPastedRef.current = false; - setHasContent(true); - scheduleDraftSave(ownerId, () => editorRef.current?.innerHTML ?? ''); - return; - } - updateHasContent(); - detectTrigger(); - syncAttachedSkills(); - scheduleDraftSave(ownerId, () => editorRef.current?.innerHTML ?? ''); - }, [updateHasContent, detectTrigger, syncAttachedSkills, ownerId]); - - const handleEditorClick = useCallback(() => { - detectTrigger(); - }, [detectTrigger]); - - const handlePickerSelect = (item: CommandPickerItem) => { - setPicker((p) => ({ ...p, visible: false })); - const editor = editorRef.current; - if (!editor) return; - - editor.focus(); - - const { triggerNode, triggerOffset, filter } = picker; - if (triggerNode && triggerNode.parentNode && editor.contains(triggerNode)) { - const endOffset = Math.min(triggerOffset + 1 + filter.length, triggerNode.length); - const range = document.createRange(); - range.setStart(triggerNode, triggerOffset); - range.setEnd(triggerNode, endOffset); - range.deleteContents(); - const sel = window.getSelection(); - if (sel) { sel.removeAllRanges(); sel.addRange(range); } - } - - if (item.type === 'skill') { - const skill = skills[item.id]; - if (!skill) return; - if (editor.querySelector(`[${SKILL_PILL_ATTR}="${skill.id}"]`)) return; - - const pill = createSkillPillElement( - { id: skill.id, name: skill.name, content: skill.content }, - removeSkillPill, - c.font.mono, - c.status.error, - ); - - const sel = window.getSelection(); - if (sel && sel.rangeCount > 0) { - const range = sel.getRangeAt(0); - range.collapse(false); - range.insertNode(pill); - const spacer = document.createTextNode('\u200B'); - pill.after(spacer); - const newRange = document.createRange(); - newRange.setStartAfter(spacer); - newRange.collapse(true); - sel.removeAllRanges(); - sel.addRange(newRange); - } - - setAttachedSkills((prev) => ({ - ...prev, - [skill.id]: { id: skill.id, name: skill.name, content: skill.content }, - })); - } else if (item.type === 'mode') { - onModeChange(item.id); - } else if (item.type === 'context') { - if (item.command === 'file') { - generalFileInputRef.current?.click(); - } else if (item.toolNames && item.toolNames.length > 0) { - setForcedTools((prev) => [...prev, { label: item.name, tools: item.toolNames!, icon: item.icon, iconKey: item.iconKey }]); - } else { - document.execCommand('insertText', false, `@${item.command} `); - } - } - - updateHasContent(); - setTimeout(() => editor.focus(), 0); - }; - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (picker.visible && ['ArrowDown', 'ArrowUp', 'Escape', 'Tab', 'Enter'].includes(e.key)) { - e.preventDefault(); - return; - } - if ((e.ctrlKey || e.metaKey) && ['b', 'i', 'u'].includes(e.key.toLowerCase())) { - e.preventDefault(); - return; - } - if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'l' && !e.shiftKey && !e.altKey) { - e.preventDefault(); - if (sessionId) { - handleSlashCommand('/clear', sessionId).catch(() => {}); - dispatch(clearSessionMessages(sessionId)); - } - const editor = editorRef.current; - if (editor) { - editor.innerHTML = ''; - updateHasContent(); - } - return; - } - if (e.key === 'Enter' && !e.shiftKey && !autoRunMode) { - e.preventDefault(); - handleSend(); - } - }; - - const handlePaste = useCallback((e: React.ClipboardEvent) => { - const copied = getClipboardCards(); - if (copied.length > 0 && elementSelection) { - e.preventDefault(); - for (const card of copied) { - const semanticTypeMap: Record = { - agent: 'agent-card', - view: 'view-card', - browser: 'browser-card', - }; - const semanticType = semanticTypeMap[card.type]; - if (!semanticType) continue; - const labelMap: Record = { - 'agent-card': 'Agent', - 'view-card': 'View', - 'browser-card': 'Browser', - }; - const semanticLabel = (labelMap[semanticType] || semanticType) + ': ' + card.name; - const el: SelectedElement = { - id: `sel-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, - selectorPath: `[data-select-type="${semanticType}"][data-select-id="${card.id}"]`, - tagName: 'DIV', - className: '', - outerHTML: '', - computedStyles: {}, - boundingRect: { x: 0, y: 0, width: 0, height: 0 }, - semanticType, - semanticLabel, - semanticData: { ...card.meta, selectId: card.id }, - }; - elementSelection.addElementForOwner(ownerId, el); - } - clearClipboard(); - return; - } - - const items = e.clipboardData?.items; - if (!items) return; - const imageFiles: File[] = []; - for (let i = 0; i < items.length; i++) { - if (items[i].type.startsWith('image/')) { - const file = items[i].getAsFile(); - if (file) imageFiles.push(file); - } - } - if (imageFiles.length > 0) { - e.preventDefault(); - addImageFiles(imageFiles); - return; - } - e.preventDefault(); - const plain = e.clipboardData.getData('text/plain'); - if (plain) { - justPastedRef.current = true; - document.execCommand('insertText', false, plain); - } - }, [addImageFiles, elementSelection, ownerId]); - - const handleDragOver = useCallback((e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - if (e.dataTransfer.types.includes('Files')) { - setIsDragOver(true); - } - }, []); - - const handleDragLeave = useCallback((e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - setIsDragOver(false); - }, []); - - const handleDrop = useCallback((e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - setIsDragOver(false); - if (e.dataTransfer.files.length === 0) return; - const allFiles = Array.from(e.dataTransfer.files); - const imageFiles = allFiles.filter((f) => f.type.startsWith('image/')); - const otherFiles = allFiles.filter((f) => !f.type.startsWith('image/')); - if (imageFiles.length > 0) addImageFiles(imageFiles); - if (otherFiles.length > 0) uploadAndAttachFiles(otherFiles); - }, [addImageFiles, uploadAndAttachFiles]); - - useEffect(() => { - const halfCap = Math.floor(currentModelCtx * 0.5); - const stillOversize: Array<{ path: string; name: string; tokens: number }> = []; - for (const cp of contextPaths) { - const t = cp.tokens || 0; - if (t > halfCap) { - const name = basename(cp.path) || cp.path; - stillOversize.push({ path: cp.path, name, tokens: t }); - } - } - setOversizeQueue((q) => { - const next = stillOversize.filter((o) => !q.find((qq) => qq.path === o.path)); - return [...q.filter((qq) => stillOversize.find((o) => o.path === qq.path)), ...next]; - }); - }, [currentModelCtx, contextPaths]); - - const detachOversize = useCallback((path: string) => { - setContextPaths((prev) => prev.filter((cp) => cp.path !== path)); - setOversizeQueue((q) => q.filter((o) => o.path !== path)); - }, []); - - const summarizeOversize = useCallback(async (path: string) => { - if (summarizingPath) return; // another summarize is in flight; ignore - setSummarizingPath(path); - try { - const tok = (() => { try { return getAuthToken(); } catch { return ''; } })(); - const headers: Record = { 'Content-Type': 'application/json' }; - if (tok) headers['Authorization'] = `Bearer ${tok}`; - const target = Math.min(8_000, Math.max(1_000, Math.floor(currentModelCtx * 0.05))); - const resp = await fetch(`${API_BASE}/settings/summarize-file`, { - method: 'POST', headers, - body: JSON.stringify({ path, target_tokens: target, primary_model: model }), - }); - if (!resp.ok) { - let detail = `summarize failed (${resp.status})`; - try { const j = await resp.json(); if (j?.detail) detail = String(j.detail); } catch {} - throw new Error(detail); - } - const data = await resp.json(); - const newPath: string = data.path; - const newTokens: number = data.tokens || 0; - setContextPaths((prev) => prev.map((cp) => cp.path === path ? { ...cp, path: newPath, tokens: newTokens, kind: 'text', media_type: 'text/plain' } : cp)); - setOversizeQueue((q) => q.filter((o) => o.path !== path)); - } catch (err) { - const msg = err instanceof Error ? err.message : 'summarize failed'; - setSummarizeError(`${msg}. Detach the file or connect an aux provider in Settings.`); - } finally { - setSummarizingPath(null); - } - }, [currentModelCtx, model, summarizingPath]); - - const removeImage = useCallback((idx: number) => { - setImages((prev) => { - const removed = prev[idx]; - if (removed?.preview?.startsWith('blob:')) { - try { URL.revokeObjectURL(removed.preview); } catch {} - } - return prev.filter((_, i) => i !== idx); - }); - }, []); - - const menuPaperProps = { - sx: { - bgcolor: c.bg.surface, - border: `1px solid ${c.border.subtle}`, - borderRadius: '10px', - minWidth: 180, - maxWidth: 380, - maxHeight: 400, - boxShadow: c.shadow.lg, - '& .MuiMenuItem-root': { - fontSize: '0.8rem', - color: c.text.secondary, - py: 0.75, - px: 1.5, - '&:hover': { bgcolor: c.bg.secondary }, - }, - }, - }; + const currentMode = modesMap[mode]; + const FALLBACK_MODE = { ...FALLBACK_MODE_BASE, color: c.accent.primary }; + const modeConf = currentMode + ? { label: currentMode.name, icon: ICON_MAP[currentMode.icon] || ICON_MAP.smart_toy, color: currentMode.color } + : FALLBACK_MODE; const selectedElements = elementSelection?.elementsByOwner?.[ownerId] ?? []; const hasAttachments = images.length > 0 || contextPaths.length > 0 || forcedTools.length > 0 || selectedElements.length > 0; return ( - - {isDragOver && ( - - - - Drop files here - - - )} - - {isUploading && ( - - - - Attaching files… - - - )} - - setPicker((p) => ({ ...p, visible: false }))} - visible={picker.visible} - /> - - {sendBlock && (() => { - const fmt = (n: number) => n >= 1000 ? `${(n / 1000).toFixed(1)}K` : String(n); - const over = sendBlock.estimate - sendBlock.window; - return ( - - - This send would overflow the model's context window - - - ~{fmt(sendBlock.estimate)} of {fmt(sendBlock.window)} tokens ({over > 0 ? `${fmt(over)} over` : 'at cap'}). History {fmt(sendBlock.history)} · Files {fmt(sendBlock.files)} · Tools/MCPs {fmt(sendBlock.framework)} · This message {fmt(sendBlock.prompt)}. - - - {sessionId && ( - { - try { - const tok = (() => { try { return getAuthToken(); } catch { return ''; } })(); - const headers: Record = { 'Content-Type': 'application/json' }; - if (tok) headers['Authorization'] = `Bearer ${tok}`; - await fetch(`${API_BASE}/agents/sessions/${sessionId}/compact`, { method: 'POST', headers }); - setSendBlock(null); - } catch (err) { console.error(err); } - }} - sx={{ - background: c.accent.primary, color: '#fff', border: 'none', borderRadius: '6px', - px: 1, py: 0.5, fontSize: '0.72rem', cursor: 'pointer', '&:hover': { opacity: 0.9 }, - }} - > - Compact memory - - )} - {sendBlock.largestFile && ( - { - const p = sendBlock.largestFile!.path; - setContextPaths((prev) => prev.filter((cp) => cp.path !== p)); - setSendBlock(null); - }} - sx={{ - background: 'transparent', color: c.text.primary, border: `1px solid ${c.border.subtle}`, - borderRadius: '6px', px: 1, py: 0.5, fontSize: '0.72rem', cursor: 'pointer', - '&:hover': { background: c.bg.secondary }, - }} - > - Detach largest file (~{fmt(sendBlock.largestFile.tokens)}) - - )} - { setModelAnchor(e.currentTarget as HTMLElement); setSendBlock(null); }} - sx={{ - background: 'transparent', color: c.text.primary, border: `1px solid ${c.border.subtle}`, - borderRadius: '6px', px: 1, py: 0.5, fontSize: '0.72rem', cursor: 'pointer', - '&:hover': { background: c.bg.secondary }, - }} - > - Switch model - - setSendBlock(null)} - sx={{ - background: 'transparent', color: c.text.muted, border: 'none', - borderRadius: '6px', px: 1, py: 0.5, fontSize: '0.72rem', cursor: 'pointer', - '&:hover': { background: c.bg.secondary }, - }} - > - Dismiss - - - - ); - })()} - - {images.length > 0 && ( - - {images.map((img, idx) => ( - setLightboxSrc(img.preview)} - > - - { e.stopPropagation(); removeImage(idx); }} - sx={{ - position: 'absolute', - top: -2, - right: -2, - width: 18, - height: 18, - bgcolor: c.bg.surface, - border: `1px solid ${c.border.medium}`, - color: c.text.tertiary, - '&:hover': { bgcolor: c.bg.secondary, color: c.text.primary }, - }} - > - - - - ))} - - )} - - {contextPaths.length > 0 && ( - 0 ? 0.25 : 1, pb: 0 }}> - {contextPaths.map((cp, idx) => { - const isAppWorkspace = /\/outputs_workspace\/ws-[^/]+\/?$/.test(cp.path); - const label = isAppWorkspace - ? 'App files' - : pathTail(cp.path, 2); - return ( - - {(() => { - const unsupported = (cp.kind === 'pdf' && !pdfSupported) || - (cp.kind === 'image' && !imageSupported) || - cp.kind === 'binary'; - const chipColor = unsupported ? c.status.warning : c.accent.primary; - return ( - - : - } - label={(() => { - const kindTag = cp.kind && cp.kind !== 'text' ? ` · ${cp.kind}` : ''; - const tokTag = typeof cp.tokens === 'number' && cp.tokens > 0 ? ` · ${formatTokenCount(cp.tokens)}` : ''; - const warn = unsupported ? ' · not on this model' : ''; - return `${label}${kindTag}${tokTag}${warn}`; - })()} - size="small" - onClick={() => { - navigator.clipboard.writeText(cp.path); - setCopiedPathIdx(idx); - setTimeout(() => setCopiedPathIdx((cur) => cur === idx ? null : cur), 1200); - }} - onDelete={() => setContextPaths((prev) => prev.filter((_, i) => i !== idx))} - sx={{ - bgcolor: `${chipColor}12`, - color: chipColor, - fontSize: '0.72rem', - fontFamily: c.font.mono, - height: 26, - maxWidth: 280, - cursor: 'pointer', - '& .MuiChip-label': { overflow: 'hidden', textOverflow: 'ellipsis' }, - '& .MuiChip-deleteIcon': { - color: chipColor, - fontSize: 16, - '&:hover': { color: c.status.error }, - }, - }} - /> - ); - })()} - - ); - })} - - )} - - {forcedTools.length > 0 && ( - 0 || contextPaths.length > 0) ? 0.25 : 1, pb: 0 }}> - {forcedTools.map((ft, idx) => ( - {ft.icon || getToolGroupIcon(ft.iconKey || ft.label, 14)}} - label={`@${ft.label.toLowerCase()}`} - size="small" - onDelete={() => setForcedTools((prev) => prev.filter((_, i) => i !== idx))} - sx={{ - bgcolor: `${c.status.info}15`, - color: c.status.info, - fontSize: '0.72rem', - fontFamily: c.font.mono, - height: 26, - maxWidth: 220, - '& .MuiChip-label': { overflow: 'hidden', textOverflow: 'ellipsis' }, - '& .MuiChip-deleteIcon': { - color: c.status.info, - fontSize: 16, - '&:hover': { color: c.status.error }, - }, - }} - /> - ))} - - )} - - {selectedElements.length > 0 && ( - 0 || contextPaths.length > 0 || forcedTools.length > 0) ? 0.25 : 1, pb: 0 }}> - {selectedElements.map((el) => { - const chipLabel = el.semanticLabel - ? el.semanticLabel - : el.className - ? `${el.tagName.toLowerCase()}.${el.className.split(' ')[0]}` - : el.tagName.toLowerCase(); - const tooltipText = el.semanticType - ? `${el.semanticType}: ${el.semanticLabel || el.selectorPath}` - : el.selectorPath; - return ( - - } - label={chipLabel} - size="small" - onDelete={() => elementSelection?.removeOwnerElement(ownerId, el.id)} - sx={{ - bgcolor: 'rgba(59, 130, 246, 0.1)', - color: '#3b82f6', - fontSize: '0.72rem', - fontFamily: c.font.mono, - height: 26, - maxWidth: 220, - '& .MuiChip-label': { overflow: 'hidden', textOverflow: 'ellipsis' }, - '& .MuiChip-deleteIcon': { - color: '#3b82f6', - fontSize: 16, - '&:hover': { color: c.status.error }, - }, - '& .MuiChip-icon': { - color: '#3b82f6', - }, - }} - /> - - ); - })} - - )} - - -
- {!hasContent && ( -
- {disabled ? 'Agent is working...' : autoRunMode ? 'Describe what data to generate…' : isRunning ? (queueLength > 0 ? `${queueLength} queued, type another or wait…` : 'Agent is working, messages will queue…') : `${modeConf.label}, @ for context, / for commands`} -
- )} - - - - setModeAnchor(e.currentTarget)} - sx={{ - display: 'inline-flex', - alignItems: 'center', - gap: 0.5, - px: 1, - py: 0.375, - borderRadius: '999px', - cursor: 'pointer', - userSelect: 'none', - color: modeConf.color, - bgcolor: `${modeConf.color}14`, - '&:hover': { bgcolor: `${modeConf.color}22` }, - transition: 'background 0.15s', - }} - > - {modeConf.icon} - - {modeConf.label} - - - - - setModeAnchor(null)} - anchorOrigin={{ vertical: 'top', horizontal: 'left' }} - transformOrigin={{ vertical: 'bottom', horizontal: 'left' }} - slotProps={{ paper: menuPaperProps }} - autoFocus - MenuListProps={{ autoFocusItem: true, disablePadding: false }} - > - {modesArr.map((m) => { - const icon = ICON_MAP[m.icon] || ICON_MAP.smart_toy; - return ( - { - onModeChange(m.id); - setModeAnchor(null); - }} - > - - {icon} - - - - ); - })} - - - setModelAnchor(e.currentTarget)} - sx={{ - display: 'inline-flex', - alignItems: 'center', - gap: 0.25, - px: 0.75, - py: 0.25, - borderRadius: '6px', - cursor: 'pointer', - userSelect: 'none', - color: c.text.muted, - '&:hover': { bgcolor: 'rgba(0,0,0,0.04)' }, - transition: 'background 0.15s', - }} - > - - {(() => { const m = allModelOptions.flat.find((m) => m.value === model); return m ? m.label : model; })()} - - - - - setModelAnchor(null)} - anchorOrigin={{ vertical: 'top', horizontal: 'left' }} - transformOrigin={{ vertical: 'bottom', horizontal: 'left' }} - slotProps={{ paper: menuPaperProps }} - autoFocus={false} - MenuListProps={{ autoFocusItem: false }} - > - {/* Sticky header stops click+key so Menu doesn't typeahead while user types. */} - { - if (e.key !== 'Escape') e.stopPropagation(); - }} - onClick={(e) => e.stopPropagation()} - sx={{ - position: 'sticky', top: 0, zIndex: 2, - bgcolor: c.bg.surface, - borderBottom: `1px solid ${c.border.subtle}`, - display: 'flex', flexDirection: 'column', - outline: 'none', - '&:focus, &:focus-within': { outline: 'none' }, - }} - > - - - setModelSearch(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter' && modelSearch.trim()) { - pushRecentSearch(modelSearch); - } - }} - placeholder="Search models…" - fullWidth - sx={{ - fontSize: '0.85rem', - color: c.text.primary, - '& input': { padding: 0 }, - '& input::placeholder': { color: c.text.ghost, opacity: 1 }, - }} - /> - 0, costIdx > 0].filter(Boolean).length} active filter${[capFilters.reasoning, capFilters.subscription, capFilters.apiKey, ctxIdx > 0, costIdx > 0].filter(Boolean).length === 1 ? '' : 's'}` - : (filtersExpanded ? 'Hide filters' : 'Show filters')} - placement="bottom" - enterDelay={400} - slotProps={tooltipSlotProps} - > - - - {anyFilterActive && ( - - )} - - - - - - {([ - { key: 'reasoning', label: 'Reasoning' }, - { key: 'subscription', label: 'Subscription' }, - { key: 'apiKey', label: 'API key' }, - ] as const).map(({ key, label }) => { - const active = capFilters[key]; - return ( - setCapFilters((prev) => ({ ...prev, [key]: !prev[key] }))} - sx={{ - cursor: 'pointer', userSelect: 'none', - px: 0.85, height: 20, - display: 'inline-flex', alignItems: 'center', - fontSize: '0.66rem', fontWeight: 600, - letterSpacing: '0.04em', - borderRadius: '4px', - border: `1px solid ${active ? c.accent.primary : c.border.subtle}`, - bgcolor: active ? `${c.accent.primary}1a` : 'transparent', - color: active ? c.accent.primary : c.text.tertiary, - whiteSpace: 'nowrap', - transition: 'all 0.12s', - '&:hover': { borderColor: c.accent.primary, color: active ? c.accent.primary : c.text.muted }, - }} - > - {label} - - ); - })} - {anyFilterActive && ( - { - setCapFilters({ reasoning: false, subscription: false, apiKey: false }); - setCtxIdx(0); setCostIdx(0); - }} - sx={{ - cursor: 'pointer', userSelect: 'none', - fontSize: '0.66rem', fontWeight: 500, - color: c.text.ghost, - ml: 0.5, px: 0.5, - '&:hover': { color: c.text.muted }, - }} - > - Reset - - )} - - - {([ - { label: 'Min context', idx: ctxIdx, set: setCtxIdx, max: CTX_STEPS.length - 1, valueLabel: CTX_LABELS[ctxIdx] }, - { label: 'Max cost', idx: costIdx, set: setCostIdx, max: COST_STEPS.length - 1, valueLabel: COST_LABELS[costIdx] }, - ] as const).map((row, i) => ( - - - {row.label} - - row.set(v as number)} - step={1} - min={0} - max={row.max} - marks - sx={{ - color: c.accent.primary, - height: 3, - padding: '8px 0', - '& .MuiSlider-thumb': { - width: 10, height: 10, - '&:before': { boxShadow: 'none' }, - '&:hover, &.Mui-focusVisible': { boxShadow: `0 0 0 6px ${c.accent.primary}26` }, - }, - '& .MuiSlider-rail': { - opacity: 0.35, color: c.border.subtle, - }, - '& .MuiSlider-mark': { - width: 2, height: 2, borderRadius: '50%', - bgcolor: c.text.ghost, opacity: 0.6, - }, - '& .MuiSlider-markActive': { opacity: 0 }, - }} - /> - 0 ? c.accent.primary : c.text.ghost, - fontVariantNumeric: 'tabular-nums', - textAlign: 'right', - }}> - {row.valueLabel} - - - ))} - - - - - {probeResult && probeResult.value === model && !probeResult.ok && ( - - e.stopPropagation()} - sx={{ - mx: 1, my: 0.5, - px: 1, height: 26, - display: 'flex', alignItems: 'center', gap: 0.5, - borderRadius: '6px', - bgcolor: 'rgba(239, 68, 68, 0.08)', - border: '1px solid rgba(239, 68, 68, 0.18)', - color: '#ef4444', - fontSize: '0.7rem', - flexShrink: 0, - overflow: 'hidden', - }} - > - Heads up - - · {probeResult.error || 'this model failed its health check'} - - - - )} - - {showRecents && (() => { - const recentKey = 'Recent'; - const recentCollapsed = !!collapsedGroups[recentKey]; - return ( - <> - { - e.stopPropagation(); - toggleGroupCollapse(recentKey, recentCollapsed); - }} - sx={{ - opacity: '1 !important', - py: 0.75, px: 1.5, minHeight: 'auto', - cursor: 'pointer', - '&:hover': { bgcolor: 'rgba(255,255,255,0.04)' }, - }} - > - - - - - Recent - - - {recentMaterialised.length} - - - - - {recentMaterialised.map((opt: any) => ( - - { - onModelChange(opt.value); - pushRecentModel(opt.value); - setModelAnchor(null); - }} - > - - - - ))} - - - ); - })()} - - {Object.keys(filteredModelGroups).length === 0 && ( - - {modelSearch.trim() ? ( - <>No models match "{modelSearch.trim()}".{anyFilterActive && (<>
Try clearing the filters above.)} - ) : ( - <>No models match the current filters. - )} -
- )} - - {Object.entries(filteredModelGroups).map(([prov, models]) => { - const isOpenSwarmPro = prov === 'OpenSwarm Pro'; - const isOR = prov.startsWith('OpenRouter'); - const ms = models as any[]; - // OR vendor groups with >12 entries auto-collapse on first open; search disables this. - const collapsible = true; - const searchActive = modelSearch.trim().length > 0; - const userToggle = collapsedGroups[prov]; - const autoCollapse = isOR && !searchActive && ms.length > OR_AUTO_COLLAPSE_THRESHOLD; - const collapsed = userToggle !== undefined ? userToggle : autoCollapse; - const brandKey = (isOR ? 'openrouter' : prov.toLowerCase()); - const brandColor = PROVIDER_COLORS[brandKey] ?? c.text.tertiary; - const OPENSWARM_GRADIENT = - 'linear-gradient(135deg, #8FB3FF 0%, #E56BC4 45%, #FFA85C 100%)'; - - const highlightMatch = (text: string): React.ReactNode => { - const q = modelSearch.trim(); - if (!q) return text; - const idx = text.toLowerCase().indexOf(q.toLowerCase()); - if (idx < 0) return text; - return ( - <> - {text.slice(0, idx)} - - {text.slice(idx, idx + q.length)} - - {text.slice(idx + q.length)} - - ); - }; - - return [ - { - e.stopPropagation(); - toggleGroupCollapse(prov, collapsed); - }} - sx={{ - opacity: '1 !important', - py: 0.75, px: 1.5, minHeight: 'auto', - cursor: 'pointer', - '&:hover': { bgcolor: 'rgba(255,255,255,0.04)' }, - }} - > - - - - - {prov} - - - {ms.length} - - - , - - {models.map((opt: any) => { - let displayLabel = opt.label; - if (isOR && displayLabel.includes(': ')) { - const groupVendor = prov.replace(/^OpenRouter\s*[·•]\s*/i, '').toLowerCase(); - const colonIdx = displayLabel.indexOf(': '); - const labelPrefix = displayLabel.slice(0, colonIdx).toLowerCase(); - if (labelPrefix === groupVendor) { - displayLabel = displayLabel.slice(colonIdx + 2); - } - } - return ( - - { - onModelChange(opt.value); - pushRecentModel(opt.value); - if (modelSearch.trim()) pushRecentSearch(modelSearch); - if (onProviderChange) { - const provLower = prov.toLowerCase(); - const providerMap: Record = { - anthropic: 'anthropic', - 'openswarm pro': 'anthropic', - openai: 'openai', - google: 'gemini', - }; - onProviderChange(providerMap[provLower] || (isOR ? 'openrouter' : provLower)); - } - setModelAnchor(null); - }} - > - - {(() => { - const win = (opt.context_window as number) || 0; - const api = (opt.api as string || 'anthropic').toLowerCase(); - const optIsCodex = typeof opt.value === 'string' && (opt.value.toLowerCase().includes('codex') || opt.value.toLowerCase().startsWith('cx/')); - const optSupportsPdf = ( - ['anthropic', 'gemini', 'gemini-cli', 'openrouter'].includes(api) || - (api === 'openai' && !optIsCodex) - ); - const optSupportsImage = ['anthropic', 'gemini', 'gemini-cli', 'openai', 'openrouter'].includes(api); - const cannotPdf = pendingKinds.has('pdf') && !optSupportsPdf; - const cannotImg = pendingKinds.has('image') && !optSupportsImage; - if (!win) return null; - const fits = pendingPayloadEstimate > 0 && win >= Math.floor(pendingPayloadEstimate * 1.1); - const tight = pendingPayloadEstimate > 0 && !fits && win >= pendingPayloadEstimate; - const tooSmall = pendingPayloadEstimate > 0 && win < pendingPayloadEstimate; - return ( - - {(cannotPdf || cannotImg) && ( - - No {cannotPdf ? 'PDF' : 'image'} - - )} - {!cannotPdf && !cannotImg && fits && ( - - Fits - - )} - {!cannotPdf && !cannotImg && tight && ( - - Tight - - )} - {!cannotPdf && !cannotImg && tooSmall && ( - - Too small - - )} - - {formatTokenCount(win)} - - - ); - })()} - - - ); - })} - , - ]; - }).flat()} - - e.stopPropagation()} - sx={{ - position: 'sticky', bottom: 0, - bgcolor: c.bg.surface, - borderTop: `1px solid ${c.border.subtle}`, - px: 1.25, py: 0.5, - fontSize: '0.65rem', color: c.text.ghost, - display: 'flex', justifyContent: 'space-between', - gap: 1, - }} - > - - Type to search, Esc to close - - {(() => { - const breakdown: Array<[string, number]> = ([ - ['Free', pickerSummary.free], - ['Subscription', pickerSummary.subscription], - ['API key', pickerSummary.apiKey], - ['Pay-per-use', pickerSummary.paid], - ['Reasoning', pickerSummary.reasoning], - ['1M+ context', pickerSummary.longContext], - ] as Array<[string, number]>).filter(([, n]) => n > 0); - const breakdownTooltip = breakdown.length > 0 ? ( - - - {pickerSummary.total} model{pickerSummary.total === 1 ? '' : 's'} available - - - {breakdown.map(([label, n]) => ( - - {label} - {n} - - ))} - - - ) : null; - return ( - - - {pickerSummary.total} model{pickerSummary.total === 1 ? '' : 's'} - - - ); - })()} - -
- - {(() => { - const currentModel = allModelOptions.flat.find((m: any) => m.value === model) as any; - if (!currentModel?.reasoning || !onThinkingLevelChange) return null; - const levels: Array<{ value: 'off' | 'low' | 'medium' | 'high' | 'auto'; label: string; desc: string }> = [ - { value: 'auto', label: 'Auto', desc: 'Model decides (recommended)' }, - { value: 'off', label: 'Off', desc: 'No thinking (fastest)' }, - { value: 'low', label: 'Low', desc: 'Minimal thinking' }, - { value: 'medium', label: 'Medium', desc: 'Balanced' }, - { value: 'high', label: 'High', desc: 'Extensive thinking (slowest)' }, - ]; - const current = levels.find((l) => l.value === thinkingLevel) || levels[0]; - return ( - <> - setThinkingAnchor(e.currentTarget)} - sx={{ - display: 'inline-flex', alignItems: 'center', gap: 0.25, - px: 0.75, py: 0.25, borderRadius: '6px', cursor: 'pointer', userSelect: 'none', - color: c.text.muted, - '&:hover': { bgcolor: 'rgba(0,0,0,0.04)' }, - transition: 'background 0.15s', - }} - > - - - {current.label} - - - - setThinkingAnchor(null)} - anchorOrigin={{ vertical: 'top', horizontal: 'left' }} - transformOrigin={{ vertical: 'bottom', horizontal: 'left' }} - slotProps={{ paper: menuPaperProps }} - autoFocus - MenuListProps={{ autoFocusItem: true }} - > - - - Thinking Level - - - {/* Gemini 3 preview rejects "thought signature" on tool-call turns when thinking is on; warn search users. */} - {(() => { - const isGemini3 = typeof model === 'string' && (model.includes('gemini-3') || (allModelOptions.flat.find((m: any) => m.value === model)?.label || '').toLowerCase().includes('gemini 3')); - if (!isGemini3 || thinkingLevel === 'off') return null; - return ( - - - Web search breaks on Gemini 3 preview while thinking is on. Set to Off if you need search. - - - ); - })()} - {levels.map((lvl) => ( - { onThinkingLevelChange(lvl.value); setThinkingAnchor(null); }} - sx={{ py: 0.6 }} - > - - - {lvl.label} - - - {lvl.desc} - - - - ))} - - - ); - })()} - - - - {contextEstimate && ( - - )} - - {elementSelection && !autoRunMode && (() => { - const isMySelectMode = elementSelection.selectMode && elementSelection.activeOwnerId === ownerId; - return ( - - e.preventDefault()} - data-onboarding="element-selection-toggle" - onClick={() => { - if (isMySelectMode) { - elementSelection.setSelectMode(false); - } else { - if (elementSelection.activeOwnerId !== ownerId) { - elementSelection.clearOwnerElements(ownerId); - } - elementSelection.setActiveOwnerId(ownerId); - if (sessionId) { - elementSelection.setExcludeSelectId(sessionId); - } else { - elementSelection.setExcludeSelectId(null); - } - elementSelection.setSelectMode(true); - } - }} - sx={{ - p: 0.5, - ...(isMySelectMode - ? { - bgcolor: '#3b82f6', - color: '#fff', - '&:hover': { bgcolor: '#2563eb' }, - animation: 'selectBtnPulse 2s ease-in-out infinite', - '@keyframes selectBtnPulse': { - '0%, 100%': { boxShadow: '0 0 0 0 rgba(59,130,246,0.4)' }, - '50%': { boxShadow: '0 0 0 4px rgba(59,130,246,0.1)' }, - }, - } - : { - color: c.text.tertiary, - '&:hover': { color: c.text.secondary, bgcolor: 'rgba(0,0,0,0.04)' }, - }), - transition: 'background-color 0.15s, color 0.15s', - }} - > - - - - ); - })()} - - { - if (!e.target.files) return; - const all = Array.from(e.target.files); - const imgs = all.filter((f) => f.type.startsWith('image/')); - const rest = all.filter((f) => !f.type.startsWith('image/')); - if (imgs.length > 0) addImageFiles(imgs); - if (rest.length > 0) uploadAndAttachFiles(rest); - e.target.value = ''; - }} - /> - - generalFileInputRef.current?.click()} - sx={{ - color: c.text.tertiary, - p: 0.5, - '&:hover': { color: c.text.secondary, bgcolor: 'rgba(0,0,0,0.04)' }, - }} - > - - - - {!autoRunMode && ( - - {hasContent && ( - - - - - - )} - {isRunning ? ( - - - - - - ) : !hasContent ? ( - - - - - - - - ) : null} - - )} - - - setLightboxSrc(null)} - sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }} - > - setLightboxSrc(null)} - sx={{ position: 'relative', outline: 'none', maxWidth: '90vw', maxHeight: '90vh' }} - > - setLightboxSrc(null)} - sx={{ - position: 'absolute', - top: -16, - right: -16, - bgcolor: c.bg.surface, - border: `1px solid ${c.border.medium}`, - color: c.text.secondary, - width: 32, - height: 32, - zIndex: 1, - '&:hover': { bgcolor: c.bg.secondary }, - boxShadow: c.shadow.md, - }} - > - - - e.stopPropagation()} - style={{ - maxWidth: '90vw', - maxHeight: '90vh', - borderRadius: 8, - boxShadow: '0 8px 32px rgba(0,0,0,0.4)', - display: 'block', - }} - /> - - - - 0} - anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} - sx={{ mb: 10 }} - > - - oversizeQueue[0] && summarizeOversize(oversizeQueue[0].path)} - sx={{ - background: 'rgba(255,255,255,0.18)', color: 'inherit', border: 'none', - borderRadius: '6px', px: 1, py: 0.5, fontSize: '0.72rem', cursor: 'pointer', - '&:hover': { background: 'rgba(255,255,255,0.28)' }, - '&:disabled': { opacity: 0.6, cursor: 'wait' }, - }} - > - {summarizingPath === oversizeQueue[0]?.path ? 'Summarizing…' : 'Summarize instead'} - - oversizeQueue[0] && detachOversize(oversizeQueue[0].path)} - sx={{ - background: 'transparent', color: 'inherit', border: '1px solid rgba(255,255,255,0.4)', - borderRadius: '6px', px: 1, py: 0.5, fontSize: '0.72rem', cursor: 'pointer', - '&:hover': { background: 'rgba(255,255,255,0.12)' }, - }} - > - Detach - -
- } - > - {oversizeQueue[0] ? ( - - {oversizeQueue[0].name} is ~{formatTokenCount(oversizeQueue[0].tokens)} tokens, over 50% of this model's window ({formatTokenCount(currentModelCtx)}). Summarize sends the file content to your configured aux provider. - - ) : null} - - - - setSummarizeError(null)} - sx={{ mb: 18 }} - > - setSummarizeError(null)} sx={{ fontSize: '0.78rem', maxWidth: 520 }}> - {summarizeError} - - - - + ); });