From f891316ec0ef6735646e6ed8e2f47f0fb5a486e7 Mon Sep 17 00:00:00 2001 From: Haileyesus <118998054+blackmammoth@users.noreply.github.com> Date: Fri, 13 Feb 2026 22:26:47 +0300 Subject: [PATCH] Refactor/app content main content and chat interface (#374) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: improve version comparison logic in useVersionCheck hook * refactor: useVersionCheck.js to typescript * refactor: move VersionUpgradeModal component to its own file and remove it from AppContent component * refactor: improve VersionUpgradeModal props and extract ReleaseInfo type Using useVersionCheck hook in 2 places caused github requests to be made twice, which is not ideal. * refactor: handleUpdateNow function with useCallback and error display in VersionUpgradeModal * refactor: move isOpen check to the correct position in VersionUpgradeModal * refactor: move VersionUpgradeModal and collapsed sidebar to Sidebar component from App.jsx * refactor: remove unused SettingsIcon import from App.jsx * refactor: move formatTimeAgo function to dateUtils.ts * refactor: replace useLocalStorage with useUiPreferences for better state management in AppContent * refactor: use shared props for Sidebar props in AppContent * refactor: remove showQuickSettings state and toggle from AppContent, manage isOpen state directly in QuickSettingsPanel * refactor: move preference props directly to QuickSettingsPanel and MainContent * refactor: remove unused isPWA prop * refactor: remove unused isPWA prop from AppContent * refactor: remove unused generatingSummary state from Sidebar component * refactor: remove unused isPWA prop from MainContent component * refactor: use usePrefrences for sidebar visibility in Sidebar component * refactor: extract device detection into hook and localize PWA handling to Sidebar - Add new `useDeviceSettings` hook (`src/hooks/useDeviceSettings.ts`) to centralize device-related state: - exposes `isMobile` and `isPWA` - supports options: `mobileBreakpoint`, `trackMobile`, `trackPWA` - listens to window resize for mobile updates - listens to `display-mode: standalone` changes for PWA updates - includes `matchMedia.addListener/removeListener` fallback for older environments - Update `AppContent` (`src/App.jsx`) to consume `isMobile` from `useDeviceSettings({ trackPWA: false })`: - remove local `isMobile` state/effect - remove local `isPWA` state/effect - keep existing `isMobile` behavior for layout and mobile sidebar flow - stop passing `isPWA` into `Sidebar` props - Update `Sidebar` (`src/components/Sidebar.jsx`) to own PWA detection: - consume `isPWA` from `useDeviceSettings({ trackMobile: false })` - add effect to toggle `pwa-mode` class on `document.documentElement` and `document.body` - retain use of `isMobile` prop from `App` for sidebar/mobile rendering decisions Why: - removes duplicated device-detection logic from `AppContent` - makes device-state logic reusable and easier to maintain - keeps PWA-specific behavior where it is actually used (`Sidebar`) * chore(to-remove): comment todo's * refactor: remove unused createNewProject and cancelNewProject functions from Sidebar component * refactor(sidebar): extract typed app/sidebar architecture and split Sidebar into modular components - Replace `src/App.jsx` with `src/App.tsx` and move route-level UI orchestration into `src/components/app/AppContent.tsx`. This separates provider/bootstrap concerns from runtime app layout logic, keeps route definitions minimal, and improves readability of the root app entry. - Introduce `src/hooks/useProjectsState.ts` to centralize project/session/sidebar state management previously embedded in `App.jsx`. This keeps the existing behavior for: project loading, Cursor session hydration, WebSocket `loading_progress` handling, additive-update protection for active sessions, URL-based session selection, sidebar refresh/delete/new-session flows. The hook now exposes a typed `sidebarSharedProps` contract and typed handlers used by `AppContent`. - Introduce `src/hooks/useSessionProtection.ts` for active/processing session lifecycle logic. This preserves session-protection behavior while isolating `activeSessions`, `processingSessions`, and temporary-session replacement into a dedicated reusable hook. - Replace monolithic `src/components/Sidebar.jsx` with typed `src/components/Sidebar.tsx` as a thin orchestrator. `Sidebar.tsx` now focuses on wiring controller state/actions, modal visibility, collapsed mode, and version modal behavior instead of rendering every UI branch inline. - Add `src/hooks/useSidebarController.ts` to encapsulate sidebar interaction/state logic. This includes expand/collapse state, inline project/session editing state, project starring/sorting/filtering, lazy session pagination, delete confirmations, rename/delete actions, refresh state, and mobile touch click handling. - Add strongly typed sidebar domain models in `src/components/sidebar/types.ts` and move sidebar-derived helpers into `src/components/sidebar/utils.ts`. Utility coverage now includes: session provider normalization, session view-model creation (name/time/activity/message count), project sorting/filtering, task indicator status derivation, starred-project persistence and readbacks. - Split sidebar rendering into focused components under `src/components/sidebar/`: `SidebarContent.tsx` for top-level sidebar layout composition. `SidebarProjectList.tsx` for project-state branching and project iteration. `SidebarProjectsState.tsx` for loading/empty/no-search-result placeholders. `SidebarProjectItem.tsx` for per-project desktop/mobile header rendering and actions. `SidebarProjectSessions.tsx` for expanded session area, skeletons, pagination, and new-session controls. `SidebarSessionItem.tsx` for per-session desktop/mobile item rendering and session actions. `SessionProviderIcon.tsx` for provider icon normalization. `SidebarHeader.tsx`, `SidebarFooter.tsx`, `SidebarCollapsed.tsx`, and `SidebarModals.tsx` as dedicated typed UI surfaces. This keeps rendering responsibilities local and significantly improves traceability. - Convert shared UI primitives from JSX to TSX: `src/components/ui/button.tsx`, `src/components/ui/input.tsx`, `src/components/ui/badge.tsx`, `src/components/ui/scroll-area.tsx`. These now provide typed props/variants (`forwardRef` where appropriate) while preserving existing class/behavior. - Add shared app typings in `src/types/app.ts` for projects/sessions/websocket/loading contracts used by new hooks/components. - Add global window declarations in `src/types/global.d.ts` for `__ROUTER_BASENAME__`, `refreshProjects`, and `openSettings`, removing implicit `any` usage for global integration points. - Update `src/main.jsx` to import `App.tsx` and keep app bootstrap consistent with the TS migration. - Update `src/components/QuickSettingsPanel.jsx` to self-resolve mobile state via `useDeviceSettings` (remove `isMobile` prop dependency), and update `src/components/ChatInterface.jsx` to render `QuickSettingsPanel` directly. This reduces prop drilling and keeps quick settings colocated with chat UI concerns. * refactor(sidebar): integrate settings modal into SidebarModals and update props * fix(mobile): prevent menu tap from triggering unintended dashboard navigation The mobile sidebar menu button redirects users to `cloudcli.ai/dashboard` when a session was active. The redirect happened because the menu was opened on `touchstart`, which mounted the sidebar before the touch sequence completed; the follow-up tap/click then landed on the sidebar header anchor. This change rewrites mobile menu interaction handling in `MainContent.jsx` to eliminate touch/click event leakage and ghost-click behavior. Key changes: - Added `suppressNextMenuClickRef` to guard against synthetic click events that fire after a touch interaction. - Added `openMobileMenu(event)` helper to centralize `preventDefault`, `stopPropagation`, and `onMenuClick()` invocation. - Added `handleMobileMenuTouchEnd(event)`: - opens the menu on `touchend` instead of `touchstart` - sets a short suppression window (350ms) for the next click. - Added `handleMobileMenuClick(event)`: - ignores/suppresses click events during the suppression window - otherwise opens the menu normally. - Updated all mobile menu button instances in `MainContent.jsx` (loading state, no-project state, active header state) to use: - `onTouchEnd={handleMobileMenuTouchEnd}` - `onClick={handleMobileMenuClick}` - Removed the previous `onTouchStart` path that caused premature DOM mutation. Behavioral impact: - Mobile sidebar still opens reliably with one tap. - Tap no longer leaks to newly-mounted sidebar header links. - Prevents accidental redirects while preserving existing menu UX. * refactor(main-content): migrate MainContent to TypeScript and modularize UI/state boundaries Replace the previous monolithic MainContent.jsx with a typed one and extract focused subcomponents/hooks to improve readability, local state ownership, and maintainability while keeping runtime behavior unchanged. Key changes: - Replace `src/components/MainContent.jsx` with `src/components/MainContent.tsx`. - Add typed contracts for main-content domain in `src/components/main-content/types.ts`. - Extract header composition into: - `MainContentHeader.tsx` - `MainContentTitle.tsx` - `MainContentTabSwitcher.tsx` - `MobileMenuButton.tsx` - Extract loading/empty project views into `MainContentStateView.tsx`. - Extract editor presentation into `EditorSidebar.tsx`. - Move editor file-open + resize behavior into `useEditorSidebar.ts`. - Move mobile menu touch/click suppression logic into `useMobileMenuHandlers.ts`. - Extract TaskMaster-specific concerns into `TaskMasterPanel.tsx`: - task detail modal state - PRD editor modal state - PRD list loading/refresh - PRD save notification lifecycle Behavior/compatibility notes: - Preserve existing tab behavior, session passthrough props, and Chat/Git/File flows. - Keep interop with existing JS components via boundary `as any` casts where needed. - No intentional functional changes; this commit is structural/type-oriented refactor. Validation: - `npm run typecheck` passes. - `npm run build` passes (existing unrelated CSS minify warnings remain). * refactor(chat): split monolithic chat interface into typed modules and hooks Replace the legacy monolithic ChatInterface.jsx implementation with a modular TypeScript architecture centered around a small orchestration component (ChatInterface.tsx). Core architecture changes: - Remove src/components/ChatInterface.jsx and add src/components/ChatInterface.tsx as a thin coordinator that wires provider state, session state, realtime WebSocket handlers, and composer behavior via dedicated hooks. - Update src/components/MainContent.tsx to use typed ChatInterface directly (remove AnyChatInterface cast). State ownership and hook extraction: - Add src/hooks/chat/useChatProviderState.ts to centralize provider/model/permission-mode state, provider/session synchronization, cursor model bootstrap from backend config, and pending permission request scoping. - Add src/hooks/chat/useChatSessionState.ts to own chat/session lifecycle state: session loading, cursor/claude/codex history loading, pagination, scroll restoration, visible-window slicing, token budget loading, persisted chat hydration, and processing-state restoration. - Add src/hooks/chat/useChatRealtimeHandlers.ts to isolate WebSocket event processing for Claude/Cursor/Codex, including session filtering, streaming chunk buffering, session-created/pending-session transitions, permission request queueing/cancellation, completion/error handling, and session status updates. - Add src/hooks/chat/useChatComposerState.ts to own composer-local state and interactions: input/draft persistence, textarea sizing and keyboard behavior, slash command execution, file mentions, image attachment/drop/paste workflow, submit/abort flows, permission decision responses, and transcript insertion. UI modularization under src/components/chat: - Add view/ChatMessagesPane.tsx for message list rendering, loading/empty states, pagination affordances, and thinking indicator. - Add view/ChatComposer.tsx for composer shell layout and input area composition. - Add view/ChatInputControls.tsx for mode toggles, token display, command launcher, clear-input, and scroll-to-bottom controls. - Add view/PermissionRequestsBanner.tsx for explicit tool-permission review actions (allow once / allow & remember / deny). - Add view/ProviderSelectionEmptyState.tsx for provider and model selection UX plus task starter integration. - Add messages/MessageComponent.tsx and markdown/Markdown.tsx to isolate message rendering concerns, markdown/code rendering, and rich tool-output presentation. - Add input/ImageAttachment.tsx for attachment previews/removal/progress/error overlay rendering. Shared chat typing and utilities: - Add src/components/chat/types.ts with shared types for providers, permission mode, message/tool payloads, pending permission requests, and ChatInterfaceProps. - Add src/components/chat/utils/chatFormatting.ts for html decoding, code fence normalization, regex escaping, math-safe unescaping, and usage-limit text formatting. - Add src/components/chat/utils/chatPermissions.ts for permission rule derivation, suggestion generation, and grant flow. - Add src/components/chat/utils/chatStorage.ts for resilient localStorage access, quota handling, and normalized Claude settings retrieval. - Add src/components/chat/utils/messageTransforms.ts for session message normalization (Claude/Codex/Cursor) and cached diff computation utilities. Command/file input ergonomics: - Add src/hooks/chat/useSlashCommands.ts for slash command fetching, usage-based ranking, fuzzy filtering, keyboard navigation, and command history persistence. - Add src/hooks/chat/useFileMentions.tsx for project file flattening, @mention suggestions, mention highlighting, and keyboard/file insertion behavior. TypeScript support additions: - Add src/types/react-syntax-highlighter.d.ts module declarations to type-check markdown code highlighting imports. Behavioral intent: - Preserve existing chat behavior and provider flows while improving readability, separation of concerns, and future refactorability. - Move state closer to the components/hooks that own it, reducing cross-cutting concerns in the top-level chat component. * perf(project-loading): eliminate repeated Codex session rescans and duplicate cursor fetches The staged changes remove the main source of project-load latency by avoiding repeated full scans of ~/.codex/sessions for every project and by removing redundant client-side cursor session refetches. Server changes (server/projects.js):\n- Add a per-request Codex index reference in getProjects so Codex metadata is built once and reused across all projects, including manually added ones.\n- Introduce normalizeComparablePath() to canonicalize project paths (including Windows long-path prefixes and case-insensitive matching on Windows).\n- Introduce findCodexJsonlFiles() + buildCodexSessionsIndex() to perform a single recursive Codex scan and group sessions by normalized cwd.\n- Update getCodexSessions() to accept indexRef and read from the prebuilt index, with fallback index construction when no ref is provided.\n- Preserve existing session limiting behavior (limit=5 default, limit=0 returns all). Client changes (src/hooks/useProjectsState.ts):\n- Remove loadCursorSessionsForProjects(), which previously triggered one extra /api/cursor/sessions request per project after /api/projects.\n- Use /api/projects response directly during initial load and refresh.\n- Expand projectsHaveChanges() to treat both cursorSessions and codexSessions as external session deltas.\n- Keep refresh comparison aligned with external session updates by using includeExternalSessions=true in sidebar refresh path. Impact:\n- Reduces backend work from roughly O(projects * codex_session_files) to O(codex_session_files + projects) for Codex discovery during a project load cycle.\n- Removes an additional client-side O(projects) network fan-out for Cursor session fetches.\n- Improves perceived and actual sidebar project-loading time, especially in large session datasets. * fix(chat): make Stop and Esc reliably abort active sessions Problem Stop requests were unreliable because aborting depended on currentSessionId being set, Esc had no actual global abort binding, stale pending session ids could be reused, and abort failures were surfaced as successful interruptions. Codex sessions also used a soft abort flag without wiring SDK cancellation. Changes - Add global Escape key handler in chat while a run is loading/cancellable to trigger the same abort path as the Stop button. - Harden abort session target selection in composer by resolving from multiple active session id sources (current, pending view, pending storage, cursor storage, selected session) and ignoring temporary new-session-* ids. - Clear stale pendingSessionId when launching a brand-new session to prevent aborting an old run. - Update realtime abort handling to respect backend success=false responses: keep loading state when abort fails and emit an explicit failure message instead of pretending interruption succeeded. - Improve websocket send reliability by checking socket.readyState === WebSocket.OPEN directly before send. - Implement real Codex cancellation via AbortController + runStreamed(..., { signal }), propagate aborted status, and suppress expected abort-error noise. Impact This makes both UI Stop and Esc-to-stop materially more reliable across Claude/Cursor/Codex flows, especially during early-session windows before currentSessionId is finalized, and prevents false-positive interrupted states when backend cancellation fails. Validation - npm run -s typecheck - npm run -s build - node --check server/openai-codex.js * refactor: tool components * refactor: tool components * fix: remove one-line logic from messagecomponent * refactor(design): change the design of bash * refactor(design): fix bash design and config * refactor(design): change the design of tools and introduce todo list and task list. * refactor(improvement):add memo on diffviewer, cleanup messsagecomponent * refactor: update readme and remove unusedfiles. * refactor(sidebar): remove duplicate loading message in SidebarProjectsState * refactor(sidebar): move VersionUpgradeModal into SidebarModals * refactor: replace individual provider logos with a unified SessionProviderLogo component * fix(commands): restore /cost slash command and improve command execution errors The /cost command was listed as built-in but had no handler, causing execution to fall through to custom command logic and return 400 ("command path is required"). - Add a built-in /cost handler in server/routes/commands.js - Return the expected payload shape for the chat UI (`action: "cost"`, token usage, estimated cost, model) - Normalize token usage inputs and compute usage percentage - Add provider-based default pricing for cost estimation - Fix model selection in command execution context so codex uses `codexModel` instead of `claudeModel` - Improve frontend command error handling by parsing backend error responses and showing meaningful error messages instead of a generic failure * fix(command-menu): correct slash command selection with frequent commands When the “Frequently Used” section is visible, command clicks/hover could use a UI-local index instead of the canonical `filteredCommands` index, causing the wrong command to execute (e.g. clicking `/help` running `/clear`). - map rendered menu items back to canonical command indices using a stable key (`name + namespace/type + path`) - use canonical index for hover/click selection callbacks - deduplicate frequent commands from other grouped sections to avoid duplicate rows and selection ambiguity - keep and restore original inline comments, with clarifications where needed * refactor(sidebar): update sessionMeta handling for session loading logic - This fixes an issue where the sidebar was showing 6+ even when there were only 5 sessions, due to the hasMore logic not accounting for the case where there are exactly 6 sessions. It was also showing "Show more sessions" even where there were no more sessions to load. - This was because `hasMore` was sometimes `undefined` and the logic checked for hasMore !== false, which treated undefined as true. Now we explicitly check for hasMore === true to determine if there are more sessions to load. * refactor(project-watcher): add codex and cursor file watchers * fix: chat session scroll to bottom error even when scrolled up * fix(chat): clear stuck loading state across realtime lifecycle events The chat UI could remain in a stale "Thinking/Processing" state when session IDs did not line up exactly between view state (`currentSessionId`), selected route session, pending session IDs, and provider lifecycle events. This was most visible with Codex completion/abort flows, but the same mismatch risk existed in shared handlers. Unify lifecycle cleanup behavior in realtime handlers and make processing tracking key off the active viewed session identity. Changes: - src/hooks/chat/useChatRealtimeHandlers.ts - src/components/ChatInterface.tsx - src/hooks/chat/useChatSessionState.ts What changed: - Added shared helpers in realtime handling: - `collectSessionIds(...)` to normalize and dedupe candidate session IDs. - `clearLoadingIndicators()` to consistently clear `isLoading`, abort UI, and status. - `markSessionsAsCompleted(...)` to consistently notify inactive/not-processing state. - Updated lifecycle branches to use shared cleanup logic: - `cursor-result` - `claude-complete` - `codex-response` (`turn_complete` and `turn_failed`) - `codex-complete` - `session-aborted` - Expanded completion/abort cleanup to include all relevant session IDs (`latestMessage.sessionId`, `currentSessionId`, `selectedSession?.id`, `pendingSessionId`, and Codex `actualSessionId` when present). - Switched processing-session marking in `ChatInterface` to use `selectedSession?.id || currentSessionId` instead of `currentSessionId` alone. - Switched processing-session rehydration in `useChatSessionState` to use the same active-view session identity fallback. Result: - Prevents stale loading indicators after completion/abort when IDs differ. - Keeps processing session bookkeeping aligned with the currently viewed session. - Reduces provider-specific drift by using one lifecycle cleanup pattern. * fix(chat): stabilize long-history scroll-up pagination behavior - fix top-pagination lockups by only locking when older messages are actually fetched - make fetched older messages visible immediately by increasing `visibleMessageCount` on prepend - prevent unintended auto-scroll-to-bottom during older-message loading and scroll restore - replace state-based pagination offset with a ref to avoid stale offset/reload side effects - ensure initial auto-scroll runs only after initial session load completes - reset top-load lock/restore state and visible window when switching sessions - loosen top-lock release near the top to avoid requiring a full down/up cycle * refactor: Restructure files and folders to better mimic feature-based architecture * refactor: reorganize chat view components and types * feat(chat): move thinking modes, token usage pie, and related logic into chat folder * refactor(tools): add agent category for Task tool Add visual distinction for the Task tool (subagent invocation) by introducing a new 'agent' category with purple border styling. This separates subagent tasks from regular task management tools (TaskCreate, TaskUpdate, etc.) for clearer user feedback. Also refactor terminal command layout in OneLineDisplay to properly nest flex containers, fixing copy button alignment issues. * refactor(tools): improve Task tool display formatting Update Task tool config to show cleaner subagent information in the UI. Simplifies the input display by showing only the prompt when no optional fields are present, reducing visual clutter. Updates title format to "Subagent / {type}" for better categorization. Enhances result display to better handle complex agent response structures with array content types, extracting text blocks for cleaner presentation. * fix: show auth url panel in shell only on mobile - use static url: https://auth.openai.com/codex/device, for codex login. - add an option for hiding the panel * fix(chat): escape command name in regex to prevent unintended matches * fix(chat): handle JSON parsing errors for saved chat messages * refactor(chat): replace localStorage provider retrieval with prop usage in MessageComponent * fix(chat): handle potential null content in message before splitting lines * refactor(todo): update TodoListContentProps to include optional id and priority fields that are used in TodoList.jsx * fix(watcher): ensure provider folders exist before creating watchers to maintain active watching * refactor(chat): improve message handling by cloning state updates and improving structured message parsing * refactor(chat): exclude currentSessionId from dependency array to prevent unnecessary reloading of messages * refactor(useFileMentions): implement abort controller for fetch requests * refactor(MessageComponent): add types * refactor(calculateDiff): optimize LCS algorithm for improved diff calculation * refactor(createCachedDiffCalculator): use both newStr and oldStr as cache keys * refactor(useSidebarController): manage project session overrides in local state * refactor(ScrollArea): adjust ref placement and className order * fix: type annotations * refactor(ChatInputControls): update import statement for ThinkingModeSelector * refactor(dateUtils): update type annotation for formatTimeAgo function * refactor(ToolRenderer): ensure stable hook order * refactor(useProjectsState): normalize refreshed session metadata to maintain provider stability; use getProjectSessions helper for session retrieval. * refactor(useChatComposerState): improve input handling and command execution flow * refactor(useChatRealtimeHandlers): normalize interactive prompt content to string for consistent ChatMessage shape * refactor(OneLineDisplay): improve clipboard functionality with fallback for unsupported environments * refactor(QuickSettingsPanel): simplify state management by removing localIsOpen and using isOpen directly * refactor(ChatMessagesPane): use stable message key * refactor:: move AssistantThinkingIndicator component to its own file * refactor(ChatMessagesPane): extract message key generation logic to a utility function * refactor(SidebarModals): move normalizeProjectForSettings into utils file * refactor(ToolConfigs): use optional chaining for content retrieval * fix(chat): stabilize provider/message handling and complete chat i18n coverage Unify provider typing, harden realtime message effects, normalize tool input serialization, and finish i18n/a11y updates across chat UI components. - tighten provider contracts from `Provider | string` to `SessionProvider` in: - `useChatProviderState` - `useChatComposerState` - `useChatRealtimeHandlers` - `ChatMessagesPane` - `ProviderSelectionEmptyState` - refactor `AssistantThinkingIndicator` to accept `selectedProvider` via props instead of reading provider from local storage during render - fix stale-closure risk in `useChatRealtimeHandlers` by: - adding missing effect dependencies - introducing `lastProcessedMessageRef` to prevent duplicate processing when dependencies change without a new message object - standardize `toolInput` shape in `messageTransforms`: - add `normalizeToolInput(...)` - ensure all conversion paths produce consistent string output - remove mixed `null`/raw/stringified variants across cursor/session branches - harden tool display fallback in `CollapsibleDisplay`: - default border class now falls back safely for unknown categories - improve chat i18n consistency: - localize hardcoded strings in `MessageComponent` (`permissions.*`, `interactive.*`, `thinking.emoji`, `json.response`, `messageTypes.error`) - localize button titles in `ChatInputControls` (`input.clearInput`, `input.scrollToBottom`) - localize provider-specific empty-project prompt in `ChatInterface` (`projectSelection.startChatWithProvider`) - localize repeated “Start the next task” prompt in `ProviderSelectionEmptyState` (`tasks.nextTaskPrompt`) - add missing translation keys in all supported chat locales: - `src/i18n/locales/en/chat.json` - `src/i18n/locales/ko/chat.json` - `src/i18n/locales/zh-CN/chat.json` - new keys: - `input.clearInput` - `input.scrollToBottom` - `projectSelection.startChatWithProvider` - `tasks.nextTaskPrompt` - improve attachment remove-button accessibility in `ImageAttachment`: - add `type="button"` and `aria-label` - make control visible on touch/small screens and focusable states - preserve hover behavior on larger screens Validation: - `npm run typecheck` * fix(chat): sync quick settings state and stabilize thinking toggle Synchronize useUiPreferences instances via custom sync events and storage listeners so Quick Settings updates apply across UI consumers immediately. Also hide standalone thinking messages when showThinking is disabled, while preserving hook order to avoid Rendered fewer hooks runtime errors. * refactor(validateGitRepository): improve directory validation for git work tree * refactor(GitPanel): clear stale state on project change and improve error handling * refactor(git): use spawnAsync for command execution and improve commit log retrieval * fix: iOS pwa bottom margin * fix: pass diff information to code editor * refactor(sidebar): remove touch event handlers from project and session items * bumping node to v22 * Release 1.17.0 --------- Co-authored-by: Haileyesus Co-authored-by: simosmik --- .nvmrc | 2 +- README.md | 2 +- README.zh-CN.md | 2 +- package-lock.json | 4 +- package.json | 2 +- server/index.js | 192 +- server/openai-codex.js | 35 +- server/projects.js | 186 +- server/routes/commands.js | 80 + server/routes/git.js | 74 +- src/App.jsx | 1011 --- src/App.tsx | 35 + src/components/ChatInterface.jsx | 5692 ----------------- src/components/CommandMenu.jsx | 141 +- src/components/GitPanel.jsx | 38 +- src/components/MainContent.jsx | 686 -- src/components/QuickSettingsPanel.jsx | 56 +- src/components/SessionProviderLogo.tsx | 24 + src/components/Settings.jsx | 3 - src/components/Shell.jsx | 60 +- src/components/Sidebar.jsx | 1547 ----- src/components/TodoList.jsx | 28 +- src/components/app/AppContent.tsx | 144 + .../chat/constants/thinkingModes.ts | 44 + .../chat/hooks/useChatComposerState.ts | 957 +++ .../chat/hooks/useChatProviderState.ts | 114 + .../chat/hooks/useChatRealtimeHandlers.ts | 956 +++ .../chat/hooks/useChatSessionState.ts | 612 ++ src/components/chat/hooks/useFileMentions.tsx | 268 + src/components/chat/hooks/useSlashCommands.ts | 375 ++ src/components/chat/tools/README.md | 224 + src/components/chat/tools/ToolRenderer.tsx | 209 + .../tools/components/CollapsibleDisplay.tsx | 76 + .../tools/components/CollapsibleSection.tsx | 61 + .../ContentRenderers/FileListContent.tsx | 56 + .../ContentRenderers/MarkdownContent.tsx | 22 + .../ContentRenderers/TaskListContent.tsx | 125 + .../ContentRenderers/TextContent.tsx | 48 + .../ContentRenderers/TodoListContent.tsx | 23 + .../components/ContentRenderers/index.ts | 5 + .../chat/tools/components/DiffViewer.tsx | 88 + .../chat/tools/components/OneLineDisplay.tsx | 233 + src/components/chat/tools/components/index.ts | 5 + .../chat/tools/configs/toolConfigs.ts | 569 ++ src/components/chat/tools/index.ts | 3 + src/components/chat/types/types.ts | 92 + src/components/chat/utils/chatFormatting.ts | 86 + src/components/chat/utils/chatPermissions.ts | 64 + src/components/chat/utils/chatStorage.ts | 105 + src/components/chat/utils/messageKeys.ts | 38 + .../chat/utils/messageTransforms.ts | 508 ++ src/components/chat/view/ChatInterface.tsx | 384 ++ .../AssistantThinkingIndicator.tsx | 37 + .../chat/view/subcomponents/ChatComposer.tsx | 346 + .../view/subcomponents/ChatInputControls.tsx | 138 + .../view/subcomponents/ChatMessagesPane.tsx | 208 + .../view/subcomponents/ImageAttachment.tsx | 50 + .../chat/view/subcomponents/Markdown.tsx | 188 + .../view/subcomponents/MessageComponent.tsx | 446 ++ .../PermissionRequestsBanner.tsx | 109 + .../ProviderSelectionEmptyState.tsx | 225 + .../subcomponents/ThinkingModeSelector.tsx} | 84 +- .../view/subcomponents/TokenUsagePie.tsx} | 13 +- .../main-content/hooks/useEditorSidebar.ts | 110 + .../hooks/useMobileMenuHandlers.ts | 50 + src/components/main-content/types/types.ts | 107 + .../main-content/view/MainContent.tsx | 181 + .../view/subcomponents/EditorSidebar.tsx | 60 + .../view/subcomponents/MainContentHeader.tsx | 38 + .../subcomponents/MainContentStateView.tsx | 55 + .../subcomponents/MainContentTabSwitcher.tsx | 84 + .../view/subcomponents/MainContentTitle.tsx | 79 + .../view/subcomponents/MobileMenuButton.tsx | 23 + .../view/subcomponents/TaskMasterPanel.tsx | 206 + src/components/modals/VersionUpgradeModal.tsx | 219 + src/components/settings/AccountContent.jsx | 10 +- src/components/settings/AgentListItem.jsx | 12 +- .../sidebar/hooks/useSidebarController.ts | 469 ++ src/components/sidebar/types/types.ts | 62 + src/components/sidebar/utils/utils.ts | 223 + src/components/sidebar/view/Sidebar.tsx | 245 + .../view/subcomponents/SidebarCollapsed.tsx | 59 + .../view/subcomponents/SidebarContent.tsx | 84 + .../view/subcomponents/SidebarFooter.tsx | 94 + .../view/subcomponents/SidebarHeader.tsx | 187 + .../view/subcomponents/SidebarModals.tsx | 201 + .../view/subcomponents/SidebarProjectItem.tsx | 432 ++ .../view/subcomponents/SidebarProjectList.tsx | 153 + .../subcomponents/SidebarProjectSessions.tsx | 160 + .../subcomponents/SidebarProjectsState.tsx | 79 + .../view/subcomponents/SidebarSessionItem.tsx | 236 + src/components/ui/badge.jsx | 31 - src/components/ui/badge.tsx | 31 + src/components/ui/button.jsx | 46 - src/components/ui/button.tsx | 50 + src/components/ui/input.jsx | 19 - src/components/ui/input.tsx | 24 + src/components/ui/scroll-area.jsx | 23 - src/components/ui/scroll-area.tsx | 27 + src/contexts/WebSocketContext.tsx | 4 +- src/hooks/useAudioRecorder.js | 109 - src/hooks/useDeviceSettings.ts | 88 + src/hooks/useProjectsState.ts | 517 ++ src/hooks/useSessionProtection.ts | 73 + src/hooks/useUiPreferences.ts | 238 + ...{useVersionCheck.js => useVersionCheck.ts} | 30 +- src/i18n/locales/en/chat.json | 10 +- src/i18n/locales/ko/chat.json | 10 +- src/i18n/locales/zh-CN/chat.json | 10 +- src/index.css | 6 +- src/main.jsx | 2 +- src/types/app.ts | 69 + src/types/global.d.ts | 9 + src/types/react-syntax-highlighter.d.ts | 2 + src/types/sharedTypes.ts | 6 + src/utils/api.js | 4 +- src/utils/dateUtils.ts | 26 + 117 files changed, 14050 insertions(+), 9570 deletions(-) delete mode 100644 src/App.jsx create mode 100644 src/App.tsx delete mode 100644 src/components/ChatInterface.jsx delete mode 100644 src/components/MainContent.jsx create mode 100644 src/components/SessionProviderLogo.tsx delete mode 100644 src/components/Sidebar.jsx create mode 100644 src/components/app/AppContent.tsx create mode 100644 src/components/chat/constants/thinkingModes.ts create mode 100644 src/components/chat/hooks/useChatComposerState.ts create mode 100644 src/components/chat/hooks/useChatProviderState.ts create mode 100644 src/components/chat/hooks/useChatRealtimeHandlers.ts create mode 100644 src/components/chat/hooks/useChatSessionState.ts create mode 100644 src/components/chat/hooks/useFileMentions.tsx create mode 100644 src/components/chat/hooks/useSlashCommands.ts create mode 100644 src/components/chat/tools/README.md create mode 100644 src/components/chat/tools/ToolRenderer.tsx create mode 100644 src/components/chat/tools/components/CollapsibleDisplay.tsx create mode 100644 src/components/chat/tools/components/CollapsibleSection.tsx create mode 100644 src/components/chat/tools/components/ContentRenderers/FileListContent.tsx create mode 100644 src/components/chat/tools/components/ContentRenderers/MarkdownContent.tsx create mode 100644 src/components/chat/tools/components/ContentRenderers/TaskListContent.tsx create mode 100644 src/components/chat/tools/components/ContentRenderers/TextContent.tsx create mode 100644 src/components/chat/tools/components/ContentRenderers/TodoListContent.tsx create mode 100644 src/components/chat/tools/components/ContentRenderers/index.ts create mode 100644 src/components/chat/tools/components/DiffViewer.tsx create mode 100644 src/components/chat/tools/components/OneLineDisplay.tsx create mode 100644 src/components/chat/tools/components/index.ts create mode 100644 src/components/chat/tools/configs/toolConfigs.ts create mode 100644 src/components/chat/tools/index.ts create mode 100644 src/components/chat/types/types.ts create mode 100644 src/components/chat/utils/chatFormatting.ts create mode 100644 src/components/chat/utils/chatPermissions.ts create mode 100644 src/components/chat/utils/chatStorage.ts create mode 100644 src/components/chat/utils/messageKeys.ts create mode 100644 src/components/chat/utils/messageTransforms.ts create mode 100644 src/components/chat/view/ChatInterface.tsx create mode 100644 src/components/chat/view/subcomponents/AssistantThinkingIndicator.tsx create mode 100644 src/components/chat/view/subcomponents/ChatComposer.tsx create mode 100644 src/components/chat/view/subcomponents/ChatInputControls.tsx create mode 100644 src/components/chat/view/subcomponents/ChatMessagesPane.tsx create mode 100644 src/components/chat/view/subcomponents/ImageAttachment.tsx create mode 100644 src/components/chat/view/subcomponents/Markdown.tsx create mode 100644 src/components/chat/view/subcomponents/MessageComponent.tsx create mode 100644 src/components/chat/view/subcomponents/PermissionRequestsBanner.tsx create mode 100644 src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx rename src/components/{ThinkingModeSelector.jsx => chat/view/subcomponents/ThinkingModeSelector.tsx} (75%) rename src/components/{TokenUsagePie.jsx => chat/view/subcomponents/TokenUsagePie.tsx} (87%) create mode 100644 src/components/main-content/hooks/useEditorSidebar.ts create mode 100644 src/components/main-content/hooks/useMobileMenuHandlers.ts create mode 100644 src/components/main-content/types/types.ts create mode 100644 src/components/main-content/view/MainContent.tsx create mode 100644 src/components/main-content/view/subcomponents/EditorSidebar.tsx create mode 100644 src/components/main-content/view/subcomponents/MainContentHeader.tsx create mode 100644 src/components/main-content/view/subcomponents/MainContentStateView.tsx create mode 100644 src/components/main-content/view/subcomponents/MainContentTabSwitcher.tsx create mode 100644 src/components/main-content/view/subcomponents/MainContentTitle.tsx create mode 100644 src/components/main-content/view/subcomponents/MobileMenuButton.tsx create mode 100644 src/components/main-content/view/subcomponents/TaskMasterPanel.tsx create mode 100644 src/components/modals/VersionUpgradeModal.tsx create mode 100644 src/components/sidebar/hooks/useSidebarController.ts create mode 100644 src/components/sidebar/types/types.ts create mode 100644 src/components/sidebar/utils/utils.ts create mode 100644 src/components/sidebar/view/Sidebar.tsx create mode 100644 src/components/sidebar/view/subcomponents/SidebarCollapsed.tsx create mode 100644 src/components/sidebar/view/subcomponents/SidebarContent.tsx create mode 100644 src/components/sidebar/view/subcomponents/SidebarFooter.tsx create mode 100644 src/components/sidebar/view/subcomponents/SidebarHeader.tsx create mode 100644 src/components/sidebar/view/subcomponents/SidebarModals.tsx create mode 100644 src/components/sidebar/view/subcomponents/SidebarProjectItem.tsx create mode 100644 src/components/sidebar/view/subcomponents/SidebarProjectList.tsx create mode 100644 src/components/sidebar/view/subcomponents/SidebarProjectSessions.tsx create mode 100644 src/components/sidebar/view/subcomponents/SidebarProjectsState.tsx create mode 100644 src/components/sidebar/view/subcomponents/SidebarSessionItem.tsx delete mode 100644 src/components/ui/badge.jsx create mode 100644 src/components/ui/badge.tsx delete mode 100644 src/components/ui/button.jsx create mode 100644 src/components/ui/button.tsx delete mode 100644 src/components/ui/input.jsx create mode 100644 src/components/ui/input.tsx delete mode 100644 src/components/ui/scroll-area.jsx create mode 100644 src/components/ui/scroll-area.tsx delete mode 100755 src/hooks/useAudioRecorder.js create mode 100644 src/hooks/useDeviceSettings.ts create mode 100644 src/hooks/useProjectsState.ts create mode 100644 src/hooks/useSessionProtection.ts create mode 100644 src/hooks/useUiPreferences.ts rename src/hooks/{useVersionCheck.js => useVersionCheck.ts} (61%) create mode 100644 src/types/app.ts create mode 100644 src/types/global.d.ts create mode 100644 src/types/react-syntax-highlighter.d.ts create mode 100644 src/types/sharedTypes.ts create mode 100644 src/utils/dateUtils.ts diff --git a/.nvmrc b/.nvmrc index 09c06f5..92f279e 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v20.19.3 \ No newline at end of file +v22 \ No newline at end of file diff --git a/README.md b/README.md index 2303365..fae57a6 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ A desktop and mobile UI for [Claude Code](https://docs.anthropic.com/en/docs/cla ### Prerequisites -- [Node.js](https://nodejs.org/) v20 or higher +- [Node.js](https://nodejs.org/) v22 or higher - [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) installed and configured, and/or - [Cursor CLI](https://docs.cursor.com/en/cli/overview) installed and configured, and/or - [Codex](https://developers.openai.com/codex) installed and configured diff --git a/README.zh-CN.md b/README.zh-CN.md index b62cd21..64981c7 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -57,7 +57,7 @@ ### 前置要求 -- [Node.js](https://nodejs.org/) v20 或更高版本 +- [Node.js](https://nodejs.org/) v22 或更高版本 - 已安装并配置 [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code),和/或 - 已安装并配置 [Cursor CLI](https://docs.cursor.com/en/cli/overview),和/或 - 已安装并配置 [Codex](https://developers.openai.com/codex) diff --git a/package-lock.json b/package-lock.json index fcd70ff..9bbab5a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@siteboon/claude-code-ui", - "version": "1.16.4", + "version": "1.17.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@siteboon/claude-code-ui", - "version": "1.16.4", + "version": "1.17.0", "license": "MIT", "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.1.29", diff --git a/package.json b/package.json index a5a9732..c2ae2b1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@siteboon/claude-code-ui", - "version": "1.16.4", + "version": "1.17.0", "description": "A web-based UI for Claude Code CLI", "type": "module", "main": "server/index.js", diff --git a/server/index.js b/server/index.js index 8a2604d..bac8e0b 100755 --- a/server/index.js +++ b/server/index.js @@ -63,8 +63,24 @@ import { initializeDatabase } from './database/db.js'; import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js'; import { IS_PLATFORM } from './constants/config.js'; -// File system watcher for projects folder -let projectsWatcher = null; +// File system watchers for provider project/session folders +const PROVIDER_WATCH_PATHS = [ + { provider: 'claude', rootPath: path.join(os.homedir(), '.claude', 'projects') }, + { provider: 'cursor', rootPath: path.join(os.homedir(), '.cursor', 'chats') }, + { provider: 'codex', rootPath: path.join(os.homedir(), '.codex', 'sessions') } +]; +const WATCHER_IGNORED_PATTERNS = [ + '**/node_modules/**', + '**/.git/**', + '**/dist/**', + '**/build/**', + '**/*.tmp', + '**/*.swp', + '**/.DS_Store' +]; +const WATCHER_DEBOUNCE_MS = 300; +let projectsWatchers = []; +let projectsWatcherDebounceTimer = null; const connectedClients = new Set(); let isGetProjectsRunning = false; // Flag to prevent reentrant calls @@ -81,94 +97,110 @@ function broadcastProgress(progress) { }); } -// Setup file system watcher for Claude projects folder using chokidar +// Setup file system watchers for Claude, Cursor, and Codex project/session folders async function setupProjectsWatcher() { const chokidar = (await import('chokidar')).default; - const claudeProjectsPath = path.join(os.homedir(), '.claude', 'projects'); - if (projectsWatcher) { - projectsWatcher.close(); + if (projectsWatcherDebounceTimer) { + clearTimeout(projectsWatcherDebounceTimer); + projectsWatcherDebounceTimer = null; } - try { - // Initialize chokidar watcher with optimized settings - projectsWatcher = chokidar.watch(claudeProjectsPath, { - ignored: [ - '**/node_modules/**', - '**/.git/**', - '**/dist/**', - '**/build/**', - '**/*.tmp', - '**/*.swp', - '**/.DS_Store' - ], - persistent: true, - ignoreInitial: true, // Don't fire events for existing files on startup - followSymlinks: false, - depth: 10, // Reasonable depth limit - awaitWriteFinish: { - stabilityThreshold: 100, // Wait 100ms for file to stabilize - pollInterval: 50 + await Promise.all( + projectsWatchers.map(async (watcher) => { + try { + await watcher.close(); + } catch (error) { + console.error('[WARN] Failed to close watcher:', error); } - }); + }) + ); + projectsWatchers = []; - // Debounce function to prevent excessive notifications - let debounceTimer; - const debouncedUpdate = async (eventType, filePath) => { - clearTimeout(debounceTimer); - debounceTimer = setTimeout(async () => { - // Prevent reentrant calls - if (isGetProjectsRunning) { - return; + const debouncedUpdate = (eventType, filePath, provider, rootPath) => { + if (projectsWatcherDebounceTimer) { + clearTimeout(projectsWatcherDebounceTimer); + } + + projectsWatcherDebounceTimer = setTimeout(async () => { + // Prevent reentrant calls + if (isGetProjectsRunning) { + return; + } + + try { + isGetProjectsRunning = true; + + // Clear project directory cache when files change + clearProjectDirectoryCache(); + + // Get updated projects list + const updatedProjects = await getProjects(broadcastProgress); + + // Notify all connected clients about the project changes + const updateMessage = JSON.stringify({ + type: 'projects_updated', + projects: updatedProjects, + timestamp: new Date().toISOString(), + changeType: eventType, + changedFile: path.relative(rootPath, filePath), + watchProvider: provider + }); + + connectedClients.forEach(client => { + if (client.readyState === WebSocket.OPEN) { + client.send(updateMessage); + } + }); + + } catch (error) { + console.error('[ERROR] Error handling project changes:', error); + } finally { + isGetProjectsRunning = false; + } + }, WATCHER_DEBOUNCE_MS); + }; + + for (const { provider, rootPath } of PROVIDER_WATCH_PATHS) { + try { + // chokidar v4 emits ENOENT via the "error" event for missing roots and will not auto-recover. + // Ensure provider folders exist before creating the watcher so watching stays active. + await fsPromises.mkdir(rootPath, { recursive: true }); + + // Initialize chokidar watcher with optimized settings + const watcher = chokidar.watch(rootPath, { + ignored: WATCHER_IGNORED_PATTERNS, + persistent: true, + ignoreInitial: true, // Don't fire events for existing files on startup + followSymlinks: false, + depth: 10, // Reasonable depth limit + awaitWriteFinish: { + stabilityThreshold: 100, // Wait 100ms for file to stabilize + pollInterval: 50 } - - try { - isGetProjectsRunning = true; - - // Clear project directory cache when files change - clearProjectDirectoryCache(); - - // Get updated projects list - const updatedProjects = await getProjects(broadcastProgress); - - // Notify all connected clients about the project changes - const updateMessage = JSON.stringify({ - type: 'projects_updated', - projects: updatedProjects, - timestamp: new Date().toISOString(), - changeType: eventType, - changedFile: path.relative(claudeProjectsPath, filePath) - }); - - connectedClients.forEach(client => { - if (client.readyState === WebSocket.OPEN) { - client.send(updateMessage); - } - }); - - } catch (error) { - console.error('[ERROR] Error handling project changes:', error); - } finally { - isGetProjectsRunning = false; - } - }, 300); // 300ms debounce (slightly faster than before) - }; - - // Set up event listeners - projectsWatcher - .on('add', (filePath) => debouncedUpdate('add', filePath)) - .on('change', (filePath) => debouncedUpdate('change', filePath)) - .on('unlink', (filePath) => debouncedUpdate('unlink', filePath)) - .on('addDir', (dirPath) => debouncedUpdate('addDir', dirPath)) - .on('unlinkDir', (dirPath) => debouncedUpdate('unlinkDir', dirPath)) - .on('error', (error) => { - console.error('[ERROR] Chokidar watcher error:', error); - }) - .on('ready', () => { }); - } catch (error) { - console.error('[ERROR] Failed to setup projects watcher:', error); + // Set up event listeners + watcher + .on('add', (filePath) => debouncedUpdate('add', filePath, provider, rootPath)) + .on('change', (filePath) => debouncedUpdate('change', filePath, provider, rootPath)) + .on('unlink', (filePath) => debouncedUpdate('unlink', filePath, provider, rootPath)) + .on('addDir', (dirPath) => debouncedUpdate('addDir', dirPath, provider, rootPath)) + .on('unlinkDir', (dirPath) => debouncedUpdate('unlinkDir', dirPath, provider, rootPath)) + .on('error', (error) => { + console.error(`[ERROR] ${provider} watcher error:`, error); + }) + .on('ready', () => { + }); + + projectsWatchers.push(watcher); + } catch (error) { + console.error(`[ERROR] Failed to setup ${provider} watcher for ${rootPath}:`, error); + } + } + + if (projectsWatchers.length === 0) { + console.error('[ERROR] Failed to setup any provider watchers'); } } diff --git a/server/openai-codex.js b/server/openai-codex.js index 1967de4..bd368ff 100644 --- a/server/openai-codex.js +++ b/server/openai-codex.js @@ -203,6 +203,7 @@ export async function queryCodex(command, options = {}, ws) { let codex; let thread; let currentSessionId = sessionId; + const abortController = new AbortController(); try { // Initialize Codex SDK @@ -232,6 +233,7 @@ export async function queryCodex(command, options = {}, ws) { thread, codex, status: 'running', + abortController, startedAt: new Date().toISOString() }); @@ -243,7 +245,9 @@ export async function queryCodex(command, options = {}, ws) { }); // Execute with streaming - const streamedTurn = await thread.runStreamed(command); + const streamedTurn = await thread.runStreamed(command, { + signal: abortController.signal + }); for await (const event of streamedTurn.events) { // Check if session was aborted @@ -286,20 +290,27 @@ export async function queryCodex(command, options = {}, ws) { }); } catch (error) { - console.error('[Codex] Error:', error); + const session = currentSessionId ? activeCodexSessions.get(currentSessionId) : null; + const wasAborted = + session?.status === 'aborted' || + error?.name === 'AbortError' || + String(error?.message || '').toLowerCase().includes('aborted'); - sendMessage(ws, { - type: 'codex-error', - error: error.message, - sessionId: currentSessionId - }); + if (!wasAborted) { + console.error('[Codex] Error:', error); + sendMessage(ws, { + type: 'codex-error', + error: error.message, + sessionId: currentSessionId + }); + } } finally { // Update session status if (currentSessionId) { const session = activeCodexSessions.get(currentSessionId); if (session) { - session.status = 'completed'; + session.status = session.status === 'aborted' ? 'aborted' : 'completed'; } } } @@ -318,9 +329,11 @@ export function abortCodexSession(sessionId) { } session.status = 'aborted'; - - // The SDK doesn't have a direct abort method, but marking status - // will cause the streaming loop to exit + try { + session.abortController?.abort(); + } catch (error) { + console.warn(`[Codex] Failed to abort session ${sessionId}:`, error); + } return true; } diff --git a/server/projects.js b/server/projects.js index 475b323..50a22c5 100755 --- a/server/projects.js +++ b/server/projects.js @@ -384,6 +384,7 @@ async function getProjects(progressCallback = null) { const config = await loadProjectConfig(); const projects = []; const existingProjects = new Set(); + const codexSessionsIndexRef = { sessionsByProject: null }; let totalProjects = 0; let processedProjects = 0; let directories = []; @@ -419,8 +420,6 @@ async function getProjects(progressCallback = null) { }); } - const projectPath = path.join(claudeDir, entry.name); - // Extract actual project directory from JSONL sessions const actualProjectDir = await extractProjectDirectory(entry.name); @@ -435,7 +434,11 @@ async function getProjects(progressCallback = null) { displayName: customName || autoDisplayName, fullPath: fullPath, isCustomName: !!customName, - sessions: [] + sessions: [], + sessionMeta: { + hasMore: false, + total: 0 + } }; // Try to get sessions for this project (just first 5 for performance) @@ -448,6 +451,10 @@ async function getProjects(progressCallback = null) { }; } catch (e) { console.warn(`Could not load sessions for project ${entry.name}:`, e.message); + project.sessionMeta = { + hasMore: false, + total: 0 + }; } // Also fetch Cursor sessions for this project @@ -460,7 +467,9 @@ async function getProjects(progressCallback = null) { // Also fetch Codex sessions for this project try { - project.codexSessions = await getCodexSessions(actualProjectDir); + project.codexSessions = await getCodexSessions(actualProjectDir, { + indexRef: codexSessionsIndexRef, + }); } catch (e) { console.warn(`Could not load Codex sessions for project ${entry.name}:`, e.message); project.codexSessions = []; @@ -525,7 +534,7 @@ async function getProjects(progressCallback = null) { } } - const project = { + const project = { name: projectName, path: actualProjectDir, displayName: projectConfig.displayName || await generateDisplayName(projectName, actualProjectDir), @@ -533,9 +542,13 @@ async function getProjects(progressCallback = null) { isCustomName: !!projectConfig.displayName, isManuallyAdded: true, sessions: [], + sessionMeta: { + hasMore: false, + total: 0 + }, cursorSessions: [], codexSessions: [] - }; + }; // Try to fetch Cursor sessions for manual projects too try { @@ -546,7 +559,9 @@ async function getProjects(progressCallback = null) { // Try to fetch Codex sessions for manual projects too try { - project.codexSessions = await getCodexSessions(actualProjectDir); + project.codexSessions = await getCodexSessions(actualProjectDir, { + indexRef: codexSessionsIndexRef, + }); } catch (e) { console.warn(`Could not load Codex sessions for manual project ${projectName}:`, e.message); } @@ -1244,75 +1259,114 @@ async function getCursorSessions(projectPath) { } +function normalizeComparablePath(inputPath) { + if (!inputPath || typeof inputPath !== 'string') { + return ''; + } + + const withoutLongPathPrefix = inputPath.startsWith('\\\\?\\') + ? inputPath.slice(4) + : inputPath; + const normalized = path.normalize(withoutLongPathPrefix.trim()); + + if (!normalized) { + return ''; + } + + const resolved = path.resolve(normalized); + return process.platform === 'win32' ? resolved.toLowerCase() : resolved; +} + +async function findCodexJsonlFiles(dir) { + const files = []; + + try { + const entries = await fs.readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...await findCodexJsonlFiles(fullPath)); + } else if (entry.name.endsWith('.jsonl')) { + files.push(fullPath); + } + } + } catch (error) { + // Skip directories we can't read + } + + return files; +} + +async function buildCodexSessionsIndex() { + const codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions'); + const sessionsByProject = new Map(); + + try { + await fs.access(codexSessionsDir); + } catch (error) { + return sessionsByProject; + } + + const jsonlFiles = await findCodexJsonlFiles(codexSessionsDir); + + for (const filePath of jsonlFiles) { + try { + const sessionData = await parseCodexSessionFile(filePath); + if (!sessionData || !sessionData.id) { + continue; + } + + const normalizedProjectPath = normalizeComparablePath(sessionData.cwd); + if (!normalizedProjectPath) { + continue; + } + + const session = { + id: sessionData.id, + summary: sessionData.summary || 'Codex Session', + messageCount: sessionData.messageCount || 0, + lastActivity: sessionData.timestamp ? new Date(sessionData.timestamp) : new Date(), + cwd: sessionData.cwd, + model: sessionData.model, + filePath, + provider: 'codex', + }; + + if (!sessionsByProject.has(normalizedProjectPath)) { + sessionsByProject.set(normalizedProjectPath, []); + } + + sessionsByProject.get(normalizedProjectPath).push(session); + } catch (error) { + console.warn(`Could not parse Codex session file ${filePath}:`, error.message); + } + } + + for (const sessions of sessionsByProject.values()) { + sessions.sort((a, b) => new Date(b.lastActivity) - new Date(a.lastActivity)); + } + + return sessionsByProject; +} + // Fetch Codex sessions for a given project path async function getCodexSessions(projectPath, options = {}) { - const { limit = 5 } = options; + const { limit = 5, indexRef = null } = options; try { - const codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions'); - const sessions = []; - - // Check if the directory exists - try { - await fs.access(codexSessionsDir); - } catch (error) { - // No Codex sessions directory + const normalizedProjectPath = normalizeComparablePath(projectPath); + if (!normalizedProjectPath) { return []; } - // Recursively find all .jsonl files in the sessions directory - const findJsonlFiles = async (dir) => { - const files = []; - try { - const entries = await fs.readdir(dir, { withFileTypes: true }); - for (const entry of entries) { - const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - files.push(...await findJsonlFiles(fullPath)); - } else if (entry.name.endsWith('.jsonl')) { - files.push(fullPath); - } - } - } catch (error) { - // Skip directories we can't read - } - return files; - }; - - const jsonlFiles = await findJsonlFiles(codexSessionsDir); - - // Process each file to find sessions matching the project path - for (const filePath of jsonlFiles) { - try { - const sessionData = await parseCodexSessionFile(filePath); - - // Check if this session matches the project path - // Handle Windows long paths with \\?\ prefix - const sessionCwd = sessionData?.cwd || ''; - const cleanSessionCwd = sessionCwd.startsWith('\\\\?\\') ? sessionCwd.slice(4) : sessionCwd; - const cleanProjectPath = projectPath.startsWith('\\\\?\\') ? projectPath.slice(4) : projectPath; - - if (sessionData && (sessionData.cwd === projectPath || cleanSessionCwd === cleanProjectPath || path.relative(cleanSessionCwd, cleanProjectPath) === '')) { - sessions.push({ - id: sessionData.id, - summary: sessionData.summary || 'Codex Session', - messageCount: sessionData.messageCount || 0, - lastActivity: sessionData.timestamp ? new Date(sessionData.timestamp) : new Date(), - cwd: sessionData.cwd, - model: sessionData.model, - filePath: filePath, - provider: 'codex' - }); - } - } catch (error) { - console.warn(`Could not parse Codex session file ${filePath}:`, error.message); - } + if (indexRef && !indexRef.sessionsByProject) { + indexRef.sessionsByProject = await buildCodexSessionsIndex(); } - // Sort sessions by last activity (newest first) - sessions.sort((a, b) => new Date(b.lastActivity) - new Date(a.lastActivity)); + const sessionsByProject = indexRef?.sessionsByProject || await buildCodexSessionsIndex(); + const sessions = sessionsByProject.get(normalizedProjectPath) || []; // Return limited sessions for performance (0 = unlimited for deletion) - return limit > 0 ? sessions.slice(0, limit) : sessions; + return limit > 0 ? sessions.slice(0, limit) : [...sessions]; } catch (error) { console.error('Error fetching Codex sessions:', error); diff --git a/server/routes/commands.js b/server/routes/commands.js index b13a8f3..5446734 100644 --- a/server/routes/commands.js +++ b/server/routes/commands.js @@ -209,6 +209,86 @@ Custom commands can be created in: }; }, + '/cost': async (args, context) => { + const tokenUsage = context?.tokenUsage || {}; + const provider = context?.provider || 'claude'; + const model = + context?.model || + (provider === 'cursor' + ? CURSOR_MODELS.DEFAULT + : provider === 'codex' + ? CODEX_MODELS.DEFAULT + : CLAUDE_MODELS.DEFAULT); + + const used = Number(tokenUsage.used ?? tokenUsage.totalUsed ?? tokenUsage.total_tokens ?? 0) || 0; + const total = + Number( + tokenUsage.total ?? + tokenUsage.contextWindow ?? + parseInt(process.env.CONTEXT_WINDOW || '160000', 10), + ) || 160000; + const percentage = total > 0 ? Number(((used / total) * 100).toFixed(1)) : 0; + + const inputTokensRaw = + Number( + tokenUsage.inputTokens ?? + tokenUsage.input ?? + tokenUsage.cumulativeInputTokens ?? + tokenUsage.promptTokens ?? + 0, + ) || 0; + const outputTokens = + Number( + tokenUsage.outputTokens ?? + tokenUsage.output ?? + tokenUsage.cumulativeOutputTokens ?? + tokenUsage.completionTokens ?? + 0, + ) || 0; + const cacheTokens = + Number( + tokenUsage.cacheReadTokens ?? + tokenUsage.cacheCreationTokens ?? + tokenUsage.cacheTokens ?? + tokenUsage.cachedTokens ?? + 0, + ) || 0; + + // If we only have total used tokens, treat them as input for display/estimation. + const inputTokens = + inputTokensRaw > 0 || outputTokens > 0 || cacheTokens > 0 ? inputTokensRaw + cacheTokens : used; + + // Rough default rates by provider (USD / 1M tokens). + const pricingByProvider = { + claude: { input: 3, output: 15 }, + cursor: { input: 3, output: 15 }, + codex: { input: 1.5, output: 6 }, + }; + const rates = pricingByProvider[provider] || pricingByProvider.claude; + + const inputCost = (inputTokens / 1_000_000) * rates.input; + const outputCost = (outputTokens / 1_000_000) * rates.output; + const totalCost = inputCost + outputCost; + + return { + type: 'builtin', + action: 'cost', + data: { + tokenUsage: { + used, + total, + percentage, + }, + cost: { + input: inputCost.toFixed(4), + output: outputCost.toFixed(4), + total: totalCost.toFixed(4), + }, + model, + }, + }; + }, + '/status': async (args, context) => { // Read version from package.json const packageJsonPath = path.join(path.dirname(__dirname), '..', 'package.json'); diff --git a/server/routes/git.js b/server/routes/git.js index 0df4e44..e6fecee 100755 --- a/server/routes/git.js +++ b/server/routes/git.js @@ -1,5 +1,5 @@ import express from 'express'; -import { exec } from 'child_process'; +import { exec, spawn } from 'child_process'; import { promisify } from 'util'; import path from 'path'; import { promises as fs } from 'fs'; @@ -10,6 +10,43 @@ import { spawnCursor } from '../cursor-cli.js'; const router = express.Router(); const execAsync = promisify(exec); +function spawnAsync(command, args, options = {}) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + ...options, + shell: false, + }); + + let stdout = ''; + let stderr = ''; + + child.stdout.on('data', (data) => { + stdout += data.toString(); + }); + + child.stderr.on('data', (data) => { + stderr += data.toString(); + }); + + child.on('error', (error) => { + reject(error); + }); + + child.on('close', (code) => { + if (code === 0) { + resolve({ stdout, stderr }); + return; + } + + const error = new Error(`Command failed: ${command} ${args.join(' ')}`); + error.code = code; + error.stdout = stdout; + error.stderr = stderr; + reject(error); + }); + }); +} + // Helper function to get the actual project path from the encoded project name async function getActualProjectPath(projectName) { try { @@ -60,19 +97,16 @@ async function validateGitRepository(projectPath) { } try { - // Use --show-toplevel to get the root of the git repository - const { stdout: gitRoot } = await execAsync('git rev-parse --show-toplevel', { cwd: projectPath }); - const normalizedGitRoot = path.resolve(gitRoot.trim()); - const normalizedProjectPath = path.resolve(projectPath); - - // Ensure the git root matches our project path (prevent using parent git repos) - if (normalizedGitRoot !== normalizedProjectPath) { - throw new Error(`Project directory is not a git repository. This directory is inside a git repository at ${normalizedGitRoot}, but git operations should be run from the repository root.`); - } - } catch (error) { - if (error.message.includes('Project directory is not a git repository')) { - throw error; + // Allow any directory that is inside a work tree (repo root or nested folder). + const { stdout: insideWorkTreeOutput } = await execAsync('git rev-parse --is-inside-work-tree', { cwd: projectPath }); + const isInsideWorkTree = insideWorkTreeOutput.trim() === 'true'; + if (!isInsideWorkTree) { + throw new Error('Not inside a git work tree'); } + + // Ensure git can resolve the repository root for this directory. + await execAsync('git rev-parse --show-toplevel', { cwd: projectPath }); + } catch { throw new Error('Not a git repository. This directory does not contain a .git folder. Initialize a git repository with "git init" to use source control features.'); } } @@ -445,11 +479,17 @@ router.get('/commits', async (req, res) => { try { const projectPath = await getActualProjectPath(project); + await validateGitRepository(projectPath); + const parsedLimit = Number.parseInt(String(limit), 10); + const safeLimit = Number.isFinite(parsedLimit) && parsedLimit > 0 + ? Math.min(parsedLimit, 100) + : 10; // Get commit log with stats - const { stdout } = await execAsync( - `git log --pretty=format:'%H|%an|%ae|%ad|%s' --date=relative -n ${limit}`, - { cwd: projectPath } + const { stdout } = await spawnAsync( + 'git', + ['log', '--pretty=format:%H|%an|%ae|%ad|%s', '--date=relative', '-n', String(safeLimit)], + { cwd: projectPath }, ); const commits = stdout @@ -1125,4 +1165,4 @@ router.post('/delete-untracked', async (req, res) => { } }); -export default router; \ No newline at end of file +export default router; diff --git a/src/App.jsx b/src/App.jsx deleted file mode 100644 index 3ded1e6..0000000 --- a/src/App.jsx +++ /dev/null @@ -1,1011 +0,0 @@ -/* - * App.jsx - Main Application Component with Session Protection System - * - * SESSION PROTECTION SYSTEM OVERVIEW: - * =================================== - * - * Problem: Automatic project updates from WebSocket would refresh the sidebar and clear chat messages - * during active conversations, creating a poor user experience. - * - * Solution: Track "active sessions" and pause project updates during conversations. - * - * How it works: - * 1. When user sends message → session marked as "active" - * 2. Project updates are skipped while session is active - * 3. When conversation completes/aborts → session marked as "inactive" - * 4. Project updates resume normally - * - * Handles both existing sessions (with real IDs) and new sessions (with temporary IDs). - */ - -import React, { useState, useEffect, useCallback, useRef } from 'react'; -import { BrowserRouter as Router, Routes, Route, useNavigate, useParams } from 'react-router-dom'; -import { Settings as SettingsIcon, Sparkles } from 'lucide-react'; -import Sidebar from './components/Sidebar'; -import MainContent from './components/MainContent'; -import MobileNav from './components/MobileNav'; -import Settings from './components/Settings'; -import QuickSettingsPanel from './components/QuickSettingsPanel'; - -import { ThemeProvider } from './contexts/ThemeContext'; -import { AuthProvider } from './contexts/AuthContext'; -import { TaskMasterProvider } from './contexts/TaskMasterContext'; -import { TasksSettingsProvider } from './contexts/TasksSettingsContext'; -import { WebSocketProvider, useWebSocket } from './contexts/WebSocketContext'; -import ProtectedRoute from './components/ProtectedRoute'; -import { useVersionCheck } from './hooks/useVersionCheck'; -import useLocalStorage from './hooks/useLocalStorage'; -import { api, authenticatedFetch } from './utils/api'; -import { I18nextProvider, useTranslation } from 'react-i18next'; -import i18n from './i18n/config.js'; - - -// ! Move to a separate file called AppContent.ts -// Main App component with routing -function AppContent() { - const navigate = useNavigate(); - const { sessionId } = useParams(); - const { t } = useTranslation('common'); - // * This is a tracker for avoiding excessive re-renders during development - const renderCountRef = useRef(0); - // console.log(`AppContent render count: ${renderCountRef.current++}`); - - const { updateAvailable, latestVersion, currentVersion, releaseInfo } = useVersionCheck('siteboon', 'claudecodeui'); - const [showVersionModal, setShowVersionModal] = useState(false); - - const [projects, setProjects] = useState([]); - const [selectedProject, setSelectedProject] = useState(null); - const [selectedSession, setSelectedSession] = useState(null); - const [activeTab, setActiveTab] = useState('chat'); // 'chat' or 'files' - const [isMobile, setIsMobile] = useState(false); - const [sidebarOpen, setSidebarOpen] = useState(false); - const [isLoadingProjects, setIsLoadingProjects] = useState(true); - const [loadingProgress, setLoadingProgress] = useState(null); // { phase, current, total, currentProject } - const [isInputFocused, setIsInputFocused] = useState(false); - const [showSettings, setShowSettings] = useState(false); - const [settingsInitialTab, setSettingsInitialTab] = useState('agents'); - const [showQuickSettings, setShowQuickSettings] = useState(false); - const [autoExpandTools, setAutoExpandTools] = useLocalStorage('autoExpandTools', false); - const [showRawParameters, setShowRawParameters] = useLocalStorage('showRawParameters', false); - const [showThinking, setShowThinking] = useLocalStorage('showThinking', true); - const [autoScrollToBottom, setAutoScrollToBottom] = useLocalStorage('autoScrollToBottom', true); - const [sendByCtrlEnter, setSendByCtrlEnter] = useLocalStorage('sendByCtrlEnter', false); - const [sidebarVisible, setSidebarVisible] = useLocalStorage('sidebarVisible', true); - // Session Protection System: Track sessions with active conversations to prevent - // automatic project updates from interrupting ongoing chats. When a user sends - // a message, the session is marked as "active" and project updates are paused - // until the conversation completes or is aborted. - const [activeSessions, setActiveSessions] = useState(new Set()); // Track sessions with active conversations - - // Processing Sessions: Track which sessions are currently thinking/processing - // This allows us to restore the "Thinking..." banner when switching back to a processing session - const [processingSessions, setProcessingSessions] = useState(new Set()); - - // External Message Update Trigger: Incremented when external CLI modifies current session's JSONL - // Triggers ChatInterface to reload messages without switching sessions - const [externalMessageUpdate, setExternalMessageUpdate] = useState(0); - - const { ws, sendMessage, latestMessage } = useWebSocket(); - - // Ref to track loading progress timeout for cleanup - const loadingProgressTimeoutRef = useRef(null); - - // Detect if running as PWA - const [isPWA, setIsPWA] = useState(false); - - useEffect(() => { - // Check if running in standalone mode (PWA) - const checkPWA = () => { - const isStandalone = window.matchMedia('(display-mode: standalone)').matches || - window.navigator.standalone || - document.referrer.includes('android-app://'); - setIsPWA(isStandalone); - document.addEventListener('touchstart', {}); - - // Add class to html and body for CSS targeting - if (isStandalone) { - document.documentElement.classList.add('pwa-mode'); - document.body.classList.add('pwa-mode'); - } else { - document.documentElement.classList.remove('pwa-mode'); - document.body.classList.remove('pwa-mode'); - } - }; - - checkPWA(); - - // Listen for changes - window.matchMedia('(display-mode: standalone)').addEventListener('change', checkPWA); - - return () => { - window.matchMedia('(display-mode: standalone)').removeEventListener('change', checkPWA); - }; - }, []); - - useEffect(() => { - const checkMobile = () => { - setIsMobile(window.innerWidth < 768); - }; - - checkMobile(); - window.addEventListener('resize', checkMobile); - - return () => window.removeEventListener('resize', checkMobile); - }, []); - - useEffect(() => { - // Fetch projects on component mount - fetchProjects(); - }, []); - - // Helper function to determine if an update is purely additive (new sessions/projects) - // vs modifying existing selected items that would interfere with active conversations - const isUpdateAdditive = (currentProjects, updatedProjects, selectedProject, selectedSession) => { - if (!selectedProject || !selectedSession) { - // No active session to protect, allow all updates - return true; - } - - // Find the selected project in both current and updated data - const currentSelectedProject = currentProjects?.find(p => p.name === selectedProject.name); - const updatedSelectedProject = updatedProjects?.find(p => p.name === selectedProject.name); - - if (!currentSelectedProject || !updatedSelectedProject) { - // Project structure changed significantly, not purely additive - return false; - } - - // Find the selected session in both current and updated project data - const currentSelectedSession = currentSelectedProject.sessions?.find(s => s.id === selectedSession.id); - const updatedSelectedSession = updatedSelectedProject.sessions?.find(s => s.id === selectedSession.id); - - if (!currentSelectedSession || !updatedSelectedSession) { - // Selected session was deleted or significantly changed, not purely additive - return false; - } - - // Check if the selected session's content has changed (modification vs addition) - // Compare key fields that would affect the loaded chat interface - const sessionUnchanged = - currentSelectedSession.id === updatedSelectedSession.id && - currentSelectedSession.title === updatedSelectedSession.title && - currentSelectedSession.created_at === updatedSelectedSession.created_at && - currentSelectedSession.updated_at === updatedSelectedSession.updated_at; - - // This is considered additive if the selected session is unchanged - // (new sessions may have been added elsewhere, but active session is protected) - return sessionUnchanged; - }; - - // Handle WebSocket messages for real-time project updates - useEffect(() => { - if (latestMessage) { - // Handle loading progress updates - if (latestMessage.type === 'loading_progress') { - if (loadingProgressTimeoutRef.current) { - clearTimeout(loadingProgressTimeoutRef.current); - loadingProgressTimeoutRef.current = null; - } - setLoadingProgress(latestMessage); - if (latestMessage.phase === 'complete') { - loadingProgressTimeoutRef.current = setTimeout(() => { - setLoadingProgress(null); - loadingProgressTimeoutRef.current = null; - }, 500); - } - return; - } - - if (latestMessage.type === 'projects_updated') { - - // External Session Update Detection: Check if the changed file is the current session's JSONL - // If so, and the session is not active, trigger a message reload in ChatInterface - if (latestMessage.changedFile && selectedSession && selectedProject) { - // Extract session ID from changedFile (format: "project-name/session-id.jsonl") - const normalized = latestMessage.changedFile.replace(/\\/g, '/'); - const changedFileParts = normalized.split('/'); - - if (changedFileParts.length >= 2) { - const filename = changedFileParts[changedFileParts.length - 1]; - const changedSessionId = filename.replace('.jsonl', ''); - - // Check if this is the currently-selected session - if (changedSessionId === selectedSession.id) { - const isSessionActive = activeSessions.has(selectedSession.id); - - if (!isSessionActive) { - // Session is not active - safe to reload messages - setExternalMessageUpdate(prev => prev + 1); - } - } - } - } - - // Session Protection Logic: Allow additions but prevent changes during active conversations - // This allows new sessions/projects to appear in sidebar while protecting active chat messages - // We check for two types of active sessions: - // 1. Existing sessions: selectedSession.id exists in activeSessions - // 2. New sessions: temporary "new-session-*" identifiers in activeSessions (before real session ID is received) - const hasActiveSession = (selectedSession && activeSessions.has(selectedSession.id)) || - (activeSessions.size > 0 && Array.from(activeSessions).some(id => id.startsWith('new-session-'))); - - if (hasActiveSession) { - // Allow updates but be selective: permit additions, prevent changes to existing items - const updatedProjects = latestMessage.projects; - const currentProjects = projects; - - // Check if this is purely additive (new sessions/projects) vs modification of existing ones - const isAdditiveUpdate = isUpdateAdditive(currentProjects, updatedProjects, selectedProject, selectedSession); - - if (!isAdditiveUpdate) { - // Skip updates that would modify existing selected session/project - return; - } - // Continue with additive updates below - } - - // Update projects state with the new data from WebSocket - const updatedProjects = latestMessage.projects; - setProjects(updatedProjects); - - // Update selected project if it exists in the updated projects - if (selectedProject) { - const updatedSelectedProject = updatedProjects.find(p => p.name === selectedProject.name); - if (updatedSelectedProject) { - // Only update selected project if it actually changed - prevents flickering - if (JSON.stringify(updatedSelectedProject) !== JSON.stringify(selectedProject)) { - setSelectedProject(updatedSelectedProject); - } - - if (selectedSession) { - const allSessions = [ - ...(updatedSelectedProject.sessions || []), - ...(updatedSelectedProject.codexSessions || []), - ...(updatedSelectedProject.cursorSessions || []) - ]; - const updatedSelectedSession = allSessions.find(s => s.id === selectedSession.id); - if (!updatedSelectedSession) { - setSelectedSession(null); - } - } - } - } - } - } - - return () => { - if (loadingProgressTimeoutRef.current) { - clearTimeout(loadingProgressTimeoutRef.current); - loadingProgressTimeoutRef.current = null; - } - }; - }, [latestMessage, selectedProject, selectedSession, activeSessions]); - - const fetchProjects = async () => { - try { - setIsLoadingProjects(true); - const response = await api.projects(); - const data = await response.json(); - - // Always fetch Cursor sessions for each project so we can combine views - for (let project of data) { - try { - const url = `/api/cursor/sessions?projectPath=${encodeURIComponent(project.fullPath || project.path)}`; - const cursorResponse = await authenticatedFetch(url); - if (cursorResponse.ok) { - const cursorData = await cursorResponse.json(); - if (cursorData.success && cursorData.sessions) { - project.cursorSessions = cursorData.sessions; - } else { - project.cursorSessions = []; - } - } else { - project.cursorSessions = []; - } - } catch (error) { - console.error(`Error fetching Cursor sessions for project ${project.name}:`, error); - project.cursorSessions = []; - } - } - - // Optimize to preserve object references when data hasn't changed - setProjects(prevProjects => { - // If no previous projects, just set the new data - if (prevProjects.length === 0) { - return data; - } - - // Check if the projects data has actually changed - const hasChanges = data.some((newProject, index) => { - const prevProject = prevProjects[index]; - if (!prevProject) return true; - - // Compare key properties that would affect UI - return ( - newProject.name !== prevProject.name || - newProject.displayName !== prevProject.displayName || - newProject.fullPath !== prevProject.fullPath || - JSON.stringify(newProject.sessionMeta) !== JSON.stringify(prevProject.sessionMeta) || - JSON.stringify(newProject.sessions) !== JSON.stringify(prevProject.sessions) || - JSON.stringify(newProject.cursorSessions) !== JSON.stringify(prevProject.cursorSessions) - ); - }) || data.length !== prevProjects.length; - - // Only update if there are actual changes - return hasChanges ? data : prevProjects; - }); - - // Don't auto-select any project - user should choose manually - } catch (error) { - console.error('Error fetching projects:', error); - } finally { - setIsLoadingProjects(false); - } - }; - - // Expose fetchProjects globally for component access - window.refreshProjects = fetchProjects; - - // Expose openSettings function globally for component access - window.openSettings = useCallback((tab = 'tools') => { - setSettingsInitialTab(tab); - setShowSettings(true); - }, []); - - // Handle URL-based session loading - useEffect(() => { - if (sessionId && projects.length > 0) { - // Only switch tabs on initial load, not on every project update - const shouldSwitchTab = !selectedSession || selectedSession.id !== sessionId; - // Find the session across all projects - for (const project of projects) { - let session = project.sessions?.find(s => s.id === sessionId); - if (session) { - setSelectedProject(project); - setSelectedSession({ ...session, __provider: 'claude' }); - // Only switch to chat tab if we're loading a different session - if (shouldSwitchTab) { - setActiveTab('chat'); - } - return; - } - // Also check Cursor sessions - const cSession = project.cursorSessions?.find(s => s.id === sessionId); - if (cSession) { - setSelectedProject(project); - setSelectedSession({ ...cSession, __provider: 'cursor' }); - if (shouldSwitchTab) { - setActiveTab('chat'); - } - return; - } - } - - // If session not found, it might be a newly created session - // Just navigate to it and it will be found when the sidebar refreshes - // Don't redirect to home, let the session load naturally - } - }, [sessionId, projects, navigate]); - - const handleProjectSelect = (project) => { - setSelectedProject(project); - setSelectedSession(null); - navigate('/'); - if (isMobile) { - setSidebarOpen(false); - } - }; - - const handleSessionSelect = (session) => { - setSelectedSession(session); - // Only switch to chat tab when user explicitly selects a session - // This prevents tab switching during automatic updates - if (activeTab !== 'git' && activeTab !== 'preview') { - setActiveTab('chat'); - } - - // For Cursor sessions, we need to set the session ID differently - // since they're persistent and not created by Claude - const provider = localStorage.getItem('selected-provider') || 'claude'; - if (provider === 'cursor') { - // Cursor sessions have persistent IDs - sessionStorage.setItem('cursorSessionId', session.id); - } - - // Only close sidebar on mobile if switching to a different project - if (isMobile) { - const sessionProjectName = session.__projectName; - const currentProjectName = selectedProject?.name; - - // Close sidebar if clicking a session from a different project - // Keep it open if clicking a session from the same project - if (sessionProjectName !== currentProjectName) { - setSidebarOpen(false); - } - } - navigate(`/session/${session.id}`); - }; - - const handleNewSession = (project) => { - setSelectedProject(project); - setSelectedSession(null); - setActiveTab('chat'); - navigate('/'); - if (isMobile) { - setSidebarOpen(false); - } - }; - - const handleSessionDelete = (sessionId) => { - // If the deleted session was currently selected, clear it - if (selectedSession?.id === sessionId) { - setSelectedSession(null); - navigate('/'); - } - - // Update projects state locally instead of full refresh - setProjects(prevProjects => - prevProjects.map(project => ({ - ...project, - sessions: project.sessions?.filter(session => session.id !== sessionId) || [], - sessionMeta: { - ...project.sessionMeta, - total: Math.max(0, (project.sessionMeta?.total || 0) - 1) - } - })) - ); - }; - - - - const handleSidebarRefresh = async () => { - // Refresh only the sessions for all projects, don't change selected state - try { - const response = await api.projects(); - const freshProjects = await response.json(); - - // Optimize to preserve object references and minimize re-renders - setProjects(prevProjects => { - // Check if projects data has actually changed - const hasChanges = freshProjects.some((newProject, index) => { - const prevProject = prevProjects[index]; - if (!prevProject) return true; - - return ( - newProject.name !== prevProject.name || - newProject.displayName !== prevProject.displayName || - newProject.fullPath !== prevProject.fullPath || - JSON.stringify(newProject.sessionMeta) !== JSON.stringify(prevProject.sessionMeta) || - JSON.stringify(newProject.sessions) !== JSON.stringify(prevProject.sessions) - ); - }) || freshProjects.length !== prevProjects.length; - - return hasChanges ? freshProjects : prevProjects; - }); - - // If we have a selected project, make sure it's still selected after refresh - if (selectedProject) { - const refreshedProject = freshProjects.find(p => p.name === selectedProject.name); - if (refreshedProject) { - // Only update selected project if it actually changed - if (JSON.stringify(refreshedProject) !== JSON.stringify(selectedProject)) { - setSelectedProject(refreshedProject); - } - - // If we have a selected session, try to find it in the refreshed project - if (selectedSession) { - const refreshedSession = refreshedProject.sessions?.find(s => s.id === selectedSession.id); - if (refreshedSession && JSON.stringify(refreshedSession) !== JSON.stringify(selectedSession)) { - setSelectedSession(refreshedSession); - } - } - } - } - } catch (error) { - console.error('Error refreshing sidebar:', error); - } - }; - - const handleProjectDelete = (projectName) => { - // If the deleted project was currently selected, clear it - if (selectedProject?.name === projectName) { - setSelectedProject(null); - setSelectedSession(null); - navigate('/'); - } - - // Update projects state locally instead of full refresh - setProjects(prevProjects => - prevProjects.filter(project => project.name !== projectName) - ); - }; - - // Session Protection Functions: Manage the lifecycle of active sessions - - // markSessionAsActive: Called when user sends a message to mark session as protected - // This includes both real session IDs and temporary "new-session-*" identifiers - const markSessionAsActive = useCallback((sessionId) => { - if (sessionId) { - setActiveSessions(prev => new Set([...prev, sessionId])); - } - }, []); - - // markSessionAsInactive: Called when conversation completes/aborts to re-enable project updates - const markSessionAsInactive = useCallback((sessionId) => { - if (sessionId) { - setActiveSessions(prev => { - const newSet = new Set(prev); - newSet.delete(sessionId); - return newSet; - }); - } - }, []); - - // Processing Session Functions: Track which sessions are currently thinking/processing - - // markSessionAsProcessing: Called when Claude starts thinking/processing - const markSessionAsProcessing = useCallback((sessionId) => { - if (sessionId) { - setProcessingSessions(prev => new Set([...prev, sessionId])); - } - }, []); - - // markSessionAsNotProcessing: Called when Claude finishes thinking/processing - const markSessionAsNotProcessing = useCallback((sessionId) => { - if (sessionId) { - setProcessingSessions(prev => { - const newSet = new Set(prev); - newSet.delete(sessionId); - return newSet; - }); - } - }, []); - - // replaceTemporarySession: Called when WebSocket provides real session ID for new sessions - // Removes temporary "new-session-*" identifiers and adds the real session ID - // This maintains protection continuity during the transition from temporary to real session - const replaceTemporarySession = useCallback((realSessionId) => { - if (realSessionId) { - setActiveSessions(prev => { - const newSet = new Set(); - // Keep all non-temporary sessions and add the real session ID - for (const sessionId of prev) { - if (!sessionId.startsWith('new-session-')) { - newSet.add(sessionId); - } - } - newSet.add(realSessionId); - return newSet; - }); - } - }, []); - - // Version Upgrade Modal Component - const VersionUpgradeModal = () => { - const { t } = useTranslation('common'); - const [isUpdating, setIsUpdating] = useState(false); - const [updateOutput, setUpdateOutput] = useState(''); - const [updateError, setUpdateError] = useState(''); - - if (!showVersionModal) return null; - - // Clean up changelog by removing GitHub-specific metadata - const cleanChangelog = (body) => { - if (!body) return ''; - - return body - // Remove full commit hashes (40 character hex strings) - .replace(/\b[0-9a-f]{40}\b/gi, '') - // Remove short commit hashes (7-10 character hex strings at start of line or after dash/space) - .replace(/(?:^|\s|-)([0-9a-f]{7,10})\b/gi, '') - // Remove "Full Changelog" links - .replace(/\*\*Full Changelog\*\*:.*$/gim, '') - // Remove compare links (e.g., https://github.com/.../compare/v1.0.0...v1.0.1) - .replace(/https?:\/\/github\.com\/[^\/]+\/[^\/]+\/compare\/[^\s)]+/gi, '') - // Clean up multiple consecutive empty lines - .replace(/\n\s*\n\s*\n/g, '\n\n') - // Trim whitespace - .trim(); - }; - - const handleUpdateNow = async () => { - setIsUpdating(true); - setUpdateOutput('Starting update...\n'); - setUpdateError(''); - - try { - // Call the backend API to run the update command - const response = await authenticatedFetch('/api/system/update', { - method: 'POST', - }); - - const data = await response.json(); - - if (response.ok) { - setUpdateOutput(prev => prev + data.output + '\n'); - setUpdateOutput(prev => prev + '\n✅ Update completed successfully!\n'); - setUpdateOutput(prev => prev + 'Please restart the server to apply changes.\n'); - } else { - setUpdateError(data.error || 'Update failed'); - setUpdateOutput(prev => prev + '\n❌ Update failed: ' + (data.error || 'Unknown error') + '\n'); - } - } catch (error) { - setUpdateError(error.message); - setUpdateOutput(prev => prev + '\n❌ Update failed: ' + error.message + '\n'); - } finally { - setIsUpdating(false); - } - }; - - return ( -
- {/* Backdrop */} - -
- - {/* Version Info */} -
-
- {t('versionUpdate.currentVersion')} - {currentVersion} -
-
- {t('versionUpdate.latestVersion')} - {latestVersion} -
-
- - {/* Changelog */} - {releaseInfo?.body && ( -
-
-

{t('versionUpdate.whatsNew')}

- {releaseInfo?.htmlUrl && ( - - {t('versionUpdate.viewFullRelease')} - - - - - )} -
-
-
- {cleanChangelog(releaseInfo.body)} -
-
-
- )} - - {/* Update Output */} - {updateOutput && ( -
-

{t('versionUpdate.updateProgress')}

-
-
{updateOutput}
-
-
- )} - - {/* Upgrade Instructions */} - {!isUpdating && !updateOutput && ( -
-

{t('versionUpdate.manualUpgrade')}

-
- - git checkout main && git pull && npm install - -
-

- {t('versionUpdate.manualUpgradeHint')} -

-
- )} - - {/* Actions */} -
- - {!updateOutput && ( - <> - - - - )} -
- - - ); - }; - - return ( -
- {/* Fixed Desktop Sidebar */} - {!isMobile && ( -
-
- {sidebarVisible ? ( - setShowSettings(true)} - updateAvailable={updateAvailable} - latestVersion={latestVersion} - currentVersion={currentVersion} - releaseInfo={releaseInfo} - onShowVersionModal={() => setShowVersionModal(true)} - isPWA={isPWA} - isMobile={isMobile} - onToggleSidebar={() => setSidebarVisible(false)} - /> - ) : ( - /* Collapsed Sidebar */ -
- {/* Expand Button */} - - - {/* Settings Icon */} - - - {/* Update Indicator */} - {updateAvailable && ( - - )} -
- )} -
-
- )} - - {/* Mobile Sidebar Overlay */} - {isMobile && ( -
-
- )} - - {/* Main Content Area - Flexible */} -
- setSidebarOpen(true)} - isLoading={isLoadingProjects} - onInputFocusChange={setIsInputFocused} - onSessionActive={markSessionAsActive} - onSessionInactive={markSessionAsInactive} - onSessionProcessing={markSessionAsProcessing} - onSessionNotProcessing={markSessionAsNotProcessing} - processingSessions={processingSessions} - onReplaceTemporarySession={replaceTemporarySession} - onNavigateToSession={(sessionId) => navigate(`/session/${sessionId}`)} - onShowSettings={() => setShowSettings(true)} - autoExpandTools={autoExpandTools} - showRawParameters={showRawParameters} - showThinking={showThinking} - autoScrollToBottom={autoScrollToBottom} - sendByCtrlEnter={sendByCtrlEnter} - externalMessageUpdate={externalMessageUpdate} - /> -
- - {/* Mobile Bottom Navigation */} - {isMobile && ( - - )} - {/* Quick Settings Panel - Only show on chat tab */} - {activeTab === 'chat' && ( - - )} - - {/* Settings Modal */} - setShowSettings(false)} - projects={projects} - initialTab={settingsInitialTab} - /> - - {/* Version Upgrade Modal */} - -
- ); -} - -// Root App component with router -function App() { - return ( - - - - - - - - - - } /> - } /> - - - - - - - - - - ); -} - -export default App; diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..8593236 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,35 @@ +import { BrowserRouter as Router, Route, Routes } from 'react-router-dom'; +import { I18nextProvider } from 'react-i18next'; +import { ThemeProvider } from './contexts/ThemeContext'; +import { AuthProvider } from './contexts/AuthContext'; +import { TaskMasterProvider } from './contexts/TaskMasterContext'; +import { TasksSettingsProvider } from './contexts/TasksSettingsContext'; +import { WebSocketProvider } from './contexts/WebSocketContext'; +import ProtectedRoute from './components/ProtectedRoute'; +import AppContent from './components/app/AppContent'; +import i18n from './i18n/config.js'; + +export default function App() { + return ( + + + + + + + + + + } /> + } /> + + + + + + + + + + ); +} diff --git a/src/components/ChatInterface.jsx b/src/components/ChatInterface.jsx deleted file mode 100644 index d3d7bea..0000000 --- a/src/components/ChatInterface.jsx +++ /dev/null @@ -1,5692 +0,0 @@ -/* - * ChatInterface.jsx - Chat Component with Session Protection Integration - * - * SESSION PROTECTION INTEGRATION: - * =============================== - * - * This component integrates with the Session Protection System to prevent project updates - * from interrupting active conversations: - * - * Key Integration Points: - * 1. handleSubmit() - Marks session as active when user sends message (including temp ID for new sessions) - * 2. session-created handler - Replaces temporary session ID with real WebSocket session ID - * 3. claude-complete handler - Marks session as inactive when conversation finishes - * 4. session-aborted handler - Marks session as inactive when conversation is aborted - * - * This ensures uninterrupted chat experience by coordinating with App.jsx to pause sidebar updates. - */ - -import React, { useState, useEffect, useRef, useMemo, useCallback, useLayoutEffect, memo } from 'react'; -import ReactMarkdown from 'react-markdown'; -import remarkGfm from 'remark-gfm'; -import remarkMath from 'remark-math'; -import rehypeKatex from 'rehype-katex'; -import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; -import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism'; -import { useDropzone } from 'react-dropzone'; -import TodoList from './TodoList'; -import ClaudeLogo from './ClaudeLogo.jsx'; -import CursorLogo from './CursorLogo.jsx'; -import CodexLogo from './CodexLogo.jsx'; -import NextTaskBanner from './NextTaskBanner.jsx'; -import { useTasksSettings } from '../contexts/TasksSettingsContext'; -import { useTranslation } from 'react-i18next'; - -import ClaudeStatus from './ClaudeStatus'; -import TokenUsagePie from './TokenUsagePie'; -import { MicButton } from './MicButton.jsx'; -import { api, authenticatedFetch } from '../utils/api'; -import ThinkingModeSelector, { thinkingModes } from './ThinkingModeSelector.jsx'; -import Fuse from 'fuse.js'; -import CommandMenu from './CommandMenu'; -import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../shared/modelConstants'; - -import { safeJsonParse } from '../lib/utils.js'; - -// ! Move all utility functions to utils/chatUtils.ts - -// Helper function to decode HTML entities in text -function decodeHtmlEntities(text) { - if (!text) return text; - return text - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/"/g, '"') - .replace(/'/g, "'") - .replace(/&/g, '&'); -} - -// Normalize markdown text where providers mistakenly wrap short inline code with single-line triple fences. -// Only convert fences that do NOT contain any newline to avoid touching real code blocks. -function normalizeInlineCodeFences(text) { - if (!text || typeof text !== 'string') return text; - try { - // ```code``` -> `code` - return text.replace(/```\s*([^\n\r]+?)\s*```/g, '`$1`'); - } catch { - return text; - } -} - -// Unescape \n, \t, \r while protecting LaTeX formulas ($...$ and $$...$$) from being corrupted -function unescapeWithMathProtection(text) { - if (!text || typeof text !== 'string') return text; - - const mathBlocks = []; - const PLACEHOLDER_PREFIX = '__MATH_BLOCK_'; - const PLACEHOLDER_SUFFIX = '__'; - - // Extract and protect math formulas - let processedText = text.replace(/\$\$([\s\S]*?)\$\$|\$([^\$\n]+?)\$/g, (match) => { - const index = mathBlocks.length; - mathBlocks.push(match); - return `${PLACEHOLDER_PREFIX}${index}${PLACEHOLDER_SUFFIX}`; - }); - - // Process escape sequences on non-math content - processedText = processedText.replace(/\\n/g, '\n') - .replace(/\\t/g, '\t') - .replace(/\\r/g, '\r'); - - // Restore math formulas - processedText = processedText.replace( - new RegExp(`${PLACEHOLDER_PREFIX}(\\d+)${PLACEHOLDER_SUFFIX}`, 'g'), - (match, index) => { - return mathBlocks[parseInt(index)]; - } - ); - - return processedText; -} - -function escapeRegExp(value) { - return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - -// Small wrapper to keep markdown behavior consistent in one place -const Markdown = ({ children, className }) => { - const content = normalizeInlineCodeFences(String(children ?? '')); - const remarkPlugins = useMemo(() => [remarkGfm, remarkMath], []); - const rehypePlugins = useMemo(() => [rehypeKatex], []); - - return ( -
- - {content} - -
- ); -}; - -// Format "Claude AI usage limit reached|" into a local time string -function formatUsageLimitText(text) { - try { - if (typeof text !== 'string') return text; - return text.replace(/Claude AI usage limit reached\|(\d{10,13})/g, (match, ts) => { - let timestampMs = parseInt(ts, 10); - if (!Number.isFinite(timestampMs)) return match; - if (timestampMs < 1e12) timestampMs *= 1000; // seconds → ms - const reset = new Date(timestampMs); - - // Time HH:mm in local time - const timeStr = new Intl.DateTimeFormat(undefined, { - hour: '2-digit', - minute: '2-digit', - hour12: false - }).format(reset); - - // Human-readable timezone: GMT±HH[:MM] (City) - const offsetMinutesLocal = -reset.getTimezoneOffset(); - const sign = offsetMinutesLocal >= 0 ? '+' : '-'; - const abs = Math.abs(offsetMinutesLocal); - const offH = Math.floor(abs / 60); - const offM = abs % 60; - const gmt = `GMT${sign}${offH}${offM ? ':' + String(offM).padStart(2, '0') : ''}`; - const tzId = Intl.DateTimeFormat().resolvedOptions().timeZone || ''; - const cityRaw = tzId.split('/').pop() || ''; - const city = cityRaw - .replace(/_/g, ' ') - .toLowerCase() - .replace(/\b\w/g, c => c.toUpperCase()); - const tzHuman = city ? `${gmt} (${city})` : gmt; - - // Readable date like "8 Jun 2025" - const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; - const dateReadable = `${reset.getDate()} ${months[reset.getMonth()]} ${reset.getFullYear()}`; - - return `Claude usage limit reached. Your limit will reset at **${timeStr} ${tzHuman}** - ${dateReadable}`; - }); - } catch { - return text; - } -} - -// Safe localStorage utility to handle quota exceeded errors -const safeLocalStorage = { - setItem: (key, value) => { - try { - // For chat messages, implement compression and size limits - if (key.startsWith('chat_messages_') && typeof value === 'string') { - try { - const parsed = JSON.parse(value); - // Limit to last 50 messages to prevent storage bloat - if (Array.isArray(parsed) && parsed.length > 50) { - console.warn(`Truncating chat history for ${key} from ${parsed.length} to 50 messages`); - const truncated = parsed.slice(-50); - value = JSON.stringify(truncated); - } - } catch (parseError) { - console.warn('Could not parse chat messages for truncation:', parseError); - } - } - - localStorage.setItem(key, value); - } catch (error) { - if (error.name === 'QuotaExceededError') { - console.warn('localStorage quota exceeded, clearing old data'); - // Clear old chat messages to free up space - const keys = Object.keys(localStorage); - const chatKeys = keys.filter(k => k.startsWith('chat_messages_')).sort(); - - // Remove oldest chat data first, keeping only the 3 most recent projects - if (chatKeys.length > 3) { - chatKeys.slice(0, chatKeys.length - 3).forEach(k => { - localStorage.removeItem(k); - console.log(`Removed old chat data: ${k}`); - }); - } - - // If still failing, clear draft inputs too - const draftKeys = keys.filter(k => k.startsWith('draft_input_')); - draftKeys.forEach(k => { - localStorage.removeItem(k); - }); - - // Try again with reduced data - try { - localStorage.setItem(key, value); - } catch (retryError) { - console.error('Failed to save to localStorage even after cleanup:', retryError); - // Last resort: Try to save just the last 10 messages - if (key.startsWith('chat_messages_') && typeof value === 'string') { - try { - const parsed = JSON.parse(value); - if (Array.isArray(parsed) && parsed.length > 10) { - const minimal = parsed.slice(-10); - localStorage.setItem(key, JSON.stringify(minimal)); - console.warn('Saved only last 10 messages due to quota constraints'); - } - } catch (finalError) { - console.error('Final save attempt failed:', finalError); - } - } - } - } else { - console.error('localStorage error:', error); - } - } - }, - getItem: (key) => { - try { - return localStorage.getItem(key); - } catch (error) { - console.error('localStorage getItem error:', error); - return null; - } - }, - removeItem: (key) => { - try { - localStorage.removeItem(key); - } catch (error) { - console.error('localStorage removeItem error:', error); - } - } -}; - -const CLAUDE_SETTINGS_KEY = 'claude-settings'; - - -function getClaudeSettings() { - const raw = safeLocalStorage.getItem(CLAUDE_SETTINGS_KEY); - if (!raw) { - return { - allowedTools: [], - disallowedTools: [], - skipPermissions: false, - projectSortOrder: 'name' - }; - } - - try { - const parsed = JSON.parse(raw); - return { - ...parsed, - allowedTools: Array.isArray(parsed.allowedTools) ? parsed.allowedTools : [], - disallowedTools: Array.isArray(parsed.disallowedTools) ? parsed.disallowedTools : [], - skipPermissions: Boolean(parsed.skipPermissions), - projectSortOrder: parsed.projectSortOrder || 'name' - }; - } catch { - return { - allowedTools: [], - disallowedTools: [], - skipPermissions: false, - projectSortOrder: 'name' - }; - } -} - -function buildClaudeToolPermissionEntry(toolName, toolInput) { - if (!toolName) return null; - if (toolName !== 'Bash') return toolName; - - const parsed = safeJsonParse(toolInput); - const command = typeof parsed?.command === 'string' ? parsed.command.trim() : ''; - if (!command) return toolName; - - const tokens = command.split(/\s+/); - if (tokens.length === 0) return toolName; - - // For Bash, allow the command family instead of every Bash invocation. - if (tokens[0] === 'git' && tokens[1]) { - return `Bash(${tokens[0]} ${tokens[1]}:*)`; - } - return `Bash(${tokens[0]}:*)`; -} - -// Normalize tool inputs for display in the permission banner. -// This does not sanitize/redact secrets; it is strictly formatting so users -// can see the raw input that triggered the permission prompt. -function formatToolInputForDisplay(input) { - if (input === undefined || input === null) return ''; - if (typeof input === 'string') return input; - try { - return JSON.stringify(input, null, 2); - } catch { - return String(input); - } -} - -function getClaudePermissionSuggestion(message, provider) { - if (provider !== 'claude') return null; - if (!message?.toolResult?.isError) return null; - - const toolName = message?.toolName; - const entry = buildClaudeToolPermissionEntry(toolName, message.toolInput); - - if (!entry) return null; - - const settings = getClaudeSettings(); - const isAllowed = settings.allowedTools.includes(entry); - return { toolName, entry, isAllowed }; -} - -function grantClaudeToolPermission(entry) { - if (!entry) return { success: false }; - - const settings = getClaudeSettings(); - const alreadyAllowed = settings.allowedTools.includes(entry); - const nextAllowed = alreadyAllowed ? settings.allowedTools : [...settings.allowedTools, entry]; - const nextDisallowed = settings.disallowedTools.filter(tool => tool !== entry); - const updatedSettings = { - ...settings, - allowedTools: nextAllowed, - disallowedTools: nextDisallowed, - lastUpdated: new Date().toISOString() - }; - - safeLocalStorage.setItem(CLAUDE_SETTINGS_KEY, JSON.stringify(updatedSettings)); - return { success: true, alreadyAllowed, updatedSettings }; -} - -// Common markdown components to ensure consistent rendering (tables, inline code, links, etc.) -const CodeBlock = ({ node, inline, className, children, ...props }) => { - const { t } = useTranslation('chat'); - const [copied, setCopied] = React.useState(false); - const raw = Array.isArray(children) ? children.join('') : String(children ?? ''); - const looksMultiline = /[\r\n]/.test(raw); - const inlineDetected = inline || (node && node.type === 'inlineCode'); - const shouldInline = inlineDetected || !looksMultiline; // fallback to inline if single-line - - // Inline code rendering - if (shouldInline) { - return ( - - {children} - - ); - } - - // Extract language from className (format: language-xxx) - const match = /language-(\w+)/.exec(className || ''); - const language = match ? match[1] : 'text'; - const textToCopy = raw; - - const handleCopy = () => { - const doSet = () => { - setCopied(true); - setTimeout(() => setCopied(false), 1500); - }; - try { - if (navigator && navigator.clipboard && navigator.clipboard.writeText) { - navigator.clipboard.writeText(textToCopy).then(doSet).catch(() => { - // Fallback - const ta = document.createElement('textarea'); - ta.value = textToCopy; - ta.style.position = 'fixed'; - ta.style.opacity = '0'; - document.body.appendChild(ta); - ta.select(); - try { document.execCommand('copy'); } catch {} - document.body.removeChild(ta); - doSet(); - }); - } else { - const ta = document.createElement('textarea'); - ta.value = textToCopy; - ta.style.position = 'fixed'; - ta.style.opacity = '0'; - document.body.appendChild(ta); - ta.select(); - try { document.execCommand('copy'); } catch {} - document.body.removeChild(ta); - doSet(); - } - } catch {} - }; - - // Code block with syntax highlighting - return ( -
- {/* Language label */} - {language && language !== 'text' && ( -
- {language} -
- )} - - {/* Copy button */} - - - {/* Syntax highlighted code */} - - {raw} - -
- ); - }; - -// Common markdown components to ensure consistent rendering (tables, inline code, links, etc.) -const markdownComponents = { - code: CodeBlock, - blockquote: ({ children }) => ( -
- {children} -
- ), - a: ({ href, children }) => ( - - {children} - - ), - p: ({ children }) =>
{children}
, - // GFM tables - table: ({ children }) => ( -
- - {children} -
-
- ), - thead: ({ children }) => ( - {children} - ), - th: ({ children }) => ( - {children} - ), - td: ({ children }) => ( - {children} - ) -}; - -// Memoized message component to prevent unnecessary re-renders -const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFileOpen, onShowSettings, onGrantToolPermission, autoExpandTools, showRawParameters, showThinking, selectedProject, provider }) => { - const { t } = useTranslation('chat'); - const isGrouped = prevMessage && prevMessage.type === message.type && - ((prevMessage.type === 'assistant') || - (prevMessage.type === 'user') || - (prevMessage.type === 'tool') || - (prevMessage.type === 'error')); - const messageRef = React.useRef(null); - const [isExpanded, setIsExpanded] = React.useState(false); - const permissionSuggestion = getClaudePermissionSuggestion(message, provider); - const [permissionGrantState, setPermissionGrantState] = React.useState('idle'); - - React.useEffect(() => { - setPermissionGrantState('idle'); - }, [permissionSuggestion?.entry, message.toolId]); - - React.useEffect(() => { - if (!autoExpandTools || !messageRef.current || !message.isToolUse) return; - - const observer = new IntersectionObserver( - (entries) => { - entries.forEach((entry) => { - if (entry.isIntersecting && !isExpanded) { - setIsExpanded(true); - // Find all details elements and open them - const details = messageRef.current.querySelectorAll('details'); - details.forEach(detail => { - detail.open = true; - }); - } - }); - }, - { threshold: 0.1 } - ); - - observer.observe(messageRef.current); - - return () => { - if (messageRef.current) { - observer.unobserve(messageRef.current); - } - }; - }, [autoExpandTools, isExpanded, message.isToolUse]); - - return ( -
- {message.type === 'user' ? ( - /* User message bubble on the right */ -
-
-
- {message.content} -
- {message.images && message.images.length > 0 && ( -
- {message.images.map((img, idx) => ( - {img.name} window.open(img.data, '_blank')} - /> - ))} -
- )} -
- {new Date(message.timestamp).toLocaleTimeString()} -
-
- {!isGrouped && ( -
- U -
- )} -
- ) : ( - /* Claude/Error/Tool messages on the left */ -
- {!isGrouped && ( -
- {message.type === 'error' ? ( -
- ! -
- ) : message.type === 'tool' ? ( -
- 🔧 -
- ) : ( -
- {(localStorage.getItem('selected-provider') || 'claude') === 'cursor' ? ( - - ) : (localStorage.getItem('selected-provider') || 'claude') === 'codex' ? ( - - ) : ( - - )} -
- )} -
- {message.type === 'error' ? t('messageTypes.error') : message.type === 'tool' ? t('messageTypes.tool') : ((localStorage.getItem('selected-provider') || 'claude') === 'cursor' ? t('messageTypes.cursor') : (localStorage.getItem('selected-provider') || 'claude') === 'codex' ? t('messageTypes.codex') : t('messageTypes.claude'))} -
-
- )} - -
- - {message.isToolUse && !['Read', 'TodoWrite', 'TodoRead'].includes(message.toolName) ? ( - (() => { - // Minimize Grep and Glob tools since they happen frequently - const isSearchTool = ['Grep', 'Glob'].includes(message.toolName); - - if (isSearchTool) { - return ( - <> -
-
-
- - - - {message.toolName} - - {message.toolInput && (() => { - try { - const input = JSON.parse(message.toolInput); - return ( - - {input.pattern && {t('search.pattern')} {input.pattern}} - {input.path && {t('search.in')} {input.path}} - - ); - } catch (e) { - return null; - } - })()} -
- {message.toolResult && ( - - {t('tools.searchResults')} - - - - - )} -
-
- - ); - } - - // Full display for other tools - return ( -
- {/* Decorative gradient overlay */} -
- -
-
-
- - - - - {/* Subtle pulse animation */} -
-
-
- - {message.toolName} - - - {message.toolId} - -
-
- {onShowSettings && ( - - )} -
- {message.toolInput && message.toolName === 'Edit' && (() => { - try { - const input = JSON.parse(message.toolInput); - if (input.file_path && input.old_string && input.new_string) { - return ( -
- - - - - - View edit diff for - - - -
-
-
- - - Diff - -
-
- {createDiff(input.old_string, input.new_string).map((diffLine, i) => ( -
- - {diffLine.type === 'removed' ? '-' : '+'} - - - {diffLine.content} - -
- ))} -
-
- {showRawParameters && ( -
- - - - - View raw parameters - -
-                                  {message.toolInput}
-                                
-
- )} -
-
- ); - } - } catch (e) { - // Fall back to raw display if parsing fails - } - return ( -
- - - - - View input parameters - -
-                        {message.toolInput}
-                      
-
- ); - })()} - {message.toolInput && message.toolName !== 'Edit' && (() => { - // Debug log to see what we're dealing with - - // Special handling for Write tool - if (message.toolName === 'Write') { - try { - let input; - // Handle both JSON string and already parsed object - if (typeof message.toolInput === 'string') { - input = JSON.parse(message.toolInput); - } else { - input = message.toolInput; - } - - - if (input.file_path && input.content !== undefined) { - return ( -
- - - - - - 📄 - Creating new file: - - - -
-
-
- - - New File - -
-
- {createDiff('', input.content).map((diffLine, i) => ( -
- - {diffLine.type === 'removed' ? '-' : '+'} - - - {diffLine.content} - -
- ))} -
-
- {showRawParameters && ( -
- - - - - View raw parameters - -
-                                    {message.toolInput}
-                                  
-
- )} -
-
- ); - } - } catch (e) { - // Fall back to regular display - } - } - - // Special handling for TodoWrite tool - if (message.toolName === 'TodoWrite') { - try { - const input = JSON.parse(message.toolInput); - if (input.todos && Array.isArray(input.todos)) { - return ( -
- - - - - - - Updating Todo List - - -
- - {showRawParameters && ( -
- - - - - View raw parameters - -
-                                    {message.toolInput}
-                                  
-
- )} -
-
- ); - } - } catch (e) { - // Fall back to regular display - } - } - - // Special handling for Bash tool - if (message.toolName === 'Bash') { - try { - const input = JSON.parse(message.toolInput); - return ( -
-
- $ - {input.command} -
- {input.description && ( -
- {input.description} -
- )} -
- ); - } catch (e) { - // Fall back to regular display - } - } - - // Special handling for Read tool - if (message.toolName === 'Read') { - try { - const input = JSON.parse(message.toolInput); - if (input.file_path) { - const filename = input.file_path.split('/').pop(); - - return ( -
- Read{' '} - -
- ); - } - } catch (e) { - // Fall back to regular display - } - } - - // Special handling for exit_plan_mode tool - if (message.toolName === 'exit_plan_mode') { - try { - const input = JSON.parse(message.toolInput); - if (input.plan) { - // Replace escaped newlines with actual newlines - const planContent = input.plan.replace(/\\n/g, '\n'); - return ( -
- - - - - 📋 View implementation plan - - - {planContent} - -
- ); - } - } catch (e) { - // Fall back to regular display - } - } - - // Regular tool input display for other tools - return ( -
- - - - - View input parameters - -
-                        {message.toolInput}
-                      
-
- ); - })()} - - {/* Tool Result Section */} - {message.toolResult && (() => { - // Hide tool results for Edit/Write/Bash unless there's an error - const shouldHideResult = !message.toolResult.isError && - (message.toolName === 'Edit' || message.toolName === 'Write' || message.toolName === 'ApplyPatch' || message.toolName === 'Bash'); - - if (shouldHideResult) { - return null; - } - - return ( -
- {/* Decorative gradient overlay */} -
- -
-
- - {message.toolResult.isError ? ( - - ) : ( - - )} - -
- - {message.toolResult.isError ? 'Tool Error' : 'Tool Result'} - -
- -
- {(() => { - const content = String(message.toolResult.content || ''); - - // Special handling for TodoWrite/TodoRead results - if ((message.toolName === 'TodoWrite' || message.toolName === 'TodoRead') && - (content.includes('Todos have been modified successfully') || - content.includes('Todo list') || - (content.startsWith('[') && content.includes('"content"') && content.includes('"status"')))) { - try { - // Try to parse if it looks like todo JSON data - let todos = null; - if (content.startsWith('[')) { - todos = JSON.parse(content); - } else if (content.includes('Todos have been modified successfully')) { - // For TodoWrite success messages, we don't have the data in the result - return ( -
-
- Todo list has been updated successfully -
-
- ); - } - - if (todos && Array.isArray(todos)) { - return ( -
-
- Current Todo List -
- -
- ); - } - } catch (e) { - // Fall through to regular handling - } - } - - // Special handling for exit_plan_mode tool results - if (message.toolName === 'exit_plan_mode') { - try { - // The content should be JSON with a "plan" field - const parsed = JSON.parse(content); - if (parsed.plan) { - // Replace escaped newlines with actual newlines - const planContent = parsed.plan.replace(/\\n/g, '\n'); - return ( -
-
- Implementation Plan -
- - {planContent} - -
- ); - } - } catch (e) { - // Fall through to regular handling - } - } - - // Special handling for Grep/Glob results with structured data - if ((message.toolName === 'Grep' || message.toolName === 'Glob') && message.toolResult?.toolUseResult) { - const toolData = message.toolResult.toolUseResult; - - // Handle files_with_matches mode or any tool result with filenames array - if (toolData.filenames && Array.isArray(toolData.filenames) && toolData.filenames.length > 0) { - return ( -
-
- - Found {toolData.numFiles || toolData.filenames.length} {(toolData.numFiles === 1 || toolData.filenames.length === 1) ? 'file' : 'files'} - -
-
- {toolData.filenames.map((filePath, index) => { - const fileName = filePath.split('/').pop(); - const dirPath = filePath.substring(0, filePath.lastIndexOf('/')); - - return ( -
{ - if (onFileOpen) { - onFileOpen(filePath); - } - }} - className="group flex items-center gap-2 px-2 py-1.5 rounded hover:bg-green-100/50 dark:hover:bg-green-800/20 cursor-pointer transition-colors" - > - - - -
-
- {fileName} -
-
- {dirPath} -
-
- - - -
- ); - })} -
-
- ); - } - } - - // Special handling for interactive prompts - if (content.includes('Do you want to proceed?') && message.toolName === 'Bash') { - const lines = content.split('\n'); - const promptIndex = lines.findIndex(line => line.includes('Do you want to proceed?')); - const beforePrompt = lines.slice(0, promptIndex).join('\n'); - const promptLines = lines.slice(promptIndex); - - // Extract the question and options - const questionLine = promptLines.find(line => line.includes('Do you want to proceed?')) || ''; - const options = []; - - // Parse numbered options (1. Yes, 2. No, etc.) - promptLines.forEach(line => { - const optionMatch = line.match(/^\s*(\d+)\.\s+(.+)$/); - if (optionMatch) { - options.push({ - number: optionMatch[1], - text: optionMatch[2].trim() - }); - } - }); - - // Find which option was selected (usually indicated by "> 1" or similar) - const selectedMatch = content.match(/>\s*(\d+)/); - const selectedOption = selectedMatch ? selectedMatch[1] : null; - - return ( -
- {beforePrompt && ( -
-
{beforePrompt}
-
- )} -
-
-
- - - -
-
-

