refactor(chat): split monolithic chat interface into typed modules and hooks

Replace the legacy monolithic ChatInterface.jsx implementation with a modular TypeScript architecture centered around a small orchestration component (ChatInterface.tsx).

Core architecture changes:
- Remove src/components/ChatInterface.jsx and add src/components/ChatInterface.tsx as a thin coordinator that wires provider state, session state, realtime WebSocket handlers, and composer behavior via dedicated hooks.
- Update src/components/MainContent.tsx to use typed ChatInterface directly (remove AnyChatInterface cast).

State ownership and hook extraction:
- Add src/hooks/chat/useChatProviderState.ts to centralize provider/model/permission-mode state, provider/session synchronization, cursor model bootstrap from backend config, and pending permission request scoping.
- Add src/hooks/chat/useChatSessionState.ts to own chat/session lifecycle state: session loading, cursor/claude/codex history loading, pagination, scroll restoration, visible-window slicing, token budget loading, persisted chat hydration, and processing-state restoration.
- Add src/hooks/chat/useChatRealtimeHandlers.ts to isolate WebSocket event processing for Claude/Cursor/Codex, including session filtering, streaming chunk buffering, session-created/pending-session transitions, permission request queueing/cancellation, completion/error handling, and session status updates.
- Add src/hooks/chat/useChatComposerState.ts to own composer-local state and interactions: input/draft persistence, textarea sizing and keyboard behavior, slash command execution, file mentions, image attachment/drop/paste workflow, submit/abort flows, permission decision responses, and transcript insertion.

UI modularization under src/components/chat:
- Add view/ChatMessagesPane.tsx for message list rendering, loading/empty states, pagination affordances, and thinking indicator.
- Add view/ChatComposer.tsx for composer shell layout and input area composition.
- Add view/ChatInputControls.tsx for mode toggles, token display, command launcher, clear-input, and scroll-to-bottom controls.
- Add view/PermissionRequestsBanner.tsx for explicit tool-permission review actions (allow once / allow & remember / deny).
- Add view/ProviderSelectionEmptyState.tsx for provider and model selection UX plus task starter integration.
- Add messages/MessageComponent.tsx and markdown/Markdown.tsx to isolate message rendering concerns, markdown/code rendering, and rich tool-output presentation.
- Add input/ImageAttachment.tsx for attachment previews/removal/progress/error overlay rendering.

Shared chat typing and utilities:
- Add src/components/chat/types.ts with shared types for providers, permission mode, message/tool payloads, pending permission requests, and ChatInterfaceProps.
- Add src/components/chat/utils/chatFormatting.ts for html decoding, code fence normalization, regex escaping, math-safe unescaping, and usage-limit text formatting.
- Add src/components/chat/utils/chatPermissions.ts for permission rule derivation, suggestion generation, and grant flow.
- Add src/components/chat/utils/chatStorage.ts for resilient localStorage access, quota handling, and normalized Claude settings retrieval.
- Add src/components/chat/utils/messageTransforms.ts for session message normalization (Claude/Codex/Cursor) and cached diff computation utilities.

Command/file input ergonomics:
- Add src/hooks/chat/useSlashCommands.ts for slash command fetching, usage-based ranking, fuzzy filtering, keyboard navigation, and command history persistence.
- Add src/hooks/chat/useFileMentions.tsx for project file flattening, @mention suggestions, mention highlighting, and keyboard/file insertion behavior.

TypeScript support additions:
- Add src/types/react-syntax-highlighter.d.ts module declarations to type-check markdown code highlighting imports.

Behavioral intent:
- Preserve existing chat behavior and provider flows while improving readability, separation of concerns, and future refactorability.
- Move state closer to the components/hooks that own it, reducing cross-cutting concerns in the top-level chat component.
This commit is contained in:
Haileyesus
2026-02-08 17:12:16 +03:00
parent cdc03e754f
commit 1d8b70f614
23 changed files with 6912 additions and 5698 deletions

View File

