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,346 @@
import CommandMenu from '../../CommandMenu';
import ClaudeStatus from '../../ClaudeStatus';
import { MicButton } from '../../MicButton.jsx';
import ImageAttachment from '../input/ImageAttachment';
import PermissionRequestsBanner from './PermissionRequestsBanner';
import ChatInputControls from './ChatInputControls';
import { useTranslation } from 'react-i18next';
import type {
ChangeEvent,
ClipboardEvent,
Dispatch,
FormEvent,
KeyboardEvent,
MouseEvent,
ReactNode,
RefObject,
SetStateAction,
TouchEvent,
} from 'react';
import type { PendingPermissionRequest, PermissionMode, Provider } from '../types';
interface MentionableFile {
name: string;
path: string;
}
interface SlashCommand {
name: string;
description?: string;
namespace?: string;
path?: string;
type?: string;
metadata?: Record<string, unknown>;
[key: string]: unknown;
}
interface ChatComposerProps {
pendingPermissionRequests: PendingPermissionRequest[];
handlePermissionDecision: (
requestIds: string | string[],
decision: { allow?: boolean; message?: string; rememberEntry?: string | null; updatedInput?: unknown },
) => void;
handleGrantToolPermission: (suggestion: { entry: string; toolName: string }) => { success: boolean };
claudeStatus: { text: string; tokens: number; can_interrupt: boolean } | null;
isLoading: boolean;
onAbortSession: () => void;
provider: Provider | string;
permissionMode: PermissionMode | string;
onModeSwitch: () => void;
thinkingMode: string;
setThinkingMode: Dispatch<SetStateAction<string>>;
tokenBudget: { used?: number; total?: number } | null;
slashCommandsCount: number;
onToggleCommandMenu: () => void;
hasInput: boolean;
onClearInput: () => void;
isUserScrolledUp: boolean;
hasMessages: boolean;
onScrollToBottom: () => void;
onSubmit: (event: FormEvent<HTMLFormElement> | MouseEvent<HTMLButtonElement> | TouchEvent<HTMLButtonElement>) => void;
isDragActive: boolean;
attachedImages: File[];
onRemoveImage: (index: number) => void;
uploadingImages: Map<string, number>;
imageErrors: Map<string, string>;
showFileDropdown: boolean;
filteredFiles: MentionableFile[];
selectedFileIndex: number;
onSelectFile: (file: MentionableFile) => void;
filteredCommands: SlashCommand[];
selectedCommandIndex: number;
onCommandSelect: (command: SlashCommand, index: number, isHover: boolean) => void;
onCloseCommandMenu: () => void;
isCommandMenuOpen: boolean;
frequentCommands: SlashCommand[];
getRootProps: (...args: unknown[]) => Record<string, unknown>;
getInputProps: (...args: unknown[]) => Record<string, unknown>;
openImagePicker: () => void;
inputHighlightRef: RefObject<HTMLDivElement>;
renderInputWithMentions: (text: string) => ReactNode;
textareaRef: RefObject<HTMLTextAreaElement>;
input: string;
onInputChange: (event: ChangeEvent<HTMLTextAreaElement>) => void;
onTextareaClick: (event: MouseEvent<HTMLTextAreaElement>) => void;
onTextareaKeyDown: (event: KeyboardEvent<HTMLTextAreaElement>) => void;
onTextareaPaste: (event: ClipboardEvent<HTMLTextAreaElement>) => void;
onTextareaScrollSync: (target: HTMLTextAreaElement) => void;
onTextareaInput: (event: FormEvent<HTMLTextAreaElement>) => void;
onInputFocusChange?: (focused: boolean) => void;
placeholder: string;
isTextareaExpanded: boolean;
sendByCtrlEnter?: boolean;
onTranscript: (text: string) => void;
}
export default function ChatComposer({
pendingPermissionRequests,
handlePermissionDecision,
handleGrantToolPermission,
claudeStatus,
isLoading,
onAbortSession,
provider,
permissionMode,
onModeSwitch,
thinkingMode,
setThinkingMode,
tokenBudget,
slashCommandsCount,
onToggleCommandMenu,
hasInput,
onClearInput,
isUserScrolledUp,
hasMessages,
onScrollToBottom,
onSubmit,
isDragActive,
attachedImages,
onRemoveImage,
uploadingImages,
imageErrors,
showFileDropdown,
filteredFiles,
selectedFileIndex,
onSelectFile,
filteredCommands,
selectedCommandIndex,
onCommandSelect,
onCloseCommandMenu,
isCommandMenuOpen,
frequentCommands,
getRootProps,
getInputProps,
openImagePicker,
inputHighlightRef,
renderInputWithMentions,
textareaRef,
input,
onInputChange,
onTextareaClick,
onTextareaKeyDown,
onTextareaPaste,
onTextareaScrollSync,
onTextareaInput,
onInputFocusChange,
placeholder,
isTextareaExpanded,
sendByCtrlEnter,
onTranscript,
}: ChatComposerProps) {
const { t } = useTranslation('chat');
const AnyCommandMenu = CommandMenu as any;
const textareaRect = textareaRef.current?.getBoundingClientRect();
const commandMenuPosition = {
top: textareaRect ? Math.max(16, textareaRect.top - 316) : 0,
left: textareaRect ? textareaRect.left : 16,
bottom: textareaRect ? window.innerHeight - textareaRect.top + 8 : 90,
};
return (
<div className="p-2 sm:p-4 md:p-4 flex-shrink-0 pb-2 sm:pb-4 md:pb-6">
<div className="flex-1">
<ClaudeStatus
status={claudeStatus}
isLoading={isLoading}
onAbort={onAbortSession}
provider={provider}
/>
</div>
<div className="max-w-4xl mx-auto mb-3">
<PermissionRequestsBanner
pendingPermissionRequests={pendingPermissionRequests}
handlePermissionDecision={handlePermissionDecision}
handleGrantToolPermission={handleGrantToolPermission}
/>
<ChatInputControls
permissionMode={permissionMode}
onModeSwitch={onModeSwitch}
provider={provider}
thinkingMode={thinkingMode}
setThinkingMode={setThinkingMode}
tokenBudget={tokenBudget}
slashCommandsCount={slashCommandsCount}
onToggleCommandMenu={onToggleCommandMenu}
hasInput={hasInput}
onClearInput={onClearInput}
isUserScrolledUp={isUserScrolledUp}
hasMessages={hasMessages}
onScrollToBottom={onScrollToBottom}
/>
</div>
<form onSubmit={onSubmit as (event: FormEvent<HTMLFormElement>) => void} className="relative max-w-4xl mx-auto">
{isDragActive && (
<div className="absolute inset-0 bg-blue-500/20 border-2 border-dashed border-blue-500 rounded-lg flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 shadow-lg">
<svg className="w-8 h-8 text-blue-500 mx-auto mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
<p className="text-sm font-medium">Drop images here</p>
</div>
</div>
)}
{attachedImages.length > 0 && (
<div className="mb-2 p-2 bg-gray-50 dark:bg-gray-800 rounded-lg">
<div className="flex flex-wrap gap-2">
{attachedImages.map((file, index) => (
<ImageAttachment
key={index}
file={file}
onRemove={() => onRemoveImage(index)}
uploadProgress={uploadingImages.get(file.name)}
error={imageErrors.get(file.name)}
/>
))}
</div>
</div>
)}
{showFileDropdown && filteredFiles.length > 0 && (
<div className="absolute bottom-full left-0 right-0 mb-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-lg shadow-lg max-h-48 overflow-y-auto z-50 backdrop-blur-sm">
{filteredFiles.map((file, index) => (
<div
key={file.path}
className={`px-4 py-3 cursor-pointer border-b border-gray-100 dark:border-gray-700 last:border-b-0 touch-manipulation ${
index === selectedFileIndex
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300'
: 'hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300'
}`}
onMouseDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onSelectFile(file);
}}
>
<div className="font-medium text-sm">{file.name}</div>
<div className="text-xs text-gray-500 dark:text-gray-400 font-mono">{file.path}</div>
</div>
))}
</div>
)}
<AnyCommandMenu
commands={filteredCommands}
selectedIndex={selectedCommandIndex}
onSelect={onCommandSelect}
onClose={onCloseCommandMenu}
position={commandMenuPosition}
isOpen={isCommandMenuOpen}
frequentCommands={frequentCommands}
/>
<div
{...getRootProps()}
className={`relative bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-600 focus-within:ring-2 focus-within:ring-blue-500 dark:focus-within:ring-blue-500 focus-within:border-blue-500 transition-all duration-200 overflow-hidden ${
isTextareaExpanded ? 'chat-input-expanded' : ''
}`}
>
<input {...getInputProps()} />
<div ref={inputHighlightRef} aria-hidden="true" className="absolute inset-0 pointer-events-none overflow-hidden rounded-2xl">
<div className="chat-input-placeholder block w-full pl-12 pr-20 sm:pr-40 py-1.5 sm:py-4 text-transparent text-base leading-6 whitespace-pre-wrap break-words">
{renderInputWithMentions(input)}
</div>
</div>
<div className="relative z-10">
<textarea
ref={textareaRef}
value={input}
onChange={onInputChange}
onClick={onTextareaClick}
onKeyDown={onTextareaKeyDown}
onPaste={onTextareaPaste}
onScroll={(event) => onTextareaScrollSync(event.target as HTMLTextAreaElement)}
onFocus={() => onInputFocusChange?.(true)}
onBlur={() => onInputFocusChange?.(false)}
onInput={onTextareaInput}
placeholder={placeholder}
disabled={isLoading}
className="chat-input-placeholder block w-full pl-12 pr-20 sm:pr-40 py-1.5 sm:py-4 bg-transparent rounded-2xl focus:outline-none text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 disabled:opacity-50 resize-none min-h-[50px] sm:min-h-[80px] max-h-[40vh] sm:max-h-[300px] overflow-y-auto text-base leading-6 transition-all duration-200"
style={{ height: '50px' }}
/>
<button
type="button"
onClick={openImagePicker}
className="absolute left-2 top-1/2 transform -translate-y-1/2 p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
title={t('input.attachImages')}
>
<svg className="w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
</button>
<div className="absolute right-16 sm:right-16 top-1/2 transform -translate-y-1/2" style={{ display: 'none' }}>
<MicButton onTranscript={onTranscript} className="w-10 h-10 sm:w-10 sm:h-10" />
</div>
<button
type="submit"
disabled={!input.trim() || isLoading}
onMouseDown={(event) => {
event.preventDefault();
onSubmit(event);
}}
onTouchStart={(event) => {
event.preventDefault();
onSubmit(event);
}}
className="absolute right-2 top-1/2 transform -translate-y-1/2 w-12 h-12 sm:w-12 sm:h-12 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed rounded-full flex items-center justify-center transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:ring-offset-gray-800"
>
<svg className="w-4 h-4 sm:w-5 sm:h-5 text-white transform rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
</svg>
</button>
<div
className={`absolute bottom-1 left-12 right-14 sm:right-40 text-xs text-gray-400 dark:text-gray-500 pointer-events-none hidden sm:block transition-opacity duration-200 ${
input.trim() ? 'opacity-0' : 'opacity-100'
}`}
>
{sendByCtrlEnter ? t('input.hintText.ctrlEnter') : t('input.hintText.enter')}
</div>
</div>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,138 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import ThinkingModeSelector from '../../ThinkingModeSelector.jsx';
import TokenUsagePie from '../../TokenUsagePie';
import type { PermissionMode, Provider } from '../types';
interface ChatInputControlsProps {
permissionMode: PermissionMode | string;
onModeSwitch: () => void;
provider: Provider | string;
thinkingMode: string;
setThinkingMode: React.Dispatch<React.SetStateAction<string>>;
tokenBudget: { used?: number; total?: number } | null;
slashCommandsCount: number;
onToggleCommandMenu: () => void;
hasInput: boolean;
onClearInput: () => void;
isUserScrolledUp: boolean;
hasMessages: boolean;
onScrollToBottom: () => void;
}
export default function ChatInputControls({
permissionMode,
onModeSwitch,
provider,
thinkingMode,
setThinkingMode,
tokenBudget,
slashCommandsCount,
onToggleCommandMenu,
hasInput,
onClearInput,
isUserScrolledUp,
hasMessages,
onScrollToBottom,
}: ChatInputControlsProps) {
const { t } = useTranslation('chat');
return (
<div className="flex items-center justify-center gap-3">
<button
type="button"
onClick={onModeSwitch}
className={`px-3 py-1.5 rounded-lg text-sm font-medium border transition-all duration-200 ${
permissionMode === 'default'
? 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600 hover:bg-gray-200 dark:hover:bg-gray-600'
: permissionMode === 'acceptEdits'
? 'bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-300 border-green-300 dark:border-green-600 hover:bg-green-100 dark:hover:bg-green-900/30'
: permissionMode === 'bypassPermissions'
? 'bg-orange-50 dark:bg-orange-900/20 text-orange-700 dark:text-orange-300 border-orange-300 dark:border-orange-600 hover:bg-orange-100 dark:hover:bg-orange-900/30'
: 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border-blue-300 dark:border-blue-600 hover:bg-blue-100 dark:hover:bg-blue-900/30'
}`}
title={t('input.clickToChangeMode')}
>
<div className="flex items-center gap-2">
<div
className={`w-2 h-2 rounded-full ${
permissionMode === 'default'
? 'bg-gray-500'
: permissionMode === 'acceptEdits'
? 'bg-green-500'
: permissionMode === 'bypassPermissions'
? 'bg-orange-500'
: 'bg-blue-500'
}`}
/>
<span>
{permissionMode === 'default' && t('codex.modes.default')}
{permissionMode === 'acceptEdits' && t('codex.modes.acceptEdits')}
{permissionMode === 'bypassPermissions' && t('codex.modes.bypassPermissions')}
{permissionMode === 'plan' && t('codex.modes.plan')}
</span>
</div>
</button>
{provider === 'claude' && (
<ThinkingModeSelector selectedMode={thinkingMode} onModeChange={setThinkingMode} onClose={() => {}} className="" />
)}
<TokenUsagePie used={tokenBudget?.used || 0} total={tokenBudget?.total || parseInt(import.meta.env.VITE_CONTEXT_WINDOW) || 160000} />
<button
type="button"
onClick={onToggleCommandMenu}
className="relative w-8 h-8 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 rounded-full flex items-center justify-center transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:ring-offset-gray-800"
title={t('input.showAllCommands')}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z"
/>
</svg>
{slashCommandsCount > 0 && (
<span
className="absolute -top-1 -right-1 bg-blue-600 text-white text-xs font-bold rounded-full w-5 h-5 flex items-center justify-center"
style={{ fontSize: '10px' }}
>
{slashCommandsCount}
</span>
)}
</button>
{hasInput && (
<button
type="button"
onClick={onClearInput}
className="w-8 h-8 bg-white dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-full flex items-center justify-center transition-all duration-200 group shadow-sm"
title="Clear input"
>
<svg
className="w-4 h-4 text-gray-600 dark:text-gray-300 group-hover:text-gray-800 dark:group-hover:text-gray-100 transition-colors"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
{isUserScrolledUp && hasMessages && (
<button
onClick={onScrollToBottom}
className="w-8 h-8 bg-blue-600 hover:bg-blue-700 text-white rounded-full shadow-lg flex items-center justify-center transition-all duration-200 hover:scale-105 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:ring-offset-gray-800"
title="Scroll to bottom"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 14l-7 7m0 0l-7-7m7 7V3" />
</svg>
</button>
)}
</div>
);
}

View File

@@ -0,0 +1,217 @@
import { useTranslation } from 'react-i18next';
import type { Dispatch, RefObject, SetStateAction } from 'react';
import ClaudeLogo from '../../ClaudeLogo.jsx';
import CursorLogo from '../../CursorLogo.jsx';
import CodexLogo from '../../CodexLogo.jsx';
import MessageComponent from '../messages/MessageComponent';
import ProviderSelectionEmptyState from './ProviderSelectionEmptyState';
import type { ChatMessage, Provider } from '../types';
import type { Project, ProjectSession } from '../../../types/app';
interface ChatMessagesPaneProps {
scrollContainerRef: RefObject<HTMLDivElement>;
onWheel: () => void;
onTouchMove: () => void;
isLoadingSessionMessages: boolean;
chatMessages: ChatMessage[];
selectedSession: ProjectSession | null;
currentSessionId: string | null;
provider: Provider | string;
setProvider: (provider: Provider | string) => void;
textareaRef: RefObject<HTMLTextAreaElement>;
claudeModel: string;
setClaudeModel: (model: string) => void;
cursorModel: string;
setCursorModel: (model: string) => void;
codexModel: string;
setCodexModel: (model: string) => void;
tasksEnabled: boolean;
isTaskMasterInstalled: boolean | null;
onShowAllTasks?: (() => void) | null;
setInput: Dispatch<SetStateAction<string>>;
isLoadingMoreMessages: boolean;
hasMoreMessages: boolean;
totalMessages: number;
sessionMessagesCount: number;
visibleMessageCount: number;
visibleMessages: ChatMessage[];
loadEarlierMessages: () => void;
createDiff: any;
onFileOpen?: (filePath: string, diffInfo?: unknown) => void;
onShowSettings?: () => void;
onGrantToolPermission: (suggestion: { entry: string; toolName: string }) => { success: boolean };
autoExpandTools?: boolean;
showRawParameters?: boolean;
showThinking?: boolean;
selectedProject: Project;
isLoading: boolean;
}
function AssistantThinkingIndicator() {
const selectedProvider = (localStorage.getItem('selected-provider') || 'claude') as Provider;
return (
<div className="chat-message assistant">
<div className="w-full">
<div className="flex items-center space-x-3 mb-2">
<div className="w-8 h-8 rounded-full flex items-center justify-center text-white text-sm flex-shrink-0 p-1 bg-transparent">
{selectedProvider === 'cursor' ? (
<CursorLogo className="w-full h-full" />
) : selectedProvider === 'codex' ? (
<CodexLogo className="w-full h-full" />
) : (
<ClaudeLogo className="w-full h-full" />
)}
</div>
<div className="text-sm font-medium text-gray-900 dark:text-white">
{selectedProvider === 'cursor' ? 'Cursor' : selectedProvider === 'codex' ? 'Codex' : 'Claude'}
</div>
</div>
<div className="w-full text-sm text-gray-500 dark:text-gray-400 pl-3 sm:pl-0">
<div className="flex items-center space-x-1">
<div className="animate-pulse">.</div>
<div className="animate-pulse" style={{ animationDelay: '0.2s' }}>
.
</div>
<div className="animate-pulse" style={{ animationDelay: '0.4s' }}>
.
</div>
<span className="ml-2">Thinking...</span>
</div>
</div>
</div>
</div>
);
}
export default function ChatMessagesPane({
scrollContainerRef,
onWheel,
onTouchMove,
isLoadingSessionMessages,
chatMessages,
selectedSession,
currentSessionId,
provider,
setProvider,
textareaRef,
claudeModel,
setClaudeModel,
cursorModel,
setCursorModel,
codexModel,
setCodexModel,
tasksEnabled,
isTaskMasterInstalled,
onShowAllTasks,
setInput,
isLoadingMoreMessages,
hasMoreMessages,
totalMessages,
sessionMessagesCount,
visibleMessageCount,
visibleMessages,
loadEarlierMessages,
createDiff,
onFileOpen,
onShowSettings,
onGrantToolPermission,
autoExpandTools,
showRawParameters,
showThinking,
selectedProject,
isLoading,
}: ChatMessagesPaneProps) {
const { t } = useTranslation('chat');
return (
<div
ref={scrollContainerRef}
onWheel={onWheel}
onTouchMove={onTouchMove}
className="flex-1 overflow-y-auto overflow-x-hidden px-0 py-3 sm:p-4 space-y-3 sm:space-y-4 relative"
>
{isLoadingSessionMessages && chatMessages.length === 0 ? (
<div className="text-center text-gray-500 dark:text-gray-400 mt-8">
<div className="flex items-center justify-center space-x-2">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-gray-400" />
<p>{t('session.loading.sessionMessages')}</p>
</div>
</div>
) : chatMessages.length === 0 ? (
<ProviderSelectionEmptyState
selectedSession={selectedSession}
currentSessionId={currentSessionId}
provider={provider}
setProvider={setProvider}
textareaRef={textareaRef}
claudeModel={claudeModel}
setClaudeModel={setClaudeModel}
cursorModel={cursorModel}
setCursorModel={setCursorModel}
codexModel={codexModel}
setCodexModel={setCodexModel}
tasksEnabled={tasksEnabled}
isTaskMasterInstalled={isTaskMasterInstalled}
onShowAllTasks={onShowAllTasks}
setInput={setInput}
/>
) : (
<>
{isLoadingMoreMessages && (
<div className="text-center text-gray-500 dark:text-gray-400 py-3">
<div className="flex items-center justify-center space-x-2">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-gray-400" />
<p className="text-sm">{t('session.loading.olderMessages')}</p>
</div>
</div>
)}
{hasMoreMessages && !isLoadingMoreMessages && (
<div className="text-center text-gray-500 dark:text-gray-400 text-sm py-2 border-b border-gray-200 dark:border-gray-700">
{totalMessages > 0 && (
<span>
{t('session.messages.showingOf', { shown: sessionMessagesCount, total: totalMessages })} |
<span className="text-xs">{t('session.messages.scrollToLoad')}</span>
</span>
)}
</div>
)}
{!hasMoreMessages && chatMessages.length > visibleMessageCount && (
<div className="text-center text-gray-500 dark:text-gray-400 text-sm py-2 border-b border-gray-200 dark:border-gray-700">
{t('session.messages.showingLast', { count: visibleMessageCount, total: chatMessages.length })} |
<button className="ml-1 text-blue-600 hover:text-blue-700 underline" onClick={loadEarlierMessages}>
{t('session.messages.loadEarlier')}
</button>
</div>
)}
{visibleMessages.map((message, index) => {
const prevMessage = index > 0 ? visibleMessages[index - 1] : null;
return (
<MessageComponent
key={index}
message={message}
index={index}
prevMessage={prevMessage}
createDiff={createDiff}
onFileOpen={onFileOpen}
onShowSettings={onShowSettings}
onGrantToolPermission={onGrantToolPermission}
autoExpandTools={autoExpandTools}
showRawParameters={showRawParameters}
showThinking={showThinking}
selectedProject={selectedProject}
provider={provider}
/>
);
})}
</>
)}
{isLoading && <AssistantThinkingIndicator />}
</div>
);
}

View File

@@ -0,0 +1,109 @@
import React from 'react';
import type { PendingPermissionRequest } from '../types';
import { buildClaudeToolPermissionEntry, formatToolInputForDisplay } from '../utils/chatPermissions';
import { getClaudeSettings } from '../utils/chatStorage';
interface PermissionRequestsBannerProps {
pendingPermissionRequests: PendingPermissionRequest[];
handlePermissionDecision: (
requestIds: string | string[],
decision: { allow?: boolean; message?: string; rememberEntry?: string | null; updatedInput?: unknown },
) => void;
handleGrantToolPermission: (suggestion: { entry: string; toolName: string }) => { success: boolean };
}
export default function PermissionRequestsBanner({
pendingPermissionRequests,
handlePermissionDecision,
handleGrantToolPermission,
}: PermissionRequestsBannerProps) {
if (!pendingPermissionRequests.length) {
return null;
}
return (
<div className="mb-3 space-y-2">
{pendingPermissionRequests.map((request) => {
const rawInput = formatToolInputForDisplay(request.input);
const permissionEntry = buildClaudeToolPermissionEntry(request.toolName, rawInput);
const settings = getClaudeSettings();
const alreadyAllowed = permissionEntry ? settings.allowedTools.includes(permissionEntry) : false;
const rememberLabel = alreadyAllowed ? 'Allow (saved)' : 'Allow & remember';
const matchingRequestIds = permissionEntry
? pendingPermissionRequests
.filter(
(item) =>
buildClaudeToolPermissionEntry(item.toolName, formatToolInputForDisplay(item.input)) === permissionEntry,
)
.map((item) => item.requestId)
: [request.requestId];
return (
<div
key={request.requestId}
className="rounded-lg border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20 p-3 shadow-sm"
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="text-sm font-semibold text-amber-900 dark:text-amber-100">Permission required</div>
<div className="text-xs text-amber-800 dark:text-amber-200">
Tool: <span className="font-mono">{request.toolName}</span>
</div>
</div>
{permissionEntry && (
<div className="text-xs text-amber-700 dark:text-amber-300">
Allow rule: <span className="font-mono">{permissionEntry}</span>
</div>
)}
</div>
{rawInput && (
<details className="mt-2">
<summary className="cursor-pointer text-xs text-amber-800 dark:text-amber-200 hover:text-amber-900 dark:hover:text-amber-100">
View tool input
</summary>
<pre className="mt-2 max-h-40 overflow-auto rounded-md bg-white/80 dark:bg-gray-900/60 border border-amber-200/60 dark:border-amber-800/60 p-2 text-xs text-amber-900 dark:text-amber-100 whitespace-pre-wrap">
{rawInput}
</pre>
</details>
)}
<div className="mt-3 flex flex-wrap gap-2">
<button
type="button"
onClick={() => handlePermissionDecision(request.requestId, { allow: true })}
className="inline-flex items-center gap-2 rounded-md bg-amber-600 text-white text-xs font-medium px-3 py-1.5 hover:bg-amber-700 transition-colors"
>
Allow once
</button>
<button
type="button"
onClick={() => {
if (permissionEntry && !alreadyAllowed) {
handleGrantToolPermission({ entry: permissionEntry, toolName: request.toolName });
}
handlePermissionDecision(matchingRequestIds, { allow: true, rememberEntry: permissionEntry });
}}
className={`inline-flex items-center gap-2 rounded-md text-xs font-medium px-3 py-1.5 border transition-colors ${
permissionEntry
? 'border-amber-300 text-amber-800 hover:bg-amber-100 dark:border-amber-700 dark:text-amber-100 dark:hover:bg-amber-900/30'
: 'border-gray-300 text-gray-400 cursor-not-allowed'
}`}
disabled={!permissionEntry}
>
{rememberLabel}
</button>
<button
type="button"
onClick={() => handlePermissionDecision(request.requestId, { allow: false, message: 'User denied tool use' })}
className="inline-flex items-center gap-2 rounded-md text-xs font-medium px-3 py-1.5 border border-red-300 text-red-700 hover:bg-red-50 dark:border-red-800 dark:text-red-200 dark:hover:bg-red-900/30 transition-colors"
>
Deny
</button>
</div>
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,226 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import ClaudeLogo from '../../ClaudeLogo.jsx';
import CursorLogo from '../../CursorLogo.jsx';
import CodexLogo from '../../CodexLogo.jsx';
import NextTaskBanner from '../../NextTaskBanner.jsx';
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../../../shared/modelConstants';
import type { Provider } from '../types';
import type { ProjectSession } from '../../../types/app';
interface ProviderSelectionEmptyStateProps {
selectedSession: ProjectSession | null;
currentSessionId: string | null;
provider: Provider | string;
setProvider: (next: Provider | string) => void;
textareaRef: React.RefObject<HTMLTextAreaElement>;
claudeModel: string;
setClaudeModel: (model: string) => void;
cursorModel: string;
setCursorModel: (model: string) => void;
codexModel: string;
setCodexModel: (model: string) => void;
tasksEnabled: boolean;
isTaskMasterInstalled: boolean | null;
onShowAllTasks?: (() => void) | null;
setInput: React.Dispatch<React.SetStateAction<string>>;
}
export default function ProviderSelectionEmptyState({
selectedSession,
currentSessionId,
provider,
setProvider,
textareaRef,
claudeModel,
setClaudeModel,
cursorModel,
setCursorModel,
codexModel,
setCodexModel,
tasksEnabled,
isTaskMasterInstalled,
onShowAllTasks,
setInput,
}: ProviderSelectionEmptyStateProps) {
const { t } = useTranslation('chat');
const selectProvider = (nextProvider: Provider) => {
setProvider(nextProvider);
localStorage.setItem('selected-provider', nextProvider);
setTimeout(() => textareaRef.current?.focus(), 100);
};
return (
<div className="flex items-center justify-center h-full">
{!selectedSession && !currentSessionId && (
<div className="text-center px-6 sm:px-4 py-8">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-3">{t('providerSelection.title')}</h2>
<p className="text-gray-600 dark:text-gray-400 mb-8">{t('providerSelection.description')}</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center mb-8">
<button
onClick={() => selectProvider('claude')}
className={`group relative w-64 h-32 bg-white dark:bg-gray-800 rounded-xl border-2 transition-all duration-200 hover:scale-105 hover:shadow-xl ${
provider === 'claude'
? 'border-blue-500 shadow-lg ring-2 ring-blue-500/20'
: 'border-gray-200 dark:border-gray-700 hover:border-blue-400'
}`}
>
<div className="flex flex-col items-center justify-center h-full gap-3">
<ClaudeLogo className="w-10 h-10" />
<div>
<p className="font-semibold text-gray-900 dark:text-white">Claude Code</p>
<p className="text-xs text-gray-500 dark:text-gray-400">{t('providerSelection.providerInfo.anthropic')}</p>
</div>
</div>
{provider === 'claude' && (
<div className="absolute top-2 right-2">
<div className="w-5 h-5 bg-blue-500 rounded-full flex items-center justify-center">
<svg className="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
</div>
</div>
)}
</button>
<button
onClick={() => selectProvider('cursor')}
className={`group relative w-64 h-32 bg-white dark:bg-gray-800 rounded-xl border-2 transition-all duration-200 hover:scale-105 hover:shadow-xl ${
provider === 'cursor'
? 'border-purple-500 shadow-lg ring-2 ring-purple-500/20'
: 'border-gray-200 dark:border-gray-700 hover:border-purple-400'
}`}
>
<div className="flex flex-col items-center justify-center h-full gap-3">
<CursorLogo className="w-10 h-10" />
<div>
<p className="font-semibold text-gray-900 dark:text-white">Cursor</p>
<p className="text-xs text-gray-500 dark:text-gray-400">{t('providerSelection.providerInfo.cursorEditor')}</p>
</div>
</div>
{provider === 'cursor' && (
<div className="absolute top-2 right-2">
<div className="w-5 h-5 bg-purple-500 rounded-full flex items-center justify-center">
<svg className="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
</div>
</div>
)}
</button>
<button
onClick={() => selectProvider('codex')}
className={`group relative w-64 h-32 bg-white dark:bg-gray-800 rounded-xl border-2 transition-all duration-200 hover:scale-105 hover:shadow-xl ${
provider === 'codex'
? 'border-gray-800 dark:border-gray-300 shadow-lg ring-2 ring-gray-800/20 dark:ring-gray-300/20'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-500 dark:hover:border-gray-400'
}`}
>
<div className="flex flex-col items-center justify-center h-full gap-3">
<CodexLogo className="w-10 h-10" />
<div>
<p className="font-semibold text-gray-900 dark:text-white">Codex</p>
<p className="text-xs text-gray-500 dark:text-gray-400">{t('providerSelection.providerInfo.openai')}</p>
</div>
</div>
{provider === 'codex' && (
<div className="absolute top-2 right-2">
<div className="w-5 h-5 bg-gray-800 dark:bg-gray-300 rounded-full flex items-center justify-center">
<svg className="w-3 h-3 text-white dark:text-gray-800" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
</div>
</div>
)}
</button>
</div>
<div className={`mb-6 transition-opacity duration-200 ${provider ? 'opacity-100' : 'opacity-0 pointer-events-none'}`}>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{t('providerSelection.selectModel')}</label>
{provider === 'claude' ? (
<select
value={claudeModel}
onChange={(e) => {
const newModel = e.target.value;
setClaudeModel(newModel);
localStorage.setItem('claude-model', newModel);
}}
className="pl-4 pr-10 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 min-w-[140px]"
>
{CLAUDE_MODELS.OPTIONS.map(({ value, label }) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
) : provider === 'codex' ? (
<select
value={codexModel}
onChange={(e) => {
const newModel = e.target.value;
setCodexModel(newModel);
localStorage.setItem('codex-model', newModel);
}}
className="pl-4 pr-10 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-gray-500 min-w-[140px]"
>
{CODEX_MODELS.OPTIONS.map(({ value, label }) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
) : (
<select
value={cursorModel}
onChange={(e) => {
const newModel = e.target.value;
setCursorModel(newModel);
localStorage.setItem('cursor-model', newModel);
}}
className="pl-4 pr-10 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 min-w-[140px]"
disabled={provider !== 'cursor'}
>
{CURSOR_MODELS.OPTIONS.map(({ value, label }) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
)}
</div>
<p className="text-sm text-gray-500 dark:text-gray-400">
{provider === 'claude'
? t('providerSelection.readyPrompt.claude', { model: claudeModel })
: provider === 'cursor'
? t('providerSelection.readyPrompt.cursor', { model: cursorModel })
: provider === 'codex'
? t('providerSelection.readyPrompt.codex', { model: codexModel })
: t('providerSelection.readyPrompt.default')}
</p>
{provider && tasksEnabled && isTaskMasterInstalled && (
<div className="mt-4 px-4 sm:px-0">
<NextTaskBanner onStartTask={() => setInput('Start the next task')} onShowAllTasks={onShowAllTasks} />
</div>
)}
</div>
)}
{selectedSession && (
<div className="text-center text-gray-500 dark:text-gray-400 px-6 sm:px-4">
<p className="font-bold text-lg sm:text-xl mb-3">{t('session.continue.title')}</p>
<p className="text-sm sm:text-base leading-relaxed">{t('session.continue.description')}</p>
{tasksEnabled && isTaskMasterInstalled && (
<div className="mt-4 px-4 sm:px-0">
<NextTaskBanner onStartTask={() => setInput('Start the next task')} onShowAllTasks={onShowAllTasks} />
</div>
)}
</div>
)}
</div>
);
}