mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-02-14 20:57:32 +00:00
refactor: Restructure files and folders to better mimic feature-based architecture
This commit is contained in:
@@ -2,8 +2,8 @@ import { useEffect } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import Sidebar from '../Sidebar';
|
||||
import MainContent from '../MainContent';
|
||||
import Sidebar from '../sidebar/view/Sidebar';
|
||||
import MainContent from '../main-content/view/MainContent';
|
||||
import MobileNav from '../MobileNav';
|
||||
|
||||
import { useWebSocket } from '../../contexts/WebSocketContext';
|
||||
|
||||
937
src/components/chat/hooks/useChatComposerState.ts
Normal file
937
src/components/chat/hooks/useChatComposerState.ts
Normal file
@@ -0,0 +1,937 @@
|
||||
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 '../../ThinkingModeSelector.jsx';
|
||||
import { grantClaudeToolPermission } from '../utils/chatPermissions';
|
||||
import { safeLocalStorage } from '../utils/chatStorage';
|
||||
import type {
|
||||
ChatMessage,
|
||||
PendingPermissionRequest,
|
||||
PermissionMode,
|
||||
Provider,
|
||||
} from '../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>;
|
||||
};
|
||||
|
||||
const isTemporarySessionId = (sessionId: string | null | undefined) =>
|
||||
Boolean(sessionId && sessionId.startsWith('new-session-'));
|
||||
|
||||
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 : provider === 'codex' ? codexModel : 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) {
|
||||
let errorMessage = `Failed to execute command (${response.status})`;
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
errorMessage = errorData?.message || errorData?.error || errorMessage;
|
||||
} catch {
|
||||
// Ignore JSON parse failures and use fallback message.
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
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,
|
||||
codexModel,
|
||||
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) {
|
||||
if (typeof window !== 'undefined') {
|
||||
// Reset stale pending IDs from previous interrupted runs before creating a new one.
|
||||
sessionStorage.removeItem('pendingSessionId');
|
||||
}
|
||||
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 (!canAbortSession) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pendingSessionId =
|
||||
typeof window !== 'undefined' ? sessionStorage.getItem('pendingSessionId') : null;
|
||||
const cursorSessionId =
|
||||
typeof window !== 'undefined' ? sessionStorage.getItem('cursorSessionId') : null;
|
||||
|
||||
const candidateSessionIds = [
|
||||
currentSessionId,
|
||||
pendingViewSessionRef.current?.sessionId || null,
|
||||
pendingSessionId,
|
||||
provider === 'cursor' ? cursorSessionId : null,
|
||||
selectedSession?.id || null,
|
||||
];
|
||||
|
||||
const targetSessionId =
|
||||
candidateSessionIds.find((sessionId) => Boolean(sessionId) && !isTemporarySessionId(sessionId)) || null;
|
||||
|
||||
if (!targetSessionId) {
|
||||
console.warn('Abort requested but no concrete session ID is available yet.');
|
||||
return;
|
||||
}
|
||||
|
||||
sendMessage({
|
||||
type: 'abort-session',
|
||||
sessionId: targetSessionId,
|
||||
provider,
|
||||
});
|
||||
}, [canAbortSession, currentSessionId, pendingViewSessionRef, provider, selectedSession?.id, 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/components/chat/hooks/useChatProviderState.ts
Normal file
114
src/components/chat/hooks/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 '../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,
|
||||
};
|
||||
}
|
||||
909
src/components/chat/hooks/useChatRealtimeHandlers.ts
Normal file
909
src/components/chat/hooks/useChatRealtimeHandlers.ts
Normal file
@@ -0,0 +1,909 @@
|
||||
import { useEffect } from 'react';
|
||||
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
|
||||
import { decodeHtmlEntities, formatUsageLimitText } from '../utils/chatFormatting';
|
||||
import { safeLocalStorage } from '../utils/chatStorage';
|
||||
import type { ChatMessage, PendingPermissionRequest, Provider } from '../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);
|
||||
};
|
||||
|
||||
const collectSessionIds = (...sessionIds: Array<string | null | undefined>) =>
|
||||
Array.from(
|
||||
new Set(
|
||||
sessionIds.filter((sessionId): sessionId is string => typeof sessionId === 'string' && sessionId.length > 0),
|
||||
),
|
||||
);
|
||||
|
||||
const clearLoadingIndicators = () => {
|
||||
setIsLoading(false);
|
||||
setCanAbortSession(false);
|
||||
setClaudeStatus(null);
|
||||
};
|
||||
|
||||
const markSessionsAsCompleted = (...sessionIds: Array<string | null | undefined>) => {
|
||||
const normalizedSessionIds = collectSessionIds(...sessionIds);
|
||||
normalizedSessionIds.forEach((sessionId) => {
|
||||
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;
|
||||
const pendingCursorSessionId = sessionStorage.getItem('pendingSessionId');
|
||||
|
||||
clearLoadingIndicators();
|
||||
markSessionsAsCompleted(
|
||||
cursorCompletedSessionId,
|
||||
currentSessionId,
|
||||
selectedSession?.id,
|
||||
pendingCursorSessionId,
|
||||
);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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 pendingSessionId = sessionStorage.getItem('pendingSessionId');
|
||||
const completedSessionId =
|
||||
latestMessage.sessionId || currentSessionId || pendingSessionId;
|
||||
|
||||
clearLoadingIndicators();
|
||||
markSessionsAsCompleted(
|
||||
completedSessionId,
|
||||
currentSessionId,
|
||||
selectedSession?.id,
|
||||
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') {
|
||||
clearLoadingIndicators();
|
||||
markSessionsAsCompleted(latestMessage.sessionId, currentSessionId, selectedSession?.id);
|
||||
}
|
||||
|
||||
if (codexData.type === 'turn_failed') {
|
||||
clearLoadingIndicators();
|
||||
markSessionsAsCompleted(latestMessage.sessionId, currentSessionId, selectedSession?.id);
|
||||
setChatMessages((previous) => [
|
||||
...previous,
|
||||
{
|
||||
type: 'error',
|
||||
content: codexData.error?.message || 'Turn failed',
|
||||
timestamp: new Date(),
|
||||
},
|
||||
]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'codex-complete': {
|
||||
const codexPendingSessionId = sessionStorage.getItem('pendingSessionId');
|
||||
const codexActualSessionId = latestMessage.actualSessionId || codexPendingSessionId;
|
||||
const codexCompletedSessionId =
|
||||
latestMessage.sessionId || currentSessionId || codexPendingSessionId;
|
||||
|
||||
clearLoadingIndicators();
|
||||
markSessionsAsCompleted(
|
||||
codexCompletedSessionId,
|
||||
codexActualSessionId,
|
||||
currentSessionId,
|
||||
selectedSession?.id,
|
||||
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 pendingSessionId =
|
||||
typeof window !== 'undefined' ? sessionStorage.getItem('pendingSessionId') : null;
|
||||
const abortedSessionId = latestMessage.sessionId || currentSessionId;
|
||||
const abortSucceeded = latestMessage.success !== false;
|
||||
|
||||
if (abortSucceeded) {
|
||||
clearLoadingIndicators();
|
||||
markSessionsAsCompleted(abortedSessionId, currentSessionId, selectedSession?.id, pendingSessionId);
|
||||
if (pendingSessionId && (!abortedSessionId || pendingSessionId === abortedSessionId)) {
|
||||
sessionStorage.removeItem('pendingSessionId');
|
||||
}
|
||||
|
||||
setPendingPermissionRequests([]);
|
||||
setChatMessages((previous) => [
|
||||
...previous,
|
||||
{
|
||||
type: 'assistant',
|
||||
content: 'Session interrupted by user.',
|
||||
timestamp: new Date(),
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
setChatMessages((previous) => [
|
||||
...previous,
|
||||
{
|
||||
type: 'error',
|
||||
content: 'Stop request failed. The session is still running.',
|
||||
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]);
|
||||
}
|
||||
603
src/components/chat/hooks/useChatSessionState.ts
Normal file
603
src/components/chat/hooks/useChatSessionState.ts
Normal file
@@ -0,0 +1,603 @@
|
||||
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 '../types';
|
||||
import type { Project, ProjectSession } from '../../../types/app';
|
||||
import { safeLocalStorage } from '../utils/chatStorage';
|
||||
import {
|
||||
convertCursorSessionMessages,
|
||||
convertSessionMessages,
|
||||
createCachedDiffCalculator,
|
||||
type DiffCalculator,
|
||||
} from '../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 [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 pendingInitialScrollRef = useRef(true);
|
||||
const messagesOffsetRef = useRef(0);
|
||||
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 ? messagesOffsetRef.current : 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) {
|
||||
const loadedCount = data.messages?.length || 0;
|
||||
setHasMoreMessages(Boolean(data.hasMore));
|
||||
setTotalMessages(Number(data.total || 0));
|
||||
messagesOffsetRef.current = currentOffset + loadedCount;
|
||||
return data.messages || [];
|
||||
}
|
||||
|
||||
const messages = data.messages || [];
|
||||
setHasMoreMessages(false);
|
||||
setTotalMessages(messages.length);
|
||||
messagesOffsetRef.current = messages.length;
|
||||
return messages;
|
||||
} catch (error) {
|
||||
console.error('Error loading session messages:', error);
|
||||
return [];
|
||||
} finally {
|
||||
if (isInitialLoad) {
|
||||
setIsLoadingSessionMessages(false);
|
||||
} else {
|
||||
setIsLoadingMoreMessages(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
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) {
|
||||
return false;
|
||||
}
|
||||
|
||||
pendingScrollRestoreRef.current = {
|
||||
height: previousScrollHeight,
|
||||
top: previousScrollTop,
|
||||
};
|
||||
setSessionMessages((previous) => [...moreMessages, ...previous]);
|
||||
// Keep the rendered window in sync with top-pagination so newly loaded history becomes visible.
|
||||
setVisibleMessageCount((previousCount) => previousCount + moreMessages.length);
|
||||
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) {
|
||||
// After a top-load restore, release the lock once user has moved away from absolute top.
|
||||
if (container.scrollTop > 20) {
|
||||
topLoadLockRef.current = false;
|
||||
}
|
||||
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(() => {
|
||||
pendingInitialScrollRef.current = true;
|
||||
topLoadLockRef.current = false;
|
||||
pendingScrollRestoreRef.current = null;
|
||||
setVisibleMessageCount(INITIAL_VISIBLE_MESSAGES);
|
||||
setIsUserScrolledUp(false);
|
||||
}, [selectedProject?.name, selectedSession?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingInitialScrollRef.current || !scrollContainerRef.current || isLoadingSessionMessages) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (chatMessages.length === 0) {
|
||||
pendingInitialScrollRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
pendingInitialScrollRef.current = false;
|
||||
setTimeout(() => {
|
||||
scrollToBottom();
|
||||
}, 200);
|
||||
}, [chatMessages.length, isLoadingSessionMessages, scrollToBottom]);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
messagesOffsetRef.current = 0;
|
||||
setHasMoreMessages(false);
|
||||
setTotalMessages(0);
|
||||
setTokenBudget(null);
|
||||
setIsLoading(false);
|
||||
|
||||
if (ws) {
|
||||
sendMessage({
|
||||
type: 'check-session-status',
|
||||
sessionId: selectedSession.id,
|
||||
provider,
|
||||
});
|
||||
}
|
||||
} else if (currentSessionId === null) {
|
||||
messagesOffsetRef.current = 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');
|
||||
messagesOffsetRef.current = 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 (isLoadingMoreRef.current || isLoadingMoreMessages || pendingScrollRestoreRef.current) {
|
||||
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, isLoadingMoreMessages, isUserScrolledUp, scrollToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
container.addEventListener('scroll', handleScroll);
|
||||
return () => container.removeEventListener('scroll', handleScroll);
|
||||
}, [handleScroll]);
|
||||
|
||||
useEffect(() => {
|
||||
const activeViewSessionId = selectedSession?.id || currentSessionId;
|
||||
if (!activeViewSessionId || !processingSessions) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldBeProcessing = processingSessions.has(activeViewSessionId);
|
||||
if (shouldBeProcessing && !isLoading) {
|
||||
setIsLoading(true);
|
||||
setCanAbortSession(true);
|
||||
}
|
||||
}, [currentSessionId, isLoading, processingSessions, selectedSession?.id]);
|
||||
|
||||
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/components/chat/hooks/useFileMentions.tsx
Normal file
257
src/components/chat/hooks/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 '../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/components/chat/hooks/useSlashCommands.ts
Normal file
375
src/components/chat/hooks/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 '../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,
|
||||
};
|
||||
}
|
||||
@@ -1,15 +1,15 @@
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
import QuickSettingsPanel from './QuickSettingsPanel';
|
||||
import { useTasksSettings } from '../contexts/TasksSettingsContext';
|
||||
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';
|
||||
import ChatMessagesPane from './ChatMessagesPane';
|
||||
import ChatComposer from './ChatComposer';
|
||||
import type { ChatInterfaceProps } from '../types';
|
||||
import { useChatProviderState } from '../hooks/useChatProviderState';
|
||||
import { useChatSessionState } from '../hooks/useChatSessionState';
|
||||
import { useChatRealtimeHandlers } from '../hooks/useChatRealtimeHandlers';
|
||||
import { useChatComposerState } from '../hooks/useChatComposerState';
|
||||
import type { Provider } from '../types';
|
||||
|
||||
type PendingViewSession = {
|
||||
sessionId: string | null;
|
||||
110
src/components/main-content/hooks/useEditorSidebar.ts
Normal file
110
src/components/main-content/hooks/useEditorSidebar.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { MouseEvent as ReactMouseEvent } from 'react';
|
||||
import type { Project } from '../../../types/app';
|
||||
import type { DiffInfo, EditingFile } from '../types/types';
|
||||
|
||||
type UseEditorSidebarOptions = {
|
||||
selectedProject: Project | null;
|
||||
isMobile: boolean;
|
||||
initialWidth?: number;
|
||||
};
|
||||
|
||||
export function useEditorSidebar({
|
||||
selectedProject,
|
||||
isMobile,
|
||||
initialWidth = 600,
|
||||
}: UseEditorSidebarOptions) {
|
||||
const [editingFile, setEditingFile] = useState<EditingFile | null>(null);
|
||||
const [editorWidth, setEditorWidth] = useState(initialWidth);
|
||||
const [editorExpanded, setEditorExpanded] = useState(false);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const resizeHandleRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const handleFileOpen = useCallback(
|
||||
(filePath: string, diffInfo: DiffInfo | null = null) => {
|
||||
const normalizedPath = filePath.replace(/\\/g, '/');
|
||||
const fileName = normalizedPath.split('/').pop() || filePath;
|
||||
|
||||
setEditingFile({
|
||||
name: fileName,
|
||||
path: filePath,
|
||||
projectName: selectedProject?.name,
|
||||
diffInfo,
|
||||
});
|
||||
},
|
||||
[selectedProject?.name],
|
||||
);
|
||||
|
||||
const handleCloseEditor = useCallback(() => {
|
||||
setEditingFile(null);
|
||||
setEditorExpanded(false);
|
||||
}, []);
|
||||
|
||||
const handleToggleEditorExpand = useCallback(() => {
|
||||
setEditorExpanded((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const handleResizeStart = useCallback(
|
||||
(event: ReactMouseEvent<HTMLDivElement>) => {
|
||||
if (isMobile) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsResizing(true);
|
||||
event.preventDefault();
|
||||
},
|
||||
[isMobile],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (event: globalThis.MouseEvent) => {
|
||||
if (!isResizing) {
|
||||
return;
|
||||
}
|
||||
|
||||
const container = resizeHandleRef.current?.parentElement;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const newWidth = containerRect.right - event.clientX;
|
||||
|
||||
const minWidth = 300;
|
||||
const maxWidth = containerRect.width * 0.8;
|
||||
|
||||
if (newWidth >= minWidth && newWidth <= maxWidth) {
|
||||
setEditorWidth(newWidth);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsResizing(false);
|
||||
};
|
||||
|
||||
if (isResizing) {
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
};
|
||||
}, [isResizing]);
|
||||
|
||||
return {
|
||||
editingFile,
|
||||
editorWidth,
|
||||
editorExpanded,
|
||||
resizeHandleRef,
|
||||
handleFileOpen,
|
||||
handleCloseEditor,
|
||||
handleToggleEditorExpand,
|
||||
handleResizeStart,
|
||||
};
|
||||
}
|
||||
50
src/components/main-content/hooks/useMobileMenuHandlers.ts
Normal file
50
src/components/main-content/hooks/useMobileMenuHandlers.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import type { MouseEvent, TouchEvent } from 'react';
|
||||
|
||||
type MenuEvent = MouseEvent<HTMLButtonElement> | TouchEvent<HTMLButtonElement>;
|
||||
|
||||
export function useMobileMenuHandlers(onMenuClick: () => void) {
|
||||
const suppressNextMenuClickRef = useRef(false);
|
||||
|
||||
const openMobileMenu = useCallback(
|
||||
(event?: MenuEvent) => {
|
||||
if (event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
onMenuClick();
|
||||
},
|
||||
[onMenuClick],
|
||||
);
|
||||
|
||||
const handleMobileMenuTouchEnd = useCallback(
|
||||
(event: TouchEvent<HTMLButtonElement>) => {
|
||||
suppressNextMenuClickRef.current = true;
|
||||
openMobileMenu(event);
|
||||
|
||||
window.setTimeout(() => {
|
||||
suppressNextMenuClickRef.current = false;
|
||||
}, 350);
|
||||
},
|
||||
[openMobileMenu],
|
||||
);
|
||||
|
||||
const handleMobileMenuClick = useCallback(
|
||||
(event: MouseEvent<HTMLButtonElement>) => {
|
||||
if (suppressNextMenuClickRef.current) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
openMobileMenu(event);
|
||||
},
|
||||
[openMobileMenu],
|
||||
);
|
||||
|
||||
return {
|
||||
handleMobileMenuClick,
|
||||
handleMobileMenuTouchEnd,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Dispatch, MouseEvent, RefObject, SetStateAction } from 'react';
|
||||
import type { AppTab, Project, ProjectSession } from '../../types/app';
|
||||
import type { AppTab, Project, ProjectSession } from '../../../types/app';
|
||||
|
||||
export type SessionLifecycleHandler = (sessionId?: string | null) => void;
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import React, { useEffect } from 'react';
|
||||
|
||||
import ChatInterface from './ChatInterface';
|
||||
import FileTree from './FileTree';
|
||||
import StandaloneShell from './StandaloneShell';
|
||||
import GitPanel from './GitPanel';
|
||||
import ErrorBoundary from './ErrorBoundary';
|
||||
import ChatInterface from '../../chat/view/ChatInterface';
|
||||
import FileTree from '../../FileTree';
|
||||
import StandaloneShell from '../../StandaloneShell';
|
||||
import GitPanel from '../../GitPanel';
|
||||
import ErrorBoundary from '../../ErrorBoundary';
|
||||
|
||||
import MainContentHeader from './main-content/MainContentHeader';
|
||||
import MainContentStateView from './main-content/MainContentStateView';
|
||||
import EditorSidebar from './main-content/EditorSidebar';
|
||||
import TaskMasterPanel from './main-content/TaskMasterPanel';
|
||||
import type { MainContentProps } from './main-content/types';
|
||||
import MainContentHeader from './subcomponents/MainContentHeader';
|
||||
import MainContentStateView from './subcomponents/MainContentStateView';
|
||||
import EditorSidebar from './subcomponents/EditorSidebar';
|
||||
import TaskMasterPanel from './subcomponents/TaskMasterPanel';
|
||||
import type { MainContentProps } from '../types/types';
|
||||
|
||||
import { useTaskMaster } from '../contexts/TaskMasterContext';
|
||||
import { useTasksSettings } from '../contexts/TasksSettingsContext';
|
||||
import { useUiPreferences } from '../hooks/useUiPreferences';
|
||||
import { useEditorSidebar } from '../hooks/main-content/useEditorSidebar';
|
||||
import type { Project } from '../types/app';
|
||||
import { useTaskMaster } from '../../../contexts/TaskMasterContext';
|
||||
import { useTasksSettings } from '../../../contexts/TasksSettingsContext';
|
||||
import { useUiPreferences } from '../../../hooks/useUiPreferences';
|
||||
import { useEditorSidebar } from '../hooks/useEditorSidebar';
|
||||
import type { Project } from '../../../types/app';
|
||||
|
||||
const AnyStandaloneShell = StandaloneShell as any;
|
||||
const AnyGitPanel = GitPanel as any;
|
||||
@@ -1,5 +1,5 @@
|
||||
import CodeEditor from '../CodeEditor';
|
||||
import type { EditorSidebarProps } from './types';
|
||||
import CodeEditor from '../../../CodeEditor';
|
||||
import type { EditorSidebarProps } from '../../types/types';
|
||||
|
||||
const AnyCodeEditor = CodeEditor as any;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import MobileMenuButton from './MobileMenuButton';
|
||||
import MainContentTabSwitcher from './MainContentTabSwitcher';
|
||||
import MainContentTitle from './MainContentTitle';
|
||||
import type { MainContentHeaderProps } from './types';
|
||||
import type { MainContentHeaderProps } from '../../types/types';
|
||||
|
||||
export default function MainContentHeader({
|
||||
activeTab,
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import MobileMenuButton from './MobileMenuButton';
|
||||
import type { MainContentStateViewProps } from './types';
|
||||
import type { MainContentStateViewProps } from '../../types/types';
|
||||
|
||||
export default function MainContentStateView({ mode, isMobile, onMenuClick }: MainContentStateViewProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -1,5 +1,5 @@
|
||||
import Tooltip from '../Tooltip';
|
||||
import type { AppTab } from '../../types/app';
|
||||
import Tooltip from '../../../Tooltip';
|
||||
import type { AppTab } from '../../../../types/app';
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import SessionProviderLogo from '../SessionProviderLogo';
|
||||
import type { AppTab, Project, ProjectSession } from '../../types/app';
|
||||
import SessionProviderLogo from '../../../SessionProviderLogo';
|
||||
import type { AppTab, Project, ProjectSession } from '../../../../types/app';
|
||||
|
||||
type MainContentTitleProps = {
|
||||
activeTab: AppTab;
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { MobileMenuButtonProps } from './types';
|
||||
import { useMobileMenuHandlers } from '../../hooks/main-content/useMobileMenuHandlers';
|
||||
import type { MobileMenuButtonProps } from '../../types/types';
|
||||
import { useMobileMenuHandlers } from '../../hooks/useMobileMenuHandlers';
|
||||
|
||||
export default function MobileMenuButton({ onMenuClick, compact = false }: MobileMenuButtonProps) {
|
||||
const { handleMobileMenuClick, handleMobileMenuTouchEnd } = useMobileMenuHandlers(onMenuClick);
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import TaskList from '../TaskList';
|
||||
import TaskDetail from '../TaskDetail';
|
||||
import PRDEditor from '../PRDEditor';
|
||||
import { useTaskMaster } from '../../contexts/TaskMasterContext';
|
||||
import { api } from '../../utils/api';
|
||||
import type { Project } from '../../types/app';
|
||||
import type { PrdFile, TaskMasterPanelProps, TaskMasterTask, TaskSelection } from './types';
|
||||
import TaskList from '../../../TaskList';
|
||||
import TaskDetail from '../../../TaskDetail';
|
||||
import PRDEditor from '../../../PRDEditor';
|
||||
import { useTaskMaster } from '../../../../contexts/TaskMasterContext';
|
||||
import { api } from '../../../../utils/api';
|
||||
import type { Project } from '../../../../types/app';
|
||||
import type { PrdFile, TaskMasterPanelProps, TaskMasterTask, TaskSelection } from '../../types/types';
|
||||
|
||||
const AnyTaskList = TaskList as any;
|
||||
const AnyTaskDetail = TaskDetail as any;
|
||||
448
src/components/sidebar/hooks/useSidebarController.ts
Normal file
448
src/components/sidebar/hooks/useSidebarController.ts
Normal file
@@ -0,0 +1,448 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import type React from 'react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { api } from '../../../utils/api';
|
||||
import type { Project, ProjectSession } from '../../../types/app';
|
||||
import type {
|
||||
AdditionalSessionsByProject,
|
||||
DeleteProjectConfirmation,
|
||||
LoadingSessionsByProject,
|
||||
ProjectSortOrder,
|
||||
SessionDeleteConfirmation,
|
||||
SessionWithProvider,
|
||||
} from '../types/types';
|
||||
import {
|
||||
filterProjects,
|
||||
getAllSessions,
|
||||
loadStarredProjects,
|
||||
persistStarredProjects,
|
||||
readProjectSortOrder,
|
||||
sortProjects,
|
||||
} from '../utils/utils';
|
||||
|
||||
type UseSidebarControllerArgs = {
|
||||
projects: Project[];
|
||||
selectedProject: Project | null;
|
||||
selectedSession: ProjectSession | null;
|
||||
isLoading: boolean;
|
||||
isMobile: boolean;
|
||||
t: TFunction;
|
||||
onRefresh: () => Promise<void> | void;
|
||||
onProjectSelect: (project: Project) => void;
|
||||
onSessionSelect: (session: ProjectSession) => void;
|
||||
onSessionDelete?: (sessionId: string) => void;
|
||||
onProjectDelete?: (projectName: string) => void;
|
||||
setCurrentProject: (project: Project) => void;
|
||||
setSidebarVisible: (visible: boolean) => void;
|
||||
sidebarVisible: boolean;
|
||||
};
|
||||
|
||||
export function useSidebarController({
|
||||
projects,
|
||||
selectedProject,
|
||||
selectedSession,
|
||||
isLoading,
|
||||
isMobile,
|
||||
t,
|
||||
onRefresh,
|
||||
onProjectSelect,
|
||||
onSessionSelect,
|
||||
onSessionDelete,
|
||||
onProjectDelete,
|
||||
setCurrentProject,
|
||||
setSidebarVisible,
|
||||
sidebarVisible,
|
||||
}: UseSidebarControllerArgs) {
|
||||
const [expandedProjects, setExpandedProjects] = useState<Set<string>>(new Set());
|
||||
const [editingProject, setEditingProject] = useState<string | null>(null);
|
||||
const [showNewProject, setShowNewProject] = useState(false);
|
||||
const [editingName, setEditingName] = useState('');
|
||||
const [loadingSessions, setLoadingSessions] = useState<LoadingSessionsByProject>({});
|
||||
const [additionalSessions, setAdditionalSessions] = useState<AdditionalSessionsByProject>({});
|
||||
const [initialSessionsLoaded, setInitialSessionsLoaded] = useState<Set<string>>(new Set());
|
||||
const [currentTime, setCurrentTime] = useState(new Date());
|
||||
const [projectSortOrder, setProjectSortOrder] = useState<ProjectSortOrder>('name');
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [editingSession, setEditingSession] = useState<string | null>(null);
|
||||
const [editingSessionName, setEditingSessionName] = useState('');
|
||||
const [searchFilter, setSearchFilter] = useState('');
|
||||
const [deletingProjects, setDeletingProjects] = useState<Set<string>>(new Set());
|
||||
const [deleteConfirmation, setDeleteConfirmation] = useState<DeleteProjectConfirmation | null>(null);
|
||||
const [sessionDeleteConfirmation, setSessionDeleteConfirmation] = useState<SessionDeleteConfirmation | null>(null);
|
||||
const [showVersionModal, setShowVersionModal] = useState(false);
|
||||
const [starredProjects, setStarredProjects] = useState<Set<string>>(() => loadStarredProjects());
|
||||
|
||||
const isSidebarCollapsed = !isMobile && !sidebarVisible;
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setCurrentTime(new Date());
|
||||
}, 60000);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setAdditionalSessions({});
|
||||
setInitialSessionsLoaded(new Set());
|
||||
}, [projects]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedSession && selectedProject) {
|
||||
setExpandedProjects((prev) => {
|
||||
if (prev.has(selectedProject.name)) {
|
||||
return prev;
|
||||
}
|
||||
const next = new Set(prev);
|
||||
next.add(selectedProject.name);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, [selectedSession, selectedProject]);
|
||||
|
||||
useEffect(() => {
|
||||
if (projects.length > 0 && !isLoading) {
|
||||
const loadedProjects = new Set<string>();
|
||||
projects.forEach((project) => {
|
||||
if (project.sessions && project.sessions.length >= 0) {
|
||||
loadedProjects.add(project.name);
|
||||
}
|
||||
});
|
||||
setInitialSessionsLoaded(loadedProjects);
|
||||
}
|
||||
}, [projects, isLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadSortOrder = () => {
|
||||
setProjectSortOrder(readProjectSortOrder());
|
||||
};
|
||||
|
||||
loadSortOrder();
|
||||
|
||||
const handleStorageChange = (event: StorageEvent) => {
|
||||
if (event.key === 'claude-settings') {
|
||||
loadSortOrder();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('storage', handleStorageChange);
|
||||
|
||||
const interval = setInterval(() => {
|
||||
if (document.hasFocus()) {
|
||||
loadSortOrder();
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('storage', handleStorageChange);
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleTouchClick = useCallback(
|
||||
(callback: () => void) =>
|
||||
(event: React.TouchEvent<HTMLElement>) => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.closest('.overflow-y-auto') || target.closest('[data-scroll-container]')) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
callback();
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const toggleProject = useCallback((projectName: string) => {
|
||||
setExpandedProjects((prev) => {
|
||||
const next = new Set<string>();
|
||||
if (!prev.has(projectName)) {
|
||||
next.add(projectName);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSessionClick = useCallback(
|
||||
(session: SessionWithProvider, projectName: string) => {
|
||||
onSessionSelect({ ...session, __projectName: projectName });
|
||||
},
|
||||
[onSessionSelect],
|
||||
);
|
||||
|
||||
const toggleStarProject = useCallback((projectName: string) => {
|
||||
setStarredProjects((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(projectName)) {
|
||||
next.delete(projectName);
|
||||
} else {
|
||||
next.add(projectName);
|
||||
}
|
||||
|
||||
persistStarredProjects(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const isProjectStarred = useCallback(
|
||||
(projectName: string) => starredProjects.has(projectName),
|
||||
[starredProjects],
|
||||
);
|
||||
|
||||
const getProjectSessions = useCallback(
|
||||
(project: Project) => getAllSessions(project, additionalSessions),
|
||||
[additionalSessions],
|
||||
);
|
||||
|
||||
const sortedProjects = useMemo(
|
||||
() => sortProjects(projects, projectSortOrder, starredProjects, additionalSessions),
|
||||
[additionalSessions, projectSortOrder, projects, starredProjects],
|
||||
);
|
||||
|
||||
const filteredProjects = useMemo(
|
||||
() => filterProjects(sortedProjects, searchFilter),
|
||||
[searchFilter, sortedProjects],
|
||||
);
|
||||
|
||||
const startEditing = useCallback((project: Project) => {
|
||||
setEditingProject(project.name);
|
||||
setEditingName(project.displayName);
|
||||
}, []);
|
||||
|
||||
const cancelEditing = useCallback(() => {
|
||||
setEditingProject(null);
|
||||
setEditingName('');
|
||||
}, []);
|
||||
|
||||
const saveProjectName = useCallback(
|
||||
async (projectName: string) => {
|
||||
try {
|
||||
const response = await api.renameProject(projectName, editingName);
|
||||
if (response.ok) {
|
||||
if (window.refreshProjects) {
|
||||
await window.refreshProjects();
|
||||
} else {
|
||||
window.location.reload();
|
||||
}
|
||||
} else {
|
||||
console.error('Failed to rename project');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error renaming project:', error);
|
||||
} finally {
|
||||
setEditingProject(null);
|
||||
setEditingName('');
|
||||
}
|
||||
},
|
||||
[editingName],
|
||||
);
|
||||
|
||||
const showDeleteSessionConfirmation = useCallback(
|
||||
(
|
||||
projectName: string,
|
||||
sessionId: string,
|
||||
sessionTitle: string,
|
||||
provider: SessionDeleteConfirmation['provider'] = 'claude',
|
||||
) => {
|
||||
setSessionDeleteConfirmation({ projectName, sessionId, sessionTitle, provider });
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const confirmDeleteSession = useCallback(async () => {
|
||||
if (!sessionDeleteConfirmation) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { projectName, sessionId, provider } = sessionDeleteConfirmation;
|
||||
setSessionDeleteConfirmation(null);
|
||||
|
||||
try {
|
||||
const response =
|
||||
provider === 'codex'
|
||||
? await api.deleteCodexSession(sessionId)
|
||||
: await api.deleteSession(projectName, sessionId);
|
||||
|
||||
if (response.ok) {
|
||||
onSessionDelete?.(sessionId);
|
||||
} else {
|
||||
const errorText = await response.text();
|
||||
console.error('[Sidebar] Failed to delete session:', {
|
||||
status: response.status,
|
||||
error: errorText,
|
||||
});
|
||||
alert(t('messages.deleteSessionFailed'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Sidebar] Error deleting session:', error);
|
||||
alert(t('messages.deleteSessionError'));
|
||||
}
|
||||
}, [onSessionDelete, sessionDeleteConfirmation, t]);
|
||||
|
||||
const requestProjectDelete = useCallback(
|
||||
(project: Project) => {
|
||||
setDeleteConfirmation({
|
||||
project,
|
||||
sessionCount: getProjectSessions(project).length,
|
||||
});
|
||||
},
|
||||
[getProjectSessions],
|
||||
);
|
||||
|
||||
const confirmDeleteProject = useCallback(async () => {
|
||||
if (!deleteConfirmation) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { project, sessionCount } = deleteConfirmation;
|
||||
const isEmpty = sessionCount === 0;
|
||||
|
||||
setDeleteConfirmation(null);
|
||||
setDeletingProjects((prev) => new Set([...prev, project.name]));
|
||||
|
||||
try {
|
||||
const response = await api.deleteProject(project.name, !isEmpty);
|
||||
|
||||
if (response.ok) {
|
||||
onProjectDelete?.(project.name);
|
||||
} else {
|
||||
const error = (await response.json()) as { error?: string };
|
||||
alert(error.error || t('messages.deleteProjectFailed'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting project:', error);
|
||||
alert(t('messages.deleteProjectError'));
|
||||
} finally {
|
||||
setDeletingProjects((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(project.name);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, [deleteConfirmation, onProjectDelete, t]);
|
||||
|
||||
const loadMoreSessions = useCallback(
|
||||
async (project: Project) => {
|
||||
const canLoadMore = project.sessionMeta?.hasMore === true;
|
||||
if (!canLoadMore || loadingSessions[project.name]) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingSessions((prev) => ({ ...prev, [project.name]: true }));
|
||||
|
||||
try {
|
||||
const currentSessionCount =
|
||||
(project.sessions?.length || 0) + (additionalSessions[project.name]?.length || 0);
|
||||
const response = await api.sessions(project.name, 5, currentSessionCount);
|
||||
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = (await response.json()) as {
|
||||
sessions?: ProjectSession[];
|
||||
hasMore?: boolean;
|
||||
};
|
||||
|
||||
setAdditionalSessions((prev) => ({
|
||||
...prev,
|
||||
[project.name]: [...(prev[project.name] || []), ...(result.sessions || [])],
|
||||
}));
|
||||
|
||||
if (result.hasMore === false) {
|
||||
project.sessionMeta = { ...project.sessionMeta, hasMore: false };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading more sessions:', error);
|
||||
} finally {
|
||||
setLoadingSessions((prev) => ({ ...prev, [project.name]: false }));
|
||||
}
|
||||
},
|
||||
[additionalSessions, loadingSessions],
|
||||
);
|
||||
|
||||
const handleProjectSelect = useCallback(
|
||||
(project: Project) => {
|
||||
onProjectSelect(project);
|
||||
setCurrentProject(project);
|
||||
},
|
||||
[onProjectSelect, setCurrentProject],
|
||||
);
|
||||
|
||||
const refreshProjects = useCallback(async () => {
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
await onRefresh();
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
}, [onRefresh]);
|
||||
|
||||
const updateSessionSummary = useCallback(
|
||||
async (_projectName: string, _sessionId: string, _summary: string) => {
|
||||
// Session rename endpoint is not currently exposed on the API.
|
||||
setEditingSession(null);
|
||||
setEditingSessionName('');
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const collapseSidebar = useCallback(() => {
|
||||
setSidebarVisible(false);
|
||||
}, [setSidebarVisible]);
|
||||
|
||||
const expandSidebar = useCallback(() => {
|
||||
setSidebarVisible(true);
|
||||
}, [setSidebarVisible]);
|
||||
|
||||
return {
|
||||
isSidebarCollapsed,
|
||||
expandedProjects,
|
||||
editingProject,
|
||||
showNewProject,
|
||||
editingName,
|
||||
loadingSessions,
|
||||
additionalSessions,
|
||||
initialSessionsLoaded,
|
||||
currentTime,
|
||||
projectSortOrder,
|
||||
isRefreshing,
|
||||
editingSession,
|
||||
editingSessionName,
|
||||
searchFilter,
|
||||
deletingProjects,
|
||||
deleteConfirmation,
|
||||
sessionDeleteConfirmation,
|
||||
showVersionModal,
|
||||
starredProjects,
|
||||
filteredProjects,
|
||||
handleTouchClick,
|
||||
toggleProject,
|
||||
handleSessionClick,
|
||||
toggleStarProject,
|
||||
isProjectStarred,
|
||||
getProjectSessions,
|
||||
startEditing,
|
||||
cancelEditing,
|
||||
saveProjectName,
|
||||
showDeleteSessionConfirmation,
|
||||
confirmDeleteSession,
|
||||
requestProjectDelete,
|
||||
confirmDeleteProject,
|
||||
loadMoreSessions,
|
||||
handleProjectSelect,
|
||||
refreshProjects,
|
||||
updateSessionSummary,
|
||||
collapseSidebar,
|
||||
expandSidebar,
|
||||
setShowNewProject,
|
||||
setEditingName,
|
||||
setEditingSession,
|
||||
setEditingSessionName,
|
||||
setSearchFilter,
|
||||
setDeleteConfirmation,
|
||||
setSessionDeleteConfirmation,
|
||||
setShowVersionModal,
|
||||
};
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { Project } from '../../types/app';
|
||||
import type { Project } from '../../../types/app';
|
||||
import type {
|
||||
AdditionalSessionsByProject,
|
||||
ProjectSortOrder,
|
||||
SessionViewModel,
|
||||
SessionWithProvider,
|
||||
} from './types';
|
||||
} from '../types/types';
|
||||
|
||||
export const readProjectSortOrder = (): ProjectSortOrder => {
|
||||
try {
|
||||
@@ -1,17 +1,17 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDeviceSettings } from '../hooks/useDeviceSettings';
|
||||
import { useVersionCheck } from '../hooks/useVersionCheck';
|
||||
import { useUiPreferences } from '../hooks/useUiPreferences';
|
||||
import { useDeviceSettings } from '../../../hooks/useDeviceSettings';
|
||||
import { useVersionCheck } from '../../../hooks/useVersionCheck';
|
||||
import { useUiPreferences } from '../../../hooks/useUiPreferences';
|
||||
import { useSidebarController } from '../hooks/useSidebarController';
|
||||
import { useTaskMaster } from '../contexts/TaskMasterContext';
|
||||
import { useTasksSettings } from '../contexts/TasksSettingsContext';
|
||||
import SidebarCollapsed from './sidebar/SidebarCollapsed';
|
||||
import SidebarContent from './sidebar/SidebarContent';
|
||||
import SidebarModals from './sidebar/SidebarModals';
|
||||
import type { Project } from '../types/app';
|
||||
import type { SidebarProjectListProps } from './sidebar/SidebarProjectList';
|
||||
import type { MCPServerStatus, SidebarProps } from './sidebar/types';
|
||||
import { useTaskMaster } from '../../../contexts/TaskMasterContext';
|
||||
import { useTasksSettings } from '../../../contexts/TasksSettingsContext';
|
||||
import SidebarCollapsed from './subcomponents/SidebarCollapsed';
|
||||
import SidebarContent from './subcomponents/SidebarContent';
|
||||
import SidebarModals from './subcomponents/SidebarModals';
|
||||
import type { Project } from '../../../types/app';
|
||||
import type { SidebarProjectListProps } from './subcomponents/SidebarProjectList';
|
||||
import type { MCPServerStatus, SidebarProps } from '../types/types';
|
||||
|
||||
type TaskMasterSidebarContext = {
|
||||
setCurrentProject: (project: Project) => void;
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ScrollArea } from '../ui/scroll-area';
|
||||
import { ScrollArea } from '../../../ui/scroll-area';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { Project } from '../../types/app';
|
||||
import type { ReleaseInfo } from '../../types/sharedTypes';
|
||||
import type { Project } from '../../../../types/app';
|
||||
import type { ReleaseInfo } from '../../../../types/sharedTypes';
|
||||
import SidebarFooter from './SidebarFooter';
|
||||
import SidebarHeader from './SidebarHeader';
|
||||
import SidebarProjectList, { type SidebarProjectListProps } from './SidebarProjectList';
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Settings } from 'lucide-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { ReleaseInfo } from '../../types/sharedTypes';
|
||||
import { Button } from '../ui/button';
|
||||
import type { ReleaseInfo } from '../../../../types/sharedTypes';
|
||||
import { Button } from '../../../ui/button';
|
||||
|
||||
type SidebarFooterProps = {
|
||||
updateAvailable: boolean;
|
||||
@@ -1,8 +1,8 @@
|
||||
import { FolderPlus, MessageSquare, RefreshCw, Search, X } from 'lucide-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
import { IS_PLATFORM } from '../../constants/config';
|
||||
import { Button } from '../../../ui/button';
|
||||
import { Input } from '../../../ui/input';
|
||||
import { IS_PLATFORM } from '../../../../constants/config';
|
||||
|
||||
type SidebarHeaderProps = {
|
||||
isPWA: boolean;
|
||||
@@ -1,13 +1,13 @@
|
||||
import ReactDOM from 'react-dom';
|
||||
import { AlertTriangle, Trash2 } from 'lucide-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { Button } from '../ui/button';
|
||||
import ProjectCreationWizard from '../ProjectCreationWizard';
|
||||
import Settings from '../Settings';
|
||||
import VersionUpgradeModal from '../modals/VersionUpgradeModal';
|
||||
import type { Project } from '../../types/app';
|
||||
import type { ReleaseInfo } from '../../types/sharedTypes';
|
||||
import type { DeleteProjectConfirmation, SessionDeleteConfirmation } from './types';
|
||||
import { Button } from '../../../ui/button';
|
||||
import ProjectCreationWizard from '../../../ProjectCreationWizard';
|
||||
import Settings from '../../../Settings';
|
||||
import VersionUpgradeModal from '../../../modals/VersionUpgradeModal';
|
||||
import type { Project } from '../../../../types/app';
|
||||
import type { ReleaseInfo } from '../../../../types/sharedTypes';
|
||||
import type { DeleteProjectConfirmation, SessionDeleteConfirmation } from '../../types/types';
|
||||
|
||||
type SidebarModalsProps = {
|
||||
projects: Project[];
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Button } from '../ui/button';
|
||||
import { Button } from '../../../ui/button';
|
||||
import { Check, ChevronDown, ChevronRight, Edit3, Folder, FolderOpen, Star, Trash2, X } from 'lucide-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { cn } from '../../lib/utils';
|
||||
import TaskIndicator from '../TaskIndicator';
|
||||
import type { Project, ProjectSession, SessionProvider } from '../../types/app';
|
||||
import type { MCPServerStatus, SessionWithProvider, TouchHandlerFactory } from './types';
|
||||
import { getTaskIndicatorStatus } from './utils';
|
||||
import { cn } from '../../../../lib/utils';
|
||||
import TaskIndicator from '../../../TaskIndicator';
|
||||
import type { Project, ProjectSession, SessionProvider } from '../../../../types/app';
|
||||
import type { MCPServerStatus, SessionWithProvider, TouchHandlerFactory } from '../../types/types';
|
||||
import { getTaskIndicatorStatus } from '../../utils/utils';
|
||||
import SidebarProjectSessions from './SidebarProjectSessions';
|
||||
|
||||
type SidebarProjectItemProps = {
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { LoadingProgress, Project, ProjectSession, SessionProvider } from '../../types/app';
|
||||
import type { LoadingProgress, Project, ProjectSession, SessionProvider } from '../../../../types/app';
|
||||
import type {
|
||||
LoadingSessionsByProject,
|
||||
MCPServerStatus,
|
||||
SessionWithProvider,
|
||||
TouchHandlerFactory,
|
||||
} from './types';
|
||||
} from '../../types/types';
|
||||
import SidebarProjectItem from './SidebarProjectItem';
|
||||
import SidebarProjectsState from './SidebarProjectsState';
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { ChevronDown, Plus } from 'lucide-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { Button } from '../ui/button';
|
||||
import type { Project, ProjectSession, SessionProvider } from '../../types/app';
|
||||
import type { SessionWithProvider, TouchHandlerFactory } from './types';
|
||||
import { Button } from '../../../ui/button';
|
||||
import type { Project, ProjectSession, SessionProvider } from '../../../../types/app';
|
||||
import type { SessionWithProvider, TouchHandlerFactory } from '../../types/types';
|
||||
import SidebarSessionItem from './SidebarSessionItem';
|
||||
|
||||
type SidebarProjectSessionsProps = {
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Folder, Search } from 'lucide-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { LoadingProgress } from '../../types/app';
|
||||
import type { LoadingProgress } from '../../../../types/app';
|
||||
|
||||
type SidebarProjectsStateProps = {
|
||||
isLoading: boolean;
|
||||
@@ -1,13 +1,13 @@
|
||||
import { Badge } from '../ui/badge';
|
||||
import { Button } from '../ui/button';
|
||||
import { Badge } from '../../../ui/badge';
|
||||
import { Button } from '../../../ui/button';
|
||||
import { Check, Clock, Edit2, Trash2, X } from 'lucide-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { formatTimeAgo } from '../../utils/dateUtils';
|
||||
import type { Project, ProjectSession, SessionProvider } from '../../types/app';
|
||||
import type { SessionWithProvider, TouchHandlerFactory } from './types';
|
||||
import { createSessionViewModel } from './utils';
|
||||
import SessionProviderLogo from '../SessionProviderLogo';
|
||||
import { cn } from '../../../../lib/utils';
|
||||
import { formatTimeAgo } from '../../../../utils/dateUtils';
|
||||
import type { Project, ProjectSession, SessionProvider } from '../../../../types/app';
|
||||
import type { SessionWithProvider, TouchHandlerFactory } from '../../types/types';
|
||||
import { createSessionViewModel } from '../../utils/utils';
|
||||
import SessionProviderLogo from '../../../SessionProviderLogo';
|
||||
|
||||
type SidebarSessionItemProps = {
|
||||
project: Project;
|
||||
Reference in New Issue
Block a user