mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-02-16 05:37:31 +00:00
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:
346
src/components/chat/view/ChatComposer.tsx
Normal file
346
src/components/chat/view/ChatComposer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
138
src/components/chat/view/ChatInputControls.tsx
Normal file
138
src/components/chat/view/ChatInputControls.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
217
src/components/chat/view/ChatMessagesPane.tsx
Normal file
217
src/components/chat/view/ChatMessagesPane.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
109
src/components/chat/view/PermissionRequestsBanner.tsx
Normal file
109
src/components/chat/view/PermissionRequestsBanner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
226
src/components/chat/view/ProviderSelectionEmptyState.tsx
Normal file
226
src/components/chat/view/ProviderSelectionEmptyState.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user