- Interactive Prompt -

-

- {questionLine} -

- - {/* Option buttons */} -
- {options.map((option) => ( - - ))} -
- - {selectedOption && ( -
-

- ✓ Claude selected option {selectedOption} -

-

- In the CLI, you would select this option interactively using arrow keys or by typing the number. -

-
- )} -
-
-
-
- ); - } - - const fileEditMatch = content.match(/The file (.+?) has been updated\./); - if (fileEditMatch) { - return ( -
-
- File updated successfully -
- -
- ); - } - - // Handle Write tool output for file creation - const fileCreateMatch = content.match(/(?:The file|File) (.+?) has been (?:created|written)(?: successfully)?\.?/); - if (fileCreateMatch) { - return ( -
-
- File created successfully -
- -
- ); - } - - // Special handling for Write tool - hide content if it's just the file content - if (message.toolName === 'Write' && !message.toolResult.isError) { - // For Write tool, the diff is already shown in the tool input section - // So we just show a success message here - return ( -
-
- - - - File written successfully -
-

- The file content is displayed in the diff view above -

-
- ); - } - - if (content.includes('cat -n') && content.includes('→')) { - return ( -
- - - - - View file content - -
-
- {content} -
-
-
- ); - } - - if (content.length > 300) { - return ( -
- - - - - View full output ({content.length} chars) - - - {content} - -
- ); - } - - return ( - - {content} - - ); - })()} - {permissionSuggestion && ( -
-
- - {onShowSettings && ( - - )} -
-
- Adds {permissionSuggestion.entry} to Allowed Tools. -
- {permissionGrantState === 'error' && ( -
- Unable to update permissions. Please try again. -
- )} - {(permissionSuggestion.isAllowed || permissionGrantState === 'granted') && ( -
- Permission saved. Retry the request to use the tool. -
- )} -
- )} -
-
- ); - })()} -
- ); - })() - ) : message.isInteractivePrompt ? ( - // Special handling for interactive prompts -
-
-
- - - -
-
-

- Interactive Prompt -

- {(() => { - const lines = message.content.split('\n').filter(line => line.trim()); - const questionLine = lines.find(line => line.includes('?')) || lines[0] || ''; - const options = []; - - // Parse the menu options - lines.forEach(line => { - // Match lines like "❯ 1. Yes" or " 2. No" - const optionMatch = line.match(/[❯\s]*(\d+)\.\s+(.+)/); - if (optionMatch) { - const isSelected = line.includes('❯'); - options.push({ - number: optionMatch[1], - text: optionMatch[2].trim(), - isSelected - }); - } - }); - - return ( - <> -

- {questionLine} -

- - {/* Option buttons */} -
- {options.map((option) => ( - - ))} -
- -
-

- ⏳ Waiting for your response in the CLI -

-

- Please select an option in your terminal where Claude is running. -

-
- - ); - })()} -
-
-
- ) : message.isToolUse && message.toolName === 'Read' ? ( - // Simple Read tool indicator - (() => { - try { - const input = JSON.parse(message.toolInput); - if (input.file_path) { - const filename = input.file_path.split('/').pop(); - return ( -
-
- - - - Read - -
-
- ); - } - } catch (e) { - return ( -
-
- - - - Read file -
-
- ); - } - })() - ) : message.isToolUse && message.toolName === 'TodoWrite' ? ( - // Simple TodoWrite tool indicator with tasks - (() => { - try { - const input = JSON.parse(message.toolInput); - if (input.todos && Array.isArray(input.todos)) { - return ( -
-
- - - - Update todo list -
- -
- ); - } - } catch (e) { - return ( -
-
- - - - Update todo list -
-
- ); - } - })() - ) : message.isToolUse && message.toolName === 'TodoRead' ? ( - // Simple TodoRead tool indicator -
-
- - - - Read todo list -
-
- ) : message.isThinking ? ( - /* Thinking messages - collapsible by default */ -
-
- - - - - 💭 Thinking... - -
- - {message.content} - -
-
-
- ) : ( -
- {/* Thinking accordion for reasoning */} - {showThinking && message.reasoning && ( -
- - 💭 Thinking... - -
-
- {message.reasoning} -
-
-
- )} - - {(() => { - const content = formatUsageLimitText(String(message.content || '')); - - // Detect if content is pure JSON (starts with { or [) - const trimmedContent = content.trim(); - if ((trimmedContent.startsWith('{') || trimmedContent.startsWith('[')) && - (trimmedContent.endsWith('}') || trimmedContent.endsWith(']'))) { - try { - const parsed = JSON.parse(trimmedContent); - const formatted = JSON.stringify(parsed, null, 2); - - return ( -
-
- - - - JSON Response -
-
-
-                              
-                                {formatted}
-                              
-                            
-
-
- ); - } catch (e) { - // Not valid JSON, fall through to normal rendering - } - } - - // Normal rendering for non-JSON content - return message.type === 'assistant' ? ( - - {content} - - ) : ( -
- {content} -
- ); - })()} -
- )} - -
- {new Date(message.timestamp).toLocaleTimeString()} -
-
-
- )} -
- ); -}); - -// ImageAttachment component for displaying image previews -const ImageAttachment = ({ file, onRemove, uploadProgress, error }) => { - const [preview, setPreview] = useState(null); - - useEffect(() => { - const url = URL.createObjectURL(file); - setPreview(url); - return () => URL.revokeObjectURL(url); - }, [file]); - - return ( -
- {file.name} - {uploadProgress !== undefined && uploadProgress < 100 && ( -
-
{uploadProgress}%
-
- )} - {error && ( -
- - - -
- )} - -
- ); -}; - -// ChatInterface: Main chat component with Session Protection System integration -// -// Session Protection System prevents automatic project updates from interrupting active conversations: -// - onSessionActive: Called when user sends message to mark session as protected -// - onSessionInactive: Called when conversation completes/aborts to re-enable updates -// - onReplaceTemporarySession: Called to replace temporary session ID with real WebSocket session ID -// -// This ensures uninterrupted chat experience by pausing sidebar refreshes during conversations. -function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, latestMessage, onFileOpen, onInputFocusChange, onSessionActive, onSessionInactive, onSessionProcessing, onSessionNotProcessing, processingSessions, onReplaceTemporarySession, onNavigateToSession, onShowSettings, autoExpandTools, showRawParameters, showThinking, autoScrollToBottom, sendByCtrlEnter, externalMessageUpdate, onTaskClick, onShowAllTasks }) { - const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings(); - const { t } = useTranslation('chat'); - const [input, setInput] = useState(() => { - if (typeof window !== 'undefined' && selectedProject) { - return safeLocalStorage.getItem(`draft_input_${selectedProject.name}`) || ''; - } - return ''; - }); - const [chatMessages, setChatMessages] = useState(() => { - if (typeof window !== 'undefined' && selectedProject) { - const saved = safeLocalStorage.getItem(`chat_messages_${selectedProject.name}`); - return saved ? JSON.parse(saved) : []; - } - return []; - }); - const [isLoading, setIsLoading] = useState(false); - const [currentSessionId, setCurrentSessionId] = useState(selectedSession?.id || null); - const [isInputFocused, setIsInputFocused] = useState(false); - const [sessionMessages, setSessionMessages] = useState([]); - const [isLoadingSessionMessages, setIsLoadingSessionMessages] = useState(false); - const [isLoadingMoreMessages, setIsLoadingMoreMessages] = useState(false); - const [messagesOffset, setMessagesOffset] = useState(0); - const [hasMoreMessages, setHasMoreMessages] = useState(false); - const [totalMessages, setTotalMessages] = useState(0); - const MESSAGES_PER_PAGE = 20; - const [isSystemSessionChange, setIsSystemSessionChange] = useState(false); - const [permissionMode, setPermissionMode] = useState('default'); - // In-memory queue of tool permission prompts for the current UI view. - // These are not persisted and do not survive a page refresh; introduced so - // the UI can present pending approvals while the SDK waits. - const [pendingPermissionRequests, setPendingPermissionRequests] = useState([]); - const [attachedImages, setAttachedImages] = useState([]); - const [uploadingImages, setUploadingImages] = useState(new Map()); - const [imageErrors, setImageErrors] = useState(new Map()); - const messagesEndRef = useRef(null); - const textareaRef = useRef(null); - const inputContainerRef = useRef(null); - const inputHighlightRef = useRef(null); - const scrollContainerRef = useRef(null); - const isLoadingSessionRef = useRef(false); // Track session loading to prevent multiple scrolls - const isLoadingMoreRef = useRef(false); - const topLoadLockRef = useRef(false); - const pendingScrollRestoreRef = useRef(null); - // Streaming throttle buffers - const streamBufferRef = useRef(''); - const streamTimerRef = useRef(null); - // Track the session that this view expects when starting a brand‑new chat - // (prevents background sessions from streaming into a different view). - const pendingViewSessionRef = useRef(null); - const commandQueryTimerRef = useRef(null); - const [debouncedInput, setDebouncedInput] = useState(''); - const [showFileDropdown, setShowFileDropdown] = useState(false); - const [fileList, setFileList] = useState([]); - const [fileMentions, setFileMentions] = useState([]); - const [filteredFiles, setFilteredFiles] = useState([]); - const [selectedFileIndex, setSelectedFileIndex] = useState(-1); - const [cursorPosition, setCursorPosition] = useState(0); - const [atSymbolPosition, setAtSymbolPosition] = useState(-1); - const [canAbortSession, setCanAbortSession] = useState(false); - const [isUserScrolledUp, setIsUserScrolledUp] = useState(false); - const scrollPositionRef = useRef({ height: 0, top: 0 }); - const [showCommandMenu, setShowCommandMenu] = useState(false); - const [slashCommands, setSlashCommands] = useState([]); - const [filteredCommands, setFilteredCommands] = useState([]); - const [commandQuery, setCommandQuery] = useState(''); - const [isTextareaExpanded, setIsTextareaExpanded] = useState(false); - const [tokenBudget, setTokenBudget] = useState(null); - const [selectedCommandIndex, setSelectedCommandIndex] = useState(-1); - const [slashPosition, setSlashPosition] = useState(-1); - const [visibleMessageCount, setVisibleMessageCount] = useState(100); - const [claudeStatus, setClaudeStatus] = useState(null); - const [thinkingMode, setThinkingMode] = useState('none'); - const [provider, setProvider] = useState(() => { - return localStorage.getItem('selected-provider') || 'claude'; - }); - const [cursorModel, setCursorModel] = useState(() => { - return localStorage.getItem('cursor-model') || CURSOR_MODELS.DEFAULT; - }); - const [claudeModel, setClaudeModel] = useState(() => { - return localStorage.getItem('claude-model') || CLAUDE_MODELS.DEFAULT; - }); - const [codexModel, setCodexModel] = useState(() => { - return localStorage.getItem('codex-model') || CODEX_MODELS.DEFAULT; - }); - // Track provider transitions so we only clear approvals when provider truly changes. - // This does not sync with the backend; it just prevents UI prompts from disappearing. - const lastProviderRef = useRef(provider); - - const resetStreamingState = useCallback(() => { - if (streamTimerRef.current) { - clearTimeout(streamTimerRef.current); - streamTimerRef.current = null; - } - streamBufferRef.current = ''; - }, []); - // Load permission mode for the current session - useEffect(() => { - if (selectedSession?.id) { - const savedMode = localStorage.getItem(`permissionMode-${selectedSession.id}`); - if (savedMode) { - setPermissionMode(savedMode); - } else { - setPermissionMode('default'); - } - } - }, [selectedSession?.id]); - - // When selecting a session from Sidebar, auto-switch provider to match session's origin - useEffect(() => { - if (selectedSession && selectedSession.__provider && selectedSession.__provider !== provider) { - setProvider(selectedSession.__provider); - localStorage.setItem('selected-provider', selectedSession.__provider); - } - }, [selectedSession]); - - // Clear pending permission prompts when switching providers; filter when switching sessions. - // This does not preserve prompts across provider changes; it exists to keep the - // Claude approval flow intact while preventing prompts from a different provider. - useEffect(() => { - if (lastProviderRef.current !== provider) { - setPendingPermissionRequests([]); - lastProviderRef.current = provider; - } - }, [provider]); - - // When the selected session changes, drop prompts that belong to other sessions. - // This does not attempt to migrate prompts across sessions; it only filters, - // introduced so the UI does not show approvals for a session the user is no longer viewing. - useEffect(() => { - setPendingPermissionRequests(prev => prev.filter(req => !req.sessionId || req.sessionId === selectedSession?.id)); - }, [selectedSession?.id]); - - // Load Cursor default model from config - useEffect(() => { - if (provider === 'cursor') { - authenticatedFetch('/api/cursor/config') - .then(res => res.json()) - .then(data => { - if (data.success && data.config?.model?.modelId) { - // Use the model from config directly - const modelId = data.config.model.modelId; - if (!localStorage.getItem('cursor-model')) { - setCursorModel(modelId); - } - } - }) - .catch(err => console.error('Error loading Cursor config:', err)); - } - }, [provider]); - - // Fetch slash commands on mount and when project changes - useEffect(() => { - const fetchCommands = async () => { - if (!selectedProject) 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(); - - // Combine built-in and custom commands - const allCommands = [ - ...(data.builtIn || []).map(cmd => ({ ...cmd, type: 'built-in' })), - ...(data.custom || []).map(cmd => ({ ...cmd, type: 'custom' })) - ]; - - setSlashCommands(allCommands); - - // Load command history from localStorage - const historyKey = `command_history_${selectedProject.name}`; - const history = safeLocalStorage.getItem(historyKey); - if (history) { - try { - const parsedHistory = JSON.parse(history); - // Sort commands by usage frequency - const sortedCommands = allCommands.sort((a, b) => { - const aCount = parsedHistory[a.name] || 0; - const bCount = parsedHistory[b.name] || 0; - return bCount - aCount; - }); - setSlashCommands(sortedCommands); - } catch (e) { - console.error('Error parsing command history:', e); - } - } - } catch (error) { - console.error('Error fetching slash commands:', error); - setSlashCommands([]); - } - }; - - fetchCommands(); - }, [selectedProject]); - - // Create Fuse instance for fuzzy search - 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]); - - // Filter commands based on query - useEffect(() => { - if (!commandQuery) { - setFilteredCommands(slashCommands); - return; - } - - if (!fuse) { - setFilteredCommands([]); - return; - } - - const results = fuse.search(commandQuery); - setFilteredCommands(results.map(result => result.item)); - }, [commandQuery, slashCommands, fuse]); - - // Calculate frequently used commands - const frequentCommands = useMemo(() => { - if (!selectedProject || slashCommands.length === 0) return []; - - const historyKey = `command_history_${selectedProject.name}`; - const history = safeLocalStorage.getItem(historyKey); - - if (!history) return []; - - try { - const parsedHistory = JSON.parse(history); - - // Sort commands by usage count - const commandsWithUsage = slashCommands - .map(cmd => ({ - ...cmd, - usageCount: parsedHistory[cmd.name] || 0 - })) - .filter(cmd => cmd.usageCount > 0) - .sort((a, b) => b.usageCount - a.usageCount) - .slice(0, 5); // Top 5 most used - - return commandsWithUsage; - } catch (e) { - console.error('Error parsing command history:', e); - return []; - } - }, [selectedProject, slashCommands]); - - // Command selection callback with history tracking - const handleCommandSelect = useCallback((command, index, isHover) => { - if (!command || !selectedProject) return; - - // If hovering, just update the selected index - if (isHover) { - setSelectedCommandIndex(index); - return; - } - - // Update command history - const historyKey = `command_history_${selectedProject.name}`; - const history = safeLocalStorage.getItem(historyKey); - let parsedHistory = {}; - - try { - parsedHistory = history ? JSON.parse(history) : {}; - } catch (e) { - console.error('Error parsing command history:', e); - } - - parsedHistory[command.name] = (parsedHistory[command.name] || 0) + 1; - safeLocalStorage.setItem(historyKey, JSON.stringify(parsedHistory)); - - // Execute the command - executeCommand(command); - }, [selectedProject]); - - // Execute a command - const handleBuiltInCommand = useCallback((result) => { - const { action, data } = result; - - switch (action) { - case 'clear': - // Clear conversation history - setChatMessages([]); - setSessionMessages([]); - break; - - case 'help': - // Show help content - setChatMessages(prev => [...prev, { - role: 'assistant', - content: data.content, - timestamp: Date.now() - }]); - break; - - case 'model': - // Show model information - setChatMessages(prev => [...prev, { - role: '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(prev => [...prev, { role: '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(prev => [...prev, { role: 'assistant', content: statusMessage, timestamp: Date.now() }]); - break; - } - case 'memory': - // Show memory file info - if (data.error) { - setChatMessages(prev => [...prev, { - role: 'assistant', - content: `⚠️ ${data.message}`, - timestamp: Date.now() - }]); - } else { - setChatMessages(prev => [...prev, { - role: 'assistant', - content: `📝 ${data.message}\n\nPath: \`${data.path}\``, - timestamp: Date.now() - }]); - // Optionally open file in editor - if (data.exists && onFileOpen) { - onFileOpen(data.path); - } - } - break; - - case 'config': - // Open settings - if (onShowSettings) { - onShowSettings(); - } - break; - - case 'rewind': - // Rewind conversation - if (data.error) { - setChatMessages(prev => [...prev, { - role: 'assistant', - content: `⚠️ ${data.message}`, - timestamp: Date.now() - }]); - } else { - // Remove last N messages - setChatMessages(prev => prev.slice(0, -data.steps * 2)); // Remove user + assistant pairs - setChatMessages(prev => [...prev, { - role: 'assistant', - content: `⏪ ${data.message}`, - timestamp: Date.now() - }]); - } - break; - - default: - console.warn('Unknown built-in command action:', action); - } - }, [onFileOpen, onShowSettings]); - - // Ref to store handleSubmit so we can call it from handleCustomCommand - const handleSubmitRef = useRef(null); - - // Handle custom command execution - const handleCustomCommand = useCallback(async (result, args) => { - const { content, hasBashCommands, hasFileIncludes } = result; - - // Show confirmation for bash commands - if (hasBashCommands) { - const confirmed = window.confirm( - 'This command contains bash commands that will be executed. Do you want to proceed?' - ); - if (!confirmed) { - setChatMessages(prev => [...prev, { - role: 'assistant', - content: '❌ Command execution cancelled', - timestamp: Date.now() - }]); - return; - } - } - - // Set the input to the command content - setInput(content); - - // Wait for state to update, then directly call handleSubmit - setTimeout(() => { - if (handleSubmitRef.current) { - // Create a fake event to pass to handleSubmit - const fakeEvent = { preventDefault: () => {} }; - handleSubmitRef.current(fakeEvent); - } - }, 50); - }, []); - const executeCommand = useCallback(async (command) => { - if (!command || !selectedProject) return; - - try { - // Parse command and arguments from current input - const commandMatch = input.match(new RegExp(`${command.name}\\s*(.*)`)); - const args = commandMatch && commandMatch[1] - ? commandMatch[1].trim().split(/\s+/) - : []; - - // Prepare context for command execution - const context = { - projectPath: selectedProject.path, - projectName: selectedProject.name, - sessionId: currentSessionId, - provider, - model: provider === 'cursor' ? cursorModel : claudeModel, - tokenUsage: tokenBudget - }; - - // Call the execute endpoint - const response = await authenticatedFetch('/api/commands/execute', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - commandName: command.name, - commandPath: command.path, - args, - context - }) - }); - - if (!response.ok) { - throw new Error('Failed to execute command'); - } - - const result = await response.json(); - - // Handle built-in commands - if (result.type === 'builtin') { - handleBuiltInCommand(result); - } else if (result.type === 'custom') { - // Handle custom commands - inject as system message - await handleCustomCommand(result, args); - } - - // Clear the input after successful execution - setInput(''); - setShowCommandMenu(false); - setSlashPosition(-1); - setCommandQuery(''); - setSelectedCommandIndex(-1); - - } catch (error) { - console.error('Error executing command:', error); - // Show error message to user - setChatMessages(prev => [...prev, { - role: 'assistant', - content: `Error executing command: ${error.message}`, - timestamp: Date.now() - }]); - } - }, [input, selectedProject, currentSessionId, provider, cursorModel, tokenBudget]); - - // Handle built-in command actions - - - // Memoized diff calculation to prevent recalculating on every render - const createDiff = useMemo(() => { - const cache = new Map(); - return (oldStr, newStr) => { - const key = `${oldStr.length}-${newStr.length}-${oldStr.slice(0, 50)}`; - if (cache.has(key)) { - return cache.get(key); - } - - const result = calculateDiff(oldStr, newStr); - cache.set(key, result); - if (cache.size > 100) { - const firstKey = cache.keys().next().value; - cache.delete(firstKey); - } - return result; - }; - }, []); - - // Load session messages from API with pagination - const loadSessionMessages = useCallback(async (projectName, sessionId, loadMore = false, provider = 'claude') => { - if (!projectName || !sessionId) return []; - - const isInitialLoad = !loadMore; - if (isInitialLoad) { - setIsLoadingSessionMessages(true); - } else { - setIsLoadingMoreMessages(true); - } - - try { - const currentOffset = loadMore ? messagesOffset : 0; - const response = await api.sessionMessages(projectName, sessionId, MESSAGES_PER_PAGE, currentOffset, provider); - if (!response.ok) { - throw new Error('Failed to load session messages'); - } - const data = await response.json(); - - // Extract token usage if present (Codex includes it in messages response) - if (isInitialLoad && data.tokenUsage) { - setTokenBudget(data.tokenUsage); - } - - // Handle paginated response - if (data.hasMore !== undefined) { - setHasMoreMessages(data.hasMore); - setTotalMessages(data.total); - setMessagesOffset(currentOffset + (data.messages?.length || 0)); - return data.messages || []; - } else { - // Backward compatibility for non-paginated response - const messages = data.messages || []; - setHasMoreMessages(false); - setTotalMessages(messages.length); - return messages; - } - } catch (error) { - console.error('Error loading session messages:', error); - return []; - } finally { - if (isInitialLoad) { - setIsLoadingSessionMessages(false); - } else { - setIsLoadingMoreMessages(false); - } - } - }, [messagesOffset]); - - // Load Cursor session messages from SQLite via backend - const loadCursorSessionMessages = useCallback(async (projectPath, sessionId) => { - if (!projectPath || !sessionId) return []; - setIsLoadingSessionMessages(true); - try { - const url = `/api/cursor/sessions/${encodeURIComponent(sessionId)}?projectPath=${encodeURIComponent(projectPath)}`; - const res = await authenticatedFetch(url); - if (!res.ok) return []; - const data = await res.json(); - const blobs = data?.session?.messages || []; - const converted = []; - const toolUseMap = {}; // Map to store tool uses by ID for linking results - - // First pass: process all messages maintaining order - for (let blobIdx = 0; blobIdx < blobs.length; blobIdx++) { - const blob = blobs[blobIdx]; - const content = blob.content; - let text = ''; - let role = 'assistant'; - let reasoningText = null; // Move to outer scope - try { - // Handle different Cursor message formats - if (content?.role && content?.content) { - // Direct format: {"role":"user","content":[{"type":"text","text":"..."}]} - // Skip system messages - if (content.role === 'system') { - continue; - } - - // Handle tool messages - if (content.role === 'tool') { - // Tool result format - find the matching tool use message and update it - if (Array.isArray(content.content)) { - for (const item of content.content) { - if (item?.type === 'tool-result') { - // Map ApplyPatch to Edit for consistency - let toolName = item.toolName || 'Unknown Tool'; - if (toolName === 'ApplyPatch') { - toolName = 'Edit'; - } - const toolCallId = item.toolCallId || content.id; - const result = item.result || ''; - - // Store the tool result to be linked later - if (toolUseMap[toolCallId]) { - toolUseMap[toolCallId].toolResult = { - content: result, - isError: false - }; - } else { - // No matching tool use found, create a standalone result message - converted.push({ - type: 'assistant', - content: '', - timestamp: new Date(Date.now() + blobIdx * 1000), - blobId: blob.id, - sequence: blob.sequence, - rowid: blob.rowid, - isToolUse: true, - toolName: toolName, - toolId: toolCallId, - toolInput: null, - toolResult: { - content: result, - isError: false - } - }); - } - } - } - } - continue; // Don't add tool messages as regular messages - } else { - // User or assistant messages - role = content.role === 'user' ? 'user' : 'assistant'; - - if (Array.isArray(content.content)) { - // Extract text, reasoning, and tool calls from content array - const textParts = []; - - for (const part of content.content) { - if (part?.type === 'text' && part?.text) { - textParts.push(decodeHtmlEntities(part.text)); - } else if (part?.type === 'reasoning' && part?.text) { - // Handle reasoning type - will be displayed in a collapsible section - reasoningText = decodeHtmlEntities(part.text); - } else if (part?.type === 'tool-call') { - // First, add any text/reasoning we've collected so far as a message - if (textParts.length > 0 || reasoningText) { - converted.push({ - type: role, - content: textParts.join('\n'), - reasoning: reasoningText, - timestamp: new Date(Date.now() + blobIdx * 1000), - blobId: blob.id, - sequence: blob.sequence, - rowid: blob.rowid - }); - textParts.length = 0; - reasoningText = null; - } - - // Tool call in assistant message - format like Claude Code - // Map ApplyPatch to Edit for consistency with Claude Code - let toolName = part.toolName || 'Unknown Tool'; - if (toolName === 'ApplyPatch') { - toolName = 'Edit'; - } - const toolId = part.toolCallId || `tool_${blobIdx}`; - - // Create a tool use message with Claude Code format - // Map Cursor args format to Claude Code format - let toolInput = part.args; - - if (toolName === 'Edit' && part.args) { - // ApplyPatch uses 'patch' format, convert to Edit format - if (part.args.patch) { - // Parse the patch to extract old and new content - const patchLines = part.args.patch.split('\n'); - let oldLines = []; - let newLines = []; - let inPatch = false; - - for (const line of patchLines) { - if (line.startsWith('@@')) { - inPatch = true; - } else if (inPatch) { - if (line.startsWith('-')) { - oldLines.push(line.substring(1)); - } else if (line.startsWith('+')) { - newLines.push(line.substring(1)); - } else if (line.startsWith(' ')) { - // Context line - add to both - oldLines.push(line.substring(1)); - newLines.push(line.substring(1)); - } - } - } - - const filePath = part.args.file_path; - const absolutePath = filePath && !filePath.startsWith('/') - ? `${projectPath}/${filePath}` - : filePath; - toolInput = { - file_path: absolutePath, - old_string: oldLines.join('\n') || part.args.patch, - new_string: newLines.join('\n') || part.args.patch - }; - } else { - // Direct edit format - toolInput = part.args; - } - } else if (toolName === 'Read' && part.args) { - // Map 'path' to 'file_path' - // Convert relative path to absolute if needed - const filePath = part.args.path || part.args.file_path; - const absolutePath = filePath && !filePath.startsWith('/') - ? `${projectPath}/${filePath}` - : filePath; - toolInput = { - file_path: absolutePath - }; - } else if (toolName === 'Write' && part.args) { - // Map fields for Write tool - const filePath = part.args.path || part.args.file_path; - const absolutePath = filePath && !filePath.startsWith('/') - ? `${projectPath}/${filePath}` - : filePath; - toolInput = { - file_path: absolutePath, - content: part.args.contents || part.args.content - }; - } - - const toolMessage = { - type: 'assistant', - content: '', - timestamp: new Date(Date.now() + blobIdx * 1000), - blobId: blob.id, - sequence: blob.sequence, - rowid: blob.rowid, - isToolUse: true, - toolName: toolName, - toolId: toolId, - toolInput: toolInput ? JSON.stringify(toolInput) : null, - toolResult: null // Will be filled when we get the tool result - }; - converted.push(toolMessage); - toolUseMap[toolId] = toolMessage; // Store for linking results - } else if (part?.type === 'tool_use') { - // Old format support - if (textParts.length > 0 || reasoningText) { - converted.push({ - type: role, - content: textParts.join('\n'), - reasoning: reasoningText, - timestamp: new Date(Date.now() + blobIdx * 1000), - blobId: blob.id, - sequence: blob.sequence, - rowid: blob.rowid - }); - textParts.length = 0; - reasoningText = null; - } - - const toolName = part.name || 'Unknown Tool'; - const toolId = part.id || `tool_${blobIdx}`; - - const toolMessage = { - type: 'assistant', - content: '', - timestamp: new Date(Date.now() + blobIdx * 1000), - blobId: blob.id, - sequence: blob.sequence, - rowid: blob.rowid, - isToolUse: true, - toolName: toolName, - toolId: toolId, - toolInput: part.input ? JSON.stringify(part.input) : null, - toolResult: null - }; - converted.push(toolMessage); - toolUseMap[toolId] = toolMessage; - } else if (typeof part === 'string') { - textParts.push(part); - } - } - - // Add any remaining text/reasoning - if (textParts.length > 0) { - text = textParts.join('\n'); - if (reasoningText && !text) { - // Just reasoning, no text - converted.push({ - type: role, - content: '', - reasoning: reasoningText, - timestamp: new Date(Date.now() + blobIdx * 1000), - blobId: blob.id, - sequence: blob.sequence, - rowid: blob.rowid - }); - text = ''; // Clear to avoid duplicate - } - } else { - text = ''; - } - } else if (typeof content.content === 'string') { - text = content.content; - } - } - } else if (content?.message?.role && content?.message?.content) { - // Nested message format - if (content.message.role === 'system') { - continue; - } - role = content.message.role === 'user' ? 'user' : 'assistant'; - if (Array.isArray(content.message.content)) { - text = content.message.content - .map(p => (typeof p === 'string' ? p : (p?.text || ''))) - .filter(Boolean) - .join('\n'); - } else if (typeof content.message.content === 'string') { - text = content.message.content; - } - } - } catch (e) { - console.log('Error parsing blob content:', e); - } - if (text && text.trim()) { - const message = { - type: role, - content: text, - timestamp: new Date(Date.now() + blobIdx * 1000), - blobId: blob.id, - sequence: blob.sequence, - rowid: blob.rowid - }; - - // Add reasoning if we have it - if (reasoningText) { - message.reasoning = reasoningText; - } - - converted.push(message); - } - } - - // Sort messages by sequence/rowid to maintain chronological order - converted.sort((a, b) => { - // First sort by sequence if available (clean 1,2,3... numbering) - if (a.sequence !== undefined && b.sequence !== undefined) { - return a.sequence - b.sequence; - } - // Then try rowid (original SQLite row IDs) - if (a.rowid !== undefined && b.rowid !== undefined) { - return a.rowid - b.rowid; - } - // Fallback to timestamp - return new Date(a.timestamp) - new Date(b.timestamp); - }); - - return converted; - } catch (e) { - console.error('Error loading Cursor session messages:', e); - return []; - } finally { - setIsLoadingSessionMessages(false); - } - }, []); - - // Actual diff calculation function - const calculateDiff = (oldStr, newStr) => { - const oldLines = oldStr.split('\n'); - const newLines = newStr.split('\n'); - - // Simple diff algorithm - find common lines and differences - const diffLines = []; - let oldIndex = 0; - let newIndex = 0; - - while (oldIndex < oldLines.length || newIndex < newLines.length) { - const oldLine = oldLines[oldIndex]; - const newLine = newLines[newIndex]; - - if (oldIndex >= oldLines.length) { - // Only new lines remaining - diffLines.push({ type: 'added', content: newLine, lineNum: newIndex + 1 }); - newIndex++; - } else if (newIndex >= newLines.length) { - // Only old lines remaining - diffLines.push({ type: 'removed', content: oldLine, lineNum: oldIndex + 1 }); - oldIndex++; - } else if (oldLine === newLine) { - // Lines are the same - skip in diff view (or show as context) - oldIndex++; - newIndex++; - } else { - // Lines are different - diffLines.push({ type: 'removed', content: oldLine, lineNum: oldIndex + 1 }); - diffLines.push({ type: 'added', content: newLine, lineNum: newIndex + 1 }); - oldIndex++; - newIndex++; - } - } - - return diffLines; - }; - - const convertSessionMessages = (rawMessages) => { - const converted = []; - const toolResults = new Map(); // Map tool_use_id to tool result - - // First pass: collect all tool results - for (const msg of rawMessages) { - if (msg.message?.role === 'user' && Array.isArray(msg.message?.content)) { - for (const part of msg.message.content) { - if (part.type === 'tool_result') { - toolResults.set(part.tool_use_id, { - content: part.content, - isError: part.is_error, - timestamp: new Date(msg.timestamp || Date.now()), - // Extract structured tool result data (e.g., for Grep, Glob) - toolUseResult: msg.toolUseResult || null - }); - } - } - } - } - - // Second pass: process messages and attach tool results to tool uses - for (const msg of rawMessages) { - // Handle user messages - if (msg.message?.role === 'user' && msg.message?.content) { - let content = ''; - let messageType = 'user'; - - if (Array.isArray(msg.message.content)) { - // Handle array content, but skip tool results (they're attached to tool uses) - const textParts = []; - - for (const part of msg.message.content) { - if (part.type === 'text') { - textParts.push(decodeHtmlEntities(part.text)); - } - // Skip tool_result parts - they're handled in the first pass - } - - content = textParts.join('\n'); - } else if (typeof msg.message.content === 'string') { - content = decodeHtmlEntities(msg.message.content); - } else { - content = decodeHtmlEntities(String(msg.message.content)); - } - - // Skip command messages, system messages, and empty content - const shouldSkip = !content || - content.startsWith('') || - content.startsWith('') || - content.startsWith('') || - content.startsWith('') || - content.startsWith('') || - content.startsWith('Caveat:') || - content.startsWith('This session is being continued from a previous') || - content.startsWith('[Request interrupted'); - - if (!shouldSkip) { - // Unescape with math formula protection - content = unescapeWithMathProtection(content); - converted.push({ - type: messageType, - content: content, - timestamp: msg.timestamp || new Date().toISOString() - }); - } - } - - // Handle thinking messages (Codex reasoning) - else if (msg.type === 'thinking' && msg.message?.content) { - converted.push({ - type: 'assistant', - content: unescapeWithMathProtection(msg.message.content), - timestamp: msg.timestamp || new Date().toISOString(), - isThinking: true - }); - } - - // Handle tool_use messages (Codex function calls) - else if (msg.type === 'tool_use' && msg.toolName) { - converted.push({ - type: 'assistant', - content: '', - timestamp: msg.timestamp || new Date().toISOString(), - isToolUse: true, - toolName: msg.toolName, - toolInput: msg.toolInput || '', - toolCallId: msg.toolCallId - }); - } - - // Handle tool_result messages (Codex function outputs) - else if (msg.type === 'tool_result') { - // Find the matching tool_use by callId, or the last tool_use without a result - for (let i = converted.length - 1; i >= 0; i--) { - if (converted[i].isToolUse && !converted[i].toolResult) { - if (!msg.toolCallId || converted[i].toolCallId === msg.toolCallId) { - converted[i].toolResult = { - content: msg.output || '', - isError: false - }; - break; - } - } - } - } - - // Handle assistant messages - else if (msg.message?.role === 'assistant' && msg.message?.content) { - if (Array.isArray(msg.message.content)) { - for (const part of msg.message.content) { - if (part.type === 'text') { - // Unescape with math formula protection - let text = part.text; - if (typeof text === 'string') { - text = unescapeWithMathProtection(text); - } - converted.push({ - type: 'assistant', - content: text, - timestamp: msg.timestamp || new Date().toISOString() - }); - } else if (part.type === 'tool_use') { - // Get the corresponding tool result - const toolResult = toolResults.get(part.id); - - converted.push({ - type: 'assistant', - content: '', - timestamp: msg.timestamp || new Date().toISOString(), - isToolUse: true, - toolName: part.name, - toolInput: JSON.stringify(part.input), - toolResult: toolResult ? { - content: typeof toolResult.content === 'string' ? toolResult.content : JSON.stringify(toolResult.content), - isError: toolResult.isError, - toolUseResult: toolResult.toolUseResult - } : null, - toolError: toolResult?.isError || false, - toolResultTimestamp: toolResult?.timestamp || new Date() - }); - } - } - } else if (typeof msg.message.content === 'string') { - // Unescape with math formula protection - let text = msg.message.content; - text = unescapeWithMathProtection(text); - converted.push({ - type: 'assistant', - content: text, - timestamp: msg.timestamp || new Date().toISOString() - }); - } - } - } - - return converted; - }; - - // Memoize expensive convertSessionMessages operation - const convertedMessages = useMemo(() => { - return convertSessionMessages(sessionMessages); - }, [sessionMessages]); - - // Note: Token budgets are not saved to JSONL files, only sent via WebSocket - // So we don't try to extract them from loaded sessionMessages - - // Define scroll functions early to avoid hoisting issues in useEffect dependencies - const scrollToBottom = useCallback(() => { - if (scrollContainerRef.current) { - scrollContainerRef.current.scrollTop = scrollContainerRef.current.scrollHeight; - // Don't reset isUserScrolledUp here - let the scroll handler manage it - // This prevents fighting with user's scroll position during streaming - } - }, []); - - // Check if user is near the bottom of the scroll container - const isNearBottom = useCallback(() => { - if (!scrollContainerRef.current) return false; - const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.current; - // Consider "near bottom" if within 50px of the bottom - return scrollHeight - scrollTop - clientHeight < 50; - }, []); - - const loadOlderMessages = useCallback(async (container) => { - if (!container || isLoadingMoreRef.current || isLoadingMoreMessages) return false; - if (!hasMoreMessages || !selectedSession || !selectedProject) return false; - - const sessionProvider = selectedSession.__provider || 'claude'; - if (sessionProvider === 'cursor') return false; - - isLoadingMoreRef.current = true; - const previousScrollHeight = container.scrollHeight; - const previousScrollTop = container.scrollTop; - - try { - const moreMessages = await loadSessionMessages( - selectedProject.name, - selectedSession.id, - true, - sessionProvider - ); - - if (moreMessages.length > 0) { - pendingScrollRestoreRef.current = { - height: previousScrollHeight, - top: previousScrollTop - }; - // Prepend new messages to the existing ones - setSessionMessages(prev => [...moreMessages, ...prev]); - } - return true; - } finally { - isLoadingMoreRef.current = false; - } - }, [hasMoreMessages, isLoadingMoreMessages, selectedSession, selectedProject, loadSessionMessages]); - - // Handle scroll events to detect when user manually scrolls up and load more messages - const handleScroll = useCallback(async () => { - if (scrollContainerRef.current) { - const container = scrollContainerRef.current; - const nearBottom = isNearBottom(); - setIsUserScrolledUp(!nearBottom); - - // Check if we should load more messages (scrolled near top) - const scrolledNearTop = container.scrollTop < 100; - if (!scrolledNearTop) { - topLoadLockRef.current = false; - } else if (!topLoadLockRef.current) { - const didLoad = await loadOlderMessages(container); - if (didLoad) { - topLoadLockRef.current = true; - } - } - } - }, [isNearBottom, loadOlderMessages]); - - // Restore scroll position after paginated messages render - 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(() => { - // Load session messages when session changes - const loadMessages = async () => { - if (selectedSession && selectedProject) { - const provider = localStorage.getItem('selected-provider') || 'claude'; - - // Mark that we're loading a session to prevent multiple scroll triggers - isLoadingSessionRef.current = true; - - // Only reset state if the session ID actually changed (not initial load) - const sessionChanged = currentSessionId !== null && currentSessionId !== selectedSession.id; - - if (sessionChanged) { - if (!isSystemSessionChange) { - // Clear any streaming leftovers from the previous session - resetStreamingState(); - pendingViewSessionRef.current = null; - setChatMessages([]); - setSessionMessages([]); - setClaudeStatus(null); - setCanAbortSession(false); - } - // Reset pagination state when switching sessions - setMessagesOffset(0); - setHasMoreMessages(false); - setTotalMessages(0); - // Reset token budget when switching sessions - // It will update when user sends a message and receives new budget from WebSocket - setTokenBudget(null); - // Reset loading state when switching sessions (unless the new session is processing) - // The restore effect will set it back to true if needed - setIsLoading(false); - - // Check if the session is currently processing on the backend - if (ws && sendMessage) { - sendMessage({ - type: 'check-session-status', - sessionId: selectedSession.id, - provider - }); - } - } else if (currentSessionId === null) { - // Initial load - reset pagination but not token budget - setMessagesOffset(0); - setHasMoreMessages(false); - setTotalMessages(0); - - // Check if the session is currently processing on the backend - if (ws && sendMessage) { - sendMessage({ - type: 'check-session-status', - sessionId: selectedSession.id, - provider - }); - } - } - - if (provider === 'cursor') { - // For Cursor, set the session ID for resuming - setCurrentSessionId(selectedSession.id); - sessionStorage.setItem('cursorSessionId', selectedSession.id); - - // Only load messages from SQLite if this is NOT a system-initiated session change - // For system-initiated changes, preserve existing messages - if (!isSystemSessionChange) { - // Load historical messages for Cursor session from SQLite - const projectPath = selectedProject.fullPath || selectedProject.path; - const converted = await loadCursorSessionMessages(projectPath, selectedSession.id); - setSessionMessages([]); - setChatMessages(converted); - } else { - // Reset the flag after handling system session change - setIsSystemSessionChange(false); - } - } else { - // For Claude, load messages normally with pagination - setCurrentSessionId(selectedSession.id); - - // Only load messages from API if this is a user-initiated session change - // For system-initiated changes, preserve existing messages and rely on WebSocket - if (!isSystemSessionChange) { - const messages = await loadSessionMessages(selectedProject.name, selectedSession.id, false, selectedSession.__provider || 'claude'); - setSessionMessages(messages); - // convertedMessages will be automatically updated via useMemo - // Scroll will be handled by the main scroll useEffect after messages are rendered - } else { - // Reset the flag after handling system session change - setIsSystemSessionChange(false); - } - } - } else { - // New session view (no selected session) - always reset UI state - if (!isSystemSessionChange) { - resetStreamingState(); - pendingViewSessionRef.current = null; - setChatMessages([]); - setSessionMessages([]); - setClaudeStatus(null); - setCanAbortSession(false); - setIsLoading(false); - } - setCurrentSessionId(null); - sessionStorage.removeItem('cursorSessionId'); - setMessagesOffset(0); - setHasMoreMessages(false); - setTotalMessages(0); - setTokenBudget(null); - } - - // Mark loading as complete after messages are set - // Use setTimeout to ensure state updates and DOM rendering are complete - setTimeout(() => { - isLoadingSessionRef.current = false; - }, 250); - }; - - loadMessages(); - }, [selectedSession, selectedProject, loadCursorSessionMessages, scrollToBottom, isSystemSessionChange, resetStreamingState]); - - // External Message Update Handler: Reload messages when external CLI modifies current session - // This triggers when App.jsx detects a JSONL file change for the currently-viewed session - // Only reloads if the session is NOT active (respecting Session Protection System) - useEffect(() => { - if (externalMessageUpdate > 0 && selectedSession && selectedProject) { - const reloadExternalMessages = async () => { - try { - const provider = localStorage.getItem('selected-provider') || 'claude'; - - if (provider === 'cursor') { - // Reload Cursor messages from SQLite - const projectPath = selectedProject.fullPath || selectedProject.path; - const converted = await loadCursorSessionMessages(projectPath, selectedSession.id); - setSessionMessages([]); - setChatMessages(converted); - } else { - // Reload Claude/Codex messages from API/JSONL - const messages = await loadSessionMessages(selectedProject.name, selectedSession.id, false, selectedSession.__provider || 'claude'); - setSessionMessages(messages); - // convertedMessages will be automatically updated via useMemo - - // Smart scroll behavior: only auto-scroll if user is near bottom - const shouldAutoScroll = autoScrollToBottom && isNearBottom(); - if (shouldAutoScroll) { - setTimeout(() => scrollToBottom(), 200); - } - // If user scrolled up, preserve their position (they're reading history) - } - } catch (error) { - console.error('Error reloading messages from external update:', error); - } - }; - - reloadExternalMessages(); - } - }, [externalMessageUpdate, selectedSession, selectedProject, loadCursorSessionMessages, loadSessionMessages, isNearBottom, autoScrollToBottom, scrollToBottom]); - - // When the user navigates to a specific session, clear any pending "new session" marker. - useEffect(() => { - if (selectedSession?.id) { - pendingViewSessionRef.current = null; - } - }, [selectedSession?.id]); - - // Update chatMessages when convertedMessages changes - useEffect(() => { - if (sessionMessages.length > 0) { - setChatMessages(convertedMessages); - } - }, [convertedMessages, sessionMessages]); - - // Notify parent when input focus changes - useEffect(() => { - if (onInputFocusChange) { - onInputFocusChange(isInputFocused); - } - }, [isInputFocused, onInputFocusChange]); - - // Persist input draft to localStorage - useEffect(() => { - if (selectedProject && input !== '') { - safeLocalStorage.setItem(`draft_input_${selectedProject.name}`, input); - } else if (selectedProject && input === '') { - safeLocalStorage.removeItem(`draft_input_${selectedProject.name}`); - } - }, [input, selectedProject]); - - // Persist chat messages to localStorage - useEffect(() => { - if (selectedProject && chatMessages.length > 0) { - safeLocalStorage.setItem(`chat_messages_${selectedProject.name}`, JSON.stringify(chatMessages)); - } - }, [chatMessages, selectedProject]); - - // Load saved state when project changes (but don't interfere with session loading) - useEffect(() => { - if (selectedProject) { - // Always load saved input draft for the project - const savedInput = safeLocalStorage.getItem(`draft_input_${selectedProject.name}`) || ''; - if (savedInput !== input) { - setInput(savedInput); - } - } - }, [selectedProject?.name]); - - // Track processing state: notify parent when isLoading becomes true - // Note: onSessionNotProcessing is called directly in completion message handlers - useEffect(() => { - if (currentSessionId && isLoading && onSessionProcessing) { - onSessionProcessing(currentSessionId); - } - }, [isLoading, currentSessionId, onSessionProcessing]); - - // Restore processing state when switching to a processing session - useEffect(() => { - if (currentSessionId && processingSessions) { - const shouldBeProcessing = processingSessions.has(currentSessionId); - if (shouldBeProcessing && !isLoading) { - setIsLoading(true); - setCanAbortSession(true); // Assume processing sessions can be aborted - } - } - }, [currentSessionId, processingSessions]); - - useEffect(() => { - // Handle WebSocket messages - if (latestMessage) { - const messageData = latestMessage.data?.message || latestMessage.data; - - // Filter messages by session ID to prevent cross-session interference - // Skip filtering for global messages that apply to all sessions - const globalMessageTypes = ['projects_updated', 'taskmaster-project-updated', 'session-created']; - const isGlobalMessage = globalMessageTypes.includes(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 || 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) => { - if (!sessionId) return; - if (onSessionInactive) { - onSessionInactive(sessionId); - } - if (onSessionNotProcessing) { - onSessionNotProcessing(sessionId); - } - }; - - if (!shouldBypassSessionFilter) { - if (!activeViewSessionId) { - // No session in view; ignore session-scoped traffic. - if (latestMessage.sessionId && lifecycleMessageTypes.has(latestMessage.type)) { - handleBackgroundLifecycle(latestMessage.sessionId); - } - if (!isUnscopedError) { - return; - } - } - if (!latestMessage.sessionId && !isUnscopedError) { - // Drop unscoped messages to prevent cross-session bleed. - return; - } - if (latestMessage.sessionId !== activeViewSessionId) { - if (latestMessage.sessionId && lifecycleMessageTypes.has(latestMessage.type)) { - handleBackgroundLifecycle(latestMessage.sessionId); - } - // Message is for a different session, ignore it - console.log('??-?,? Skipping message for different session:', latestMessage.sessionId, 'current:', activeViewSessionId); - return; - } - } - - switch (latestMessage.type) { - case 'session-created': - // New session created by Claude CLI - we receive the real session ID here - // Store it temporarily until conversation completes (prevents premature session association) - if (latestMessage.sessionId && !currentSessionId) { - sessionStorage.setItem('pendingSessionId', latestMessage.sessionId); - if (pendingViewSessionRef.current && !pendingViewSessionRef.current.sessionId) { - pendingViewSessionRef.current.sessionId = latestMessage.sessionId; - } - - // Mark as system change to prevent clearing messages when session ID updates - setIsSystemSessionChange(true); - - // Session Protection: Replace temporary "new-session-*" identifier with real session ID - // This maintains protection continuity - no gap between temp ID and real ID - // The temporary session is removed and real session is marked as active - if (onReplaceTemporarySession) { - onReplaceTemporarySession(latestMessage.sessionId); - } - - // Attach the real session ID to any pending permission requests so they - // do not disappear during the "new-session -> real-session" transition. - // This does not create or auto-approve requests; it only keeps UI state aligned. - setPendingPermissionRequests(prev => prev.map(req => ( - req.sessionId ? req : { ...req, sessionId: latestMessage.sessionId } - ))); - } - break; - - case 'token-budget': - // Use token budget from WebSocket for active sessions - if (latestMessage.data) { - setTokenBudget(latestMessage.data); - } - break; - - case 'claude-response': - - // Handle Cursor streaming format (content_block_delta / content_block_stop) - if (messageData && typeof messageData === 'object' && messageData.type) { - if (messageData.type === 'content_block_delta' && messageData.delta?.text) { - // Decode HTML entities and buffer deltas - const decodedText = decodeHtmlEntities(messageData.delta.text); - streamBufferRef.current += decodedText; - if (!streamTimerRef.current) { - streamTimerRef.current = setTimeout(() => { - const chunk = streamBufferRef.current; - streamBufferRef.current = ''; - streamTimerRef.current = null; - if (!chunk) return; - setChatMessages(prev => { - const updated = [...prev]; - const last = updated[updated.length - 1]; - if (last && last.type === 'assistant' && !last.isToolUse && last.isStreaming) { - last.content = (last.content || '') + chunk; - } else { - updated.push({ type: 'assistant', content: chunk, timestamp: new Date(), isStreaming: true }); - } - return updated; - }); - }, 100); - } - return; - } - if (messageData.type === 'content_block_stop') { - // Flush any buffered text and mark streaming message complete - if (streamTimerRef.current) { - clearTimeout(streamTimerRef.current); - streamTimerRef.current = null; - } - const chunk = streamBufferRef.current; - streamBufferRef.current = ''; - if (chunk) { - setChatMessages(prev => { - const updated = [...prev]; - const last = updated[updated.length - 1]; - if (last && last.type === 'assistant' && !last.isToolUse && last.isStreaming) { - last.content = (last.content || '') + chunk; - } else { - updated.push({ type: 'assistant', content: chunk, timestamp: new Date(), isStreaming: true }); - } - return updated; - }); - } - setChatMessages(prev => { - const updated = [...prev]; - const last = updated[updated.length - 1]; - if (last && last.type === 'assistant' && last.isStreaming) { - last.isStreaming = false; - } - return updated; - }); - return; - } - } - - // Handle Claude CLI session duplication bug workaround: - // When resuming a session, Claude CLI creates a new session instead of resuming. - // We detect this by checking for system/init messages with session_id that differs - // from our current session. When found, we need to switch the user to the new session. - // This works exactly like new session detection - preserve messages during navigation. - 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 - }); - - // Mark this as a system-initiated session change to preserve messages - // This works exactly like new session init - messages stay visible during navigation - setIsSystemSessionChange(true); - - // Switch to the new session using React Router navigation - // This triggers the session loading logic in App.jsx without a page reload - if (onNavigateToSession) { - onNavigateToSession(latestMessage.data.session_id); - } - return; // Don't process the message further, let the navigation handle it - } - - // Handle system/init for new sessions (when currentSessionId is null) - 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 - }); - - // Mark this as a system-initiated session change to preserve messages - setIsSystemSessionChange(true); - - // Switch to the new session - if (onNavigateToSession) { - onNavigateToSession(latestMessage.data.session_id); - } - return; // Don't process the message further, let the navigation handle it - } - - // For system/init messages that match current session, just ignore them - 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; // Don't process the message further - } - - // Handle different types of content in the response - if (Array.isArray(messageData.content)) { - for (const part of messageData.content) { - if (part.type === 'tool_use') { - // Add tool use message - const toolInput = part.input ? JSON.stringify(part.input, null, 2) : ''; - setChatMessages(prev => [...prev, { - type: 'assistant', - content: '', - timestamp: new Date(), - isToolUse: true, - toolName: part.name, - toolInput: toolInput, - toolId: part.id, - toolResult: null // Will be updated when result comes in - }]); - } else if (part.type === 'text' && part.text?.trim()) { - // Decode HTML entities and normalize usage limit message to local time - let content = decodeHtmlEntities(part.text); - content = formatUsageLimitText(content); - - // Add regular text message - setChatMessages(prev => [...prev, { - type: 'assistant', - content: content, - timestamp: new Date() - }]); - } - } - } else if (typeof messageData.content === 'string' && messageData.content.trim()) { - // Decode HTML entities and normalize usage limit message to local time - let content = decodeHtmlEntities(messageData.content); - content = formatUsageLimitText(content); - - // Add regular text message - setChatMessages(prev => [...prev, { - type: 'assistant', - content: content, - timestamp: new Date() - }]); - } - - // Handle tool results from user messages (these come separately) - if (messageData.role === 'user' && Array.isArray(messageData.content)) { - for (const part of messageData.content) { - if (part.type === 'tool_result') { - // Find the corresponding tool use and update it with the result - setChatMessages(prev => prev.map(msg => { - if (msg.isToolUse && msg.toolId === part.tool_use_id) { - return { - ...msg, - toolResult: { - content: part.content, - isError: part.is_error, - timestamp: new Date() - } - }; - } - return msg; - })); - } - } - } - break; - - case 'claude-output': - { - const cleaned = String(latestMessage.data || ''); - if (cleaned.trim()) { - streamBufferRef.current += (streamBufferRef.current ? `\n${cleaned}` : cleaned); - if (!streamTimerRef.current) { - streamTimerRef.current = setTimeout(() => { - const chunk = streamBufferRef.current; - streamBufferRef.current = ''; - streamTimerRef.current = null; - if (!chunk) return; - setChatMessages(prev => { - const updated = [...prev]; - const last = updated[updated.length - 1]; - if (last && last.type === 'assistant' && !last.isToolUse && last.isStreaming) { - last.content = last.content ? `${last.content}\n${chunk}` : chunk; - } else { - updated.push({ type: 'assistant', content: chunk, timestamp: new Date(), isStreaming: true }); - } - return updated; - }); - }, 100); - } - } - } - break; - case 'claude-interactive-prompt': - // Handle interactive prompts from CLI - setChatMessages(prev => [...prev, { - type: 'assistant', - content: latestMessage.data, - timestamp: new Date(), - isInteractivePrompt: true - }]); - break; - - case 'claude-permission-request': { - // Receive a tool approval request from the backend and surface it in the UI. - // This does not approve anything automatically; it only queues a prompt, - // introduced so the user can decide before the SDK continues. - if (provider !== 'claude' || !latestMessage.requestId) { - break; - } - - setPendingPermissionRequests(prev => { - if (prev.some(req => req.requestId === latestMessage.requestId)) { - return prev; - } - return [ - ...prev, - { - requestId: latestMessage.requestId, - toolName: latestMessage.toolName || 'UnknownTool', - input: latestMessage.input, - context: latestMessage.context, - sessionId: latestMessage.sessionId || null, - receivedAt: new Date() - } - ]; - }); - - // Keep the session in a "waiting" state while approval is pending. - // This does not resume the run; it only updates the UI status so the - // user knows Claude is blocked on a decision. - setIsLoading(true); - setCanAbortSession(true); - setClaudeStatus({ - text: 'Waiting for permission', - tokens: 0, - can_interrupt: true - }); - break; - } - - case 'claude-permission-cancelled': { - // Backend cancelled the approval (timeout or SDK cancel); remove the banner. - // We currently do not show a user-facing warning here; this is intentional - // to avoid noisy alerts when the SDK cancels in the background. - if (!latestMessage.requestId) { - break; - } - setPendingPermissionRequests(prev => prev.filter(req => req.requestId !== latestMessage.requestId)); - break; - } - - case 'claude-error': - setChatMessages(prev => [...prev, { - type: 'error', - content: `Error: ${latestMessage.error}`, - timestamp: new Date() - }]); - break; - - case 'cursor-system': - // Handle Cursor system/init messages similar to Claude - try { - const cdata = latestMessage.data; - if (cdata && cdata.type === 'system' && cdata.subtype === 'init' && cdata.session_id) { - if (!isSystemInitForView) { - return; - } - // If we already have a session and this differs, switch (duplication/redirect) - if (currentSessionId && cdata.session_id !== currentSessionId) { - console.log('🔄 Cursor session switch detected:', { originalSession: currentSessionId, newSession: cdata.session_id }); - setIsSystemSessionChange(true); - if (onNavigateToSession) { - onNavigateToSession(cdata.session_id); - } - return; - } - // If we don't yet have a session, adopt this one - if (!currentSessionId) { - console.log('🔄 Cursor new session init detected:', { newSession: cdata.session_id }); - setIsSystemSessionChange(true); - if (onNavigateToSession) { - onNavigateToSession(cdata.session_id); - } - return; - } - } - // For other cursor-system messages, avoid dumping raw objects to chat - } catch (e) { - console.warn('Error handling cursor-system message:', e); - } - break; - - case 'cursor-user': - // Handle Cursor user messages (usually echoes) - // Don't add user messages as they're already shown from input - break; - - case 'cursor-tool-use': - // Handle Cursor tool use messages - setChatMessages(prev => [...prev, { - 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': - // Show Cursor errors as error messages in chat - setChatMessages(prev => [...prev, { - type: 'error', - content: `Cursor error: ${latestMessage.error || 'Unknown error'}`, - timestamp: new Date() - }]); - break; - - case 'cursor-result': - // Get session ID from message or fall back to current session - const cursorCompletedSessionId = latestMessage.sessionId || currentSessionId; - - // Only update UI state if this is the current session - if (cursorCompletedSessionId === currentSessionId) { - setIsLoading(false); - setCanAbortSession(false); - setClaudeStatus(null); - } - - // Always mark the completed session as inactive and not processing - if (cursorCompletedSessionId) { - if (onSessionInactive) { - onSessionInactive(cursorCompletedSessionId); - } - if (onSessionNotProcessing) { - onSessionNotProcessing(cursorCompletedSessionId); - } - } - - // Only process result for current session - if (cursorCompletedSessionId === currentSessionId) { - try { - const r = latestMessage.data || {}; - const textResult = typeof r.result === 'string' ? r.result : ''; - // Flush buffered deltas before finalizing - if (streamTimerRef.current) { - clearTimeout(streamTimerRef.current); - streamTimerRef.current = null; - } - const pendingChunk = streamBufferRef.current; - streamBufferRef.current = ''; - - setChatMessages(prev => { - const updated = [...prev]; - // Try to consolidate into the last streaming assistant message - const last = updated[updated.length - 1]; - if (last && last.type === 'assistant' && !last.isToolUse && last.isStreaming) { - // Replace streaming content with the final content so deltas don't remain - const finalContent = textResult && textResult.trim() ? textResult : (last.content || '') + (pendingChunk || ''); - last.content = finalContent; - last.isStreaming = false; - } else if (textResult && textResult.trim()) { - updated.push({ type: r.is_error ? 'error' : 'assistant', content: textResult, timestamp: new Date(), isStreaming: false }); - } - return updated; - }); - } catch (e) { - console.warn('Error handling cursor-result message:', e); - } - } - - // Store session ID for future use and trigger refresh (for new sessions) - const pendingCursorSessionId = sessionStorage.getItem('pendingSessionId'); - if (cursorCompletedSessionId && !currentSessionId && cursorCompletedSessionId === pendingCursorSessionId) { - setCurrentSessionId(cursorCompletedSessionId); - sessionStorage.removeItem('pendingSessionId'); - - // Trigger a project refresh to update the sidebar with the new session - if (window.refreshProjects) { - setTimeout(() => window.refreshProjects(), 500); - } - } - break; - - case 'cursor-output': - // Handle Cursor raw terminal output; strip ANSI and ignore empty control-only payloads - 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 = setTimeout(() => { - const chunk = streamBufferRef.current; - streamBufferRef.current = ''; - streamTimerRef.current = null; - if (!chunk) return; - setChatMessages(prev => { - const updated = [...prev]; - const last = updated[updated.length - 1]; - if (last && last.type === 'assistant' && !last.isToolUse && last.isStreaming) { - last.content = last.content ? `${last.content}\n${chunk}` : chunk; - } else { - updated.push({ type: 'assistant', content: chunk, timestamp: new Date(), isStreaming: true }); - } - return updated; - }); - }, 100); - } - } - } catch (e) { - console.warn('Error handling cursor-output message:', e); - } - break; - - case 'claude-complete': - // Get session ID from message or fall back to current session - const completedSessionId = latestMessage.sessionId || currentSessionId || sessionStorage.getItem('pendingSessionId'); - - // Update UI state if this is the current session OR if we don't have a session ID yet (new session) - if (completedSessionId === currentSessionId || !currentSessionId) { - setIsLoading(false); - setCanAbortSession(false); - setClaudeStatus(null); - } - - // Always mark the completed session as inactive and not processing - if (completedSessionId) { - if (onSessionInactive) { - onSessionInactive(completedSessionId); - } - if (onSessionNotProcessing) { - onSessionNotProcessing(completedSessionId); - } - } - - // If we have a pending session ID and the conversation completed successfully, use it - const pendingSessionId = sessionStorage.getItem('pendingSessionId'); - if (pendingSessionId && !currentSessionId && latestMessage.exitCode === 0) { - setCurrentSessionId(pendingSessionId); - sessionStorage.removeItem('pendingSessionId'); - - // No need to manually refresh - projects_updated WebSocket message will handle it - console.log('✅ New session complete, ID set to:', pendingSessionId); - } - - // Clear persisted chat messages after successful completion - if (selectedProject && latestMessage.exitCode === 0) { - safeLocalStorage.removeItem(`chat_messages_${selectedProject.name}`); - } - // Conversation finished; clear any stale permission prompts. - // This does not remove saved permissions; it only resets transient UI state. - setPendingPermissionRequests([]); - break; - - case 'codex-response': - // Handle Codex SDK responses - const codexData = latestMessage.data; - if (codexData) { - // Handle item events - if (codexData.type === 'item') { - switch (codexData.itemType) { - case 'agent_message': - if (codexData.message?.content?.trim()) { - const content = decodeHtmlEntities(codexData.message.content); - setChatMessages(prev => [...prev, { - type: 'assistant', - content: content, - timestamp: new Date() - }]); - } - break; - - case 'reasoning': - if (codexData.message?.content?.trim()) { - const content = decodeHtmlEntities(codexData.message.content); - setChatMessages(prev => [...prev, { - type: 'assistant', - content: content, - timestamp: new Date(), - isThinking: true - }]); - } - break; - - case 'command_execution': - if (codexData.command) { - setChatMessages(prev => [...prev, { - 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(c => `${c.kind}: ${c.path}`).join('\n'); - setChatMessages(prev => [...prev, { - type: 'assistant', - content: '', - timestamp: new Date(), - isToolUse: true, - toolName: 'FileChanges', - toolInput: changesList, - toolResult: `Status: ${codexData.status}` - }]); - } - break; - - case 'mcp_tool_call': - setChatMessages(prev => [...prev, { - 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(prev => [...prev, { - type: 'error', - content: codexData.message.content, - timestamp: new Date() - }]); - } - break; - - default: - console.log('[Codex] Unhandled item type:', codexData.itemType, codexData); - } - } - - // Handle turn complete - if (codexData.type === 'turn_complete') { - // Turn completed, message stream done - setIsLoading(false); - } - - // Handle turn failed - if (codexData.type === 'turn_failed') { - setIsLoading(false); - setChatMessages(prev => [...prev, { - type: 'error', - content: codexData.error?.message || 'Turn failed', - timestamp: new Date() - }]); - } - } - break; - - case 'codex-complete': - // Handle Codex session completion - const codexCompletedSessionId = latestMessage.sessionId || currentSessionId || sessionStorage.getItem('pendingSessionId'); - - if (codexCompletedSessionId === currentSessionId || !currentSessionId) { - setIsLoading(false); - setCanAbortSession(false); - setClaudeStatus(null); - } - - if (codexCompletedSessionId) { - if (onSessionInactive) { - onSessionInactive(codexCompletedSessionId); - } - if (onSessionNotProcessing) { - onSessionNotProcessing(codexCompletedSessionId); - } - } - - const codexPendingSessionId = sessionStorage.getItem('pendingSessionId'); - const codexActualSessionId = latestMessage.actualSessionId || codexPendingSessionId; - if (codexPendingSessionId && !currentSessionId) { - setCurrentSessionId(codexActualSessionId); - setIsSystemSessionChange(true); - if (onNavigateToSession) { - 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': - // Handle Codex errors - setIsLoading(false); - setCanAbortSession(false); - setChatMessages(prev => [...prev, { - type: 'error', - content: latestMessage.error || 'An error occurred with Codex', - timestamp: new Date() - }]); - break; - - case 'session-aborted': { - // Get session ID from message or fall back to current session - const abortedSessionId = latestMessage.sessionId || currentSessionId; - - // Only update UI state if this is the current session - if (abortedSessionId === currentSessionId) { - setIsLoading(false); - setCanAbortSession(false); - setClaudeStatus(null); - } - - // Always mark the aborted session as inactive and not processing - if (abortedSessionId) { - if (onSessionInactive) { - onSessionInactive(abortedSessionId); - } - if (onSessionNotProcessing) { - onSessionNotProcessing(abortedSessionId); - } - } - - // Abort ends the run; clear permission prompts to avoid dangling UI state. - // This does not change allowlists; it only clears the current banner. - setPendingPermissionRequests([]); - - setChatMessages(prev => [...prev, { - type: 'assistant', - content: 'Session interrupted by user.', - timestamp: new Date() - }]); - break; - } - - case 'session-status': { - const statusSessionId = latestMessage.sessionId; - const isCurrentSession = statusSessionId === currentSessionId || - (selectedSession && statusSessionId === selectedSession.id); - if (isCurrentSession && latestMessage.isProcessing) { - // Session is currently processing, restore UI state - setIsLoading(true); - setCanAbortSession(true); - if (onSessionProcessing) { - onSessionProcessing(statusSessionId); - } - } - break; - } - - case 'claude-status': - // Handle Claude working status messages - const statusData = latestMessage.data; - if (statusData) { - // Parse the status message to extract relevant information - let statusInfo = { - text: 'Working...', - tokens: 0, - can_interrupt: true - }; - - // Check for different status message formats - if (statusData.message) { - statusInfo.text = statusData.message; - } else if (statusData.status) { - statusInfo.text = statusData.status; - } else if (typeof statusData === 'string') { - statusInfo.text = statusData; - } - - // Extract token count - if (statusData.tokens) { - statusInfo.tokens = statusData.tokens; - } else if (statusData.token_count) { - statusInfo.tokens = statusData.token_count; - } - - // Check if can interrupt - if (statusData.can_interrupt !== undefined) { - statusInfo.can_interrupt = statusData.can_interrupt; - } - - setClaudeStatus(statusInfo); - setIsLoading(true); - setCanAbortSession(statusInfo.can_interrupt); - } - break; - - } - } - }, [latestMessage]); - - // Load file list when project changes - useEffect(() => { - if (selectedProject) { - fetchProjectFiles(); - } - }, [selectedProject]); - - const fetchProjectFiles = async () => { - try { - const response = await api.getFiles(selectedProject.name); - if (response.ok) { - const files = await response.json(); - // Flatten the file tree to get all file paths - const flatFiles = flattenFileTree(files); - setFileList(flatFiles); - } - } catch (error) { - console.error('Error fetching files:', error); - } - }; - - const flattenFileTree = (files, basePath = '') => { - let result = []; - for (const file of files) { - const fullPath = basePath ? `${basePath}/${file.name}` : file.name; - if (file.type === 'directory' && file.children) { - result = result.concat(flattenFileTree(file.children, fullPath)); - } else if (file.type === 'file') { - result.push({ - name: file.name, - path: fullPath, - relativePath: file.path - }); - } - } - return result; - }; - - - // Handle @ symbol detection and file filtering - useEffect(() => { - const textBeforeCursor = input.slice(0, cursorPosition); - const lastAtIndex = textBeforeCursor.lastIndexOf('@'); - - if (lastAtIndex !== -1) { - const textAfterAt = textBeforeCursor.slice(lastAtIndex + 1); - // Check if there's a space after the @ symbol (which would end the file reference) - if (!textAfterAt.includes(' ')) { - setAtSymbolPosition(lastAtIndex); - setShowFileDropdown(true); - - // Filter files based on the text after @ - const filtered = fileList.filter(file => - file.name.toLowerCase().includes(textAfterAt.toLowerCase()) || - file.path.toLowerCase().includes(textAfterAt.toLowerCase()) - ).slice(0, 10); // Limit to 10 results - - setFilteredFiles(filtered); - setSelectedFileIndex(-1); - } else { - setShowFileDropdown(false); - setAtSymbolPosition(-1); - } - } else { - setShowFileDropdown(false); - setAtSymbolPosition(-1); - } - }, [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 unique = Array.from(new Set(activeFileMentions)); - return unique.sort((a, b) => b.length - a.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) => { - if (!text) return ''; - if (!fileMentionRegex) return text; - const parts = text.split(fileMentionRegex); - return parts.map((part, index) => ( - fileMentionSet.has(part) ? ( - - {part} - - ) : ( - {part} - ) - )); - }, [fileMentionRegex, fileMentionSet]); - - // Debounced input handling - useEffect(() => { - const timer = setTimeout(() => { - setDebouncedInput(input); - }, 150); // 150ms debounce - - return () => clearTimeout(timer); - }, [input]); - - // Show only recent messages for better performance - const visibleMessages = useMemo(() => { - if (chatMessages.length <= visibleMessageCount) { - return chatMessages; - } - return chatMessages.slice(-visibleMessageCount); - }, [chatMessages, visibleMessageCount]); - - // Capture scroll position before render when auto-scroll is disabled - useEffect(() => { - if (!autoScrollToBottom && scrollContainerRef.current) { - const container = scrollContainerRef.current; - scrollPositionRef.current = { - height: container.scrollHeight, - top: container.scrollTop - }; - } - }); - - useEffect(() => { - // Auto-scroll to bottom when new messages arrive - if (scrollContainerRef.current && chatMessages.length > 0) { - if (autoScrollToBottom) { - // If auto-scroll is enabled, always scroll to bottom unless user has manually scrolled up - if (!isUserScrolledUp) { - setTimeout(() => scrollToBottom(), 50); // Small delay to ensure DOM is updated - } - } else { - // When auto-scroll is disabled, preserve the visual position - const container = scrollContainerRef.current; - const prevHeight = scrollPositionRef.current.height; - const prevTop = scrollPositionRef.current.top; - const newHeight = container.scrollHeight; - const heightDiff = newHeight - prevHeight; - - // If content was added above the current view, adjust scroll position - if (heightDiff > 0 && prevTop > 0) { - container.scrollTop = prevTop + heightDiff; - } - } - } - }, [chatMessages.length, isUserScrolledUp, scrollToBottom, autoScrollToBottom]); - - // Scroll to bottom when messages first load after session switch - useEffect(() => { - if (scrollContainerRef.current && chatMessages.length > 0 && !isLoadingSessionRef.current) { - // Only scroll if we're not in the middle of loading a session - // This prevents the "double scroll" effect during session switching - // Reset scroll state when switching sessions - setIsUserScrolledUp(false); - setTimeout(() => { - scrollToBottom(); - // After scrolling, the scroll event handler will naturally set isUserScrolledUp based on position - }, 200); // Delay to ensure full rendering - } - }, [selectedSession?.id, selectedProject?.name]); // Only trigger when session/project changes - - // Add scroll event listener to detect user scrolling - useEffect(() => { - const scrollContainer = scrollContainerRef.current; - if (scrollContainer) { - scrollContainer.addEventListener('scroll', handleScroll); - return () => scrollContainer.removeEventListener('scroll', handleScroll); - } - }, [handleScroll]); - - // Initial textarea setup - set to 2 rows height - useEffect(() => { - if (textareaRef.current) { - textareaRef.current.style.height = 'auto'; - textareaRef.current.style.height = textareaRef.current.scrollHeight + 'px'; - - // Check if initially expanded - const lineHeight = parseInt(window.getComputedStyle(textareaRef.current).lineHeight); - const isExpanded = textareaRef.current.scrollHeight > lineHeight * 2; - setIsTextareaExpanded(isExpanded); - } - }, []); // Only run once on mount - - // Reset textarea height when input is cleared programmatically - useEffect(() => { - if (textareaRef.current && !input.trim()) { - textareaRef.current.style.height = 'auto'; - setIsTextareaExpanded(false); - } - }, [input]); - - // Load token usage when session changes for Claude sessions only - // (Codex token usage is included in messages response, Cursor doesn't support it) - useEffect(() => { - if (!selectedProject || !selectedSession?.id || selectedSession.id.startsWith('new-session-')) { - setTokenBudget(null); - return; - } - - const sessionProvider = selectedSession.__provider || 'claude'; - - // Skip for Codex (included in messages) and Cursor (not supported) - if (sessionProvider !== 'claude') { - return; - } - - // Fetch token usage for Claude sessions - 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(); - }, [selectedSession?.id, selectedSession?.__provider, selectedProject?.path]); - - const handleTranscript = useCallback((text) => { - if (text.trim()) { - setInput(prevInput => { - const newInput = prevInput.trim() ? `${prevInput} ${text}` : text; - - // Update textarea height after setting new content - setTimeout(() => { - if (textareaRef.current) { - textareaRef.current.style.height = 'auto'; - textareaRef.current.style.height = textareaRef.current.scrollHeight + 'px'; - - // Check if expanded after transcript - const lineHeight = parseInt(window.getComputedStyle(textareaRef.current).lineHeight); - const isExpanded = textareaRef.current.scrollHeight > lineHeight * 2; - setIsTextareaExpanded(isExpanded); - } - }, 0); - - return newInput; - }); - } - }, []); - - // Load earlier messages by increasing the visible message count - const loadEarlierMessages = useCallback(() => { - setVisibleMessageCount(prevCount => prevCount + 100); - }, []); - - // Handle image files from drag & drop or file picker - const handleImageFiles = useCallback((files) => { - const validFiles = files.filter(file => { - try { - // Validate file object and properties - 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) { - // Safely get file name with fallback - const fileName = file.name || 'Unknown file'; - setImageErrors(prev => { - const newMap = new Map(prev); - newMap.set(fileName, 'File too large (max 5MB)'); - return newMap; - }); - return false; - } - - return true; - } catch (error) { - console.error('Error validating file:', error, file); - return false; - } - }); - - if (validFiles.length > 0) { - setAttachedImages(prev => [...prev, ...validFiles].slice(0, 5)); // Max 5 images - } - }, []); - - // Handle clipboard paste for images - const handlePaste = useCallback(async (e) => { - const items = Array.from(e.clipboardData.items); - - for (const item of items) { - if (item.type.startsWith('image/')) { - const file = item.getAsFile(); - if (file) { - handleImageFiles([file]); - } - } - } - - // Fallback for some browsers/platforms - if (items.length === 0 && e.clipboardData.files.length > 0) { - const files = Array.from(e.clipboardData.files); - const imageFiles = files.filter(f => f.type.startsWith('image/')); - if (imageFiles.length > 0) { - handleImageFiles(imageFiles); - } - } - }, [handleImageFiles]); - - // Setup dropzone - const { getRootProps, getInputProps, isDragActive, open } = useDropzone({ - accept: { - 'image/*': ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'] - }, - maxSize: 5 * 1024 * 1024, // 5MB - maxFiles: 5, - onDrop: handleImageFiles, - noClick: true, // We'll use our own button - noKeyboard: true - }); - - const handleSubmit = useCallback(async (e) => { - e.preventDefault(); - if (!input.trim() || isLoading || !selectedProject) return; - - // Apply thinking mode prefix if selected - let messageContent = input; - const selectedThinkingMode = thinkingModes.find(mode => mode.id === thinkingMode); - if (selectedThinkingMode && selectedThinkingMode.prefix) { - messageContent = `${selectedThinkingMode.prefix}: ${input}`; - } - - // Upload images first if any - let uploadedImages = []; - 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: {}, // Let browser set Content-Type for FormData - body: formData - }); - - if (!response.ok) { - throw new Error('Failed to upload images'); - } - - const result = await response.json(); - uploadedImages = result.images; - } catch (error) { - console.error('Image upload failed:', error); - setChatMessages(prev => [...prev, { - type: 'error', - content: `Failed to upload images: ${error.message}`, - timestamp: new Date() - }]); - return; - } - } - - const userMessage = { - type: 'user', - content: input, - images: uploadedImages, - timestamp: new Date() - }; - - setChatMessages(prev => [...prev, userMessage]); - setIsLoading(true); - setCanAbortSession(true); - // Set a default status when starting - setClaudeStatus({ - text: 'Processing', - tokens: 0, - can_interrupt: true - }); - - // Always scroll to bottom when user sends a message and reset scroll state - setIsUserScrolledUp(false); // Reset scroll state so auto-scroll works for Claude's response - setTimeout(() => scrollToBottom(), 100); // Longer delay to ensure message is rendered - - // Determine effective session id for replies to avoid race on state updates - const effectiveSessionId = currentSessionId || selectedSession?.id || sessionStorage.getItem('cursorSessionId'); - - // Session Protection: Mark session as active to prevent automatic project updates during conversation - // Use existing session if available; otherwise a temporary placeholder until backend provides real ID - const sessionToActivate = effectiveSessionId || `new-session-${Date.now()}`; - if (!effectiveSessionId && !selectedSession?.id) { - // We are starting a brand-new session in this view. Track it so we only - // accept streaming updates for this run. - pendingViewSessionRef.current = { sessionId: null, startedAt: Date.now() }; - } - if (onSessionActive) { - onSessionActive(sessionToActivate); - } - - // Get tools settings from localStorage based on provider - 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(); - - // Send command based on provider - if (provider === 'cursor') { - // Send Cursor command (always use cursor-command; include resume/sessionId when replying) - sendMessage({ - type: 'cursor-command', - command: messageContent, - sessionId: effectiveSessionId, - options: { - // Prefer fullPath (actual cwd for project), fallback to path - cwd: selectedProject.fullPath || selectedProject.path, - projectPath: selectedProject.fullPath || selectedProject.path, - sessionId: effectiveSessionId, - resume: !!effectiveSessionId, - model: cursorModel, - skipPermissions: toolsSettings?.skipPermissions || false, - toolsSettings: toolsSettings - } - }); - } else if (provider === 'codex') { - // Send Codex command - sendMessage({ - type: 'codex-command', - command: messageContent, - sessionId: effectiveSessionId, - options: { - cwd: selectedProject.fullPath || selectedProject.path, - projectPath: selectedProject.fullPath || selectedProject.path, - sessionId: effectiveSessionId, - resume: !!effectiveSessionId, - model: codexModel, - permissionMode: permissionMode === 'plan' ? 'default' : permissionMode - } - }); - } else { - // Send Claude command (existing code) - sendMessage({ - type: 'claude-command', - command: messageContent, - options: { - projectPath: selectedProject.path, - cwd: selectedProject.fullPath, - sessionId: currentSessionId, - resume: !!currentSessionId, - toolsSettings: toolsSettings, - permissionMode: permissionMode, - model: claudeModel, - images: uploadedImages // Pass images to backend - } - }); - } - - setInput(''); - setAttachedImages([]); - setUploadingImages(new Map()); - setImageErrors(new Map()); - setIsTextareaExpanded(false); - setThinkingMode('none'); // Reset thinking mode after sending - - // Reset textarea height - if (textareaRef.current) { - textareaRef.current.style.height = 'auto'; - } - - // Clear the saved draft since message was sent - if (selectedProject) { - safeLocalStorage.removeItem(`draft_input_${selectedProject.name}`); - } - }, [input, isLoading, selectedProject, attachedImages, currentSessionId, selectedSession, provider, permissionMode, onSessionActive, cursorModel, claudeModel, codexModel, sendMessage, setInput, setAttachedImages, setUploadingImages, setImageErrors, setIsTextareaExpanded, textareaRef, setChatMessages, setIsLoading, setCanAbortSession, setClaudeStatus, setIsUserScrolledUp, scrollToBottom, thinkingMode]); - - const handleGrantToolPermission = useCallback((suggestion) => { - if (!suggestion || provider !== 'claude') { - return { success: false }; - } - return grantClaudeToolPermission(suggestion.entry); - }, [provider]); - - // Send a UI decision back to the server (single or batched request IDs). - // This does not validate tool inputs or permissions; the backend enforces rules. - // It exists so "Allow & remember" can resolve multiple queued prompts at once. - const handlePermissionDecision = useCallback((requestIds, decision) => { - 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(prev => { - const next = prev.filter(req => !validIds.includes(req.requestId)); - if (next.length === 0) { - setClaudeStatus(null); - } - return next; - }); - }, [sendMessage]); - - // Store handleSubmit in ref so handleCustomCommand can access it - useEffect(() => { - handleSubmitRef.current = handleSubmit; - }, [handleSubmit]); - - const selectCommand = (command) => { - if (!command) return; - - // Prepare the input with command name and any arguments that were already typed - 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; - - // Update input temporarily so executeCommand can parse arguments - setInput(newInput); - - // Hide command menu - setShowCommandMenu(false); - setSlashPosition(-1); - setCommandQuery(''); - setSelectedCommandIndex(-1); - - // Clear debounce timer - if (commandQueryTimerRef.current) { - clearTimeout(commandQueryTimerRef.current); - } - - // Execute the command (which will load its content and send to Claude) - executeCommand(command); - }; - - const handleKeyDown = (e) => { - // Handle command menu navigation - if (showCommandMenu && filteredCommands.length > 0) { - if (e.key === 'ArrowDown') { - e.preventDefault(); - setSelectedCommandIndex(prev => - prev < filteredCommands.length - 1 ? prev + 1 : 0 - ); - return; - } - if (e.key === 'ArrowUp') { - e.preventDefault(); - setSelectedCommandIndex(prev => - prev > 0 ? prev - 1 : filteredCommands.length - 1 - ); - return; - } - if (e.key === 'Tab' || e.key === 'Enter') { - e.preventDefault(); - if (selectedCommandIndex >= 0) { - selectCommand(filteredCommands[selectedCommandIndex]); - } else if (filteredCommands.length > 0) { - selectCommand(filteredCommands[0]); - } - return; - } - if (e.key === 'Escape') { - e.preventDefault(); - setShowCommandMenu(false); - setSlashPosition(-1); - setCommandQuery(''); - setSelectedCommandIndex(-1); - if (commandQueryTimerRef.current) { - clearTimeout(commandQueryTimerRef.current); - } - return; - } - } - - // Handle file dropdown navigation - if (showFileDropdown && filteredFiles.length > 0) { - if (e.key === 'ArrowDown') { - e.preventDefault(); - setSelectedFileIndex(prev => - prev < filteredFiles.length - 1 ? prev + 1 : 0 - ); - return; - } - if (e.key === 'ArrowUp') { - e.preventDefault(); - setSelectedFileIndex(prev => - prev > 0 ? prev - 1 : filteredFiles.length - 1 - ); - return; - } - if (e.key === 'Tab' || e.key === 'Enter') { - e.preventDefault(); - if (selectedFileIndex >= 0) { - selectFile(filteredFiles[selectedFileIndex]); - } else if (filteredFiles.length > 0) { - selectFile(filteredFiles[0]); - } - return; - } - if (e.key === 'Escape') { - e.preventDefault(); - setShowFileDropdown(false); - return; - } - } - - // Handle Tab key for mode switching (only when dropdowns are not showing) - if (e.key === 'Tab' && !showFileDropdown && !showCommandMenu) { - e.preventDefault(); - // Codex doesn't support plan mode - const modes = provider === 'codex' - ? ['default', 'acceptEdits', 'bypassPermissions'] - : ['default', 'acceptEdits', 'bypassPermissions', 'plan']; - const currentIndex = modes.indexOf(permissionMode); - const nextIndex = (currentIndex + 1) % modes.length; - const newMode = modes[nextIndex]; - setPermissionMode(newMode); - - // Save mode for this session - if (selectedSession?.id) { - localStorage.setItem(`permissionMode-${selectedSession.id}`, newMode); - } - return; - } - - // Handle Enter key: Ctrl+Enter (Cmd+Enter on Mac) sends, Shift+Enter creates new line - if (e.key === 'Enter') { - // If we're in composition, don't send message - if (e.nativeEvent.isComposing) { - return; // Let IME handle the Enter key - } - - if ((e.ctrlKey || e.metaKey) && !e.shiftKey) { - // Ctrl+Enter or Cmd+Enter: Send message - e.preventDefault(); - handleSubmit(e); - } else if (!e.shiftKey && !e.ctrlKey && !e.metaKey) { - // Plain Enter: Send message only if not in IME composition - if (!sendByCtrlEnter) { - e.preventDefault(); - handleSubmit(e); - } - } - // Shift+Enter: Allow default behavior (new line) - } - }; - - const selectFile = (file) => { - 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 newCursorPos = textBeforeAt.length + file.path.length + 1; - - // Immediately ensure focus is maintained - if (textareaRef.current && !textareaRef.current.matches(':focus')) { - textareaRef.current.focus(); - } - - // Update input and cursor position - setInput(newInput); - setCursorPosition(newCursorPos); - setFileMentions(prev => (prev.includes(file.path) ? prev : [...prev, file.path])); - - // Hide dropdown - setShowFileDropdown(false); - setAtSymbolPosition(-1); - - // Set cursor position synchronously - if (textareaRef.current) { - // Use requestAnimationFrame for smoother updates - requestAnimationFrame(() => { - if (textareaRef.current) { - textareaRef.current.setSelectionRange(newCursorPos, newCursorPos); - // Ensure focus is maintained - if (!textareaRef.current.matches(':focus')) { - textareaRef.current.focus(); - } - } - }); - } - }; - - const handleInputChange = (e) => { - const newValue = e.target.value; - const cursorPos = e.target.selectionStart; - - // Auto-select Claude provider if no session exists and user starts typing - if (!currentSessionId && newValue.trim() && provider === 'claude') { - // Provider is already set to 'claude' by default, so no need to change it - // The session will be created automatically when they submit - } - - setInput(newValue); - setCursorPosition(cursorPos); - - // Handle height reset when input becomes empty - if (!newValue.trim()) { - e.target.style.height = 'auto'; - setIsTextareaExpanded(false); - setShowCommandMenu(false); - setSlashPosition(-1); - setCommandQuery(''); - return; - } - - // Detect slash command at cursor position - // Look backwards from cursor to find a slash that starts a command - const textBeforeCursor = newValue.slice(0, cursorPos); - - // Check if we're in a code block (simple heuristic: between triple backticks) - const backticksBefore = (textBeforeCursor.match(/```/g) || []).length; - const inCodeBlock = backticksBefore % 2 === 1; - - if (inCodeBlock) { - // Don't show command menu in code blocks - setShowCommandMenu(false); - setSlashPosition(-1); - setCommandQuery(''); - return; - } - - // Find the last slash before cursor that could start a command - // Slash is valid if it's at the start or preceded by whitespace - const slashPattern = /(^|\s)\/(\S*)$/; - const match = textBeforeCursor.match(slashPattern); - - if (match) { - const slashPos = match.index + match[1].length; // Position of the slash - const query = match[2]; // Text after the slash - - // Update states with debouncing for query - setSlashPosition(slashPos); - setShowCommandMenu(true); - setSelectedCommandIndex(-1); - - // Debounce the command query update - if (commandQueryTimerRef.current) { - clearTimeout(commandQueryTimerRef.current); - } - - commandQueryTimerRef.current = setTimeout(() => { - setCommandQuery(query); - }, 150); // 150ms debounce - } else { - // No slash command detected - setShowCommandMenu(false); - setSlashPosition(-1); - setCommandQuery(''); - - if (commandQueryTimerRef.current) { - clearTimeout(commandQueryTimerRef.current); - } - } - }; - - const syncInputOverlayScroll = useCallback((target) => { - if (!inputHighlightRef.current || !target) return; - inputHighlightRef.current.scrollTop = target.scrollTop; - inputHighlightRef.current.scrollLeft = target.scrollLeft; - }, []); - - const handleTextareaClick = (e) => { - setCursorPosition(e.target.selectionStart); - }; - - -// ! Unused - const handleNewSession = () => { - setChatMessages([]); - setInput(''); - setIsLoading(false); - setCanAbortSession(false); - }; - - const handleAbortSession = () => { - if (currentSessionId && canAbortSession) { - sendMessage({ - type: 'abort-session', - sessionId: currentSessionId, - provider: provider - }); - } - }; - - const handleModeSwitch = () => { - // Codex doesn't support plan mode - const modes = provider === 'codex' - ? ['default', 'acceptEdits', 'bypassPermissions'] - : ['default', 'acceptEdits', 'bypassPermissions', 'plan']; - const currentIndex = modes.indexOf(permissionMode); - const nextIndex = (currentIndex + 1) % modes.length; - const newMode = modes[nextIndex]; - setPermissionMode(newMode); - - // Save mode for this session - if (selectedSession?.id) { - localStorage.setItem(`permissionMode-${selectedSession.id}`, newMode); - } - }; - - // Don't render if no project is selected - if (!selectedProject) { - return ( -
-
-

