import { useCallback, useEffect, useRef, useState } from 'react'; import type { ChangeEvent, ClipboardEvent, Dispatch, FormEvent, KeyboardEvent, MouseEvent, SetStateAction, TouchEvent, } from 'react'; import { useDropzone } from 'react-dropzone'; import { authenticatedFetch } from '../../../utils/api'; import { thinkingModes } from '../constants/thinkingModes'; import { grantClaudeToolPermission } from '../utils/chatPermissions'; import { safeLocalStorage } from '../utils/chatStorage'; import type { ChatMessage, PendingPermissionRequest, PermissionMode, } from '../types/types'; import { useFileMentions } from './useFileMentions'; import { type SlashCommand, useSlashCommands } from './useSlashCommands'; import type { Project, ProjectSession, SessionProvider } from '../../../types/app'; import { escapeRegExp } from '../utils/chatFormatting'; type PendingViewSession = { sessionId: string | null; startedAt: number; }; interface UseChatComposerStateArgs { selectedProject: Project | null; selectedSession: ProjectSession | null; currentSessionId: string | null; provider: SessionProvider; permissionMode: PermissionMode | string; cyclePermissionMode: () => void; cursorModel: string; claudeModel: string; codexModel: string; isLoading: boolean; canAbortSession: boolean; tokenBudget: Record | null; sendMessage: (message: unknown) => void; sendByCtrlEnter?: boolean; onSessionActive?: (sessionId?: string | null) => void; onInputFocusChange?: (focused: boolean) => void; onFileOpen?: (filePath: string, diffInfo?: unknown) => void; onShowSettings?: () => void; pendingViewSessionRef: { current: PendingViewSession | null }; scrollToBottom: () => void; setChatMessages: Dispatch>; setSessionMessages?: Dispatch>; setIsLoading: (loading: boolean) => void; setCanAbortSession: (canAbort: boolean) => void; setClaudeStatus: (status: { text: string; tokens: number; can_interrupt: boolean } | null) => void; setIsUserScrolledUp: (isScrolledUp: boolean) => void; setPendingPermissionRequests: Dispatch>; } interface MentionableFile { name: string; path: string; } interface CommandExecutionResult { type: 'builtin' | 'custom'; action?: string; data?: any; content?: string; hasBashCommands?: boolean; hasFileIncludes?: boolean; } const createFakeSubmitEvent = () => { return { preventDefault: () => undefined } as unknown as FormEvent; }; const isTemporarySessionId = (sessionId: string | null | undefined) => Boolean(sessionId && sessionId.startsWith('new-session-')); export function useChatComposerState({ selectedProject, selectedSession, currentSessionId, provider, permissionMode, cyclePermissionMode, cursorModel, claudeModel, codexModel, isLoading, canAbortSession, tokenBudget, sendMessage, sendByCtrlEnter, onSessionActive, onInputFocusChange, onFileOpen, onShowSettings, pendingViewSessionRef, scrollToBottom, setChatMessages, setSessionMessages, setIsLoading, setCanAbortSession, setClaudeStatus, setIsUserScrolledUp, setPendingPermissionRequests, }: UseChatComposerStateArgs) { const [input, setInput] = useState(() => { if (typeof window !== 'undefined' && selectedProject) { return safeLocalStorage.getItem(`draft_input_${selectedProject.name}`) || ''; } return ''; }); const [attachedImages, setAttachedImages] = useState([]); const [uploadingImages, setUploadingImages] = useState>(new Map()); const [imageErrors, setImageErrors] = useState>(new Map()); const [isTextareaExpanded, setIsTextareaExpanded] = useState(false); const [thinkingMode, setThinkingMode] = useState('none'); const textareaRef = useRef(null); const inputHighlightRef = useRef(null); const handleSubmitRef = useRef< ((event: FormEvent | MouseEvent | TouchEvent | KeyboardEvent) => Promise) | null >(null); const inputValueRef = useRef(input); const handleBuiltInCommand = useCallback( (result: CommandExecutionResult) => { const { action, data } = result; switch (action) { case 'clear': setChatMessages([]); setSessionMessages?.([]); break; case 'help': setChatMessages((previous) => [ ...previous, { type: 'assistant', content: data.content, timestamp: Date.now(), }, ]); break; case 'model': setChatMessages((previous) => [ ...previous, { type: 'assistant', content: `**Current Model**: ${data.current.model}\n\n**Available Models**:\n\nClaude: ${data.available.claude.join(', ')}\n\nCursor: ${data.available.cursor.join(', ')}`, timestamp: Date.now(), }, ]); break; case 'cost': { const costMessage = `**Token Usage**: ${data.tokenUsage.used.toLocaleString()} / ${data.tokenUsage.total.toLocaleString()} (${data.tokenUsage.percentage}%)\n\n**Estimated Cost**:\n- Input: $${data.cost.input}\n- Output: $${data.cost.output}\n- **Total**: $${data.cost.total}\n\n**Model**: ${data.model}`; setChatMessages((previous) => [ ...previous, { type: 'assistant', content: costMessage, timestamp: Date.now() }, ]); break; } case 'status': { const statusMessage = `**System Status**\n\n- Version: ${data.version}\n- Uptime: ${data.uptime}\n- Model: ${data.model}\n- Provider: ${data.provider}\n- Node.js: ${data.nodeVersion}\n- Platform: ${data.platform}`; setChatMessages((previous) => [ ...previous, { type: 'assistant', content: statusMessage, timestamp: Date.now() }, ]); break; } case 'memory': if (data.error) { setChatMessages((previous) => [ ...previous, { type: 'assistant', content: `⚠️ ${data.message}`, timestamp: Date.now(), }, ]); } else { setChatMessages((previous) => [ ...previous, { type: 'assistant', content: `📝 ${data.message}\n\nPath: \`${data.path}\``, timestamp: Date.now(), }, ]); if (data.exists && onFileOpen) { onFileOpen(data.path); } } break; case 'config': onShowSettings?.(); break; case 'rewind': if (data.error) { setChatMessages((previous) => [ ...previous, { type: 'assistant', content: `⚠️ ${data.message}`, timestamp: Date.now(), }, ]); } else { setChatMessages((previous) => previous.slice(0, -data.steps * 2)); setChatMessages((previous) => [ ...previous, { type: 'assistant', content: `⏪ ${data.message}`, timestamp: Date.now(), }, ]); } break; default: console.warn('Unknown built-in command action:', action); } }, [onFileOpen, onShowSettings, setChatMessages, setSessionMessages], ); const handleCustomCommand = useCallback(async (result: CommandExecutionResult) => { const { content, hasBashCommands } = result; if (hasBashCommands) { const confirmed = window.confirm( 'This command contains bash commands that will be executed. Do you want to proceed?', ); if (!confirmed) { setChatMessages((previous) => [ ...previous, { type: 'assistant', content: '❌ Command execution cancelled', timestamp: Date.now(), }, ]); return; } } const commandContent = content || ''; setInput(commandContent); inputValueRef.current = commandContent; // Defer submit to next tick so the command text is reflected in UI before dispatching. setTimeout(() => { if (handleSubmitRef.current) { handleSubmitRef.current(createFakeSubmitEvent()); } }, 0); }, [setChatMessages]); const executeCommand = useCallback( async (command: SlashCommand) => { if (!command || !selectedProject) { return; } try { const commandMatch = input.match(new RegExp(`${escapeRegExp(command.name)}\\s*(.*)`)); const args = commandMatch && commandMatch[1] ? commandMatch[1].trim().split(/\s+/) : []; const context = { projectPath: selectedProject.fullPath || selectedProject.path, projectName: selectedProject.name, sessionId: currentSessionId, provider, model: provider === 'cursor' ? cursorModel : provider === 'codex' ? codexModel : claudeModel, tokenUsage: tokenBudget, }; const response = await authenticatedFetch('/api/commands/execute', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ commandName: command.name, commandPath: command.path, args, context, }), }); if (!response.ok) { let errorMessage = `Failed to execute command (${response.status})`; try { const errorData = await response.json(); errorMessage = errorData?.message || errorData?.error || errorMessage; } catch { // Ignore JSON parse failures and use fallback message. } throw new Error(errorMessage); } const result = (await response.json()) as CommandExecutionResult; if (result.type === 'builtin') { handleBuiltInCommand(result); setInput(''); inputValueRef.current = ''; } else if (result.type === 'custom') { await handleCustomCommand(result); } } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; console.error('Error executing command:', error); setChatMessages((previous) => [ ...previous, { type: 'assistant', content: `Error executing command: ${message}`, timestamp: Date.now(), }, ]); } }, [ claudeModel, codexModel, currentSessionId, cursorModel, handleBuiltInCommand, handleCustomCommand, input, provider, selectedProject, setChatMessages, tokenBudget, ], ); const { slashCommandsCount, filteredCommands, frequentCommands, commandQuery, showCommandMenu, selectedCommandIndex, resetCommandMenuState, handleCommandSelect, handleToggleCommandMenu, handleCommandInputChange, handleCommandMenuKeyDown, } = useSlashCommands({ selectedProject, input, setInput, textareaRef, onExecuteCommand: executeCommand, }); const { showFileDropdown, filteredFiles, selectedFileIndex, renderInputWithMentions, selectFile, setCursorPosition, handleFileMentionsKeyDown, } = useFileMentions({ selectedProject, input, setInput, textareaRef, }); const syncInputOverlayScroll = useCallback((target: HTMLTextAreaElement) => { if (!inputHighlightRef.current || !target) { return; } inputHighlightRef.current.scrollTop = target.scrollTop; inputHighlightRef.current.scrollLeft = target.scrollLeft; }, []); const handleImageFiles = useCallback((files: File[]) => { const validFiles = files.filter((file) => { try { if (!file || typeof file !== 'object') { console.warn('Invalid file object:', file); return false; } if (!file.type || !file.type.startsWith('image/')) { return false; } if (!file.size || file.size > 5 * 1024 * 1024) { const fileName = file.name || 'Unknown file'; setImageErrors((previous) => { const next = new Map(previous); next.set(fileName, 'File too large (max 5MB)'); return next; }); return false; } return true; } catch (error) { console.error('Error validating file:', error, file); return false; } }); if (validFiles.length > 0) { setAttachedImages((previous) => [...previous, ...validFiles].slice(0, 5)); } }, []); const handlePaste = useCallback( (event: ClipboardEvent) => { const items = Array.from(event.clipboardData.items); items.forEach((item) => { if (!item.type.startsWith('image/')) { return; } const file = item.getAsFile(); if (file) { handleImageFiles([file]); } }); if (items.length === 0 && event.clipboardData.files.length > 0) { const files = Array.from(event.clipboardData.files); const imageFiles = files.filter((file) => file.type.startsWith('image/')); if (imageFiles.length > 0) { handleImageFiles(imageFiles); } } }, [handleImageFiles], ); const { getRootProps, getInputProps, isDragActive, open } = useDropzone({ accept: { 'image/*': ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'], }, maxSize: 5 * 1024 * 1024, maxFiles: 5, onDrop: handleImageFiles, noClick: true, noKeyboard: true, }); const handleSubmit = useCallback( async ( event: FormEvent | MouseEvent | TouchEvent | KeyboardEvent, ) => { event.preventDefault(); const currentInput = inputValueRef.current; if (!currentInput.trim() || isLoading || !selectedProject) { return; } let messageContent = currentInput; const selectedThinkingMode = thinkingModes.find((mode: { id: string; prefix?: string }) => mode.id === thinkingMode); if (selectedThinkingMode && selectedThinkingMode.prefix) { messageContent = `${selectedThinkingMode.prefix}: ${currentInput}`; } let uploadedImages: unknown[] = []; if (attachedImages.length > 0) { const formData = new FormData(); attachedImages.forEach((file) => { formData.append('images', file); }); try { const response = await authenticatedFetch(`/api/projects/${selectedProject.name}/upload-images`, { method: 'POST', headers: {}, body: formData, }); if (!response.ok) { throw new Error('Failed to upload images'); } const result = await response.json(); uploadedImages = result.images; } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; console.error('Image upload failed:', error); setChatMessages((previous) => [ ...previous, { type: 'error', content: `Failed to upload images: ${message}`, timestamp: new Date(), }, ]); return; } } const userMessage: ChatMessage = { type: 'user', content: currentInput, images: uploadedImages as any, timestamp: new Date(), }; setChatMessages((previous) => [...previous, userMessage]); setIsLoading(true); setCanAbortSession(true); setClaudeStatus({ text: 'Processing', tokens: 0, can_interrupt: true, }); setIsUserScrolledUp(false); setTimeout(() => scrollToBottom(), 100); const effectiveSessionId = currentSessionId || selectedSession?.id || sessionStorage.getItem('cursorSessionId'); const sessionToActivate = effectiveSessionId || `new-session-${Date.now()}`; if (!effectiveSessionId && !selectedSession?.id) { if (typeof window !== 'undefined') { // Reset stale pending IDs from previous interrupted runs before creating a new one. sessionStorage.removeItem('pendingSessionId'); } pendingViewSessionRef.current = { sessionId: null, startedAt: Date.now() }; } onSessionActive?.(sessionToActivate); const getToolsSettings = () => { try { const settingsKey = provider === 'cursor' ? 'cursor-tools-settings' : provider === 'codex' ? 'codex-settings' : 'claude-settings'; const savedSettings = safeLocalStorage.getItem(settingsKey); if (savedSettings) { return JSON.parse(savedSettings); } } catch (error) { console.error('Error loading tools settings:', error); } return { allowedTools: [], disallowedTools: [], skipPermissions: false, }; }; const toolsSettings = getToolsSettings(); const resolvedProjectPath = selectedProject.fullPath || selectedProject.path || ''; if (provider === 'cursor') { sendMessage({ type: 'cursor-command', command: messageContent, sessionId: effectiveSessionId, options: { cwd: resolvedProjectPath, projectPath: resolvedProjectPath, sessionId: effectiveSessionId, resume: Boolean(effectiveSessionId), model: cursorModel, skipPermissions: toolsSettings?.skipPermissions || false, toolsSettings, }, }); } else if (provider === 'codex') { sendMessage({ type: 'codex-command', command: messageContent, sessionId: effectiveSessionId, options: { cwd: resolvedProjectPath, projectPath: resolvedProjectPath, sessionId: effectiveSessionId, resume: Boolean(effectiveSessionId), model: codexModel, permissionMode: permissionMode === 'plan' ? 'default' : permissionMode, }, }); } else { sendMessage({ type: 'claude-command', command: messageContent, options: { projectPath: resolvedProjectPath, cwd: resolvedProjectPath, sessionId: effectiveSessionId, resume: Boolean(effectiveSessionId), toolsSettings, permissionMode, model: claudeModel, images: uploadedImages, }, }); } setInput(''); inputValueRef.current = ''; resetCommandMenuState(); setAttachedImages([]); setUploadingImages(new Map()); setImageErrors(new Map()); setIsTextareaExpanded(false); setThinkingMode('none'); if (textareaRef.current) { textareaRef.current.style.height = 'auto'; } safeLocalStorage.removeItem(`draft_input_${selectedProject.name}`); }, [ attachedImages, claudeModel, codexModel, currentSessionId, cursorModel, isLoading, onSessionActive, pendingViewSessionRef, permissionMode, provider, resetCommandMenuState, scrollToBottom, selectedProject, selectedSession?.id, sendMessage, setCanAbortSession, setChatMessages, setClaudeStatus, setIsLoading, setIsUserScrolledUp, thinkingMode, ], ); useEffect(() => { handleSubmitRef.current = handleSubmit; }, [handleSubmit]); useEffect(() => { inputValueRef.current = input; }, [input]); useEffect(() => { if (!selectedProject) { return; } const savedInput = safeLocalStorage.getItem(`draft_input_${selectedProject.name}`) || ''; setInput((previous) => { const next = previous === savedInput ? previous : savedInput; inputValueRef.current = next; return next; }); }, [selectedProject?.name]); useEffect(() => { if (!selectedProject) { return; } if (input !== '') { safeLocalStorage.setItem(`draft_input_${selectedProject.name}`, input); } else { safeLocalStorage.removeItem(`draft_input_${selectedProject.name}`); } }, [input, selectedProject]); useEffect(() => { if (!textareaRef.current) { return; } // Re-run when input changes so restored drafts get the same autosize behavior as typed text. textareaRef.current.style.height = 'auto'; textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`; const lineHeight = parseInt(window.getComputedStyle(textareaRef.current).lineHeight); const expanded = textareaRef.current.scrollHeight > lineHeight * 2; setIsTextareaExpanded(expanded); }, [input]); useEffect(() => { if (!textareaRef.current || input.trim()) { return; } textareaRef.current.style.height = 'auto'; setIsTextareaExpanded(false); }, [input]); const handleInputChange = useCallback( (event: ChangeEvent) => { const newValue = event.target.value; const cursorPos = event.target.selectionStart; setInput(newValue); inputValueRef.current = newValue; setCursorPosition(cursorPos); if (!newValue.trim()) { event.target.style.height = 'auto'; setIsTextareaExpanded(false); resetCommandMenuState(); return; } handleCommandInputChange(newValue, cursorPos); }, [handleCommandInputChange, resetCommandMenuState, setCursorPosition], ); const handleKeyDown = useCallback( (event: KeyboardEvent) => { if (handleCommandMenuKeyDown(event)) { return; } if (handleFileMentionsKeyDown(event)) { return; } if (event.key === 'Tab' && !showFileDropdown && !showCommandMenu) { event.preventDefault(); cyclePermissionMode(); return; } if (event.key === 'Enter') { if (event.nativeEvent.isComposing) { return; } if ((event.ctrlKey || event.metaKey) && !event.shiftKey) { event.preventDefault(); handleSubmit(event); } else if (!event.shiftKey && !event.ctrlKey && !event.metaKey && !sendByCtrlEnter) { event.preventDefault(); handleSubmit(event); } } }, [ cyclePermissionMode, handleCommandMenuKeyDown, handleFileMentionsKeyDown, handleSubmit, sendByCtrlEnter, showCommandMenu, showFileDropdown, ], ); const handleTextareaClick = useCallback( (event: MouseEvent) => { setCursorPosition(event.currentTarget.selectionStart); }, [setCursorPosition], ); const handleTextareaInput = useCallback( (event: FormEvent) => { const target = event.currentTarget; target.style.height = 'auto'; target.style.height = `${target.scrollHeight}px`; setCursorPosition(target.selectionStart); syncInputOverlayScroll(target); const lineHeight = parseInt(window.getComputedStyle(target).lineHeight); setIsTextareaExpanded(target.scrollHeight > lineHeight * 2); }, [setCursorPosition, syncInputOverlayScroll], ); const handleClearInput = useCallback(() => { setInput(''); inputValueRef.current = ''; resetCommandMenuState(); if (textareaRef.current) { textareaRef.current.style.height = 'auto'; textareaRef.current.focus(); } setIsTextareaExpanded(false); }, [resetCommandMenuState]); const handleAbortSession = useCallback(() => { if (!canAbortSession) { return; } const pendingSessionId = typeof window !== 'undefined' ? sessionStorage.getItem('pendingSessionId') : null; const cursorSessionId = typeof window !== 'undefined' ? sessionStorage.getItem('cursorSessionId') : null; const candidateSessionIds = [ currentSessionId, pendingViewSessionRef.current?.sessionId || null, pendingSessionId, provider === 'cursor' ? cursorSessionId : null, selectedSession?.id || null, ]; const targetSessionId = candidateSessionIds.find((sessionId) => Boolean(sessionId) && !isTemporarySessionId(sessionId)) || null; if (!targetSessionId) { console.warn('Abort requested but no concrete session ID is available yet.'); return; } sendMessage({ type: 'abort-session', sessionId: targetSessionId, provider, }); }, [canAbortSession, currentSessionId, pendingViewSessionRef, provider, selectedSession?.id, sendMessage]); const handleTranscript = useCallback((text: string) => { if (!text.trim()) { return; } setInput((previousInput) => { const newInput = previousInput.trim() ? `${previousInput} ${text}` : text; inputValueRef.current = newInput; setTimeout(() => { if (!textareaRef.current) { return; } textareaRef.current.style.height = 'auto'; textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`; const lineHeight = parseInt(window.getComputedStyle(textareaRef.current).lineHeight); setIsTextareaExpanded(textareaRef.current.scrollHeight > lineHeight * 2); }, 0); return newInput; }); }, []); const handleGrantToolPermission = useCallback( (suggestion: { entry: string; toolName: string }) => { if (!suggestion || provider !== 'claude') { return { success: false }; } return grantClaudeToolPermission(suggestion.entry); }, [provider], ); const handlePermissionDecision = useCallback( ( requestIds: string | string[], decision: { allow?: boolean; message?: string; rememberEntry?: string | null; updatedInput?: unknown }, ) => { const ids = Array.isArray(requestIds) ? requestIds : [requestIds]; const validIds = ids.filter(Boolean); if (validIds.length === 0) { return; } validIds.forEach((requestId) => { sendMessage({ type: 'claude-permission-response', requestId, allow: Boolean(decision?.allow), updatedInput: decision?.updatedInput, message: decision?.message, rememberEntry: decision?.rememberEntry, }); }); setPendingPermissionRequests((previous) => { const next = previous.filter((request) => !validIds.includes(request.requestId)); if (next.length === 0) { setClaudeStatus(null); } return next; }); }, [sendMessage, setClaudeStatus, setPendingPermissionRequests], ); const handleInputFocusChange = useCallback( (focused: boolean) => { onInputFocusChange?.(focused); }, [onInputFocusChange], ); return { input, setInput, textareaRef, inputHighlightRef, isTextareaExpanded, thinkingMode, setThinkingMode, slashCommandsCount, filteredCommands, frequentCommands, commandQuery, showCommandMenu, selectedCommandIndex, resetCommandMenuState, handleCommandSelect, handleToggleCommandMenu, showFileDropdown, filteredFiles: filteredFiles as MentionableFile[], selectedFileIndex, renderInputWithMentions, selectFile, attachedImages, setAttachedImages, uploadingImages, imageErrors, getRootProps, getInputProps, isDragActive, openImagePicker: open, handleSubmit, handleInputChange, handleKeyDown, handlePaste, handleTextareaClick, handleTextareaInput, syncInputOverlayScroll, handleClearInput, handleAbortSession, handleTranscript, handlePermissionDecision, handleGrantToolPermission, handleInputFocusChange, }; }