mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-02-14 20:57:32 +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:
File diff suppressed because it is too large
Load Diff
351
src/components/ChatInterface.tsx
Normal file
351
src/components/ChatInterface.tsx
Normal 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);
|
||||
@@ -18,7 +18,6 @@ import { useUiPreferences } from '../hooks/useUiPreferences';
|
||||
import { useEditorSidebar } from '../hooks/main-content/useEditorSidebar';
|
||||
import type { Project } from '../types/app';
|
||||
|
||||
const AnyChatInterface = ChatInterface as any;
|
||||
const AnyStandaloneShell = StandaloneShell as any;
|
||||
const AnyGitPanel = GitPanel as any;
|
||||
|
||||
@@ -113,7 +112,7 @@ function MainContent({
|
||||
<div className={`flex-1 flex flex-col min-h-0 overflow-hidden ${editorExpanded ? 'hidden' : ''}`}>
|
||||
<div className={`h-full ${activeTab === 'chat' ? 'block' : 'hidden'}`}>
|
||||
<ErrorBoundary showDetails>
|
||||
<AnyChatInterface
|
||||
<ChatInterface
|
||||
selectedProject={selectedProject}
|
||||
selectedSession={selectedSession}
|
||||
ws={ws}
|
||||
|
||||
48
src/components/chat/input/ImageAttachment.tsx
Normal file
48
src/components/chat/input/ImageAttachment.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface ImageAttachmentProps {
|
||||
file: File;
|
||||
onRemove: () => void;
|
||||
uploadProgress?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const ImageAttachment = ({ file, onRemove, uploadProgress, error }: ImageAttachmentProps) => {
|
||||
const [preview, setPreview] = useState<string | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
const url = URL.createObjectURL(file);
|
||||
setPreview(url);
|
||||
return () => URL.revokeObjectURL(url);
|
||||
}, [file]);
|
||||
|
||||
return (
|
||||
<div className="relative group">
|
||||
<img src={preview} alt={file.name} className="w-20 h-20 object-cover rounded" />
|
||||
{uploadProgress !== undefined && uploadProgress < 100 && (
|
||||
<div className="absolute inset-0 bg-black/50 flex items-center justify-center">
|
||||
<div className="text-white text-xs">{uploadProgress}%</div>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="absolute inset-0 bg-red-500/50 flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={onRemove}
|
||||
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full p-1 opacity-0 group-hover:opacity-100"
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageAttachment;
|
||||
|
||||
|
||||
188
src/components/chat/markdown/Markdown.tsx
Normal file
188
src/components/chat/markdown/Markdown.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import remarkMath from 'remark-math';
|
||||
import rehypeKatex from 'rehype-katex';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { normalizeInlineCodeFences } from '../utils/chatFormatting';
|
||||
|
||||
type MarkdownProps = {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
type CodeBlockProps = {
|
||||
node?: any;
|
||||
inline?: boolean;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
const CodeBlock = ({ node, inline, className, children, ...props }: CodeBlockProps) => {
|
||||
const { t } = useTranslation('chat');
|
||||
const [copied, setCopied] = useState(false);
|
||||
const raw = Array.isArray(children) ? children.join('') : String(children ?? '');
|
||||
const looksMultiline = /[\r\n]/.test(raw);
|
||||
const inlineDetected = inline || (node && node.type === 'inlineCode');
|
||||
const shouldInline = inlineDetected || !looksMultiline;
|
||||
|
||||
if (shouldInline) {
|
||||
return (
|
||||
<code
|
||||
className={`font-mono text-[0.9em] px-1.5 py-0.5 rounded-md bg-gray-100 text-gray-900 border border-gray-200 dark:bg-gray-800/60 dark:text-gray-100 dark:border-gray-700 whitespace-pre-wrap break-words ${
|
||||
className || ''
|
||||
}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
const language = match ? match[1] : 'text';
|
||||
const textToCopy = raw;
|
||||
|
||||
const handleCopy = () => {
|
||||
const doSet = () => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
};
|
||||
try {
|
||||
if (navigator && navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(textToCopy).then(doSet).catch(() => {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = textToCopy;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.opacity = '0';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
} catch {}
|
||||
document.body.removeChild(ta);
|
||||
doSet();
|
||||
});
|
||||
} else {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = textToCopy;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.opacity = '0';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
} catch {}
|
||||
document.body.removeChild(ta);
|
||||
doSet();
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative group my-2">
|
||||
{language && language !== 'text' && (
|
||||
<div className="absolute top-2 left-3 z-10 text-xs text-gray-400 font-medium uppercase">{language}</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="absolute top-2 right-2 z-10 opacity-0 group-hover:opacity-100 focus:opacity-100 active:opacity-100 transition-opacity text-xs px-2 py-1 rounded-md bg-gray-700/80 hover:bg-gray-700 text-white border border-gray-600"
|
||||
title={copied ? t('codeBlock.copied') : t('codeBlock.copyCode')}
|
||||
aria-label={copied ? t('codeBlock.copied') : t('codeBlock.copyCode')}
|
||||
>
|
||||
{copied ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<svg className="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{t('codeBlock.copied')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1">
|
||||
<svg
|
||||
className="w-3.5 h-3.5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
||||
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"></path>
|
||||
</svg>
|
||||
{t('codeBlock.copy')}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<SyntaxHighlighter
|
||||
language={language}
|
||||
style={oneDark}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
borderRadius: '0.5rem',
|
||||
fontSize: '0.875rem',
|
||||
padding: language && language !== 'text' ? '2rem 1rem 1rem 1rem' : '1rem',
|
||||
}}
|
||||
codeTagProps={{
|
||||
style: {
|
||||
fontFamily:
|
||||
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{raw}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const markdownComponents = {
|
||||
code: CodeBlock,
|
||||
blockquote: ({ children }: { children?: React.ReactNode }) => (
|
||||
<blockquote className="border-l-4 border-gray-300 dark:border-gray-600 pl-4 italic text-gray-600 dark:text-gray-400 my-2">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
a: ({ href, children }: { href?: string; children?: React.ReactNode }) => (
|
||||
<a href={href} className="text-blue-600 dark:text-blue-400 hover:underline" target="_blank" rel="noopener noreferrer">
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
p: ({ children }: { children?: React.ReactNode }) => <div className="mb-2 last:mb-0">{children}</div>,
|
||||
table: ({ children }: { children?: React.ReactNode }) => (
|
||||
<div className="overflow-x-auto my-2">
|
||||
<table className="min-w-full border-collapse border border-gray-200 dark:border-gray-700">{children}</table>
|
||||
</div>
|
||||
),
|
||||
thead: ({ children }: { children?: React.ReactNode }) => <thead className="bg-gray-50 dark:bg-gray-800">{children}</thead>,
|
||||
th: ({ children }: { children?: React.ReactNode }) => (
|
||||
<th className="px-3 py-2 text-left text-sm font-semibold border border-gray-200 dark:border-gray-700">{children}</th>
|
||||
),
|
||||
td: ({ children }: { children?: React.ReactNode }) => (
|
||||
<td className="px-3 py-2 align-top text-sm border border-gray-200 dark:border-gray-700">{children}</td>
|
||||
),
|
||||
};
|
||||
|
||||
export function Markdown({ children, className }: MarkdownProps) {
|
||||
const content = normalizeInlineCodeFences(String(children ?? ''));
|
||||
const remarkPlugins = useMemo(() => [remarkGfm, remarkMath], []);
|
||||
const rehypePlugins = useMemo(() => [rehypeKatex], []);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<ReactMarkdown remarkPlugins={remarkPlugins} rehypePlugins={rehypePlugins} components={markdownComponents as any}>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1359
src/components/chat/messages/MessageComponent.tsx
Normal file
1359
src/components/chat/messages/MessageComponent.tsx
Normal file
File diff suppressed because it is too large
Load Diff
92
src/components/chat/types.ts
Normal file
92
src/components/chat/types.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import type { Project, ProjectSession, SessionProvider } from '../../types/app';
|
||||
|
||||
export type Provider = SessionProvider;
|
||||
|
||||
export type PermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan';
|
||||
|
||||
export interface ChatImage {
|
||||
data: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ToolResult {
|
||||
content?: unknown;
|
||||
isError?: boolean;
|
||||
timestamp?: string | number | Date;
|
||||
toolUseResult?: unknown;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
type: string;
|
||||
content?: string;
|
||||
timestamp: string | number | Date;
|
||||
images?: ChatImage[];
|
||||
reasoning?: string;
|
||||
isThinking?: boolean;
|
||||
isStreaming?: boolean;
|
||||
isInteractivePrompt?: boolean;
|
||||
isToolUse?: boolean;
|
||||
toolName?: string;
|
||||
toolInput?: unknown;
|
||||
toolResult?: ToolResult | null;
|
||||
toolId?: string;
|
||||
toolCallId?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ClaudeSettings {
|
||||
allowedTools: string[];
|
||||
disallowedTools: string[];
|
||||
skipPermissions: boolean;
|
||||
projectSortOrder: string;
|
||||
lastUpdated?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ClaudePermissionSuggestion {
|
||||
toolName: string;
|
||||
entry: string;
|
||||
isAllowed: boolean;
|
||||
}
|
||||
|
||||
export interface PermissionGrantResult {
|
||||
success: boolean;
|
||||
alreadyAllowed?: boolean;
|
||||
updatedSettings?: ClaudeSettings;
|
||||
}
|
||||
|
||||
export interface PendingPermissionRequest {
|
||||
requestId: string;
|
||||
toolName: string;
|
||||
input?: unknown;
|
||||
context?: unknown;
|
||||
sessionId?: string | null;
|
||||
receivedAt?: Date;
|
||||
}
|
||||
|
||||
export interface ChatInterfaceProps {
|
||||
selectedProject: Project | null;
|
||||
selectedSession: ProjectSession | null;
|
||||
ws: WebSocket | null;
|
||||
sendMessage: (message: unknown) => void;
|
||||
latestMessage: any;
|
||||
onFileOpen?: (filePath: string, diffInfo?: any) => void;
|
||||
onInputFocusChange?: (focused: boolean) => void;
|
||||
onSessionActive?: (sessionId?: string | null) => void;
|
||||
onSessionInactive?: (sessionId?: string | null) => void;
|
||||
onSessionProcessing?: (sessionId?: string | null) => void;
|
||||
onSessionNotProcessing?: (sessionId?: string | null) => void;
|
||||
processingSessions?: Set<string>;
|
||||
onReplaceTemporarySession?: (sessionId?: string | null) => void;
|
||||
onNavigateToSession?: (targetSessionId: string) => void;
|
||||
onShowSettings?: () => void;
|
||||
autoExpandTools?: boolean;
|
||||
showRawParameters?: boolean;
|
||||
showThinking?: boolean;
|
||||
autoScrollToBottom?: boolean;
|
||||
sendByCtrlEnter?: boolean;
|
||||
externalMessageUpdate?: number;
|
||||
onTaskClick?: (...args: unknown[]) => void;
|
||||
onShowAllTasks?: (() => void) | null;
|
||||
}
|
||||
86
src/components/chat/utils/chatFormatting.ts
Normal file
86
src/components/chat/utils/chatFormatting.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
export function decodeHtmlEntities(text: string) {
|
||||
if (!text) return text;
|
||||
return text
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&/g, '&');
|
||||
}
|
||||
|
||||
export function normalizeInlineCodeFences(text: string) {
|
||||
if (!text || typeof text !== 'string') return text;
|
||||
try {
|
||||
return text.replace(/```\s*([^\n\r]+?)\s*```/g, '`$1`');
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
export function unescapeWithMathProtection(text: string) {
|
||||
if (!text || typeof text !== 'string') return text;
|
||||
|
||||
const mathBlocks: string[] = [];
|
||||
const placeholderPrefix = '__MATH_BLOCK_';
|
||||
const placeholderSuffix = '__';
|
||||
|
||||
let processedText = text.replace(/\$\$([\s\S]*?)\$\$|\$([^\$\n]+?)\$/g, (match) => {
|
||||
const index = mathBlocks.length;
|
||||
mathBlocks.push(match);
|
||||
return `${placeholderPrefix}${index}${placeholderSuffix}`;
|
||||
});
|
||||
|
||||
processedText = processedText.replace(/\\n/g, '\n').replace(/\\t/g, '\t').replace(/\\r/g, '\r');
|
||||
|
||||
processedText = processedText.replace(
|
||||
new RegExp(`${placeholderPrefix}(\\d+)${placeholderSuffix}`, 'g'),
|
||||
(match, index) => {
|
||||
return mathBlocks[parseInt(index, 10)];
|
||||
},
|
||||
);
|
||||
|
||||
return processedText;
|
||||
}
|
||||
|
||||
export function escapeRegExp(value: string) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
export function formatUsageLimitText(text: string) {
|
||||
try {
|
||||
if (typeof text !== 'string') return text;
|
||||
return text.replace(/Claude AI usage limit reached\|(\d{10,13})/g, (match, ts) => {
|
||||
let timestampMs = parseInt(ts, 10);
|
||||
if (!Number.isFinite(timestampMs)) return match;
|
||||
if (timestampMs < 1e12) timestampMs *= 1000;
|
||||
const reset = new Date(timestampMs);
|
||||
|
||||
const timeStr = new Intl.DateTimeFormat(undefined, {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
}).format(reset);
|
||||
|
||||
const offsetMinutesLocal = -reset.getTimezoneOffset();
|
||||
const sign = offsetMinutesLocal >= 0 ? '+' : '-';
|
||||
const abs = Math.abs(offsetMinutesLocal);
|
||||
const offH = Math.floor(abs / 60);
|
||||
const offM = abs % 60;
|
||||
const gmt = `GMT${sign}${offH}${offM ? ':' + String(offM).padStart(2, '0') : ''}`;
|
||||
const tzId = Intl.DateTimeFormat().resolvedOptions().timeZone || '';
|
||||
const cityRaw = tzId.split('/').pop() || '';
|
||||
const city = cityRaw
|
||||
.replace(/_/g, ' ')
|
||||
.toLowerCase()
|
||||
.replace(/\b\w/g, (char) => char.toUpperCase());
|
||||
const tzHuman = city ? `${gmt} (${city})` : gmt;
|
||||
|
||||
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
const dateReadable = `${reset.getDate()} ${months[reset.getMonth()]} ${reset.getFullYear()}`;
|
||||
|
||||
return `Claude usage limit reached. Your limit will reset at **${timeStr} ${tzHuman}** - ${dateReadable}`;
|
||||
});
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
64
src/components/chat/utils/chatPermissions.ts
Normal file
64
src/components/chat/utils/chatPermissions.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { safeJsonParse } from '../../../lib/utils.js';
|
||||
import type { ChatMessage, ClaudePermissionSuggestion, PermissionGrantResult } from '../types';
|
||||
import { CLAUDE_SETTINGS_KEY, getClaudeSettings, safeLocalStorage } from './chatStorage';
|
||||
|
||||
export function buildClaudeToolPermissionEntry(toolName?: string, toolInput?: unknown) {
|
||||
if (!toolName) return null;
|
||||
if (toolName !== 'Bash') return toolName;
|
||||
|
||||
const parsed = safeJsonParse(toolInput);
|
||||
const command = typeof parsed?.command === 'string' ? parsed.command.trim() : '';
|
||||
if (!command) return toolName;
|
||||
|
||||
const tokens = command.split(/\s+/);
|
||||
if (tokens.length === 0) return toolName;
|
||||
|
||||
if (tokens[0] === 'git' && tokens[1]) {
|
||||
return `Bash(${tokens[0]} ${tokens[1]}:*)`;
|
||||
}
|
||||
return `Bash(${tokens[0]}:*)`;
|
||||
}
|
||||
|
||||
export function formatToolInputForDisplay(input: unknown) {
|
||||
if (input === undefined || input === null) return '';
|
||||
if (typeof input === 'string') return input;
|
||||
try {
|
||||
return JSON.stringify(input, null, 2);
|
||||
} catch {
|
||||
return String(input);
|
||||
}
|
||||
}
|
||||
|
||||
export function getClaudePermissionSuggestion(
|
||||
message: ChatMessage | null | undefined,
|
||||
provider: string,
|
||||
): ClaudePermissionSuggestion | null {
|
||||
if (provider !== 'claude') return null;
|
||||
if (!message?.toolResult?.isError) return null;
|
||||
|
||||
const toolName = message?.toolName;
|
||||
const entry = buildClaudeToolPermissionEntry(toolName, message.toolInput);
|
||||
if (!entry) return null;
|
||||
|
||||
const settings = getClaudeSettings();
|
||||
const isAllowed = settings.allowedTools.includes(entry);
|
||||
return { toolName: toolName || 'UnknownTool', entry, isAllowed };
|
||||
}
|
||||
|
||||
export function grantClaudeToolPermission(entry: string | null): PermissionGrantResult {
|
||||
if (!entry) return { success: false };
|
||||
|
||||
const settings = getClaudeSettings();
|
||||
const alreadyAllowed = settings.allowedTools.includes(entry);
|
||||
const nextAllowed = alreadyAllowed ? settings.allowedTools : [...settings.allowedTools, entry];
|
||||
const nextDisallowed = settings.disallowedTools.filter((tool) => tool !== entry);
|
||||
const updatedSettings = {
|
||||
...settings,
|
||||
allowedTools: nextAllowed,
|
||||
disallowedTools: nextDisallowed,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
|
||||
safeLocalStorage.setItem(CLAUDE_SETTINGS_KEY, JSON.stringify(updatedSettings));
|
||||
return { success: true, alreadyAllowed, updatedSettings };
|
||||
}
|
||||
105
src/components/chat/utils/chatStorage.ts
Normal file
105
src/components/chat/utils/chatStorage.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import type { ClaudeSettings } from '../types';
|
||||
|
||||
export const CLAUDE_SETTINGS_KEY = 'claude-settings';
|
||||
|
||||
export const safeLocalStorage = {
|
||||
setItem: (key: string, value: string) => {
|
||||
try {
|
||||
if (key.startsWith('chat_messages_') && typeof value === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (Array.isArray(parsed) && parsed.length > 50) {
|
||||
const truncated = parsed.slice(-50);
|
||||
value = JSON.stringify(truncated);
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.warn('Could not parse chat messages for truncation:', parseError);
|
||||
}
|
||||
}
|
||||
|
||||
localStorage.setItem(key, value);
|
||||
} catch (error: any) {
|
||||
if (error?.name === 'QuotaExceededError') {
|
||||
console.warn('localStorage quota exceeded, clearing old data');
|
||||
|
||||
const keys = Object.keys(localStorage);
|
||||
const chatKeys = keys.filter((k) => k.startsWith('chat_messages_')).sort();
|
||||
|
||||
if (chatKeys.length > 3) {
|
||||
chatKeys.slice(0, chatKeys.length - 3).forEach((k) => {
|
||||
localStorage.removeItem(k);
|
||||
});
|
||||
}
|
||||
|
||||
const draftKeys = keys.filter((k) => k.startsWith('draft_input_'));
|
||||
draftKeys.forEach((k) => {
|
||||
localStorage.removeItem(k);
|
||||
});
|
||||
|
||||
try {
|
||||
localStorage.setItem(key, value);
|
||||
} catch (retryError) {
|
||||
console.error('Failed to save to localStorage even after cleanup:', retryError);
|
||||
if (key.startsWith('chat_messages_') && typeof value === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (Array.isArray(parsed) && parsed.length > 10) {
|
||||
const minimal = parsed.slice(-10);
|
||||
localStorage.setItem(key, JSON.stringify(minimal));
|
||||
}
|
||||
} catch (finalError) {
|
||||
console.error('Final save attempt failed:', finalError);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.error('localStorage error:', error);
|
||||
}
|
||||
}
|
||||
},
|
||||
getItem: (key: string): string | null => {
|
||||
try {
|
||||
return localStorage.getItem(key);
|
||||
} catch (error) {
|
||||
console.error('localStorage getItem error:', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
try {
|
||||
localStorage.removeItem(key);
|
||||
} catch (error) {
|
||||
console.error('localStorage removeItem error:', error);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export function getClaudeSettings(): ClaudeSettings {
|
||||
const raw = safeLocalStorage.getItem(CLAUDE_SETTINGS_KEY);
|
||||
if (!raw) {
|
||||
return {
|
||||
allowedTools: [],
|
||||
disallowedTools: [],
|
||||
skipPermissions: false,
|
||||
projectSortOrder: 'name',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return {
|
||||
...parsed,
|
||||
allowedTools: Array.isArray(parsed.allowedTools) ? parsed.allowedTools : [],
|
||||
disallowedTools: Array.isArray(parsed.disallowedTools) ? parsed.disallowedTools : [],
|
||||
skipPermissions: Boolean(parsed.skipPermissions),
|
||||
projectSortOrder: parsed.projectSortOrder || 'name',
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
allowedTools: [],
|
||||
disallowedTools: [],
|
||||
skipPermissions: false,
|
||||
projectSortOrder: 'name',
|
||||
};
|
||||
}
|
||||
}
|
||||
473
src/components/chat/utils/messageTransforms.ts
Normal file
473
src/components/chat/utils/messageTransforms.ts
Normal file
@@ -0,0 +1,473 @@
|
||||
import type { ChatMessage } from '../types';
|
||||
import { decodeHtmlEntities, unescapeWithMathProtection } from './chatFormatting';
|
||||
|
||||
export interface DiffLine {
|
||||
type: 'added' | 'removed';
|
||||
content: string;
|
||||
lineNum: number;
|
||||
}
|
||||
|
||||
export type DiffCalculator = (oldStr: string, newStr: string) => DiffLine[];
|
||||
|
||||
type CursorBlob = {
|
||||
id?: string;
|
||||
sequence?: number;
|
||||
rowid?: number;
|
||||
content?: any;
|
||||
};
|
||||
|
||||
const asArray = <T>(value: unknown): T[] => (Array.isArray(value) ? (value as T[]) : []);
|
||||
|
||||
const toAbsolutePath = (projectPath: string, filePath?: string) => {
|
||||
if (!filePath) {
|
||||
return filePath;
|
||||
}
|
||||
return filePath.startsWith('/') ? filePath : `${projectPath}/${filePath}`;
|
||||
};
|
||||
|
||||
export const calculateDiff = (oldStr: string, newStr: string): DiffLine[] => {
|
||||
const oldLines = oldStr.split('\n');
|
||||
const newLines = newStr.split('\n');
|
||||
|
||||
const diffLines: DiffLine[] = [];
|
||||
let oldIndex = 0;
|
||||
let newIndex = 0;
|
||||
|
||||
while (oldIndex < oldLines.length || newIndex < newLines.length) {
|
||||
const oldLine = oldLines[oldIndex];
|
||||
const newLine = newLines[newIndex];
|
||||
|
||||
if (oldIndex >= oldLines.length) {
|
||||
diffLines.push({ type: 'added', content: newLine, lineNum: newIndex + 1 });
|
||||
newIndex += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (newIndex >= newLines.length) {
|
||||
diffLines.push({ type: 'removed', content: oldLine, lineNum: oldIndex + 1 });
|
||||
oldIndex += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (oldLine === newLine) {
|
||||
oldIndex += 1;
|
||||
newIndex += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
diffLines.push({ type: 'removed', content: oldLine, lineNum: oldIndex + 1 });
|
||||
diffLines.push({ type: 'added', content: newLine, lineNum: newIndex + 1 });
|
||||
oldIndex += 1;
|
||||
newIndex += 1;
|
||||
}
|
||||
|
||||
return diffLines;
|
||||
};
|
||||
|
||||
export const createCachedDiffCalculator = (): DiffCalculator => {
|
||||
const cache = new Map<string, DiffLine[]>();
|
||||
|
||||
return (oldStr: string, newStr: string) => {
|
||||
const key = `${oldStr.length}-${newStr.length}-${oldStr.slice(0, 50)}`;
|
||||
const cached = cache.get(key);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const calculated = calculateDiff(oldStr, newStr);
|
||||
cache.set(key, calculated);
|
||||
if (cache.size > 100) {
|
||||
const firstKey = cache.keys().next().value;
|
||||
if (firstKey) {
|
||||
cache.delete(firstKey);
|
||||
}
|
||||
}
|
||||
return calculated;
|
||||
};
|
||||
};
|
||||
|
||||
export const convertCursorSessionMessages = (blobs: CursorBlob[], projectPath: string): ChatMessage[] => {
|
||||
const converted: ChatMessage[] = [];
|
||||
const toolUseMap: Record<string, ChatMessage> = {};
|
||||
|
||||
for (let blobIdx = 0; blobIdx < blobs.length; blobIdx += 1) {
|
||||
const blob = blobs[blobIdx];
|
||||
const content = blob.content;
|
||||
let text = '';
|
||||
let role: ChatMessage['type'] = 'assistant';
|
||||
let reasoningText: string | null = null;
|
||||
|
||||
try {
|
||||
if (content?.role && content?.content) {
|
||||
if (content.role === 'system') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (content.role === 'tool') {
|
||||
const toolItems = asArray<any>(content.content);
|
||||
for (const item of toolItems) {
|
||||
if (item?.type !== 'tool-result') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const toolName = item.toolName === 'ApplyPatch' ? 'Edit' : item.toolName || 'Unknown Tool';
|
||||
const toolCallId = item.toolCallId || content.id;
|
||||
const result = item.result || '';
|
||||
|
||||
if (toolCallId && toolUseMap[toolCallId]) {
|
||||
toolUseMap[toolCallId].toolResult = {
|
||||
content: result,
|
||||
isError: false,
|
||||
};
|
||||
} else {
|
||||
converted.push({
|
||||
type: 'assistant',
|
||||
content: '',
|
||||
timestamp: new Date(Date.now() + blobIdx * 1000),
|
||||
blobId: blob.id,
|
||||
sequence: blob.sequence,
|
||||
rowid: blob.rowid,
|
||||
isToolUse: true,
|
||||
toolName,
|
||||
toolId: toolCallId,
|
||||
toolInput: null,
|
||||
toolResult: {
|
||||
content: result,
|
||||
isError: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
role = content.role === 'user' ? 'user' : 'assistant';
|
||||
|
||||
if (Array.isArray(content.content)) {
|
||||
const textParts: string[] = [];
|
||||
|
||||
for (const part of content.content) {
|
||||
if (part?.type === 'text' && part?.text) {
|
||||
textParts.push(decodeHtmlEntities(part.text));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (part?.type === 'reasoning' && part?.text) {
|
||||
reasoningText = decodeHtmlEntities(part.text);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (part?.type === 'tool-call' || part?.type === 'tool_use') {
|
||||
if (textParts.length > 0 || reasoningText) {
|
||||
converted.push({
|
||||
type: role,
|
||||
content: textParts.join('\n'),
|
||||
reasoning: reasoningText ?? undefined,
|
||||
timestamp: new Date(Date.now() + blobIdx * 1000),
|
||||
blobId: blob.id,
|
||||
sequence: blob.sequence,
|
||||
rowid: blob.rowid,
|
||||
});
|
||||
textParts.length = 0;
|
||||
reasoningText = null;
|
||||
}
|
||||
|
||||
const toolNameRaw = part.toolName || part.name || 'Unknown Tool';
|
||||
const toolName = toolNameRaw === 'ApplyPatch' ? 'Edit' : toolNameRaw;
|
||||
const toolId = part.toolCallId || part.id || `tool_${blobIdx}`;
|
||||
let toolInput = part.args || part.input;
|
||||
|
||||
if (toolName === 'Edit' && part.args) {
|
||||
if (part.args.patch) {
|
||||
const patchLines = String(part.args.patch).split('\n');
|
||||
const oldLines: string[] = [];
|
||||
const newLines: string[] = [];
|
||||
let inPatch = false;
|
||||
|
||||
patchLines.forEach((line) => {
|
||||
if (line.startsWith('@@')) {
|
||||
inPatch = true;
|
||||
return;
|
||||
}
|
||||
if (!inPatch) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (line.startsWith('-')) {
|
||||
oldLines.push(line.slice(1));
|
||||
} else if (line.startsWith('+')) {
|
||||
newLines.push(line.slice(1));
|
||||
} else if (line.startsWith(' ')) {
|
||||
oldLines.push(line.slice(1));
|
||||
newLines.push(line.slice(1));
|
||||
}
|
||||
});
|
||||
|
||||
toolInput = {
|
||||
file_path: toAbsolutePath(projectPath, part.args.file_path),
|
||||
old_string: oldLines.join('\n') || part.args.patch,
|
||||
new_string: newLines.join('\n') || part.args.patch,
|
||||
};
|
||||
} else {
|
||||
toolInput = part.args;
|
||||
}
|
||||
} else if (toolName === 'Read' && part.args) {
|
||||
const filePath = part.args.path || part.args.file_path;
|
||||
toolInput = {
|
||||
file_path: toAbsolutePath(projectPath, filePath),
|
||||
};
|
||||
} else if (toolName === 'Write' && part.args) {
|
||||
const filePath = part.args.path || part.args.file_path;
|
||||
toolInput = {
|
||||
file_path: toAbsolutePath(projectPath, filePath),
|
||||
content: part.args.contents || part.args.content,
|
||||
};
|
||||
}
|
||||
|
||||
const toolMessage: ChatMessage = {
|
||||
type: 'assistant',
|
||||
content: '',
|
||||
timestamp: new Date(Date.now() + blobIdx * 1000),
|
||||
blobId: blob.id,
|
||||
sequence: blob.sequence,
|
||||
rowid: blob.rowid,
|
||||
isToolUse: true,
|
||||
toolName,
|
||||
toolId,
|
||||
toolInput: toolInput ? JSON.stringify(toolInput) : null,
|
||||
toolResult: null,
|
||||
};
|
||||
converted.push(toolMessage);
|
||||
toolUseMap[toolId] = toolMessage;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof part === 'string') {
|
||||
textParts.push(part);
|
||||
}
|
||||
}
|
||||
|
||||
if (textParts.length > 0) {
|
||||
text = textParts.join('\n');
|
||||
if (reasoningText && !text) {
|
||||
converted.push({
|
||||
type: role,
|
||||
content: '',
|
||||
reasoning: reasoningText,
|
||||
timestamp: new Date(Date.now() + blobIdx * 1000),
|
||||
blobId: blob.id,
|
||||
sequence: blob.sequence,
|
||||
rowid: blob.rowid,
|
||||
});
|
||||
text = '';
|
||||
}
|
||||
} else {
|
||||
text = '';
|
||||
}
|
||||
} else if (typeof content.content === 'string') {
|
||||
text = content.content;
|
||||
}
|
||||
} else if (content?.message?.role && content?.message?.content) {
|
||||
if (content.message.role === 'system') {
|
||||
continue;
|
||||
}
|
||||
|
||||
role = content.message.role === 'user' ? 'user' : 'assistant';
|
||||
if (Array.isArray(content.message.content)) {
|
||||
text = content.message.content
|
||||
.map((part: any) => (typeof part === 'string' ? part : part?.text || ''))
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
} else if (typeof content.message.content === 'string') {
|
||||
text = content.message.content;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Error parsing blob content:', error);
|
||||
}
|
||||
|
||||
if (text && text.trim()) {
|
||||
const message: ChatMessage = {
|
||||
type: role,
|
||||
content: text,
|
||||
timestamp: new Date(Date.now() + blobIdx * 1000),
|
||||
blobId: blob.id,
|
||||
sequence: blob.sequence,
|
||||
rowid: blob.rowid,
|
||||
};
|
||||
if (reasoningText) {
|
||||
message.reasoning = reasoningText;
|
||||
}
|
||||
converted.push(message);
|
||||
}
|
||||
}
|
||||
|
||||
converted.sort((messageA, messageB) => {
|
||||
if (messageA.sequence !== undefined && messageB.sequence !== undefined) {
|
||||
return Number(messageA.sequence) - Number(messageB.sequence);
|
||||
}
|
||||
if (messageA.rowid !== undefined && messageB.rowid !== undefined) {
|
||||
return Number(messageA.rowid) - Number(messageB.rowid);
|
||||
}
|
||||
return new Date(messageA.timestamp).getTime() - new Date(messageB.timestamp).getTime();
|
||||
});
|
||||
|
||||
return converted;
|
||||
};
|
||||
|
||||
export const convertSessionMessages = (rawMessages: any[]): ChatMessage[] => {
|
||||
const converted: ChatMessage[] = [];
|
||||
const toolResults = new Map<
|
||||
string,
|
||||
{ content: unknown; isError: boolean; timestamp: Date; toolUseResult: unknown }
|
||||
>();
|
||||
|
||||
rawMessages.forEach((message) => {
|
||||
if (message.message?.role === 'user' && Array.isArray(message.message?.content)) {
|
||||
message.message.content.forEach((part: any) => {
|
||||
if (part.type !== 'tool_result') {
|
||||
return;
|
||||
}
|
||||
toolResults.set(part.tool_use_id, {
|
||||
content: part.content,
|
||||
isError: Boolean(part.is_error),
|
||||
timestamp: new Date(message.timestamp || Date.now()),
|
||||
toolUseResult: message.toolUseResult || null,
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
rawMessages.forEach((message) => {
|
||||
if (message.message?.role === 'user' && message.message?.content) {
|
||||
let content = '';
|
||||
if (Array.isArray(message.message.content)) {
|
||||
const textParts: string[] = [];
|
||||
message.message.content.forEach((part: any) => {
|
||||
if (part.type === 'text') {
|
||||
textParts.push(decodeHtmlEntities(part.text));
|
||||
}
|
||||
});
|
||||
content = textParts.join('\n');
|
||||
} else if (typeof message.message.content === 'string') {
|
||||
content = decodeHtmlEntities(message.message.content);
|
||||
} else {
|
||||
content = decodeHtmlEntities(String(message.message.content));
|
||||
}
|
||||
|
||||
const shouldSkip =
|
||||
!content ||
|
||||
content.startsWith('<command-name>') ||
|
||||
content.startsWith('<command-message>') ||
|
||||
content.startsWith('<command-args>') ||
|
||||
content.startsWith('<local-command-stdout>') ||
|
||||
content.startsWith('<system-reminder>') ||
|
||||
content.startsWith('Caveat:') ||
|
||||
content.startsWith('This session is being continued from a previous') ||
|
||||
content.startsWith('[Request interrupted');
|
||||
|
||||
if (!shouldSkip) {
|
||||
converted.push({
|
||||
type: 'user',
|
||||
content: unescapeWithMathProtection(content),
|
||||
timestamp: message.timestamp || new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'thinking' && message.message?.content) {
|
||||
converted.push({
|
||||
type: 'assistant',
|
||||
content: unescapeWithMathProtection(message.message.content),
|
||||
timestamp: message.timestamp || new Date().toISOString(),
|
||||
isThinking: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'tool_use' && message.toolName) {
|
||||
converted.push({
|
||||
type: 'assistant',
|
||||
content: '',
|
||||
timestamp: message.timestamp || new Date().toISOString(),
|
||||
isToolUse: true,
|
||||
toolName: message.toolName,
|
||||
toolInput: message.toolInput || '',
|
||||
toolCallId: message.toolCallId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'tool_result') {
|
||||
for (let index = converted.length - 1; index >= 0; index -= 1) {
|
||||
const convertedMessage = converted[index];
|
||||
if (!convertedMessage.isToolUse || convertedMessage.toolResult) {
|
||||
continue;
|
||||
}
|
||||
if (!message.toolCallId || convertedMessage.toolCallId === message.toolCallId) {
|
||||
convertedMessage.toolResult = {
|
||||
content: message.output || '',
|
||||
isError: false,
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.message?.role === 'assistant' && message.message?.content) {
|
||||
if (Array.isArray(message.message.content)) {
|
||||
message.message.content.forEach((part: any) => {
|
||||
if (part.type === 'text') {
|
||||
let text = part.text;
|
||||
if (typeof text === 'string') {
|
||||
text = unescapeWithMathProtection(text);
|
||||
}
|
||||
converted.push({
|
||||
type: 'assistant',
|
||||
content: text,
|
||||
timestamp: message.timestamp || new Date().toISOString(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (part.type === 'tool_use') {
|
||||
const toolResult = toolResults.get(part.id);
|
||||
converted.push({
|
||||
type: 'assistant',
|
||||
content: '',
|
||||
timestamp: message.timestamp || new Date().toISOString(),
|
||||
isToolUse: true,
|
||||
toolName: part.name,
|
||||
toolInput: JSON.stringify(part.input),
|
||||
toolResult: toolResult
|
||||
? {
|
||||
content:
|
||||
typeof toolResult.content === 'string'
|
||||
? toolResult.content
|
||||
: JSON.stringify(toolResult.content),
|
||||
isError: toolResult.isError,
|
||||
toolUseResult: toolResult.toolUseResult,
|
||||
}
|
||||
: null,
|
||||
toolError: toolResult?.isError || false,
|
||||
toolResultTimestamp: toolResult?.timestamp || new Date(),
|
||||
});
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof message.message.content === 'string') {
|
||||
converted.push({
|
||||
type: 'assistant',
|
||||
content: unescapeWithMathProtection(message.message.content),
|
||||
timestamp: message.timestamp || new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return converted;
|
||||
};
|
||||
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