Select a project to start chatting with Claude

-
-
- ); - } - - return ( - <> - -
- {/* Messages Area - Scrollable Middle Section */} -
- {isLoadingSessionMessages && chatMessages.length === 0 ? ( -
-
-
-

{t('session.loading.sessionMessages')}

-
-
- ) : chatMessages.length === 0 ? ( -
- {!selectedSession && !currentSessionId && ( -
-

{t('providerSelection.title')}

-

- {t('providerSelection.description')} -

- -
- {/* Claude Button */} - - - {/* Cursor Button */} - - - {/* Codex Button */} - -
- - {/* Model Selection - Always reserve space to prevent jumping */} -
- - {provider === 'claude' ? ( - - ) : provider === 'codex' ? ( - - ) : ( - - )} -
- -

- {provider === 'claude' - ? t('providerSelection.readyPrompt.claude', { model: claudeModel }) - : provider === 'cursor' - ? t('providerSelection.readyPrompt.cursor', { model: cursorModel }) - : provider === 'codex' - ? t('providerSelection.readyPrompt.codex', { model: codexModel }) - : t('providerSelection.readyPrompt.default') - } -

- - {/* Show NextTaskBanner when provider is selected and ready, only if TaskMaster is installed */} - {provider && tasksEnabled && isTaskMasterInstalled && ( -
- setInput('Start the next task')} - onShowAllTasks={onShowAllTasks} - /> -
- )} -
- )} - {selectedSession && ( -
-

{t('session.continue.title')}

-

- {t('session.continue.description')} -

- - {/* Show NextTaskBanner for existing sessions too, only if TaskMaster is installed */} - {tasksEnabled && isTaskMasterInstalled && ( -
- setInput('Start the next task')} - onShowAllTasks={onShowAllTasks} - /> -
- )} -
- )} -
- ) : ( - <> - {/* Loading indicator for older messages */} - {isLoadingMoreMessages && ( -
-
-
-

{t('session.loading.olderMessages')}

-
-
- )} - - {/* Indicator showing there are more messages to load */} - {hasMoreMessages && !isLoadingMoreMessages && ( -
- {totalMessages > 0 && ( - - {t('session.messages.showingOf', { shown: sessionMessages.length, total: totalMessages })} • - {t('session.messages.scrollToLoad')} - - )} -
- )} - - {/* Legacy message count indicator (for non-paginated view) */} - {!hasMoreMessages && chatMessages.length > visibleMessageCount && ( -
- {t('session.messages.showingLast', { count: visibleMessageCount, total: chatMessages.length })} • - -
- )} - - {visibleMessages.map((message, index) => { - const prevMessage = index > 0 ? visibleMessages[index - 1] : null; - - return ( - - ); - })} - - )} - - {isLoading && ( -
-
-
-
- {(localStorage.getItem('selected-provider') || 'claude') === 'cursor' ? ( - - ) : (localStorage.getItem('selected-provider') || 'claude') === 'codex' ? ( - - ) : ( - - )} -
-
{(localStorage.getItem('selected-provider') || 'claude') === 'cursor' ? 'Cursor' : (localStorage.getItem('selected-provider') || 'claude') === 'codex' ? 'Codex' : 'Claude'}
- {/* Abort button removed - functionality not yet implemented at backend */} -
-
-
-
-
-
- Thinking... -
-
-
-
- )} - -
-
- - - {/* Input Area - Fixed Bottom */} -
- -
- -
- {/* Permission Mode Selector with scroll to bottom button - Above input, clickable for mobile */} -
- {pendingPermissionRequests.length > 0 && ( - // Permission banner for tool approvals. This renders the input, allows - // "allow once" or "allow & remember", and supports batching similar requests. - // It does not persist permissions by itself; persistence is handled by - // the existing localStorage-based settings helpers, introduced to surface - // approvals before tool execution resumes. -
- {pendingPermissionRequests.map((request) => { - const rawInput = formatToolInputForDisplay(request.input); - const permissionEntry = buildClaudeToolPermissionEntry(request.toolName, rawInput); - const settings = getClaudeSettings(); - const alreadyAllowed = permissionEntry - ? settings.allowedTools.includes(permissionEntry) - : false; - const rememberLabel = alreadyAllowed ? 'Allow (saved)' : 'Allow & remember'; - // Group pending prompts that resolve to the same allow rule so - // a single "Allow & remember" can clear them in one click. - // This does not attempt fuzzy matching; it only batches identical rules. - const matchingRequestIds = permissionEntry - ? pendingPermissionRequests - .filter(item => buildClaudeToolPermissionEntry(item.toolName, formatToolInputForDisplay(item.input)) === permissionEntry) - .map(item => item.requestId) - : [request.requestId]; - - return ( -
-
-
-
- Permission required -
-
- Tool: {request.toolName} -
-
- {permissionEntry && ( -
- Allow rule: {permissionEntry} -
- )} -
- - {rawInput && ( -
- - View tool input - -
-                          {rawInput}
-                        
-
- )} - -
- - - -
-
- ); - })} -
- )} - -
- - - {/* Thinking Mode Selector */} - { - provider === 'claude' && ( - - - )} - {/* Token usage pie chart - positioned next to mode indicator */} - - - {/* Slash commands button */} - - - {/* Clear input button - positioned to the right of token pie, only shows when there's input */} - {input.trim() && ( - - )} - - {/* Scroll to bottom button - positioned next to mode indicator */} - {isUserScrolledUp && chatMessages.length > 0 && ( - - )} -
-
- -
- {/* Drag overlay */} - {isDragActive && ( -
-
- - - -

Drop images here

-
-
- )} - - {/* Image attachments preview */} - {attachedImages.length > 0 && ( -
-
- {attachedImages.map((file, index) => ( - { - setAttachedImages(prev => prev.filter((_, i) => i !== index)); - }} - uploadProgress={uploadingImages.get(file.name)} - error={imageErrors.get(file.name)} - /> - ))} -
-
- )} - - {/* File dropdown - positioned outside dropzone to avoid conflicts */} - {showFileDropdown && filteredFiles.length > 0 && ( -
- {filteredFiles.map((file, index) => ( -
{ - // Prevent textarea from losing focus on mobile - e.preventDefault(); - e.stopPropagation(); - }} - onClick={(e) => { - e.preventDefault(); - e.stopPropagation(); - selectFile(file); - }} - > -
{file.name}
-
- {file.path} -
-
- ))} -
- )} - - {/* Command Menu */} - { - setShowCommandMenu(false); - setSlashPosition(-1); - setCommandQuery(''); - setSelectedCommandIndex(-1); - }} - position={{ - top: textareaRef.current - ? Math.max(16, textareaRef.current.getBoundingClientRect().top - 316) - : 0, - left: textareaRef.current - ? textareaRef.current.getBoundingClientRect().left - : 16, - bottom: textareaRef.current - ? window.innerHeight - textareaRef.current.getBoundingClientRect().top + 8 - : 90 - }} - isOpen={showCommandMenu} - frequentCommands={commandQuery ? [] : frequentCommands} - /> - -
- - -
-