@@ -0,0 +1,351 @@
import React, { useCallback, useEffect, useRef } from 'react';
import QuickSettingsPanel from './QuickSettingsPanel';
import { useTasksSettings } from '../contexts/TasksSettingsContext';
import { useTranslation } from 'react-i18next';
import ChatMessagesPane from './chat/view/ChatMessagesPane';
import ChatComposer from './chat/view/ChatComposer';
import type { ChatInterfaceProps } from './chat/types';
import { useChatProviderState } from '../hooks/chat/useChatProviderState';
import { useChatSessionState } from '../hooks/chat/useChatSessionState';
import { useChatRealtimeHandlers } from '../hooks/chat/useChatRealtimeHandlers';
import { useChatComposerState } from '../hooks/chat/useChatComposerState';
import type { Provider } from './chat/types';
type PendingViewSession = {
sessionId: string | null;
startedAt: number;
};
function ChatInterface({
selectedProject,
selectedSession,
ws,
sendMessage,
latestMessage,
onFileOpen,
onInputFocusChange,
onSessionActive,
onSessionInactive,
onSessionProcessing,
onSessionNotProcessing,
processingSessions,
onReplaceTemporarySession,
onNavigateToSession,
onShowSettings,
autoExpandTools,
showRawParameters,
showThinking,
autoScrollToBottom,
sendByCtrlEnter,
externalMessageUpdate,
onShowAllTasks,
}: ChatInterfaceProps) {
const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings();
const { t } = useTranslation('chat');
const streamBufferRef = useRef('');
const streamTimerRef = useRef<number | null>(null);
const pendingViewSessionRef = useRef<PendingViewSession | null>(null);
const resetStreamingState = useCallback(() => {
if (streamTimerRef.current) {
clearTimeout(streamTimerRef.current);
streamTimerRef.current = null;
}
streamBufferRef.current = '';
}, []);
const {
provider,
setProvider,
cursorModel,
setCursorModel,
claudeModel,
setClaudeModel,
codexModel,
setCodexModel,
permissionMode,
pendingPermissionRequests,
setPendingPermissionRequests,
cyclePermissionMode,
} = useChatProviderState({
selectedSession,
});
const {
chatMessages,
setChatMessages,
isLoading,
setIsLoading,
currentSessionId,
setCurrentSessionId,
sessionMessages,
setSessionMessages,
isLoadingSessionMessages,
isLoadingMoreMessages,
hasMoreMessages,
totalMessages,
isSystemSessionChange,
setIsSystemSessionChange,
canAbortSession,
setCanAbortSession,
isUserScrolledUp,
setIsUserScrolledUp,
tokenBudget,
setTokenBudget,
visibleMessageCount,
visibleMessages,
loadEarlierMessages,
claudeStatus,
setClaudeStatus,
createDiff,
scrollContainerRef,
scrollToBottom,
handleScroll,
} = useChatSessionState({
selectedProject,
selectedSession,
ws,
sendMessage,
autoScrollToBottom,
externalMessageUpdate,
processingSessions,
resetStreamingState,
pendingViewSessionRef,
});
const {
input,
setInput,
textareaRef,
inputHighlightRef,
isTextareaExpanded,
thinkingMode,
setThinkingMode,
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,
handleTranscript,
handlePermissionDecision,
handleGrantToolPermission,
handleInputFocusChange,
} = 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,
});
useChatRealtimeHandlers({
latestMessage,
provider,
selectedProject,
selectedSession,
currentSessionId,
setCurrentSessionId,
setChatMessages,
setIsLoading,
setCanAbortSession,
setClaudeStatus,
setTokenBudget,
setIsSystemSessionChange,
setPendingPermissionRequests,
pendingViewSessionRef,
streamBufferRef,
streamTimerRef,
onSessionInactive,
onSessionProcessing,
onSessionNotProcessing,
onReplaceTemporarySession,
onNavigateToSession,
});
useEffect(() => {
if (currentSessionId && isLoading && onSessionProcessing) {
onSessionProcessing(currentSessionId);
}
}, [currentSessionId, isLoading, onSessionProcessing]);
useEffect(() => {
return () => {
resetStreamingState();
};
}, [resetStreamingState]);
if (!selectedProject) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center text-gray-500 dark:text-gray-400">
<p>Select a project to start chatting with Claude</p>
</div>
</div>
);
}
return (
<>
<div className="h-full flex flex-col">
<ChatMessagesPane
scrollContainerRef={scrollContainerRef}
onWheel={handleScroll}
onTouchMove={handleScroll}
isLoadingSessionMessages={isLoadingSessionMessages}
chatMessages={chatMessages}
selectedSession={selectedSession}
currentSessionId={currentSessionId}
provider={provider}
setProvider={(nextProvider) => setProvider(nextProvider as Provider)}
textareaRef={textareaRef}
claudeModel={claudeModel}
setClaudeModel={setClaudeModel}
cursorModel={cursorModel}
setCursorModel={setCursorModel}
codexModel={codexModel}
setCodexModel={setCodexModel}
tasksEnabled={tasksEnabled}
isTaskMasterInstalled={isTaskMasterInstalled}
onShowAllTasks={onShowAllTasks}
setInput={setInput}
isLoadingMoreMessages={isLoadingMoreMessages}
hasMoreMessages={hasMoreMessages}
totalMessages={totalMessages}
sessionMessagesCount={sessionMessages.length}
visibleMessageCount={visibleMessageCount}
visibleMessages={visibleMessages}
loadEarlierMessages={loadEarlierMessages}
createDiff={createDiff}
onFileOpen={onFileOpen}
onShowSettings={onShowSettings}
onGrantToolPermission={handleGrantToolPermission}
autoExpandTools={autoExpandTools}
showRawParameters={showRawParameters}
showThinking={showThinking}
selectedProject={selectedProject}
isLoading={isLoading}
/>
<ChatComposer
pendingPermissionRequests={pendingPermissionRequests}
handlePermissionDecision={handlePermissionDecision}
handleGrantToolPermission={handleGrantToolPermission}
claudeStatus={claudeStatus}
isLoading={isLoading}
onAbortSession={handleAbortSession}
provider={provider}
permissionMode={permissionMode}
onModeSwitch={cyclePermissionMode}
thinkingMode={thinkingMode}
setThinkingMode={setThinkingMode}
tokenBudget={tokenBudget}
slashCommandsCount={slashCommandsCount}
onToggleCommandMenu={handleToggleCommandMenu}
hasInput={Boolean(input.trim())}
onClearInput={handleClearInput}
isUserScrolledUp={isUserScrolledUp}
hasMessages={chatMessages.length > 0}
onScrollToBottom={scrollToBottom}
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<string, unknown>}
getInputProps={getInputProps as (...args: unknown[]) => Record<string, unknown>}
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')
: t('messageTypes.claude'),
})}
isTextareaExpanded={isTextareaExpanded}
sendByCtrlEnter={sendByCtrlEnter}
onTranscript={handleTranscript}
/>
</div>
<QuickSettingsPanel />
</>
);
}
export default React.memo(ChatInterface);