import React from 'react'; import { Textarea } from '@/components/ui/textarea'; import { RiAddCircleLine, RiAiAgentLine, RiAttachment2, RiFileUploadLine, RiSendPlane2Line, } from '@remixicon/react'; import { useSessionStore } from '@/stores/useSessionStore'; import { useConfigStore } from '@/stores/useConfigStore'; import { useUIStore } from '@/stores/useUIStore'; import { useMessageQueueStore, type QueuedMessage } from '@/stores/messageQueueStore'; import type { AttachedFile, EditPermissionMode } from '@/stores/types/sessionTypes'; import { getEditModeColors } from '@/lib/permissions/editModeColors'; import { AttachedFilesList } from './FileAttachment'; import { QueuedMessageChips } from './QueuedMessageChips'; import { FileMentionAutocomplete, type FileMentionHandle } from './FileMentionAutocomplete'; import { CommandAutocomplete, type CommandAutocompleteHandle } from './CommandAutocomplete'; import { AgentMentionAutocomplete, type AgentMentionAutocompleteHandle } from './AgentMentionAutocomplete'; import { SkillAutocomplete, type SkillAutocompleteHandle } from './SkillAutocomplete'; import { cn } from '@/lib/utils'; import { ServerFilePicker } from './ServerFilePicker'; import { ModelControls } from './ModelControls'; import { parseAgentMentions } from '@/lib/messages/agentMentions'; import { StatusRow } from './StatusRow'; import { useAssistantStatus } from '@/hooks/useAssistantStatus'; import { useCurrentSessionActivity } from '@/hooks/useSessionActivity'; import { toast } from '@/components/ui'; import { useFileStore } from '@/stores/fileStore'; import { isVSCodeRuntime } from '@/lib/desktop'; import { isIMECompositionEvent } from '@/lib/ime'; import { StopIcon } from '@/components/icons/StopIcon'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { useContextStore } from '@/stores/contextStore'; const MAX_VISIBLE_TEXTAREA_LINES = 8; const EMPTY_QUEUE: QueuedMessage[] = []; interface ChatInputProps { onOpenSettings?: () => void; scrollToBottom?: (options?: { instant?: boolean; force?: boolean; clearAnchor?: boolean }) => void; } const isPrimaryMode = (mode?: string) => mode === 'primary' || mode === 'all' || mode === undefined || mode === null; type PermissionAction = 'allow' | 'ask' | 'deny'; type PermissionRule = { permission: string; pattern: string; action: PermissionAction }; const asPermissionRuleset = (value: unknown): PermissionRule[] | null => { if (!Array.isArray(value)) { return null; } const rules: PermissionRule[] = []; for (const entry of value) { if (!entry || typeof entry !== 'object') { continue; } const candidate = entry as Partial; if (typeof candidate.permission !== 'string' || typeof candidate.pattern !== 'string' || typeof candidate.action !== 'string') { continue; } if (candidate.action !== 'allow' && candidate.action !== 'ask' && candidate.action !== 'deny') { continue; } rules.push({ permission: candidate.permission, pattern: candidate.pattern, action: candidate.action }); } return rules; }; const resolveWildcardPermissionAction = (ruleset: unknown, permission: string): PermissionAction | undefined => { const rules = asPermissionRuleset(ruleset); if (!rules || rules.length === 0) { return undefined; } for (let i = rules.length - 1; i >= 0; i -= 1) { const rule = rules[i]; if (rule.permission === permission && rule.pattern === '*') { return rule.action; } } for (let i = rules.length - 1; i >= 0; i -= 1) { const rule = rules[i]; if (rule.permission === '*' && rule.pattern === '*') { return rule.action; } } return undefined; }; export const ChatInput: React.FC = ({ onOpenSettings, scrollToBottom }) => { const [message, setMessage] = React.useState(''); const [isDragging, setIsDragging] = React.useState(false); const [showFileMention, setShowFileMention] = React.useState(false); const [mentionQuery, setMentionQuery] = React.useState(''); const [showCommandAutocomplete, setShowCommandAutocomplete] = React.useState(false); const [commandQuery, setCommandQuery] = React.useState(''); const [showAgentAutocomplete, setShowAgentAutocomplete] = React.useState(false); const [agentQuery, setAgentQuery] = React.useState(''); const [showSkillAutocomplete, setShowSkillAutocomplete] = React.useState(false); const [skillQuery, setSkillQuery] = React.useState(''); const [textareaSize, setTextareaSize] = React.useState<{ height: number; maxHeight: number } | null>(null); const textareaRef = React.useRef(null); const dropZoneRef = React.useRef(null); const mentionRef = React.useRef(null); const commandRef = React.useRef(null); const agentRef = React.useRef(null); const skillRef = React.useRef(null); const sendMessage = useSessionStore((state) => state.sendMessage); const currentSessionId = useSessionStore((state) => state.currentSessionId); const newSessionDraftOpen = useSessionStore((state) => state.newSessionDraft?.open); const abortCurrentOperation = useSessionStore((state) => state.abortCurrentOperation); const acknowledgeSessionAbort = useSessionStore((state) => state.acknowledgeSessionAbort); const abortPromptSessionId = useSessionStore((state) => state.abortPromptSessionId); const clearAbortPrompt = useSessionStore((state) => state.clearAbortPrompt); const sessionAbortFlags = useSessionStore((state) => state.sessionAbortFlags); const attachedFiles = useSessionStore((state) => state.attachedFiles); const addAttachedFile = useSessionStore((state) => state.addAttachedFile); const addServerFile = useSessionStore((state) => state.addServerFile); const clearAttachedFiles = useSessionStore((state) => state.clearAttachedFiles); const saveSessionAgentSelection = useSessionStore((state) => state.saveSessionAgentSelection); const consumePendingInputText = useSessionStore((state) => state.consumePendingInputText); const pendingInputText = useSessionStore((state) => state.pendingInputText); const { currentProviderId, currentModelId, currentVariant, currentAgentName, setAgent, getVisibleAgents } = useConfigStore(); const agents = getVisibleAgents(); const { isMobile, inputBarOffset, isKeyboardOpen, setTimelineDialogOpen, cornerRadius } = useUIStore(); const { working } = useAssistantStatus(); const [showAbortStatus, setShowAbortStatus] = React.useState(false); const abortTimeoutRef = React.useRef | null>(null); const prevWasAbortedRef = React.useRef(false); const sendTriggeredByPointerDownRef = React.useRef(false); // Message queue const queueModeEnabled = useMessageQueueStore((state) => state.queueModeEnabled); const queuedMessages = useMessageQueueStore( React.useCallback( (state) => { if (!currentSessionId) return EMPTY_QUEUE; return state.queuedMessages[currentSessionId] ?? EMPTY_QUEUE; }, [currentSessionId] ) ); const addToQueue = useMessageQueueStore((state) => state.addToQueue); const clearQueue = useMessageQueueStore((state) => state.clearQueue); // Session activity for auto-send on idle const { phase: sessionPhase } = useCurrentSessionActivity(); const prevSessionPhaseRef = React.useRef(sessionPhase); const autoSendTriggeredRef = React.useRef(false); const handleTextareaPointerDownCapture = React.useCallback((event: React.PointerEvent) => { if (!isMobile) { return; } if (event.pointerType !== 'touch') { return; } const textarea = textareaRef.current; if (!textarea) { return; } if (document.activeElement === textarea) { return; } // Prevent iOS from scrolling the page to reveal the input. event.preventDefault(); event.stopPropagation(); const scroller = document.scrollingElement; if (scroller && scroller.scrollTop !== 0) { scroller.scrollTop = 0; } if (window.scrollY !== 0) { window.scrollTo(0, 0); } try { textarea.focus({ preventScroll: true }); } catch { textarea.focus(); } const len = textarea.value.length; try { textarea.setSelectionRange(len, len); } catch { // ignored } }, [isMobile]); // Consume pending input text (e.g., from revert action) React.useEffect(() => { if (pendingInputText !== null) { const text = consumePendingInputText(); if (text) { setMessage(text); // Focus textarea after setting message setTimeout(() => { textareaRef.current?.focus(); }, 0); } } }, [pendingInputText, consumePendingInputText]); const currentAgent = React.useMemo(() => { const selectedName = currentSessionId ? (useContextStore.getState().getSessionAgentSelection(currentSessionId) || currentAgentName) : currentAgentName; if (!selectedName) { return undefined; } return agents.find((agent) => agent.name === selectedName); }, [agents, currentAgentName, currentSessionId]); const agentEditAction = React.useMemo(() => { if (!currentAgent) { return 'deny'; } return resolveWildcardPermissionAction(currentAgent.permission, 'edit') ?? 'allow'; }, [currentAgent]); const sessionEditMode = useContextStore( React.useCallback((state) => { if (!currentAgentName) { return undefined; } const sessionId = currentSessionId ?? '__global__'; return state.getSessionAgentEditMode(sessionId, currentAgentName, 'ask'); }, [currentAgentName, currentSessionId]) ); const selectionContextReady = Boolean(currentSessionId && currentAgentName); const effectiveEditPermission = React.useMemo(() => { // Only show accent when edits are effectively allowed. if (agentEditAction === 'allow') { return 'allow'; } if (agentEditAction !== 'ask') { return 'ask'; } const sessionMode = selectionContextReady ? (sessionEditMode ?? 'ask') : 'ask'; return (sessionMode === 'allow' || sessionMode === 'full') ? 'allow' : 'ask'; }, [agentEditAction, selectionContextReady, sessionEditMode]); const chatInputAccent = React.useMemo(() => getEditModeColors(effectiveEditPermission), [effectiveEditPermission]); // VS Code webviews tend to have stronger status border colors; in web/desktop themes the same // border tokens can already be subtle, so avoid double-softening there. const softenBorderColor = React.useCallback((color: string) => ( isVSCodeRuntime() ? `color-mix(in srgb, ${color} 55%, transparent)` : color ), []); const chatInputWrapperStyle = React.useMemo(() => { // Keep border width stable so toggling modes doesn't shift layout. const baseBorderWidth = isVSCodeRuntime() ? 1 : 2; const baseStyle: React.CSSProperties = { borderRadius: cornerRadius, }; if (!chatInputAccent) { return { ...baseStyle, borderWidth: baseBorderWidth }; } const borderColor = chatInputAccent.border ?? chatInputAccent.text; return { ...baseStyle, borderColor: softenBorderColor(borderColor), borderWidth: baseBorderWidth, }; }, [chatInputAccent, softenBorderColor, cornerRadius]); const hasContent = message.trim() || attachedFiles.length > 0; const hasQueuedMessages = queuedMessages.length > 0; const canSend = hasContent || hasQueuedMessages; const canAbort = working.isWorking; // Add message to queue instead of sending const handleQueueMessage = React.useCallback(() => { if (!hasContent || !currentSessionId) return; const messageToQueue = message.replace(/^\n+|\n+$/g, ''); const attachmentsToQueue = attachedFiles.map((file) => ({ ...file })); addToQueue(currentSessionId, { content: messageToQueue, attachments: attachmentsToQueue.length > 0 ? attachmentsToQueue : undefined, }); // Clear input and attachments setMessage(''); if (attachmentsToQueue.length > 0) { clearAttachedFiles(); } if (!isMobile) { textareaRef.current?.focus(); } }, [hasContent, currentSessionId, message, attachedFiles, addToQueue, clearAttachedFiles, isMobile]); const handleSubmit = async (e?: React.FormEvent) => { e?.preventDefault(); if (!canSend || (!currentSessionId && !newSessionDraftOpen)) return; // Re-pin and scroll to bottom when sending scrollToBottom?.({ instant: true, force: true }); if (!currentProviderId || !currentModelId) { console.warn('Cannot send message: provider or model not selected'); return; } // Build the primary message (first part) and additional parts let primaryText = ''; let primaryAttachments: AttachedFile[] = []; let agentMentionName: string | undefined; const additionalParts: Array<{ text: string; attachments?: AttachedFile[] }> = []; // Process queued messages first for (let i = 0; i < queuedMessages.length; i++) { const queuedMsg = queuedMessages[i]; const { sanitizedText, mention } = parseAgentMentions(queuedMsg.content, agents); // Use agent mention from first message that has one if (!agentMentionName && mention?.name) { agentMentionName = mention.name; } if (i === 0) { // First queued message becomes primary primaryText = sanitizedText; primaryAttachments = queuedMsg.attachments ?? []; } else { // Subsequent queued messages become additional parts additionalParts.push({ text: sanitizedText, attachments: queuedMsg.attachments, }); } } // Add current input if (hasContent) { const messageToSend = message.replace(/^\n+|\n+$/g, ''); const { sanitizedText, mention } = parseAgentMentions(messageToSend, agents); const attachmentsToSend = attachedFiles.map((file) => ({ ...file })); if (!agentMentionName && mention?.name) { agentMentionName = mention.name; } if (queuedMessages.length === 0) { // No queue - current input is primary primaryText = sanitizedText; primaryAttachments = attachmentsToSend; } else { // Has queue - current input is additional part additionalParts.push({ text: sanitizedText, attachments: attachmentsToSend.length > 0 ? attachmentsToSend : undefined, }); } } if (!primaryText && additionalParts.length === 0) return; // Clear queue and input if (currentSessionId && hasQueuedMessages) { clearQueue(currentSessionId); } setMessage(''); if (attachedFiles.length > 0) { clearAttachedFiles(); } if (isMobile) { textareaRef.current?.blur(); } // Handle slash commands locally before sending const normalizedCommand = primaryText.trimStart(); if (normalizedCommand.startsWith('/')) { const commandName = normalizedCommand .slice(1) .trim() .split(/\s+/)[0] ?.toLowerCase(); // NEW: /undo - revert to last message (populates input with reverted message text) if (commandName === 'undo' && currentSessionId) { await useSessionStore.getState().handleSlashUndo(currentSessionId); // Don't clear message - pendingInputText will populate it with reverted message scrollToBottom?.({ instant: true, force: true }); return; // Don't send to assistant } // NEW: /redo - unrevert or partial redo (populates input with message text) else if (commandName === 'redo' && currentSessionId) { await useSessionStore.getState().handleSlashRedo(currentSessionId); // Don't clear message - pendingInputText will populate it scrollToBottom?.({ instant: true, force: true }); return; // Don't send to assistant } // NEW: /timeline - open timeline dialog else if (commandName === 'timeline' && currentSessionId) { setTimelineDialogOpen(true); setMessage(''); return; // Don't send to assistant } // /compact - call SDK summarize endpoint else if (commandName === 'compact' && currentSessionId) { try { const { opencodeClient } = await import('@/lib/opencode/client'); const directory = opencodeClient.getDirectory(); const response = await opencodeClient.getApiClient().session.summarize({ sessionID: currentSessionId, directory: directory || undefined, providerID: currentProviderId, modelID: currentModelId, }); if (response.error) { throw new Error('Failed to compact session'); } scrollToBottom?.({ instant: true, force: true }); } catch (error) { console.error('Failed to compact session:', error); toast.error('Failed to compact session'); } setMessage(''); return; // Don't send to assistant } } // Collect all attachments for error recovery const allAttachments = [ ...primaryAttachments, ...additionalParts.flatMap(p => p.attachments ?? []), ]; void sendMessage( primaryText, currentProviderId, currentModelId, currentAgentName, primaryAttachments, agentMentionName, additionalParts.length > 0 ? additionalParts : undefined, currentVariant ).catch((error: unknown) => { const rawMessage = error instanceof Error ? error.message : typeof error === 'string' ? error : String(error ?? ''); const normalized = rawMessage.toLowerCase(); console.error('Message send failed:', rawMessage || error); const isSoftNetworkError = normalized.includes('timeout') || normalized.includes('timed out') || normalized.includes('may still be processing') || normalized.includes('being processed') || normalized.includes('failed to fetch') || normalized.includes('networkerror') || normalized.includes('network error') || normalized.includes('gateway timeout') || normalized === 'failed to send message'; if (normalized.includes('payload too large') || normalized.includes('413') || normalized.includes('entity too large')) { toast.error('Attachments are too large to send. Please try reducing the number or size of images.'); if (allAttachments.length > 0) { useFileStore.setState({ attachedFiles: allAttachments }); } return; } if (isSoftNetworkError) { return; } if (allAttachments.length > 0) { useFileStore.setState({ attachedFiles: allAttachments }); } toast.error(rawMessage || 'Message failed to send. Attachments restored.'); }); if (!isMobile) { textareaRef.current?.focus(); } }; // Keep a ref to handleSubmit for auto-send effect const handleSubmitRef = React.useRef(handleSubmit); handleSubmitRef.current = handleSubmit; // Auto-send queued messages when session becomes idle (but not after abort) React.useEffect(() => { const wasWorking = prevSessionPhaseRef.current === 'busy' || prevSessionPhaseRef.current === 'cooldown'; const isNowIdle = sessionPhase === 'idle'; // Check if session was recently aborted (within last 2 seconds) const wasRecentlyAborted = currentSessionId && sessionAbortFlags.has(currentSessionId) && (() => { const abortRecord = sessionAbortFlags.get(currentSessionId); if (!abortRecord) return false; const timeSinceAbort = Date.now() - abortRecord.timestamp; return timeSinceAbort < 2000; })(); // Detect transition from working to idle, but skip if aborted if (wasWorking && isNowIdle && queuedMessages.length > 0 && !autoSendTriggeredRef.current && !wasRecentlyAborted) { // Prevent double-triggering autoSendTriggeredRef.current = true; // Use setTimeout to avoid calling during render setTimeout(() => { if (currentSessionId && currentProviderId && currentModelId) { void handleSubmitRef.current(); } autoSendTriggeredRef.current = false; }, 100); } prevSessionPhaseRef.current = sessionPhase; }, [sessionPhase, queuedMessages.length, currentSessionId, currentProviderId, currentModelId, sessionAbortFlags]); const handleKeyDown = (e: React.KeyboardEvent) => { // Early return during IME composition to prevent interference with autocomplete. // Uses keyCode === 229 fallback for WebKit where compositionend fires before keydown. if (isIMECompositionEvent(e)) return; if (showCommandAutocomplete && commandRef.current) { if (e.key === 'Enter' || e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Escape' || e.key === 'Tab') { e.preventDefault(); commandRef.current.handleKeyDown(e.key); return; } } if (showAgentAutocomplete && agentRef.current) { if (e.key === 'Enter' || e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Escape' || e.key === 'Tab') { e.preventDefault(); agentRef.current.handleKeyDown(e.key); return; } } if (showSkillAutocomplete && skillRef.current) { if (e.key === 'Enter' || e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Escape' || e.key === 'Tab') { e.preventDefault(); skillRef.current.handleKeyDown(e.key); return; } } if (showFileMention && mentionRef.current) { if (e.key === 'Enter' || e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Escape' || e.key === 'Tab') { e.preventDefault(); mentionRef.current.handleKeyDown(e.key); return; } } if (e.key === 'Tab' && !showCommandAutocomplete && !showAgentAutocomplete && !showFileMention) { e.preventDefault(); cycleAgent(); return; } // Handle Enter/Ctrl+Enter based on queue mode if (e.key === 'Enter' && !e.shiftKey && !isMobile) { e.preventDefault(); const isCtrlEnter = e.ctrlKey || e.metaKey; // Queue mode: Enter queues, Ctrl+Enter sends // Normal mode: Enter sends, Ctrl+Enter queues // Note: Queueing only works when there's an existing session (currentSessionId) // For new sessions (draft), always send immediately const canQueue = hasContent && currentSessionId && sessionPhase !== 'idle'; if (queueModeEnabled) { if (isCtrlEnter || !canQueue) { // Ctrl+Enter sends, or Enter when can't queue (new session) handleSubmit(); } else { // Enter queues when we have a session handleQueueMessage(); } } else { if (isCtrlEnter && canQueue) { // Ctrl+Enter queues when we have a session handleQueueMessage(); } else { // Enter sends handleSubmit(); } } } }; const startAbortIndicator = React.useCallback(() => { if (abortTimeoutRef.current) { clearTimeout(abortTimeoutRef.current); abortTimeoutRef.current = null; } setShowAbortStatus(true); abortTimeoutRef.current = setTimeout(() => { setShowAbortStatus(false); abortTimeoutRef.current = null; }, 1800); }, []); const handleAbort = React.useCallback(() => { clearAbortPrompt(); startAbortIndicator(); void abortCurrentOperation(); }, [abortCurrentOperation, clearAbortPrompt, startAbortIndicator]); const cycleAgent = () => { const primaryAgents = agents.filter(agent => isPrimaryMode(agent.mode)); if (primaryAgents.length <= 1) return; const currentIndex = primaryAgents.findIndex(agent => agent.name === currentAgentName); const nextIndex = (currentIndex + 1) % primaryAgents.length; const nextAgent = primaryAgents[nextIndex]; setAgent(nextAgent.name); if (currentSessionId) { saveSessionAgentSelection(currentSessionId, nextAgent.name); } }; const adjustTextareaHeight = React.useCallback(() => { const textarea = textareaRef.current; if (!textarea) { return; } textarea.style.height = 'auto'; const view = textarea.ownerDocument?.defaultView; const computedStyle = view ? view.getComputedStyle(textarea) : null; const lineHeight = computedStyle ? parseFloat(computedStyle.lineHeight) : NaN; const paddingTop = computedStyle ? parseFloat(computedStyle.paddingTop) : NaN; const paddingBottom = computedStyle ? parseFloat(computedStyle.paddingBottom) : NaN; const fallbackLineHeight = 22; const fallbackPadding = 16; const paddingTotal = Number.isNaN(paddingTop) || Number.isNaN(paddingBottom) ? fallbackPadding : paddingTop + paddingBottom; const targetLineHeight = Number.isNaN(lineHeight) ? fallbackLineHeight : lineHeight; const maxHeight = targetLineHeight * MAX_VISIBLE_TEXTAREA_LINES + paddingTotal; const scrollHeight = textarea.scrollHeight || textarea.offsetHeight; const nextHeight = Math.min(scrollHeight, maxHeight); textarea.style.height = `${nextHeight}px`; textarea.style.maxHeight = `${maxHeight}px`; setTextareaSize((prev) => { if (prev && prev.height === nextHeight && prev.maxHeight === maxHeight) { return prev; } return { height: nextHeight, maxHeight }; }); }, []); React.useLayoutEffect(() => { adjustTextareaHeight(); }, [adjustTextareaHeight, message, isMobile]); const updateAutocompleteState = React.useCallback((value: string, cursorPosition: number) => { if (value.startsWith('/')) { const firstSpace = value.indexOf(' '); const firstNewline = value.indexOf('\n'); const commandEnd = Math.min( firstSpace === -1 ? value.length : firstSpace, firstNewline === -1 ? value.length : firstNewline ); if (cursorPosition <= commandEnd && firstSpace === -1) { const commandText = value.substring(1, commandEnd); setCommandQuery(commandText); setShowCommandAutocomplete(true); setShowFileMention(false); setShowAgentAutocomplete(false); setShowSkillAutocomplete(false); return; } } setShowCommandAutocomplete(false); const textBeforeCursor = value.substring(0, cursorPosition); const lastHashSymbol = textBeforeCursor.lastIndexOf('#'); if (lastHashSymbol !== -1) { const charBefore = lastHashSymbol > 0 ? textBeforeCursor[lastHashSymbol - 1] : null; const textAfterHash = textBeforeCursor.substring(lastHashSymbol + 1); const hasSeparator = textAfterHash.includes(' ') || textAfterHash.includes('\n'); const isWordBoundary = !charBefore || /\s/.test(charBefore); if (isWordBoundary && !hasSeparator) { setAgentQuery(textAfterHash); setShowAgentAutocomplete(true); setShowFileMention(false); return; } } setShowAgentAutocomplete(false); setAgentQuery(''); const lastSlashSymbol = textBeforeCursor.lastIndexOf('/'); if (lastSlashSymbol !== -1) { const charBefore = lastSlashSymbol > 0 ? textBeforeCursor[lastSlashSymbol - 1] : null; const textAfterSlash = textBeforeCursor.substring(lastSlashSymbol + 1); const hasSeparator = textAfterSlash.includes(' ') || textAfterSlash.includes('\n'); const isWordBoundary = !charBefore || /\s/.test(charBefore); if (isWordBoundary && !hasSeparator) { setSkillQuery(textAfterSlash); setShowSkillAutocomplete(true); setShowFileMention(false); setShowAgentAutocomplete(false); return; } } setShowSkillAutocomplete(false); setSkillQuery(''); const lastAtSymbol = textBeforeCursor.lastIndexOf('@'); if (lastAtSymbol !== -1) { const textAfterAt = textBeforeCursor.substring(lastAtSymbol + 1); if (!textAfterAt.includes(' ') && !textAfterAt.includes('\n')) { setMentionQuery(textAfterAt); setShowFileMention(true); } else { setShowFileMention(false); } } else { setShowFileMention(false); } }, [setAgentQuery, setCommandQuery, setMentionQuery, setShowAgentAutocomplete, setShowCommandAutocomplete, setShowFileMention, setShowSkillAutocomplete, setSkillQuery]); const insertTextAtSelection = React.useCallback((text: string) => { if (!text) { return; } const textarea = textareaRef.current; if (!textarea) { const nextValue = message + text; setMessage(nextValue); updateAutocompleteState(nextValue, nextValue.length); requestAnimationFrame(() => adjustTextareaHeight()); return; } const start = textarea.selectionStart ?? message.length; const end = textarea.selectionEnd ?? message.length; const nextValue = `${message.substring(0, start)}${text}${message.substring(end)}`; setMessage(nextValue); const cursorPosition = start + text.length; requestAnimationFrame(() => { const currentTextarea = textareaRef.current; if (currentTextarea) { currentTextarea.selectionStart = cursorPosition; currentTextarea.selectionEnd = cursorPosition; } adjustTextareaHeight(); }); updateAutocompleteState(nextValue, cursorPosition); }, [adjustTextareaHeight, message, updateAutocompleteState]); const handleTextChange = (e: React.ChangeEvent) => { const value = e.target.value; const cursorPosition = e.target.selectionStart ?? value.length; setMessage(value); adjustTextareaHeight(); updateAutocompleteState(value, cursorPosition); }; const handlePaste = React.useCallback(async (e: React.ClipboardEvent) => { const fileMap = new Map(); Array.from(e.clipboardData.files || []).forEach(file => { if (file.type.startsWith('image/')) { fileMap.set(`${file.name}-${file.size}`, file); } }); Array.from(e.clipboardData.items || []).forEach(item => { if (item.kind === 'file' && item.type.startsWith('image/')) { const file = item.getAsFile(); if (file) { fileMap.set(`${file.name}-${file.size}`, file); } } }); const imageFiles = Array.from(fileMap.values()); if (imageFiles.length === 0) { return; } if (!currentSessionId && !newSessionDraftOpen) { return; } e.preventDefault(); const pastedText = e.clipboardData.getData('text'); if (pastedText) { insertTextAtSelection(pastedText); } let attachedCount = 0; for (const file of imageFiles) { const sizeBefore = useSessionStore.getState().attachedFiles.length; try { await addAttachedFile(file); const sizeAfter = useSessionStore.getState().attachedFiles.length; if (sizeAfter > sizeBefore) { attachedCount += 1; } } catch (error) { console.error('Clipboard image attach failed', error); toast.error(error instanceof Error ? error.message : 'Failed to attach image from clipboard'); } } if (attachedCount > 0) { toast.success(`Attached ${attachedCount} image${attachedCount > 1 ? 's' : ''} from clipboard`); } }, [addAttachedFile, currentSessionId, newSessionDraftOpen, insertTextAtSelection]); const handleFileSelect = (file: { name: string; path: string }) => { const cursorPosition = textareaRef.current?.selectionStart || 0; const textBeforeCursor = message.substring(0, cursorPosition); const lastAtSymbol = textBeforeCursor.lastIndexOf('@'); if (lastAtSymbol !== -1) { const newMessage = message.substring(0, lastAtSymbol) + file.name + message.substring(cursorPosition); setMessage(newMessage); } setShowFileMention(false); setMentionQuery(''); textareaRef.current?.focus(); }; const handleAgentSelect = (agentName: string) => { const textarea = textareaRef.current; const cursorPosition = textarea?.selectionStart ?? message.length; const textBeforeCursor = message.substring(0, cursorPosition); const lastHashSymbol = textBeforeCursor.lastIndexOf('#'); if (lastHashSymbol !== -1) { const newMessage = message.substring(0, lastHashSymbol) + `#${agentName} ` + message.substring(cursorPosition); setMessage(newMessage); const nextCursor = lastHashSymbol + agentName.length + 2; requestAnimationFrame(() => { if (textareaRef.current) { textareaRef.current.selectionStart = nextCursor; textareaRef.current.selectionEnd = nextCursor; } adjustTextareaHeight(); updateAutocompleteState(newMessage, nextCursor); }); } setShowAgentAutocomplete(false); setAgentQuery(''); textareaRef.current?.focus(); }; const handleSkillSelect = (skillName: string) => { const textarea = textareaRef.current; const cursorPosition = textarea?.selectionStart ?? message.length; const textBeforeCursor = message.substring(0, cursorPosition); const lastSlashSymbol = textBeforeCursor.lastIndexOf('/'); if (lastSlashSymbol !== -1) { const newMessage = message.substring(0, lastSlashSymbol) + `${skillName} ` + message.substring(cursorPosition); setMessage(newMessage); const nextCursor = lastSlashSymbol + skillName.length + 1; requestAnimationFrame(() => { if (textareaRef.current) { textareaRef.current.selectionStart = nextCursor; textareaRef.current.selectionEnd = nextCursor; } adjustTextareaHeight(); updateAutocompleteState(newMessage, nextCursor); }); } setShowSkillAutocomplete(false); setSkillQuery(''); textareaRef.current?.focus(); }; const handleCommandSelect = (command: { name: string; description?: string; agent?: string; model?: string }) => { setMessage(`/${command.name} `); const textareaElement = textareaRef.current as HTMLTextAreaElement & { _commandMetadata?: typeof command }; if (textareaElement) { textareaElement._commandMetadata = command; } setShowCommandAutocomplete(false); setCommandQuery(''); setTimeout(() => { if (textareaRef.current) { textareaRef.current.focus(); textareaRef.current.setSelectionRange(textareaRef.current.value.length, textareaRef.current.value.length); } }, 0); }; React.useEffect(() => { if (currentSessionId && textareaRef.current && !isMobile) { textareaRef.current.focus(); } }, [currentSessionId, isMobile]); React.useEffect(() => { if (abortPromptSessionId && abortPromptSessionId !== currentSessionId) { clearAbortPrompt(); } }, [abortPromptSessionId, currentSessionId, clearAbortPrompt]); const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); if ((currentSessionId || newSessionDraftOpen) && !isDragging) { setIsDragging(true); } }; const handleDragLeave = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); if (e.currentTarget === e.target) { setIsDragging(false); } }; const handleDrop = async (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragging(false); if (!currentSessionId && !newSessionDraftOpen) return; const files = Array.from(e.dataTransfer.files); let attachedCount = 0; for (const file of files) { const sizeBefore = useSessionStore.getState().attachedFiles.length; try { await addAttachedFile(file); const sizeAfter = useSessionStore.getState().attachedFiles.length; if (sizeAfter > sizeBefore) { attachedCount += 1; } } catch (error) { console.error('File attach failed', error); toast.error(error instanceof Error ? error.message : 'Failed to attach file'); } } if (attachedCount > 0) { toast.success(`Attached ${attachedCount} file${attachedCount > 1 ? 's' : ''}`); } }; const handleServerFilesSelected = React.useCallback(async (files: Array<{ path: string; name: string }>) => { let attachedCount = 0; for (const file of files) { const sizeBefore = useSessionStore.getState().attachedFiles.length; try { await addServerFile(file.path, file.name); const sizeAfter = useSessionStore.getState().attachedFiles.length; if (sizeAfter > sizeBefore) { attachedCount += 1; } } catch (error) { console.error('Server file attach failed', error); toast.error(error instanceof Error ? error.message : 'Failed to attach file'); } } if (attachedCount > 0) { toast.success(`Attached ${attachedCount} file${attachedCount > 1 ? 's' : ''}`); } }, [addServerFile]); const fileInputRef = React.useRef(null); const [projectFilePickerOpen, setProjectFilePickerOpen] = React.useState(false); const attachFiles = React.useCallback(async (files: FileList | File[]) => { let attachedCount = 0; const list = Array.isArray(files) ? files : Array.from(files); for (const file of list) { const sizeBefore = useSessionStore.getState().attachedFiles.length; try { await addAttachedFile(file); const sizeAfter = useSessionStore.getState().attachedFiles.length; if (sizeAfter > sizeBefore) { attachedCount += 1; } } catch (error) { console.error('File attach failed', error); toast.error(error instanceof Error ? error.message : 'Failed to attach file'); } } if (attachedCount > 0) { toast.success(`Attached ${attachedCount} file${attachedCount > 1 ? 's' : ''}`); } }, [addAttachedFile]); const handleVSCodePickFiles = React.useCallback(async () => { try { const response = await fetch('/api/vscode/pick-files'); const data = await response.json(); const picked = Array.isArray(data?.files) ? data.files : []; const skipped = Array.isArray(data?.skipped) ? data.skipped : []; if (skipped.length > 0) { const summary = skipped .map((s: { name?: string; reason?: string }) => `${s?.name || 'file'}: ${s?.reason || 'skipped'}`) .join('\n'); toast.error(`Some files were skipped:\n${summary}`); } const asFiles = picked .map((file: { name: string; mimeType?: string; dataUrl?: string }) => { if (!file?.dataUrl) return null; try { const [meta, base64] = file.dataUrl.split(','); const mime = file.mimeType || (meta?.match(/data:(.*);base64/)?.[1] || 'application/octet-stream'); if (!base64) return null; const binary = atob(base64); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) { bytes[i] = binary.charCodeAt(i); } const blob = new Blob([bytes], { type: mime }); return new File([blob], file.name || 'file', { type: mime }); } catch (err) { console.error('Failed to decode VS Code picked file', err); return null; } }) .filter(Boolean) as File[]; if (asFiles.length > 0) { await attachFiles(asFiles); } } catch (error) { console.error('VS Code file pick failed', error); toast.error(error instanceof Error ? error.message : 'Failed to pick files in VS Code'); } }, [attachFiles]); const handlePickLocalFiles = React.useCallback(() => { if (isVSCodeRuntime()) { void handleVSCodePickFiles(); return; } fileInputRef.current?.click(); }, [handleVSCodePickFiles]); const handleLocalFileSelect = React.useCallback(async (event: React.ChangeEvent) => { const files = event.target.files; if (!files) return; await attachFiles(files); event.target.value = ''; }, [attachFiles]); const footerGapClass = isMobile ? 'gap-x-0.5 gap-y-0' : 'gap-x-1.5 gap-y-0'; const isVSCode = isVSCodeRuntime(); const footerPaddingClass = isMobile ? 'px-1.5 py-1.5' : (isVSCode ? 'px-1.5 py-1' : 'px-2.5 py-1.5'); const footerHeightClass = isMobile ? 'h-9 w-9' : (isVSCode ? 'h-[22px] w-[22px]' : 'h-7 w-7'); const iconSizeClass = isMobile ? 'h-5 w-5' : (isVSCode ? 'h-4 w-4' : 'h-[18px] w-[18px]'); const iconButtonBaseClass = cn( footerHeightClass, 'flex items-center justify-center text-muted-foreground transition-none outline-none focus:outline-none flex-shrink-0' ); const cancelButton = canAbort ? ( ) : null; const sendButton = ( ); const attachmentMenu = ( <>
{isMobile ? null : ( { requestAnimationFrame(() => handlePickLocalFiles()); }} > Attach files { requestAnimationFrame(() => { setProjectFilePickerOpen(true); }); }} > Attach from project
); const settingsButton = onOpenSettings ? ( ) : null; const attachmentsControls = ( <> {attachmentMenu} {settingsButton} ); const workingStatusText = working.statusText; React.useEffect(() => { const pendingAbortBanner = Boolean(working.wasAborted); if (!prevWasAbortedRef.current && pendingAbortBanner && !showAbortStatus) { startAbortIndicator(); if (currentSessionId) { acknowledgeSessionAbort(currentSessionId); } } prevWasAbortedRef.current = pendingAbortBanner; }, [ acknowledgeSessionAbort, currentSessionId, showAbortStatus, startAbortIndicator, working.wasAborted, ]); React.useEffect(() => { return () => { if (abortTimeoutRef.current) { clearTimeout(abortTimeoutRef.current); abortTimeoutRef.current = null; } }; }, []); return (
0 && !isKeyboardOpen ? { marginBottom: `${inputBarOffset}px` } : undefined} > {/* Absolute positioned above input - no layout shift */}
{isDragging && (

Drop files here to attach

)} { setMessage(content); setTimeout(() => { textareaRef.current?.focus(); }, 0); }} />
{} {showCommandAutocomplete && ( setShowCommandAutocomplete(false)} /> )} {} {showAgentAutocomplete && ( setShowAgentAutocomplete(false)} /> )} {showSkillAutocomplete && ( setShowSkillAutocomplete(false)} /> )} {showFileMention && ( setShowFileMention(false)} /> )}