mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-02 18:45:34 +08:00
* refactor: remove unused TasksSettings component
* refactor: migrate TodoList component to a new file with improved structure and normalization logic
* refactor: Move Tooltip and DarkModeToggle to shared/ui
* refactor: Move Tooltip and DarkModeToggle to shared/view/ui
* refactor: move GeminiLogo to llm-logo-provider and update imports
* refactor: remove unused GeminiStatus component
* refactor: move components in src/components/ui to src/shared/view/ui
* refactor: move ErrorBoundary component to main-content/view and update imports
* refactor: move VersionUpgradeModal to its own module
* refactor(wizard): rebuild project creation flow as modular TypeScript components
Replace the monolithic `ProjectCreationWizard.jsx` with a feature-based TS
implementation under `src/components/project-creation-wizard`, while preserving
existing behavior and improving readability, maintainability, and state isolation.
Why:
- The previous wizard mixed API logic, flow state, folder browsing, and UI in one file.
- Refactoring and testing were difficult due to tightly coupled concerns.
- We needed stronger type safety and localized component state.
What changed:
- Deleted:
- `src/components/ProjectCreationWizard.jsx`
- Added new modular structure:
- `src/components/project-creation-wizard/index.ts`
- `src/components/project-creation-wizard/ProjectCreationWizard.tsx`
- `src/components/project-creation-wizard/types.ts`
- `src/components/project-creation-wizard/data/workspaceApi.ts`
- `src/components/project-creation-wizard/hooks/useGithubTokens.ts`
- `src/components/project-creation-wizard/utils/pathUtils.ts`
- `src/components/project-creation-wizard/components/*`
- `WizardProgress`, `WizardFooter`, `ErrorBanner`
- `StepTypeSelection`, `StepConfiguration`, `StepReview`
- `WorkspacePathField`, `GithubAuthenticationCard`, `FolderBrowserModal`
- Updated import usage:
- `src/components/sidebar/view/subcomponents/SidebarModals.tsx`
now imports from `../../../project-creation-wizard`.
Implementation details:
- Migrated wizard logic to TypeScript using `type` aliases only.
- Kept component prop types colocated in each component file.
- Split responsibilities by feature:
- container/orchestration in `ProjectCreationWizard.tsx`
- API/SSE and request parsing in `data/workspaceApi.ts`
- GitHub token loading/caching behavior in `useGithubTokens`
- path/URL helpers in `utils/pathUtils.ts`
- Localized UI-only state to child components:
- folder browser modal state (current path, hidden folders, create-folder input)
- path suggestion dropdown state with debounced lookup
- Preserved existing UX flows:
- step navigation and validation
- existing/new workspace modes
- optional GitHub clone + auth modes
- clone progress via SSE
- folder browsing + folder creation
- Added focused comments for non-obvious logic (debounce, SSE auth constraint, path edge cases).
* refactor(quick-settings): migrate panel to typed feature-based modules
Refactor QuickSettingsPanel from a single JSX component into a modular TypeScript feature structure while preserving behavior and translations.
Highlights:
- Replace legacy src/components/QuickSettingsPanel.jsx with a typed entrypoint (src/components/QuickSettingsPanel.tsx).
- Introduce src/components/quick-settings-panel/ with clear separation of concerns:
- view/: panel shell, header, handle, section wrappers, toggle rows, and content sections.
- hooks/: drag interactions and whisper mode persistence.
- constants.ts and types.ts for shared config and strict local typing.
- Move drag logic into useQuickSettingsDrag with explicit touch/mouse handling, drag threshold detection, click suppression after drag, position clamping, and localStorage persistence.
- Keep user-visible behavior intact:
- same open/close panel interactions.
- same mobile/desktop drag behavior and persisted handle position.
- same quick preference toggles and wiring to useUiPreferences.
- same hidden whisper section behavior and localStorage/event updates.
- Improve readability and maintainability by extracting repetitive setting rows and section scaffolding into reusable components.
- Add focused comments around non-obvious behavior (drag click suppression, touch scroll lock, hidden whisper section intent).
- Keep files small and reviewable (all new/changed files are under 300 lines).
Validation:
- npm run typecheck
- npm run build
* refactor(quick-settings-panel): restructure QuickSettingsPanel import and create index file
* refactor(shared): move shared ui components to share/view/ui without subfolders
* refactor(LanguageSelector): move LanguageSelector to shared UI components
* refactor(prd-editor): modularize PRD editor with typed feature modules
Break the legacy PRDEditor.jsx monolith into a feature-based TypeScript architecture under src/components/prd-editor while keeping behavior parity and readability.
Key changes:
- Replace PRDEditor.jsx with a typed orchestrator component and a compatibility export bridge at src/components/PRDEditor.tsx.
- Split responsibilities into dedicated hooks: document loading/init, existing PRD registry fetching, save workflow with overwrite detection, and keyboard shortcuts.
- Split UI into focused view components: header, editor/preview body, footer stats, loading state, generate-tasks modal, and overwrite-confirm modal.
- Move filename concerns into utility helpers (sanitize, extension handling, default naming) and centralize template/constants.
- Keep component-local state close to the UI that owns it (workspace controls/modal toggles), while shared workflow state remains in the feature container.
- Reuse the existing MarkdownPreview component for safer markdown rendering instead of ad-hoc HTML conversion.
- Update TaskMasterPanel integration to consume typed PRDEditor directly (remove any-cast) and pass isExisting metadata for correct overwrite behavior.
- Keep all new/changed files below 300 lines and add targeted comments where behavior needs clarification.
Validation:
- npm run typecheck
- npm run build
* refactor(TaskMasterPanel): update PRDEditor import path to match new structure
* refactor(TaskMaster): Remove unused TaskMasterSetupWizard and TaskMasterStatus components
* refactor(TaskDetail): remove unused TaskIndicator import
* refactor(task-master): migrate tasks to a typed feature module
- introduce a new feature-oriented TaskMaster domain under src/components/task-master
- add typed TaskMaster context/provider with explicit project, task, MCP, and loading state handling
- split task UI into focused components (panel, board, toolbar, content, card, detail modal, setup/help modals, banner)
- move task board filtering/sorting/kanban derivation into dedicated hooks and utilities
- relocate CreateTaskModal into the feature module and keep task views modular/readable
- remove legacy monolithic TaskList/TaskDetail/TaskCard files and route main task panel to the new feature panel
- replace contexts/TaskMasterContext.jsx with a typed contexts/TaskMasterContext.ts re-export to the feature context
- update MainContent project sync logic to compare by project name to avoid state churn
- validation: npm run typecheck, npm run build
* refactor(MobileNav): remove unused React import and TaskMasterContext
* refactor(auth): migrate login and setup flows to typed feature module
- Introduce a new feature-based auth module under src/components/auth with clear separation of concerns:\n - context/AuthContext.tsx for session lifecycle, onboarding status checks, token persistence, and auth actions\n - view/* components for loading, route guarding, form layout, input fields, and error display\n - shared auth constants, utility helpers, and type aliases (no interfaces)\n- Convert login and setup UIs to TypeScript and keep form state local to each component for readability and component-level ownership\n- Add explicit API payload typing and safe JSON parsing helpers to improve resilience when backend responses are malformed or incomplete\n- Centralize error fallback handling for auth requests to reduce repeated logic
- Replace legacy auth entrypoints with the new feature module in app wiring:\n - App now imports AuthProvider and ProtectedRoute from src/components/auth\n - WebSocketContext, TaskMasterContext, and Onboarding now consume useAuth from the new typed auth context\n- Remove duplicated legacy auth screens (LoginForm.jsx, SetupForm.jsx, ProtectedRoute.jsx)\n- Keep backward compatibility by turning src/contexts/AuthContext.jsx into a thin re-export of the new provider/hook
Result: auth code now follows a feature/domain structure, is fully typed, easier to navigate, and cleaner to extend without touching unrelated UI areas.
* refactor(AppContent): update MobileNav import path and add MobileNav component
* refactor(DiffViewer): rename different diff viewers and place them in different components
* refactor(components): reorganize onboarding/provider auth/sidebar indicator into domain features
- Move onboarding out of root-level components into a dedicated feature module:
- add src/components/onboarding/view/Onboarding.tsx
- split onboarding UI into focused subcomponents:
- OnboardingStepProgress
- GitConfigurationStep
- AgentConnectionsStep
- AgentConnectionCard
- add onboarding-local types and utils for provider status and validation helpers
- Move multi-provider login modal into a dedicated provider-auth feature:
- add src/components/provider-auth/view/ProviderLoginModal.tsx
- add src/components/provider-auth/types.ts
- keep provider-specific command/title behavior and Gemini setup guidance
- preserve compatibility for both onboarding flow and settings login flow
- Move TaskIndicator into the sidebar domain:
- add src/components/sidebar/view/subcomponents/TaskIndicator.tsx
- update SidebarProjectItem to consume local sidebar TaskIndicator
- Update integration points to the new structure:
- ProtectedRoute now imports onboarding from onboarding feature
- Settings now imports ProviderLoginModal directly (remove legacy cast wrapper)
- git panel consumers now import shared GitDiffViewer by explicit name
- Rename git shared diff view to clearer domain naming:
- replace shared DiffViewer with shared GitDiffViewer
- update FileChangeItem and CommitHistoryItem imports accordingly
- Remove superseded root-level legacy components:
- delete src/components/LoginModal.jsx
- delete src/components/Onboarding.jsx
- delete src/components/TaskIndicator.jsx
- delete old src/components/git-panel/view/shared/DiffViewer.tsx
- Result:
- clearer feature boundaries (auth vs onboarding vs provider-auth vs sidebar)
- easier navigation and ownership by domain
- preserved runtime behavior with improved readability and modularity
* refactor(MainContent): remove TaskMasterPanel import and relocate to task-master component
* fix: update import paths for Input component in FileTree and FileTreeNode
* refactor(FileTree): make file tree context menu a typescript component and move it inside the file tree view
* refactor(FileTree): remove unused ScrollArea import
* feat: setup eslint with typescript and react rules, add unused imports plugin
* fix: remove unused imports, functions, and types after discovering using `npm run lint`
* feat: setup eslint-plugin-react, react-refresh, import-x, and tailwindcss plugins with recommended rules and configurations
* chore: reformat files after running `npm run lint:fix`
* chore: add omments about eslint config plugin uses
* feat: add husky and lint-staged for pre-commit linting
* feat: setup commitlint with conventional config
* fix: i18n translations
---------
Co-authored-by: Haileyesus <something@gmail.com>
Co-authored-by: viper151 <simosmik@gmail.com>
1013 lines
30 KiB
TypeScript
1013 lines
30 KiB
TypeScript
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 '../constants/thinkingModes';
|
|
import { grantClaudeToolPermission } from '../utils/chatPermissions';
|
|
import { safeLocalStorage } from '../utils/chatStorage';
|
|
import type {
|
|
ChatMessage,
|
|
PendingPermissionRequest,
|
|
PermissionMode,
|
|
} from '../types/types';
|
|
import type { Project, ProjectSession, SessionProvider } from '../../../types/app';
|
|
import { escapeRegExp } from '../utils/chatFormatting';
|
|
import { useFileMentions } from './useFileMentions';
|
|
import { type SlashCommand, useSlashCommands } from './useSlashCommands';
|
|
|
|
type PendingViewSession = {
|
|
sessionId: string | null;
|
|
startedAt: number;
|
|
};
|
|
|
|
interface UseChatComposerStateArgs {
|
|
selectedProject: Project | null;
|
|
selectedSession: ProjectSession | null;
|
|
currentSessionId: string | null;
|
|
provider: SessionProvider;
|
|
permissionMode: PermissionMode | string;
|
|
cyclePermissionMode: () => void;
|
|
cursorModel: string;
|
|
claudeModel: string;
|
|
codexModel: string;
|
|
geminiModel: string;
|
|
isLoading: boolean;
|
|
canAbortSession: boolean;
|
|
tokenBudget: Record<string, unknown> | null;
|
|
sendMessage: (message: unknown) => void;
|
|
sendByCtrlEnter?: boolean;
|
|
onSessionActive?: (sessionId?: string | null) => void;
|
|
onSessionProcessing?: (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,
|
|
geminiModel,
|
|
isLoading,
|
|
canAbortSession,
|
|
tokenBudget,
|
|
sendMessage,
|
|
sendByCtrlEnter,
|
|
onSessionActive,
|
|
onSessionProcessing,
|
|
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 inputValueRef = useRef(input);
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
const commandContent = content || '';
|
|
setInput(commandContent);
|
|
inputValueRef.current = commandContent;
|
|
|
|
// Defer submit to next tick so the command text is reflected in UI before dispatching.
|
|
setTimeout(() => {
|
|
if (handleSubmitRef.current) {
|
|
handleSubmitRef.current(createFakeSubmitEvent());
|
|
}
|
|
}, 0);
|
|
}, [setChatMessages]);
|
|
|
|
const executeCommand = useCallback(
|
|
async (command: SlashCommand, rawInput?: string) => {
|
|
if (!command || !selectedProject) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const effectiveInput = rawInput ?? input;
|
|
const commandMatch = effectiveInput.match(new RegExp(`${escapeRegExp(command.name)}\\s*(.*)`));
|
|
const args =
|
|
commandMatch && commandMatch[1] ? commandMatch[1].trim().split(/\s+/) : [];
|
|
|
|
const context = {
|
|
projectPath: selectedProject.fullPath || selectedProject.path,
|
|
projectName: selectedProject.name,
|
|
sessionId: currentSessionId,
|
|
provider,
|
|
model: provider === 'cursor' ? cursorModel : provider === 'codex' ? codexModel : provider === 'gemini' ? geminiModel : 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);
|
|
setInput('');
|
|
inputValueRef.current = '';
|
|
} else if (result.type === 'custom') {
|
|
await handleCustomCommand(result);
|
|
}
|
|
} 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,
|
|
geminiModel,
|
|
handleBuiltInCommand,
|
|
handleCustomCommand,
|
|
input,
|
|
provider,
|
|
selectedProject,
|
|
setChatMessages,
|
|
tokenBudget,
|
|
],
|
|
);
|
|
|
|
const {
|
|
slashCommands,
|
|
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();
|
|
const currentInput = inputValueRef.current;
|
|
if (!currentInput.trim() || isLoading || !selectedProject) {
|
|
return;
|
|
}
|
|
|
|
// Intercept slash commands: if input starts with /commandName, execute as command with args
|
|
const trimmedInput = currentInput.trim();
|
|
if (trimmedInput.startsWith('/')) {
|
|
const firstSpace = trimmedInput.indexOf(' ');
|
|
const commandName = firstSpace > 0 ? trimmedInput.slice(0, firstSpace) : trimmedInput;
|
|
const matchedCommand = slashCommands.find((cmd: SlashCommand) => cmd.name === commandName);
|
|
if (matchedCommand) {
|
|
executeCommand(matchedCommand, trimmedInput);
|
|
setInput('');
|
|
inputValueRef.current = '';
|
|
setAttachedImages([]);
|
|
setUploadingImages(new Map());
|
|
setImageErrors(new Map());
|
|
resetCommandMenuState();
|
|
setIsTextareaExpanded(false);
|
|
if (textareaRef.current) {
|
|
textareaRef.current.style.height = 'auto';
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
let messageContent = currentInput;
|
|
const selectedThinkingMode = thinkingModes.find((mode: { id: string; prefix?: string }) => mode.id === thinkingMode);
|
|
if (selectedThinkingMode && selectedThinkingMode.prefix) {
|
|
messageContent = `${selectedThinkingMode.prefix}: ${currentInput}`;
|
|
}
|
|
|
|
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: currentInput,
|
|
images: uploadedImages as any,
|
|
timestamp: new Date(),
|
|
};
|
|
|
|
setChatMessages((previous) => [...previous, userMessage]);
|
|
setIsLoading(true); // Processing banner starts
|
|
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);
|
|
if (effectiveSessionId && !isTemporarySessionId(effectiveSessionId)) {
|
|
onSessionProcessing?.(effectiveSessionId);
|
|
}
|
|
|
|
const getToolsSettings = () => {
|
|
try {
|
|
const settingsKey =
|
|
provider === 'cursor'
|
|
? 'cursor-tools-settings'
|
|
: provider === 'codex'
|
|
? 'codex-settings'
|
|
: provider === 'gemini'
|
|
? 'gemini-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();
|
|
const resolvedProjectPath = selectedProject.fullPath || selectedProject.path || '';
|
|
|
|
if (provider === 'cursor') {
|
|
sendMessage({
|
|
type: 'cursor-command',
|
|
command: messageContent,
|
|
sessionId: effectiveSessionId,
|
|
options: {
|
|
cwd: resolvedProjectPath,
|
|
projectPath: resolvedProjectPath,
|
|
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: resolvedProjectPath,
|
|
projectPath: resolvedProjectPath,
|
|
sessionId: effectiveSessionId,
|
|
resume: Boolean(effectiveSessionId),
|
|
model: codexModel,
|
|
permissionMode: permissionMode === 'plan' ? 'default' : permissionMode,
|
|
},
|
|
});
|
|
} else if (provider === 'gemini') {
|
|
sendMessage({
|
|
type: 'gemini-command',
|
|
command: messageContent,
|
|
sessionId: effectiveSessionId,
|
|
options: {
|
|
cwd: resolvedProjectPath,
|
|
projectPath: resolvedProjectPath,
|
|
sessionId: effectiveSessionId,
|
|
resume: Boolean(effectiveSessionId),
|
|
model: geminiModel,
|
|
permissionMode,
|
|
toolsSettings,
|
|
},
|
|
});
|
|
} else {
|
|
sendMessage({
|
|
type: 'claude-command',
|
|
command: messageContent,
|
|
options: {
|
|
projectPath: resolvedProjectPath,
|
|
cwd: resolvedProjectPath,
|
|
sessionId: effectiveSessionId,
|
|
resume: Boolean(effectiveSessionId),
|
|
toolsSettings,
|
|
permissionMode,
|
|
model: claudeModel,
|
|
images: uploadedImages,
|
|
},
|
|
});
|
|
}
|
|
|
|
setInput('');
|
|
inputValueRef.current = '';
|
|
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,
|
|
executeCommand,
|
|
geminiModel,
|
|
isLoading,
|
|
onSessionActive,
|
|
onSessionProcessing,
|
|
pendingViewSessionRef,
|
|
permissionMode,
|
|
provider,
|
|
resetCommandMenuState,
|
|
scrollToBottom,
|
|
selectedProject,
|
|
selectedSession?.id,
|
|
sendMessage,
|
|
setCanAbortSession,
|
|
setChatMessages,
|
|
setClaudeStatus,
|
|
setIsLoading,
|
|
setIsUserScrolledUp,
|
|
slashCommands,
|
|
thinkingMode,
|
|
],
|
|
);
|
|
|
|
useEffect(() => {
|
|
handleSubmitRef.current = handleSubmit;
|
|
}, [handleSubmit]);
|
|
|
|
useEffect(() => {
|
|
inputValueRef.current = input;
|
|
}, [input]);
|
|
|
|
useEffect(() => {
|
|
if (!selectedProject) {
|
|
return;
|
|
}
|
|
const savedInput = safeLocalStorage.getItem(`draft_input_${selectedProject.name}`) || '';
|
|
setInput((previous) => {
|
|
const next = previous === savedInput ? previous : savedInput;
|
|
inputValueRef.current = next;
|
|
return next;
|
|
});
|
|
}, [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;
|
|
}
|
|
// Re-run when input changes so restored drafts get the same autosize behavior as typed text.
|
|
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);
|
|
}, [input]);
|
|
|
|
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);
|
|
inputValueRef.current = 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('');
|
|
inputValueRef.current = '';
|
|
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;
|
|
inputValueRef.current = newInput;
|
|
|
|
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 [isInputFocused, setIsInputFocused] = useState(false);
|
|
|
|
const handleInputFocusChange = useCallback(
|
|
(focused: boolean) => {
|
|
setIsInputFocused(focused);
|
|
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,
|
|
isInputFocused,
|
|
};
|
|
}
|