import React, { useCallback, useEffect, useMemo, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { useTasksSettings } from '../../../contexts/TasksSettingsContext'; import { useWebSocket } from '../../../contexts/WebSocketContext'; import PermissionContext from '../../../contexts/PermissionContext'; import { QuickSettingsPanel } from '../../quick-settings-panel'; import type { ChatInterfaceProps, Provider } from '../types/types'; import { useChatProviderState } from '../hooks/useChatProviderState'; import { useChatSessionState } from '../hooks/useChatSessionState'; import { useChatRealtimeHandlers } from '../hooks/useChatRealtimeHandlers'; import { useChatComposerState } from '../hooks/useChatComposerState'; import { useSessionStore } from '../../../stores/useSessionStore'; import ChatMessagesPane from './subcomponents/ChatMessagesPane'; import ChatComposer from './subcomponents/ChatComposer'; import CommandResultModal from './subcomponents/CommandResultModal'; function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, onFileOpen, onInputFocusChange, onSessionProcessing, onSessionIdle, processingSessions, onNavigateToSession, onSessionEstablished, onShowSettings, autoExpandTools, showRawParameters, showThinking, autoScrollToBottom, sendByCtrlEnter, externalMessageUpdate, newSessionTrigger, onShowAllTasks, }: ChatInterfaceProps) { const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings(); const { subscribe } = useWebSocket(); const { t } = useTranslation('chat'); const sessionStore = useSessionStore(); const streamTimerRef = useRef(null); const accumulatedStreamRef = useRef(''); // When each session's `chat.subscribe` was last sent; idle acks older than // a later local request are discarded as stale. const statusCheckSentAtRef = useRef(new Map()); // Highest live `seq` observed per session. Written by the realtime handler // on every sequenced frame, read whenever a `chat.subscribe` is sent so the // server replays only the events this client actually missed. const lastSeqRef = useRef(new Map()); const resetStreamingState = useCallback(() => { if (streamTimerRef.current) { clearTimeout(streamTimerRef.current); streamTimerRef.current = null; } accumulatedStreamRef.current = ''; }, []); const { provider, setProvider, cursorModel, setCursorModel, claudeModel, setClaudeModel, codexModel, setCodexModel, geminiModel, setGeminiModel, opencodeModel, setOpenCodeModel, permissionMode, pendingPermissionRequests, setPendingPermissionRequests, cyclePermissionMode, providerModelCatalog, providerModelCacheCatalog, providerModelsLoading, providerModelsRefreshing, hardRefreshProviderModels, selectProviderModel, } = useChatProviderState({ selectedSession, selectedProject, }); const { chatMessages, addMessage, sessionActivity, isProcessing, canAbortSession, currentSessionId, setCurrentSessionId, isLoadingSessionMessages, isLoadingMoreMessages, hasMoreMessages, totalMessages, isUserScrolledUp, setIsUserScrolledUp, tokenBudget, setTokenBudget, visibleMessageCount, visibleMessages, loadEarlierMessages, loadAllMessages, allMessagesLoaded, isLoadingAllMessages, loadAllJustFinished, showLoadAllOverlay, createDiff, scrollContainerRef, scrollToBottom, scrollToBottomAndReset, handleScroll, } = useChatSessionState({ selectedProject, selectedSession, ws, sendMessage, autoScrollToBottom, externalMessageUpdate, newSessionTrigger, processingSessions, onSessionIdle, resetStreamingState, statusCheckSentAtRef, lastSeqRef, sessionStore, }); // Brand-new conversation: the composer allocated a stable session id via // the session gateway before the first send. Record it locally and put it // in the URL — this id never changes again, so there is no later handoff. const handleSessionEstablished = useCallback>((sessionId, context) => { setCurrentSessionId(sessionId); onSessionEstablished?.(sessionId, context); onNavigateToSession?.(sessionId); }, [setCurrentSessionId, onSessionEstablished, onNavigateToSession]); const { input, setInput, textareaRef, inputHighlightRef, isTextareaExpanded, slashCommandsCount, filteredCommands, frequentCommands, commandQuery, showCommandMenu, selectedCommandIndex, resetCommandMenuState, handleCommandSelect, handleToggleCommandMenu, showFileDropdown, filteredFiles, selectedFileIndex, renderInputWithMentions, selectFile, attachedImages, setAttachedImages, uploadingImages, imageErrors, getRootProps, getInputProps, isDragActive, openImagePicker, handleSubmit, handleInputChange, handleKeyDown, handlePaste, handleTextareaClick, handleTextareaInput, syncInputOverlayScroll, handleClearInput, handleAbortSession, handlePermissionDecision, handleGrantToolPermission, handleInputFocusChange, isInputFocused: _isInputFocused, commandModalPayload, closeCommandModal, showCostModal, } = useChatComposerState({ selectedProject, selectedSession, currentSessionId, provider, permissionMode, cyclePermissionMode, cursorModel, claudeModel, codexModel, geminiModel, opencodeModel, isLoading: isProcessing, canAbortSession, tokenBudget, sendMessage, sendByCtrlEnter, onSessionProcessing, onSessionEstablished: handleSessionEstablished, onInputFocusChange, onFileOpen, onShowSettings, scrollToBottom, addMessage, setIsUserScrolledUp, setPendingPermissionRequests, }); // On WebSocket reconnect, re-fetch the current session's messages from the // server so missed streaming events are shown, then re-subscribe — the // `chat_subscribed` ack restores or clears the activity indicator, replays // missed live events, and re-attaches a still-running stream to this socket. const handleWebSocketReconnect = useCallback(async () => { if (!selectedProject || !selectedSession) return; await sessionStore.refreshFromServer(selectedSession.id); statusCheckSentAtRef.current.set(selectedSession.id, Date.now()); sendMessage({ type: 'chat.subscribe', sessions: [{ sessionId: selectedSession.id, lastSeq: lastSeqRef.current.get(selectedSession.id) ?? 0, }], }); }, [selectedProject, selectedSession, sendMessage, sessionStore]); useChatRealtimeHandlers({ subscribe, provider, selectedSession, currentSessionId, setTokenBudget, setPendingPermissionRequests, streamTimerRef, accumulatedStreamRef, lastSeqRef, statusCheckSentAtRef, onSessionProcessing, onSessionIdle, onWebSocketReconnect: handleWebSocketReconnect, sessionStore, }); useEffect(() => { if (!canAbortSession) { return; } const handleGlobalEscape = (event: KeyboardEvent) => { if (event.key !== 'Escape' || event.repeat || event.defaultPrevented) { return; } event.preventDefault(); handleAbortSession(); }; document.addEventListener('keydown', handleGlobalEscape, { capture: true }); return () => { document.removeEventListener('keydown', handleGlobalEscape, { capture: true }); }; }, [canAbortSession, handleAbortSession]); useEffect(() => { return () => { resetStreamingState(); }; }, [resetStreamingState]); const permissionContextValue = useMemo(() => ({ pendingPermissionRequests, handlePermissionDecision, }), [pendingPermissionRequests, handlePermissionDecision]); if (!selectedProject) { const selectedProviderLabel = provider === 'cursor' ? t('messageTypes.cursor') : provider === 'codex' ? t('messageTypes.codex') : provider === 'gemini' ? t('messageTypes.gemini') : provider === 'opencode' ? t('messageTypes.opencode', { defaultValue: 'OpenCode' }) : t('messageTypes.claude'); return (

{t('projectSelection.startChatWithProvider', { provider: selectedProviderLabel, defaultValue: 'Select a project to start chatting with {{provider}}', })}

); } return (
setProvider(nextProvider as Provider)} textareaRef={textareaRef} claudeModel={claudeModel} setClaudeModel={setClaudeModel} cursorModel={cursorModel} setCursorModel={setCursorModel} codexModel={codexModel} setCodexModel={setCodexModel} geminiModel={geminiModel} setGeminiModel={setGeminiModel} opencodeModel={opencodeModel} setOpenCodeModel={setOpenCodeModel} providerModelCatalog={providerModelCatalog} providerModelsLoading={providerModelsLoading} tasksEnabled={tasksEnabled} isTaskMasterInstalled={isTaskMasterInstalled} onShowAllTasks={onShowAllTasks} setInput={setInput} isLoadingMoreMessages={isLoadingMoreMessages} hasMoreMessages={hasMoreMessages} totalMessages={totalMessages} sessionMessagesCount={chatMessages.length} visibleMessageCount={visibleMessageCount} visibleMessages={visibleMessages} loadEarlierMessages={loadEarlierMessages} loadAllMessages={loadAllMessages} allMessagesLoaded={allMessagesLoaded} isLoadingAllMessages={isLoadingAllMessages} loadAllJustFinished={loadAllJustFinished} showLoadAllOverlay={showLoadAllOverlay} createDiff={createDiff} onFileOpen={onFileOpen} onShowSettings={onShowSettings} onGrantToolPermission={handleGrantToolPermission} autoExpandTools={autoExpandTools} showRawParameters={showRawParameters} showThinking={showThinking} selectedProject={selectedProject} /> 0} onScrollToBottom={scrollToBottomAndReset} onSubmit={handleSubmit} isDragActive={isDragActive} attachedImages={attachedImages} onRemoveImage={(index) => setAttachedImages((previous) => previous.filter((_, currentIndex) => currentIndex !== index), ) } uploadingImages={uploadingImages} imageErrors={imageErrors} showFileDropdown={showFileDropdown} filteredFiles={filteredFiles} selectedFileIndex={selectedFileIndex} onSelectFile={selectFile} filteredCommands={filteredCommands} selectedCommandIndex={selectedCommandIndex} onCommandSelect={handleCommandSelect} onCloseCommandMenu={resetCommandMenuState} isCommandMenuOpen={showCommandMenu} frequentCommands={commandQuery ? [] : frequentCommands} getRootProps={getRootProps as (...args: unknown[]) => Record} getInputProps={getInputProps as (...args: unknown[]) => Record} openImagePicker={openImagePicker} inputHighlightRef={inputHighlightRef} renderInputWithMentions={renderInputWithMentions} textareaRef={textareaRef} input={input} onInputChange={handleInputChange} onTextareaClick={handleTextareaClick} onTextareaKeyDown={handleKeyDown} onTextareaPaste={handlePaste} onTextareaScrollSync={syncInputOverlayScroll} onTextareaInput={handleTextareaInput} onInputFocusChange={handleInputFocusChange} placeholder={t('input.placeholder', { provider: provider === 'cursor' ? t('messageTypes.cursor') : provider === 'codex' ? t('messageTypes.codex') : provider === 'gemini' ? t('messageTypes.gemini') : provider === 'opencode' ? t('messageTypes.opencode', { defaultValue: 'OpenCode' }) : t('messageTypes.claude'), })} isTextareaExpanded={isTextareaExpanded} sendByCtrlEnter={sendByCtrlEnter} />
); } export default React.memo(ChatInterface);