mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-02-15 05:07:35 +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>
|
||||
);
|
||||
}
|
||||
900
src/hooks/chat/useChatComposerState.ts
Normal file
900
src/hooks/chat/useChatComposerState.ts
Normal file
@@ -0,0 +1,900 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type {
|
||||
ChangeEvent,
|
||||
ClipboardEvent,
|
||||
Dispatch,
|
||||
FormEvent,
|
||||
KeyboardEvent,
|
||||
MouseEvent,
|
||||
SetStateAction,
|
||||
TouchEvent,
|
||||
} from 'react';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
import { authenticatedFetch } from '../../utils/api';
|
||||
import { thinkingModes } from '../../components/ThinkingModeSelector.jsx';
|
||||
import { grantClaudeToolPermission } from '../../components/chat/utils/chatPermissions';
|
||||
import { safeLocalStorage } from '../../components/chat/utils/chatStorage';
|
||||
import type {
|
||||
ChatMessage,
|
||||
PendingPermissionRequest,
|
||||
PermissionMode,
|
||||
Provider,
|
||||
} from '../../components/chat/types';
|
||||
import { useFileMentions } from './useFileMentions';
|
||||
import { type SlashCommand, useSlashCommands } from './useSlashCommands';
|
||||
import type { Project, ProjectSession } from '../../types/app';
|
||||
|
||||
type PendingViewSession = {
|
||||
sessionId: string | null;
|
||||
startedAt: number;
|
||||
};
|
||||
|
||||
interface UseChatComposerStateArgs {
|
||||
selectedProject: Project | null;
|
||||
selectedSession: ProjectSession | null;
|
||||
currentSessionId: string | null;
|
||||
provider: Provider | string;
|
||||
permissionMode: PermissionMode | string;
|
||||
cyclePermissionMode: () => void;
|
||||
cursorModel: string;
|
||||
claudeModel: string;
|
||||
codexModel: string;
|
||||
isLoading: boolean;
|
||||
canAbortSession: boolean;
|
||||
tokenBudget: Record<string, unknown> | null;
|
||||
sendMessage: (message: unknown) => void;
|
||||
sendByCtrlEnter?: boolean;
|
||||
onSessionActive?: (sessionId?: string | null) => void;
|
||||
onInputFocusChange?: (focused: boolean) => void;
|
||||
onFileOpen?: (filePath: string, diffInfo?: unknown) => void;
|
||||
onShowSettings?: () => void;
|
||||
pendingViewSessionRef: { current: PendingViewSession | null };
|
||||
scrollToBottom: () => void;
|
||||
setChatMessages: Dispatch<SetStateAction<ChatMessage[]>>;
|
||||
setSessionMessages?: Dispatch<SetStateAction<any[]>>;
|
||||
setIsLoading: (loading: boolean) => void;
|
||||
setCanAbortSession: (canAbort: boolean) => void;
|
||||
setClaudeStatus: (status: { text: string; tokens: number; can_interrupt: boolean } | null) => void;
|
||||
setIsUserScrolledUp: (isScrolledUp: boolean) => void;
|
||||
setPendingPermissionRequests: Dispatch<SetStateAction<PendingPermissionRequest[]>>;
|
||||
}
|
||||
|
||||
interface MentionableFile {
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface CommandExecutionResult {
|
||||
type: 'builtin' | 'custom';
|
||||
action?: string;
|
||||
data?: any;
|
||||
content?: string;
|
||||
hasBashCommands?: boolean;
|
||||
hasFileIncludes?: boolean;
|
||||
}
|
||||
|
||||
const createFakeSubmitEvent = () => {
|
||||
return { preventDefault: () => undefined } as unknown as FormEvent<HTMLFormElement>;
|
||||
};
|
||||
|
||||
export function 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,
|
||||
}: UseChatComposerStateArgs) {
|
||||
const [input, setInput] = useState(() => {
|
||||
if (typeof window !== 'undefined' && selectedProject) {
|
||||
return safeLocalStorage.getItem(`draft_input_${selectedProject.name}`) || '';
|
||||
}
|
||||
return '';
|
||||
});
|
||||
const [attachedImages, setAttachedImages] = useState<File[]>([]);
|
||||
const [uploadingImages, setUploadingImages] = useState<Map<string, number>>(new Map());
|
||||
const [imageErrors, setImageErrors] = useState<Map<string, string>>(new Map());
|
||||
const [isTextareaExpanded, setIsTextareaExpanded] = useState(false);
|
||||
const [thinkingMode, setThinkingMode] = useState('none');
|
||||
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const inputHighlightRef = useRef<HTMLDivElement>(null);
|
||||
const handleSubmitRef = useRef<
|
||||
((event: FormEvent<HTMLFormElement> | MouseEvent | TouchEvent | KeyboardEvent<HTMLTextAreaElement>) => Promise<void>) | null
|
||||
>(null);
|
||||
|
||||
const handleBuiltInCommand = useCallback(
|
||||
(result: CommandExecutionResult) => {
|
||||
const { action, data } = result;
|
||||
switch (action) {
|
||||
case 'clear':
|
||||
setChatMessages([]);
|
||||
setSessionMessages?.([]);
|
||||
break;
|
||||
|
||||
case 'help':
|
||||
setChatMessages((previous) => [
|
||||
...previous,
|
||||
{
|
||||
type: 'assistant',
|
||||
content: data.content,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'model':
|
||||
setChatMessages((previous) => [
|
||||
...previous,
|
||||
{
|
||||
type: 'assistant',
|
||||
content: `**Current Model**: ${data.current.model}\n\n**Available Models**:\n\nClaude: ${data.available.claude.join(', ')}\n\nCursor: ${data.available.cursor.join(', ')}`,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'cost': {
|
||||
const costMessage = `**Token Usage**: ${data.tokenUsage.used.toLocaleString()} / ${data.tokenUsage.total.toLocaleString()} (${data.tokenUsage.percentage}%)\n\n**Estimated Cost**:\n- Input: $${data.cost.input}\n- Output: $${data.cost.output}\n- **Total**: $${data.cost.total}\n\n**Model**: ${data.model}`;
|
||||
setChatMessages((previous) => [
|
||||
...previous,
|
||||
{ type: 'assistant', content: costMessage, timestamp: Date.now() },
|
||||
]);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'status': {
|
||||
const statusMessage = `**System Status**\n\n- Version: ${data.version}\n- Uptime: ${data.uptime}\n- Model: ${data.model}\n- Provider: ${data.provider}\n- Node.js: ${data.nodeVersion}\n- Platform: ${data.platform}`;
|
||||
setChatMessages((previous) => [
|
||||
...previous,
|
||||
{ type: 'assistant', content: statusMessage, timestamp: Date.now() },
|
||||
]);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'memory':
|
||||
if (data.error) {
|
||||
setChatMessages((previous) => [
|
||||
...previous,
|
||||
{
|
||||
type: 'assistant',
|
||||
content: `⚠️ ${data.message}`,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
setChatMessages((previous) => [
|
||||
...previous,
|
||||
{
|
||||
type: 'assistant',
|
||||
content: `📝 ${data.message}\n\nPath: \`${data.path}\``,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]);
|
||||
if (data.exists && onFileOpen) {
|
||||
onFileOpen(data.path);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'config':
|
||||
onShowSettings?.();
|
||||
break;
|
||||
|
||||
case 'rewind':
|
||||
if (data.error) {
|
||||
setChatMessages((previous) => [
|
||||
...previous,
|
||||
{
|
||||
type: 'assistant',
|
||||
content: `⚠️ ${data.message}`,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
setChatMessages((previous) => previous.slice(0, -data.steps * 2));
|
||||
setChatMessages((previous) => [
|
||||
...previous,
|
||||
{
|
||||
type: 'assistant',
|
||||
content: `⏪ ${data.message}`,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn('Unknown built-in command action:', action);
|
||||
}
|
||||
},
|
||||
[onFileOpen, onShowSettings, setChatMessages, setSessionMessages],
|
||||
);
|
||||
|
||||
const handleCustomCommand = useCallback(async (result: CommandExecutionResult) => {
|
||||
const { content, hasBashCommands } = result;
|
||||
|
||||
if (hasBashCommands) {
|
||||
const confirmed = window.confirm(
|
||||
'This command contains bash commands that will be executed. Do you want to proceed?',
|
||||
);
|
||||
if (!confirmed) {
|
||||
setChatMessages((previous) => [
|
||||
...previous,
|
||||
{
|
||||
type: 'assistant',
|
||||
content: '❌ Command execution cancelled',
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setInput(content || '');
|
||||
|
||||
setTimeout(() => {
|
||||
if (handleSubmitRef.current) {
|
||||
handleSubmitRef.current(createFakeSubmitEvent());
|
||||
}
|
||||
}, 50);
|
||||
}, [setChatMessages]);
|
||||
|
||||
const executeCommand = useCallback(
|
||||
async (command: SlashCommand) => {
|
||||
if (!command || !selectedProject) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const commandMatch = input.match(new RegExp(`${command.name}\\s*(.*)`));
|
||||
const args =
|
||||
commandMatch && commandMatch[1] ? commandMatch[1].trim().split(/\s+/) : [];
|
||||
|
||||
const context = {
|
||||
projectPath: selectedProject.path,
|
||||
projectName: selectedProject.name,
|
||||
sessionId: currentSessionId,
|
||||
provider,
|
||||
model: provider === 'cursor' ? cursorModel : claudeModel,
|
||||
tokenUsage: tokenBudget,
|
||||
};
|
||||
|
||||
const response = await authenticatedFetch('/api/commands/execute', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
commandName: command.name,
|
||||
commandPath: command.path,
|
||||
args,
|
||||
context,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to execute command');
|
||||
}
|
||||
|
||||
const result = (await response.json()) as CommandExecutionResult;
|
||||
if (result.type === 'builtin') {
|
||||
handleBuiltInCommand(result);
|
||||
} else if (result.type === 'custom') {
|
||||
await handleCustomCommand(result);
|
||||
}
|
||||
|
||||
setInput('');
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
console.error('Error executing command:', error);
|
||||
setChatMessages((previous) => [
|
||||
...previous,
|
||||
{
|
||||
type: 'assistant',
|
||||
content: `Error executing command: ${message}`,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]);
|
||||
}
|
||||
},
|
||||
[
|
||||
claudeModel,
|
||||
currentSessionId,
|
||||
cursorModel,
|
||||
handleBuiltInCommand,
|
||||
handleCustomCommand,
|
||||
input,
|
||||
provider,
|
||||
selectedProject,
|
||||
setChatMessages,
|
||||
tokenBudget,
|
||||
],
|
||||
);
|
||||
|
||||
const {
|
||||
slashCommandsCount,
|
||||
filteredCommands,
|
||||
frequentCommands,
|
||||
commandQuery,
|
||||
showCommandMenu,
|
||||
selectedCommandIndex,
|
||||
resetCommandMenuState,
|
||||
handleCommandSelect,
|
||||
handleToggleCommandMenu,
|
||||
handleCommandInputChange,
|
||||
handleCommandMenuKeyDown,
|
||||
} = useSlashCommands({
|
||||
selectedProject,
|
||||
input,
|
||||
setInput,
|
||||
textareaRef,
|
||||
onExecuteCommand: executeCommand,
|
||||
});
|
||||
|
||||
const {
|
||||
showFileDropdown,
|
||||
filteredFiles,
|
||||
selectedFileIndex,
|
||||
renderInputWithMentions,
|
||||
selectFile,
|
||||
setCursorPosition,
|
||||
handleFileMentionsKeyDown,
|
||||
} = useFileMentions({
|
||||
selectedProject,
|
||||
input,
|
||||
setInput,
|
||||
textareaRef,
|
||||
});
|
||||
|
||||
const syncInputOverlayScroll = useCallback((target: HTMLTextAreaElement) => {
|
||||
if (!inputHighlightRef.current || !target) {
|
||||
return;
|
||||
}
|
||||
inputHighlightRef.current.scrollTop = target.scrollTop;
|
||||
inputHighlightRef.current.scrollLeft = target.scrollLeft;
|
||||
}, []);
|
||||
|
||||
const handleImageFiles = useCallback((files: File[]) => {
|
||||
const validFiles = files.filter((file) => {
|
||||
try {
|
||||
if (!file || typeof file !== 'object') {
|
||||
console.warn('Invalid file object:', file);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!file.type || !file.type.startsWith('image/')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!file.size || file.size > 5 * 1024 * 1024) {
|
||||
const fileName = file.name || 'Unknown file';
|
||||
setImageErrors((previous) => {
|
||||
const next = new Map(previous);
|
||||
next.set(fileName, 'File too large (max 5MB)');
|
||||
return next;
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error validating file:', error, file);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
if (validFiles.length > 0) {
|
||||
setAttachedImages((previous) => [...previous, ...validFiles].slice(0, 5));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handlePaste = useCallback(
|
||||
(event: ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
const items = Array.from(event.clipboardData.items);
|
||||
|
||||
items.forEach((item) => {
|
||||
if (!item.type.startsWith('image/')) {
|
||||
return;
|
||||
}
|
||||
const file = item.getAsFile();
|
||||
if (file) {
|
||||
handleImageFiles([file]);
|
||||
}
|
||||
});
|
||||
|
||||
if (items.length === 0 && event.clipboardData.files.length > 0) {
|
||||
const files = Array.from(event.clipboardData.files);
|
||||
const imageFiles = files.filter((file) => file.type.startsWith('image/'));
|
||||
if (imageFiles.length > 0) {
|
||||
handleImageFiles(imageFiles);
|
||||
}
|
||||
}
|
||||
},
|
||||
[handleImageFiles],
|
||||
);
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive, open } = useDropzone({
|
||||
accept: {
|
||||
'image/*': ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'],
|
||||
},
|
||||
maxSize: 5 * 1024 * 1024,
|
||||
maxFiles: 5,
|
||||
onDrop: handleImageFiles,
|
||||
noClick: true,
|
||||
noKeyboard: true,
|
||||
});
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (
|
||||
event: FormEvent<HTMLFormElement> | MouseEvent | TouchEvent | KeyboardEvent<HTMLTextAreaElement>,
|
||||
) => {
|
||||
event.preventDefault();
|
||||
if (!input.trim() || isLoading || !selectedProject) {
|
||||
return;
|
||||
}
|
||||
|
||||
let messageContent = input;
|
||||
const selectedThinkingMode = thinkingModes.find((mode: { id: string; prefix?: string }) => mode.id === thinkingMode);
|
||||
if (selectedThinkingMode && selectedThinkingMode.prefix) {
|
||||
messageContent = `${selectedThinkingMode.prefix}: ${input}`;
|
||||
}
|
||||
|
||||
let uploadedImages: unknown[] = [];
|
||||
if (attachedImages.length > 0) {
|
||||
const formData = new FormData();
|
||||
attachedImages.forEach((file) => {
|
||||
formData.append('images', file);
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await authenticatedFetch(`/api/projects/${selectedProject.name}/upload-images`, {
|
||||
method: 'POST',
|
||||
headers: {},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to upload images');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
uploadedImages = result.images;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
console.error('Image upload failed:', error);
|
||||
setChatMessages((previous) => [
|
||||
...previous,
|
||||
{
|
||||
type: 'error',
|
||||
content: `Failed to upload images: ${message}`,
|
||||
timestamp: new Date(),
|
||||
},
|
||||
]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const userMessage: ChatMessage = {
|
||||
type: 'user',
|
||||
content: input,
|
||||
images: uploadedImages as any,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
setChatMessages((previous) => [...previous, userMessage]);
|
||||
setIsLoading(true);
|
||||
setCanAbortSession(true);
|
||||
setClaudeStatus({
|
||||
text: 'Processing',
|
||||
tokens: 0,
|
||||
can_interrupt: true,
|
||||
});
|
||||
|
||||
setIsUserScrolledUp(false);
|
||||
setTimeout(() => scrollToBottom(), 100);
|
||||
|
||||
const effectiveSessionId =
|
||||
currentSessionId || selectedSession?.id || sessionStorage.getItem('cursorSessionId');
|
||||
const sessionToActivate = effectiveSessionId || `new-session-${Date.now()}`;
|
||||
|
||||
if (!effectiveSessionId && !selectedSession?.id) {
|
||||
pendingViewSessionRef.current = { sessionId: null, startedAt: Date.now() };
|
||||
}
|
||||
onSessionActive?.(sessionToActivate);
|
||||
|
||||
const getToolsSettings = () => {
|
||||
try {
|
||||
const settingsKey =
|
||||
provider === 'cursor'
|
||||
? 'cursor-tools-settings'
|
||||
: provider === 'codex'
|
||||
? 'codex-settings'
|
||||
: 'claude-settings';
|
||||
const savedSettings = safeLocalStorage.getItem(settingsKey);
|
||||
if (savedSettings) {
|
||||
return JSON.parse(savedSettings);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading tools settings:', error);
|
||||
}
|
||||
|
||||
return {
|
||||
allowedTools: [],
|
||||
disallowedTools: [],
|
||||
skipPermissions: false,
|
||||
};
|
||||
};
|
||||
|
||||
const toolsSettings = getToolsSettings();
|
||||
|
||||
if (provider === 'cursor') {
|
||||
sendMessage({
|
||||
type: 'cursor-command',
|
||||
command: messageContent,
|
||||
sessionId: effectiveSessionId,
|
||||
options: {
|
||||
cwd: selectedProject.fullPath || selectedProject.path,
|
||||
projectPath: selectedProject.fullPath || selectedProject.path,
|
||||
sessionId: effectiveSessionId,
|
||||
resume: Boolean(effectiveSessionId),
|
||||
model: cursorModel,
|
||||
skipPermissions: toolsSettings?.skipPermissions || false,
|
||||
toolsSettings,
|
||||
},
|
||||
});
|
||||
} else if (provider === 'codex') {
|
||||
sendMessage({
|
||||
type: 'codex-command',
|
||||
command: messageContent,
|
||||
sessionId: effectiveSessionId,
|
||||
options: {
|
||||
cwd: selectedProject.fullPath || selectedProject.path,
|
||||
projectPath: selectedProject.fullPath || selectedProject.path,
|
||||
sessionId: effectiveSessionId,
|
||||
resume: Boolean(effectiveSessionId),
|
||||
model: codexModel,
|
||||
permissionMode: permissionMode === 'plan' ? 'default' : permissionMode,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
sendMessage({
|
||||
type: 'claude-command',
|
||||
command: messageContent,
|
||||
options: {
|
||||
projectPath: selectedProject.path,
|
||||
cwd: selectedProject.fullPath,
|
||||
sessionId: currentSessionId,
|
||||
resume: Boolean(currentSessionId),
|
||||
toolsSettings,
|
||||
permissionMode,
|
||||
model: claudeModel,
|
||||
images: uploadedImages,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
setInput('');
|
||||
resetCommandMenuState();
|
||||
setAttachedImages([]);
|
||||
setUploadingImages(new Map());
|
||||
setImageErrors(new Map());
|
||||
setIsTextareaExpanded(false);
|
||||
setThinkingMode('none');
|
||||
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto';
|
||||
}
|
||||
|
||||
safeLocalStorage.removeItem(`draft_input_${selectedProject.name}`);
|
||||
},
|
||||
[
|
||||
attachedImages,
|
||||
claudeModel,
|
||||
codexModel,
|
||||
currentSessionId,
|
||||
cursorModel,
|
||||
input,
|
||||
isLoading,
|
||||
onSessionActive,
|
||||
pendingViewSessionRef,
|
||||
permissionMode,
|
||||
provider,
|
||||
resetCommandMenuState,
|
||||
scrollToBottom,
|
||||
selectedProject,
|
||||
selectedSession?.id,
|
||||
sendMessage,
|
||||
setCanAbortSession,
|
||||
setChatMessages,
|
||||
setClaudeStatus,
|
||||
setIsLoading,
|
||||
setIsUserScrolledUp,
|
||||
thinkingMode,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
handleSubmitRef.current = handleSubmit;
|
||||
}, [handleSubmit]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedProject) {
|
||||
return;
|
||||
}
|
||||
const savedInput = safeLocalStorage.getItem(`draft_input_${selectedProject.name}`) || '';
|
||||
setInput((previous) => (previous === savedInput ? previous : savedInput));
|
||||
}, [selectedProject?.name]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedProject) {
|
||||
return;
|
||||
}
|
||||
if (input !== '') {
|
||||
safeLocalStorage.setItem(`draft_input_${selectedProject.name}`, input);
|
||||
} else {
|
||||
safeLocalStorage.removeItem(`draft_input_${selectedProject.name}`);
|
||||
}
|
||||
}, [input, selectedProject]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!textareaRef.current) {
|
||||
return;
|
||||
}
|
||||
textareaRef.current.style.height = 'auto';
|
||||
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
|
||||
const lineHeight = parseInt(window.getComputedStyle(textareaRef.current).lineHeight);
|
||||
const expanded = textareaRef.current.scrollHeight > lineHeight * 2;
|
||||
setIsTextareaExpanded(expanded);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!textareaRef.current || input.trim()) {
|
||||
return;
|
||||
}
|
||||
textareaRef.current.style.height = 'auto';
|
||||
setIsTextareaExpanded(false);
|
||||
}, [input]);
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
(event: ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const newValue = event.target.value;
|
||||
const cursorPos = event.target.selectionStart;
|
||||
|
||||
setInput(newValue);
|
||||
setCursorPosition(cursorPos);
|
||||
|
||||
if (!newValue.trim()) {
|
||||
event.target.style.height = 'auto';
|
||||
setIsTextareaExpanded(false);
|
||||
resetCommandMenuState();
|
||||
return;
|
||||
}
|
||||
|
||||
handleCommandInputChange(newValue, cursorPos);
|
||||
},
|
||||
[handleCommandInputChange, resetCommandMenuState, setCursorPosition],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (handleCommandMenuKeyDown(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (handleFileMentionsKeyDown(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Tab' && !showFileDropdown && !showCommandMenu) {
|
||||
event.preventDefault();
|
||||
cyclePermissionMode();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
if (event.nativeEvent.isComposing) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ((event.ctrlKey || event.metaKey) && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
handleSubmit(event);
|
||||
} else if (!event.shiftKey && !event.ctrlKey && !event.metaKey && !sendByCtrlEnter) {
|
||||
event.preventDefault();
|
||||
handleSubmit(event);
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
cyclePermissionMode,
|
||||
handleCommandMenuKeyDown,
|
||||
handleFileMentionsKeyDown,
|
||||
handleSubmit,
|
||||
sendByCtrlEnter,
|
||||
showCommandMenu,
|
||||
showFileDropdown,
|
||||
],
|
||||
);
|
||||
|
||||
const handleTextareaClick = useCallback(
|
||||
(event: MouseEvent<HTMLTextAreaElement>) => {
|
||||
setCursorPosition(event.currentTarget.selectionStart);
|
||||
},
|
||||
[setCursorPosition],
|
||||
);
|
||||
|
||||
const handleTextareaInput = useCallback(
|
||||
(event: FormEvent<HTMLTextAreaElement>) => {
|
||||
const target = event.currentTarget;
|
||||
target.style.height = 'auto';
|
||||
target.style.height = `${target.scrollHeight}px`;
|
||||
setCursorPosition(target.selectionStart);
|
||||
syncInputOverlayScroll(target);
|
||||
|
||||
const lineHeight = parseInt(window.getComputedStyle(target).lineHeight);
|
||||
setIsTextareaExpanded(target.scrollHeight > lineHeight * 2);
|
||||
},
|
||||
[setCursorPosition, syncInputOverlayScroll],
|
||||
);
|
||||
|
||||
const handleClearInput = useCallback(() => {
|
||||
setInput('');
|
||||
resetCommandMenuState();
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto';
|
||||
textareaRef.current.focus();
|
||||
}
|
||||
setIsTextareaExpanded(false);
|
||||
}, [resetCommandMenuState]);
|
||||
|
||||
const handleAbortSession = useCallback(() => {
|
||||
if (!currentSessionId || !canAbortSession) {
|
||||
return;
|
||||
}
|
||||
sendMessage({
|
||||
type: 'abort-session',
|
||||
sessionId: currentSessionId,
|
||||
provider,
|
||||
});
|
||||
}, [canAbortSession, currentSessionId, provider, sendMessage]);
|
||||
|
||||
const handleTranscript = useCallback((text: string) => {
|
||||
if (!text.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setInput((previousInput) => {
|
||||
const newInput = previousInput.trim() ? `${previousInput} ${text}` : text;
|
||||
|
||||
setTimeout(() => {
|
||||
if (!textareaRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
textareaRef.current.style.height = 'auto';
|
||||
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
|
||||
const lineHeight = parseInt(window.getComputedStyle(textareaRef.current).lineHeight);
|
||||
setIsTextareaExpanded(textareaRef.current.scrollHeight > lineHeight * 2);
|
||||
}, 0);
|
||||
|
||||
return newInput;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleGrantToolPermission = useCallback(
|
||||
(suggestion: { entry: string; toolName: string }) => {
|
||||
if (!suggestion || provider !== 'claude') {
|
||||
return { success: false };
|
||||
}
|
||||
return grantClaudeToolPermission(suggestion.entry);
|
||||
},
|
||||
[provider],
|
||||
);
|
||||
|
||||
const handlePermissionDecision = useCallback(
|
||||
(
|
||||
requestIds: string | string[],
|
||||
decision: { allow?: boolean; message?: string; rememberEntry?: string | null; updatedInput?: unknown },
|
||||
) => {
|
||||
const ids = Array.isArray(requestIds) ? requestIds : [requestIds];
|
||||
const validIds = ids.filter(Boolean);
|
||||
if (validIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
validIds.forEach((requestId) => {
|
||||
sendMessage({
|
||||
type: 'claude-permission-response',
|
||||
requestId,
|
||||
allow: Boolean(decision?.allow),
|
||||
updatedInput: decision?.updatedInput,
|
||||
message: decision?.message,
|
||||
rememberEntry: decision?.rememberEntry,
|
||||
});
|
||||
});
|
||||
|
||||
setPendingPermissionRequests((previous) => {
|
||||
const next = previous.filter((request) => !validIds.includes(request.requestId));
|
||||
if (next.length === 0) {
|
||||
setClaudeStatus(null);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[sendMessage, setClaudeStatus, setPendingPermissionRequests],
|
||||
);
|
||||
|
||||
const handleInputFocusChange = useCallback(
|
||||
(focused: boolean) => {
|
||||
onInputFocusChange?.(focused);
|
||||
},
|
||||
[onInputFocusChange],
|
||||
);
|
||||
|
||||
return {
|
||||
input,
|
||||
setInput,
|
||||
textareaRef,
|
||||
inputHighlightRef,
|
||||
isTextareaExpanded,
|
||||
thinkingMode,
|
||||
setThinkingMode,
|
||||
slashCommandsCount,
|
||||
filteredCommands,
|
||||
frequentCommands,
|
||||
commandQuery,
|
||||
showCommandMenu,
|
||||
selectedCommandIndex,
|
||||
resetCommandMenuState,
|
||||
handleCommandSelect,
|
||||
handleToggleCommandMenu,
|
||||
showFileDropdown,
|
||||
filteredFiles: filteredFiles as MentionableFile[],
|
||||
selectedFileIndex,
|
||||
renderInputWithMentions,
|
||||
selectFile,
|
||||
attachedImages,
|
||||
setAttachedImages,
|
||||
uploadingImages,
|
||||
imageErrors,
|
||||
getRootProps,
|
||||
getInputProps,
|
||||
isDragActive,
|
||||
openImagePicker: open,
|
||||
handleSubmit,
|
||||
handleInputChange,
|
||||
handleKeyDown,
|
||||
handlePaste,
|
||||
handleTextareaClick,
|
||||
handleTextareaInput,
|
||||
syncInputOverlayScroll,
|
||||
handleClearInput,
|
||||
handleAbortSession,
|
||||
handleTranscript,
|
||||
handlePermissionDecision,
|
||||
handleGrantToolPermission,
|
||||
handleInputFocusChange,
|
||||
};
|
||||
}
|
||||
114
src/hooks/chat/useChatProviderState.ts
Normal file
114
src/hooks/chat/useChatProviderState.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { authenticatedFetch } from '../../utils/api';
|
||||
import { CLAUDE_MODELS, CODEX_MODELS, CURSOR_MODELS } from '../../../shared/modelConstants';
|
||||
import type { PendingPermissionRequest, PermissionMode, Provider } from '../../components/chat/types';
|
||||
import type { ProjectSession } from '../../types/app';
|
||||
|
||||
interface UseChatProviderStateArgs {
|
||||
selectedSession: ProjectSession | null;
|
||||
}
|
||||
|
||||
export function useChatProviderState({ selectedSession }: UseChatProviderStateArgs) {
|
||||
const [permissionMode, setPermissionMode] = useState<PermissionMode>('default');
|
||||
const [pendingPermissionRequests, setPendingPermissionRequests] = useState<PendingPermissionRequest[]>([]);
|
||||
const [provider, setProvider] = useState<Provider>(() => {
|
||||
return (localStorage.getItem('selected-provider') as Provider) || 'claude';
|
||||
});
|
||||
const [cursorModel, setCursorModel] = useState<string>(() => {
|
||||
return localStorage.getItem('cursor-model') || CURSOR_MODELS.DEFAULT;
|
||||
});
|
||||
const [claudeModel, setClaudeModel] = useState<string>(() => {
|
||||
return localStorage.getItem('claude-model') || CLAUDE_MODELS.DEFAULT;
|
||||
});
|
||||
const [codexModel, setCodexModel] = useState<string>(() => {
|
||||
return localStorage.getItem('codex-model') || CODEX_MODELS.DEFAULT;
|
||||
});
|
||||
|
||||
const lastProviderRef = useRef(provider);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedSession?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const savedMode = localStorage.getItem(`permissionMode-${selectedSession.id}`);
|
||||
setPermissionMode((savedMode as PermissionMode) || 'default');
|
||||
}, [selectedSession?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedSession?.__provider || selectedSession.__provider === provider) {
|
||||
return;
|
||||
}
|
||||
|
||||
setProvider(selectedSession.__provider);
|
||||
localStorage.setItem('selected-provider', selectedSession.__provider);
|
||||
}, [provider, selectedSession]);
|
||||
|
||||
useEffect(() => {
|
||||
if (lastProviderRef.current === provider) {
|
||||
return;
|
||||
}
|
||||
setPendingPermissionRequests([]);
|
||||
lastProviderRef.current = provider;
|
||||
}, [provider]);
|
||||
|
||||
useEffect(() => {
|
||||
setPendingPermissionRequests((previous) =>
|
||||
previous.filter((request) => !request.sessionId || request.sessionId === selectedSession?.id),
|
||||
);
|
||||
}, [selectedSession?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (provider !== 'cursor') {
|
||||
return;
|
||||
}
|
||||
|
||||
authenticatedFetch('/api/cursor/config')
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
if (!data.success || !data.config?.model?.modelId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const modelId = data.config.model.modelId as string;
|
||||
if (!localStorage.getItem('cursor-model')) {
|
||||
setCursorModel(modelId);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Error loading Cursor config:', error);
|
||||
});
|
||||
}, [provider]);
|
||||
|
||||
const cyclePermissionMode = useCallback(() => {
|
||||
const modes: PermissionMode[] =
|
||||
provider === 'codex'
|
||||
? ['default', 'acceptEdits', 'bypassPermissions']
|
||||
: ['default', 'acceptEdits', 'bypassPermissions', 'plan'];
|
||||
|
||||
const currentIndex = modes.indexOf(permissionMode);
|
||||
const nextIndex = (currentIndex + 1) % modes.length;
|
||||
const nextMode = modes[nextIndex];
|
||||
setPermissionMode(nextMode);
|
||||
|
||||
if (selectedSession?.id) {
|
||||
localStorage.setItem(`permissionMode-${selectedSession.id}`, nextMode);
|
||||
}
|
||||
}, [permissionMode, provider, selectedSession?.id]);
|
||||
|
||||
return {
|
||||
provider,
|
||||
setProvider,
|
||||
cursorModel,
|
||||
setCursorModel,
|
||||
claudeModel,
|
||||
setClaudeModel,
|
||||
codexModel,
|
||||
setCodexModel,
|
||||
permissionMode,
|
||||
setPermissionMode,
|
||||
pendingPermissionRequests,
|
||||
setPendingPermissionRequests,
|
||||
cyclePermissionMode,
|
||||
};
|
||||
}
|
||||
887
src/hooks/chat/useChatRealtimeHandlers.ts
Normal file
887
src/hooks/chat/useChatRealtimeHandlers.ts
Normal file
@@ -0,0 +1,887 @@
|
||||
import { useEffect } from 'react';
|
||||
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
|
||||
import { decodeHtmlEntities, formatUsageLimitText } from '../../components/chat/utils/chatFormatting';
|
||||
import { safeLocalStorage } from '../../components/chat/utils/chatStorage';
|
||||
import type { ChatMessage, PendingPermissionRequest, Provider } from '../../components/chat/types';
|
||||
import type { Project, ProjectSession } from '../../types/app';
|
||||
|
||||
type PendingViewSession = {
|
||||
sessionId: string | null;
|
||||
startedAt: number;
|
||||
};
|
||||
|
||||
type LatestChatMessage = {
|
||||
type?: string;
|
||||
data?: any;
|
||||
sessionId?: string;
|
||||
requestId?: string;
|
||||
toolName?: string;
|
||||
input?: unknown;
|
||||
context?: unknown;
|
||||
error?: string;
|
||||
tool?: string;
|
||||
exitCode?: number;
|
||||
isProcessing?: boolean;
|
||||
actualSessionId?: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
interface UseChatRealtimeHandlersArgs {
|
||||
latestMessage: LatestChatMessage | null;
|
||||
provider: Provider | string;
|
||||
selectedProject: Project | null;
|
||||
selectedSession: ProjectSession | null;
|
||||
currentSessionId: string | null;
|
||||
setCurrentSessionId: (sessionId: string | null) => void;
|
||||
setChatMessages: Dispatch<SetStateAction<ChatMessage[]>>;
|
||||
setIsLoading: (loading: boolean) => void;
|
||||
setCanAbortSession: (canAbort: boolean) => void;
|
||||
setClaudeStatus: (status: { text: string; tokens: number; can_interrupt: boolean } | null) => void;
|
||||
setTokenBudget: (budget: Record<string, unknown> | null) => void;
|
||||
setIsSystemSessionChange: (isSystemSessionChange: boolean) => void;
|
||||
setPendingPermissionRequests: Dispatch<SetStateAction<PendingPermissionRequest[]>>;
|
||||
pendingViewSessionRef: MutableRefObject<PendingViewSession | null>;
|
||||
streamBufferRef: MutableRefObject<string>;
|
||||
streamTimerRef: MutableRefObject<number | null>;
|
||||
onSessionInactive?: (sessionId?: string | null) => void;
|
||||
onSessionProcessing?: (sessionId?: string | null) => void;
|
||||
onSessionNotProcessing?: (sessionId?: string | null) => void;
|
||||
onReplaceTemporarySession?: (sessionId?: string | null) => void;
|
||||
onNavigateToSession?: (sessionId: string) => void;
|
||||
}
|
||||
|
||||
const appendStreamingChunk = (
|
||||
setChatMessages: Dispatch<SetStateAction<ChatMessage[]>>,
|
||||
chunk: string,
|
||||
newline = false,
|
||||
) => {
|
||||
if (!chunk) {
|
||||
return;
|
||||
}
|
||||
|
||||
setChatMessages((previous) => {
|
||||
const updated = [...previous];
|
||||
const last = updated[updated.length - 1];
|
||||
if (last && last.type === 'assistant' && !last.isToolUse && last.isStreaming) {
|
||||
if (newline) {
|
||||
last.content = last.content ? `${last.content}\n${chunk}` : chunk;
|
||||
} else {
|
||||
last.content = `${last.content || ''}${chunk}`;
|
||||
}
|
||||
} else {
|
||||
updated.push({ type: 'assistant', content: chunk, timestamp: new Date(), isStreaming: true });
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
const finalizeStreamingMessage = (setChatMessages: Dispatch<SetStateAction<ChatMessage[]>>) => {
|
||||
setChatMessages((previous) => {
|
||||
const updated = [...previous];
|
||||
const last = updated[updated.length - 1];
|
||||
if (last && last.type === 'assistant' && last.isStreaming) {
|
||||
last.isStreaming = false;
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
export function useChatRealtimeHandlers({
|
||||
latestMessage,
|
||||
provider,
|
||||
selectedProject,
|
||||
selectedSession,
|
||||
currentSessionId,
|
||||
setCurrentSessionId,
|
||||
setChatMessages,
|
||||
setIsLoading,
|
||||
setCanAbortSession,
|
||||
setClaudeStatus,
|
||||
setTokenBudget,
|
||||
setIsSystemSessionChange,
|
||||
setPendingPermissionRequests,
|
||||
pendingViewSessionRef,
|
||||
streamBufferRef,
|
||||
streamTimerRef,
|
||||
onSessionInactive,
|
||||
onSessionProcessing,
|
||||
onSessionNotProcessing,
|
||||
onReplaceTemporarySession,
|
||||
onNavigateToSession,
|
||||
}: UseChatRealtimeHandlersArgs) {
|
||||
useEffect(() => {
|
||||
if (!latestMessage) {
|
||||
return;
|
||||
}
|
||||
|
||||
const messageData = latestMessage.data?.message || latestMessage.data;
|
||||
|
||||
const globalMessageTypes = ['projects_updated', 'taskmaster-project-updated', 'session-created'];
|
||||
const isGlobalMessage = globalMessageTypes.includes(String(latestMessage.type));
|
||||
const lifecycleMessageTypes = new Set([
|
||||
'claude-complete',
|
||||
'codex-complete',
|
||||
'cursor-result',
|
||||
'session-aborted',
|
||||
'claude-error',
|
||||
'cursor-error',
|
||||
'codex-error',
|
||||
]);
|
||||
|
||||
const isClaudeSystemInit =
|
||||
latestMessage.type === 'claude-response' &&
|
||||
messageData &&
|
||||
messageData.type === 'system' &&
|
||||
messageData.subtype === 'init';
|
||||
|
||||
const isCursorSystemInit =
|
||||
latestMessage.type === 'cursor-system' &&
|
||||
latestMessage.data &&
|
||||
latestMessage.data.type === 'system' &&
|
||||
latestMessage.data.subtype === 'init';
|
||||
|
||||
const systemInitSessionId = isClaudeSystemInit
|
||||
? messageData?.session_id
|
||||
: isCursorSystemInit
|
||||
? latestMessage.data?.session_id
|
||||
: null;
|
||||
|
||||
const activeViewSessionId =
|
||||
selectedSession?.id || currentSessionId || pendingViewSessionRef.current?.sessionId || null;
|
||||
const isSystemInitForView =
|
||||
systemInitSessionId && (!activeViewSessionId || systemInitSessionId === activeViewSessionId);
|
||||
const shouldBypassSessionFilter = isGlobalMessage || Boolean(isSystemInitForView);
|
||||
const isUnscopedError =
|
||||
!latestMessage.sessionId &&
|
||||
pendingViewSessionRef.current &&
|
||||
!pendingViewSessionRef.current.sessionId &&
|
||||
(latestMessage.type === 'claude-error' ||
|
||||
latestMessage.type === 'cursor-error' ||
|
||||
latestMessage.type === 'codex-error');
|
||||
|
||||
const handleBackgroundLifecycle = (sessionId?: string) => {
|
||||
if (!sessionId) {
|
||||
return;
|
||||
}
|
||||
onSessionInactive?.(sessionId);
|
||||
onSessionNotProcessing?.(sessionId);
|
||||
};
|
||||
|
||||
if (!shouldBypassSessionFilter) {
|
||||
if (!activeViewSessionId) {
|
||||
if (latestMessage.sessionId && lifecycleMessageTypes.has(String(latestMessage.type))) {
|
||||
handleBackgroundLifecycle(latestMessage.sessionId);
|
||||
}
|
||||
if (!isUnscopedError) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!latestMessage.sessionId && !isUnscopedError) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (latestMessage.sessionId !== activeViewSessionId) {
|
||||
if (latestMessage.sessionId && lifecycleMessageTypes.has(String(latestMessage.type))) {
|
||||
handleBackgroundLifecycle(latestMessage.sessionId);
|
||||
}
|
||||
console.log(
|
||||
'Skipping message for different session:',
|
||||
latestMessage.sessionId,
|
||||
'current:',
|
||||
activeViewSessionId,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
switch (latestMessage.type) {
|
||||
case 'session-created':
|
||||
if (latestMessage.sessionId && !currentSessionId) {
|
||||
sessionStorage.setItem('pendingSessionId', latestMessage.sessionId);
|
||||
if (pendingViewSessionRef.current && !pendingViewSessionRef.current.sessionId) {
|
||||
pendingViewSessionRef.current.sessionId = latestMessage.sessionId;
|
||||
}
|
||||
|
||||
setIsSystemSessionChange(true);
|
||||
onReplaceTemporarySession?.(latestMessage.sessionId);
|
||||
|
||||
setPendingPermissionRequests((previous) =>
|
||||
previous.map((request) =>
|
||||
request.sessionId ? request : { ...request, sessionId: latestMessage.sessionId },
|
||||
),
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'token-budget':
|
||||
if (latestMessage.data) {
|
||||
setTokenBudget(latestMessage.data);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'claude-response': {
|
||||
if (messageData && typeof messageData === 'object' && messageData.type) {
|
||||
if (messageData.type === 'content_block_delta' && messageData.delta?.text) {
|
||||
const decodedText = decodeHtmlEntities(messageData.delta.text);
|
||||
streamBufferRef.current += decodedText;
|
||||
if (!streamTimerRef.current) {
|
||||
streamTimerRef.current = window.setTimeout(() => {
|
||||
const chunk = streamBufferRef.current;
|
||||
streamBufferRef.current = '';
|
||||
streamTimerRef.current = null;
|
||||
appendStreamingChunk(setChatMessages, chunk, false);
|
||||
}, 100);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (messageData.type === 'content_block_stop') {
|
||||
if (streamTimerRef.current) {
|
||||
clearTimeout(streamTimerRef.current);
|
||||
streamTimerRef.current = null;
|
||||
}
|
||||
const chunk = streamBufferRef.current;
|
||||
streamBufferRef.current = '';
|
||||
appendStreamingChunk(setChatMessages, chunk, false);
|
||||
finalizeStreamingMessage(setChatMessages);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
latestMessage.data.type === 'system' &&
|
||||
latestMessage.data.subtype === 'init' &&
|
||||
latestMessage.data.session_id &&
|
||||
currentSessionId &&
|
||||
latestMessage.data.session_id !== currentSessionId &&
|
||||
isSystemInitForView
|
||||
) {
|
||||
console.log('Claude CLI session duplication detected:', {
|
||||
originalSession: currentSessionId,
|
||||
newSession: latestMessage.data.session_id,
|
||||
});
|
||||
|
||||
setIsSystemSessionChange(true);
|
||||
onNavigateToSession?.(latestMessage.data.session_id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
latestMessage.data.type === 'system' &&
|
||||
latestMessage.data.subtype === 'init' &&
|
||||
latestMessage.data.session_id &&
|
||||
!currentSessionId &&
|
||||
isSystemInitForView
|
||||
) {
|
||||
console.log('New session init detected:', {
|
||||
newSession: latestMessage.data.session_id,
|
||||
});
|
||||
|
||||
setIsSystemSessionChange(true);
|
||||
onNavigateToSession?.(latestMessage.data.session_id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
latestMessage.data.type === 'system' &&
|
||||
latestMessage.data.subtype === 'init' &&
|
||||
latestMessage.data.session_id &&
|
||||
currentSessionId &&
|
||||
latestMessage.data.session_id === currentSessionId &&
|
||||
isSystemInitForView
|
||||
) {
|
||||
console.log('System init message for current session, ignoring');
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(messageData.content)) {
|
||||
messageData.content.forEach((part: any) => {
|
||||
if (part.type === 'tool_use') {
|
||||
const toolInput = part.input ? JSON.stringify(part.input, null, 2) : '';
|
||||
setChatMessages((previous) => [
|
||||
...previous,
|
||||
{
|
||||
type: 'assistant',
|
||||
content: '',
|
||||
timestamp: new Date(),
|
||||
isToolUse: true,
|
||||
toolName: part.name,
|
||||
toolInput,
|
||||
toolId: part.id,
|
||||
toolResult: null,
|
||||
},
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (part.type === 'text' && part.text?.trim()) {
|
||||
let content = decodeHtmlEntities(part.text);
|
||||
content = formatUsageLimitText(content);
|
||||
setChatMessages((previous) => [
|
||||
...previous,
|
||||
{
|
||||
type: 'assistant',
|
||||
content,
|
||||
timestamp: new Date(),
|
||||
},
|
||||
]);
|
||||
}
|
||||
});
|
||||
} else if (typeof messageData.content === 'string' && messageData.content.trim()) {
|
||||
let content = decodeHtmlEntities(messageData.content);
|
||||
content = formatUsageLimitText(content);
|
||||
setChatMessages((previous) => [
|
||||
...previous,
|
||||
{
|
||||
type: 'assistant',
|
||||
content,
|
||||
timestamp: new Date(),
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
if (messageData.role === 'user' && Array.isArray(messageData.content)) {
|
||||
messageData.content.forEach((part: any) => {
|
||||
if (part.type !== 'tool_result') {
|
||||
return;
|
||||
}
|
||||
|
||||
setChatMessages((previous) =>
|
||||
previous.map((message) => {
|
||||
if (message.isToolUse && message.toolId === part.tool_use_id) {
|
||||
return {
|
||||
...message,
|
||||
toolResult: {
|
||||
content: part.content,
|
||||
isError: part.is_error,
|
||||
timestamp: new Date(),
|
||||
},
|
||||
};
|
||||
}
|
||||
return message;
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'claude-output': {
|
||||
const cleaned = String(latestMessage.data || '');
|
||||
if (cleaned.trim()) {
|
||||
streamBufferRef.current += streamBufferRef.current ? `\n${cleaned}` : cleaned;
|
||||
if (!streamTimerRef.current) {
|
||||
streamTimerRef.current = window.setTimeout(() => {
|
||||
const chunk = streamBufferRef.current;
|
||||
streamBufferRef.current = '';
|
||||
streamTimerRef.current = null;
|
||||
appendStreamingChunk(setChatMessages, chunk, true);
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'claude-interactive-prompt':
|
||||
setChatMessages((previous) => [
|
||||
...previous,
|
||||
{
|
||||
type: 'assistant',
|
||||
content: latestMessage.data,
|
||||
timestamp: new Date(),
|
||||
isInteractivePrompt: true,
|
||||
},
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'claude-permission-request':
|
||||
if (provider !== 'claude' || !latestMessage.requestId) {
|
||||
break;
|
||||
}
|
||||
{
|
||||
const requestId = latestMessage.requestId;
|
||||
|
||||
setPendingPermissionRequests((previous) => {
|
||||
if (previous.some((request) => request.requestId === requestId)) {
|
||||
return previous;
|
||||
}
|
||||
return [
|
||||
...previous,
|
||||
{
|
||||
requestId,
|
||||
toolName: latestMessage.toolName || 'UnknownTool',
|
||||
input: latestMessage.input,
|
||||
context: latestMessage.context,
|
||||
sessionId: latestMessage.sessionId || null,
|
||||
receivedAt: new Date(),
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setCanAbortSession(true);
|
||||
setClaudeStatus({
|
||||
text: 'Waiting for permission',
|
||||
tokens: 0,
|
||||
can_interrupt: true,
|
||||
});
|
||||
break;
|
||||
|
||||
case 'claude-permission-cancelled':
|
||||
if (!latestMessage.requestId) {
|
||||
break;
|
||||
}
|
||||
setPendingPermissionRequests((previous) =>
|
||||
previous.filter((request) => request.requestId !== latestMessage.requestId),
|
||||
);
|
||||
break;
|
||||
|
||||
case 'claude-error':
|
||||
setChatMessages((previous) => [
|
||||
...previous,
|
||||
{
|
||||
type: 'error',
|
||||
content: `Error: ${latestMessage.error}`,
|
||||
timestamp: new Date(),
|
||||
},
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'cursor-system':
|
||||
try {
|
||||
const cursorData = latestMessage.data;
|
||||
if (
|
||||
cursorData &&
|
||||
cursorData.type === 'system' &&
|
||||
cursorData.subtype === 'init' &&
|
||||
cursorData.session_id
|
||||
) {
|
||||
if (!isSystemInitForView) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentSessionId && cursorData.session_id !== currentSessionId) {
|
||||
console.log('Cursor session switch detected:', {
|
||||
originalSession: currentSessionId,
|
||||
newSession: cursorData.session_id,
|
||||
});
|
||||
setIsSystemSessionChange(true);
|
||||
onNavigateToSession?.(cursorData.session_id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentSessionId) {
|
||||
console.log('Cursor new session init detected:', { newSession: cursorData.session_id });
|
||||
setIsSystemSessionChange(true);
|
||||
onNavigateToSession?.(cursorData.session_id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Error handling cursor-system message:', error);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'cursor-user':
|
||||
break;
|
||||
|
||||
case 'cursor-tool-use':
|
||||
setChatMessages((previous) => [
|
||||
...previous,
|
||||
{
|
||||
type: 'assistant',
|
||||
content: `Using tool: ${latestMessage.tool} ${
|
||||
latestMessage.input ? `with ${latestMessage.input}` : ''
|
||||
}`,
|
||||
timestamp: new Date(),
|
||||
isToolUse: true,
|
||||
toolName: latestMessage.tool,
|
||||
toolInput: latestMessage.input,
|
||||
},
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'cursor-error':
|
||||
setChatMessages((previous) => [
|
||||
...previous,
|
||||
{
|
||||
type: 'error',
|
||||
content: `Cursor error: ${latestMessage.error || 'Unknown error'}`,
|
||||
timestamp: new Date(),
|
||||
},
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'cursor-result': {
|
||||
const cursorCompletedSessionId = latestMessage.sessionId || currentSessionId;
|
||||
|
||||
if (cursorCompletedSessionId === currentSessionId) {
|
||||
setIsLoading(false);
|
||||
setCanAbortSession(false);
|
||||
setClaudeStatus(null);
|
||||
}
|
||||
|
||||
if (cursorCompletedSessionId) {
|
||||
onSessionInactive?.(cursorCompletedSessionId);
|
||||
onSessionNotProcessing?.(cursorCompletedSessionId);
|
||||
}
|
||||
|
||||
if (cursorCompletedSessionId === currentSessionId) {
|
||||
try {
|
||||
const resultData = latestMessage.data || {};
|
||||
const textResult = typeof resultData.result === 'string' ? resultData.result : '';
|
||||
|
||||
if (streamTimerRef.current) {
|
||||
clearTimeout(streamTimerRef.current);
|
||||
streamTimerRef.current = null;
|
||||
}
|
||||
const pendingChunk = streamBufferRef.current;
|
||||
streamBufferRef.current = '';
|
||||
|
||||
setChatMessages((previous) => {
|
||||
const updated = [...previous];
|
||||
const last = updated[updated.length - 1];
|
||||
if (last && last.type === 'assistant' && !last.isToolUse && last.isStreaming) {
|
||||
const finalContent =
|
||||
textResult && textResult.trim()
|
||||
? textResult
|
||||
: `${last.content || ''}${pendingChunk || ''}`;
|
||||
last.content = finalContent;
|
||||
last.isStreaming = false;
|
||||
} else if (textResult && textResult.trim()) {
|
||||
updated.push({
|
||||
type: resultData.is_error ? 'error' : 'assistant',
|
||||
content: textResult,
|
||||
timestamp: new Date(),
|
||||
isStreaming: false,
|
||||
});
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('Error handling cursor-result message:', error);
|
||||
}
|
||||
}
|
||||
|
||||
const pendingCursorSessionId = sessionStorage.getItem('pendingSessionId');
|
||||
if (cursorCompletedSessionId && !currentSessionId && cursorCompletedSessionId === pendingCursorSessionId) {
|
||||
setCurrentSessionId(cursorCompletedSessionId);
|
||||
sessionStorage.removeItem('pendingSessionId');
|
||||
if (window.refreshProjects) {
|
||||
setTimeout(() => window.refreshProjects?.(), 500);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'cursor-output':
|
||||
try {
|
||||
const raw = String(latestMessage.data ?? '');
|
||||
const cleaned = raw
|
||||
.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '')
|
||||
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '')
|
||||
.trim();
|
||||
|
||||
if (cleaned) {
|
||||
streamBufferRef.current += streamBufferRef.current ? `\n${cleaned}` : cleaned;
|
||||
if (!streamTimerRef.current) {
|
||||
streamTimerRef.current = window.setTimeout(() => {
|
||||
const chunk = streamBufferRef.current;
|
||||
streamBufferRef.current = '';
|
||||
streamTimerRef.current = null;
|
||||
appendStreamingChunk(setChatMessages, chunk, true);
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Error handling cursor-output message:', error);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'claude-complete': {
|
||||
const completedSessionId =
|
||||
latestMessage.sessionId || currentSessionId || sessionStorage.getItem('pendingSessionId');
|
||||
|
||||
if (completedSessionId === currentSessionId || !currentSessionId) {
|
||||
setIsLoading(false);
|
||||
setCanAbortSession(false);
|
||||
setClaudeStatus(null);
|
||||
}
|
||||
|
||||
if (completedSessionId) {
|
||||
onSessionInactive?.(completedSessionId);
|
||||
onSessionNotProcessing?.(completedSessionId);
|
||||
}
|
||||
|
||||
const pendingSessionId = sessionStorage.getItem('pendingSessionId');
|
||||
if (pendingSessionId && !currentSessionId && latestMessage.exitCode === 0) {
|
||||
setCurrentSessionId(pendingSessionId);
|
||||
sessionStorage.removeItem('pendingSessionId');
|
||||
console.log('New session complete, ID set to:', pendingSessionId);
|
||||
}
|
||||
|
||||
if (selectedProject && latestMessage.exitCode === 0) {
|
||||
safeLocalStorage.removeItem(`chat_messages_${selectedProject.name}`);
|
||||
}
|
||||
setPendingPermissionRequests([]);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'codex-response': {
|
||||
const codexData = latestMessage.data;
|
||||
if (!codexData) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (codexData.type === 'item') {
|
||||
switch (codexData.itemType) {
|
||||
case 'agent_message':
|
||||
if (codexData.message?.content?.trim()) {
|
||||
const content = decodeHtmlEntities(codexData.message.content);
|
||||
setChatMessages((previous) => [
|
||||
...previous,
|
||||
{
|
||||
type: 'assistant',
|
||||
content,
|
||||
timestamp: new Date(),
|
||||
},
|
||||
]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'reasoning':
|
||||
if (codexData.message?.content?.trim()) {
|
||||
const content = decodeHtmlEntities(codexData.message.content);
|
||||
setChatMessages((previous) => [
|
||||
...previous,
|
||||
{
|
||||
type: 'assistant',
|
||||
content,
|
||||
timestamp: new Date(),
|
||||
isThinking: true,
|
||||
},
|
||||
]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'command_execution':
|
||||
if (codexData.command) {
|
||||
setChatMessages((previous) => [
|
||||
...previous,
|
||||
{
|
||||
type: 'assistant',
|
||||
content: '',
|
||||
timestamp: new Date(),
|
||||
isToolUse: true,
|
||||
toolName: 'Bash',
|
||||
toolInput: codexData.command,
|
||||
toolResult: codexData.output || null,
|
||||
exitCode: codexData.exitCode,
|
||||
},
|
||||
]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'file_change':
|
||||
if (codexData.changes?.length > 0) {
|
||||
const changesList = codexData.changes
|
||||
.map((change: { kind: string; path: string }) => `${change.kind}: ${change.path}`)
|
||||
.join('\n');
|
||||
setChatMessages((previous) => [
|
||||
...previous,
|
||||
{
|
||||
type: 'assistant',
|
||||
content: '',
|
||||
timestamp: new Date(),
|
||||
isToolUse: true,
|
||||
toolName: 'FileChanges',
|
||||
toolInput: changesList,
|
||||
toolResult: {
|
||||
content: `Status: ${codexData.status}`,
|
||||
isError: false,
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'mcp_tool_call':
|
||||
setChatMessages((previous) => [
|
||||
...previous,
|
||||
{
|
||||
type: 'assistant',
|
||||
content: '',
|
||||
timestamp: new Date(),
|
||||
isToolUse: true,
|
||||
toolName: `${codexData.server}:${codexData.tool}`,
|
||||
toolInput: JSON.stringify(codexData.arguments, null, 2),
|
||||
toolResult: codexData.result
|
||||
? JSON.stringify(codexData.result, null, 2)
|
||||
: codexData.error?.message || null,
|
||||
},
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
if (codexData.message?.content) {
|
||||
setChatMessages((previous) => [
|
||||
...previous,
|
||||
{
|
||||
type: 'error',
|
||||
content: codexData.message.content,
|
||||
timestamp: new Date(),
|
||||
},
|
||||
]);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log('[Codex] Unhandled item type:', codexData.itemType, codexData);
|
||||
}
|
||||
}
|
||||
|
||||
if (codexData.type === 'turn_complete') {
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
if (codexData.type === 'turn_failed') {
|
||||
setIsLoading(false);
|
||||
setChatMessages((previous) => [
|
||||
...previous,
|
||||
{
|
||||
type: 'error',
|
||||
content: codexData.error?.message || 'Turn failed',
|
||||
timestamp: new Date(),
|
||||
},
|
||||
]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'codex-complete': {
|
||||
const codexCompletedSessionId =
|
||||
latestMessage.sessionId || currentSessionId || sessionStorage.getItem('pendingSessionId');
|
||||
|
||||
if (codexCompletedSessionId === currentSessionId || !currentSessionId) {
|
||||
setIsLoading(false);
|
||||
setCanAbortSession(false);
|
||||
setClaudeStatus(null);
|
||||
}
|
||||
|
||||
if (codexCompletedSessionId) {
|
||||
onSessionInactive?.(codexCompletedSessionId);
|
||||
onSessionNotProcessing?.(codexCompletedSessionId);
|
||||
}
|
||||
|
||||
const codexPendingSessionId = sessionStorage.getItem('pendingSessionId');
|
||||
const codexActualSessionId = latestMessage.actualSessionId || codexPendingSessionId;
|
||||
if (codexPendingSessionId && !currentSessionId) {
|
||||
setCurrentSessionId(codexActualSessionId);
|
||||
setIsSystemSessionChange(true);
|
||||
if (codexActualSessionId) {
|
||||
onNavigateToSession?.(codexActualSessionId);
|
||||
}
|
||||
sessionStorage.removeItem('pendingSessionId');
|
||||
console.log('Codex session complete, ID set to:', codexPendingSessionId);
|
||||
}
|
||||
|
||||
if (selectedProject) {
|
||||
safeLocalStorage.removeItem(`chat_messages_${selectedProject.name}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'codex-error':
|
||||
setIsLoading(false);
|
||||
setCanAbortSession(false);
|
||||
setChatMessages((previous) => [
|
||||
...previous,
|
||||
{
|
||||
type: 'error',
|
||||
content: latestMessage.error || 'An error occurred with Codex',
|
||||
timestamp: new Date(),
|
||||
},
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'session-aborted': {
|
||||
const abortedSessionId = latestMessage.sessionId || currentSessionId;
|
||||
|
||||
if (abortedSessionId === currentSessionId) {
|
||||
setIsLoading(false);
|
||||
setCanAbortSession(false);
|
||||
setClaudeStatus(null);
|
||||
}
|
||||
|
||||
if (abortedSessionId) {
|
||||
onSessionInactive?.(abortedSessionId);
|
||||
onSessionNotProcessing?.(abortedSessionId);
|
||||
}
|
||||
|
||||
setPendingPermissionRequests([]);
|
||||
setChatMessages((previous) => [
|
||||
...previous,
|
||||
{
|
||||
type: 'assistant',
|
||||
content: 'Session interrupted by user.',
|
||||
timestamp: new Date(),
|
||||
},
|
||||
]);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'session-status': {
|
||||
const statusSessionId = latestMessage.sessionId;
|
||||
const isCurrentSession =
|
||||
statusSessionId === currentSessionId || (selectedSession && statusSessionId === selectedSession.id);
|
||||
if (isCurrentSession && latestMessage.isProcessing) {
|
||||
setIsLoading(true);
|
||||
setCanAbortSession(true);
|
||||
onSessionProcessing?.(statusSessionId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'claude-status': {
|
||||
const statusData = latestMessage.data;
|
||||
if (!statusData) {
|
||||
break;
|
||||
}
|
||||
|
||||
const statusInfo: { text: string; tokens: number; can_interrupt: boolean } = {
|
||||
text: 'Working...',
|
||||
tokens: 0,
|
||||
can_interrupt: true,
|
||||
};
|
||||
|
||||
if (statusData.message) {
|
||||
statusInfo.text = statusData.message;
|
||||
} else if (statusData.status) {
|
||||
statusInfo.text = statusData.status;
|
||||
} else if (typeof statusData === 'string') {
|
||||
statusInfo.text = statusData;
|
||||
}
|
||||
|
||||
if (statusData.tokens) {
|
||||
statusInfo.tokens = statusData.tokens;
|
||||
} else if (statusData.token_count) {
|
||||
statusInfo.tokens = statusData.token_count;
|
||||
}
|
||||
|
||||
if (statusData.can_interrupt !== undefined) {
|
||||
statusInfo.can_interrupt = statusData.can_interrupt;
|
||||
}
|
||||
|
||||
setClaudeStatus(statusInfo);
|
||||
setIsLoading(true);
|
||||
setCanAbortSession(statusInfo.can_interrupt);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}, [latestMessage]);
|
||||
}
|
||||
574
src/hooks/chat/useChatSessionState.ts
Normal file
574
src/hooks/chat/useChatSessionState.ts
Normal file
@@ -0,0 +1,574 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { MutableRefObject } from 'react';
|
||||
import { api, authenticatedFetch } from '../../utils/api';
|
||||
import type { ChatMessage, Provider } from '../../components/chat/types';
|
||||
import type { Project, ProjectSession } from '../../types/app';
|
||||
import { safeLocalStorage } from '../../components/chat/utils/chatStorage';
|
||||
import {
|
||||
convertCursorSessionMessages,
|
||||
convertSessionMessages,
|
||||
createCachedDiffCalculator,
|
||||
type DiffCalculator,
|
||||
} from '../../components/chat/utils/messageTransforms';
|
||||
|
||||
const MESSAGES_PER_PAGE = 20;
|
||||
const INITIAL_VISIBLE_MESSAGES = 100;
|
||||
|
||||
type PendingViewSession = {
|
||||
sessionId: string | null;
|
||||
startedAt: number;
|
||||
};
|
||||
|
||||
interface UseChatSessionStateArgs {
|
||||
selectedProject: Project | null;
|
||||
selectedSession: ProjectSession | null;
|
||||
ws: WebSocket | null;
|
||||
sendMessage: (message: unknown) => void;
|
||||
autoScrollToBottom?: boolean;
|
||||
externalMessageUpdate?: number;
|
||||
processingSessions?: Set<string>;
|
||||
resetStreamingState: () => void;
|
||||
pendingViewSessionRef: MutableRefObject<PendingViewSession | null>;
|
||||
}
|
||||
|
||||
interface ScrollRestoreState {
|
||||
height: number;
|
||||
top: number;
|
||||
}
|
||||
|
||||
export function useChatSessionState({
|
||||
selectedProject,
|
||||
selectedSession,
|
||||
ws,
|
||||
sendMessage,
|
||||
autoScrollToBottom,
|
||||
externalMessageUpdate,
|
||||
processingSessions,
|
||||
resetStreamingState,
|
||||
pendingViewSessionRef,
|
||||
}: UseChatSessionStateArgs) {
|
||||
const [chatMessages, setChatMessages] = useState<ChatMessage[]>(() => {
|
||||
if (typeof window !== 'undefined' && selectedProject) {
|
||||
const saved = safeLocalStorage.getItem(`chat_messages_${selectedProject.name}`);
|
||||
return saved ? (JSON.parse(saved) as ChatMessage[]) : [];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [currentSessionId, setCurrentSessionId] = useState<string | null>(selectedSession?.id || null);
|
||||
const [sessionMessages, setSessionMessages] = useState<any[]>([]);
|
||||
const [isLoadingSessionMessages, setIsLoadingSessionMessages] = useState(false);
|
||||
const [isLoadingMoreMessages, setIsLoadingMoreMessages] = useState(false);
|
||||
const [messagesOffset, setMessagesOffset] = useState(0);
|
||||
const [hasMoreMessages, setHasMoreMessages] = useState(false);
|
||||
const [totalMessages, setTotalMessages] = useState(0);
|
||||
const [isSystemSessionChange, setIsSystemSessionChange] = useState(false);
|
||||
const [canAbortSession, setCanAbortSession] = useState(false);
|
||||
const [isUserScrolledUp, setIsUserScrolledUp] = useState(false);
|
||||
const [tokenBudget, setTokenBudget] = useState<Record<string, unknown> | null>(null);
|
||||
const [visibleMessageCount, setVisibleMessageCount] = useState(INITIAL_VISIBLE_MESSAGES);
|
||||
const [claudeStatus, setClaudeStatus] = useState<{ text: string; tokens: number; can_interrupt: boolean } | null>(null);
|
||||
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const isLoadingSessionRef = useRef(false);
|
||||
const isLoadingMoreRef = useRef(false);
|
||||
const topLoadLockRef = useRef(false);
|
||||
const pendingScrollRestoreRef = useRef<ScrollRestoreState | null>(null);
|
||||
const scrollPositionRef = useRef({ height: 0, top: 0 });
|
||||
|
||||
const createDiff = useMemo<DiffCalculator>(() => createCachedDiffCalculator(), []);
|
||||
|
||||
const loadSessionMessages = useCallback(
|
||||
async (projectName: string, sessionId: string, loadMore = false, provider: Provider | string = 'claude') => {
|
||||
if (!projectName || !sessionId) {
|
||||
return [] as any[];
|
||||
}
|
||||
|
||||
const isInitialLoad = !loadMore;
|
||||
if (isInitialLoad) {
|
||||
setIsLoadingSessionMessages(true);
|
||||
} else {
|
||||
setIsLoadingMoreMessages(true);
|
||||
}
|
||||
|
||||
try {
|
||||
const currentOffset = loadMore ? messagesOffset : 0;
|
||||
const response = await (api.sessionMessages as any)(
|
||||
projectName,
|
||||
sessionId,
|
||||
MESSAGES_PER_PAGE,
|
||||
currentOffset,
|
||||
provider,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load session messages');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (isInitialLoad && data.tokenUsage) {
|
||||
setTokenBudget(data.tokenUsage);
|
||||
}
|
||||
|
||||
if (data.hasMore !== undefined) {
|
||||
setHasMoreMessages(Boolean(data.hasMore));
|
||||
setTotalMessages(Number(data.total || 0));
|
||||
setMessagesOffset(currentOffset + (data.messages?.length || 0));
|
||||
return data.messages || [];
|
||||
}
|
||||
|
||||
const messages = data.messages || [];
|
||||
setHasMoreMessages(false);
|
||||
setTotalMessages(messages.length);
|
||||
return messages;
|
||||
} catch (error) {
|
||||
console.error('Error loading session messages:', error);
|
||||
return [];
|
||||
} finally {
|
||||
if (isInitialLoad) {
|
||||
setIsLoadingSessionMessages(false);
|
||||
} else {
|
||||
setIsLoadingMoreMessages(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[messagesOffset],
|
||||
);
|
||||
|
||||
const loadCursorSessionMessages = useCallback(async (projectPath: string, sessionId: string) => {
|
||||
if (!projectPath || !sessionId) {
|
||||
return [] as ChatMessage[];
|
||||
}
|
||||
|
||||
setIsLoadingSessionMessages(true);
|
||||
try {
|
||||
const url = `/api/cursor/sessions/${encodeURIComponent(sessionId)}?projectPath=${encodeURIComponent(projectPath)}`;
|
||||
const response = await authenticatedFetch(url);
|
||||
if (!response.ok) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const blobs = (data?.session?.messages || []) as any[];
|
||||
return convertCursorSessionMessages(blobs, projectPath);
|
||||
} catch (error) {
|
||||
console.error('Error loading Cursor session messages:', error);
|
||||
return [];
|
||||
} finally {
|
||||
setIsLoadingSessionMessages(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const convertedMessages = useMemo(() => {
|
||||
return convertSessionMessages(sessionMessages);
|
||||
}, [sessionMessages]);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}, []);
|
||||
|
||||
const isNearBottom = useCallback(() => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) {
|
||||
return false;
|
||||
}
|
||||
const { scrollTop, scrollHeight, clientHeight } = container;
|
||||
return scrollHeight - scrollTop - clientHeight < 50;
|
||||
}, []);
|
||||
|
||||
const loadOlderMessages = useCallback(
|
||||
async (container: HTMLDivElement) => {
|
||||
if (!container || isLoadingMoreRef.current || isLoadingMoreMessages) {
|
||||
return false;
|
||||
}
|
||||
if (!hasMoreMessages || !selectedSession || !selectedProject) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const sessionProvider = selectedSession.__provider || 'claude';
|
||||
if (sessionProvider === 'cursor') {
|
||||
return false;
|
||||
}
|
||||
|
||||
isLoadingMoreRef.current = true;
|
||||
const previousScrollHeight = container.scrollHeight;
|
||||
const previousScrollTop = container.scrollTop;
|
||||
|
||||
try {
|
||||
const moreMessages = await loadSessionMessages(
|
||||
selectedProject.name,
|
||||
selectedSession.id,
|
||||
true,
|
||||
sessionProvider,
|
||||
);
|
||||
|
||||
if (moreMessages.length > 0) {
|
||||
pendingScrollRestoreRef.current = {
|
||||
height: previousScrollHeight,
|
||||
top: previousScrollTop,
|
||||
};
|
||||
setSessionMessages((previous) => [...moreMessages, ...previous]);
|
||||
}
|
||||
return true;
|
||||
} finally {
|
||||
isLoadingMoreRef.current = false;
|
||||
}
|
||||
},
|
||||
[hasMoreMessages, isLoadingMoreMessages, loadSessionMessages, selectedProject, selectedSession],
|
||||
);
|
||||
|
||||
const handleScroll = useCallback(async () => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nearBottom = isNearBottom();
|
||||
setIsUserScrolledUp(!nearBottom);
|
||||
|
||||
const scrolledNearTop = container.scrollTop < 100;
|
||||
if (!scrolledNearTop) {
|
||||
topLoadLockRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (topLoadLockRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const didLoad = await loadOlderMessages(container);
|
||||
if (didLoad) {
|
||||
topLoadLockRef.current = true;
|
||||
}
|
||||
}, [isNearBottom, loadOlderMessages]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!pendingScrollRestoreRef.current || !scrollContainerRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { height, top } = pendingScrollRestoreRef.current;
|
||||
const container = scrollContainerRef.current;
|
||||
const newScrollHeight = container.scrollHeight;
|
||||
const scrollDiff = newScrollHeight - height;
|
||||
container.scrollTop = top + Math.max(scrollDiff, 0);
|
||||
pendingScrollRestoreRef.current = null;
|
||||
}, [chatMessages.length]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadMessages = async () => {
|
||||
if (selectedSession && selectedProject) {
|
||||
const provider = (localStorage.getItem('selected-provider') as Provider) || 'claude';
|
||||
isLoadingSessionRef.current = true;
|
||||
|
||||
const sessionChanged = currentSessionId !== null && currentSessionId !== selectedSession.id;
|
||||
if (sessionChanged) {
|
||||
if (!isSystemSessionChange) {
|
||||
resetStreamingState();
|
||||
pendingViewSessionRef.current = null;
|
||||
setChatMessages([]);
|
||||
setSessionMessages([]);
|
||||
setClaudeStatus(null);
|
||||
setCanAbortSession(false);
|
||||
}
|
||||
|
||||
setMessagesOffset(0);
|
||||
setHasMoreMessages(false);
|
||||
setTotalMessages(0);
|
||||
setTokenBudget(null);
|
||||
setIsLoading(false);
|
||||
|
||||
if (ws) {
|
||||
sendMessage({
|
||||
type: 'check-session-status',
|
||||
sessionId: selectedSession.id,
|
||||
provider,
|
||||
});
|
||||
}
|
||||
} else if (currentSessionId === null) {
|
||||
setMessagesOffset(0);
|
||||
setHasMoreMessages(false);
|
||||
setTotalMessages(0);
|
||||
|
||||
if (ws) {
|
||||
sendMessage({
|
||||
type: 'check-session-status',
|
||||
sessionId: selectedSession.id,
|
||||
provider,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (provider === 'cursor') {
|
||||
setCurrentSessionId(selectedSession.id);
|
||||
sessionStorage.setItem('cursorSessionId', selectedSession.id);
|
||||
|
||||
if (!isSystemSessionChange) {
|
||||
const projectPath = selectedProject.fullPath || selectedProject.path || '';
|
||||
const converted = await loadCursorSessionMessages(projectPath, selectedSession.id);
|
||||
setSessionMessages([]);
|
||||
setChatMessages(converted);
|
||||
} else {
|
||||
setIsSystemSessionChange(false);
|
||||
}
|
||||
} else {
|
||||
setCurrentSessionId(selectedSession.id);
|
||||
|
||||
if (!isSystemSessionChange) {
|
||||
const messages = await loadSessionMessages(
|
||||
selectedProject.name,
|
||||
selectedSession.id,
|
||||
false,
|
||||
selectedSession.__provider || 'claude',
|
||||
);
|
||||
setSessionMessages(messages);
|
||||
} else {
|
||||
setIsSystemSessionChange(false);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (!isSystemSessionChange) {
|
||||
resetStreamingState();
|
||||
pendingViewSessionRef.current = null;
|
||||
setChatMessages([]);
|
||||
setSessionMessages([]);
|
||||
setClaudeStatus(null);
|
||||
setCanAbortSession(false);
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
setCurrentSessionId(null);
|
||||
sessionStorage.removeItem('cursorSessionId');
|
||||
setMessagesOffset(0);
|
||||
setHasMoreMessages(false);
|
||||
setTotalMessages(0);
|
||||
setTokenBudget(null);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
isLoadingSessionRef.current = false;
|
||||
}, 250);
|
||||
};
|
||||
|
||||
loadMessages();
|
||||
}, [
|
||||
currentSessionId,
|
||||
isSystemSessionChange,
|
||||
loadCursorSessionMessages,
|
||||
loadSessionMessages,
|
||||
pendingViewSessionRef,
|
||||
resetStreamingState,
|
||||
selectedProject,
|
||||
selectedSession,
|
||||
sendMessage,
|
||||
ws,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!externalMessageUpdate || !selectedSession || !selectedProject) {
|
||||
return;
|
||||
}
|
||||
|
||||
const reloadExternalMessages = async () => {
|
||||
try {
|
||||
const provider = (localStorage.getItem('selected-provider') as Provider) || 'claude';
|
||||
|
||||
if (provider === 'cursor') {
|
||||
const projectPath = selectedProject.fullPath || selectedProject.path || '';
|
||||
const converted = await loadCursorSessionMessages(projectPath, selectedSession.id);
|
||||
setSessionMessages([]);
|
||||
setChatMessages(converted);
|
||||
return;
|
||||
}
|
||||
|
||||
const messages = await loadSessionMessages(
|
||||
selectedProject.name,
|
||||
selectedSession.id,
|
||||
false,
|
||||
selectedSession.__provider || 'claude',
|
||||
);
|
||||
setSessionMessages(messages);
|
||||
|
||||
const shouldAutoScroll = Boolean(autoScrollToBottom) && isNearBottom();
|
||||
if (shouldAutoScroll) {
|
||||
setTimeout(() => scrollToBottom(), 200);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error reloading messages from external update:', error);
|
||||
}
|
||||
};
|
||||
|
||||
reloadExternalMessages();
|
||||
}, [
|
||||
autoScrollToBottom,
|
||||
externalMessageUpdate,
|
||||
isNearBottom,
|
||||
loadCursorSessionMessages,
|
||||
loadSessionMessages,
|
||||
scrollToBottom,
|
||||
selectedProject,
|
||||
selectedSession,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedSession?.id) {
|
||||
pendingViewSessionRef.current = null;
|
||||
}
|
||||
}, [pendingViewSessionRef, selectedSession?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sessionMessages.length > 0) {
|
||||
setChatMessages(convertedMessages);
|
||||
}
|
||||
}, [convertedMessages, sessionMessages.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedProject && chatMessages.length > 0) {
|
||||
safeLocalStorage.setItem(`chat_messages_${selectedProject.name}`, JSON.stringify(chatMessages));
|
||||
}
|
||||
}, [chatMessages, selectedProject]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedProject || !selectedSession?.id || selectedSession.id.startsWith('new-session-')) {
|
||||
setTokenBudget(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionProvider = selectedSession.__provider || 'claude';
|
||||
if (sessionProvider !== 'claude') {
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchInitialTokenUsage = async () => {
|
||||
try {
|
||||
const url = `/api/projects/${selectedProject.name}/sessions/${selectedSession.id}/token-usage`;
|
||||
const response = await authenticatedFetch(url);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setTokenBudget(data);
|
||||
} else {
|
||||
setTokenBudget(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch initial token usage:', error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchInitialTokenUsage();
|
||||
}, [selectedProject, selectedSession?.id, selectedSession?.__provider]);
|
||||
|
||||
const visibleMessages = useMemo(() => {
|
||||
if (chatMessages.length <= visibleMessageCount) {
|
||||
return chatMessages;
|
||||
}
|
||||
return chatMessages.slice(-visibleMessageCount);
|
||||
}, [chatMessages, visibleMessageCount]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoScrollToBottom && scrollContainerRef.current) {
|
||||
const container = scrollContainerRef.current;
|
||||
scrollPositionRef.current = {
|
||||
height: container.scrollHeight,
|
||||
top: container.scrollTop,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!scrollContainerRef.current || chatMessages.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (autoScrollToBottom) {
|
||||
if (!isUserScrolledUp) {
|
||||
setTimeout(() => scrollToBottom(), 50);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const container = scrollContainerRef.current;
|
||||
const prevHeight = scrollPositionRef.current.height;
|
||||
const prevTop = scrollPositionRef.current.top;
|
||||
const newHeight = container.scrollHeight;
|
||||
const heightDiff = newHeight - prevHeight;
|
||||
|
||||
if (heightDiff > 0 && prevTop > 0) {
|
||||
container.scrollTop = prevTop + heightDiff;
|
||||
}
|
||||
}, [autoScrollToBottom, chatMessages.length, isUserScrolledUp, scrollToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!scrollContainerRef.current || chatMessages.length === 0 || isLoadingSessionRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUserScrolledUp(false);
|
||||
setTimeout(() => {
|
||||
scrollToBottom();
|
||||
}, 200);
|
||||
}, [chatMessages.length, scrollToBottom, selectedProject?.name, selectedSession?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
container.addEventListener('scroll', handleScroll);
|
||||
return () => container.removeEventListener('scroll', handleScroll);
|
||||
}, [handleScroll]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentSessionId || !processingSessions) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldBeProcessing = processingSessions.has(currentSessionId);
|
||||
if (shouldBeProcessing && !isLoading) {
|
||||
setIsLoading(true);
|
||||
setCanAbortSession(true);
|
||||
}
|
||||
}, [currentSessionId, isLoading, processingSessions]);
|
||||
|
||||
const loadEarlierMessages = useCallback(() => {
|
||||
setVisibleMessageCount((previousCount) => previousCount + 100);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
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,
|
||||
isNearBottom,
|
||||
handleScroll,
|
||||
loadSessionMessages,
|
||||
loadCursorSessionMessages,
|
||||
};
|
||||
}
|
||||
257
src/hooks/chat/useFileMentions.tsx
Normal file
257
src/hooks/chat/useFileMentions.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import type { Dispatch, KeyboardEvent, RefObject, SetStateAction } from 'react';
|
||||
import { api } from '../../utils/api';
|
||||
import { escapeRegExp } from '../../components/chat/utils/chatFormatting';
|
||||
import type { Project } from '../../types/app';
|
||||
|
||||
interface ProjectFileNode {
|
||||
name: string;
|
||||
type: 'file' | 'directory';
|
||||
path?: string;
|
||||
children?: ProjectFileNode[];
|
||||
}
|
||||
|
||||
export interface MentionableFile {
|
||||
name: string;
|
||||
path: string;
|
||||
relativePath?: string;
|
||||
}
|
||||
|
||||
interface UseFileMentionsOptions {
|
||||
selectedProject: Project | null;
|
||||
input: string;
|
||||
setInput: Dispatch<SetStateAction<string>>;
|
||||
textareaRef: RefObject<HTMLTextAreaElement>;
|
||||
}
|
||||
|
||||
const flattenFileTree = (files: ProjectFileNode[], basePath = ''): MentionableFile[] => {
|
||||
let flattened: MentionableFile[] = [];
|
||||
|
||||
files.forEach((file) => {
|
||||
const fullPath = basePath ? `${basePath}/${file.name}` : file.name;
|
||||
if (file.type === 'directory' && file.children) {
|
||||
flattened = flattened.concat(flattenFileTree(file.children, fullPath));
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.type === 'file') {
|
||||
flattened.push({
|
||||
name: file.name,
|
||||
path: fullPath,
|
||||
relativePath: file.path,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return flattened;
|
||||
};
|
||||
|
||||
export function useFileMentions({ selectedProject, input, setInput, textareaRef }: UseFileMentionsOptions) {
|
||||
const [fileList, setFileList] = useState<MentionableFile[]>([]);
|
||||
const [fileMentions, setFileMentions] = useState<string[]>([]);
|
||||
const [filteredFiles, setFilteredFiles] = useState<MentionableFile[]>([]);
|
||||
const [showFileDropdown, setShowFileDropdown] = useState(false);
|
||||
const [selectedFileIndex, setSelectedFileIndex] = useState(-1);
|
||||
const [cursorPosition, setCursorPosition] = useState(0);
|
||||
const [atSymbolPosition, setAtSymbolPosition] = useState(-1);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchProjectFiles = async () => {
|
||||
if (!selectedProject) {
|
||||
setFileList([]);
|
||||
setFilteredFiles([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await api.getFiles(selectedProject.name);
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const files = (await response.json()) as ProjectFileNode[];
|
||||
setFileList(flattenFileTree(files));
|
||||
} catch (error) {
|
||||
console.error('Error fetching files:', error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchProjectFiles();
|
||||
}, [selectedProject]);
|
||||
|
||||
useEffect(() => {
|
||||
const textBeforeCursor = input.slice(0, cursorPosition);
|
||||
const lastAtIndex = textBeforeCursor.lastIndexOf('@');
|
||||
|
||||
if (lastAtIndex === -1) {
|
||||
setShowFileDropdown(false);
|
||||
setAtSymbolPosition(-1);
|
||||
return;
|
||||
}
|
||||
|
||||
const textAfterAt = textBeforeCursor.slice(lastAtIndex + 1);
|
||||
if (textAfterAt.includes(' ')) {
|
||||
setShowFileDropdown(false);
|
||||
setAtSymbolPosition(-1);
|
||||
return;
|
||||
}
|
||||
|
||||
setAtSymbolPosition(lastAtIndex);
|
||||
setShowFileDropdown(true);
|
||||
setSelectedFileIndex(-1);
|
||||
|
||||
const matchingFiles = fileList
|
||||
.filter(
|
||||
(file) =>
|
||||
file.name.toLowerCase().includes(textAfterAt.toLowerCase()) ||
|
||||
file.path.toLowerCase().includes(textAfterAt.toLowerCase()),
|
||||
)
|
||||
.slice(0, 10);
|
||||
|
||||
setFilteredFiles(matchingFiles);
|
||||
}, [input, cursorPosition, fileList]);
|
||||
|
||||
const activeFileMentions = useMemo(() => {
|
||||
if (!input || fileMentions.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return fileMentions.filter((path) => input.includes(path));
|
||||
}, [fileMentions, input]);
|
||||
|
||||
const sortedFileMentions = useMemo(() => {
|
||||
if (activeFileMentions.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const uniqueMentions = Array.from(new Set(activeFileMentions));
|
||||
return uniqueMentions.sort((mentionA, mentionB) => mentionB.length - mentionA.length);
|
||||
}, [activeFileMentions]);
|
||||
|
||||
const fileMentionRegex = useMemo(() => {
|
||||
if (sortedFileMentions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const pattern = sortedFileMentions.map(escapeRegExp).join('|');
|
||||
return new RegExp(`(${pattern})`, 'g');
|
||||
}, [sortedFileMentions]);
|
||||
|
||||
const fileMentionSet = useMemo(() => new Set(sortedFileMentions), [sortedFileMentions]);
|
||||
|
||||
const renderInputWithMentions = useCallback(
|
||||
(text: string) => {
|
||||
if (!text) {
|
||||
return '';
|
||||
}
|
||||
if (!fileMentionRegex) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const parts = text.split(fileMentionRegex);
|
||||
return parts.map((part, index) =>
|
||||
fileMentionSet.has(part) ? (
|
||||
<span
|
||||
key={`mention-${index}`}
|
||||
className="bg-blue-200/70 -ml-0.5 dark:bg-blue-300/40 px-0.5 rounded-md box-decoration-clone text-transparent"
|
||||
>
|
||||
{part}
|
||||
</span>
|
||||
) : (
|
||||
<span key={`text-${index}`}>{part}</span>
|
||||
),
|
||||
);
|
||||
},
|
||||
[fileMentionRegex, fileMentionSet],
|
||||
);
|
||||
|
||||
const selectFile = useCallback(
|
||||
(file: MentionableFile) => {
|
||||
const textBeforeAt = input.slice(0, atSymbolPosition);
|
||||
const textAfterAtQuery = input.slice(atSymbolPosition);
|
||||
const spaceIndex = textAfterAtQuery.indexOf(' ');
|
||||
const textAfterQuery = spaceIndex !== -1 ? textAfterAtQuery.slice(spaceIndex) : '';
|
||||
|
||||
const newInput = `${textBeforeAt}${file.path} ${textAfterQuery}`;
|
||||
const newCursorPosition = textBeforeAt.length + file.path.length + 1;
|
||||
|
||||
if (textareaRef.current && !textareaRef.current.matches(':focus')) {
|
||||
textareaRef.current.focus();
|
||||
}
|
||||
|
||||
setInput(newInput);
|
||||
setCursorPosition(newCursorPosition);
|
||||
setFileMentions((previousMentions) =>
|
||||
previousMentions.includes(file.path) ? previousMentions : [...previousMentions, file.path],
|
||||
);
|
||||
|
||||
setShowFileDropdown(false);
|
||||
setAtSymbolPosition(-1);
|
||||
|
||||
if (!textareaRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (!textareaRef.current) {
|
||||
return;
|
||||
}
|
||||
textareaRef.current.setSelectionRange(newCursorPosition, newCursorPosition);
|
||||
if (!textareaRef.current.matches(':focus')) {
|
||||
textareaRef.current.focus();
|
||||
}
|
||||
});
|
||||
},
|
||||
[input, atSymbolPosition, textareaRef, setInput],
|
||||
);
|
||||
|
||||
const handleFileMentionsKeyDown = useCallback(
|
||||
(event: KeyboardEvent<HTMLTextAreaElement>): boolean => {
|
||||
if (!showFileDropdown || filteredFiles.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
setSelectedFileIndex((previousIndex) =>
|
||||
previousIndex < filteredFiles.length - 1 ? previousIndex + 1 : 0,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
setSelectedFileIndex((previousIndex) =>
|
||||
previousIndex > 0 ? previousIndex - 1 : filteredFiles.length - 1,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === 'Tab' || event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
if (selectedFileIndex >= 0) {
|
||||
selectFile(filteredFiles[selectedFileIndex]);
|
||||
} else if (filteredFiles.length > 0) {
|
||||
selectFile(filteredFiles[0]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
setShowFileDropdown(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
[showFileDropdown, filteredFiles, selectedFileIndex, selectFile],
|
||||
);
|
||||
|
||||
return {
|
||||
showFileDropdown,
|
||||
filteredFiles,
|
||||
selectedFileIndex,
|
||||
renderInputWithMentions,
|
||||
selectFile,
|
||||
setCursorPosition,
|
||||
handleFileMentionsKeyDown,
|
||||
};
|
||||
}
|
||||
375
src/hooks/chat/useSlashCommands.ts
Normal file
375
src/hooks/chat/useSlashCommands.ts
Normal file
@@ -0,0 +1,375 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { Dispatch, KeyboardEvent, RefObject, SetStateAction } from 'react';
|
||||
import Fuse from 'fuse.js';
|
||||
import { authenticatedFetch } from '../../utils/api';
|
||||
import { safeLocalStorage } from '../../components/chat/utils/chatStorage';
|
||||
import type { Project } from '../../types/app';
|
||||
|
||||
const COMMAND_QUERY_DEBOUNCE_MS = 150;
|
||||
|
||||
export interface SlashCommand {
|
||||
name: string;
|
||||
description?: string;
|
||||
namespace?: string;
|
||||
path?: string;
|
||||
type?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface UseSlashCommandsOptions {
|
||||
selectedProject: Project | null;
|
||||
input: string;
|
||||
setInput: Dispatch<SetStateAction<string>>;
|
||||
textareaRef: RefObject<HTMLTextAreaElement>;
|
||||
onExecuteCommand: (command: SlashCommand) => void | Promise<void>;
|
||||
}
|
||||
|
||||
const getCommandHistoryKey = (projectName: string) => `command_history_${projectName}`;
|
||||
|
||||
const readCommandHistory = (projectName: string): Record<string, number> => {
|
||||
const history = safeLocalStorage.getItem(getCommandHistoryKey(projectName));
|
||||
if (!history) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(history);
|
||||
} catch (error) {
|
||||
console.error('Error parsing command history:', error);
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const saveCommandHistory = (projectName: string, history: Record<string, number>) => {
|
||||
safeLocalStorage.setItem(getCommandHistoryKey(projectName), JSON.stringify(history));
|
||||
};
|
||||
|
||||
const isPromiseLike = (value: unknown): value is Promise<unknown> =>
|
||||
Boolean(value) && typeof (value as Promise<unknown>).then === 'function';
|
||||
|
||||
export function useSlashCommands({
|
||||
selectedProject,
|
||||
input,
|
||||
setInput,
|
||||
textareaRef,
|
||||
onExecuteCommand,
|
||||
}: UseSlashCommandsOptions) {
|
||||
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
|
||||
const [filteredCommands, setFilteredCommands] = useState<SlashCommand[]>([]);
|
||||
const [showCommandMenu, setShowCommandMenu] = useState(false);
|
||||
const [commandQuery, setCommandQuery] = useState('');
|
||||
const [selectedCommandIndex, setSelectedCommandIndex] = useState(-1);
|
||||
const [slashPosition, setSlashPosition] = useState(-1);
|
||||
|
||||
const commandQueryTimerRef = useRef<number | null>(null);
|
||||
|
||||
const clearCommandQueryTimer = useCallback(() => {
|
||||
if (commandQueryTimerRef.current !== null) {
|
||||
window.clearTimeout(commandQueryTimerRef.current);
|
||||
commandQueryTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const resetCommandMenuState = useCallback(() => {
|
||||
setShowCommandMenu(false);
|
||||
setSlashPosition(-1);
|
||||
setCommandQuery('');
|
||||
setSelectedCommandIndex(-1);
|
||||
clearCommandQueryTimer();
|
||||
}, [clearCommandQueryTimer]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchCommands = async () => {
|
||||
if (!selectedProject) {
|
||||
setSlashCommands([]);
|
||||
setFilteredCommands([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await authenticatedFetch('/api/commands/list', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
projectPath: selectedProject.path,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch commands');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const allCommands: SlashCommand[] = [
|
||||
...((data.builtIn || []) as SlashCommand[]).map((command) => ({
|
||||
...command,
|
||||
type: 'built-in',
|
||||
})),
|
||||
...((data.custom || []) as SlashCommand[]).map((command) => ({
|
||||
...command,
|
||||
type: 'custom',
|
||||
})),
|
||||
];
|
||||
|
||||
const parsedHistory = readCommandHistory(selectedProject.name);
|
||||
const sortedCommands = [...allCommands].sort((commandA, commandB) => {
|
||||
const commandAUsage = parsedHistory[commandA.name] || 0;
|
||||
const commandBUsage = parsedHistory[commandB.name] || 0;
|
||||
return commandBUsage - commandAUsage;
|
||||
});
|
||||
|
||||
setSlashCommands(sortedCommands);
|
||||
} catch (error) {
|
||||
console.error('Error fetching slash commands:', error);
|
||||
setSlashCommands([]);
|
||||
}
|
||||
};
|
||||
|
||||
fetchCommands();
|
||||
}, [selectedProject]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showCommandMenu) {
|
||||
setSelectedCommandIndex(-1);
|
||||
}
|
||||
}, [showCommandMenu]);
|
||||
|
||||
const fuse = useMemo(() => {
|
||||
if (!slashCommands.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Fuse(slashCommands, {
|
||||
keys: [
|
||||
{ name: 'name', weight: 2 },
|
||||
{ name: 'description', weight: 1 },
|
||||
],
|
||||
threshold: 0.4,
|
||||
includeScore: true,
|
||||
minMatchCharLength: 1,
|
||||
});
|
||||
}, [slashCommands]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!commandQuery) {
|
||||
setFilteredCommands(slashCommands);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fuse) {
|
||||
setFilteredCommands([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const results = fuse.search(commandQuery);
|
||||
setFilteredCommands(results.map((result) => result.item));
|
||||
}, [commandQuery, slashCommands, fuse]);
|
||||
|
||||
const frequentCommands = useMemo(() => {
|
||||
if (!selectedProject || slashCommands.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const parsedHistory = readCommandHistory(selectedProject.name);
|
||||
|
||||
return slashCommands
|
||||
.map((command) => ({
|
||||
...command,
|
||||
usageCount: parsedHistory[command.name] || 0,
|
||||
}))
|
||||
.filter((command) => command.usageCount > 0)
|
||||
.sort((commandA, commandB) => commandB.usageCount - commandA.usageCount)
|
||||
.slice(0, 5);
|
||||
}, [selectedProject, slashCommands]);
|
||||
|
||||
const trackCommandUsage = useCallback(
|
||||
(command: SlashCommand) => {
|
||||
if (!selectedProject) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedHistory = readCommandHistory(selectedProject.name);
|
||||
parsedHistory[command.name] = (parsedHistory[command.name] || 0) + 1;
|
||||
saveCommandHistory(selectedProject.name, parsedHistory);
|
||||
},
|
||||
[selectedProject],
|
||||
);
|
||||
|
||||
const selectCommandFromKeyboard = useCallback(
|
||||
(command: SlashCommand) => {
|
||||
const textBeforeSlash = input.slice(0, slashPosition);
|
||||
const textAfterSlash = input.slice(slashPosition);
|
||||
const spaceIndex = textAfterSlash.indexOf(' ');
|
||||
const textAfterQuery = spaceIndex !== -1 ? textAfterSlash.slice(spaceIndex) : '';
|
||||
const newInput = `${textBeforeSlash}${command.name} ${textAfterQuery}`;
|
||||
|
||||
setInput(newInput);
|
||||
resetCommandMenuState();
|
||||
|
||||
const executionResult = onExecuteCommand(command);
|
||||
if (isPromiseLike(executionResult)) {
|
||||
executionResult.catch(() => {
|
||||
// Keep behavior silent; execution errors are handled by caller.
|
||||
});
|
||||
}
|
||||
},
|
||||
[input, slashPosition, setInput, resetCommandMenuState, onExecuteCommand],
|
||||
);
|
||||
|
||||
const handleCommandSelect = useCallback(
|
||||
(command: SlashCommand | null, index: number, isHover: boolean) => {
|
||||
if (!command || !selectedProject) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isHover) {
|
||||
setSelectedCommandIndex(index);
|
||||
return;
|
||||
}
|
||||
|
||||
trackCommandUsage(command);
|
||||
const executionResult = onExecuteCommand(command);
|
||||
|
||||
if (isPromiseLike(executionResult)) {
|
||||
executionResult.then(() => {
|
||||
resetCommandMenuState();
|
||||
});
|
||||
executionResult.catch(() => {
|
||||
// Keep behavior silent; execution errors are handled by caller.
|
||||
});
|
||||
} else {
|
||||
resetCommandMenuState();
|
||||
}
|
||||
},
|
||||
[selectedProject, trackCommandUsage, onExecuteCommand, resetCommandMenuState],
|
||||
);
|
||||
|
||||
const handleToggleCommandMenu = useCallback(() => {
|
||||
const isOpening = !showCommandMenu;
|
||||
setShowCommandMenu(isOpening);
|
||||
setCommandQuery('');
|
||||
setSelectedCommandIndex(-1);
|
||||
|
||||
if (isOpening) {
|
||||
setFilteredCommands(slashCommands);
|
||||
}
|
||||
|
||||
textareaRef.current?.focus();
|
||||
}, [showCommandMenu, slashCommands, textareaRef]);
|
||||
|
||||
const handleCommandInputChange = useCallback(
|
||||
(newValue: string, cursorPos: number) => {
|
||||
if (!newValue.trim()) {
|
||||
resetCommandMenuState();
|
||||
return;
|
||||
}
|
||||
|
||||
const textBeforeCursor = newValue.slice(0, cursorPos);
|
||||
const backticksBefore = (textBeforeCursor.match(/```/g) || []).length;
|
||||
const inCodeBlock = backticksBefore % 2 === 1;
|
||||
|
||||
if (inCodeBlock) {
|
||||
resetCommandMenuState();
|
||||
return;
|
||||
}
|
||||
|
||||
const slashPattern = /(^|\s)\/(\S*)$/;
|
||||
const match = textBeforeCursor.match(slashPattern);
|
||||
|
||||
if (!match) {
|
||||
resetCommandMenuState();
|
||||
return;
|
||||
}
|
||||
|
||||
const slashPos = (match.index || 0) + match[1].length;
|
||||
const query = match[2];
|
||||
|
||||
setSlashPosition(slashPos);
|
||||
setShowCommandMenu(true);
|
||||
setSelectedCommandIndex(-1);
|
||||
|
||||
clearCommandQueryTimer();
|
||||
commandQueryTimerRef.current = window.setTimeout(() => {
|
||||
setCommandQuery(query);
|
||||
}, COMMAND_QUERY_DEBOUNCE_MS);
|
||||
},
|
||||
[resetCommandMenuState, clearCommandQueryTimer],
|
||||
);
|
||||
|
||||
const handleCommandMenuKeyDown = useCallback(
|
||||
(event: KeyboardEvent<HTMLTextAreaElement>): boolean => {
|
||||
if (!showCommandMenu) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!filteredCommands.length) {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
resetCommandMenuState();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
setSelectedCommandIndex((previousIndex) =>
|
||||
previousIndex < filteredCommands.length - 1 ? previousIndex + 1 : 0,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
setSelectedCommandIndex((previousIndex) =>
|
||||
previousIndex > 0 ? previousIndex - 1 : filteredCommands.length - 1,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === 'Tab' || event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
if (selectedCommandIndex >= 0) {
|
||||
selectCommandFromKeyboard(filteredCommands[selectedCommandIndex]);
|
||||
} else if (filteredCommands.length > 0) {
|
||||
selectCommandFromKeyboard(filteredCommands[0]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
resetCommandMenuState();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
[showCommandMenu, filteredCommands, resetCommandMenuState, selectCommandFromKeyboard, selectedCommandIndex],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
clearCommandQueryTimer();
|
||||
},
|
||||
[clearCommandQueryTimer],
|
||||
);
|
||||
|
||||
return {
|
||||
slashCommands,
|
||||
slashCommandsCount: slashCommands.length,
|
||||
filteredCommands,
|
||||
frequentCommands,
|
||||
commandQuery,
|
||||
showCommandMenu,
|
||||
selectedCommandIndex,
|
||||
resetCommandMenuState,
|
||||
handleCommandSelect,
|
||||
handleToggleCommandMenu,
|
||||
handleCommandInputChange,
|
||||
handleCommandMenuKeyDown,
|
||||
};
|
||||
}
|
||||
2
src/types/react-syntax-highlighter.d.ts
vendored
Normal file
2
src/types/react-syntax-highlighter.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
declare module 'react-syntax-highlighter';
|
||||
declare module 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
Reference in New Issue
Block a user