import React from 'react'; import { Textarea } from '@/components/ui/textarea'; import { RiAddCircleLine, RiAiAgentLine, RiAttachment2, RiCloseLine, RiCommandLine, RiExternalLinkLine, RiFullscreenLine, RiGitPullRequestLine, RiGithubLine, RiSendPlane2Line, } from '@remixicon/react'; import { BrowserVoiceButton } from '@/components/voice'; 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 } from '@/stores/types/sessionTypes'; import { useInlineCommentDraftStore, type InlineCommentDraft } from '@/stores/useInlineCommentDraftStore'; import { appendInlineComments } from '@/lib/messages/inlineComments'; import { AttachedFilesList } from './FileAttachment'; import { QueuedMessageChips } from './QueuedMessageChips'; import { FileMentionAutocomplete, type FileMentionHandle } from './FileMentionAutocomplete'; import { CommandAutocomplete, type CommandAutocompleteHandle } from './CommandAutocomplete'; import { SkillAutocomplete, type SkillAutocompleteHandle } from './SkillAutocomplete'; import { cn, isMacOS } from '@/lib/utils'; import { ModelControls } from './ModelControls'; import { UnifiedControlsDrawer } from './UnifiedControlsDrawer'; import { parseAgentMentions } from '@/lib/messages/agentMentions'; import { StatusRow } from './StatusRow'; import { MobileAgentButton } from './MobileAgentButton'; import { MobileModelButton } from './MobileModelButton'; import { MobileSessionStatusBar } from './MobileSessionStatusBar'; import { useAssistantStatus } from '@/hooks/useAssistantStatus'; import { useCurrentSessionActivity } from '@/hooks/useSessionActivity'; import { toast } from '@/components/ui'; import { useFileStore } from '@/stores/fileStore'; import { useMessageStore } from '@/stores/messageStore'; import { isTauriShell, isVSCodeRuntime } from '@/lib/desktop'; import { isIMECompositionEvent } from '@/lib/ime'; import { StopIcon } from '@/components/icons/StopIcon'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import type { MobileControlsPanel } from './mobileControlsUtils'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { useThemeSystem } from '@/contexts/useThemeSystem'; import { GitHubIssuePickerDialog } from '@/components/session/GitHubIssuePickerDialog'; import { GitHubPrPickerDialog } from '@/components/session/GitHubPrPickerDialog'; import { useChatSearchDirectory } from '@/hooks/useChatSearchDirectory'; import { opencodeClient } from '@/lib/opencode/client'; const MAX_VISIBLE_TEXTAREA_LINES = 8; const EMPTY_QUEUE: QueuedMessage[] = []; const FILE_MENTION_TOKEN = /^@[^\s]+$/; interface ChatInputProps { onOpenSettings?: () => void; scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void; } type AutocompleteOverlayPosition = { top: number; left: number; place: 'above' | 'below'; maxHeight: number; }; // Per-session draft key — preserves in-progress messages across project switches const getDraftKey = (sessionId: string | null): string => `openchamber_chat_input_draft_${sessionId ?? 'new'}`; // Helper to safely read from localStorage for a given session const getStoredDraft = (sessionId: string | null): string => { try { return localStorage.getItem(getDraftKey(sessionId)) ?? ''; } catch { return ''; } }; // Helper to safely write/clear a per-session draft const saveStoredDraft = (sessionId: string | null, draft: string): void => { try { if (draft) { localStorage.setItem(getDraftKey(sessionId), draft); } else { localStorage.removeItem(getDraftKey(sessionId)); } } catch { // Ignore localStorage errors } }; export const ChatInput: React.FC = ({ onOpenSettings, scrollToBottom }) => { // Track if we restored a draft on mount (for text selection) const initialDraftRef = React.useRef(null); // Track initial session ID (captured at mount time for draft restoration) const initialSessionIdRef = React.useRef(null); const [message, setMessage] = React.useState(() => { // Read per-session draft at mount time using the current session from the store const sessionId = useSessionStore.getState().currentSessionId; initialSessionIdRef.current = sessionId; const draft = getStoredDraft(sessionId); if (draft) { initialDraftRef.current = draft; } return draft; }); const [inputMode, setInputMode] = React.useState<'normal' | 'shell'>('normal'); 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 [autocompleteTab, setAutocompleteTab] = React.useState<'commands' | 'agents' | 'files'>('commands'); const [showSkillAutocomplete, setShowSkillAutocomplete] = React.useState(false); const [skillQuery, setSkillQuery] = React.useState(''); const [textareaSize, setTextareaSize] = React.useState<{ height: number; maxHeight: number } | null>(null); const [mobileControlsOpen, setMobileControlsOpen] = React.useState(false); const [mobileControlsPanel, setMobileControlsPanel] = React.useState(null); // Message history navigation state (up/down arrow to recall previous messages) const [historyIndex, setHistoryIndex] = React.useState(-1); // -1 = not browsing, 0+ = index from most recent const [draftMessage, setDraftMessage] = React.useState(''); // Preserves input when entering history mode const textareaRef = React.useRef(null); const dropZoneRef = React.useRef(null); const canAcceptDropRef = React.useRef(false); const nativeDragInsideDropZoneRef = React.useRef(false); const mentionRef = React.useRef(null); const commandRef = React.useRef(null); const skillRef = React.useRef(null); // Ref to track current message value without triggering re-renders in effects const messageRef = React.useRef(message); 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 attachedFiles = useSessionStore((state) => state.attachedFiles); const addAttachedFile = useSessionStore((state) => state.addAttachedFile); 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 consumePendingSyntheticParts = useSessionStore((state) => state.consumePendingSyntheticParts); const { currentProviderId, currentModelId, currentVariant, currentAgentName, setAgent, getVisibleAgents } = useConfigStore(); const agents = getVisibleAgents(); const primaryAgents = React.useMemo(() => agents.filter((agent) => agent.mode === 'primary'), [agents]); const { isMobile, inputBarOffset, isKeyboardOpen, setTimelineDialogOpen, cornerRadius, persistChatDraft, isExpandedInput, setExpandedInput } = useUIStore(); const { working } = useAssistantStatus(); const { currentTheme } = useThemeSystem(); const chatSearchDirectory = useChatSearchDirectory(); const [showAbortStatus, setShowAbortStatus] = React.useState(false); const [textareaScrollTop, setTextareaScrollTop] = React.useState(0); const isDesktopExpanded = isExpandedInput && !isMobile; const sendableAttachedFiles = React.useMemo( () => attachedFiles.filter((file) => file.source !== 'server'), [attachedFiles], ); const hasInlineMentionForHighlight = React.useMemo(() => { if (!message || !message.includes('@') || inputMode === 'shell') { return false; } const knownAgentNames = new Set(agents.map((agent) => agent.name.toLowerCase())); const mentionRegex = /@([^\s]+)/g; let match: RegExpExecArray | null; while ((match = mentionRegex.exec(message)) !== null) { const offset = match.index; const charBefore = offset > 0 ? message[offset - 1] : null; if (charBefore && !/(\s|\(|\)|\[|\]|\{|\}|"|'|`|,|\.|;|:)/.test(charBefore)) { continue; } const mentionPath = String(match[1] || '').trim().replace(/[),.;:!?`"'>]+$/g, ''); if (!mentionPath) { continue; } if (knownAgentNames.has(mentionPath.toLowerCase())) { return true; } if (mentionPath.includes('/') || mentionPath.includes('\\') || mentionPath.includes('.')) { return true; } } return false; }, [agents, inputMode, message]); const highlightedComposerContent = React.useMemo(() => { if (!hasInlineMentionForHighlight) { return null; } const parts: Array<{ text: string; mentionKind: 'none' | 'file' | 'agent' }> = []; const knownAgentNames = new Set(agents.map((agent) => agent.name.toLowerCase())); const mentionRegex = /@([^\s]+)/g; let lastIndex = 0; let match: RegExpExecArray | null; while ((match = mentionRegex.exec(message)) !== null) { const full = match[0]; const mention = String(match[1] || '').trim().replace(/[),.;:!?`"'>]+$/g, ''); const start = match.index; const end = start + full.length; const charBefore = start > 0 ? message[start - 1] : null; const isBoundary = !charBefore || /(\s|\(|\)|\[|\]|\{|\}|"|'|`|,|\.|;|:)/.test(charBefore); const isAgentMention = isBoundary && mention.length > 0 && knownAgentNames.has(mention.toLowerCase()); const isFileMention = isBoundary && mention.length > 0 && !knownAgentNames.has(mention.toLowerCase()) && (mention.includes('/') || mention.includes('\\') || mention.includes('.')); if (start > lastIndex) { parts.push({ text: message.slice(lastIndex, start), mentionKind: 'none' }); } parts.push({ text: full, mentionKind: isFileMention ? 'file' : isAgentMention ? 'agent' : 'none', }); lastIndex = end; } if (lastIndex < message.length) { parts.push({ text: message.slice(lastIndex), mentionKind: 'none' }); } return parts; }, [agents, hasInlineMentionForHighlight, message]); const sanitizeAttachmentsForSend = React.useCallback( (files: AttachedFile[] | undefined): AttachedFile[] => (files ?? []) .filter((file) => file.source !== 'server') .map((file) => ({ ...file })), [], ); const extractInlineFileMentions = React.useCallback((rawText: string): { sanitizedText: string; attachments: AttachedFile[] } => { if (!rawText || !rawText.includes('@')) { return { sanitizedText: rawText, attachments: [] }; } const clientDirectory = opencodeClient.getDirectory() || ''; const root = (chatSearchDirectory || clientDirectory).replace(/\\/g, '/').replace(/\/+$/, ''); const knownAgentNames = new Set(agents.map((agent) => agent.name.toLowerCase())); const seenPaths = new Set(); const attachments: AttachedFile[] = []; const mentionRegex = /@([^\s]+)/g; let match: RegExpExecArray | null; while ((match = mentionRegex.exec(rawText)) !== null) { const rawMentionPath = match[1]; const offset = match.index; const original = rawText; const charBefore = offset > 0 ? original[offset - 1] : null; if (charBefore && !/(\s|\(|\)|\[|\]|\{|\}|"|'|`|,|\.|;|:)/.test(charBefore)) { continue; } const mentionPath = String(rawMentionPath || '') .trim() .replace(/^[`"'<(]+/, '') .replace(/[),.;:!?`"'>]+$/g, ''); if (!mentionPath) { continue; } if (knownAgentNames.has(mentionPath.toLowerCase())) { continue; } const looksLikeFilePath = mentionPath.includes('/') || mentionPath.includes('\\') || mentionPath.includes('.'); if (!looksLikeFilePath) { continue; } const normalizedMentionPath = mentionPath.replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\/+/, ''); if (!normalizedMentionPath) { continue; } const serverPath = mentionPath.startsWith('/') ? mentionPath.replace(/\\/g, '/') : root ? `${root}/${normalizedMentionPath}` : null; if (!serverPath) { continue; } const normalizedServerPath = serverPath.replace(/\/+/g, '/'); if (seenPaths.has(normalizedServerPath)) { continue; } seenPaths.add(normalizedServerPath); const filename = normalizedMentionPath.split('/').filter(Boolean).pop() || normalizedMentionPath; attachments.push({ id: `inline-server-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, file: new File([], filename, { type: 'text/plain' }), filename, mimeType: 'text/plain', size: 0, dataUrl: normalizedServerPath, source: 'server', serverPath: normalizedServerPath, }); } return { sanitizedText: rawText, attachments, }; }, [agents, chatSearchDirectory]); const [autocompleteOverlayPosition, setAutocompleteOverlayPosition] = React.useState(null); const abortTimeoutRef = React.useRef | null>(null); const prevWasAbortedRef = React.useRef(false); // Issue linking state const [issuePickerOpen, setIssuePickerOpen] = React.useState(false); const [prPickerOpen, setPrPickerOpen] = React.useState(false); const [linkedIssue, setLinkedIssue] = React.useState<{ number: number; title: string; url: string; contextText: string; author?: { login: string; avatarUrl?: string }; } | null>(null); const [linkedPr, setLinkedPr] = React.useState<{ number: number; title: string; url: string; head: string; base: string; includeDiff: boolean; instructionsText: string; contextText: string; author?: { login: string; avatarUrl?: string }; } | null>(null); // 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); // Inline comment drafts const draftCount = useInlineCommentDraftStore( React.useCallback( (state) => { const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : ''); if (!sessionKey) return 0; return (state.drafts[sessionKey] ?? []).length; }, [currentSessionId, newSessionDraftOpen] ) ); const consumeDrafts = useInlineCommentDraftStore((state) => state.consumeDrafts); const hasDrafts = draftCount > 0; // User message history for up/down arrow navigation // Get raw messages from store (stable reference) const sessionMessages = useMessageStore( React.useCallback( (state) => (currentSessionId ? state.messages.get(currentSessionId) : undefined), [currentSessionId] ) ); // Derive user message history with useMemo to avoid infinite re-renders const userMessageHistory = React.useMemo(() => { if (!sessionMessages) return []; return sessionMessages .filter((m) => m.info.role === 'user') .map((m) => { const textPart = m.parts.find((p) => p.type === 'text'); if (textPart && 'text' in textPart) { return String(textPart.text); } return ''; }) .filter((text) => text.length > 0) .reverse(); // Most recent first }, [sessionMessages]); // Keep messageRef in sync with message state React.useEffect(() => { messageRef.current = message; }, [message]); // Handle initial draft restoration and text selection const hasHandledInitialDraftRef = React.useRef(false); React.useEffect(() => { if (hasHandledInitialDraftRef.current) return; hasHandledInitialDraftRef.current = true; const draft = initialDraftRef.current; if (!draft) return; if (!persistChatDraft) { // Setting disabled - clear the restored draft setMessage(''); try { localStorage.removeItem(getDraftKey(initialSessionIdRef.current)); } catch { // Ignore } } else { // Setting enabled - select all text requestAnimationFrame(() => { textareaRef.current?.select(); }); } }, [persistChatDraft]); // Handle session switching: save draft for old session, restore draft for new session const prevSessionIdRef = React.useRef(currentSessionId); React.useEffect(() => { if (prevSessionIdRef.current !== currentSessionId) { const oldSessionId = prevSessionIdRef.current; prevSessionIdRef.current = currentSessionId; setInputMode('normal'); if (persistChatDraft) { // Save current draft for the session we're leaving saveStoredDraft(oldSessionId, messageRef.current); // Restore draft for the session we're entering const newDraft = getStoredDraft(currentSessionId); setMessage(newDraft); if (newDraft) { requestAnimationFrame(() => { textareaRef.current?.select(); }); } } else { // Persist disabled: clear input without saving setMessage(''); } } }, [currentSessionId, persistChatDraft]); // Focus textarea when new session draft is opened const prevNewSessionDraftOpenRef = React.useRef(newSessionDraftOpen); React.useEffect(() => { if (!prevNewSessionDraftOpenRef.current && newSessionDraftOpen) { // New session draft just opened - focus the textarea requestAnimationFrame(() => { if (isMobile) { // On mobile, use preventScroll to avoid viewport jumping textareaRef.current?.focus({ preventScroll: true }); } else { textareaRef.current?.focus(); } }); } prevNewSessionDraftOpenRef.current = newSessionDraftOpen; }, [newSessionDraftOpen, isMobile]); // Persist chat input draft to localStorage per session (only if setting enabled) React.useEffect(() => { if (!persistChatDraft) { // Clear stored draft for current session when setting is disabled try { localStorage.removeItem(getDraftKey(currentSessionId)); } catch { // Ignore } return; } saveStoredDraft(currentSessionId, message); }, [message, persistChatDraft, currentSessionId]); // Session activity for queue availability and controls const { phase: sessionPhase } = useCurrentSessionActivity(); 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]); const handleOpenMobileControls = React.useCallback(() => { if (!isMobile) { return; } if (mobileControlsOpen) { setMobileControlsOpen(false); return; } setMobileControlsPanel(null); if (isKeyboardOpen) { textareaRef.current?.blur(); requestAnimationFrame(() => { setMobileControlsOpen(true); }); return; } setMobileControlsOpen(true); }, [isMobile, isKeyboardOpen, mobileControlsOpen]); const handleCloseMobileControls = React.useCallback(() => { setMobileControlsOpen(false); }, []); const handleOpenMobilePanel = React.useCallback((panel: MobileControlsPanel) => { if (!isMobile) { return; } setMobileControlsOpen(false); textareaRef.current?.blur(); requestAnimationFrame(() => { setMobileControlsPanel(panel); }); }, [isMobile]); const handleReturnToUnifiedControls = React.useCallback(() => { if (!isMobile) { return; } setMobileControlsPanel(null); requestAnimationFrame(() => { setMobileControlsOpen(true); }); }, [isMobile]); // Consume pending input text (e.g., from revert action) React.useEffect(() => { if (pendingInputText !== null) { const pending = consumePendingInputText(); if (pending?.text) { if (pending.mode === 'append') { setMessage((prev) => { const next = pending.text.trim(); if (!next) return prev; const base = prev.trimEnd(); if (!base.trim()) return next; return `${base} ${next}`; }); } else { setMessage(pending.text); } // Focus textarea after setting message setTimeout(() => { textareaRef.current?.focus(); }, 0); } } }, [pendingInputText, consumePendingInputText]); const hasContent = message.trim() || sendableAttachedFiles.length > 0 || hasDrafts; const hasQueuedMessages = queuedMessages.length > 0; const canSend = hasContent || hasQueuedMessages; const canAbort = working.isWorking; // Keep a ref to handleSubmit so callbacks don't depend on it. type SubmitOptions = { queuedOnly?: boolean; }; const handleSubmitRef = React.useRef<(options?: SubmitOptions) => Promise>(async () => {}); // Add message to queue instead of sending const handleQueueMessage = React.useCallback(() => { if (!hasContent || !currentSessionId) return; const drafts = consumeDrafts(currentSessionId); let messageToQueue = message.replace(/^\n+|\n+$/g, ''); if (drafts.length > 0) { messageToQueue = appendInlineComments(messageToQueue, drafts); } const attachmentsToQueue = sanitizeAttachmentsForSend(sendableAttachedFiles); 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, sendableAttachedFiles, sanitizeAttachmentsForSend, addToQueue, clearAttachedFiles, isMobile, consumeDrafts]); const handleSubmit = async (options?: SubmitOptions) => { const queuedOnly = options?.queuedOnly ?? false; if (queuedOnly) { if (!hasQueuedMessages || !currentSessionId) return; } else 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[]; synthetic?: boolean }> = []; // Consume any pending synthetic parts (from conflict resolution, etc.) const syntheticParts = consumePendingSyntheticParts(); // Process queued messages first for (let i = 0; i < queuedMessages.length; i++) { const queuedMsg = queuedMessages[i]; const { sanitizedText, mention } = parseAgentMentions(queuedMsg.content, agents); const { sanitizedText: queuedText, attachments: mentionAttachments } = extractInlineFileMentions(sanitizedText); // 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 = queuedText; primaryAttachments = [ ...sanitizeAttachmentsForSend(queuedMsg.attachments), ...mentionAttachments, ]; } else { // Subsequent queued messages become additional parts const queuedAttachments = sanitizeAttachmentsForSend(queuedMsg.attachments); additionalParts.push({ text: queuedText, attachments: [...queuedAttachments, ...mentionAttachments], }); } } // Add current input (skip for queued-only auto-send) if (!queuedOnly && hasContent) { const messageToSend = message.replace(/^\n+|\n+$/g, ''); const { sanitizedText, mention } = parseAgentMentions(messageToSend, agents); const { sanitizedText: messageText, attachments: mentionAttachments } = extractInlineFileMentions(sanitizedText); const attachmentsToSend = sanitizeAttachmentsForSend(sendableAttachedFiles); if (!agentMentionName && mention?.name) { agentMentionName = mention.name; } if (queuedMessages.length === 0) { // No queue - current input is primary primaryText = messageText; primaryAttachments = [...attachmentsToSend, ...mentionAttachments]; } else { // Has queue - current input is additional part additionalParts.push({ text: messageText, attachments: [...attachmentsToSend, ...mentionAttachments], }); } } const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : null); let drafts: InlineCommentDraft[] = []; if (!queuedOnly && sessionKey) { drafts = consumeDrafts(sessionKey); } if (drafts.length > 0) { if (queuedMessages.length === 0) { primaryText = appendInlineComments(primaryText, drafts); } else if (additionalParts.length > 0) { const lastPart = additionalParts[additionalParts.length - 1]; lastPart.text = appendInlineComments(lastPart.text, drafts); } else { primaryText = appendInlineComments(primaryText, drafts); } } // Add synthetic parts (from conflict resolution, etc.) if (syntheticParts && syntheticParts.length > 0) { for (const part of syntheticParts) { additionalParts.push({ text: part.text, synthetic: true, }); } } // Add linked issue as synthetic part (only the parts with synthetic: true) // The text part (synthetic: false) is completely dropped per requirements if (linkedIssue) { additionalParts.push({ text: linkedIssue.contextText, synthetic: true, }); } if (linkedPr) { additionalParts.push({ text: linkedPr.instructionsText, synthetic: true, }); additionalParts.push({ text: linkedPr.contextText, synthetic: true, }); } if (!primaryText && additionalParts.length === 0) return; // Clear queue and input if (currentSessionId && hasQueuedMessages) { clearQueue(currentSessionId); } if (!queuedOnly) { setMessage(''); // Clear per-session draft on submit saveStoredDraft(currentSessionId, ''); // Reset message history navigation state setHistoryIndex(-1); setDraftMessage(''); if (attachedFiles.length > 0) { clearAttachedFiles(); } // Close expanded input overlay when submitting setExpandedInput(false); } if (isMobile) { textareaRef.current?.blur(); } // Handle local slash commands only in normal mode const normalizedCommand = primaryText.trimStart(); if (inputMode === 'normal' && 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 } } // 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, inputMode ).then(() => { // Clear linked issue after successful message send if (linkedIssue) { setLinkedIssue(null); } if (linkedPr) { setLinkedPr(null); } }).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) { if (allAttachments.length > 0) { useFileStore.setState({ attachedFiles: allAttachments }); toast.error('Failed to send attachments. Try fewer files or smaller images.'); } return; } if (allAttachments.length > 0) { useFileStore.setState({ attachedFiles: allAttachments }); } toast.error(rawMessage || 'Message failed to send. Attachments restored.'); }); if (!isMobile) { textareaRef.current?.focus(); } }; // Update ref with latest handleSubmit on every render handleSubmitRef.current = handleSubmit; // Primary action for send button - respects queue mode setting const handlePrimaryAction = React.useCallback(() => { const canQueue = inputMode === 'normal' && hasContent && currentSessionId && sessionPhase !== 'idle'; if (queueModeEnabled && canQueue) { handleQueueMessage(); } else { void handleSubmitRef.current(); } }, [inputMode, hasContent, currentSessionId, sessionPhase, queueModeEnabled, handleQueueMessage]); 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 (inputMode === 'shell' && e.key === 'Escape') { e.preventDefault(); setInputMode('normal'); return; } if (inputMode === 'shell' && e.key === 'Backspace' && message.length === 0) { e.preventDefault(); setInputMode('normal'); return; } if ((e.key === 'Backspace' || e.key === 'Delete') && !e.metaKey && !e.ctrlKey && !e.altKey) { const textarea = textareaRef.current; const selectionStart = textarea?.selectionStart ?? message.length; const selectionEnd = textarea?.selectionEnd ?? message.length; const hasCollapsedSelection = selectionStart === selectionEnd; if (hasCollapsedSelection) { const probeIndex = e.key === 'Backspace' ? selectionStart - 1 : selectionStart; if (probeIndex >= 0 && probeIndex < message.length) { let tokenStart = probeIndex; while (tokenStart > 0 && !/\s/.test(message[tokenStart - 1])) { tokenStart -= 1; } let tokenEnd = probeIndex + 1; while (tokenEnd < message.length && !/\s/.test(message[tokenEnd])) { tokenEnd += 1; } const token = message.slice(tokenStart, tokenEnd); const looksLikeFileMention = FILE_MENTION_TOKEN.test(token) && (token.includes('/') || token.includes('\\') || token.includes('.')); if (looksLikeFileMention) { const removeUntil = message[tokenEnd] === ' ' ? tokenEnd + 1 : tokenEnd; const nextMessage = `${message.slice(0, tokenStart)}${message.slice(removeUntil)}`; e.preventDefault(); setMessage(nextMessage); requestAnimationFrame(() => { if (textareaRef.current) { textareaRef.current.selectionStart = tokenStart; textareaRef.current.selectionEnd = tokenStart; } adjustTextareaHeight(); }); updateAutocompleteState(nextMessage, tokenStart); 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 (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 (isDesktopExpanded && e.key === 'Escape') { e.preventDefault(); setExpandedInput(false); return; } if (e.key === 'Tab' && !showCommandAutocomplete && !showFileMention) { e.preventDefault(); handleCycleAgent(); return; } // Handle ArrowUp/ArrowDown for message history navigation // ArrowUp: only when cursor at start (position 0) or input is empty // ArrowDown: also works when cursor at end (to cycle forward through history) const isAnyAutocompleteOpen = showCommandAutocomplete || showSkillAutocomplete || showFileMention; const cursorAtStart = textareaRef.current?.selectionStart === 0 && textareaRef.current?.selectionEnd === 0; const cursorAtEnd = textareaRef.current?.selectionStart === message.length && textareaRef.current?.selectionEnd === message.length; const canNavigateHistoryUp = !isAnyAutocompleteOpen && (message.length === 0 || cursorAtStart); const canNavigateHistoryDown = !isAnyAutocompleteOpen && (message.length === 0 || cursorAtEnd); if (e.key === 'ArrowUp' && canNavigateHistoryUp && userMessageHistory.length > 0) { e.preventDefault(); if (historyIndex === -1) { // Entering history mode - save current input as draft setDraftMessage(message); setHistoryIndex(0); setMessage(userMessageHistory[0]); } else if (historyIndex < userMessageHistory.length - 1) { // Navigate to older message const newIndex = historyIndex + 1; setHistoryIndex(newIndex); setMessage(userMessageHistory[newIndex]); } // Move cursor to start after history navigation requestAnimationFrame(() => { textareaRef.current?.setSelectionRange(0, 0); }); // If at oldest message, do nothing return; } if (e.key === 'ArrowDown' && canNavigateHistoryDown && historyIndex >= 0) { e.preventDefault(); if (historyIndex === 0) { // Exit history mode - restore draft setHistoryIndex(-1); setMessage(draftMessage); setDraftMessage(''); } else { // Navigate to newer message const newIndex = historyIndex - 1; setHistoryIndex(newIndex); setMessage(userMessageHistory[newIndex]); } 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 = inputMode === 'normal' && 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 measureCaretInTextarea = React.useCallback((textarea: HTMLTextAreaElement, cursorPosition: number) => { const doc = textarea.ownerDocument; const win = doc.defaultView; if (!win) return null; const style = win.getComputedStyle(textarea); const mirror = doc.createElement('div'); const mirrorStyle = mirror.style; mirrorStyle.position = 'absolute'; mirrorStyle.visibility = 'hidden'; mirrorStyle.pointerEvents = 'none'; mirrorStyle.whiteSpace = 'pre-wrap'; mirrorStyle.wordWrap = 'break-word'; mirrorStyle.overflow = 'hidden'; mirrorStyle.left = '-9999px'; mirrorStyle.top = '0'; mirrorStyle.width = `${textarea.clientWidth}px`; mirrorStyle.font = style.font; mirrorStyle.fontSize = style.fontSize; mirrorStyle.fontFamily = style.fontFamily; mirrorStyle.fontWeight = style.fontWeight; mirrorStyle.fontStyle = style.fontStyle; mirrorStyle.fontVariant = style.fontVariant; mirrorStyle.letterSpacing = style.letterSpacing; mirrorStyle.textTransform = style.textTransform; mirrorStyle.textIndent = style.textIndent; mirrorStyle.padding = style.padding; mirrorStyle.border = style.border; mirrorStyle.boxSizing = style.boxSizing; mirrorStyle.lineHeight = style.lineHeight; mirrorStyle.tabSize = style.tabSize; mirror.textContent = textarea.value.slice(0, cursorPosition); const marker = doc.createElement('span'); marker.textContent = textarea.value.slice(cursorPosition, cursorPosition + 1) || ' '; mirror.appendChild(marker); doc.body.appendChild(mirror); const top = marker.offsetTop; const left = marker.offsetLeft; doc.body.removeChild(mirror); return { top, left }; }, []); const updateAutocompleteOverlayPosition = React.useCallback(() => { if (!isDesktopExpanded) { setAutocompleteOverlayPosition(null); return; } if (!showCommandAutocomplete && !showSkillAutocomplete && !showFileMention) { setAutocompleteOverlayPosition(null); return; } const textarea = textareaRef.current; const container = dropZoneRef.current; if (!textarea || !container) return; const cursor = textarea.selectionStart ?? message.length; const caret = measureCaretInTextarea(textarea, cursor); if (!caret) return; const textareaRect = textarea.getBoundingClientRect(); const containerRect = container.getBoundingClientRect(); const caretY = textareaRect.top - containerRect.top + (caret.top - textarea.scrollTop); const caretX = textareaRect.left - containerRect.left + (caret.left - textarea.scrollLeft); const popupMargin = 8; const estimatedPopupHeight = 260; const spaceAbove = caretY - popupMargin; const spaceBelow = containerRect.height - caretY - popupMargin; const place: 'above' | 'below' = spaceBelow >= estimatedPopupHeight || spaceBelow >= spaceAbove ? 'below' : 'above'; const desiredWidth = showFileMention ? 520 : showCommandAutocomplete ? 450 : 360; const clampedLeft = Math.max( popupMargin, Math.min(caretX - 24, containerRect.width - desiredWidth - popupMargin) ); const maxHeight = Math.max(120, Math.min(estimatedPopupHeight, place === 'below' ? spaceBelow : spaceAbove)); setAutocompleteOverlayPosition({ top: place === 'below' ? caretY + 22 : caretY - 6, left: clampedLeft, place, maxHeight, }); }, [ isDesktopExpanded, measureCaretInTextarea, message.length, showCommandAutocomplete, showFileMention, showSkillAutocomplete, ]); React.useLayoutEffect(() => { updateAutocompleteOverlayPosition(); }, [ updateAutocompleteOverlayPosition, message, showCommandAutocomplete, showSkillAutocomplete, showFileMention, isDesktopExpanded, ]); React.useEffect(() => { if (!isDesktopExpanded) return; const onResize = () => updateAutocompleteOverlayPosition(); window.addEventListener('resize', onResize); return () => { window.removeEventListener('resize', onResize); }; }, [isDesktopExpanded, updateAutocompleteOverlayPosition]); 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 handleCycleAgent = React.useCallback(() => { 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); } }, [primaryAgents, currentAgentName, currentSessionId, setAgent, saveSessionAgentSelection]); const adjustTextareaHeight = React.useCallback(() => { const textarea = textareaRef.current; if (!textarea) { return; } if (isDesktopExpanded) { textarea.style.height = '100%'; textarea.style.maxHeight = 'none'; setTextareaSize(null); 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 }; }); }, [isDesktopExpanded]); React.useLayoutEffect(() => { adjustTextareaHeight(); }, [adjustTextareaHeight, message, isMobile]); const updateAutocompleteState = React.useCallback((value: string, cursorPosition: number) => { if (inputMode === 'shell') { setShowCommandAutocomplete(false); setShowFileMention(false); setShowSkillAutocomplete(false); return; } 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); setAutocompleteTab('commands'); setShowCommandAutocomplete(true); setShowFileMention(false); setShowSkillAutocomplete(false); return; } } setShowCommandAutocomplete(false); const textBeforeCursor = value.substring(0, cursorPosition); 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); return; } } setShowSkillAutocomplete(false); setSkillQuery(''); const lastAtSymbol = textBeforeCursor.lastIndexOf('@'); if (lastAtSymbol !== -1) { const charBefore = lastAtSymbol > 0 ? textBeforeCursor[lastAtSymbol - 1] : null; const textAfterAt = textBeforeCursor.substring(lastAtSymbol + 1); const isWordBoundary = !charBefore || /\s/.test(charBefore); if (isWordBoundary && !textAfterAt.includes(' ') && !textAfterAt.includes('\n')) { setMentionQuery(textAfterAt); setAutocompleteTab('agents'); setShowFileMention(true); } else { setShowFileMention(false); } } else { setShowFileMention(false); } }, [inputMode, setAutocompleteTab, setCommandQuery, setMentionQuery, setShowCommandAutocomplete, setShowFileMention, setShowSkillAutocomplete, setSkillQuery]); const applyAutocompletePrefix = React.useCallback((prefix: '/' | '@') => { const nextMessage = message.length === 0 ? prefix : (message[0] === '/' || message[0] === '@') ? `${prefix}${message.slice(1)}` : `${prefix}${message}`; setMessage(nextMessage); requestAnimationFrame(() => { if (textareaRef.current) { const nextCursor = Math.min(nextMessage.length, textareaRef.current.value.length); textareaRef.current.selectionStart = nextCursor; textareaRef.current.selectionEnd = nextCursor; } adjustTextareaHeight(); updateAutocompleteState(nextMessage, nextMessage.length); }); }, [adjustTextareaHeight, message, setMessage, updateAutocompleteState]); const handleAutocompleteTabSelect = React.useCallback((tab: 'commands' | 'agents' | 'files') => { const textarea = textareaRef.current; if (isMobile && textarea) { try { textarea.focus({ preventScroll: true }); } catch { textarea.focus(); } const len = textarea.value.length; try { textarea.setSelectionRange(len, len); } catch { // ignored } } setAutocompleteTab(tab); setCommandQuery(''); setMentionQuery(''); if (tab === 'commands') { applyAutocompletePrefix('/'); } if (tab === 'agents') { applyAutocompletePrefix('@'); } if (tab === 'files') { applyAutocompletePrefix('@'); } setShowSkillAutocomplete(false); setShowCommandAutocomplete(tab === 'commands'); setShowFileMention(tab === 'agents' || tab === 'files'); }, [applyAutocompletePrefix, isMobile, setAutocompleteTab, setCommandQuery, setMentionQuery, setShowCommandAutocomplete, setShowFileMention, setShowSkillAutocomplete]); const handleOpenCommandMenu = React.useCallback(() => { if (!isMobile) { return; } const textarea = textareaRef.current; if (textarea) { try { textarea.focus({ preventScroll: true }); } catch { textarea.focus(); } const len = textarea.value.length; try { textarea.setSelectionRange(len, len); } catch { // ignored } } applyAutocompletePrefix('/'); setCommandQuery(''); setAutocompleteTab('commands'); setShowCommandAutocomplete(true); setShowFileMention(false); setShowSkillAutocomplete(false); }, [applyAutocompletePrefix, isMobile, setAutocompleteTab, setCommandQuery, setShowCommandAutocomplete, setShowFileMention, setShowSkillAutocomplete]); 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; if (inputMode === 'normal' && value.startsWith('!')) { const shellCommand = value.slice(1); const nextCursor = Math.max(0, cursorPosition - 1); setInputMode('shell'); setMessage(shellCommand); adjustTextareaHeight(); setShowCommandAutocomplete(false); setShowSkillAutocomplete(false); setShowFileMention(false); requestAnimationFrame(() => { if (textareaRef.current) { textareaRef.current.selectionStart = nextCursor; textareaRef.current.selectionEnd = nextCursor; } }); return; } 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; relativePath?: string }) => { const cursorPosition = textareaRef.current?.selectionStart || 0; const textBeforeCursor = message.substring(0, cursorPosition); const lastAtSymbol = textBeforeCursor.lastIndexOf('@'); const mentionPath = (file.relativePath && file.relativePath.trim().length > 0) ? file.relativePath.trim() : (toProjectRelativeMentionPath(file.path) || file.name); if (lastAtSymbol !== -1) { const newMessage = message.substring(0, lastAtSymbol) + `@${mentionPath} ` + message.substring(cursorPosition); setMessage(newMessage); const nextCursor = lastAtSymbol + mentionPath.length + 2; requestAnimationFrame(() => { if (textareaRef.current) { textareaRef.current.selectionStart = nextCursor; textareaRef.current.selectionEnd = nextCursor; } adjustTextareaHeight(); updateAutocompleteState(newMessage, nextCursor); }); } else if (textareaRef.current) { const newMessage = message.substring(0, cursorPosition) + `@${mentionPath} ` + message.substring(cursorPosition); setMessage(newMessage); const nextCursor = cursorPosition + mentionPath.length + 2; requestAnimationFrame(() => { if (textareaRef.current) { textareaRef.current.selectionStart = nextCursor; textareaRef.current.selectionEnd = nextCursor; } adjustTextareaHeight(); updateAutocompleteState(newMessage, nextCursor); }); } 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 lastAtSymbol = textBeforeCursor.lastIndexOf('@'); if (lastAtSymbol !== -1) { const newMessage = message.substring(0, lastAtSymbol) + `@${agentName} ` + message.substring(cursorPosition); setMessage(newMessage); const nextCursor = lastAtSymbol + agentName.length + 2; requestAnimationFrame(() => { if (textareaRef.current) { textareaRef.current.selectionStart = nextCursor; textareaRef.current.selectionEnd = nextCursor; } adjustTextareaHeight(); updateAutocompleteState(newMessage, nextCursor); }); } else if (textareaRef.current) { const newMessage = message.substring(0, cursorPosition) + `@${agentName} ` + message.substring(cursorPosition); setMessage(newMessage); const nextCursor = cursorPosition + agentName.length + 2; requestAnimationFrame(() => { if (textareaRef.current) { textareaRef.current.selectionStart = nextCursor; textareaRef.current.selectionEnd = nextCursor; } adjustTextareaHeight(); updateAutocompleteState(newMessage, nextCursor); }); } setShowFileMention(false); setMentionQuery(''); 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 + 2; 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(''); const refocus = () => { if (textareaRef.current) { try { textareaRef.current.focus({ preventScroll: true }); } catch { textareaRef.current.focus(); } textareaRef.current.setSelectionRange(textareaRef.current.value.length, textareaRef.current.value.length); } }; requestAnimationFrame(() => { refocus(); requestAnimationFrame(refocus); }); setTimeout(refocus, 60); }; React.useEffect(() => { if (currentSessionId && textareaRef.current && !isMobile) { textareaRef.current.focus(); } }, [currentSessionId, isMobile]); React.useEffect(() => { if (!isMobile) { setMobileControlsOpen(false); setMobileControlsPanel(null); } }, [isMobile]); React.useEffect(() => { if (abortPromptSessionId && abortPromptSessionId !== currentSessionId) { clearAbortPrompt(); } }, [abortPromptSessionId, currentSessionId, clearAbortPrompt]); React.useEffect(() => { canAcceptDropRef.current = Boolean(currentSessionId || newSessionDraftOpen); }, [currentSessionId, newSessionDraftOpen]); const hasDraggedFiles = React.useCallback((dataTransfer: DataTransfer | null | undefined): boolean => { if (!dataTransfer) return false; if (dataTransfer.files && dataTransfer.files.length > 0) return true; if (dataTransfer.types) { const types = Array.from(dataTransfer.types); if (types.includes('Files')) return true; if (types.includes('text/uri-list')) return true; } const uriList = dataTransfer.getData('text/uri-list') || dataTransfer.getData('text/plain'); return typeof uriList === 'string' && uriList.toLowerCase().includes('file://'); }, []); const collectDroppedFiles = React.useCallback((dataTransfer: DataTransfer | null | undefined): File[] => { if (!dataTransfer) return []; const directFiles = Array.from(dataTransfer.files || []); if (directFiles.length > 0) { return directFiles; } const fromItems = Array.from(dataTransfer.items || []) .filter((item) => item.kind === 'file') .map((item) => item.getAsFile()) .filter((file): file is File => Boolean(file)); return fromItems; }, []); const collectDroppedFileUris = React.useCallback((dataTransfer: DataTransfer | null | undefined): string[] => { if (!dataTransfer || typeof dataTransfer.getData !== 'function') return []; const rawUriList = dataTransfer.getData('text/uri-list') || dataTransfer.getData('text/plain'); if (!rawUriList) return []; const candidates = rawUriList .split(/\r?\n/) .map((value) => value.trim()) .filter((value) => value.length > 0 && !value.startsWith('#')) .filter((value) => value.toLowerCase().startsWith('file://')); return Array.from(new Set(candidates)); }, []); const attachVSCodeDroppedUris = React.useCallback(async (uris: string[]) => { if (uris.length === 0) return; try { const response = await fetch('/api/vscode/drop-files', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ uris }), }); if (!response.ok) { throw new Error(`Failed to attach dropped files (${response.status})`); } 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((entry: { name?: string; reason?: string }) => `${entry?.name || 'file'}: ${entry?.reason || 'skipped'}`) .join('\n'); toast.error(`Some dropped files were skipped:\n${summary}`); } let attachedCount = 0; for (const file of picked as Array<{ name: string; mimeType?: string; dataUrl?: string }>) { if (!file?.dataUrl) continue; const sizeBefore = useSessionStore.getState().attachedFiles.length; try { const [meta, base64] = file.dataUrl.split(','); const mime = file.mimeType || (meta?.match(/data:(.*);base64/)?.[1] || 'application/octet-stream'); if (!base64) continue; 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 }); const localFile = new File([blob], file.name || 'file', { type: mime }); await addAttachedFile(localFile); const sizeAfter = useSessionStore.getState().attachedFiles.length; if (sizeAfter > sizeBefore) { attachedCount += 1; } } catch (error) { console.error('Dropped file attach failed', error); toast.error(error instanceof Error ? error.message : 'Failed to attach dropped file'); } } if (attachedCount > 0) { toast.success(`Attached ${attachedCount} file${attachedCount > 1 ? 's' : ''}`); } } catch (error) { console.error('VS Code dropped file attach failed', error); toast.error(error instanceof Error ? error.message : 'Failed to attach dropped files'); } }, [addAttachedFile]); const normalizeDroppedPath = React.useCallback((rawPath: string): string => { const input = rawPath.trim(); if (!input.toLowerCase().startsWith('file://')) { return input; } try { let pathname = decodeURIComponent(new URL(input).pathname || ''); if (/^\/[A-Za-z]:\//.test(pathname)) { pathname = pathname.slice(1); } return pathname || input; } catch { const stripped = input.replace(/^file:\/\//i, ''); try { return decodeURIComponent(stripped); } catch { return stripped; } } }, []); const toProjectRelativeMentionPath = React.useCallback((absolutePath: string): string => { const normalizedAbsolutePath = absolutePath.replace(/\\/g, '/').trim(); const normalizedRoot = (chatSearchDirectory || '').replace(/\\/g, '/').replace(/\/+$/, ''); if (!normalizedRoot) { return normalizedAbsolutePath; } if (normalizedAbsolutePath === normalizedRoot) { return normalizedAbsolutePath; } const rootWithSlash = `${normalizedRoot}/`; if (normalizedAbsolutePath.startsWith(rootWithSlash)) { return normalizedAbsolutePath.slice(rootWithSlash.length); } return normalizedAbsolutePath; }, [chatSearchDirectory]); const handleDragEnter = (e: React.DragEvent) => { if (!hasDraggedFiles(e.dataTransfer)) { return; } e.preventDefault(); e.stopPropagation(); if ((currentSessionId || newSessionDraftOpen) && !isDragging) { setIsDragging(true); } }; const handleDragOver = (e: React.DragEvent) => { if (!hasDraggedFiles(e.dataTransfer)) { return; } e.preventDefault(); e.stopPropagation(); e.dataTransfer.dropEffect = 'copy'; 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) => { if (!hasDraggedFiles(e.dataTransfer)) { return; } e.preventDefault(); e.stopPropagation(); setIsDragging(false); if (!currentSessionId && !newSessionDraftOpen) return; const files = collectDroppedFiles(e.dataTransfer); if (files.length === 0 && isVSCodeRuntime()) { const droppedUris = collectDroppedFileUris(e.dataTransfer); if (droppedUris.length > 0) { await attachVSCodeDroppedUris(droppedUris); } return; } let attachedCount = 0; if (files.length > 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' : ''}`); } }; // Tauri desktop: handle native file drops via onDragDropEvent React.useEffect(() => { if (!isTauriShell()) return; let cancelled = false; let unlisten: (() => void) | null = null; void (async () => { try { const { getCurrentWebviewWindow } = await import('@tauri-apps/api/webviewWindow'); const webviewWindow = getCurrentWebviewWindow(); const removeListener = await webviewWindow.onDragDropEvent(async (event) => { if (!canAcceptDropRef.current) return; const payload = (event as { payload?: unknown }).payload; if (!payload || typeof payload !== 'object') return; const typed = payload as { type?: string; paths?: string[]; position?: { x?: number; y?: number } }; const type = typed.type; const x = typed.position?.x; const y = typed.position?.y; // Check if drop is inside the chat input area const zone = dropZoneRef.current; let inZone: boolean | null = null; if (zone && typeof x === 'number' && typeof y === 'number') { const rect = zone.getBoundingClientRect(); inZone = x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom; // Handle retina displays where Tauri might report physical pixels if (!inZone && window.devicePixelRatio > 1) { const sx = x / window.devicePixelRatio; const sy = y / window.devicePixelRatio; inZone = sx >= rect.left && sx <= rect.right && sy >= rect.top && sy <= rect.bottom; } } if (type === 'enter' || type === 'over') { if (inZone !== null) { nativeDragInsideDropZoneRef.current = inZone; } setIsDragging(nativeDragInsideDropZoneRef.current); return; } if (type === 'leave') { nativeDragInsideDropZoneRef.current = false; setIsDragging(false); return; } if (type === 'drop') { const shouldHandleDrop = inZone ?? nativeDragInsideDropZoneRef.current; nativeDragInsideDropZoneRef.current = false; setIsDragging(false); if (!shouldHandleDrop) return; const paths = Array.isArray(typed.paths) ? typed.paths.filter((p): p is string => typeof p === 'string') : []; if (paths.length === 0) return; let attachedCount = 0; for (const path of paths) { const sizeBefore = useSessionStore.getState().attachedFiles.length; try { const normalizedPath = normalizeDroppedPath(path); const fileName = normalizedPath.split(/[\\/]/).pop() || normalizedPath; let file: File; // In Tauri shell, dropped paths are local machine paths. // Read bytes via native command to avoid workspace-bound /api/fs/raw restrictions. if (isTauriShell()) { const { invoke } = await import('@tauri-apps/api/core'); const result = await invoke<{ mime: string; base64: string }>('desktop_read_file', { path: normalizedPath }); const byteCharacters = atob(result.base64); const byteNumbers = new Array(byteCharacters.length); for (let i = 0; i < byteCharacters.length; i++) { byteNumbers[i] = byteCharacters.charCodeAt(i); } const byteArray = new Uint8Array(byteNumbers); const blob = new Blob([byteArray], { type: result.mime || 'application/octet-stream' }); file = new File([blob], fileName, { type: result.mime || 'application/octet-stream' }); } else { const response = await fetch(`/api/fs/raw?path=${encodeURIComponent(normalizedPath)}`); if (!response.ok) { throw new Error(`Failed to read dropped file (${response.status})`); } const blob = await response.blob(); file = new File([blob], fileName, { type: blob.type || 'application/octet-stream' }); } await addAttachedFile(file); const sizeAfter = useSessionStore.getState().attachedFiles.length; if (sizeAfter > sizeBefore) attachedCount++; } catch (error) { console.error('Failed to attach dropped file:', path, error); toast.error(`Failed to attach ${path.split(/[\\/]/).pop() || 'file'}`); } } if (attachedCount > 0) { toast.success(`Attached ${attachedCount} file${attachedCount > 1 ? 's' : ''}`); } } }); if (cancelled) { removeListener(); return; } unlisten = removeListener; } catch (error) { if (!cancelled) { console.warn('Failed to register Tauri drag-drop listener:', error); } } })(); return () => { cancelled = true; if (unlisten) unlisten(); }; }, [addAttachedFile, normalizeDroppedPath]); const fileInputRef = React.useRef(null); 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 = '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 buttonSizeClass = isMobile ? 'h-8 w-8' : (isVSCode ? 'h-5 w-5' : 'h-6 w-6'); const sendIconSizeClass = isMobile ? 'h-4 w-4' : (isVSCode ? 'h-3.5 w-3.5' : 'h-4 w-4'); const stopIconSizeClass = isMobile ? 'h-6 w-6' : (isVSCode ? 'h-4 w-4' : 'h-5 w-5'); const iconSizeClass = isMobile ? 'h-[18px] w-[18px]' : (isVSCode ? 'h-4 w-4' : 'h-[18px] w-[18px]'); const iconButtonBaseClass = 'flex cursor-pointer items-center justify-center text-foreground transition-none outline-none focus:outline-none flex-shrink-0 disabled:cursor-not-allowed'; const footerIconButtonClass = cn(iconButtonBaseClass, buttonSizeClass); // Send button - respects queue mode setting const sendButton = ( ); // Queue button for adding message to queue while working const queueButton = ( ); // Stop button replaces send button when working const stopButton = ( ); // Action buttons area: either send button, or stop (+ optional queue button floating above) const actionButtons = canAbort ? (
{hasContent && queueButton} {stopButton}
) : ( sendButton ); const attachmentMenu = ( <>
{isVSCode ? ( ) : ( { requestAnimationFrame(() => handlePickLocalFiles()); }} > Attach files { requestAnimationFrame(() => { setIssuePickerOpen(true); }); }} > Link GitHub Issue { requestAnimationFrame(() => { setPrPickerOpen(true); }); }} > Link GitHub PR )}
); const settingsButton = onOpenSettings ? ( ) : null; const attachmentsControls = (
{isMobile ? ( ) : null} {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 ( <>
{ e.preventDefault(); handlePrimaryAction(); }} className={cn( "relative pt-0 pb-4", isDesktopExpanded && 'flex h-full min-h-0 flex-col pt-4', isMobile && isKeyboardOpen ? "ios-keyboard-safe-area" : "bottom-safe-area" )} data-keyboard-avoid="true" style={isMobile && inputBarOffset > 0 && !isKeyboardOpen ? { marginBottom: `${inputBarOffset}px` } : undefined} > {/* Absolute positioned above input - no layout shift */}
{ setMessage(content); setTimeout(() => { textareaRef.current?.focus(); }, 0); }} /> {hasDrafts && (
Review comments: {draftCount}
)} {/* Linked Issue row */} {linkedIssue && !isVSCode && (
)} {linkedPr && !isVSCode && (
)}
{isDragging && (

Drop files here to attach

)} {showCommandAutocomplete && ( setShowCommandAutocomplete(false)} style={isDesktopExpanded && autocompleteOverlayPosition ? { left: `${autocompleteOverlayPosition.left}px`, top: `${autocompleteOverlayPosition.top}px`, bottom: 'auto', width: `min(450px, calc(100% - ${autocompleteOverlayPosition.left + 8}px))`, maxHeight: `${autocompleteOverlayPosition.maxHeight}px`, transform: autocompleteOverlayPosition.place === 'above' ? 'translateY(-100%)' : undefined, } : undefined} /> )} { } {showSkillAutocomplete && ( setShowSkillAutocomplete(false)} style={isDesktopExpanded && autocompleteOverlayPosition ? { left: `${autocompleteOverlayPosition.left}px`, top: `${autocompleteOverlayPosition.top}px`, bottom: 'auto', width: `min(360px, calc(100% - ${autocompleteOverlayPosition.left + 8}px))`, maxHeight: `${autocompleteOverlayPosition.maxHeight}px`, transform: autocompleteOverlayPosition.place === 'above' ? 'translateY(-100%)' : undefined, } : undefined} /> )} {showFileMention && ( setShowFileMention(false)} style={isDesktopExpanded && autocompleteOverlayPosition ? { left: `${autocompleteOverlayPosition.left}px`, top: `${autocompleteOverlayPosition.top}px`, bottom: 'auto', width: `min(520px, calc(100% - ${autocompleteOverlayPosition.left + 8}px))`, maxHeight: `${autocompleteOverlayPosition.maxHeight}px`, transform: autocompleteOverlayPosition.place === 'above' ? 'translateY(-100%)' : undefined, } : undefined} /> )}
{highlightedComposerContent && (
{highlightedComposerContent.map((part, index) => ( {part.text} ))}
)}