Compare commits

...

99 Commits

Author SHA1 Message Date
simosmik
e7800c494f Release 1.18.0 2026-02-16 14:15:21 +00:00
simosmik
374fe35915 fix: editor toolbar mobile visibility 2026-02-16 14:14:10 +00:00
simosmik
33b0ea4c4a feature: swap default code editor to sidebar and make the modal optional 2026-02-16 14:02:18 +00:00
simosmik
412102c531 feat: show .env syntax highlighting and markdown viewer 2026-02-16 13:48:11 +00:00
simosmik
afe1be7fca Feat: Refine design language and use theme tokens across most pages. 2026-02-16 13:17:47 +00:00
simosmik
42f13e151c feature: Ask User Question implementation for Claude Code & upgrade claude agent sdk to 0.1.71 to support the tool 2026-02-16 12:14:51 +00:00
simosmik
272eb00602 Release 1.17.1 2026-02-13 19:29:30 +00:00
Haileyesus
f891316ec0 Refactor/app content main content and chat interface (#374)
* 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 <something@gmail.com>
Co-authored-by: simosmik <simosmik@gmail.com>
2026-02-13 20:26:47 +01:00
simosmik
1ed3358cbd Release 1.16.4 2026-02-11 15:26:18 +00:00
Haileyesus
c1e025b665 fix: claude code login issues (#375)
* fix: claude code login issues

1. Now, the browser opens in a new tab automatically
2. Clicking "C" to copy works
3. I have removed the "x-term" link selector since it didn't select the whole link

* fix: remove unnecessary terminal hyperlink for auth URL

* fix(shell): resolve clipboard handling for copy and paste events

* feat(shell): add authentication URL display and copy functionality - allows copy for mobile users

* revert: Update login command for unauthenticated users to use '/exit'

---------

Co-authored-by: Haileyesus <something@gmail.com>
2026-02-11 12:05:28 +01:00
Ayaan-buzzni
cf3d23ee31 feat(i18n): add Korean language support (#367)
* feat(i18n): add Korean language support

- Add Korean (ko) translation files for all namespaces:
  - common.json, auth.json, settings.json, sidebar.json, chat.json, codeEditor.json
- Register Korean locale in config.js and languages.js
- Follow translation guidelines:
  - Keep technical terms in English (UI, API, Shell, Git, etc.)
  - Use Korean phonetic for some terms (TaskMaster → 테스크마스터)
  - Maintain concise translations to match English length where possible

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(i18n): keep technical term "UI" in Korean translation

- Change "인터페이스" back to "UI" in sidebar subtitle
- Keep technical terms in English as per translation guidelines

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-09 09:47:10 +03:00
Haileyesus
e7d6c40452 Refactor WebSocket context + centralize platform flag (#363)
* fix: remove unnecessary websocket.js file and replace its usage directly in `WebSocketContext`

* fix: connect() doesn't need to be async

* fix: update WebSocket context import to use useWebSocket hook

* fix: use `useRef` for WebSocketContext

The main issue with using states was, previously the websocket never closed
properly on unmount, so multiple connections could be opened.

This was because the useEffect cleanup function was closing an old websocket
(that was initialized to null) instead of the current one.

We could have fixed this by adding `ws` to the useEffect dependency array, but
this was unnecessary since `ws` doesn't affect rendering so we shouldn't use a state.

* fix: replace `WebSocketContext` default value with null and add type definitions

* fix: add type definition for WebSocket URL and remove redundant protocol declaration

* fix: Prevent WebSocket reconnection attempts after unmount

Right now, when the WebSocketContext component unmounts,
there is still a pending reconnection attempt that tries
to reconnect the WebSocket after 3 seconds.

* refactor: Extract WebSocket URL construction into a separate function

* refactor: Centralize platform mode detection using IS_PLATFORM constant; use `token` from Auth context in WebSocket connection

* refactor: Use IS_PLATFORM constant for platform detection in authenticatedFetch function (backend)

* refactor: move IS_PLATFORM to config file for both frontend and backend

The reason we couldn't place it in shared/modelConstants.js is that the
frontend uses Vite which requires import.meta.env for environment variables,
while the backend uses process.env. Therefore, we created separate config files
for the frontend (src/constants/config.ts) and backend (server/constants/config.js).

* refactor: update import path for IS_PLATFORM constant to use config file

* refactor: replace `messages` with `latestMessage` in WebSocket context and related components

Why?
Because, messages was only being used to access the latest message in the components it's used in.

* refactor: optimize WebSocket connection handling with useCallback and useMemo

* refactor: comment out debug log for render count in AppContent component

* refactor(backend): update environment variable handling and replace VITE_IS_PLATFORM with IS_PLATFORM constant

* refactor: update WebSocket connection effect to depend on token changes for reconnection

---------
2026-02-03 10:05:15 +01:00
Haileyesus
216932e7f9 fix: correct spelling of "claude code" and update license to GPL-3.0 2026-02-02 20:51:44 +01:00
simosmik
e9719256fc Release 1.16.3 2026-01-30 17:13:57 +00:00
simosmik
55caaf060c fix: no-session-persistence removal 2026-01-30 17:09:54 +00:00
simosmik
f9c7321c8c Release 1.16.2 2026-01-30 16:58:51 +00:00
Haileyesus
88bda6e5c0 chore: update version to 1.16.0 and comment out checkJs in tsconfig 2026-01-30 16:49:30 +00:00
Haileyesus
86b421c790 feat: setup TypeScript with configuration and type definitions 2026-01-30 17:19:45 +01:00
viper151
41ef84c283 Merge pull request #353 from siteboon/fix/WORKSPACES_ROOT-issue-in-deployed-version 2026-01-29 20:55:55 +01:00
simosmik
53224e47b6 fix: change claude login order and command 2026-01-28 23:08:11 +00:00
Haileyesus
bbb51dbf99 fix: enforce WORKSPACES_ROOT in folder browser and folder creation 2026-01-28 22:12:20 +03:00
simosmik
2d06cae0ca Release 1.15.0 2026-01-28 10:06:50 +00:00
Haileyesus
14fb81586c Merge pull request #320 from EricBlanquer/fix/new-project-folder-selection
feat: enhance project creation wizard - folder browser fixes and git clone improvements
2026-01-27 22:58:56 +03:00
viper151
4d2b592ec6 Update Router to use dynamic basename 2026-01-26 15:38:00 +01:00
viper151
4957220a05 Remove base path configuration from Vite config 2026-01-26 13:39:24 +01:00
viper151
3debc3a249 Reorder return statements for claude commands 2026-01-26 13:34:38 +01:00
viper151
5512e2e15b Merge pull request #343 from siteboon/viper151-patch-1-1
Set base path for Vite configuration
2026-01-26 11:58:22 +01:00
viper151
1b42dba902 Set base path for Vite configuration 2026-01-26 11:56:05 +01:00
Eric Blanquer​
ede56ad81b fix: simplify project wizard labels for clarity 2026-01-26 03:25:43 +01:00
Eric Blanquer
36094fb73f fix: encode Windows paths correctly in addProjectManually
The regex only replaced forward slashes, causing Windows paths like
C:\Users\Eric\my_project to remain unchanged instead of being encoded
to C--Users-Eric-my-project. This caused API routes to fail.
2026-01-26 03:09:22 +01:00
Eric Blanquer​
57828653bf fix: handle EEXIST race and prevent data loss on clone 2026-01-26 03:09:22 +01:00
Eric Blanquer​
8ef0951901 fix: update i18n translations for clone progress and SSH detection 2026-01-26 03:09:22 +01:00
Eric Blanquer​
ab50c5c1a8 fix: address CodeRabbit review comments
- Add path validation to /api/create-folder endpoint (forbidden system dirs)
- Fix repo name extraction to handle trailing slashes in URLs
- Add cleanup of partial clone directory on SSE clone failure
- Remove dead code branch (unreachable message)
- Fix Windows path separator detection in createNewFolder
- Replace alert() with setError() for consistent error handling
- Detect ssh:// URLs in addition to git@ for SSH key display
- Show create folder button for both workspace types
2026-01-26 03:09:22 +01:00
Eric Blanquer​
6726e8f44e feat: enhance project creation wizard with folder creation and git clone progress
- Add "+" button to create new folders directly from folder browser
- Add SSE endpoint for git clone with real-time progress display
- Show clone progress (receiving objects, resolving deltas) in UI
- Detect SSH URLs and display "SSH Key" instead of "No authentication"
- Hide token section for SSH URLs (tokens only work with HTTPS)
- Fix auto-advance behavior: only auto-advance for "Existing Workspace"
- Fix various misleading UI messages
- Support auth token via query param for SSE endpoints
2026-01-26 03:09:22 +01:00
Eric Blanquer​
07f89e5240 fix: folder browser navigation issues
- Show parent directory (..) button even when folder has no subfolders
- Handle Windows backslash paths when calculating parent directory
- Prevent navigation to Windows drive root (C:\) from breaking
2026-01-26 03:09:22 +01:00
Eric Blanquer​
8a675a713b fix: use resolved path from API in folder browser
When selecting home folder in New Project wizard, ~ was being used
instead of /home/<user>, causing "Workspace path does not exist" error.
Now uses the resolved absolute path returned by the browse-filesystem API.
2026-01-26 03:09:22 +01:00
simosmik
5724c11253 fix:disabling zoom on focus on mobile iframe 2026-01-26 00:29:56 +00:00
simosmik
c7b9976986 fix: text selection on login for claude 2026-01-26 00:20:19 +00:00
simosmik
f16e3e763d Release 1.14.0 2026-01-26 00:11:12 +00:00
simosmik
477bc404b0 fix: switch login mechanism for claude code 2026-01-25 23:55:05 +00:00
viper151
ae5a21cd6e Merge pull request #337 from timbot/main
fix: prevent codex spawn error when codex CLI is not installed
2026-01-25 23:18:57 +01:00
viper151
b2c69d6ea8 Merge branch 'main' into main 2026-01-25 23:17:29 +01:00
viper151
8825baf5b4 Update codex.js 2026-01-25 23:17:08 +01:00
viper151
0d1a3df1f7 Merge pull request #318 from siteboon/fix/session-streamed-to-another-chat
Fix/session streamed to another chat
2026-01-25 23:15:31 +01:00
viper151
80732923b5 Merge branch 'main' into fix/session-streamed-to-another-chat 2026-01-25 23:15:23 +01:00
viper151
6362d35d66 Merge pull request #299 from siteboon/feat/add-highlight-to-file-mentions
feat: add highlight for file mentions in chat input
2026-01-25 23:09:24 +01:00
viper151
10bfeed614 Merge branch 'main' into feat/add-highlight-to-file-mentions 2026-01-25 23:09:16 +01:00
Tim Smith
dab089b29f fix: prevent codex spawn error when codex CLI is not installed
Return success with empty servers array from the config read endpoint
when no config file exists, so the frontend doesn't fall through to
the CLI list endpoint which attempts to spawn the codex binary.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 11:21:43 -08:00
Haileyesus Dessie
38745bdf85 Merge pull request #327 from NobitaYuan/feat/add-i18n
update: Add translations for more components
2026-01-23 15:26:51 +03:00
Haileyesus Dessie
9da7c1cbae Merge pull request #314 from EricBlanquer/feature/delete-project-with-sessions
feat: allow deleting projects with sessions and add styled confirmation modal
2026-01-23 15:13:25 +03:00
YuanNiancai
844677caee Merge branch 'feat/add-i18n' of https://github.com/NobitaYuan/claudecodeui into feat/add-i18n 2026-01-23 09:27:14 +08:00
NobitaYuan
e1c67fd5d0 Merge branch 'main' into feat/add-i18n 2026-01-23 09:26:58 +08:00
YuanNiancai
9cd0cfc88f fix: add missing translation 2026-01-23 09:26:43 +08:00
viper151
09f1021c59 Merge pull request #332 from siteboon/fix/turn-on-extended-thinking-mode 2026-01-22 11:45:27 +01:00
Haileyesus Dessie
cf0f60bc48 fix: handleSubmit useCalback add thinkingMode to dependencies 2026-01-22 13:40:35 +03:00
viper151
053d94ab9d Merge pull request #331 from siteboon/fix/turn-on-extended-thinking-mode 2026-01-22 11:39:13 +01:00
Haileyesus Dessie
79f7bf9a63 fix: use messageContent instead of input for Claude command messages 2026-01-22 12:51:12 +03:00
YuanNiancai
e85cc746b1 fix: add missing translation 2026-01-22 15:24:27 +08:00
YuanNiancai
cc3368c591 add translations for Shell.jsx 2026-01-22 15:12:01 +08:00
YuanNiancai
5131d2ae27 add some translations for CodeEditor.jsx、QuickSettingsPanel.jsx 2026-01-22 14:57:32 +08:00
YuanNiancai
394b95ae29 add some translations for chatInterface.jsx 2026-01-22 14:38:24 +08:00
YuanNiancai
4948aa3d64 fix:Fix missing imports 2026-01-22 10:07:40 +08:00
NobitaYuan
6e07f140e3 Merge branch 'main' into feat/add-i18n 2026-01-22 09:56:11 +08:00
YuanNiancai
fea8e30725 update: Add translations for some components 2026-01-22 09:49:19 +08:00
Eric Blanquer​
9f534ce15b fix: use i18next v4+ pluralization format and add sessionTitle fallback 2026-01-21 23:14:41 +01:00
Eric Blanquer​
8cb34a73b5 fix: localize delete confirmation modal strings 2026-01-21 22:38:29 +01:00
Eric Blanquer​
74640a7f31 feat: allow deleting projects with sessions and add styled confirmation modal
- Add force delete option to delete projects with existing sessions
- Add styled confirmation modal with session count warning
- Add deletingProjects state to show loading indicator during deletion
- Delete associated Codex sessions when deleting a project (with limit: 0)
- Delete associated Cursor sessions directory when deleting a project
- Add fallback to extractProjectDirectory when projectPath undefined
- Use finally block for deletingProjects cleanup
- Add fallback name in delete modal
2026-01-21 22:05:00 +01:00
Haileyesus Dessie
5800d84255 Merge pull request #303 from NobitaYuan/feat/add-i18n
Feat:add i18n
2026-01-21 17:21:02 +03:00
Haileyesus Dessie
33c70a372d Merge branch 'main' into feat/add-i18n 2026-01-21 17:16:48 +03:00
Haileyesus Dessie
396f058b46 Merge pull request #311 from EricBlanquer/local/loading-progress
Add loading progress indicator
2026-01-21 17:08:30 +03:00
Haileyesus Dessie
b899695772 Merge pull request #321 from EricBlanquer/fix/session-hover-buttons
fix: hide session badge and icon on hover to show action buttons
2026-01-21 16:51:09 +03:00
YuanNiancai
a173817d37 feat: add i18n translations for ThinkingModeSelector component 2026-01-21 14:50:15 +08:00
YuanNiancai
73375d7653 fix: improve i18n translation strings based on code review
Refactored translation strings to follow best practices:

1. Removed HTML tags from translation strings (en/chat.json)
   - Changed "Adds <span class=\"font-mono\">{{entry}}</span>" to "Adds {{entry}}"

2. Simplified TasksSettings.jsx component (TasksSettings.jsx)
   - Moved command code directly into translation file

3. Updated both English and Chinese translation files
   - en/chat.json: Removed HTML markup from permissions.addTo
   - zh-CN/chat.json: Corresponding Chinese translation update
   - en/settings.json: Added task-master init command
   -zh-CN/settings.json: Added task-master init command in Chinese

根据代码审查改进 i18n 翻译字符串:

1. 从翻译字符串中移除 HTML 标签(en/chat.json)
   - 将 "Adds <span class=\"font-mono\">{{entry}}</span>" 改为 "Adds {{entry}}"

2. 简化 TasksSettings.jsx 组件
   - 将命令代码直接移到翻译文件中

3. 更新英文和中文翻译文件
   - en/chat.json: 从 permissions.addTo 移除 HTML 标记
   - zh-CN/chat.json: 对应的中文翻译更新
   - en/settings.json: 添加 task-master init 命令
   - zh-CN/settings.json: 添加 task-master init 命令的中文翻译
2026-01-21 14:29:16 +08:00
YuanNiancai
1d48b78af2 Merge branch 'feat/add-i18n' of https://github.com/NobitaYuan/claudecodeui into feat/add-i18n 2026-01-21 14:09:43 +08:00
YuanNiancai
7928285ed0 resolve conflict 2026-01-21 14:09:32 +08:00
YuanNiancai
92cbb3e7d9 Merge branch 'main' into feat/add-i18n 2026-01-21 14:08:57 +08:00
YuanNiancai
0517ee609e feat: complete internationalization (i18n) for components
Implemented comprehensive i18n translation support for the following components:

1. GitSettings.jsx - Git configuration interface
2. ApiKeysSettings.jsx - API keys settings
3. CredentialsSettings.jsx - Credentials settings (GitHub tokens)
4. TasksSettings.jsx - TaskMaster task management settings
5. ChatInterface.jsx - Chat interface (major translation work)

New translation files:
- src/i18n/locales/en/chat.json - English chat interface translations
- src/i18n/locales/zh-CN/chat.json - Chinese chat interface translations

ChatInterface.jsx translations:
- Code block copy buttons (Copy, Copied, Copy code)
- Message type labels (User, Error, Tool, Claude, Cursor, Codex)
- Tool settings tooltip
- Search result display (pattern, in, results)
- Codex permission modes (Default, Accept Edits, Bypass Permissions, Plan)
- Input placeholder and hint text
- Keyboard shortcut hints (Ctrl+Enter/Enter modes)
- Command menu button

i18n configuration updates:
- Registered chat namespace in config.js
- Extended settings.json translations (git, apiKeys, tasks, agents, mcpServers sections)

完成以下组件的 i18n 翻译工作:

1. GitSettings.jsx - Git 配置界面
2. ApiKeysSettings.jsx - API 密钥设置
3. CredentialsSettings.jsx - 凭据设置(GitHub Token)
4. TasksSettings.jsx - TaskMaster 任务管理设置
5. ChatInterface.jsx - 聊天界面(主要翻译工作)

新增翻译文件:
- src/i18n/locales/en/chat.json - 英文聊天界面翻译
- src/i18n/locales/zh-CN/chat.json - 中文聊天界面翻译

ChatInterface.jsx 翻译内容:
- 代码块复制按钮
- 消息类型标签
- 工具设置提示
- 搜索结果显示
- Codex 权限模式(默认、编辑、无限制、计划模式)
- 输入框占位符和提示文本
- 键盘快捷键提示
- 命令菜单按钮

更新 i18n 配置:
- 在 config.js 中注册 chat 命名空间
- 扩展 settings.json 翻译(git、apiKeys、tasks、agents、mcpServers 等部分)
2026-01-21 13:58:30 +08:00
NobitaYuan
3bbf38125a Merge branch 'main' into feat/add-i18n 2026-01-21 09:20:47 +08:00
Eric Blanquer​
9e03acb0db Add loading progress indicator
Adds real-time progress feedback when loading projects:
- Server broadcasts progress updates via WebSocket
- Sidebar shows current project being loaded with progress bar
- Fixed progress counter to show correct current/total
- Completion event now fires after all projects (including manual) are processed
- Race condition fix for timeout cleanup using ref
- Added cleanup function to prevent state update on unmounted component
2026-01-21 00:21:01 +01:00
Eric Blanquer​
515ad3b336 fix: hide session badge and icon on hover to show action buttons
Hide the message count badge and provider icon when hovering over a session,
so they don't overlap with the edit/delete action buttons.
2026-01-20 23:39:24 +01:00
viper151
b68a903781 Merge pull request #301 from siteboon/feature/add-thinking-mode-selector-to-chat-interface 2026-01-20 21:03:44 +01:00
NobitaYuan
a08deee6b7 Merge branch 'main' into feat/add-i18n 2026-01-20 20:57:20 +08:00
viper151
9cd1b5811a Merge branch 'main' into fix/session-streamed-to-another-chat 2026-01-20 12:59:42 +01:00
Haileyesus Dessie
ee43adb311 Merge pull request #312 from EricBlanquer/feat/folder-browser-wizard
Add folder browser to ProjectCreationWizard
2026-01-20 12:01:02 +03:00
viper151
740f3a7f0e Merge branch 'main' into feature/add-thinking-mode-selector-to-chat-interface 2026-01-19 09:50:03 +01:00
Eric Blanquer​
e1f2af1a34 feat: add folder browser to ProjectCreationWizard
- Add folder browser modal to navigate and select project folders
- Sort folders alphabetically (case-insensitive)
- Add toggle to show/hide hidden folders (hidden by default)
- Auto-advance to confirmation step when selecting a folder
- Place "Use this folder" button next to Cancel
2026-01-18 06:05:34 +01:00
YuanNiancai
50f8c4ba72 Merge main into feat/add-i18n - resolved package-lock.json conflicts 2026-01-16 20:11:17 +08:00
YuanNiancai
1e8e52ce8d Resolved package-lock.json merge conflicts by accepting main branch 2026-01-16 20:06:29 +08:00
YuanNiancai
133c762eea Remove openspect files 2026-01-16 19:23:23 +08:00
YuanNiancai
4216676395 add i18n feat && Add partial translation 2026-01-16 19:11:19 +08:00
Haileyesus Dessie
ddb26c7652 fix: resolve issue with redirecting to original session after response completion 2026-01-16 14:04:37 +03:00
Haileyesus Dessie
b3c6e95971 fix: don't stream response to another session 2026-01-16 14:04:12 +03:00
Haileyesus Dessie
f8d1ec7b9e Merge pull request #250 from ZhenhongDu/main
feat: Add codeblock highlight support in ChatInterface
2026-01-15 21:10:06 +03:00
Haileyesus Dessie
e73960ae78 feat: Conditionally render Thinking Mode Selector for Claude provider 2026-01-15 21:08:30 +03:00
Valics Lehel
1f6c0c3899 feat: Add thinking mode selector to chat interface
- Added ThinkingModeSelector component with 5 thinking modes
- Integrated selector into chat header next to permission mode
- Automatically prefixes messages with thinking instructions
- Resets to default mode after sending message
2026-01-15 21:06:38 +03:00
viper151
ee3917b3f9 Merge branch 'main' into main 2026-01-06 12:43:35 +01:00
viper151
81c0773358 Merge branch 'main' into main 2025-12-31 08:54:50 +01:00
Zhenhong Du
89c9aec5b7 feat: add codeblock highlight in ChatInterface 2025-12-01 12:01:30 +08:00
Zhenhong Du
e74a813093 add packages for code highlight in chatui 2025-12-01 11:57:44 +08:00
172 changed files with 22317 additions and 10479 deletions

2
.nvmrc
View File

@@ -1 +1 @@
v20.19.3
v22

369
README.ko.md Normal file
View File

@@ -0,0 +1,369 @@
<div align="center">
<img src="public/logo.svg" alt="Claude Code UI" width="64" height="64">
<h1>Cloud CLI (일명 Claude Code UI)</h1>
</div>
[Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Cursor CLI](https://docs.cursor.com/en/cli/overview) 및 [Codex](https://developers.openai.com/codex)를 위한 데스크톱 및 모바일 UI입니다. 로컬 또는 원격으로 사용하여 Claude Code, Cursor 또는 Codex의 활성 프로젝트와 세션을 확인하고, 어디서든(모바일 또는 데스크톱) 변경할 수 있습니다. 어디서든 작동하는 적절한 인터페이스를 제공합니다.
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.zh-CN.md">中文</a></i></div>
## 스크린샷
<div align="center">
<table>
<tr>
<td align="center">
<h3>데스크톱 뷰</h3>
<img src="public/screenshots/desktop-main.png" alt="Desktop Interface" width="400">
<br>
<em>프로젝트 개요와 채팅을 보여주는 메인 인터페이스</em>
</td>
<td align="center">
<h3>모바일 경험</h3>
<img src="public/screenshots/mobile-chat.png" alt="Mobile Interface" width="250">
<br>
<em>터치 내비게이션이 포함된 반응형 모바일 디자인</em>
</td>
</tr>
<tr>
<td align="center" colspan="2">
<h3>CLI 선택</h3>
<img src="public/screenshots/cli-selection.png" alt="CLI Selection" width="400">
<br>
<em>Claude Code, Cursor CLI, Codex 중 선택</em>
</td>
</tr>
</table>
</div>
## 기능
- **반응형 디자인** - 데스크톱, 태블릿, 모바일에서 원활하게 작동하여 모바일에서도 Claude Code, Cursor 또는 Codex를 사용할 수 있습니다
- **대화형 채팅 인터페이스** - Claude Code, Cursor 또는 Codex와 원활하게 소통하는 내장 채팅 인터페이스
- **통합 셸 터미널** - 내장 셸 기능을 통한 Claude Code, Cursor CLI 또는 Codex 직접 접근
- **파일 탐색기** - 구문 강조 및 실시간 편집이 가능한 대화형 파일 트리
- **Git 탐색기** - 변경사항 보기, 스테이징 및 커밋. 브랜치 전환도 가능
- **세션 관리** - 대화 재개, 여러 세션 관리 및 기록 추적
- **TaskMaster AI 통합** *(선택사항)* - AI 기반 작업 계획, PRD 분석 및 워크플로우 자동화를 통한 고급 프로젝트 관리
- **모델 호환성** - Claude Sonnet 4.5, Opus 4.5 및 GPT-5.2 지원
## 빠른 시작
### 사전 요구사항
- [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) 설치 및 구성
### 원클릭 실행 (권장)
설치 없이 바로 실행:
```bash
npx @siteboon/claude-code-ui
```
서버가 시작되면 `http://localhost:3001` (또는 설정한 PORT)에서 접근할 수 있습니다.
**재시작**: 서버를 중지한 후 동일한 `npx` 명령을 다시 실행하면 됩니다
### 전역 설치 (정기적 사용 시)
자주 사용하는 경우 한 번만 전역 설치:
```bash
npm install -g @siteboon/claude-code-ui
```
간단한 명령으로 시작:
```bash
claude-code-ui
```
**재시작**: Ctrl+C로 중지한 후 `claude-code-ui`를 다시 실행합니다.
**업데이트**:
```bash
cloudcli update
```
### CLI 사용법
전역 설치 후 `claude-code-ui``cloudcli` 명령을 사용할 수 있습니다:
| 명령 / 옵션 | 약어 | 설명 |
|------------------|-------|-------------|
| `cloudcli` 또는 `claude-code-ui` | | 서버 시작 (기본값) |
| `cloudcli start` | | 서버 명시적 시작 |
| `cloudcli status` | | 구성 및 데이터 위치 표시 |
| `cloudcli update` | | 최신 버전으로 업데이트 |
| `cloudcli help` | | 도움말 정보 표시 |
| `cloudcli version` | | 버전 정보 표시 |
| `--port <port>` | `-p` | 서버 포트 설정 (기본값: 3001) |
| `--database-path <path>` | | 사용자 지정 데이터베이스 위치 설정 |
**예시:**
```bash
cloudcli # 기본 설정으로 시작
cloudcli -p 8080 # 사용자 지정 포트로 시작
cloudcli status # 현재 구성 표시
```
### 백그라운드 서비스로 실행 (프로덕션 권장)
프로덕션 환경에서는 PM2(Process Manager 2)를 사용하여 Claude Code UI를 백그라운드 서비스로 실행하세요:
#### PM2 설치
```bash
npm install -g pm2
```
#### 백그라운드 서비스로 시작
```bash
# 백그라운드에서 서버 시작
pm2 start claude-code-ui --name "claude-code-ui"
# 또는 짧은 별칭 사용
pm2 start cloudcli --name "claude-code-ui"
# 사용자 지정 포트로 시작
pm2 start cloudcli --name "claude-code-ui" -- --port 8080
```
#### 시스템 부팅 시 자동 시작
시스템 부팅 시 Claude Code UI를 자동으로 시작하려면:
```bash
# 플랫폼에 맞는 시작 스크립트 생성
pm2 startup
# 현재 프로세스 목록 저장
pm2 save
```
### 로컬 개발 설치
1. **리포지토리 클론:**
```bash
git clone https://github.com/siteboon/claudecodeui.git
cd claudecodeui
```
2. **의존성 설치:**
```bash
npm install
```
3. **환경 구성:**
```bash
cp .env.example .env
# 원하는 설정으로 .env 파일 편집
```
4. **애플리케이션 시작:**
```bash
# 개발 모드 (핫 리로드 포함)
npm run dev
```
애플리케이션은 .env에서 지정한 포트에서 시작됩니다
5. **브라우저 열기:**
- 개발: `http://localhost:3001`
## 보안 및 도구 설정
**🔒 중요 공지**: 모든 Claude Code 도구는 **기본적으로 비활성화**되어 있습니다. 이는 잠재적으로 유해한 작업이 자동으로 실행되는 것을 방지합니다.
### 도구 활성화
Claude Code의 전체 기능을 사용하려면 수동으로 도구를 활성화해야 합니다:
1. **도구 설정 열기** - 사이드바의 톱니바퀴 아이콘을 클릭
3. **선택적으로 활성화** - 필요한 도구만 활성화
4. **설정 적용** - 환경설정은 로컬에 저장됩니다
<div align="center">
![도구 설정 모달](public/screenshots/tools-modal.png)
*도구 설정 인터페이스 - 필요한 것만 활성화하세요*
</div>
**권장 접근법**: 기본 도구부터 활성화하고 필요에 따라 추가하세요. 언제든지 이 설정을 조정할 수 있습니다.
## TaskMaster AI 통합 *(선택사항)*
Claude Code UI는 고급 프로젝트 관리 및 AI 기반 작업 계획을 위한 **[TaskMaster AI](https://github.com/eyaltoledano/claude-task-master)**(일명 claude-task-master) 통합을 지원합니다.
제공 기능
- PRD(제품 요구사항 문서)에서 AI 기반 작업 생성
- 스마트 작업 분해 및 의존성 관리
- 시각적 작업 보드 및 진행 상황 추적
**설정 및 문서**: 설치 지침, 구성 가이드 및 사용 예시는 [TaskMaster AI GitHub 리포지토리](https://github.com/eyaltoledano/claude-task-master)를 방문하세요.
설치 후 설정에서 활성화할 수 있습니다
## 사용 가이드
### 핵심 기능
#### 프로젝트 관리
Claude Code, Cursor 또는 Codex 세션을 사용할 수 있을 때 자동으로 발견하고 프로젝트로 그룹화합니다
- **프로젝트 작업** - 프로젝트 이름 변경, 삭제 및 정리
- **스마트 내비게이션** - 최근 프로젝트 및 세션에 빠르게 접근
- **MCP 지원** - UI를 통해 자체 MCP 서버 추가
#### 채팅 인터페이스
- **반응형 채팅 또는 Claude Code/Cursor CLI/Codex CLI 사용** - 적응형 채팅 인터페이스를 사용하거나 셸 버튼을 사용하여 선택한 CLI에 연결할 수 있습니다
- **실시간 통신** - WebSocket 연결을 통해 선택한 CLI(Claude Code/Cursor/Codex)에서 응답 스트리밍
- **세션 관리** - 이전 대화 재개 또는 새 세션 시작
- **메시지 기록** - 타임스탬프 및 메타데이터가 포함된 전체 대화 기록
- **다중 형식 지원** - 텍스트, 코드 블록 및 파일 참조
#### 파일 탐색기 및 편집기
- **대화형 파일 트리** - 확장/축소 내비게이션으로 프로젝트 구조 탐색
- **실시간 파일 편집** - 인터페이스에서 직접 파일 읽기, 수정 및 저장
- **구문 강조** - 다양한 프로그래밍 언어 지원
- **파일 작업** - 파일 및 디렉토리 생성, 이름 변경, 삭제
#### Git 탐색기
#### TaskMaster AI 통합 *(선택사항)*
- **시각적 작업 보드** - 개발 작업 관리를 위한 칸반 스타일 인터페이스
- **PRD 파서** - 제품 요구사항 문서를 생성하고 구조화된 작업으로 변환
- **진행 상황 추적** - 실시간 상태 업데이트 및 완료 추적
#### 세션 관리
- **세션 지속성** - 모든 대화 자동 저장
- **세션 정리** - 프로젝트 및 타임스탬프별 세션 그룹화
- **세션 작업** - 대화 기록 이름 변경, 삭제 및 내보내기
- **크로스 디바이스 동기화** - 모든 기기에서 세션 접근
### 모바일 앱
- **반응형 디자인** - 모든 화면 크기에 최적화
- **터치 친화적 인터페이스** - 스와이프 제스처 및 터치 내비게이션
- **모바일 내비게이션** - 엄지 내비게이션을 위한 하단 탭 바
- **적응형 레이아웃** - 접을 수 있는 사이드바 및 스마트 콘텐츠 우선순위
- **홈 화면 바로가기 추가** - 홈 화면에 바로가기를 추가하면 앱이 PWA처럼 작동합니다
## 아키텍처
### 시스템 개요
```
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Frontend │ │ Backend │ │ Agent │
│ (React/Vite) │◄──►│ (Express/WS) │◄──►│ Integration │
│ │ │ │ │ │
└─────────────────┘ └─────────────────┘ └─────────────────┘
```
### 백엔드 (Node.js + Express)
- **Express 서버** - 정적 파일 제공이 포함된 RESTful API
- **WebSocket 서버** - 채팅 및 프로젝트 새로고침을 위한 통신
- **에이전트 통합 (Claude Code / Cursor CLI / Codex)** - 프로세스 생성 및 관리
- **파일 시스템 API** - 프로젝트를 위한 파일 브라우저 노출
### 프론트엔드 (React + Vite)
- **React 18** - hooks를 사용한 현대적 컴포넌트 아키텍처
- **CodeMirror** - 구문 강조를 지원하는 고급 코드 편집기
### 기여하기
기여를 환영합니다! 다음 가이드라인을 따라주세요:
#### 시작하기
1. 리포지토리를 **Fork**합니다
2. Fork를 **클론**: `git clone <your-fork-url>`
3. 의존성 **설치**: `npm install`
4. 기능 브랜치 **생성**: `git checkout -b feature/amazing-feature`
#### 개발 프로세스
1. 기존 코드 스타일을 따라 **변경 사항을 적용**합니다
2. **철저하게 테스트** - 모든 기능이 올바르게 작동하는지 확인
3. **품질 검사 실행**: `npm run lint && npm run format`
4. [Conventional Commits](https://conventionalcommits.org/)를 따르는 설명적 메시지로 **커밋**
5. 브랜치에 **푸시**: `git push origin feature/amazing-feature`
6. 다음을 포함하여 **풀 리퀘스트를 제출**:
- 변경 사항에 대한 명확한 설명
- UI 변경 시 스크린샷
- 해당되는 경우 테스트 결과
#### 기여 내용
- **버그 수정** - 안정성 향상에 도움
- **새로운 기능** - 기능 향상 (먼저 이슈에서 논의)
- **문서** - 가이드 및 API 문서 개선
- **UI/UX 개선** - 더 나은 사용자 경험
- **성능 최적화** - 더 빠르게 만들기
## 문제 해결
### 일반적인 문제 및 해결 방법
#### "Claude 프로젝트를 찾을 수 없음"
**문제**: UI에 프로젝트가 없거나 프로젝트 목록이 비어 있음
**해결 방법**:
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code)가 올바르게 설치되었는지 확인
- 초기화를 위해 최소 하나의 프로젝트 디렉토리에서 `claude` 명령 실행
- `~/.claude/projects/` 디렉토리가 존재하고 적절한 권한이 있는지 확인
#### 파일 탐색기 문제
**문제**: 파일이 로드되지 않음, 권한 오류, 빈 디렉토리
**해결 방법**:
- 프로젝트 디렉토리 권한 확인 (터미널에서 `ls -la`)
- 프로젝트 경로가 존재하고 접근 가능한지 확인
- 자세한 오류 메시지는 서버 콘솔 로그 검토
- 프로젝트 범위 밖의 시스템 디렉토리에 접근하지 않는지 확인
## 라이선스
GNU General Public License v3.0 - 자세한 내용은 [LICENSE](LICENSE) 파일을 참조하세요.
이 프로젝트는 오픈 소스이며 GPL v3 라이선스에 따라 자유롭게 사용, 수정 및 배포할 수 있습니다.
## 감사의 말
### 사용 기술
- **[Claude Code](https://docs.anthropic.com/en/docs/claude-code)** - Anthropic의 공식 CLI
- **[Cursor CLI](https://docs.cursor.com/en/cli/overview)** - Cursor의 공식 CLI
- **[Codex](https://developers.openai.com/codex)** - OpenAI Codex
- **[React](https://react.dev/)** - 사용자 인터페이스 라이브러리
- **[Vite](https://vitejs.dev/)** - 빠른 빌드 도구 및 개발 서버
- **[Tailwind CSS](https://tailwindcss.com/)** - 유틸리티 우선 CSS 프레임워크
- **[CodeMirror](https://codemirror.net/)** - 고급 코드 편집기
- **[TaskMaster AI](https://github.com/eyaltoledano/claude-task-master)** *(선택사항)* - AI 기반 프로젝트 관리 및 작업 계획
## 지원 및 커뮤니티
### 최신 정보 받기
- 이 리포지토리에 **Star**를 눌러 지지를 표시하세요
- **Watch**로 업데이트 및 새 릴리스를 확인하세요
- 프로젝트를 **Follow**하여 공지사항을 받으세요
### 스폰서
- [Siteboon - AI powered website builder](https://siteboon.ai)
---
<div align="center">
<strong>Claude Code, Cursor 및 Codex 커뮤니티를 위해 정성껏 만들었습니다.</strong>
</div>

View File

@@ -6,6 +6,8 @@
A desktop and mobile UI for [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Cursor CLI](https://docs.cursor.com/en/cli/overview) and [Codex](https://developers.openai.com/codex). You can use it locally or remotely to view your active projects and sessions in Claude Code, Cursor, or Codex and make changes to them from everywhere (mobile or desktop). This gives you a proper interface that works everywhere.
<div align="right"><i><b>English</b> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">中文</a></i></div>
## Screenshots
<div align="center">
@@ -55,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

371
README.zh-CN.md Normal file
View File

@@ -0,0 +1,371 @@
<div align="center">
<img src="public/logo.svg" alt="Claude Code UI" width="64" height="64">
<h1>Cloud CLI (又名 Claude Code UI)</h1>
</div>
[Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[Cursor CLI](https://docs.cursor.com/en/cli/overview) 和 [Codex](https://developers.openai.com/codex) 的桌面端和移动端界面。您可以在本地或远程使用它来查看 Claude Code、Cursor 或 Codex 中的活跃项目和会话,并从任何地方(移动端或桌面端)对它们进行修改。这为您提供了一个在任何地方都能正常使用的合适界面。
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ko.md">한국어</a></i></div>
## 截图
<div align="center">
<table>
<tr>
<td align="center">
<h3>桌面视图</h3>
<img src="public/screenshots/desktop-main.png" alt="Desktop Interface" width="400">
<br>
<em>显示项目概览和聊天界面的主界面</em>
</td>
<td align="center">
<h3>移动端体验</h3>
<img src="public/screenshots/mobile-chat.png" alt="Mobile Interface" width="250">
<br>
<em>具有触摸导航的响应式移动设计</em>
</td>
</tr>
<tr>
<td align="center" colspan="2">
<h3>CLI 选择</h3>
<img src="public/screenshots/cli-selection.png" alt="CLI Selection" width="400">
<br>
<em>在 Claude Code、Cursor CLI 和 Codex 之间选择</em>
</td>
</tr>
</table>
</div>
## 功能特性
- **响应式设计** - 在桌面、平板和移动设备上无缝运行,您也可以在移动端使用 Claude Code、Cursor 或 Codex
- **交互式聊天界面** - 内置聊天界面,与 Claude Code、Cursor 或 Codex 无缝通信
- **集成 Shell 终端** - 通过内置 shell 功能直接访问 Claude Code、Cursor CLI 或 Codex
- **文件浏览器** - 交互式文件树,支持语法高亮和实时编辑
- **Git 浏览器** - 查看、暂存和提交您的更改。您还可以切换分支
- **会话管理** - 恢复对话、管理多个会话并跟踪历史记录
- **TaskMaster AI 集成** *(可选)* - 通过 AI 驱动的任务规划、PRD 解析和工作流自动化实现高级项目管理
- **模型兼容性** - 适用于 Claude Sonnet 4.5、Opus 4.5 和 GPT-5.2
## 快速开始
### 前置要求
- [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)
### 一键操作(推荐)
无需安装,直接运行:
```bash
npx @siteboon/claude-code-ui
```
服务器将启动并可通过 `http://localhost:3001`(或您配置的 PORT)访问。
**重启**: 停止服务器后只需再次运行相同的 `npx` 命令
### 全局安装(供常规使用)
为了频繁使用,一次性全局安装:
```bash
npm install -g @siteboon/claude-code-ui
```
然后使用简单命令启动:
```bash
claude-code-ui
```
**重启**: 使用 Ctrl+C 停止,然后再次运行 `claude-code-ui`
**更新**:
```bash
cloudcli update
```
### CLI 使用方法
全局安装后,您可以访问 `claude-code-ui``cloudcli` 命令:
| 命令 / 选项 | 简写 | 描述 |
|------------------|-------|-------------|
| `cloudcli``claude-code-ui` | | 启动服务器(默认) |
| `cloudcli start` | | 显式启动服务器 |
| `cloudcli status` | | 显示配置和数据位置 |
| `cloudcli update` | | 更新到最新版本 |
| `cloudcli help` | | 显示帮助信息 |
| `cloudcli version` | | 显示版本信息 |
| `--port <port>` | `-p` | 设置服务器端口(默认: 3001) |
| `--database-path <path>` | | 设置自定义数据库位置 |
**示例:**
```bash
cloudcli # 使用默认设置启动
cloudcli -p 8080 # 在自定义端口启动
cloudcli status # 显示当前配置
```
### 作为后台服务运行(推荐用于生产环境)
在生产环境中,使用 PM2(Process Manager 2)将 Claude Code UI 作为后台服务运行:
#### 安装 PM2
```bash
npm install -g pm2
```
#### 作为后台服务启动
```bash
# 在后台启动服务器
pm2 start claude-code-ui --name "claude-code-ui"
# 或使用更短的别名
pm2 start cloudcli --name "claude-code-ui"
# 在自定义端口启动
pm2 start cloudcli --name "claude-code-ui" -- --port 8080
```
#### 系统启动时自动启动
要使 Claude Code UI 在系统启动时自动启动:
```bash
# 为您的平台生成启动脚本
pm2 startup
# 保存当前进程列表
pm2 save
```
### 本地开发安装
1. **克隆仓库:**
```bash
git clone https://github.com/siteboon/claudecodeui.git
cd claudecodeui
```
2. **安装依赖:**
```bash
npm install
```
3. **配置环境:**
```bash
cp .env.example .env
# 使用您喜欢的设置编辑 .env
```
4. **启动应用程序:**
```bash
# 开发模式(支持热重载)
npm run dev
```
应用程序将在您在 .env 中指定的端口启动
5. **打开浏览器:**
- 开发环境: `http://localhost:3001`
## 安全与工具配置
**🔒 重要提示**: 所有 Claude Code 工具**默认禁用**。这可以防止潜在的有害操作自动运行。
### 启用工具
要使用 Claude Code 的完整功能,您需要手动启用工具:
1. **打开工具设置** - 点击侧边栏中的齿轮图标
3. **选择性启用** - 仅打开您需要的工具
4. **应用设置** - 您的偏好设置将保存在本地
<div align="center">
![工具设置模态框](public/screenshots/tools-modal.png)
*工具设置界面 - 仅启用您需要的内容*
</div>
**推荐方法**: 首先启用基本工具,然后根据需要添加更多。您可以随时调整这些设置。
## TaskMaster AI 集成 *(可选)*
Claude Code UI 支持 **[TaskMaster AI](https://github.com/eyaltoledano/claude-task-master)**(aka claude-task-master)集成,用于高级项目管理和 AI 驱动的任务规划。
它提供
- 从 PRD(产品需求文档)生成 AI 驱动的任务
- 智能任务分解和依赖管理
- 可视化任务板和进度跟踪
**设置与文档**: 访问 [TaskMaster AI GitHub 仓库](https://github.com/eyaltoledano/claude-task-master)获取安装说明、配置指南和使用示例。
安装后,您应该能够从设置中启用它
## 使用指南
### 核心功能
#### 项目管理
当可用时,它会自动发现 Claude Code、Cursor 或 Codex 会话并将它们分组到项目中
- **项目操作** - 重命名、删除和组织项目
- **智能导航** - 快速访问最近的项目和会话
- **MCP 支持** - 通过 UI 添加您自己的 MCP 服务器
#### 聊天界面
- **使用响应式聊天或 Claude Code/Cursor CLI/Codex CLI** - 您可以使用自适应聊天界面或使用 shell 按钮连接到您选择的 CLI
- **实时通信** - 通过 WebSocket 连接从您选择的 CLI(Claude Code/Cursor/Codex)流式传输响应
- **会话管理** - 恢复之前的对话或启动新会话
- **消息历史** - 带有时间戳和元数据的完整对话历史
- **多格式支持** - 文本、代码块和文件引用
#### 文件浏览器与编辑器
- **交互式文件树** - 使用展开/折叠导航浏览项目结构
- **实时文件编辑** - 直接在界面中读取、修改和保存文件
- **语法高亮** - 支持多种编程语言
- **文件操作** - 创建、重命名、删除文件和目录
#### Git 浏览器
#### TaskMaster AI 集成 *(可选)*
- **可视化任务板** - 用于管理开发任务的看板风格界面
- **PRD 解析器** - 创建产品需求文档并将其解析为结构化任务
- **进度跟踪** - 实时状态更新和完成跟踪
#### 会话管理
- **会话持久化** - 所有对话自动保存
- **会话组织** - 按项目和 timestamp 分组会话
- **会话操作** - 重命名、删除和导出对话历史
- **跨设备同步** - 从任何设备访问会话
### 移动应用
- **响应式设计** - 针对所有屏幕尺寸进行优化
- **触摸友好界面** - 滑动手势和触摸导航
- **移动导航** - 底部选项卡栏,方便拇指导航
- **自适应布局** - 可折叠侧边栏和智能内容优先级
- **添加到主屏幕快捷方式** - 添加快捷方式到主屏幕,应用程序将像 PWA 一样运行
## 架构
### 系统概览
```
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Frontend │ │ Backend │ │ Agent │
│ (React/Vite) │◄──►│ (Express/WS) │◄──►│ Integration │
│ │ │ │ │ │
└─────────────────┘ └─────────────────┘ └─────────────────┘
```
### 后端 (Node.js + Express)
- **Express 服务器** - 具有静态文件服务的 RESTful API
- **WebSocket 服务器** - 用于聊天和项目刷新的通信
- **Agent 集成 (Claude Code / Cursor CLI / Codex)** - 进程生成和管理
- **文件系统 API** - 为项目公开文件浏览器
### 前端 (React + Vite)
- **React 18** - 带有 hooks 的现代组件架构
- **CodeMirror** - 具有语法高亮的高级代码编辑器
### 贡献
我们欢迎贡献!请遵循以下指南:
#### 入门
1. **Fork** 仓库
2. **克隆** 您的 fork: `git clone <your-fork-url>`
3. **安装** 依赖: `npm install`
4. **创建** 特性分支: `git checkout -b feature/amazing-feature`
#### 开发流程
1. **进行更改**,遵循现有代码风格
2. **彻底测试** - 确保所有功能正常工作
3. **运行质量检查**: `npm run lint && npm run format`
4. **提交**,遵循 [Conventional Commits](https://conventionalcommits.org/)的描述性消息
5. **推送** 到您的分支: `git push origin feature/amazing-feature`
6. **提交** 拉取请求,包括:
- 更改的清晰描述
- UI 更改的截图
- 适用时的测试结果
#### 贡献内容
- **错误修复** - 帮助我们提高稳定性
- **新功能** - 增强功能(先在 issue 中讨论)
- **文档** - 改进指南和 API 文档
- **UI/UX 改进** - 更好的用户体验
- **性能优化** - 让它更快
## 故障排除
### 常见问题与解决方案
#### "未找到 Claude 项目"
**问题**: UI 显示没有项目或项目列表为空
**解决方案**:
- 确保已正确安装 [Claude Code](https://docs.anthropic.com/en/docs/claude-code)
- 至少在一个项目目录中运行 `claude` 命令以进行初始化
- 验证 `~/.claude/projects/` 目录存在并具有适当的权限
#### 文件浏览器问题
**问题**: 文件无法加载、权限错误、空目录
**解决方案**:
- 检查项目目录权限(在终端中使用 `ls -la`)
- 验证项目路径存在且可访问
- 查看服务器控制台日志以获取详细错误消息
- 确保您未尝试访问项目范围之外的系统目录
## 许可证
GNU General Public License v3.0 - 详见 [LICENSE](LICENSE) 文件。
本项目是开源的,在 GPL v3 许可下可自由使用、修改和分发。
## 致谢
### 构建工具
- **[Claude Code](https://docs.anthropic.com/en/docs/claude-code)** - Anthropic 的官方 CLI
- **[Cursor CLI](https://docs.cursor.com/en/cli/overview)** - Cursor 的官方 CLI
- **[Codex](https://developers.openai.com/codex)** - OpenAI Codex
- **[React](https://react.dev/)** - 用户界面库
- **[Vite](https://vitejs.dev/)** - 快速构建工具和开发服务器
- **[Tailwind CSS](https://tailwindcss.com/)** - 实用优先的 CSS 框架
- **[CodeMirror](https://codemirror.net/)** - 高级代码编辑器
- **[TaskMaster AI](https://github.com/eyaltoledano/claude-task-master)** *(可选)* - AI 驱动的项目管理和任务规划
## 支持与社区
### 保持更新
- **Star** 此仓库以表示支持
- **Watch** 以获取更新和新版本
- **Follow** 项目以获取公告
### 赞助商
- [Siteboon - AI powered website builder](https://siteboon.ai)
---
<div align="center">
<strong>为 Claude Code、Cursor 和 Codex 社区精心打造。</strong>
</div>

580
package-lock.json generated
View File

@@ -1,15 +1,15 @@
{
"name": "@siteboon/claude-code-ui",
"version": "1.13.6",
"version": "1.18.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@siteboon/claude-code-ui",
"version": "1.13.6",
"license": "MIT",
"version": "1.18.0",
"license": "GPL-3.0",
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.1.29",
"@anthropic-ai/claude-agent-sdk": "^0.1.71",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-html": "^6.4.9",
"@codemirror/lang-javascript": "^6.2.4",
@@ -39,6 +39,8 @@
"express": "^4.18.2",
"fuse.js": "^7.0.0",
"gray-matter": "^4.0.3",
"i18next": "^25.7.4",
"i18next-browser-languagedetector": "^8.2.0",
"jsonwebtoken": "^9.0.2",
"katex": "^0.16.25",
"lucide-react": "^0.515.0",
@@ -49,9 +51,12 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-dropzone": "^14.2.3",
"react-i18next": "^16.5.3",
"react-markdown": "^10.1.0",
"react-router-dom": "^6.8.1",
"react-syntax-highlighter": "^15.6.1",
"rehype-katex": "^7.0.1",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.0",
"remark-math": "^6.0.0",
"sqlite": "^5.1.1",
@@ -64,6 +69,7 @@
"cloudcli": "server/cli.js"
},
"devDependencies": {
"@types/node": "^22.19.7",
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@vitejs/plugin-react": "^4.6.0",
@@ -75,6 +81,7 @@
"release-it": "^19.0.5",
"sharp": "^0.34.2",
"tailwindcss": "^3.4.0",
"typescript": "^5.9.3",
"vite": "^7.0.4"
}
},
@@ -105,9 +112,9 @@
}
},
"node_modules/@anthropic-ai/claude-agent-sdk": {
"version": "0.1.29",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.1.29.tgz",
"integrity": "sha512-VbR2ybPdJHVKAD3pQdruVw8LdXoPbk5J59xU/bQoMNzAsBckHrD2LhupMJrBxLUWxLaPkIUlNKquGBRbkoK84Q==",
"version": "0.1.71",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.1.71.tgz",
"integrity": "sha512-O34SQCEsdU11Z2uy30GaJGRLdRbEwEvaQs8APywHVOW/EdIGE0rS/AE4V6p9j45/j5AFwh2USZWlbz5NTlLnrw==",
"license": "SEE LICENSE IN README.md",
"engines": {
"node": ">=18.0.0"
@@ -118,10 +125,88 @@
"@img/sharp-linux-arm": "^0.33.5",
"@img/sharp-linux-arm64": "^0.33.5",
"@img/sharp-linux-x64": "^0.33.5",
"@img/sharp-linuxmusl-arm64": "^0.33.5",
"@img/sharp-linuxmusl-x64": "^0.33.5",
"@img/sharp-win32-x64": "^0.33.5"
},
"peerDependencies": {
"zod": "^3.24.1"
"zod": "^3.24.1 || ^4.0.0"
}
},
"node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz",
"integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz",
"integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz",
"integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.0.4"
}
},
"node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz",
"integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.0.4"
}
},
"node_modules/@babel/code-frame": {
@@ -359,9 +444,9 @@
}
},
"node_modules/@babel/runtime": {
"version": "7.27.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz",
"integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==",
"version": "7.28.6",
"resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.28.6.tgz",
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -2902,6 +2987,16 @@
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.19.7",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz",
"integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@types/parse-path": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/@types/parse-path/-/parse-path-7.0.3.tgz",
@@ -5057,6 +5152,19 @@
"reusify": "^1.0.4"
}
},
"node_modules/fault": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/fault/-/fault-1.0.4.tgz",
"integrity": "sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA==",
"license": "MIT",
"dependencies": {
"format": "^0.2.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/file-selector": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz",
@@ -5136,6 +5244,14 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/format": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz",
"integrity": "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==",
"engines": {
"node": ">=0.4.x"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -5683,6 +5799,31 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-raw": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-9.1.0.tgz",
"integrity": "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"@types/unist": "^3.0.0",
"@ungap/structured-clone": "^1.0.0",
"hast-util-from-parse5": "^8.0.0",
"hast-util-to-parse5": "^8.0.0",
"html-void-elements": "^3.0.0",
"mdast-util-to-hast": "^13.0.0",
"parse5": "^7.0.0",
"unist-util-position": "^5.0.0",
"unist-util-visit": "^5.0.0",
"vfile": "^6.0.0",
"web-namespaces": "^2.0.0",
"zwitch": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-to-jsx-runtime": {
"version": "2.3.6",
"resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz",
@@ -5710,6 +5851,25 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-to-parse5": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.1.tgz",
"integrity": "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"comma-separated-tokens": "^2.0.0",
"devlop": "^1.0.0",
"property-information": "^7.0.0",
"space-separated-tokens": "^2.0.0",
"web-namespaces": "^2.0.0",
"zwitch": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-to-text": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz",
@@ -5756,6 +5916,30 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/highlight.js": {
"version": "10.7.3",
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz",
"integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==",
"license": "BSD-3-Clause",
"engines": {
"node": "*"
}
},
"node_modules/highlightjs-vue": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/highlightjs-vue/-/highlightjs-vue-1.0.0.tgz",
"integrity": "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==",
"license": "CC0-1.0"
},
"node_modules/html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://registry.npmmirror.com/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
"license": "MIT",
"dependencies": {
"void-elements": "3.1.0"
}
},
"node_modules/html-url-attributes": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
@@ -5766,6 +5950,16 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/html-void-elements": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz",
"integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/http-cache-semantics": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
@@ -5837,6 +6031,46 @@
"ms": "^2.0.0"
}
},
"node_modules/i18next": {
"version": "25.7.4",
"resolved": "https://registry.npmmirror.com/i18next/-/i18next-25.7.4.tgz",
"integrity": "sha512-hRkpEblXXcXSNbw8mBNq9042OEetgyB/ahc/X17uV/khPwzV+uB8RHceHh3qavyrkPJvmXFKXME2Sy1E0KjAfw==",
"funding": [
{
"type": "individual",
"url": "https://locize.com"
},
{
"type": "individual",
"url": "https://locize.com/i18next.html"
},
{
"type": "individual",
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
}
],
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.4"
},
"peerDependencies": {
"typescript": "^5"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/i18next-browser-languagedetector": {
"version": "8.2.0",
"resolved": "https://registry.npmmirror.com/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.0.tgz",
"integrity": "sha512-P+3zEKLnOF0qmiesW383vsLdtQVyKtCNA9cjSoKCppTKPQVfKd2W8hbVo5ZhNJKDqeM7BOcvNoKJOjpHh4Js9g==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.23.2"
}
},
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
@@ -6569,6 +6803,20 @@
"loose-envify": "cli.js"
}
},
"node_modules/lowlight": {
"version": "1.20.0",
"resolved": "https://registry.npmjs.org/lowlight/-/lowlight-1.20.0.tgz",
"integrity": "sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw==",
"license": "MIT",
"dependencies": {
"fault": "^1.0.0",
"highlight.js": "~10.7.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -8101,9 +8349,9 @@
}
},
"node_modules/node-pty": {
"version": "1.1.0-beta9",
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0-beta9.tgz",
"integrity": "sha512-/Ue38pvXJdgRZ3+me1FgfglLd301GhJN0NStiotdt61tm43N5htUyR/IXOUzOKuNaFmCwIhy6nwb77Ky41LMbw==",
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/node-pty/-/node-pty-1.1.0.tgz",
"integrity": "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@@ -8840,6 +9088,15 @@
"node": ">=10"
}
},
"node_modules/prismjs": {
"version": "1.30.0",
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz",
"integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/proc-log": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/proc-log/-/proc-log-4.2.0.tgz",
@@ -9086,6 +9343,33 @@
"react": ">= 16.8 || 18.0.0"
}
},
"node_modules/react-i18next": {
"version": "16.5.3",
"resolved": "https://registry.npmmirror.com/react-i18next/-/react-i18next-16.5.3.tgz",
"integrity": "sha512-fo+/NNch37zqxOzlBYrWMx0uy/yInPkRfjSuy4lqKdaecR17nvCHnEUt3QyzA8XjQ2B/0iW/5BhaHR3ZmukpGw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.4",
"html-parse-stringify": "^3.0.1",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
"i18next": ">= 25.6.2",
"react": ">= 16.8.0",
"typescript": "^5"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
@@ -9161,6 +9445,23 @@
"react-dom": ">=16.8"
}
},
"node_modules/react-syntax-highlighter": {
"version": "15.6.6",
"resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-15.6.6.tgz",
"integrity": "sha512-DgXrc+AZF47+HvAPEmn7Ua/1p10jNoVZVI/LoPiYdtY+OM+/nG5yefLHKJwdKqY1adMuHFbeyBaG9j64ML7vTw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.3.1",
"highlight.js": "^10.4.1",
"highlightjs-vue": "^1.0.0",
"lowlight": "^1.17.0",
"prismjs": "^1.30.0",
"refractor": "^3.6.0"
},
"peerDependencies": {
"react": ">= 0.14.0"
}
},
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@@ -9197,6 +9498,197 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/refractor": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/refractor/-/refractor-3.6.0.tgz",
"integrity": "sha512-MY9W41IOWxxk31o+YvFCNyNzdkc9M20NoZK5vq6jkv4I/uh2zkWcfudj0Q1fovjUQJrNewS9NMzeTtqPf+n5EA==",
"license": "MIT",
"dependencies": {
"hastscript": "^6.0.0",
"parse-entities": "^2.0.0",
"prismjs": "~1.27.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/refractor/node_modules/@types/hast": {
"version": "2.3.10",
"resolved": "https://registry.npmjs.org/@types/hast/-/hast-2.3.10.tgz",
"integrity": "sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw==",
"license": "MIT",
"dependencies": {
"@types/unist": "^2"
}
},
"node_modules/refractor/node_modules/@types/unist": {
"version": "2.0.11",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz",
"integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
"license": "MIT"
},
"node_modules/refractor/node_modules/character-entities": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/character-entities/-/character-entities-1.2.4.tgz",
"integrity": "sha512-iBMyeEHxfVnIakwOuDXpVkc54HijNgCyQB2w0VfGQThle6NXn50zU6V/u+LDhxHcDUPojn6Kpga3PTAD8W1bQw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/refractor/node_modules/character-entities-legacy": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-1.1.4.tgz",
"integrity": "sha512-3Xnr+7ZFS1uxeiUDvV02wQ+QDbc55o97tIV5zHScSPJpcLm/r0DFPcoY3tYRp+VZukxuMeKgXYmsXQHO05zQeA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/refractor/node_modules/character-reference-invalid": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-1.1.4.tgz",
"integrity": "sha512-mKKUkUbhPpQlCOfIuZkvSEgktjPFIsZKRRbC6KWVEMvlzblj3i3asQv5ODsrwt0N3pHAEvjP8KTQPHkp0+6jOg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/refractor/node_modules/comma-separated-tokens": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-1.0.8.tgz",
"integrity": "sha512-GHuDRO12Sypu2cV70d1dkA2EUmXHgntrzbpvOB+Qy+49ypNfGgFQIC2fhhXbnyrJRynDCAARsT7Ou0M6hirpfw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/refractor/node_modules/hast-util-parse-selector": {
"version": "2.2.5",
"resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-2.2.5.tgz",
"integrity": "sha512-7j6mrk/qqkSehsM92wQjdIgWM2/BW61u/53G6xmC8i1OmEdKLHbk419QKQUjz6LglWsfqoiHmyMRkP1BGjecNQ==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/refractor/node_modules/hastscript": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/hastscript/-/hastscript-6.0.0.tgz",
"integrity": "sha512-nDM6bvd7lIqDUiYEiu5Sl/+6ReP0BMk/2f4U/Rooccxkj0P5nm+acM5PrGJ/t5I8qPGiqZSE6hVAwZEdZIvP4w==",
"license": "MIT",
"dependencies": {
"@types/hast": "^2.0.0",
"comma-separated-tokens": "^1.0.0",
"hast-util-parse-selector": "^2.0.0",
"property-information": "^5.0.0",
"space-separated-tokens": "^1.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/refractor/node_modules/is-alphabetical": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-1.0.4.tgz",
"integrity": "sha512-DwzsA04LQ10FHTZuL0/grVDk4rFoVH1pjAToYwBrHSxcrBIGQuXrQMtD5U1b0U2XVgKZCTLLP8u2Qxqhy3l2Vg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/refractor/node_modules/is-alphanumerical": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-1.0.4.tgz",
"integrity": "sha512-UzoZUr+XfVz3t3v4KyGEniVL9BDRoQtY7tOyrRybkVNjDFWyo1yhXNGrrBTQxp3ib9BLAWs7k2YKBQsFRkZG9A==",
"license": "MIT",
"dependencies": {
"is-alphabetical": "^1.0.0",
"is-decimal": "^1.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/refractor/node_modules/is-decimal": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-1.0.4.tgz",
"integrity": "sha512-RGdriMmQQvZ2aqaQq3awNA6dCGtKpiDFcOzrTWrDAT2MiWrKQVPmxLGHl7Y2nNu6led0kEyoX0enY0qXYsv9zw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/refractor/node_modules/is-hexadecimal": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-1.0.4.tgz",
"integrity": "sha512-gyPJuv83bHMpocVYoqof5VDiZveEoGoFL8m3BXNb2VW8Xs+rz9kqO8LOQ5DH6EsuvilT1ApazU0pyl+ytbPtlw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/refractor/node_modules/parse-entities": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-2.0.0.tgz",
"integrity": "sha512-kkywGpCcRYhqQIchaWqZ875wzpS/bMKhz5HnN3p7wveJTkTtyAB/AlnS0f8DFSqYW1T82t6yEAkEcB+A1I3MbQ==",
"license": "MIT",
"dependencies": {
"character-entities": "^1.0.0",
"character-entities-legacy": "^1.0.0",
"character-reference-invalid": "^1.0.0",
"is-alphanumerical": "^1.0.0",
"is-decimal": "^1.0.0",
"is-hexadecimal": "^1.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/refractor/node_modules/prismjs": {
"version": "1.27.0",
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.27.0.tgz",
"integrity": "sha512-t13BGPUlFDR7wRB5kQDG4jjl7XeuH6jbJGt11JHPL96qwsEHNX2+68tFXqc1/k+/jALsbSWJKUOT/hcYAZ5LkA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/refractor/node_modules/property-information": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/property-information/-/property-information-5.6.0.tgz",
"integrity": "sha512-YUHSPk+A30YPv+0Qf8i9Mbfe/C0hdPXk1s1jPVToV8pk8BQtpw10ct89Eo7OWkutrwqvT0eicAxlOg3dOAu8JA==",
"license": "MIT",
"dependencies": {
"xtend": "^4.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/refractor/node_modules/space-separated-tokens": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-1.1.5.tgz",
"integrity": "sha512-q/JSVd1Lptzhf5bkYm4ob4iWPjx0KiRe3sRFBNrVqbJkFaBm5vbbowy1mymoPNLRa52+oadOhJ+K49wsSeSjTA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/rehype-katex": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/rehype-katex/-/rehype-katex-7.0.1.tgz",
@@ -9216,6 +9708,21 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/rehype-raw": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz",
"integrity": "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"hast-util-raw": "^9.0.0",
"vfile": "^6.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/release-it": {
"version": "19.0.5",
"resolved": "https://registry.npmjs.org/release-it/-/release-it-19.0.5.tgz",
@@ -11315,6 +11822,20 @@
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"license": "MIT"
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/uglify-js": {
"version": "3.19.3",
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz",
@@ -11339,6 +11860,13 @@
"node": ">=18.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/unified": {
"version": "11.0.5",
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
@@ -11536,6 +12064,15 @@
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmmirror.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@@ -11708,6 +12245,15 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmmirror.com/void-elements/-/void-elements-3.1.0.tgz",
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
@@ -12110,9 +12656,9 @@
}
},
"node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"license": "MIT",
"peer": true,
"funding": {

View File

@@ -1,6 +1,6 @@
{
"name": "@siteboon/claude-code-ui",
"version": "1.13.6",
"version": "1.18.0",
"description": "A web-based UI for Claude Code CLI",
"type": "module",
"main": "server/index.js",
@@ -14,7 +14,7 @@
"dist/",
"README.md"
],
"homepage": "https://claudecodeui.siteboon.ai",
"homepage": "https://cloudcli.ai",
"repository": {
"type": "git",
"url": "git+https://github.com/siteboon/claudecodeui.git"
@@ -28,20 +28,21 @@
"client": "vite --host",
"build": "vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit -p tsconfig.json",
"start": "npm run build && npm run server",
"release": "./release.sh"
},
"keywords": [
"claude coode",
"claude code",
"ai",
"anthropic",
"ui",
"mobile"
],
"author": "Claude Code UI Contributors",
"license": "MIT",
"license": "GPL-3.0",
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.1.29",
"@anthropic-ai/claude-agent-sdk": "^0.1.71",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-html": "^6.4.9",
"@codemirror/lang-javascript": "^6.2.4",
@@ -71,6 +72,8 @@
"express": "^4.18.2",
"fuse.js": "^7.0.0",
"gray-matter": "^4.0.3",
"i18next": "^25.7.4",
"i18next-browser-languagedetector": "^8.2.0",
"jsonwebtoken": "^9.0.2",
"katex": "^0.16.25",
"lucide-react": "^0.515.0",
@@ -81,9 +84,12 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-dropzone": "^14.2.3",
"react-i18next": "^16.5.3",
"react-markdown": "^10.1.0",
"react-router-dom": "^6.8.1",
"react-syntax-highlighter": "^15.6.1",
"rehype-katex": "^7.0.1",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.0",
"remark-math": "^6.0.0",
"sqlite": "^5.1.1",
@@ -92,6 +98,7 @@
"ws": "^8.14.2"
},
"devDependencies": {
"@types/node": "^22.19.7",
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@vitejs/plugin-react": "^4.6.0",
@@ -103,6 +110,7 @@
"release-it": "^19.0.5",
"sharp": "^0.34.2",
"tailwindcss": "^3.4.0",
"typescript": "^5.9.3",
"vite": "^7.0.4"
}
}

View File

@@ -13,41 +13,26 @@
*/
import { query } from '@anthropic-ai/claude-agent-sdk';
// Used to mint unique approval request IDs when randomUUID is not available.
// This keeps parallel tool approvals from colliding; it does not add any crypto/security guarantees.
import crypto from 'crypto';
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
import { CLAUDE_MODELS } from '../shared/modelConstants.js';
// Session tracking: Map of session IDs to active query instances
const activeSessions = new Map();
// In-memory registry of pending tool approvals keyed by requestId.
// This does not persist approvals or share across processes; it exists so the
// SDK can pause tool execution while the UI decides what to do.
const pendingToolApprovals = new Map();
// Default approval timeout kept under the SDK's 60s control timeout.
// This does not change SDK limits; it only defines how long we wait for the UI,
// introduced to avoid hanging the run when no decision arrives.
const TOOL_APPROVAL_TIMEOUT_MS = parseInt(process.env.CLAUDE_TOOL_APPROVAL_TIMEOUT_MS, 10) || 55000;
// Generate a stable request ID for UI approval flows.
// This does not encode tool details or get shown to users; it exists so the UI
// can respond to the correct pending request without collisions.
const TOOLS_REQUIRING_INTERACTION = new Set(['AskUserQuestion']);
function createRequestId() {
// if clause is used because randomUUID is not available in older Node.js versions
if (typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return crypto.randomBytes(16).toString('hex');
}
// Wait for a UI approval decision, honoring SDK cancellation.
// This does not auto-approve or auto-deny; it only resolves with UI input,
// and it cleans up the pending map to avoid leaks, introduced to prevent
// replying after the SDK cancels the control request.
function waitForToolApproval(requestId, options = {}) {
const { timeoutMs = TOOL_APPROVAL_TIMEOUT_MS, signal, onCancel } = options;
@@ -61,24 +46,25 @@ function waitForToolApproval(requestId, options = {}) {
resolve(decision);
};
let timeout;
const cleanup = () => {
pendingToolApprovals.delete(requestId);
clearTimeout(timeout);
if (timeout) clearTimeout(timeout);
if (signal && abortHandler) {
signal.removeEventListener('abort', abortHandler);
}
};
// Timeout is local to this process; it does not override SDK timing.
// It exists to prevent the UI prompt from lingering indefinitely.
const timeout = setTimeout(() => {
onCancel?.('timeout');
finalize(null);
}, timeoutMs);
// timeoutMs 0 = wait indefinitely (interactive tools)
if (timeoutMs > 0) {
timeout = setTimeout(() => {
onCancel?.('timeout');
finalize(null);
}, timeoutMs);
}
const abortHandler = () => {
// If the SDK cancels the control request, stop waiting to avoid
// replying after the process is no longer ready for writes.
onCancel?.('cancelled');
finalize({ cancelled: true });
};
@@ -98,9 +84,6 @@ function waitForToolApproval(requestId, options = {}) {
});
}
// Resolve a pending approval. This does not validate the decision payload;
// validation and tool matching remain in canUseTool, which keeps this as a
// lightweight WebSocket -> SDK relay.
function resolveToolApproval(requestId, decision) {
const resolver = pendingToolApprovals.get(requestId);
if (resolver) {
@@ -175,9 +158,6 @@ function mapCliOptionsToSDK(options = {}) {
sdkOptions.permissionMode = 'bypassPermissions';
}
// Map allowed tools (always set to avoid implicit "allow all" defaults).
// This does not grant permissions by itself; it just configures the SDK,
// introduced because leaving it undefined made the SDK treat it as "all tools allowed."
let allowedTools = [...(settings.allowedTools || [])];
// Add plan mode default tools
@@ -192,8 +172,11 @@ function mapCliOptionsToSDK(options = {}) {
sdkOptions.allowedTools = allowedTools;
// Map disallowed tools (always set so the SDK doesn't treat "undefined" as permissive).
// This does not override allowlists; it only feeds the canUseTool gate.
// Use the tools preset to make all default built-in tools available (including AskUserQuestion).
// This was introduced in SDK 0.1.57. Omitting this preserves existing behavior (all tools available),
// but being explicit ensures forward compatibility and clarity.
sdkOptions.tools = { type: 'preset', preset: 'claude_code' };
sdkOptions.disallowedTools = settings.disallowedTools || [];
// Map model (default to sonnet)
@@ -267,9 +250,7 @@ function getAllSessions() {
* @returns {Object} Transformed message ready for WebSocket
*/
function transformMessage(sdkMessage) {
// SDK messages are already in a format compatible with the frontend
// The CLI sends them wrapped in {type: 'claude-response', data: message}
// We'll do the same here to maintain compatibility
// Pass-through; SDK messages match frontend format.
return sdkMessage;
}
@@ -490,27 +471,27 @@ async function queryClaudeSDK(command, options = {}, ws) {
tempImagePaths = imageResult.tempImagePaths;
tempDir = imageResult.tempDir;
// Gate tool usage with explicit UI approval when not auto-approved.
// This does not render UI or persist permissions; it only bridges to the UI
// via WebSocket and waits for the response, introduced so tool calls pause
// instead of auto-running when the allowlist is empty.
sdkOptions.canUseTool = async (toolName, input, context) => {
if (sdkOptions.permissionMode === 'bypassPermissions') {
return { behavior: 'allow', updatedInput: input };
}
const requiresInteraction = TOOLS_REQUIRING_INTERACTION.has(toolName);
const isDisallowed = (sdkOptions.disallowedTools || []).some(entry =>
matchesToolPermission(entry, toolName, input)
);
if (isDisallowed) {
return { behavior: 'deny', message: 'Tool disallowed by settings' };
}
if (!requiresInteraction) {
if (sdkOptions.permissionMode === 'bypassPermissions') {
return { behavior: 'allow', updatedInput: input };
}
const isAllowed = (sdkOptions.allowedTools || []).some(entry =>
matchesToolPermission(entry, toolName, input)
);
if (isAllowed) {
return { behavior: 'allow', updatedInput: input };
const isDisallowed = (sdkOptions.disallowedTools || []).some(entry =>
matchesToolPermission(entry, toolName, input)
);
if (isDisallowed) {
return { behavior: 'deny', message: 'Tool disallowed by settings' };
}
const isAllowed = (sdkOptions.allowedTools || []).some(entry =>
matchesToolPermission(entry, toolName, input)
);
if (isAllowed) {
return { behavior: 'allow', updatedInput: input };
}
}
const requestId = createRequestId();
@@ -522,9 +503,8 @@ async function queryClaudeSDK(command, options = {}, ws) {
sessionId: capturedSessionId || sessionId || null
});
// Wait for the UI; if the SDK cancels, notify the UI so it can dismiss the banner.
// This does not retry or resurface the prompt; it just reflects the cancellation.
const decision = await waitForToolApproval(requestId, {
timeoutMs: requiresInteraction ? 0 : undefined,
signal: context?.signal,
onCancel: (reason) => {
ws.send({
@@ -544,8 +524,6 @@ async function queryClaudeSDK(command, options = {}, ws) {
}
if (decision.allow) {
// rememberEntry only updates this run's in-memory allowlist to prevent
// repeated prompts in the same session; persistence is handled by the UI.
if (decision.rememberEntry && typeof decision.rememberEntry === 'string') {
if (!sdkOptions.allowedTools.includes(decision.rememberEntry)) {
sdkOptions.allowedTools.push(decision.rememberEntry);
@@ -560,12 +538,22 @@ async function queryClaudeSDK(command, options = {}, ws) {
return { behavior: 'deny', message: decision.message ?? 'User denied tool use' };
};
// Create SDK query instance
// Set stream-close timeout for interactive tools (Query constructor reads it synchronously). Claude Agent SDK has a default of 5s and this overrides it
const prevStreamTimeout = process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT;
process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT = '300000';
const queryInstance = query({
prompt: finalCommand,
options: sdkOptions
});
// Restore immediately — Query constructor already captured the value
if (prevStreamTimeout !== undefined) {
process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT = prevStreamTimeout;
} else {
delete process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT;
}
// Track the query instance for abort capability
if (capturedSessionId) {
addSession(capturedSessionId, queryInstance, tempImagePaths, tempDir);
@@ -603,7 +591,8 @@ async function queryClaudeSDK(command, options = {}, ws) {
const transformedMessage = transformMessage(message);
ws.send({
type: 'claude-response',
data: transformedMessage
data: transformedMessage,
sessionId: capturedSessionId || sessionId || null
});
// Extract and send token budget updates from result messages
@@ -613,7 +602,8 @@ async function queryClaudeSDK(command, options = {}, ws) {
console.log('Token budget from modelUsage:', tokenBudget);
ws.send({
type: 'token-budget',
data: tokenBudget
data: tokenBudget,
sessionId: capturedSessionId || sessionId || null
});
}
}
@@ -651,7 +641,8 @@ async function queryClaudeSDK(command, options = {}, ws) {
// Send error to WebSocket
ws.send({
type: 'claude-error',
error: error.message
error: error.message,
sessionId: capturedSessionId || sessionId || null
});
throw error;

View File

@@ -0,0 +1,5 @@
/**
* Environment Flag: Is Platform
* Indicates if the app is running in Platform mode (hosted) or OSS mode (self-hosted)
*/
export const IS_PLATFORM = process.env.VITE_IS_PLATFORM === 'true';

View File

@@ -114,7 +114,8 @@ async function spawnCursor(command, options = {}, ws) {
// Send system info to frontend
ws.send({
type: 'cursor-system',
data: response
data: response,
sessionId: capturedSessionId || sessionId || null
});
}
break;
@@ -123,7 +124,8 @@ async function spawnCursor(command, options = {}, ws) {
// Forward user message
ws.send({
type: 'cursor-user',
data: response
data: response,
sessionId: capturedSessionId || sessionId || null
});
break;
@@ -142,7 +144,8 @@ async function spawnCursor(command, options = {}, ws) {
type: 'text_delta',
text: textContent
}
}
},
sessionId: capturedSessionId || sessionId || null
});
}
break;
@@ -157,7 +160,8 @@ async function spawnCursor(command, options = {}, ws) {
type: 'claude-response',
data: {
type: 'content_block_stop'
}
},
sessionId: capturedSessionId || sessionId || null
});
}
@@ -174,7 +178,8 @@ async function spawnCursor(command, options = {}, ws) {
// Forward any other message types
ws.send({
type: 'cursor-response',
data: response
data: response,
sessionId: capturedSessionId || sessionId || null
});
}
} catch (parseError) {
@@ -182,7 +187,8 @@ async function spawnCursor(command, options = {}, ws) {
// If not JSON, send as raw text
ws.send({
type: 'cursor-output',
data: line
data: line,
sessionId: capturedSessionId || sessionId || null
});
}
}
@@ -193,7 +199,8 @@ async function spawnCursor(command, options = {}, ws) {
console.error('Cursor CLI stderr:', data.toString());
ws.send({
type: 'cursor-error',
error: data.toString()
error: data.toString(),
sessionId: capturedSessionId || sessionId || null
});
});
@@ -229,7 +236,8 @@ async function spawnCursor(command, options = {}, ws) {
ws.send({
type: 'cursor-error',
error: error.message
error: error.message,
sessionId: capturedSessionId || sessionId || null
});
reject(error);
@@ -264,4 +272,4 @@ export {
abortCursorSession,
isCursorSessionActive,
getActiveCursorSessions
};
};

View File

@@ -1,5 +1,6 @@
#!/usr/bin/env node
// Load environment variables from .env file
// Load environment variables before other imports execute
import './load-env.js';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
@@ -28,22 +29,6 @@ const c = {
dim: (text) => `${colors.dim}${text}${colors.reset}`,
};
try {
const envPath = path.join(__dirname, '../.env');
const envFile = fs.readFileSync(envPath, 'utf8');
envFile.split('\n').forEach(line => {
const trimmedLine = line.trim();
if (trimmedLine && !trimmedLine.startsWith('#')) {
const [key, ...valueParts] = trimmedLine.split('=');
if (key && valueParts.length > 0 && !process.env[key]) {
process.env[key] = valueParts.join('=').trim();
}
}
});
} catch (e) {
console.log('No .env file found or error reading it:', e.message);
}
console.log('PORT from env:', process.env.PORT);
import express from 'express';
@@ -70,97 +55,152 @@ import mcpUtilsRoutes from './routes/mcp-utils.js';
import commandsRoutes from './routes/commands.js';
import settingsRoutes from './routes/settings.js';
import agentRoutes from './routes/agent.js';
import projectsRoutes from './routes/projects.js';
import projectsRoutes, { WORKSPACES_ROOT, validateWorkspacePath } from './routes/projects.js';
import cliAuthRoutes from './routes/cli-auth.js';
import userRoutes from './routes/user.js';
import codexRoutes from './routes/codex.js';
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
// Setup file system watcher for Claude projects folder using chokidar
// Broadcast progress to all connected WebSocket clients
function broadcastProgress(progress) {
const message = JSON.stringify({
type: 'loading_progress',
...progress
});
connectedClients.forEach(client => {
if (client.readyState === WebSocket.OPEN) {
client.send(message);
}
});
}
// 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 () => {
try {
const debouncedUpdate = (eventType, filePath, provider, rootPath) => {
if (projectsWatcherDebounceTimer) {
clearTimeout(projectsWatcherDebounceTimer);
}
// Clear project directory cache when files change
clearProjectDirectoryCache();
projectsWatcherDebounceTimer = setTimeout(async () => {
// Prevent reentrant calls
if (isGetProjectsRunning) {
return;
}
// Get updated projects list
const updatedProjects = await getProjects();
try {
isGetProjectsRunning = true;
// 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)
});
// Clear project directory cache when files change
clearProjectDirectoryCache();
connectedClients.forEach(client => {
if (client.readyState === WebSocket.OPEN) {
client.send(updateMessage);
}
});
// Get updated projects list
const updatedProjects = await getProjects(broadcastProgress);
} catch (error) {
console.error('[ERROR] Error handling project changes:', error);
// 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
}
}, 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');
}
}
@@ -170,6 +210,69 @@ const server = http.createServer(app);
const ptySessionsMap = new Map();
const PTY_SESSION_TIMEOUT = 30 * 60 * 1000;
const SHELL_URL_PARSE_BUFFER_LIMIT = 32768;
const ANSI_ESCAPE_SEQUENCE_REGEX = /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1B\\))/g;
const TRAILING_URL_PUNCTUATION_REGEX = /[)\]}>.,;:!?]+$/;
function stripAnsiSequences(value = '') {
return value.replace(ANSI_ESCAPE_SEQUENCE_REGEX, '');
}
function normalizeDetectedUrl(url) {
if (!url || typeof url !== 'string') return null;
const cleaned = url.trim().replace(TRAILING_URL_PUNCTUATION_REGEX, '');
if (!cleaned) return null;
try {
const parsed = new URL(cleaned);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return null;
}
return parsed.toString();
} catch {
return null;
}
}
function extractUrlsFromText(value = '') {
const directMatches = value.match(/https?:\/\/[^\s<>"'`\\\x1b\x07]+/gi) || [];
// Handle wrapped terminal URLs split across lines by terminal width.
const wrappedMatches = [];
const continuationRegex = /^[A-Za-z0-9\-._~:/?#\[\]@!$&'()*+,;=%]+$/;
const lines = value.split(/\r?\n/);
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
const startMatch = line.match(/https?:\/\/[^\s<>"'`\\\x1b\x07]+/i);
if (!startMatch) continue;
let combined = startMatch[0];
let j = i + 1;
while (j < lines.length) {
const continuation = lines[j].trim();
if (!continuation) break;
if (!continuationRegex.test(continuation)) break;
combined += continuation;
j++;
}
wrappedMatches.push(combined.replace(/\r?\n\s*/g, ''));
}
return Array.from(new Set([...directMatches, ...wrappedMatches]));
}
function shouldAutoOpenUrlFromOutput(value = '') {
const normalized = value.toLowerCase();
return (
normalized.includes('browser didn\'t open') ||
normalized.includes('open this url') ||
normalized.includes('continue in your browser') ||
normalized.includes('press enter to open') ||
normalized.includes('open_url:')
);
}
// Single WebSocket server that handles both paths
const wss = new WebSocketServer({
@@ -178,7 +281,7 @@ const wss = new WebSocketServer({
console.log('WebSocket connection attempt to:', info.req.url);
// Platform mode: always allow connection
if (process.env.VITE_IS_PLATFORM === 'true') {
if (IS_PLATFORM) {
const user = authenticateWebSocket(null); // Will return first user
if (!user) {
console.log('[WARN] Platform mode: No user found in database');
@@ -366,7 +469,7 @@ app.post('/api/system/update', authenticateToken, async (req, res) => {
app.get('/api/projects', authenticateToken, async (req, res) => {
try {
const projects = await getProjects();
const projects = await getProjects(broadcastProgress);
res.json(projects);
} catch (error) {
res.status(500).json({ error: error.message });
@@ -433,11 +536,12 @@ app.delete('/api/projects/:projectName/sessions/:sessionId', authenticateToken,
}
});
// Delete project endpoint (only if empty)
// Delete project endpoint (force=true to delete with sessions)
app.delete('/api/projects/:projectName', authenticateToken, async (req, res) => {
try {
const { projectName } = req.params;
await deleteProject(projectName);
const force = req.query.force === 'true';
await deleteProject(projectName, force);
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: error.message });
@@ -461,22 +565,42 @@ app.post('/api/projects/create', authenticateToken, async (req, res) => {
}
});
const expandWorkspacePath = (inputPath) => {
if (!inputPath) return inputPath;
if (inputPath === '~') {
return WORKSPACES_ROOT;
}
if (inputPath.startsWith('~/') || inputPath.startsWith('~\\')) {
return path.join(WORKSPACES_ROOT, inputPath.slice(2));
}
return inputPath;
};
// Browse filesystem endpoint for project suggestions - uses existing getFileTree
app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
try {
const { path: dirPath } = req.query;
console.log('[API] Browse filesystem request for path:', dirPath);
console.log('[API] WORKSPACES_ROOT is:', WORKSPACES_ROOT);
// Default to home directory if no path provided
const homeDir = os.homedir();
let targetPath = dirPath ? dirPath.replace('~', homeDir) : homeDir;
const defaultRoot = WORKSPACES_ROOT;
let targetPath = dirPath ? expandWorkspacePath(dirPath) : defaultRoot;
// Resolve and normalize the path
targetPath = path.resolve(targetPath);
// Security check - ensure path is within allowed workspace root
const validation = await validateWorkspacePath(targetPath);
if (!validation.valid) {
return res.status(403).json({ error: validation.error });
}
const resolvedPath = validation.resolvedPath || targetPath;
// Security check - ensure path is accessible
try {
await fs.promises.access(targetPath);
const stats = await fs.promises.stat(targetPath);
await fs.promises.access(resolvedPath);
const stats = await fs.promises.stat(resolvedPath);
if (!stats.isDirectory()) {
return res.status(400).json({ error: 'Path is not a directory' });
@@ -486,7 +610,7 @@ app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
}
// Use existing getFileTree function with shallow depth (only direct children)
const fileTree = await getFileTree(targetPath, 1, 0, false); // maxDepth=1, showHidden=false
const fileTree = await getFileTree(resolvedPath, 1, 0, false); // maxDepth=1, showHidden=false
// Filter only directories and format for suggestions
const directories = fileTree
@@ -496,11 +620,23 @@ app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
name: item.name,
type: 'directory'
}))
.slice(0, 20); // Limit results
.sort((a, b) => {
const aHidden = a.name.startsWith('.');
const bHidden = b.name.startsWith('.');
if (aHidden && !bHidden) return 1;
if (!aHidden && bHidden) return -1;
return a.name.localeCompare(b.name);
});
// Add common directories if browsing home directory
const suggestions = [];
if (targetPath === homeDir) {
let resolvedWorkspaceRoot = defaultRoot;
try {
resolvedWorkspaceRoot = await fsPromises.realpath(defaultRoot);
} catch (error) {
// Use default root as-is if realpath fails
}
if (resolvedPath === resolvedWorkspaceRoot) {
const commonDirs = ['Desktop', 'Documents', 'Projects', 'Development', 'Dev', 'Code', 'workspace'];
const existingCommon = directories.filter(dir => commonDirs.includes(dir.name));
const otherDirs = directories.filter(dir => !commonDirs.includes(dir.name));
@@ -511,7 +647,7 @@ app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
}
res.json({
path: targetPath,
path: resolvedPath,
suggestions: suggestions
});
@@ -521,13 +657,52 @@ app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
}
});
app.post('/api/create-folder', authenticateToken, async (req, res) => {
try {
const { path: folderPath } = req.body;
if (!folderPath) {
return res.status(400).json({ error: 'Path is required' });
}
const expandedPath = expandWorkspacePath(folderPath);
const resolvedInput = path.resolve(expandedPath);
const validation = await validateWorkspacePath(resolvedInput);
if (!validation.valid) {
return res.status(403).json({ error: validation.error });
}
const targetPath = validation.resolvedPath || resolvedInput;
const parentDir = path.dirname(targetPath);
try {
await fs.promises.access(parentDir);
} catch (err) {
return res.status(404).json({ error: 'Parent directory does not exist' });
}
try {
await fs.promises.access(targetPath);
return res.status(409).json({ error: 'Folder already exists' });
} catch (err) {
// Folder doesn't exist, which is what we want
}
try {
await fs.promises.mkdir(targetPath, { recursive: false });
res.json({ success: true, path: targetPath });
} catch (mkdirError) {
if (mkdirError.code === 'EEXIST') {
return res.status(409).json({ error: 'Folder already exists' });
}
throw mkdirError;
}
} catch (error) {
console.error('Error creating folder:', error);
res.status(500).json({ error: 'Failed to create folder' });
}
});
// Read file content endpoint
app.get('/api/projects/:projectName/file', authenticateToken, async (req, res) => {
try {
const { projectName } = req.params;
const { filePath } = req.query;
console.log('[DEBUG] File read request:', projectName, filePath);
// Security: ensure the requested path is inside the project root
if (!filePath) {
@@ -568,7 +743,6 @@ app.get('/api/projects/:projectName/files/content', authenticateToken, async (re
const { projectName } = req.params;
const { path: filePath } = req.query;
console.log('[DEBUG] Binary file serve request:', projectName, filePath);
// Security: ensure the requested path is inside the project root
if (!filePath) {
@@ -622,7 +796,6 @@ app.put('/api/projects/:projectName/file', authenticateToken, async (req, res) =
const { projectName } = req.params;
const { filePath, content } = req.body;
console.log('[DEBUG] File save request:', projectName, filePath);
// Security: ensure the requested path is inside the project root
if (!filePath) {
@@ -879,7 +1052,8 @@ function handleShellConnection(ws) {
console.log('🐚 Shell client connected');
let shellProcess = null;
let ptySessionKey = null;
let outputBuffer = [];
let urlDetectionBuffer = '';
const announcedAuthUrls = new Set();
ws.on('message', async (message) => {
try {
@@ -893,6 +1067,8 @@ function handleShellConnection(ws) {
const provider = data.provider || 'claude';
const initialCommand = data.initialCommand;
const isPlainShell = data.isPlainShell || (!!initialCommand && !hasSession) || provider === 'plain-shell';
urlDetectionBuffer = '';
announcedAuthUrls.clear();
// Login commands (Claude/Cursor auth) should never reuse cached sessions
const isLoginCommand = initialCommand && (
@@ -1032,9 +1208,7 @@ function handleShellConnection(ws) {
...process.env,
TERM: 'xterm-256color',
COLORTERM: 'truecolor',
FORCE_COLOR: '3',
// Override browser opening commands to echo URL for detection
BROWSER: os.platform() === 'win32' ? 'echo "OPEN_URL:"' : 'echo "OPEN_URL:"'
FORCE_COLOR: '3'
}
});
@@ -1064,38 +1238,47 @@ function handleShellConnection(ws) {
if (session.ws && session.ws.readyState === WebSocket.OPEN) {
let outputData = data;
// Check for various URL opening patterns
const patterns = [
// Direct browser opening commands
/(?:xdg-open|open|start)\s+(https?:\/\/[^\s\x1b\x07]+)/g,
// BROWSER environment variable override
const cleanChunk = stripAnsiSequences(data);
urlDetectionBuffer = `${urlDetectionBuffer}${cleanChunk}`.slice(-SHELL_URL_PARSE_BUFFER_LIMIT);
outputData = outputData.replace(
/OPEN_URL:\s*(https?:\/\/[^\s\x1b\x07]+)/g,
// Git and other tools opening URLs
/Opening\s+(https?:\/\/[^\s\x1b\x07]+)/gi,
// General URL patterns that might be opened
/Visit:\s*(https?:\/\/[^\s\x1b\x07]+)/gi,
/View at:\s*(https?:\/\/[^\s\x1b\x07]+)/gi,
/Browse to:\s*(https?:\/\/[^\s\x1b\x07]+)/gi
];
'[INFO] Opening in browser: $1'
);
patterns.forEach(pattern => {
let match;
while ((match = pattern.exec(data)) !== null) {
const url = match[1];
console.log('[DEBUG] Detected URL for opening:', url);
const emitAuthUrl = (detectedUrl, autoOpen = false) => {
const normalizedUrl = normalizeDetectedUrl(detectedUrl);
if (!normalizedUrl) return;
// Send URL opening message to client
const isNewUrl = !announcedAuthUrls.has(normalizedUrl);
if (isNewUrl) {
announcedAuthUrls.add(normalizedUrl);
session.ws.send(JSON.stringify({
type: 'url_open',
url: url
type: 'auth_url',
url: normalizedUrl,
autoOpen
}));
// Replace the OPEN_URL pattern with a user-friendly message
if (pattern.source.includes('OPEN_URL')) {
outputData = outputData.replace(match[0], `[INFO] Opening in browser: ${url}`);
}
}
});
};
const normalizedDetectedUrls = extractUrlsFromText(urlDetectionBuffer)
.map((url) => normalizeDetectedUrl(url))
.filter(Boolean);
// Prefer the most complete URL if shorter prefix variants are also present.
const dedupedDetectedUrls = Array.from(new Set(normalizedDetectedUrls)).filter((url, _, urls) =>
!urls.some((otherUrl) => otherUrl !== url && otherUrl.startsWith(url))
);
dedupedDetectedUrls.forEach((url) => emitAuthUrl(url, false));
if (shouldAutoOpenUrlFromOutput(cleanChunk) && dedupedDetectedUrls.length > 0) {
const bestUrl = dedupedDetectedUrls.reduce((longest, current) =>
current.length > longest.length ? current : longest
);
emitAuthUrl(bestUrl, true);
}
// Send regular output
session.ws.send(JSON.stringify({

24
server/load-env.js Normal file
View File

@@ -0,0 +1,24 @@
// Load environment variables from .env before other imports execute.
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
try {
const envPath = path.join(__dirname, '../.env');
const envFile = fs.readFileSync(envPath, 'utf8');
envFile.split('\n').forEach(line => {
const trimmedLine = line.trim();
if (trimmedLine && !trimmedLine.startsWith('#')) {
const [key, ...valueParts] = trimmedLine.split('=');
if (key && valueParts.length > 0 && !process.env[key]) {
process.env[key] = valueParts.join('=').trim();
}
}
});
} catch (e) {
console.log('No .env file found or error reading it:', e.message);
}

View File

@@ -1,5 +1,6 @@
import jwt from 'jsonwebtoken';
import { userDb } from '../database/db.js';
import { IS_PLATFORM } from '../constants/config.js';
// Get JWT secret from environment or use default (for development)
const JWT_SECRET = process.env.JWT_SECRET || 'claude-ui-dev-secret-change-in-production';
@@ -21,7 +22,7 @@ const validateApiKey = (req, res, next) => {
// JWT authentication middleware
const authenticateToken = async (req, res, next) => {
// Platform mode: use single database user
if (process.env.VITE_IS_PLATFORM === 'true') {
if (IS_PLATFORM) {
try {
const user = userDb.getFirstUser();
if (!user) {
@@ -37,7 +38,12 @@ const authenticateToken = async (req, res, next) => {
// Normal OSS JWT validation
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN
let token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN
// Also check query param for SSE endpoints (EventSource can't set headers)
if (!token && req.query.token) {
token = req.query.token;
}
if (!token) {
return res.status(401).json({ error: 'Access denied. No token provided.' });
@@ -75,7 +81,7 @@ const generateToken = (user) => {
// WebSocket authentication function
const authenticateWebSocket = (token) => {
// Platform mode: bypass token validation, return first user
if (process.env.VITE_IS_PLATFORM === 'true') {
if (IS_PLATFORM) {
try {
const user = userDb.getFirstUser();
if (user) {

View File

@@ -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
@@ -272,7 +276,8 @@ export async function queryCodex(command, options = {}, ws) {
data: {
used: totalTokens,
total: 200000 // Default context window for Codex models
}
},
sessionId: currentSessionId
});
}
}
@@ -285,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';
}
}
}
@@ -317,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;
}

View File

@@ -379,24 +379,47 @@ async function extractProjectDirectory(projectName) {
}
}
async function getProjects() {
async function getProjects(progressCallback = null) {
const claudeDir = path.join(os.homedir(), '.claude', 'projects');
const config = await loadProjectConfig();
const projects = [];
const existingProjects = new Set();
const codexSessionsIndexRef = { sessionsByProject: null };
let totalProjects = 0;
let processedProjects = 0;
let directories = [];
try {
// Check if the .claude/projects directory exists
await fs.access(claudeDir);
// First, get existing Claude projects from the file system
const entries = await fs.readdir(claudeDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
existingProjects.add(entry.name);
const projectPath = path.join(claudeDir, entry.name);
directories = entries.filter(e => e.isDirectory());
// Build set of existing project names for later
directories.forEach(e => existingProjects.add(e.name));
// Count manual projects not already in directories
const manualProjectsCount = Object.entries(config)
.filter(([name, cfg]) => cfg.manuallyAdded && !existingProjects.has(name))
.length;
totalProjects = directories.length + manualProjectsCount;
for (const entry of directories) {
processedProjects++;
// Emit progress
if (progressCallback) {
progressCallback({
phase: 'loading',
current: processedProjects,
total: totalProjects,
currentProject: entry.name
});
}
// Extract actual project directory from JSONL sessions
const actualProjectDir = await extractProjectDirectory(entry.name);
@@ -411,7 +434,11 @@ async function getProjects() {
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)
@@ -424,6 +451,10 @@ async function getProjects() {
};
} 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
@@ -436,7 +467,9 @@ async function getProjects() {
// 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 = [];
@@ -460,20 +493,35 @@ async function getProjects() {
status: 'error'
};
}
projects.push(project);
}
projects.push(project);
}
} catch (error) {
// If the directory doesn't exist (ENOENT), that's okay - just continue with empty projects
if (error.code !== 'ENOENT') {
console.error('Error reading projects directory:', error);
}
// Calculate total for manual projects only (no directories exist)
totalProjects = Object.entries(config)
.filter(([name, cfg]) => cfg.manuallyAdded)
.length;
}
// Add manually configured projects that don't exist as folders yet
for (const [projectName, projectConfig] of Object.entries(config)) {
if (!existingProjects.has(projectName) && projectConfig.manuallyAdded) {
processedProjects++;
// Emit progress for manual projects
if (progressCallback) {
progressCallback({
phase: 'loading',
current: processedProjects,
total: totalProjects,
currentProject: projectName
});
}
// Use the original path if available, otherwise extract from potential sessions
let actualProjectDir = projectConfig.originalPath;
@@ -486,7 +534,7 @@ async function getProjects() {
}
}
const project = {
const project = {
name: projectName,
path: actualProjectDir,
displayName: projectConfig.displayName || await generateDisplayName(projectName, actualProjectDir),
@@ -494,9 +542,13 @@ async function getProjects() {
isCustomName: !!projectConfig.displayName,
isManuallyAdded: true,
sessions: [],
sessionMeta: {
hasMore: false,
total: 0
},
cursorSessions: [],
codexSessions: []
};
};
// Try to fetch Cursor sessions for manual projects too
try {
@@ -507,7 +559,9 @@ async function getProjects() {
// 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);
}
@@ -541,7 +595,16 @@ async function getProjects() {
projects.push(project);
}
}
// Emit completion after all projects (including manual) are processed
if (progressCallback) {
progressCallback({
phase: 'complete',
current: totalProjects,
total: totalProjects
});
}
return projects;
}
@@ -978,25 +1041,56 @@ async function isProjectEmpty(projectName) {
}
}
// Delete an empty project
async function deleteProject(projectName) {
// Delete a project (force=true to delete even with sessions)
async function deleteProject(projectName, force = false) {
const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName);
try {
// First check if the project is empty
const isEmpty = await isProjectEmpty(projectName);
if (!isEmpty) {
if (!isEmpty && !force) {
throw new Error('Cannot delete project with existing sessions');
}
// Remove the project directory
await fs.rm(projectDir, { recursive: true, force: true });
// Remove from project config
const config = await loadProjectConfig();
let projectPath = config[projectName]?.path || config[projectName]?.originalPath;
// Fallback to extractProjectDirectory if projectPath is not in config
if (!projectPath) {
projectPath = await extractProjectDirectory(projectName);
}
// Remove the project directory (includes all Claude sessions)
await fs.rm(projectDir, { recursive: true, force: true });
// Delete all Codex sessions associated with this project
if (projectPath) {
try {
const codexSessions = await getCodexSessions(projectPath, { limit: 0 });
for (const session of codexSessions) {
try {
await deleteCodexSession(session.id);
} catch (err) {
console.warn(`Failed to delete Codex session ${session.id}:`, err.message);
}
}
} catch (err) {
console.warn('Failed to delete Codex sessions:', err.message);
}
// Delete Cursor sessions directory if it exists
try {
const hash = crypto.createHash('md5').update(projectPath).digest('hex');
const cursorProjectDir = path.join(os.homedir(), '.cursor', 'chats', hash);
await fs.rm(cursorProjectDir, { recursive: true, force: true });
} catch (err) {
// Cursor dir may not exist, ignore
}
}
// Remove from project config
delete config[projectName];
await saveProjectConfig(config);
return true;
} catch (error) {
console.error(`Error deleting project ${projectName}:`, error);
@@ -1007,17 +1101,17 @@ async function deleteProject(projectName) {
// Add a project manually to the config (without creating folders)
async function addProjectManually(projectPath, displayName = null) {
const absolutePath = path.resolve(projectPath);
try {
// Check if the path exists
await fs.access(absolutePath);
} catch (error) {
throw new Error(`Path does not exist: ${absolutePath}`);
}
// Generate project name (encode path for use as directory name)
const projectName = absolutePath.replace(/\//g, '-');
const projectName = absolutePath.replace(/[\\/:\s~_]/g, '-');
// Check if project already exists in config
const config = await loadProjectConfig();
const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName);
@@ -1028,13 +1122,13 @@ async function addProjectManually(projectPath, displayName = null) {
// Allow adding projects even if the directory exists - this enables tracking
// existing Claude Code or Cursor projects in the UI
// Add to config as manually added project
config[projectName] = {
manuallyAdded: true,
originalPath: absolutePath
};
if (displayName) {
config[projectName].displayName = displayName;
}
@@ -1165,74 +1259,114 @@ async function getCursorSessions(projectPath) {
}
// Fetch Codex sessions for a given project path
async function getCodexSessions(projectPath) {
try {
const codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions');
const sessions = [];
function normalizeComparablePath(inputPath) {
if (!inputPath || typeof inputPath !== 'string') {
return '';
}
// Check if the directory exists
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 {
await fs.access(codexSessionsDir);
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) {
// No Codex sessions directory
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, indexRef = null } = options;
try {
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 only the first 5 sessions for performance
return sessions.slice(0, 5);
// Return limited sessions for performance (0 = unlimited for deletion)
return limit > 0 ? sessions.slice(0, limit) : [...sessions];
} catch (error) {
console.error('Error fetching Codex sessions:', error);

View File

@@ -11,6 +11,7 @@ import { spawnCursor } from '../cursor-cli.js';
import { queryCodex } from '../openai-codex.js';
import { Octokit } from '@octokit/rest';
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../shared/modelConstants.js';
import { IS_PLATFORM } from '../constants/config.js';
const router = express.Router();
@@ -18,7 +19,7 @@ const router = express.Router();
* Middleware to authenticate agent API requests.
*
* Supports two authentication modes:
* 1. Platform mode (VITE_IS_PLATFORM=true): For managed/hosted deployments where
* 1. Platform mode (IS_PLATFORM=true): For managed/hosted deployments where
* authentication is handled by an external proxy. Requests are trusted and
* the default user context is used.
*
@@ -28,7 +29,7 @@ const router = express.Router();
const validateExternalApiKey = (req, res, next) => {
// Platform mode: Authentication is handled externally (e.g., by a proxy layer).
// Trust the request and use the default user context.
if (process.env.VITE_IS_PLATFORM === 'true') {
if (IS_PLATFORM) {
try {
const user = userDb.getFirstUser();
if (!user) {

View File

@@ -262,8 +262,7 @@ router.get('/mcp/config/read', async (req, res) => {
}
if (!configData) {
return res.json({ success: false, message: 'No Codex configuration file found', servers: [] });
}
return res.json({ success: true, configPath, servers: [] }); }
const servers = [];

View File

@@ -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');

View File

@@ -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;
export default router;

View File

@@ -7,11 +7,17 @@ import { addProjectManually } from '../projects.js';
const router = express.Router();
function sanitizeGitError(message, token) {
if (!message || !token) return message;
return message.replace(new RegExp(token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), '***');
}
// Configure allowed workspace root (defaults to user's home directory)
const WORKSPACES_ROOT = process.env.WORKSPACES_ROOT || os.homedir();
export const WORKSPACES_ROOT = process.env.WORKSPACES_ROOT || os.homedir();
// System-critical paths that should never be used as workspace directories
const FORBIDDEN_PATHS = [
export const FORBIDDEN_PATHS = [
// Unix
'/',
'/etc',
'/bin',
@@ -27,7 +33,14 @@ const FORBIDDEN_PATHS = [
'/lib64',
'/opt',
'/tmp',
'/run'
'/run',
// Windows
'C:\\Windows',
'C:\\Program Files',
'C:\\Program Files (x86)',
'C:\\ProgramData',
'C:\\System Volume Information',
'C:\\$Recycle.Bin'
];
/**
@@ -35,7 +48,7 @@ const FORBIDDEN_PATHS = [
* @param {string} requestedPath - The path to validate
* @returns {Promise<{valid: boolean, resolvedPath?: string, error?: string}>}
*/
async function validateWorkspacePath(requestedPath) {
export async function validateWorkspacePath(requestedPath) {
try {
// Resolve to absolute path
let absolutePath = path.resolve(requestedPath);
@@ -212,20 +225,7 @@ router.post('/create-workspace', async (req, res) => {
// Handle new workspace creation
if (workspaceType === 'new') {
// Check if path already exists
try {
await fs.access(absolutePath);
return res.status(400).json({
error: 'Path already exists. Please choose a different path or use "existing workspace" option.'
});
} catch (error) {
if (error.code !== 'ENOENT') {
throw error;
}
// Path doesn't exist - good, we can create it
}
// Create the directory
// Create the directory if it doesn't exist
await fs.mkdir(absolutePath, { recursive: true });
// If GitHub URL is provided, clone the repository
@@ -246,30 +246,55 @@ router.post('/create-workspace', async (req, res) => {
githubToken = newGithubToken;
}
// Clone the repository
// Extract repo name from URL for the clone destination
const normalizedUrl = githubUrl.replace(/\/+$/, '').replace(/\.git$/, '');
const repoName = normalizedUrl.split('/').pop() || 'repository';
const clonePath = path.join(absolutePath, repoName);
// Check if clone destination already exists to prevent data loss
try {
await cloneGitHubRepository(githubUrl, absolutePath, githubToken);
await fs.access(clonePath);
return res.status(409).json({
error: 'Directory already exists',
details: `The destination path "${clonePath}" already exists. Please choose a different location or remove the existing directory.`
});
} catch (err) {
// Directory doesn't exist, which is what we want
}
// Clone the repository into a subfolder
try {
await cloneGitHubRepository(githubUrl, clonePath, githubToken);
} catch (error) {
// Clean up created directory on failure
// Only clean up if clone created partial data (check if dir exists and is empty or partial)
try {
await fs.rm(absolutePath, { recursive: true, force: true });
const stats = await fs.stat(clonePath);
if (stats.isDirectory()) {
await fs.rm(clonePath, { recursive: true, force: true });
}
} catch (cleanupError) {
console.error('Failed to clean up directory after clone failure:', cleanupError);
// Continue to throw original error
// Directory doesn't exist or cleanup failed - ignore
}
throw new Error(`Failed to clone repository: ${error.message}`);
}
// Add the cloned repo path to the project list
const project = await addProjectManually(clonePath);
return res.json({
success: true,
project,
message: 'New workspace created and repository cloned successfully'
});
}
// Add the new workspace to the project list
// Add the new workspace to the project list (no clone)
const project = await addProjectManually(absolutePath);
return res.json({
success: true,
project,
message: githubUrl
? 'New workspace created and repository cloned successfully'
: 'New workspace created successfully'
message: 'New workspace created successfully'
});
}
@@ -305,31 +330,179 @@ async function getGithubTokenById(tokenId, userId) {
return null;
}
/**
* Clone repository with progress streaming (SSE)
* GET /api/projects/clone-progress
*/
router.get('/clone-progress', async (req, res) => {
const { path: workspacePath, githubUrl, githubTokenId, newGithubToken } = req.query;
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders();
const sendEvent = (type, data) => {
res.write(`data: ${JSON.stringify({ type, ...data })}\n\n`);
};
try {
if (!workspacePath || !githubUrl) {
sendEvent('error', { message: 'workspacePath and githubUrl are required' });
res.end();
return;
}
const validation = await validateWorkspacePath(workspacePath);
if (!validation.valid) {
sendEvent('error', { message: validation.error });
res.end();
return;
}
const absolutePath = validation.resolvedPath;
await fs.mkdir(absolutePath, { recursive: true });
let githubToken = null;
if (githubTokenId) {
const token = await getGithubTokenById(parseInt(githubTokenId), req.user.id);
if (!token) {
await fs.rm(absolutePath, { recursive: true, force: true });
sendEvent('error', { message: 'GitHub token not found' });
res.end();
return;
}
githubToken = token.github_token;
} else if (newGithubToken) {
githubToken = newGithubToken;
}
const normalizedUrl = githubUrl.replace(/\/+$/, '').replace(/\.git$/, '');
const repoName = normalizedUrl.split('/').pop() || 'repository';
const clonePath = path.join(absolutePath, repoName);
// Check if clone destination already exists to prevent data loss
try {
await fs.access(clonePath);
sendEvent('error', { message: `Directory "${repoName}" already exists. Please choose a different location or remove the existing directory.` });
res.end();
return;
} catch (err) {
// Directory doesn't exist, which is what we want
}
let cloneUrl = githubUrl;
if (githubToken) {
try {
const url = new URL(githubUrl);
url.username = githubToken;
url.password = '';
cloneUrl = url.toString();
} catch (error) {
// SSH URL or invalid - use as-is
}
}
sendEvent('progress', { message: `Cloning into '${repoName}'...` });
const gitProcess = spawn('git', ['clone', '--progress', cloneUrl, clonePath], {
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...process.env,
GIT_TERMINAL_PROMPT: '0'
}
});
let lastError = '';
gitProcess.stdout.on('data', (data) => {
const message = data.toString().trim();
if (message) {
sendEvent('progress', { message });
}
});
gitProcess.stderr.on('data', (data) => {
const message = data.toString().trim();
lastError = message;
if (message) {
sendEvent('progress', { message });
}
});
gitProcess.on('close', async (code) => {
if (code === 0) {
try {
const project = await addProjectManually(clonePath);
sendEvent('complete', { project, message: 'Repository cloned successfully' });
} catch (error) {
sendEvent('error', { message: `Clone succeeded but failed to add project: ${error.message}` });
}
} else {
const sanitizedError = sanitizeGitError(lastError, githubToken);
let errorMessage = 'Git clone failed';
if (lastError.includes('Authentication failed') || lastError.includes('could not read Username')) {
errorMessage = 'Authentication failed. Please check your credentials.';
} else if (lastError.includes('Repository not found')) {
errorMessage = 'Repository not found. Please check the URL and ensure you have access.';
} else if (lastError.includes('already exists')) {
errorMessage = 'Directory already exists';
} else if (sanitizedError) {
errorMessage = sanitizedError;
}
try {
await fs.rm(clonePath, { recursive: true, force: true });
} catch (cleanupError) {
console.error('Failed to clean up after clone failure:', sanitizeGitError(cleanupError.message, githubToken));
}
sendEvent('error', { message: errorMessage });
}
res.end();
});
gitProcess.on('error', (error) => {
if (error.code === 'ENOENT') {
sendEvent('error', { message: 'Git is not installed or not in PATH' });
} else {
sendEvent('error', { message: error.message });
}
res.end();
});
req.on('close', () => {
gitProcess.kill();
});
} catch (error) {
sendEvent('error', { message: error.message });
res.end();
}
});
/**
* Helper function to clone a GitHub repository
*/
function cloneGitHubRepository(githubUrl, destinationPath, githubToken = null) {
return new Promise((resolve, reject) => {
// Parse GitHub URL and inject token if provided
let cloneUrl = githubUrl;
if (githubToken) {
try {
const url = new URL(githubUrl);
// Format: https://TOKEN@github.com/user/repo.git
url.username = githubToken;
url.password = '';
cloneUrl = url.toString();
} catch (error) {
return reject(new Error('Invalid GitHub URL format'));
// SSH URL - use as-is
}
}
const gitProcess = spawn('git', ['clone', cloneUrl, destinationPath], {
const gitProcess = spawn('git', ['clone', '--progress', cloneUrl, destinationPath], {
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...process.env,
GIT_TERMINAL_PROMPT: '0' // Disable git password prompts
GIT_TERMINAL_PROMPT: '0'
}
});
@@ -348,7 +521,6 @@ function cloneGitHubRepository(githubUrl, destinationPath, githubToken = null) {
if (code === 0) {
resolve({ stdout, stderr });
} else {
// Parse git error messages to provide helpful feedback
let errorMessage = 'Git clone failed';
if (stderr.includes('Authentication failed') || stderr.includes('could not read Username')) {

View File

@@ -62,4 +62,4 @@ export const CODEX_MODELS = {
],
DEFAULT: 'gpt-5.2'
};
};

View File

@@ -1,974 +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 } 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, useWebSocketContext } from './contexts/WebSocketContext';
import ProtectedRoute from './components/ProtectedRoute';
import { useVersionCheck } from './hooks/useVersionCheck';
import useLocalStorage from './hooks/useLocalStorage';
import { api, authenticatedFetch } from './utils/api';
// Main App component with routing
function AppContent() {
const navigate = useNavigate();
const { sessionId } = useParams();
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 [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, messages } = useWebSocketContext();
// 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 (messages.length > 0) {
const latestMessage = messages[messages.length - 1];
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);
}
}
}
}
}
}
}, [messages, 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 [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 (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<button
className="fixed inset-0 bg-black/50 backdrop-blur-sm"
onClick={() => setShowVersionModal(false)}
aria-label="Close version upgrade modal"
/>
{/* Modal */}
<div className="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl border border-gray-200 dark:border-gray-700 w-full max-w-2xl mx-4 p-6 space-y-4 max-h-[90vh] overflow-y-auto">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-blue-100 dark:bg-blue-900/30 rounded-lg flex items-center justify-center">
<svg className="w-5 h-5 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10" />
</svg>
</div>
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Update Available</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">
{releaseInfo?.title || 'A new version is ready'}
</p>
</div>
</div>
<button
onClick={() => setShowVersionModal(false)}
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Version Info */}
<div className="space-y-3">
<div className="flex justify-between items-center p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Current Version</span>
<span className="text-sm text-gray-900 dark:text-white font-mono">{currentVersion}</span>
</div>
<div className="flex justify-between items-center p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-700">
<span className="text-sm font-medium text-blue-700 dark:text-blue-300">Latest Version</span>
<span className="text-sm text-blue-900 dark:text-blue-100 font-mono">{latestVersion}</span>
</div>
</div>
{/* Changelog */}
{releaseInfo?.body && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-gray-900 dark:text-white">What's New:</h3>
{releaseInfo?.htmlUrl && (
<a
href={releaseInfo.htmlUrl}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 hover:underline flex items-center gap-1"
>
View full release
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
)}
</div>
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 border border-gray-200 dark:border-gray-600 max-h-64 overflow-y-auto">
<div className="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap prose prose-sm dark:prose-invert max-w-none">
{cleanChangelog(releaseInfo.body)}
</div>
</div>
</div>
)}
{/* Update Output */}
{updateOutput && (
<div className="space-y-2">
<h3 className="text-sm font-medium text-gray-900 dark:text-white">Update Progress:</h3>
<div className="bg-gray-900 dark:bg-gray-950 rounded-lg p-4 border border-gray-700 max-h-48 overflow-y-auto">
<pre className="text-xs text-green-400 font-mono whitespace-pre-wrap">{updateOutput}</pre>
</div>
</div>
)}
{/* Upgrade Instructions */}
{!isUpdating && !updateOutput && (
<div className="space-y-3">
<h3 className="text-sm font-medium text-gray-900 dark:text-white">Manual upgrade:</h3>
<div className="bg-gray-100 dark:bg-gray-800 rounded-lg p-3 border">
<code className="text-sm text-gray-800 dark:text-gray-200 font-mono">
git checkout main && git pull && npm install
</code>
</div>
<p className="text-xs text-gray-600 dark:text-gray-400">
Or click "Update Now" to run the update automatically.
</p>
</div>
)}
{/* Actions */}
<div className="flex gap-2 pt-2">
<button
onClick={() => setShowVersionModal(false)}
className="flex-1 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-md transition-colors"
>
{updateOutput ? 'Close' : 'Later'}
</button>
{!updateOutput && (
<>
<button
onClick={() => {
navigator.clipboard.writeText('git checkout main && git pull && npm install');
}}
className="flex-1 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-md transition-colors"
>
Copy Command
</button>
<button
onClick={handleUpdateNow}
disabled={isUpdating}
className="flex-1 px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 disabled:cursor-not-allowed rounded-md transition-colors flex items-center justify-center gap-2"
>
{isUpdating ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
Updating...
</>
) : (
'Update Now'
)}
</button>
</>
)}
</div>
</div>
</div>
);
};
return (
<div className="fixed inset-0 flex bg-background">
{/* Fixed Desktop Sidebar */}
{!isMobile && (
<div
className={`h-full flex-shrink-0 border-r border-border bg-card transition-all duration-300 ${
sidebarVisible ? 'w-80' : 'w-14'
}`}
>
<div className="h-full overflow-hidden">
{sidebarVisible ? (
<Sidebar
projects={projects}
selectedProject={selectedProject}
selectedSession={selectedSession}
onProjectSelect={handleProjectSelect}
onSessionSelect={handleSessionSelect}
onNewSession={handleNewSession}
onSessionDelete={handleSessionDelete}
onProjectDelete={handleProjectDelete}
isLoading={isLoadingProjects}
onRefresh={handleSidebarRefresh}
onShowSettings={() => setShowSettings(true)}
updateAvailable={updateAvailable}
latestVersion={latestVersion}
currentVersion={currentVersion}
releaseInfo={releaseInfo}
onShowVersionModal={() => setShowVersionModal(true)}
isPWA={isPWA}
isMobile={isMobile}
onToggleSidebar={() => setSidebarVisible(false)}
/>
) : (
/* Collapsed Sidebar */
<div className="h-full flex flex-col items-center py-4 gap-4">
{/* Expand Button */}
<button
onClick={() => setSidebarVisible(true)}
className="p-2 hover:bg-accent rounded-md transition-colors duration-200 group"
aria-label="Show sidebar"
title="Show sidebar"
>
<svg
className="w-5 h-5 text-foreground group-hover:scale-110 transition-transform"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 5l7 7-7 7M5 5l7 7-7 7" />
</svg>
</button>
{/* Settings Icon */}
<button
onClick={() => setShowSettings(true)}
className="p-2 hover:bg-accent rounded-md transition-colors duration-200"
aria-label="Settings"
title="Settings"
>
<SettingsIcon className="w-5 h-5 text-muted-foreground hover:text-foreground transition-colors" />
</button>
{/* Update Indicator */}
{updateAvailable && (
<button
onClick={() => setShowVersionModal(true)}
className="relative p-2 hover:bg-accent rounded-md transition-colors duration-200"
aria-label="Update available"
title="Update available"
>
<Sparkles className="w-5 h-5 text-blue-500" />
<span className="absolute top-1 right-1 w-2 h-2 bg-blue-500 rounded-full animate-pulse" />
</button>
)}
</div>
)}
</div>
</div>
)}
{/* Mobile Sidebar Overlay */}
{isMobile && (
<div className={`fixed inset-0 z-50 flex transition-all duration-150 ease-out ${
sidebarOpen ? 'opacity-100 visible' : 'opacity-0 invisible'
}`}>
<button
className="fixed inset-0 bg-background/80 backdrop-blur-sm transition-opacity duration-150 ease-out"
onClick={(e) => {
e.stopPropagation();
setSidebarOpen(false);
}}
onTouchStart={(e) => {
e.preventDefault();
e.stopPropagation();
setSidebarOpen(false);
}}
aria-label="Close sidebar"
/>
<div
className={`relative w-[85vw] max-w-sm sm:w-80 h-full bg-card border-r border-border transform transition-transform duration-150 ease-out ${
sidebarOpen ? 'translate-x-0' : '-translate-x-full'
}`}
onClick={(e) => e.stopPropagation()}
onTouchStart={(e) => e.stopPropagation()}
>
<Sidebar
projects={projects}
selectedProject={selectedProject}
selectedSession={selectedSession}
onProjectSelect={handleProjectSelect}
onSessionSelect={handleSessionSelect}
onNewSession={handleNewSession}
onSessionDelete={handleSessionDelete}
onProjectDelete={handleProjectDelete}
isLoading={isLoadingProjects}
onRefresh={handleSidebarRefresh}
onShowSettings={() => setShowSettings(true)}
updateAvailable={updateAvailable}
latestVersion={latestVersion}
currentVersion={currentVersion}
releaseInfo={releaseInfo}
onShowVersionModal={() => setShowVersionModal(true)}
isPWA={isPWA}
isMobile={isMobile}
onToggleSidebar={() => setSidebarVisible(false)}
/>
</div>
</div>
)}
{/* Main Content Area - Flexible */}
<div className={`flex-1 flex flex-col min-w-0 ${isMobile && !isInputFocused ? 'pb-mobile-nav' : ''}`}>
<MainContent
selectedProject={selectedProject}
selectedSession={selectedSession}
activeTab={activeTab}
setActiveTab={setActiveTab}
ws={ws}
sendMessage={sendMessage}
messages={messages}
isMobile={isMobile}
isPWA={isPWA}
onMenuClick={() => 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}
/>
</div>
{/* Mobile Bottom Navigation */}
{isMobile && (
<MobileNav
activeTab={activeTab}
setActiveTab={setActiveTab}
isInputFocused={isInputFocused}
/>
)}
{/* Quick Settings Panel - Only show on chat tab */}
{activeTab === 'chat' && (
<QuickSettingsPanel
isOpen={showQuickSettings}
onToggle={setShowQuickSettings}
autoExpandTools={autoExpandTools}
onAutoExpandChange={setAutoExpandTools}
showRawParameters={showRawParameters}
onShowRawParametersChange={setShowRawParameters}
showThinking={showThinking}
onShowThinkingChange={setShowThinking}
autoScrollToBottom={autoScrollToBottom}
onAutoScrollChange={setAutoScrollToBottom}
sendByCtrlEnter={sendByCtrlEnter}
onSendByCtrlEnterChange={setSendByCtrlEnter}
isMobile={isMobile}
/>
)}
{/* Settings Modal */}
<Settings
isOpen={showSettings}
onClose={() => setShowSettings(false)}
projects={projects}
initialTab={settingsInitialTab}
/>
{/* Version Upgrade Modal */}
<VersionUpgradeModal />
</div>
);
}
// Root App component with router
function App() {
return (
<ThemeProvider>
<AuthProvider>
<WebSocketProvider>
<TasksSettingsProvider>
<TaskMasterProvider>
<ProtectedRoute>
<Router>
<Routes>
<Route path="/" element={<AppContent />} />
<Route path="/session/:sessionId" element={<AppContent />} />
</Routes>
</Router>
</ProtectedRoute>
</TaskMasterProvider>
</TasksSettingsProvider>
</WebSocketProvider>
</AuthProvider>
</ThemeProvider>
);
}
export default App;

35
src/App.tsx Normal file
View File

@@ -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 (
<I18nextProvider i18n={i18n}>
<ThemeProvider>
<AuthProvider>
<WebSocketProvider>
<TasksSettingsProvider>
<TaskMasterProvider>
<ProtectedRoute>
<Router basename={window.__ROUTER_BASENAME__ || ''}>
<Routes>
<Route path="/" element={<AppContent />} />
<Route path="/session/:sessionId" element={<AppContent />} />
</Routes>
</Router>
</ProtectedRoute>
</TaskMasterProvider>
</TasksSettingsProvider>
</WebSocketProvider>
</AuthProvider>
</ThemeProvider>
</I18nextProvider>
);
}

View File

@@ -3,8 +3,10 @@ import { Button } from './ui/button';
import { Input } from './ui/input';
import { Key, Plus, Trash2, Eye, EyeOff, Copy, Check, Github } from 'lucide-react';
import { authenticatedFetch } from '../utils/api';
import { useTranslation } from 'react-i18next';
function ApiKeysSettings() {
const { t } = useTranslation('settings');
const [apiKeys, setApiKeys] = useState([]);
const [githubTokens, setGithubTokens] = useState([]);
const [loading, setLoading] = useState(true);
@@ -63,7 +65,7 @@ function ApiKeysSettings() {
};
const deleteApiKey = async (keyId) => {
if (!confirm('Are you sure you want to delete this API key?')) return;
if (!confirm(t('apiKeys.confirmDelete'))) return;
try {
await authenticatedFetch(`/api/settings/api-keys/${keyId}`, {
@@ -113,7 +115,7 @@ function ApiKeysSettings() {
};
const deleteGithubToken = async (tokenId) => {
if (!confirm('Are you sure you want to delete this GitHub token?')) return;
if (!confirm(t('apiKeys.github.confirmDelete'))) return;
try {
await authenticatedFetch(`/api/settings/credentials/${tokenId}`, {
@@ -144,7 +146,7 @@ function ApiKeysSettings() {
};
if (loading) {
return <div className="text-muted-foreground">Loading...</div>;
return <div className="text-muted-foreground">{t('apiKeys.loading')}</div>;
}
return (
@@ -152,9 +154,9 @@ function ApiKeysSettings() {
{/* New API Key Alert */}
{newlyCreatedKey && (
<div className="p-4 bg-yellow-500/10 border border-yellow-500/20 rounded-lg">
<h4 className="font-semibold text-yellow-500 mb-2"> Save Your API Key</h4>
<h4 className="font-semibold text-yellow-500 mb-2">{t('apiKeys.newKey.alertTitle')}</h4>
<p className="text-sm text-muted-foreground mb-3">
This is the only time you'll see this key. Store it securely.
{t('apiKeys.newKey.alertMessage')}
</p>
<div className="flex items-center gap-2">
<code className="flex-1 px-3 py-2 bg-background/50 rounded font-mono text-sm break-all">
@@ -174,7 +176,7 @@ function ApiKeysSettings() {
className="mt-3"
onClick={() => setNewlyCreatedKey(null)}
>
I've saved it
{t('apiKeys.newKey.iveSavedIt')}
</Button>
</div>
)}
@@ -184,33 +186,33 @@ function ApiKeysSettings() {
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Key className="h-5 w-5" />
<h3 className="text-lg font-semibold">API Keys</h3>
<h3 className="text-lg font-semibold">{t('apiKeys.title')}</h3>
</div>
<Button
size="sm"
onClick={() => setShowNewKeyForm(!showNewKeyForm)}
>
<Plus className="h-4 w-4 mr-1" />
New API Key
{t('apiKeys.newButton')}
</Button>
</div>
<p className="text-sm text-muted-foreground mb-4">
Generate API keys to access the external API from other applications.
{t('apiKeys.description')}
</p>
{showNewKeyForm && (
<div className="mb-4 p-4 border rounded-lg bg-card">
<Input
placeholder="API Key Name (e.g., Production Server)"
placeholder={t('apiKeys.form.placeholder')}
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
className="mb-2"
/>
<div className="flex gap-2">
<Button onClick={createApiKey}>Create</Button>
<Button onClick={createApiKey}>{t('apiKeys.form.createButton')}</Button>
<Button variant="outline" onClick={() => setShowNewKeyForm(false)}>
Cancel
{t('apiKeys.form.cancelButton')}
</Button>
</div>
</div>
@@ -218,7 +220,7 @@ function ApiKeysSettings() {
<div className="space-y-2">
{apiKeys.length === 0 ? (
<p className="text-sm text-muted-foreground italic">No API keys created yet.</p>
<p className="text-sm text-muted-foreground italic">{t('apiKeys.empty')}</p>
) : (
apiKeys.map((key) => (
<div
@@ -229,8 +231,8 @@ function ApiKeysSettings() {
<div className="font-medium">{key.key_name}</div>
<code className="text-xs text-muted-foreground">{key.api_key}</code>
<div className="text-xs text-muted-foreground mt-1">
Created: {new Date(key.created_at).toLocaleDateString()}
{key.last_used && `Last used: ${new Date(key.last_used).toLocaleDateString()}`}
{t('apiKeys.list.created')} {new Date(key.created_at).toLocaleDateString()}
{key.last_used && `${t('apiKeys.list.lastUsed')} ${new Date(key.last_used).toLocaleDateString()}`}
</div>
</div>
<div className="flex items-center gap-2">
@@ -239,7 +241,7 @@ function ApiKeysSettings() {
variant={key.is_active ? 'outline' : 'secondary'}
onClick={() => toggleApiKey(key.id, key.is_active)}
>
{key.is_active ? 'Active' : 'Inactive'}
{key.is_active ? t('apiKeys.status.active') : t('apiKeys.status.inactive')}
</Button>
<Button
size="sm"
@@ -260,25 +262,25 @@ function ApiKeysSettings() {
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Github className="h-5 w-5" />
<h3 className="text-lg font-semibold">GitHub Tokens</h3>
<h3 className="text-lg font-semibold">{t('apiKeys.github.title')}</h3>
</div>
<Button
size="sm"
onClick={() => setShowNewTokenForm(!showNewTokenForm)}
>
<Plus className="h-4 w-4 mr-1" />
Add Token
{t('apiKeys.github.addButton')}
</Button>
</div>
<p className="text-sm text-muted-foreground mb-4">
Add GitHub Personal Access Tokens to clone private repositories via the external API.
{t('apiKeys.github.description')}
</p>
{showNewTokenForm && (
<div className="mb-4 p-4 border rounded-lg bg-card">
<Input
placeholder="Token Name (e.g., Personal Repos)"
placeholder={t('apiKeys.github.form.namePlaceholder')}
value={newTokenName}
onChange={(e) => setNewTokenName(e.target.value)}
className="mb-2"
@@ -286,7 +288,7 @@ function ApiKeysSettings() {
<div className="relative">
<Input
type={showToken['new'] ? 'text' : 'password'}
placeholder="GitHub Personal Access Token (ghp_...)"
placeholder={t('apiKeys.github.form.tokenPlaceholder')}
value={newGithubToken}
onChange={(e) => setNewGithubToken(e.target.value)}
className="mb-2 pr-10"
@@ -300,13 +302,13 @@ function ApiKeysSettings() {
</button>
</div>
<div className="flex gap-2">
<Button onClick={createGithubToken}>Add Token</Button>
<Button onClick={createGithubToken}>{t('apiKeys.github.form.addButton')}</Button>
<Button variant="outline" onClick={() => {
setShowNewTokenForm(false);
setNewTokenName('');
setNewGithubToken('');
}}>
Cancel
{t('apiKeys.github.form.cancelButton')}
</Button>
</div>
</div>
@@ -314,7 +316,7 @@ function ApiKeysSettings() {
<div className="space-y-2">
{githubTokens.length === 0 ? (
<p className="text-sm text-muted-foreground italic">No GitHub tokens added yet.</p>
<p className="text-sm text-muted-foreground italic">{t('apiKeys.github.empty')}</p>
) : (
githubTokens.map((token) => (
<div
@@ -324,7 +326,7 @@ function ApiKeysSettings() {
<div className="flex-1">
<div className="font-medium">{token.credential_name}</div>
<div className="text-xs text-muted-foreground mt-1">
Added: {new Date(token.created_at).toLocaleDateString()}
{t('apiKeys.github.added')} {new Date(token.created_at).toLocaleDateString()}
</div>
</div>
<div className="flex items-center gap-2">
@@ -333,7 +335,7 @@ function ApiKeysSettings() {
variant={token.is_active ? 'outline' : 'secondary'}
onClick={() => toggleGithubToken(token.id, token.is_active)}
>
{token.is_active ? 'Active' : 'Inactive'}
{token.is_active ? t('apiKeys.status.active') : t('apiKeys.status.inactive')}
</Button>
<Button
size="sm"
@@ -351,9 +353,9 @@ function ApiKeysSettings() {
{/* Documentation Link */}
<div className="p-4 bg-muted/50 rounded-lg">
<h4 className="font-semibold mb-2">External API Documentation</h4>
<h4 className="font-semibold mb-2">{t('apiKeys.documentation.title')}</h4>
<p className="text-sm text-muted-foreground mb-3">
Learn how to use the external API to trigger Claude/Cursor sessions from your applications.
{t('apiKeys.documentation.description')}
</p>
<a
href="/EXTERNAL_API.md"
@@ -361,7 +363,7 @@ function ApiKeysSettings() {
rel="noopener noreferrer"
className="text-sm text-primary hover:underline"
>
View API Documentation
{t('apiKeys.documentation.viewLink')}
</a>
</div>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -7,13 +7,143 @@ import { css } from '@codemirror/lang-css';
import { json } from '@codemirror/lang-json';
import { markdown } from '@codemirror/lang-markdown';
import { oneDark } from '@codemirror/theme-one-dark';
import { StreamLanguage } from '@codemirror/language';
import { EditorView, showPanel, ViewPlugin } from '@codemirror/view';
import { unifiedMergeView, getChunks } from '@codemirror/merge';
import { showMinimap } from '@replit/codemirror-minimap';
import { X, Save, Download, Maximize2, Minimize2 } from 'lucide-react';
import { X, Save, Download, Maximize2, Minimize2, Settings as SettingsIcon } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
import rehypeKatex from 'rehype-katex';
import rehypeRaw from 'rehype-raw';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { oneDark as prismOneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { api } from '../utils/api';
import { useTranslation } from 'react-i18next';
import { Eye, Code2 } from 'lucide-react';
function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded = false, onToggleExpand = null }) {
// Custom .env file syntax highlighting
const envLanguage = StreamLanguage.define({
token(stream) {
// Comments
if (stream.match(/^#.*/)) return 'comment';
// Key (before =)
if (stream.sol() && stream.match(/^[A-Za-z_][A-Za-z0-9_.]*(?==)/)) return 'variableName.definition';
// Equals sign
if (stream.match(/^=/)) return 'operator';
// Double-quoted string
if (stream.match(/^"(?:[^"\\]|\\.)*"?/)) return 'string';
// Single-quoted string
if (stream.match(/^'(?:[^'\\]|\\.)*'?/)) return 'string';
// Variable interpolation ${...}
if (stream.match(/^\$\{[^}]*\}?/)) return 'variableName.special';
// Variable reference $VAR
if (stream.match(/^\$[A-Za-z_][A-Za-z0-9_]*/)) return 'variableName.special';
// Numbers
if (stream.match(/^\d+/)) return 'number';
// Skip other characters
stream.next();
return null;
},
});
function MarkdownCodeBlock({ inline, className, children, ...props }) {
const [copied, setCopied] = useState(false);
const raw = Array.isArray(children) ? children.join('') : String(children ?? '');
const looksMultiline = /[\r\n]/.test(raw);
const shouldInline = inline || !looksMultiline;
if (shouldInline) {
return (
<code
className={`font-mono text-[0.9em] px-1.5 py-0.5 rounded-md bg-gray-100 text-gray-900 border border-gray-200 dark:bg-gray-800/60 dark:text-gray-100 dark:border-gray-700 whitespace-pre-wrap break-words ${className || ''}`}
{...props}
>
{children}
</code>
);
}
const match = /language-(\w+)/.exec(className || '');
const language = match ? match[1] : 'text';
return (
<div className="relative group my-2">
{language && language !== 'text' && (
<div className="absolute top-2 left-3 z-10 text-xs text-gray-400 font-medium uppercase">{language}</div>
)}
<button
type="button"
onClick={() => {
navigator.clipboard?.writeText(raw).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
});
}}
className="absolute top-2 right-2 z-10 opacity-0 group-hover:opacity-100 transition-opacity text-xs px-2 py-1 rounded-md bg-gray-700/80 hover:bg-gray-700 text-white border border-gray-600"
>
{copied ? 'Copied!' : 'Copy'}
</button>
<SyntaxHighlighter
language={language}
style={prismOneDark}
customStyle={{
margin: 0,
borderRadius: '0.5rem',
fontSize: '0.875rem',
padding: language && language !== 'text' ? '2rem 1rem 1rem 1rem' : '1rem',
}}
>
{raw}
</SyntaxHighlighter>
</div>
);
}
const markdownPreviewComponents = {
code: MarkdownCodeBlock,
blockquote: ({ children }) => (
<blockquote className="border-l-4 border-gray-300 dark:border-gray-600 pl-4 italic text-gray-600 dark:text-gray-400 my-2">
{children}
</blockquote>
),
a: ({ href, children }) => (
<a href={href} className="text-blue-600 dark:text-blue-400 hover:underline" target="_blank" rel="noopener noreferrer">
{children}
</a>
),
table: ({ children }) => (
<div className="overflow-x-auto my-2">
<table className="min-w-full border-collapse border border-gray-200 dark:border-gray-700">{children}</table>
</div>
),
thead: ({ children }) => <thead className="bg-gray-50 dark:bg-gray-800">{children}</thead>,
th: ({ children }) => (
<th className="px-3 py-2 text-left text-sm font-semibold border border-gray-200 dark:border-gray-700">{children}</th>
),
td: ({ children }) => (
<td className="px-3 py-2 align-top text-sm border border-gray-200 dark:border-gray-700">{children}</td>
),
};
function MarkdownPreview({ content }) {
const remarkPlugins = useMemo(() => [remarkGfm, remarkMath], []);
const rehypePlugins = useMemo(() => [rehypeRaw, rehypeKatex], []);
return (
<ReactMarkdown
remarkPlugins={remarkPlugins}
rehypePlugins={rehypePlugins}
components={markdownPreviewComponents}
>
{content}
</ReactMarkdown>
);
}
function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded = false, onToggleExpand = null, onPopOut = null }) {
const { t } = useTranslation('codeEditor');
const [content, setContent] = useState('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
@@ -34,10 +164,17 @@ function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded
return localStorage.getItem('codeEditorLineNumbers') !== 'false';
});
const [fontSize, setFontSize] = useState(() => {
return localStorage.getItem('codeEditorFontSize') || '14';
return localStorage.getItem('codeEditorFontSize') || '12';
});
const [markdownPreview, setMarkdownPreview] = useState(false);
const editorRef = useRef(null);
// Check if file is markdown
const isMarkdownFile = useMemo(() => {
const ext = file.name.split('.').pop()?.toLowerCase();
return ext === 'md' || ext === 'markdown';
}, [file.name]);
// Create minimap extension with chunk-based gutters
const minimapExtension = useMemo(() => {
if (!file.diffInfo || !showDiff || !minimapEnabled) return [];
@@ -103,8 +240,13 @@ function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded
];
}, [file.diffInfo, showDiff]);
// Create editor toolbar panel - always visible
// Whether toolbar has any buttons worth showing
const hasToolbarButtons = !!(file.diffInfo || (isSidebar && onPopOut) || (isSidebar && onToggleExpand));
// Create editor toolbar panel - only when there are buttons to show
const editorToolbarPanel = useMemo(() => {
if (!hasToolbarButtons) return [];
const createPanel = (view) => {
const dom = document.createElement('div');
dom.className = 'cm-editor-toolbar-panel';
@@ -125,13 +267,13 @@ function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded
toolbarHTML += '<div style="display: flex; align-items: center; gap: 8px;">';
if (hasDiff) {
toolbarHTML += `
<span style="font-weight: 500;">${chunkCount > 0 ? `${currentIndex + 1}/${chunkCount}` : '0'} changes</span>
<button class="cm-diff-nav-btn cm-diff-nav-prev" title="Previous change" ${chunkCount === 0 ? 'disabled' : ''}>
<span style="font-weight: 500;">${chunkCount > 0 ? `${currentIndex + 1}/${chunkCount}` : '0'} ${t('toolbar.changes')}</span>
<button class="cm-diff-nav-btn cm-diff-nav-prev" title="${t('toolbar.previousChange')}" ${chunkCount === 0 ? 'disabled' : ''}>
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
</svg>
</button>
<button class="cm-diff-nav-btn cm-diff-nav-next" title="Next change" ${chunkCount === 0 ? 'disabled' : ''}>
<button class="cm-diff-nav-btn cm-diff-nav-next" title="${t('toolbar.nextChange')}" ${chunkCount === 0 ? 'disabled' : ''}>
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
@@ -146,7 +288,7 @@ function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded
// Show/hide diff button (only if there's diff info)
if (file.diffInfo) {
toolbarHTML += `
<button class="cm-toolbar-btn cm-toggle-diff-btn" title="${showDiff ? 'Hide diff highlighting' : 'Show diff highlighting'}">
<button class="cm-toolbar-btn cm-toggle-diff-btn" title="${showDiff ? t('toolbar.hideDiff') : t('toolbar.showDiff')}">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
${showDiff ?
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />' :
@@ -157,19 +299,21 @@ function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded
`;
}
// Settings button
toolbarHTML += `
<button class="cm-toolbar-btn cm-settings-btn" title="Editor Settings">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button>
`;
// Pop out button (only in sidebar mode with onPopOut)
if (isSidebar && onPopOut) {
toolbarHTML += `
<button class="cm-toolbar-btn cm-popout-btn" title="Open in modal">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6M15 3h6v6M10 14L21 3" />
</svg>
</button>
`;
}
// Expand button (only in sidebar mode)
if (isSidebar && onToggleExpand) {
toolbarHTML += `
<button class="cm-toolbar-btn cm-expand-btn" title="${isExpanded ? 'Collapse editor' : 'Expand editor to full width'}">
<button class="cm-toolbar-btn cm-expand-btn" title="${isExpanded ? t('toolbar.collapse') : t('toolbar.expand')}">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
${isExpanded ?
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 9V4.5M9 9H4.5M9 9L3.75 3.75M9 15v4.5M9 15H4.5M9 15l-5.25 5.25M15 9h4.5M15 9V4.5M15 9l5.25-5.25M15 15h4.5M15 15v4.5m0-4.5l5.25 5.25" />' :
@@ -185,7 +329,6 @@ function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded
dom.innerHTML = toolbarHTML;
// Attach event listeners for diff navigation
if (hasDiff) {
const prevBtn = dom.querySelector('.cm-diff-nav-prev');
const nextBtn = dom.querySelector('.cm-diff-nav-next');
@@ -217,7 +360,6 @@ function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded
});
}
// Attach event listener for toggle diff button
if (file.diffInfo) {
const toggleDiffBtn = dom.querySelector('.cm-toggle-diff-btn');
toggleDiffBtn?.addEventListener('click', () => {
@@ -225,15 +367,13 @@ function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded
});
}
// Attach event listener for settings button
const settingsBtn = dom.querySelector('.cm-settings-btn');
settingsBtn?.addEventListener('click', () => {
if (window.openSettings) {
window.openSettings('appearance');
}
});
if (isSidebar && onPopOut) {
const popoutBtn = dom.querySelector('.cm-popout-btn');
popoutBtn?.addEventListener('click', () => {
onPopOut();
});
}
// Attach event listener for expand button
if (isSidebar && onToggleExpand) {
const expandBtn = dom.querySelector('.cm-expand-btn');
expandBtn?.addEventListener('click', () => {
@@ -252,10 +392,15 @@ function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded
};
return [showPanel.of(createPanel)];
}, [file.diffInfo, showDiff, isSidebar, isExpanded, onToggleExpand]);
}, [file.diffInfo, showDiff, isSidebar, isExpanded, onToggleExpand, onPopOut]);
// Get language extension based on file extension
const getLanguageExtension = (filename) => {
const lowerName = filename.toLowerCase();
// Handle dotfiles like .env, .env.local, .env.production, etc.
if (lowerName === '.env' || lowerName.startsWith('.env.')) {
return [envLanguage];
}
const ext = filename.split('.').pop()?.toLowerCase();
switch (ext) {
case 'js':
@@ -277,6 +422,8 @@ function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded
case 'md':
case 'markdown':
return [markdown()];
case 'env':
return [envLanguage];
default:
return [];
}
@@ -463,15 +610,15 @@ function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded
<div className="w-full h-full flex items-center justify-center bg-background">
<div className="flex items-center gap-3">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
<span className="text-gray-900 dark:text-white">Loading {file.name}...</span>
<span className="text-gray-900 dark:text-white">{t('loading', { fileName: file.name })}</span>
</div>
</div>
) : (
<div className="fixed inset-0 z-40 md:bg-black/50 md:flex md:items-center md:justify-center">
<div className="fixed inset-0 z-[9999] md:bg-black/50 md:flex md:items-center md:justify-center">
<div className="code-editor-loading w-full h-full md:rounded-lg md:w-auto md:h-auto p-8 flex items-center justify-center">
<div className="flex items-center gap-3">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
<span className="text-gray-900 dark:text-white">Loading {file.name}...</span>
<span className="text-gray-900 dark:text-white">{t('loading', { fileName: file.name })}</span>
</div>
</div>
</div>
@@ -521,16 +668,16 @@ function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded
/* Editor toolbar panel styling */
.cm-editor-toolbar-panel {
padding: 8px 12px;
padding: 4px 10px;
background-color: ${isDarkMode ? '#1f2937' : '#ffffff'};
border-bottom: 1px solid ${isDarkMode ? '#374151' : '#e5e7eb'};
color: ${isDarkMode ? '#d1d5db' : '#374151'};
font-size: 14px;
font-size: 12px;
}
.cm-diff-nav-btn,
.cm-toolbar-btn {
padding: 4px;
padding: 3px;
background: transparent;
border: none;
cursor: pointer;
@@ -555,7 +702,7 @@ function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded
</style>
<div className={isSidebar ?
'w-full h-full flex flex-col' :
`fixed inset-0 z-40 ${
`fixed inset-0 z-[9999] ${
// Mobile: native fullscreen, Desktop: modal with backdrop
'md:bg-black/50 md:flex md:items-center md:justify-center md:p-4'
} ${isFullscreen ? 'md:p-0' : ''}`}>
@@ -567,59 +714,76 @@ function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded
(isFullscreen ? ' md:w-full md:h-full md:rounded-none' : ' md:w-full md:max-w-6xl md:h-[80vh] md:max-h-[80vh]')
}`}>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-border flex-shrink-0 min-w-0">
<div className="flex items-center gap-3 min-w-0 flex-1">
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border flex-shrink-0 min-w-0">
<div className="flex items-center gap-2 min-w-0 flex-1">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 min-w-0">
<h3 className="font-medium text-gray-900 dark:text-white truncate">{file.name}</h3>
<h3 className="text-sm font-medium text-gray-900 dark:text-white truncate">{file.name}</h3>
{file.diffInfo && (
<span className="text-xs bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-300 px-2 py-1 rounded whitespace-nowrap">
Showing changes
<span className="text-[10px] bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-300 px-1.5 py-0.5 rounded whitespace-nowrap">
{t('header.showingChanges')}
</span>
)}
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 truncate">{file.path}</p>
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">{file.path}</p>
</div>
</div>
<div className="flex items-center gap-1 md:gap-2 flex-shrink-0">
<div className="flex items-center gap-0.5 md:gap-1 flex-shrink-0">
{isMarkdownFile && (
<button
onClick={() => setMarkdownPreview(!markdownPreview)}
className={`p-1.5 rounded-md min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 flex items-center justify-center transition-colors ${
markdownPreview
? 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/30'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-800'
}`}
title={markdownPreview ? t('actions.editMarkdown') : t('actions.previewMarkdown')}
>
{markdownPreview ? <Code2 className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
)}
<button
onClick={() => window.openSettings?.('appearance')}
className="p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 flex items-center justify-center"
title={t('toolbar.settings')}
>
<SettingsIcon className="w-4 h-4" />
</button>
<button
onClick={handleDownload}
className="p-2 md:p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[44px] min-h-[44px] md:min-w-0 md:min-h-0 flex items-center justify-center"
title="Download file"
className="p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 flex items-center justify-center"
title={t('actions.download')}
>
<Download className="w-5 h-5 md:w-4 md:h-4" />
<Download className="w-4 h-4" />
</button>
<button
onClick={handleSave}
disabled={saving}
className={`px-3 py-2 text-white rounded-md disabled:opacity-50 flex items-center gap-2 transition-colors min-h-[44px] md:min-h-0 ${
className={`p-1.5 rounded-md disabled:opacity-50 flex items-center justify-center transition-colors min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 ${
saveSuccess
? 'bg-green-600 hover:bg-green-700'
: 'bg-blue-600 hover:bg-blue-700'
? 'text-green-600 dark:text-green-400 bg-green-50 dark:bg-green-900/30'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-800'
}`}
title={saveSuccess ? t('actions.saved') : saving ? t('actions.saving') : t('actions.save')}
>
{saveSuccess ? (
<>
<svg className="w-5 h-5 md:w-4 md:h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className="hidden sm:inline">Saved!</span>
</>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
) : (
<>
<Save className="w-5 h-5 md:w-4 md:h-4" />
<span className="hidden sm:inline">{saving ? 'Saving...' : 'Save'}</span>
</>
<Save className="w-4 h-4" />
)}
</button>
{!isSidebar && (
<button
onClick={toggleFullscreen}
className="hidden md:flex p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 items-center justify-center"
title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
className="hidden md:flex p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 items-center justify-center"
title={isFullscreen ? t('actions.exitFullscreen') : t('actions.fullscreen')}
>
{isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
</button>
@@ -627,71 +791,79 @@ function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded
<button
onClick={onClose}
className="p-2 md:p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[44px] min-h-[44px] md:min-w-0 md:min-h-0 flex items-center justify-center"
title="Close"
className="p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 flex items-center justify-center"
title={t('actions.close')}
>
<X className="w-6 h-6 md:w-4 md:h-4" />
<X className="w-4 h-4" />
</button>
</div>
</div>
{/* Editor */}
{/* Editor / Markdown Preview */}
<div className="flex-1 overflow-hidden">
<CodeMirror
ref={editorRef}
value={content}
onChange={setContent}
extensions={[
...getLanguageExtension(file.name),
// Always show the toolbar
...editorToolbarPanel,
// Only show diff-related extensions when diff is enabled
...(file.diffInfo && showDiff && file.diffInfo.old_string !== undefined
? [
unifiedMergeView({
original: file.diffInfo.old_string,
mergeControls: false,
highlightChanges: true,
syntaxHighlightDeletions: false,
gutter: true
// NOTE: NO collapseUnchanged - this shows the full file!
}),
...minimapExtension,
...scrollToFirstChunkExtension
]
: []),
...(wordWrap ? [EditorView.lineWrapping] : [])
]}
theme={isDarkMode ? oneDark : undefined}
height="100%"
style={{
fontSize: `${fontSize}px`,
height: '100%',
}}
basicSetup={{
lineNumbers: showLineNumbers,
foldGutter: true,
dropCursor: false,
allowMultipleSelections: false,
indentOnInput: true,
bracketMatching: true,
closeBrackets: true,
autocompletion: true,
highlightSelectionMatches: true,
searchKeymap: true,
}}
/>
{markdownPreview && isMarkdownFile ? (
<div className="h-full overflow-y-auto bg-white dark:bg-gray-900">
<div className="max-w-4xl mx-auto px-8 py-6 prose prose-sm dark:prose-invert prose-headings:font-semibold prose-a:text-blue-600 dark:prose-a:text-blue-400 prose-code:text-sm prose-pre:bg-gray-900 prose-img:rounded-lg max-w-none">
<MarkdownPreview content={content} />
</div>
</div>
) : (
<CodeMirror
ref={editorRef}
value={content}
onChange={setContent}
extensions={[
...getLanguageExtension(file.name),
// Always show the toolbar
...editorToolbarPanel,
// Only show diff-related extensions when diff is enabled
...(file.diffInfo && showDiff && file.diffInfo.old_string !== undefined
? [
unifiedMergeView({
original: file.diffInfo.old_string,
mergeControls: false,
highlightChanges: true,
syntaxHighlightDeletions: false,
gutter: true
// NOTE: NO collapseUnchanged - this shows the full file!
}),
...minimapExtension,
...scrollToFirstChunkExtension
]
: []),
...(wordWrap ? [EditorView.lineWrapping] : [])
]}
theme={isDarkMode ? oneDark : undefined}
height="100%"
style={{
fontSize: `${fontSize}px`,
height: '100%',
}}
basicSetup={{
lineNumbers: showLineNumbers,
foldGutter: true,
dropCursor: false,
allowMultipleSelections: false,
indentOnInput: true,
bracketMatching: true,
closeBrackets: true,
autocompletion: true,
highlightSelectionMatches: true,
searchKeymap: true,
}}
/>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between p-3 border-t border-border bg-muted flex-shrink-0">
<div className="flex items-center gap-4 text-sm text-gray-600 dark:text-gray-400">
<span>Lines: {content.split('\n').length}</span>
<span>Characters: {content.length}</span>
<div className="flex items-center justify-between px-3 py-1.5 border-t border-border bg-muted flex-shrink-0">
<div className="flex items-center gap-3 text-xs text-gray-600 dark:text-gray-400">
<span>{t('footer.lines')} {content.split('\n').length}</span>
<span>{t('footer.characters')} {content.length}</span>
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
Press Ctrl+S to save Esc to close
<div className="text-xs text-gray-500 dark:text-gray-400">
{t('footer.shortcuts')}
</div>
</div>
</div>

View File

@@ -4,53 +4,61 @@ import React, { useEffect, useRef } from 'react';
* CommandMenu - Autocomplete dropdown for slash commands
*
* @param {Array} commands - Array of command objects to display
* @param {number} selectedIndex - Currently selected command index
* @param {number} selectedIndex - Currently selected command index (index in `commands`)
* @param {Function} onSelect - Callback when a command is selected
* @param {Function} onClose - Callback when menu should close
* @param {Object} position - Position object { top, left } for absolute positioning
* @param {boolean} isOpen - Whether the menu is open
* @param {Array} frequentCommands - Array of frequently used command objects
*/
const CommandMenu = ({ commands = [], selectedIndex = -1, onSelect, onClose, position = { top: 0, left: 0 }, isOpen = false, frequentCommands = [] }) => {
const CommandMenu = ({
commands = [],
selectedIndex = -1,
onSelect,
onClose,
position = { top: 0, left: 0 },
isOpen = false,
frequentCommands = [],
}) => {
const menuRef = useRef(null);
const selectedItemRef = useRef(null);
// Calculate responsive positioning
// Calculate responsive menu positioning.
// Mobile: dock above chat input. Desktop: clamp to viewport.
const getMenuPosition = () => {
const isMobile = window.innerWidth < 640;
const viewportHeight = window.innerHeight;
const menuHeight = 300; // Max height of menu
if (isMobile) {
// On mobile, calculate bottom position dynamically to appear above the input
// Use the bottom value which is calculated as: window.innerHeight - textarea.top + spacing
const inputBottom = position.bottom || 90; // Use provided bottom or default
// On mobile, calculate bottom position dynamically to appear above the input.
// Use the bottom value calculated as: window.innerHeight - textarea.top + spacing.
const inputBottom = position.bottom || 90;
return {
position: 'fixed',
bottom: `${inputBottom}px`, // Position above the input with spacing already included
bottom: `${inputBottom}px`, // Position above the input with spacing already included.
left: '16px',
right: '16px',
width: 'auto',
maxWidth: 'calc(100vw - 32px)',
maxHeight: 'min(50vh, 300px)' // Limit to smaller of 50vh or 300px
maxHeight: 'min(50vh, 300px)', // Limit to smaller of 50vh or 300px.
};
}
// On desktop, use provided position but ensure it stays on screen
// On desktop, use provided position but ensure it stays on screen.
return {
position: 'fixed',
top: `${Math.max(16, Math.min(position.top, viewportHeight - 316))}px`,
left: `${position.left}px`,
width: 'min(400px, calc(100vw - 32px))',
maxWidth: 'calc(100vw - 32px)',
maxHeight: '300px'
maxHeight: '300px',
};
};
const menuPosition = getMenuPosition();
// Close menu when clicking outside
// Close menu when clicking outside.
useEffect(() => {
const handleClickOutside = (event) => {
if (menuRef.current && !menuRef.current.contains(event.target) && isOpen) {
@@ -64,9 +72,11 @@ const CommandMenu = ({ commands = [], selectedIndex = -1, onSelect, onClose, pos
document.removeEventListener('mousedown', handleClickOutside);
};
}
return undefined;
}, [isOpen, onClose]);
// Scroll selected item into view
// Keep selected keyboard item visible while navigating.
useEffect(() => {
if (selectedItemRef.current && menuRef.current) {
const menuRect = menuRef.current.getBoundingClientRect();
@@ -84,7 +94,7 @@ const CommandMenu = ({ commands = [], selectedIndex = -1, onSelect, onClose, pos
return null;
}
// Show a message if no commands are available
// Show a message if no commands are available.
if (commands.length === 0) {
return (
<div
@@ -100,7 +110,7 @@ const CommandMenu = ({ commands = [], selectedIndex = -1, onSelect, onClose, pos
opacity: 1,
transform: 'translateY(0)',
transition: 'opacity 150ms ease-in-out, transform 150ms ease-in-out',
textAlign: 'center'
textAlign: 'center',
}}
>
No commands available
@@ -108,11 +118,20 @@ const CommandMenu = ({ commands = [], selectedIndex = -1, onSelect, onClose, pos
);
}
// Add frequent commands as a special group if provided
// Add frequent commands as a special group if provided.
const hasFrequentCommands = frequentCommands.length > 0;
// Group commands by namespace
const getCommandKey = (command) =>
`${command.name}::${command.namespace || command.type || 'other'}::${command.path || ''}`;
const frequentCommandKeys = new Set(frequentCommands.map(getCommandKey));
// Group commands by namespace for section rendering.
// When frequent commands are shown, avoid duplicate rows in other sections.
const groupedCommands = commands.reduce((groups, command) => {
if (hasFrequentCommands && frequentCommandKeys.has(getCommandKey(command))) {
return groups;
}
const namespace = command.namespace || command.type || 'other';
if (!groups[namespace]) {
groups[namespace] = [];
@@ -121,36 +140,33 @@ const CommandMenu = ({ commands = [], selectedIndex = -1, onSelect, onClose, pos
return groups;
}, {});
// Add frequent commands as a separate group
// Add frequent commands as a separate group.
if (hasFrequentCommands) {
groupedCommands['frequent'] = frequentCommands;
groupedCommands.frequent = frequentCommands;
}
// Order: frequent, builtin, project, user, other
// Order: frequent, builtin, project, user, other.
const namespaceOrder = hasFrequentCommands
? ['frequent', 'builtin', 'project', 'user', 'other']
: ['builtin', 'project', 'user', 'other'];
const orderedNamespaces = namespaceOrder.filter(ns => groupedCommands[ns]);
const orderedNamespaces = namespaceOrder.filter((ns) => groupedCommands[ns]);
const namespaceLabels = {
frequent: ' Frequently Used',
frequent: '\u2B50 Frequently Used',
builtin: 'Built-in Commands',
project: 'Project Commands',
user: 'User Commands',
other: 'Other Commands'
other: 'Other Commands',
};
// Calculate global index for each command
let globalIndex = 0;
const commandsWithIndex = [];
orderedNamespaces.forEach(namespace => {
groupedCommands[namespace].forEach(command => {
commandsWithIndex.push({
...command,
globalIndex: globalIndex++,
namespace
});
});
// Keep all selection indices aligned to `commands` (filteredCommands from the hook).
// This prevents mismatches between mouse selection (rendered list) and keyboard selection.
const commandIndexByKey = new Map();
commands.forEach((command, index) => {
const key = getCommandKey(command);
if (!commandIndexByKey.has(key)) {
commandIndexByKey.set(key, index);
}
});
return (
@@ -169,7 +185,7 @@ const CommandMenu = ({ commands = [], selectedIndex = -1, onSelect, onClose, pos
padding: '8px',
opacity: isOpen ? 1 : 0,
transform: isOpen ? 'translateY(0)' : 'translateY(-10px)',
transition: 'opacity 150ms ease-in-out, transform 150ms ease-in-out'
transition: 'opacity 150ms ease-in-out, transform 150ms ease-in-out',
}}
>
{orderedNamespaces.map((namespace) => (
@@ -182,25 +198,35 @@ const CommandMenu = ({ commands = [], selectedIndex = -1, onSelect, onClose, pos
textTransform: 'uppercase',
color: '#6b7280',
padding: '8px 12px 4px',
letterSpacing: '0.05em'
letterSpacing: '0.05em',
}}
>
{namespaceLabels[namespace] || namespace}
</div>
)}
{groupedCommands[namespace].map((command) => {
const cmdWithIndex = commandsWithIndex.find(c => c.name === command.name && c.namespace === namespace);
const isSelected = cmdWithIndex && cmdWithIndex.globalIndex === selectedIndex;
const commandKey = getCommandKey(command);
const commandIndex = commandIndexByKey.get(commandKey) ?? -1;
const isSelected = commandIndex === selectedIndex;
return (
<div
key={`${namespace}-${command.name}`}
key={`${namespace}-${command.name}-${command.path || ''}`}
ref={isSelected ? selectedItemRef : null}
role="option"
aria-selected={isSelected}
className="command-item"
onMouseEnter={() => onSelect && onSelect(command, cmdWithIndex.globalIndex, true)}
onClick={() => onSelect && onSelect(command, cmdWithIndex.globalIndex, false)}
onMouseEnter={() => {
if (onSelect && commandIndex >= 0) {
onSelect(command, commandIndex, true);
}
}}
onClick={() => {
if (onSelect) {
onSelect(command, commandIndex, false);
}
}}
style={{
display: 'flex',
alignItems: 'flex-start',
@@ -209,9 +235,10 @@ const CommandMenu = ({ commands = [], selectedIndex = -1, onSelect, onClose, pos
cursor: 'pointer',
backgroundColor: isSelected ? '#eff6ff' : 'transparent',
transition: 'background-color 100ms ease-in-out',
marginBottom: '2px'
marginBottom: '2px',
}}
onMouseDown={(e) => e.preventDefault()} // Prevent textarea blur
// Prevent textarea blur when clicking a menu item.
onMouseDown={(e) => e.preventDefault()}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div
@@ -219,20 +246,16 @@ const CommandMenu = ({ commands = [], selectedIndex = -1, onSelect, onClose, pos
display: 'flex',
alignItems: 'center',
gap: '8px',
marginBottom: command.description ? '4px' : 0
marginBottom: command.description ? '4px' : 0,
}}
>
{/* Command icon based on namespace */}
<span
style={{
fontSize: '16px',
flexShrink: 0
}}
>
{namespace === 'builtin' && '⚡'}
{namespace === 'project' && '📁'}
{namespace === 'user' && '👤'}
{namespace === 'other' && '📝'}
<span style={{ fontSize: '16px', flexShrink: 0 }}>
{namespace === 'builtin' && '\u26A1'}
{namespace === 'project' && '\uD83D\uDCC1'}
{namespace === 'user' && '\uD83D\uDC64'}
{namespace === 'other' && '\uD83D\uDCDD'}
{namespace === 'frequent' && '\u2B50'}
</span>
{/* Command name */}
@@ -241,7 +264,7 @@ const CommandMenu = ({ commands = [], selectedIndex = -1, onSelect, onClose, pos
fontWeight: 600,
fontSize: '14px',
color: '#111827',
fontFamily: 'monospace'
fontFamily: 'monospace',
}}
>
{command.name}
@@ -257,7 +280,7 @@ const CommandMenu = ({ commands = [], selectedIndex = -1, onSelect, onClose, pos
borderRadius: '4px',
backgroundColor: '#f3f4f6',
color: '#6b7280',
fontWeight: 500
fontWeight: 500,
}}
>
{command.metadata.type}
@@ -274,7 +297,7 @@ const CommandMenu = ({ commands = [], selectedIndex = -1, onSelect, onClose, pos
marginLeft: '24px',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis'
textOverflow: 'ellipsis',
}}
>
{command.description}
@@ -289,10 +312,10 @@ const CommandMenu = ({ commands = [], selectedIndex = -1, onSelect, onClose, pos
marginLeft: '8px',
color: '#3b82f6',
fontSize: '12px',
fontWeight: 600
fontWeight: 600,
}}
>
{'\u21B5'}
</span>
)}
</div>

View File

@@ -5,8 +5,10 @@ import { Key, Plus, Trash2, Eye, EyeOff, Copy, Check, Github, ExternalLink } fro
import { useVersionCheck } from '../hooks/useVersionCheck';
import { version } from '../../package.json';
import { authenticatedFetch } from '../utils/api';
import { useTranslation } from 'react-i18next';
function CredentialsSettings() {
const { t } = useTranslation('settings');
const [apiKeys, setApiKeys] = useState([]);
const [githubCredentials, setGithubCredentials] = useState([]);
const [loading, setLoading] = useState(true);
@@ -69,7 +71,7 @@ function CredentialsSettings() {
};
const deleteApiKey = async (keyId) => {
if (!confirm('Are you sure you want to delete this API key?')) return;
if (!confirm(t('apiKeys.confirmDelete'))) return;
try {
await authenticatedFetch(`/api/settings/api-keys/${keyId}`, {
@@ -121,7 +123,7 @@ function CredentialsSettings() {
};
const deleteGithubCredential = async (credentialId) => {
if (!confirm('Are you sure you want to delete this GitHub token?')) return;
if (!confirm(t('apiKeys.github.confirmDelete'))) return;
try {
await authenticatedFetch(`/api/settings/credentials/${credentialId}`, {
@@ -152,7 +154,7 @@ function CredentialsSettings() {
};
if (loading) {
return <div className="text-muted-foreground">Loading...</div>;
return <div className="text-muted-foreground">{t('apiKeys.loading')}</div>;
}
return (
@@ -160,9 +162,9 @@ function CredentialsSettings() {
{/* New API Key Alert */}
{newlyCreatedKey && (
<div className="p-4 bg-yellow-500/10 border border-yellow-500/20 rounded-lg">
<h4 className="font-semibold text-yellow-500 mb-2"> Save Your API Key</h4>
<h4 className="font-semibold text-yellow-500 mb-2">{t('apiKeys.newKey.alertTitle')}</h4>
<p className="text-sm text-muted-foreground mb-3">
This is the only time you'll see this key. Store it securely.
{t('apiKeys.newKey.alertMessage')}
</p>
<div className="flex items-center gap-2">
<code className="flex-1 px-3 py-2 bg-background/50 rounded font-mono text-sm break-all">
@@ -182,7 +184,7 @@ function CredentialsSettings() {
className="mt-3"
onClick={() => setNewlyCreatedKey(null)}
>
I've saved it
{t('apiKeys.newKey.iveSavedIt')}
</Button>
</div>
)}
@@ -192,20 +194,20 @@ function CredentialsSettings() {
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Key className="h-5 w-5" />
<h3 className="text-lg font-semibold">API Keys</h3>
<h3 className="text-lg font-semibold">{t('apiKeys.title')}</h3>
</div>
<Button
size="sm"
onClick={() => setShowNewKeyForm(!showNewKeyForm)}
>
<Plus className="h-4 w-4 mr-1" />
New API Key
{t('apiKeys.newButton')}
</Button>
</div>
<div className="mb-4">
<p className="text-sm text-muted-foreground mb-2">
Generate API keys to access the external API from other applications.
{t('apiKeys.description')}
</p>
<a
href="/api-docs.html"
@@ -213,7 +215,7 @@ function CredentialsSettings() {
rel="noopener noreferrer"
className="text-sm text-primary hover:underline inline-flex items-center gap-1"
>
API Documentation
{t('apiKeys.apiDocsLink')}
<ExternalLink className="h-3 w-3" />
</a>
</div>
@@ -221,15 +223,15 @@ function CredentialsSettings() {
{showNewKeyForm && (
<div className="mb-4 p-4 border rounded-lg bg-card">
<Input
placeholder="API Key Name (e.g., Production Server)"
placeholder={t('apiKeys.form.placeholder')}
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
className="mb-2"
/>
<div className="flex gap-2">
<Button onClick={createApiKey}>Create</Button>
<Button onClick={createApiKey}>{t('apiKeys.form.createButton')}</Button>
<Button variant="outline" onClick={() => setShowNewKeyForm(false)}>
Cancel
{t('apiKeys.form.cancelButton')}
</Button>
</div>
</div>
@@ -237,7 +239,7 @@ function CredentialsSettings() {
<div className="space-y-2">
{apiKeys.length === 0 ? (
<p className="text-sm text-muted-foreground italic">No API keys created yet.</p>
<p className="text-sm text-muted-foreground italic">{t('apiKeys.empty')}</p>
) : (
apiKeys.map((key) => (
<div
@@ -248,8 +250,8 @@ function CredentialsSettings() {
<div className="font-medium">{key.key_name}</div>
<code className="text-xs text-muted-foreground">{key.api_key}</code>
<div className="text-xs text-muted-foreground mt-1">
Created: {new Date(key.created_at).toLocaleDateString()}
{key.last_used && `Last used: ${new Date(key.last_used).toLocaleDateString()}`}
{t('apiKeys.list.created')} {new Date(key.created_at).toLocaleDateString()}
{key.last_used && `${t('apiKeys.list.lastUsed')} ${new Date(key.last_used).toLocaleDateString()}`}
</div>
</div>
<div className="flex items-center gap-2">
@@ -258,7 +260,7 @@ function CredentialsSettings() {
variant={key.is_active ? 'outline' : 'secondary'}
onClick={() => toggleApiKey(key.id, key.is_active)}
>
{key.is_active ? 'Active' : 'Inactive'}
{key.is_active ? t('apiKeys.status.active') : t('apiKeys.status.inactive')}
</Button>
<Button
size="sm"
@@ -279,25 +281,25 @@ function CredentialsSettings() {
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Github className="h-5 w-5" />
<h3 className="text-lg font-semibold">GitHub Credentials</h3>
<h3 className="text-lg font-semibold">{t('apiKeys.github.title')}</h3>
</div>
<Button
size="sm"
onClick={() => setShowNewGithubForm(!showNewGithubForm)}
>
<Plus className="h-4 w-4 mr-1" />
Add Token
{t('apiKeys.github.addButton')}
</Button>
</div>
<p className="text-sm text-muted-foreground mb-4">
Add GitHub Personal Access Tokens to clone private repositories. You can also pass tokens directly in API requests without storing them.
{t('apiKeys.github.descriptionAlt')}
</p>
{showNewGithubForm && (
<div className="mb-4 p-4 border rounded-lg bg-card space-y-3">
<Input
placeholder="Token Name (e.g., Personal Repos)"
placeholder={t('apiKeys.github.form.namePlaceholder')}
value={newGithubName}
onChange={(e) => setNewGithubName(e.target.value)}
/>
@@ -305,7 +307,7 @@ function CredentialsSettings() {
<div className="relative">
<Input
type={showToken['new'] ? 'text' : 'password'}
placeholder="GitHub Personal Access Token (ghp_...)"
placeholder={t('apiKeys.github.form.tokenPlaceholder')}
value={newGithubToken}
onChange={(e) => setNewGithubToken(e.target.value)}
className="pr-10"
@@ -320,20 +322,20 @@ function CredentialsSettings() {
</div>
<Input
placeholder="Description (optional)"
placeholder={t('apiKeys.github.form.descriptionPlaceholder')}
value={newGithubDescription}
onChange={(e) => setNewGithubDescription(e.target.value)}
/>
<div className="flex gap-2">
<Button onClick={createGithubCredential}>Add Token</Button>
<Button onClick={createGithubCredential}>{t('apiKeys.github.form.addButton')}</Button>
<Button variant="outline" onClick={() => {
setShowNewGithubForm(false);
setNewGithubName('');
setNewGithubToken('');
setNewGithubDescription('');
}}>
Cancel
{t('apiKeys.github.form.cancelButton')}
</Button>
</div>
@@ -343,14 +345,14 @@ function CredentialsSettings() {
rel="noopener noreferrer"
className="text-xs text-primary hover:underline block"
>
How to create a GitHub Personal Access Token
{t('apiKeys.github.form.howToCreate')}
</a>
</div>
)}
<div className="space-y-2">
{githubCredentials.length === 0 ? (
<p className="text-sm text-muted-foreground italic">No GitHub tokens added yet.</p>
<p className="text-sm text-muted-foreground italic">{t('apiKeys.github.empty')}</p>
) : (
githubCredentials.map((credential) => (
<div
@@ -363,7 +365,7 @@ function CredentialsSettings() {
<div className="text-xs text-muted-foreground">{credential.description}</div>
)}
<div className="text-xs text-muted-foreground mt-1">
Added: {new Date(credential.created_at).toLocaleDateString()}
{t('apiKeys.github.added')} {new Date(credential.created_at).toLocaleDateString()}
</div>
</div>
<div className="flex items-center gap-2">
@@ -372,7 +374,7 @@ function CredentialsSettings() {
variant={credential.is_active ? 'outline' : 'secondary'}
onClick={() => toggleGithubCredential(credential.id, credential.is_active)}
>
{credential.is_active ? 'Active' : 'Inactive'}
{credential.is_active ? t('apiKeys.status.active') : t('apiKeys.status.inactive')}
</Button>
<Button
size="sm"
@@ -406,7 +408,7 @@ function CredentialsSettings() {
rel="noopener noreferrer"
className="flex items-center gap-1.5 px-2 py-0.5 bg-green-500/10 text-green-600 dark:text-green-400 rounded-full hover:bg-green-500/20 transition-colors not-italic font-medium"
>
<span className="text-[10px]">Update available: v{latestVersion}</span>
<span className="text-[10px]">{t('apiKeys.version.updateAvailable', { version: latestVersion })}</span>
<ExternalLink className="h-2.5 w-2.5" />
</a>
)}

View File

@@ -3,7 +3,7 @@ import React from 'react';
function DiffViewer({ diff, fileName, isMobile, wrapText }) {
if (!diff) {
return (
<div className="p-4 text-center text-gray-500 dark:text-gray-400 text-sm">
<div className="p-4 text-center text-muted-foreground text-sm">
No diff available
</div>
);
@@ -17,13 +17,13 @@ function DiffViewer({ diff, fileName, isMobile, wrapText }) {
return (
<div
key={index}
className={`font-mono text-xs p-2 ${
className={`font-mono text-xs px-3 py-0.5 ${
isMobile && wrapText ? 'whitespace-pre-wrap break-all' : 'whitespace-pre overflow-x-auto'
} ${
isAddition ? 'bg-green-50 dark:bg-green-950 text-green-700 dark:text-green-300' :
isDeletion ? 'bg-red-50 dark:bg-red-950 text-red-700 dark:text-red-300' :
isHeader ? 'bg-blue-50 dark:bg-blue-950 text-blue-700 dark:text-blue-300' :
'text-gray-600 dark:text-gray-400'
isAddition ? 'bg-green-50 dark:bg-green-950/50 text-green-700 dark:text-green-300' :
isDeletion ? 'bg-red-50 dark:bg-red-950/50 text-red-700 dark:text-red-300' :
isHeader ? 'bg-primary/5 text-primary' :
'text-muted-foreground/70'
}`}
>
{line}

View File

@@ -1,20 +1,271 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { ScrollArea } from './ui/scroll-area';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { Folder, FolderOpen, File, FileText, FileCode, List, TableProperties, Eye, Search, X } from 'lucide-react';
import {
Folder, FolderOpen, File, FileText, FileCode, List, TableProperties, Eye, Search, X,
ChevronRight,
FileJson, FileType, FileSpreadsheet, FileArchive,
Hash, Braces, Terminal, Database, Globe, Palette, Music2, Video, Archive,
Lock, Shield, Settings, Image, BookOpen, Cpu, Box, Gem, Coffee,
Flame, Hexagon, FileCode2, Code2, Cog, FileWarning, Binary, SquareFunction,
Scroll, FlaskConical, NotebookPen, FileCheck, Workflow, Blocks
} from 'lucide-react';
import { cn } from '../lib/utils';
import CodeEditor from './CodeEditor';
import ImageViewer from './ImageViewer';
import { api } from '../utils/api';
function FileTree({ selectedProject }) {
// ─── File Icon Registry ──────────────────────────────────────────────
// Maps file extensions (and special filenames) to { icon, colorClass } pairs.
// Uses lucide-react icons mapped semantically to file types.
const ICON_SIZE = 'w-4 h-4 flex-shrink-0';
const FILE_ICON_MAP = {
// ── JavaScript / TypeScript ──
js: { icon: FileCode, color: 'text-yellow-500' },
jsx: { icon: FileCode, color: 'text-yellow-500' },
mjs: { icon: FileCode, color: 'text-yellow-500' },
cjs: { icon: FileCode, color: 'text-yellow-500' },
ts: { icon: FileCode2, color: 'text-blue-500' },
tsx: { icon: FileCode2, color: 'text-blue-500' },
mts: { icon: FileCode2, color: 'text-blue-500' },
// ── Python ──
py: { icon: Code2, color: 'text-emerald-500' },
pyw: { icon: Code2, color: 'text-emerald-500' },
pyi: { icon: Code2, color: 'text-emerald-400' },
ipynb:{ icon: NotebookPen, color: 'text-orange-500' },
// ── Rust ──
rs: { icon: Cog, color: 'text-orange-600' },
toml: { icon: Settings, color: 'text-gray-500' },
// ── Go ──
go: { icon: Hexagon, color: 'text-cyan-500' },
// ── Ruby ──
rb: { icon: Gem, color: 'text-red-500' },
erb: { icon: Gem, color: 'text-red-400' },
// ── PHP ──
php: { icon: Blocks, color: 'text-violet-500' },
// ── Java / Kotlin ──
java: { icon: Coffee, color: 'text-red-600' },
jar: { icon: Coffee, color: 'text-red-500' },
kt: { icon: Hexagon, color: 'text-violet-500' },
kts: { icon: Hexagon, color: 'text-violet-400' },
// ── C / C++ ──
c: { icon: Cpu, color: 'text-blue-600' },
h: { icon: Cpu, color: 'text-blue-400' },
cpp: { icon: Cpu, color: 'text-blue-700' },
hpp: { icon: Cpu, color: 'text-blue-500' },
cc: { icon: Cpu, color: 'text-blue-700' },
// ── C# ──
cs: { icon: Hexagon, color: 'text-purple-600' },
// ── Swift ──
swift:{ icon: Flame, color: 'text-orange-500' },
// ── Lua ──
lua: { icon: SquareFunction, color: 'text-blue-500' },
// ── R ──
r: { icon: FlaskConical, color: 'text-blue-600' },
// ── Web ──
html: { icon: Globe, color: 'text-orange-600' },
htm: { icon: Globe, color: 'text-orange-600' },
css: { icon: Hash, color: 'text-blue-500' },
scss: { icon: Hash, color: 'text-pink-500' },
sass: { icon: Hash, color: 'text-pink-400' },
less: { icon: Hash, color: 'text-indigo-500' },
vue: { icon: FileCode2, color: 'text-emerald-500' },
svelte:{ icon: FileCode2, color: 'text-orange-500' },
// ── Data / Config ──
json: { icon: Braces, color: 'text-yellow-600' },
jsonc:{ icon: Braces, color: 'text-yellow-500' },
json5:{ icon: Braces, color: 'text-yellow-500' },
yaml: { icon: Settings, color: 'text-purple-400' },
yml: { icon: Settings, color: 'text-purple-400' },
xml: { icon: FileCode, color: 'text-orange-500' },
csv: { icon: FileSpreadsheet, color: 'text-green-600' },
tsv: { icon: FileSpreadsheet, color: 'text-green-500' },
sql: { icon: Database, color: 'text-blue-500' },
graphql:{ icon: Workflow, color: 'text-pink-500' },
gql: { icon: Workflow, color: 'text-pink-500' },
proto:{ icon: Box, color: 'text-green-500' },
env: { icon: Shield, color: 'text-yellow-600' },
// ── Documents ──
md: { icon: BookOpen, color: 'text-blue-500' },
mdx: { icon: BookOpen, color: 'text-blue-400' },
txt: { icon: FileText, color: 'text-gray-500' },
doc: { icon: FileText, color: 'text-blue-600' },
docx: { icon: FileText, color: 'text-blue-600' },
pdf: { icon: FileCheck, color: 'text-red-600' },
rtf: { icon: FileText, color: 'text-gray-500' },
tex: { icon: Scroll, color: 'text-teal-600' },
rst: { icon: FileText, color: 'text-gray-400' },
// ── Shell / Scripts ──
sh: { icon: Terminal, color: 'text-green-500' },
bash: { icon: Terminal, color: 'text-green-500' },
zsh: { icon: Terminal, color: 'text-green-400' },
fish: { icon: Terminal, color: 'text-green-400' },
ps1: { icon: Terminal, color: 'text-blue-400' },
bat: { icon: Terminal, color: 'text-gray-500' },
cmd: { icon: Terminal, color: 'text-gray-500' },
// ── Images ──
png: { icon: Image, color: 'text-purple-500' },
jpg: { icon: Image, color: 'text-purple-500' },
jpeg: { icon: Image, color: 'text-purple-500' },
gif: { icon: Image, color: 'text-purple-400' },
webp: { icon: Image, color: 'text-purple-400' },
ico: { icon: Image, color: 'text-purple-400' },
bmp: { icon: Image, color: 'text-purple-400' },
tiff: { icon: Image, color: 'text-purple-400' },
svg: { icon: Palette, color: 'text-amber-500' },
// ── Audio ──
mp3: { icon: Music2, color: 'text-pink-500' },
wav: { icon: Music2, color: 'text-pink-500' },
ogg: { icon: Music2, color: 'text-pink-400' },
flac: { icon: Music2, color: 'text-pink-400' },
aac: { icon: Music2, color: 'text-pink-400' },
m4a: { icon: Music2, color: 'text-pink-400' },
// ── Video ──
mp4: { icon: Video, color: 'text-rose-500' },
mov: { icon: Video, color: 'text-rose-500' },
avi: { icon: Video, color: 'text-rose-500' },
webm: { icon: Video, color: 'text-rose-400' },
mkv: { icon: Video, color: 'text-rose-400' },
// ── Fonts ──
ttf: { icon: FileType, color: 'text-red-500' },
otf: { icon: FileType, color: 'text-red-500' },
woff: { icon: FileType, color: 'text-red-400' },
woff2:{ icon: FileType, color: 'text-red-400' },
eot: { icon: FileType, color: 'text-red-400' },
// ── Archives ──
zip: { icon: Archive, color: 'text-amber-600' },
tar: { icon: Archive, color: 'text-amber-600' },
gz: { icon: Archive, color: 'text-amber-600' },
bz2: { icon: Archive, color: 'text-amber-600' },
rar: { icon: Archive, color: 'text-amber-500' },
'7z': { icon: Archive, color: 'text-amber-500' },
// ── Lock files ──
lock: { icon: Lock, color: 'text-gray-500' },
// ── Binary / Executable ──
exe: { icon: Binary, color: 'text-gray-500' },
bin: { icon: Binary, color: 'text-gray-500' },
dll: { icon: Binary, color: 'text-gray-400' },
so: { icon: Binary, color: 'text-gray-400' },
dylib:{ icon: Binary, color: 'text-gray-400' },
wasm: { icon: Binary, color: 'text-purple-500' },
// ── Misc config ──
ini: { icon: Settings, color: 'text-gray-500' },
cfg: { icon: Settings, color: 'text-gray-500' },
conf: { icon: Settings, color: 'text-gray-500' },
log: { icon: Scroll, color: 'text-gray-400' },
map: { icon: File, color: 'text-gray-400' },
};
// Special full-filename matches (highest priority)
const FILENAME_ICON_MAP = {
'Dockerfile': { icon: Box, color: 'text-blue-500' },
'docker-compose.yml': { icon: Box, color: 'text-blue-500' },
'docker-compose.yaml': { icon: Box, color: 'text-blue-500' },
'.dockerignore': { icon: Box, color: 'text-gray-500' },
'.gitignore': { icon: Settings, color: 'text-gray-500' },
'.gitmodules': { icon: Settings, color: 'text-gray-500' },
'.gitattributes': { icon: Settings, color: 'text-gray-500' },
'.editorconfig': { icon: Settings, color: 'text-gray-500' },
'.prettierrc': { icon: Settings, color: 'text-pink-400' },
'.prettierignore': { icon: Settings, color: 'text-gray-500' },
'.eslintrc': { icon: Settings, color: 'text-violet-500' },
'.eslintrc.js': { icon: Settings, color: 'text-violet-500' },
'.eslintrc.json': { icon: Settings, color: 'text-violet-500' },
'.eslintrc.cjs': { icon: Settings, color: 'text-violet-500' },
'eslint.config.js': { icon: Settings, color: 'text-violet-500' },
'eslint.config.mjs':{ icon: Settings, color: 'text-violet-500' },
'.env': { icon: Shield, color: 'text-yellow-600' },
'.env.local': { icon: Shield, color: 'text-yellow-600' },
'.env.development': { icon: Shield, color: 'text-yellow-500' },
'.env.production': { icon: Shield, color: 'text-yellow-600' },
'.env.example': { icon: Shield, color: 'text-yellow-400' },
'package.json': { icon: Braces, color: 'text-green-500' },
'package-lock.json':{ icon: Lock, color: 'text-gray-500' },
'yarn.lock': { icon: Lock, color: 'text-blue-400' },
'pnpm-lock.yaml': { icon: Lock, color: 'text-orange-400' },
'bun.lockb': { icon: Lock, color: 'text-gray-400' },
'Cargo.toml': { icon: Cog, color: 'text-orange-600' },
'Cargo.lock': { icon: Lock, color: 'text-orange-400' },
'Gemfile': { icon: Gem, color: 'text-red-500' },
'Gemfile.lock': { icon: Lock, color: 'text-red-400' },
'Makefile': { icon: Terminal, color: 'text-gray-500' },
'CMakeLists.txt': { icon: Cog, color: 'text-blue-500' },
'tsconfig.json': { icon: Braces, color: 'text-blue-500' },
'jsconfig.json': { icon: Braces, color: 'text-yellow-500' },
'vite.config.ts': { icon: Flame, color: 'text-purple-500' },
'vite.config.js': { icon: Flame, color: 'text-purple-500' },
'webpack.config.js':{ icon: Cog, color: 'text-blue-500' },
'tailwind.config.js':{ icon: Hash, color: 'text-cyan-500' },
'tailwind.config.ts':{ icon: Hash, color: 'text-cyan-500' },
'postcss.config.js':{ icon: Cog, color: 'text-red-400' },
'babel.config.js': { icon: Settings, color: 'text-yellow-500' },
'.babelrc': { icon: Settings, color: 'text-yellow-500' },
'README.md': { icon: BookOpen, color: 'text-blue-500' },
'LICENSE': { icon: FileCheck, color: 'text-gray-500' },
'LICENSE.md': { icon: FileCheck, color: 'text-gray-500' },
'CHANGELOG.md': { icon: Scroll, color: 'text-blue-400' },
'requirements.txt': { icon: FileText, color: 'text-emerald-400' },
'go.mod': { icon: Hexagon, color: 'text-cyan-500' },
'go.sum': { icon: Lock, color: 'text-cyan-400' },
};
function getFileIconData(filename) {
// 1. Exact filename match
if (FILENAME_ICON_MAP[filename]) {
return FILENAME_ICON_MAP[filename];
}
// 2. Check for .env prefix pattern
if (filename.startsWith('.env')) {
return { icon: Shield, color: 'text-yellow-600' };
}
// 3. Extension-based lookup
const ext = filename.split('.').pop()?.toLowerCase();
if (ext && FILE_ICON_MAP[ext]) {
return FILE_ICON_MAP[ext];
}
// 4. Fallback
return { icon: File, color: 'text-muted-foreground' };
}
// ─── Component ───────────────────────────────────────────────────────
function FileTree({ selectedProject, onFileOpen }) {
const { t } = useTranslation();
const [files, setFiles] = useState([]);
const [loading, setLoading] = useState(false);
const [expandedDirs, setExpandedDirs] = useState(new Set());
const [selectedFile, setSelectedFile] = useState(null);
const [selectedImage, setSelectedImage] = useState(null);
const [viewMode, setViewMode] = useState('detailed'); // 'simple', 'detailed', 'compact'
const [viewMode, setViewMode] = useState('detailed');
const [searchQuery, setSearchQuery] = useState('');
const [filteredFiles, setFilteredFiles] = useState([]);
@@ -24,7 +275,6 @@ function FileTree({ selectedProject }) {
}
}, [selectedProject]);
// Load view mode preference from localStorage
useEffect(() => {
const savedViewMode = localStorage.getItem('file-tree-view-mode');
if (savedViewMode && ['simple', 'detailed', 'compact'].includes(savedViewMode)) {
@@ -32,7 +282,6 @@ function FileTree({ selectedProject }) {
}
}, []);
// Filter files based on search query
useEffect(() => {
if (!searchQuery.trim()) {
setFilteredFiles(files);
@@ -40,7 +289,6 @@ function FileTree({ selectedProject }) {
const filtered = filterFiles(files, searchQuery.toLowerCase());
setFilteredFiles(filtered);
// Auto-expand directories that contain matches
const expandMatches = (items) => {
items.forEach(item => {
if (item.type === 'directory' && item.children && item.children.length > 0) {
@@ -53,7 +301,6 @@ function FileTree({ selectedProject }) {
}
}, [files, searchQuery]);
// Recursively filter files and directories based on search query
const filterFiles = (items, query) => {
return items.reduce((filtered, item) => {
const matchesName = item.name.toLowerCase().includes(query);
@@ -63,9 +310,6 @@ function FileTree({ selectedProject }) {
filteredChildren = filterFiles(item.children, query);
}
// Include item if:
// 1. It matches the search query, or
// 2. It's a directory with matching children
if (matchesName || filteredChildren.length > 0) {
filtered.push({
...item,
@@ -81,14 +325,14 @@ function FileTree({ selectedProject }) {
setLoading(true);
try {
const response = await api.getFiles(selectedProject.name);
if (!response.ok) {
const errorText = await response.text();
console.error('❌ File fetch failed:', response.status, errorText);
setFiles([]);
return;
}
const data = await response.json();
setFiles(data);
} catch (error) {
@@ -109,13 +353,11 @@ function FileTree({ selectedProject }) {
setExpandedDirs(newExpanded);
};
// Change view mode and save preference
const changeViewMode = (mode) => {
setViewMode(mode);
localStorage.setItem('file-tree-view-mode', mode);
};
// Format file size
const formatFileSize = (bytes) => {
if (!bytes || bytes === 0) return '0 B';
const k = 1024;
@@ -124,77 +366,17 @@ function FileTree({ selectedProject }) {
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
};
// Format date as relative time
const formatRelativeTime = (date) => {
if (!date) return '-';
const now = new Date();
const past = new Date(date);
const diffInSeconds = Math.floor((now - past) / 1000);
if (diffInSeconds < 60) return 'just now';
if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)} min ago`;
if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)} hours ago`;
if (diffInSeconds < 2592000) return `${Math.floor(diffInSeconds / 86400)} days ago`;
return past.toLocaleDateString();
};
const renderFileTree = (items, level = 0) => {
return items.map((item) => (
<div key={item.path} className="select-none">
<Button
variant="ghost"
className={cn(
"w-full justify-start p-2 h-auto font-normal text-left hover:bg-accent",
)}
style={{ paddingLeft: `${level * 16 + 12}px` }}
onClick={() => {
if (item.type === 'directory') {
toggleDirectory(item.path);
} else if (isImageFile(item.name)) {
// Open image in viewer
setSelectedImage({
name: item.name,
path: item.path,
projectPath: selectedProject.path,
projectName: selectedProject.name
});
} else {
// Open file in editor
setSelectedFile({
name: item.name,
path: item.path,
projectPath: selectedProject.path,
projectName: selectedProject.name
});
}
}}
>
<div className="flex items-center gap-2 min-w-0 w-full">
{item.type === 'directory' ? (
expandedDirs.has(item.path) ? (
<FolderOpen className="w-4 h-4 text-blue-500 flex-shrink-0" />
) : (
<Folder className="w-4 h-4 text-muted-foreground flex-shrink-0" />
)
) : (
getFileIcon(item.name)
)}
<span className="text-sm truncate text-foreground">
{item.name}
</span>
</div>
</Button>
{item.type === 'directory' &&
expandedDirs.has(item.path) &&
item.children &&
item.children.length > 0 && (
<div>
{renderFileTree(item.children, level + 1)}
</div>
)}
</div>
));
if (diffInSeconds < 60) return t('fileTree.justNow');
if (diffInSeconds < 3600) return t('fileTree.minAgo', { count: Math.floor(diffInSeconds / 60) });
if (diffInSeconds < 86400) return t('fileTree.hoursAgo', { count: Math.floor(diffInSeconds / 3600) });
if (diffInSeconds < 2592000) return t('fileTree.daysAgo', { count: Math.floor(diffInSeconds / 86400) });
return past.toLocaleDateString();
};
const isImageFile = (filename) => {
@@ -204,210 +386,286 @@ function FileTree({ selectedProject }) {
};
const getFileIcon = (filename) => {
const ext = filename.split('.').pop()?.toLowerCase();
const codeExtensions = ['js', 'jsx', 'ts', 'tsx', 'py', 'java', 'cpp', 'c', 'php', 'rb', 'go', 'rs'];
const docExtensions = ['md', 'txt', 'doc', 'pdf'];
const imageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'bmp'];
if (codeExtensions.includes(ext)) {
return <FileCode className="w-4 h-4 text-green-500 flex-shrink-0" />;
} else if (docExtensions.includes(ext)) {
return <FileText className="w-4 h-4 text-blue-500 flex-shrink-0" />;
} else if (imageExtensions.includes(ext)) {
return <File className="w-4 h-4 text-purple-500 flex-shrink-0" />;
} else {
return <File className="w-4 h-4 text-muted-foreground flex-shrink-0" />;
const { icon: Icon, color } = getFileIconData(filename);
return <Icon className={cn(ICON_SIZE, color)} />;
};
// ── Click handler shared across all view modes ──
const handleItemClick = (item) => {
if (item.type === 'directory') {
toggleDirectory(item.path);
} else if (isImageFile(item.name)) {
setSelectedImage({
name: item.name,
path: item.path,
projectPath: selectedProject.path,
projectName: selectedProject.name
});
} else if (onFileOpen) {
onFileOpen(item.path);
}
};
// Render detailed view with table-like layout
// ── Indent guide + folder/file icon rendering ──
const renderIndentGuides = (level) => {
if (level === 0) return null;
return (
<span className="flex items-center flex-shrink-0" aria-hidden="true">
{Array.from({ length: level }).map((_, i) => (
<span
key={i}
className="inline-block w-4 h-full border-l border-border/50"
/>
))}
</span>
);
};
const renderItemIcons = (item) => {
const isDir = item.type === 'directory';
const isOpen = expandedDirs.has(item.path);
if (isDir) {
return (
<span className="flex items-center gap-0.5 flex-shrink-0">
<ChevronRight
className={cn(
'w-3.5 h-3.5 text-muted-foreground/70 transition-transform duration-150',
isOpen && 'rotate-90'
)}
/>
{isOpen ? (
<FolderOpen className="w-4 h-4 text-blue-500 flex-shrink-0" />
) : (
<Folder className="w-4 h-4 text-muted-foreground flex-shrink-0" />
)}
</span>
);
}
return (
<span className="flex items-center flex-shrink-0 ml-[18px]">
{getFileIcon(item.name)}
</span>
);
};
// ─── Simple (Tree) View ────────────────────────────────────────────
const renderFileTree = (items, level = 0) => {
return items.map((item) => {
const isDir = item.type === 'directory';
const isOpen = isDir && expandedDirs.has(item.path);
return (
<div key={item.path} className="select-none">
<div
className={cn(
'group flex items-center gap-1.5 py-[3px] pr-2 cursor-pointer rounded-sm',
'hover:bg-accent/60 transition-colors duration-100',
isDir && isOpen && 'border-l-2 border-primary/30',
isDir && !isOpen && 'border-l-2 border-transparent',
!isDir && 'border-l-2 border-transparent',
)}
style={{ paddingLeft: `${level * 16 + 4}px` }}
onClick={() => handleItemClick(item)}
>
{renderItemIcons(item)}
<span className={cn(
'text-[13px] leading-tight truncate',
isDir ? 'font-medium text-foreground' : 'text-foreground/90'
)}>
{item.name}
</span>
</div>
{isDir && isOpen && item.children && item.children.length > 0 && (
<div className="relative">
<span
className="absolute top-0 bottom-0 border-l border-border/40"
style={{ left: `${level * 16 + 14}px` }}
aria-hidden="true"
/>
{renderFileTree(item.children, level + 1)}
</div>
)}
</div>
);
});
};
// ─── Detailed View ────────────────────────────────────────────────
const renderDetailedView = (items, level = 0) => {
return items.map((item) => (
<div key={item.path} className="select-none">
<div
className={cn(
"grid grid-cols-12 gap-2 p-2 hover:bg-accent cursor-pointer items-center",
)}
style={{ paddingLeft: `${level * 16 + 12}px` }}
onClick={() => {
if (item.type === 'directory') {
toggleDirectory(item.path);
} else if (isImageFile(item.name)) {
setSelectedImage({
name: item.name,
path: item.path,
projectPath: selectedProject.path,
projectName: selectedProject.name
});
} else {
setSelectedFile({
name: item.name,
path: item.path,
projectPath: selectedProject.path,
projectName: selectedProject.name
});
}
}}
>
<div className="col-span-5 flex items-center gap-2 min-w-0">
{item.type === 'directory' ? (
expandedDirs.has(item.path) ? (
<FolderOpen className="w-4 h-4 text-blue-500 flex-shrink-0" />
) : (
<Folder className="w-4 h-4 text-muted-foreground flex-shrink-0" />
)
) : (
getFileIcon(item.name)
return items.map((item) => {
const isDir = item.type === 'directory';
const isOpen = isDir && expandedDirs.has(item.path);
return (
<div key={item.path} className="select-none">
<div
className={cn(
'group grid grid-cols-12 gap-2 py-[3px] pr-2 hover:bg-accent/60 cursor-pointer items-center rounded-sm transition-colors duration-100',
isDir && isOpen && 'border-l-2 border-primary/30',
isDir && !isOpen && 'border-l-2 border-transparent',
!isDir && 'border-l-2 border-transparent',
)}
<span className="text-sm truncate text-foreground">
{item.name}
</span>
</div>
<div className="col-span-2 text-sm text-muted-foreground">
{item.type === 'file' ? formatFileSize(item.size) : '-'}
</div>
<div className="col-span-3 text-sm text-muted-foreground">
{formatRelativeTime(item.modified)}
</div>
<div className="col-span-2 text-sm text-muted-foreground font-mono">
{item.permissionsRwx || '-'}
style={{ paddingLeft: `${level * 16 + 4}px` }}
onClick={() => handleItemClick(item)}
>
<div className="col-span-5 flex items-center gap-1.5 min-w-0">
{renderItemIcons(item)}
<span className={cn(
'text-[13px] leading-tight truncate',
isDir ? 'font-medium text-foreground' : 'text-foreground/90'
)}>
{item.name}
</span>
</div>
<div className="col-span-2 text-xs text-muted-foreground tabular-nums">
{item.type === 'file' ? formatFileSize(item.size) : ''}
</div>
<div className="col-span-3 text-xs text-muted-foreground">
{formatRelativeTime(item.modified)}
</div>
<div className="col-span-2 text-xs text-muted-foreground font-mono">
{item.permissionsRwx || ''}
</div>
</div>
{isDir && isOpen && item.children && (
<div className="relative">
<span
className="absolute top-0 bottom-0 border-l border-border/40"
style={{ left: `${level * 16 + 14}px` }}
aria-hidden="true"
/>
{renderDetailedView(item.children, level + 1)}
</div>
)}
</div>
{item.type === 'directory' &&
expandedDirs.has(item.path) &&
item.children &&
renderDetailedView(item.children, level + 1)}
</div>
));
);
});
};
// Render compact view with inline details
// ─── Compact View ──────────────────────────────────────────────────
const renderCompactView = (items, level = 0) => {
return items.map((item) => (
<div key={item.path} className="select-none">
<div
className={cn(
"flex items-center justify-between p-2 hover:bg-accent cursor-pointer",
return items.map((item) => {
const isDir = item.type === 'directory';
const isOpen = isDir && expandedDirs.has(item.path);
return (
<div key={item.path} className="select-none">
<div
className={cn(
'group flex items-center justify-between py-[3px] pr-2 hover:bg-accent/60 cursor-pointer rounded-sm transition-colors duration-100',
isDir && isOpen && 'border-l-2 border-primary/30',
isDir && !isOpen && 'border-l-2 border-transparent',
!isDir && 'border-l-2 border-transparent',
)}
style={{ paddingLeft: `${level * 16 + 4}px` }}
onClick={() => handleItemClick(item)}
>
<div className="flex items-center gap-1.5 min-w-0">
{renderItemIcons(item)}
<span className={cn(
'text-[13px] leading-tight truncate',
isDir ? 'font-medium text-foreground' : 'text-foreground/90'
)}>
{item.name}
</span>
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground flex-shrink-0 ml-2">
{item.type === 'file' && (
<>
<span className="tabular-nums">{formatFileSize(item.size)}</span>
<span className="font-mono">{item.permissionsRwx}</span>
</>
)}
</div>
</div>
{isDir && isOpen && item.children && (
<div className="relative">
<span
className="absolute top-0 bottom-0 border-l border-border/40"
style={{ left: `${level * 16 + 14}px` }}
aria-hidden="true"
/>
{renderCompactView(item.children, level + 1)}
</div>
)}
style={{ paddingLeft: `${level * 16 + 12}px` }}
onClick={() => {
if (item.type === 'directory') {
toggleDirectory(item.path);
} else if (isImageFile(item.name)) {
setSelectedImage({
name: item.name,
path: item.path,
projectPath: selectedProject.path,
projectName: selectedProject.name
});
} else {
setSelectedFile({
name: item.name,
path: item.path,
projectPath: selectedProject.path,
projectName: selectedProject.name
});
}
}}
>
<div className="flex items-center gap-2 min-w-0">
{item.type === 'directory' ? (
expandedDirs.has(item.path) ? (
<FolderOpen className="w-4 h-4 text-blue-500 flex-shrink-0" />
) : (
<Folder className="w-4 h-4 text-muted-foreground flex-shrink-0" />
)
) : (
getFileIcon(item.name)
)}
<span className="text-sm truncate text-foreground">
{item.name}
</span>
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
{item.type === 'file' && (
<>
<span>{formatFileSize(item.size)}</span>
<span className="font-mono">{item.permissionsRwx}</span>
</>
)}
</div>
</div>
{item.type === 'directory' &&
expandedDirs.has(item.path) &&
item.children &&
renderCompactView(item.children, level + 1)}
</div>
));
);
});
};
// ─── Loading state ─────────────────────────────────────────────────
if (loading) {
return (
<div className="h-full flex items-center justify-center">
<div className="text-gray-500 dark:text-gray-400">
Loading files...
<div className="text-muted-foreground text-sm">
{t('fileTree.loading')}
</div>
</div>
);
}
// ─── Main render ───────────────────────────────────────────────────
return (
<div className="h-full flex flex-col bg-card">
{/* Header with Search and View Mode Toggle */}
<div className="p-4 border-b border-border space-y-3">
<div className="h-full flex flex-col bg-background">
{/* Header */}
<div className="px-3 pt-3 pb-2 border-b border-border space-y-2">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-foreground">Files</h3>
<div className="flex gap-1">
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{t('fileTree.files')}
</h3>
<div className="flex gap-0.5">
<Button
variant={viewMode === 'simple' ? 'default' : 'ghost'}
size="sm"
className="h-8 w-8 p-0"
className="h-7 w-7 p-0"
onClick={() => changeViewMode('simple')}
title="Simple view"
title={t('fileTree.simpleView')}
>
<List className="w-4 h-4" />
<List className="w-3.5 h-3.5" />
</Button>
<Button
variant={viewMode === 'compact' ? 'default' : 'ghost'}
size="sm"
className="h-8 w-8 p-0"
className="h-7 w-7 p-0"
onClick={() => changeViewMode('compact')}
title="Compact view"
title={t('fileTree.compactView')}
>
<Eye className="w-4 h-4" />
<Eye className="w-3.5 h-3.5" />
</Button>
<Button
variant={viewMode === 'detailed' ? 'default' : 'ghost'}
size="sm"
className="h-8 w-8 p-0"
className="h-7 w-7 p-0"
onClick={() => changeViewMode('detailed')}
title="Detailed view"
title={t('fileTree.detailedView')}
>
<TableProperties className="w-4 h-4" />
<TableProperties className="w-3.5 h-3.5" />
</Button>
</div>
</div>
{/* Search Bar */}
<div className="relative">
<Search className="absolute left-2 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Search className="absolute left-2 top-1/2 transform -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
<Input
type="text"
placeholder="Search files and folders..."
placeholder={t('fileTree.searchPlaceholder')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-8 pr-8 h-8 text-sm"
className="pl-7 pr-7 h-7 text-xs"
/>
{searchQuery && (
<Button
variant="ghost"
size="sm"
className="absolute right-1 top-1/2 transform -translate-y-1/2 h-6 w-6 p-0 hover:bg-accent"
className="absolute right-0.5 top-1/2 transform -translate-y-1/2 h-5 w-5 p-0 hover:bg-accent"
onClick={() => setSearchQuery('')}
title="Clear search"
title={t('fileTree.clearSearch')}
>
<X className="w-3 h-3" />
</Button>
@@ -417,25 +675,25 @@ function FileTree({ selectedProject }) {
{/* Column Headers for Detailed View */}
{viewMode === 'detailed' && filteredFiles.length > 0 && (
<div className="px-4 pt-2 pb-1 border-b border-border">
<div className="grid grid-cols-12 gap-2 px-2 text-xs font-medium text-muted-foreground">
<div className="col-span-5">Name</div>
<div className="col-span-2">Size</div>
<div className="col-span-3">Modified</div>
<div className="col-span-2">Permissions</div>
<div className="px-3 pt-1.5 pb-1 border-b border-border">
<div className="grid grid-cols-12 gap-2 px-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/70">
<div className="col-span-5">{t('fileTree.name')}</div>
<div className="col-span-2">{t('fileTree.size')}</div>
<div className="col-span-3">{t('fileTree.modified')}</div>
<div className="col-span-2">{t('fileTree.permissions')}</div>
</div>
</div>
)}
<ScrollArea className="flex-1 p-4">
<ScrollArea className="flex-1 px-2 py-1">
{files.length === 0 ? (
<div className="text-center py-8">
<div className="w-12 h-12 bg-muted rounded-lg flex items-center justify-center mx-auto mb-3">
<Folder className="w-6 h-6 text-muted-foreground" />
</div>
<h4 className="font-medium text-foreground mb-1">No files found</h4>
<h4 className="font-medium text-foreground mb-1">{t('fileTree.noFilesFound')}</h4>
<p className="text-sm text-muted-foreground">
Check if the project path is accessible
{t('fileTree.checkProjectPath')}
</p>
</div>
) : filteredFiles.length === 0 && searchQuery ? (
@@ -443,29 +701,20 @@ function FileTree({ selectedProject }) {
<div className="w-12 h-12 bg-muted rounded-lg flex items-center justify-center mx-auto mb-3">
<Search className="w-6 h-6 text-muted-foreground" />
</div>
<h4 className="font-medium text-foreground mb-1">No matches found</h4>
<h4 className="font-medium text-foreground mb-1">{t('fileTree.noMatchesFound')}</h4>
<p className="text-sm text-muted-foreground">
Try a different search term or clear the search
{t('fileTree.tryDifferentSearch')}
</p>
</div>
) : (
<div className={viewMode === 'detailed' ? '' : 'space-y-1'}>
<div>
{viewMode === 'simple' && renderFileTree(filteredFiles)}
{viewMode === 'compact' && renderCompactView(filteredFiles)}
{viewMode === 'detailed' && renderDetailedView(filteredFiles)}
</div>
)}
</ScrollArea>
{/* Code Editor Modal */}
{selectedFile && (
<CodeEditor
file={selectedFile}
onClose={() => setSelectedFile(null)}
projectPath={selectedFile.projectPath}
/>
)}
{/* Image Viewer Modal */}
{selectedImage && (
<ImageViewer
@@ -477,4 +726,4 @@ function FileTree({ selectedProject }) {
);
}
export default FileTree;
export default FileTree;

View File

@@ -53,14 +53,28 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
}, []);
useEffect(() => {
if (selectedProject) {
fetchGitStatus();
fetchBranches();
fetchRemoteStatus();
if (activeView === 'history') {
fetchRecentCommits();
}
// Clear stale repo-scoped state when project changes.
setCurrentBranch('');
setBranches([]);
setGitStatus(null);
setRemoteStatus(null);
setSelectedFiles(new Set());
if (!selectedProject) {
return;
}
fetchGitStatus();
fetchBranches();
fetchRemoteStatus();
}, [selectedProject]);
useEffect(() => {
if (!selectedProject || activeView !== 'history') {
return;
}
fetchRecentCommits();
}, [selectedProject, activeView]);
// Handle click outside dropdown
@@ -88,6 +102,8 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
if (data.error) {
console.error('Git status error:', data.error);
setGitStatus({ error: data.error, details: data.details });
setCurrentBranch('');
setSelectedFiles(new Set());
} else {
setGitStatus(data);
setCurrentBranch(data.branch || 'main');
@@ -117,6 +133,9 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
}
} catch (error) {
console.error('Error fetching git status:', error);
setGitStatus({ error: 'Git operation failed', details: String(error) });
setCurrentBranch('');
setSelectedFiles(new Set());
} finally {
setIsLoading(false);
}
@@ -129,9 +148,12 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
if (!data.error && data.branches) {
setBranches(data.branches);
} else {
setBranches([]);
}
} catch (error) {
console.error('Error fetching branches:', error);
setBranches([]);
}
};
@@ -618,36 +640,36 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
const renderCommitItem = (commit) => {
const isExpanded = expandedCommits.has(commit.hash);
const diff = commitDiffs[commit.hash];
return (
<div key={commit.hash} className="border-b border-gray-200 dark:border-gray-700 last:border-0">
<div
className="flex items-start p-3 hover:bg-gray-50 dark:hover:bg-gray-800 cursor-pointer"
<div key={commit.hash} className="border-b border-border last:border-0">
<div
className="flex items-start p-3 hover:bg-accent/50 cursor-pointer transition-colors"
onClick={() => toggleCommitExpanded(commit.hash)}
>
<div className="mr-2 mt-1 p-0.5 hover:bg-gray-200 dark:hover:bg-gray-700 rounded">
<div className="mr-2 mt-1 p-0.5 hover:bg-accent rounded">
{isExpanded ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-white truncate">
<p className="text-sm font-medium text-foreground truncate">
{commit.message}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
<p className="text-xs text-muted-foreground mt-1">
{commit.author} {commit.date}
</p>
</div>
<span className="text-xs font-mono text-gray-400 dark:text-gray-500 flex-shrink-0">
<span className="text-xs font-mono text-muted-foreground/60 flex-shrink-0">
{commit.hash.substring(0, 7)}
</span>
</div>
</div>
</div>
{isExpanded && diff && (
<div className="bg-gray-50 dark:bg-gray-900">
<div className="bg-muted/50">
<div className="max-h-96 overflow-y-auto p-2">
<div className="text-xs font-mono text-gray-600 dark:text-gray-400 mb-2">
<div className="text-xs font-mono text-muted-foreground mb-2">
{commit.stats}
</div>
<DiffViewer diff={diff} fileName="commit" isMobile={isMobile} wrapText={wrapText} />
@@ -662,22 +684,20 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
const isExpanded = expandedFiles.has(filePath);
const isSelected = selectedFiles.has(filePath);
const diff = gitDiff[filePath];
return (
<div key={filePath} className="border-b border-gray-200 dark:border-gray-700 last:border-0">
<div className={`flex items-center hover:bg-gray-50 dark:hover:bg-gray-800 ${isMobile ? 'px-2 py-1.5' : 'px-3 py-2'}`}>
<div key={filePath} className="border-b border-border last:border-0">
<div className={`flex items-center hover:bg-accent/50 transition-colors ${isMobile ? 'px-2 py-1.5' : 'px-3 py-2'}`}>
<input
type="checkbox"
checked={isSelected}
onChange={() => toggleFileSelected(filePath)}
onClick={(e) => e.stopPropagation()}
className={`rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 dark:focus:ring-blue-400 dark:bg-gray-800 dark:checked:bg-blue-600 ${isMobile ? 'mr-1.5' : 'mr-2'}`}
className={`rounded border-border text-primary focus:ring-primary/40 bg-background checked:bg-primary ${isMobile ? 'mr-1.5' : 'mr-2'}`}
/>
<div
className="flex items-center flex-1"
>
<div className="flex items-center flex-1">
<div
className={`p-0.5 hover:bg-gray-200 dark:hover:bg-gray-700 rounded cursor-pointer ${isMobile ? 'mr-1' : 'mr-2'}`}
className={`p-0.5 hover:bg-accent rounded cursor-pointer ${isMobile ? 'mr-1' : 'mr-2'}`}
onClick={(e) => {
e.stopPropagation();
toggleFileExpanded(filePath);
@@ -686,7 +706,7 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
<ChevronRight className={`w-3 h-3 transition-transform duration-200 ease-in-out ${isExpanded ? 'rotate-90' : 'rotate-0'}`} />
</div>
<span
className={`flex-1 truncate ${isMobile ? 'text-xs' : 'text-sm'} cursor-pointer hover:text-blue-600 dark:hover:text-blue-400 hover:underline`}
className={`flex-1 truncate ${isMobile ? 'text-xs' : 'text-sm'} cursor-pointer hover:text-primary hover:underline`}
onClick={(e) => {
e.stopPropagation();
handleFileOpen(filePath);
@@ -700,16 +720,16 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
<button
onClick={(e) => {
e.stopPropagation();
setConfirmAction({
type: 'discard',
setConfirmAction({
type: 'discard',
file: filePath,
message: `Discard all changes to "${filePath}"? This action cannot be undone.`
message: `Discard all changes to "${filePath}"? This action cannot be undone.`
});
}}
className={`${isMobile ? 'px-2 py-1 text-xs' : 'p-1'} hover:bg-red-100 dark:hover:bg-red-900 rounded text-red-600 dark:text-red-400 font-medium flex items-center gap-1`}
className={`${isMobile ? 'px-2 py-1 text-xs' : 'p-1'} hover:bg-destructive/10 rounded text-destructive font-medium flex items-center gap-1`}
title="Discard changes"
>
<Trash2 className={`${isMobile ? 'w-3 h-3' : 'w-3 h-3'}`} />
<Trash2 className="w-3 h-3" />
{isMobile && <span>Discard</span>}
</button>
)}
@@ -717,25 +737,25 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
<button
onClick={(e) => {
e.stopPropagation();
setConfirmAction({
type: 'delete',
setConfirmAction({
type: 'delete',
file: filePath,
message: `Delete untracked file "${filePath}"? This action cannot be undone.`
message: `Delete untracked file "${filePath}"? This action cannot be undone.`
});
}}
className={`${isMobile ? 'px-2 py-1 text-xs' : 'p-1'} hover:bg-red-100 dark:hover:bg-red-900 rounded text-red-600 dark:text-red-400 font-medium flex items-center gap-1`}
className={`${isMobile ? 'px-2 py-1 text-xs' : 'p-1'} hover:bg-destructive/10 rounded text-destructive font-medium flex items-center gap-1`}
title="Delete untracked file"
>
<Trash2 className={`${isMobile ? 'w-3 h-3' : 'w-3 h-3'}`} />
<Trash2 className="w-3 h-3" />
{isMobile && <span>Delete</span>}
</button>
)}
<span
className={`inline-flex items-center justify-center w-5 h-5 rounded text-xs font-bold border ${
status === 'M' ? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300 border-yellow-200 dark:border-yellow-800' :
status === 'A' ? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300 border-green-200 dark:border-green-800' :
status === 'D' ? 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300 border-red-200 dark:border-red-800' :
'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300 border-gray-300 dark:border-gray-600'
<span
className={`inline-flex items-center justify-center w-5 h-5 rounded text-[10px] font-bold border ${
status === 'M' ? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300 border-yellow-200 dark:border-yellow-800/50' :
status === 'A' ? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300 border-green-200 dark:border-green-800/50' :
status === 'D' ? 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300 border-red-200 dark:border-red-800/50' :
'bg-muted text-muted-foreground border-border'
}`}
title={getStatusLabel(status)}
>
@@ -744,25 +764,25 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
</div>
</div>
</div>
<div className={`bg-gray-50 dark:bg-gray-900 transition-all duration-400 ease-in-out overflow-hidden ${
isExpanded && diff
? 'max-h-[600px] opacity-100 translate-y-0'
<div className={`bg-muted/50 transition-all duration-400 ease-in-out overflow-hidden ${
isExpanded && diff
? 'max-h-[600px] opacity-100 translate-y-0'
: 'max-h-0 opacity-0 -translate-y-1'
}`}>
{/* Operation header */}
<div className="flex items-center justify-between p-2 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between p-2 border-b border-border">
<div className="flex items-center gap-2">
<span
className={`inline-flex items-center justify-center w-5 h-5 rounded text-xs font-bold border ${
status === 'M' ? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300 border-yellow-200 dark:border-yellow-800' :
status === 'A' ? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300 border-green-200 dark:border-green-800' :
status === 'D' ? 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300 border-red-200 dark:border-red-800' :
'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300 border-gray-300 dark:border-gray-600'
<span
className={`inline-flex items-center justify-center w-5 h-5 rounded text-[10px] font-bold border ${
status === 'M' ? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300 border-yellow-200 dark:border-yellow-800/50' :
status === 'A' ? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300 border-green-200 dark:border-green-800/50' :
status === 'D' ? 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300 border-red-200 dark:border-red-800/50' :
'bg-muted text-muted-foreground border-border'
}`}
>
{status}
</span>
<span className="text-sm font-medium text-gray-900 dark:text-white">
<span className="text-sm font-medium text-foreground">
{getStatusLabel(status)}
</span>
</div>
@@ -772,7 +792,7 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
e.stopPropagation();
setWrapText(!wrapText);
}}
className="text-xs text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white"
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
title={wrapText ? "Switch to horizontal scroll" : "Switch to text wrap"}
>
{wrapText ? '↔️ Scroll' : '↩️ Wrap'}
@@ -789,7 +809,7 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
if (!selectedProject) {
return (
<div className="h-full flex items-center justify-center text-gray-500 dark:text-gray-400">
<div className="h-full flex items-center justify-center text-muted-foreground">
<p>Select a project to view source control</p>
</div>
);
@@ -798,13 +818,13 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
return (
<div className="h-full flex flex-col bg-background">
{/* Header */}
<div className={`flex items-center justify-between border-b border-gray-200 dark:border-gray-700 ${isMobile ? 'px-3 py-2' : 'px-4 py-3'}`}>
<div className={`flex items-center justify-between border-b border-border/60 ${isMobile ? 'px-3 py-2' : 'px-4 py-3'}`}>
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setShowBranchDropdown(!showBranchDropdown)}
className={`flex items-center hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors ${isMobile ? 'space-x-1 px-2 py-1' : 'space-x-2 px-3 py-1.5'}`}
className={`flex items-center hover:bg-accent rounded-lg transition-colors ${isMobile ? 'space-x-1 px-2 py-1' : 'space-x-2 px-3 py-1.5'}`}
>
<GitBranch className={`text-gray-600 dark:text-gray-400 ${isMobile ? 'w-3 h-3' : 'w-4 h-4'}`} />
<GitBranch className={`text-muted-foreground ${isMobile ? 'w-3 h-3' : 'w-4 h-4'}`} />
<div className="flex items-center gap-1">
<span className={`font-medium ${isMobile ? 'text-xs' : 'text-sm'}`}>{currentBranch}</span>
{/* Remote status indicators */}
@@ -816,47 +836,47 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
</span>
)}
{remoteStatus.behind > 0 && (
<span className="text-blue-600 dark:text-blue-400" title={`${remoteStatus.behind} commit${remoteStatus.behind !== 1 ? 's' : ''} behind`}>
<span className="text-primary" title={`${remoteStatus.behind} commit${remoteStatus.behind !== 1 ? 's' : ''} behind`}>
{remoteStatus.behind}
</span>
)}
{remoteStatus.isUpToDate && (
<span className="text-gray-500 dark:text-gray-400" title="Up to date with remote">
<span className="text-muted-foreground" title="Up to date with remote">
</span>
)}
</div>
)}
</div>
<ChevronDown className={`w-3 h-3 text-gray-500 transition-transform ${showBranchDropdown ? 'rotate-180' : ''}`} />
<ChevronDown className={`w-3 h-3 text-muted-foreground transition-transform ${showBranchDropdown ? 'rotate-180' : ''}`} />
</button>
{/* Branch Dropdown */}
{showBranchDropdown && (
<div className="absolute top-full left-0 mt-1 w-64 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 z-50">
<div className="absolute top-full left-0 mt-1 w-64 bg-card rounded-xl shadow-lg border border-border z-50 overflow-hidden">
<div className="py-1 max-h-64 overflow-y-auto">
{branches.map(branch => (
<button
key={branch}
onClick={() => switchBranch(branch)}
className={`w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 ${
branch === currentBranch ? 'bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-gray-100' : 'text-gray-700 dark:text-gray-300'
className={`w-full text-left px-4 py-2 text-sm hover:bg-accent transition-colors ${
branch === currentBranch ? 'bg-accent/50 text-foreground' : 'text-muted-foreground'
}`}
>
<div className="flex items-center space-x-2">
{branch === currentBranch && <Check className="w-3 h-3 text-green-600 dark:text-green-400" />}
{branch === currentBranch && <Check className="w-3 h-3 text-primary" />}
<span className={branch === currentBranch ? 'font-medium' : ''}>{branch}</span>
</div>
</button>
))}
</div>
<div className="border-t border-gray-200 dark:border-gray-700 py-1">
<div className="border-t border-border py-1">
<button
onClick={() => {
setShowNewBranchModal(true);
setShowBranchDropdown(false);
}}
className="w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center space-x-2"
className="w-full text-left px-4 py-2 text-sm hover:bg-accent transition-colors flex items-center space-x-2"
>
<Plus className="w-3 h-3" />
<span>Create new branch</span>
@@ -873,12 +893,12 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
{/* Publish button - show when branch doesn't exist on remote */}
{!remoteStatus?.hasUpstream && (
<button
onClick={() => setConfirmAction({
type: 'publish',
message: `Publish branch "${currentBranch}" to ${remoteStatus.remoteName}?`
onClick={() => setConfirmAction({
type: 'publish',
message: `Publish branch "${currentBranch}" to ${remoteStatus.remoteName}?`
})}
disabled={isPublishing}
className="px-2 py-1 text-xs bg-purple-600 text-white rounded hover:bg-purple-700 disabled:opacity-50 flex items-center gap-1"
className="px-2.5 py-1 text-xs bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 flex items-center gap-1 transition-colors"
title={`Publish branch "${currentBranch}" to ${remoteStatus.remoteName}`}
>
<Upload className={`w-3 h-3 ${isPublishing ? 'animate-pulse' : ''}`} />
@@ -892,41 +912,41 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
{/* Pull button - show when behind (primary action) */}
{remoteStatus.behind > 0 && (
<button
onClick={() => setConfirmAction({
type: 'pull',
message: `Pull ${remoteStatus.behind} commit${remoteStatus.behind !== 1 ? 's' : ''} from ${remoteStatus.remoteName}?`
onClick={() => setConfirmAction({
type: 'pull',
message: `Pull ${remoteStatus.behind} commit${remoteStatus.behind !== 1 ? 's' : ''} from ${remoteStatus.remoteName}?`
})}
disabled={isPulling}
className="px-2 py-1 text-xs bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50 flex items-center gap-1"
className="px-2.5 py-1 text-xs bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 flex items-center gap-1 transition-colors"
title={`Pull ${remoteStatus.behind} commit${remoteStatus.behind !== 1 ? 's' : ''} from ${remoteStatus.remoteName}`}
>
<Download className={`w-3 h-3 ${isPulling ? 'animate-pulse' : ''}`} />
<span>{isPulling ? 'Pulling...' : `Pull ${remoteStatus.behind}`}</span>
</button>
)}
{/* Push button - show when ahead (primary action when ahead only) */}
{remoteStatus.ahead > 0 && (
<button
onClick={() => setConfirmAction({
type: 'push',
message: `Push ${remoteStatus.ahead} commit${remoteStatus.ahead !== 1 ? 's' : ''} to ${remoteStatus.remoteName}?`
onClick={() => setConfirmAction({
type: 'push',
message: `Push ${remoteStatus.ahead} commit${remoteStatus.ahead !== 1 ? 's' : ''} to ${remoteStatus.remoteName}?`
})}
disabled={isPushing}
className="px-2 py-1 text-xs bg-orange-600 text-white rounded hover:bg-orange-700 disabled:opacity-50 flex items-center gap-1"
className="px-2.5 py-1 text-xs bg-orange-600 text-white rounded-lg hover:bg-orange-700 disabled:opacity-50 flex items-center gap-1 transition-colors"
title={`Push ${remoteStatus.ahead} commit${remoteStatus.ahead !== 1 ? 's' : ''} to ${remoteStatus.remoteName}`}
>
<Upload className={`w-3 h-3 ${isPushing ? 'animate-pulse' : ''}`} />
<span>{isPushing ? 'Pushing...' : `Push ${remoteStatus.ahead}`}</span>
</button>
)}
{/* Fetch button - show when ahead only or when diverged (secondary action) */}
{(remoteStatus.ahead > 0 || (remoteStatus.behind > 0 && remoteStatus.ahead > 0)) && (
<button
onClick={handleFetch}
disabled={isFetching}
className="px-2 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 flex items-center gap-1"
className="px-2.5 py-1 text-xs bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50 flex items-center gap-1 transition-colors"
title={`Fetch from ${remoteStatus.remoteName}`}
>
<RefreshCw className={`w-3 h-3 ${isFetching ? 'animate-spin' : ''}`} />
@@ -945,41 +965,43 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
fetchRemoteStatus();
}}
disabled={isLoading}
className={`hover:bg-gray-100 dark:hover:bg-gray-800 rounded ${isMobile ? 'p-1' : 'p-1.5'}`}
className={`hover:bg-accent rounded-lg transition-colors ${isMobile ? 'p-1' : 'p-1.5'}`}
>
<RefreshCw className={`${isLoading ? 'animate-spin' : ''} ${isMobile ? 'w-3 h-3' : 'w-4 h-4'}`} />
<RefreshCw className={`text-muted-foreground ${isLoading ? 'animate-spin' : ''} ${isMobile ? 'w-3 h-3' : 'w-4 h-4'}`} />
</button>
</div>
</div>
{/* Git Repository Not Found Message */}
{gitStatus?.error ? (
<div className="flex-1 flex flex-col items-center justify-center text-gray-500 dark:text-gray-400 px-6 py-12">
<GitBranch className="w-20 h-20 mb-6 opacity-30" />
<h3 className="text-xl font-medium mb-3 text-center">{gitStatus.error}</h3>
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground px-6 py-12">
<div className="w-16 h-16 rounded-2xl bg-muted/50 flex items-center justify-center mb-6">
<GitBranch className="w-8 h-8 opacity-40" />
</div>
<h3 className="text-lg font-medium mb-3 text-center text-foreground">{gitStatus.error}</h3>
{gitStatus.details && (
<p className="text-sm text-center leading-relaxed mb-6 max-w-md">{gitStatus.details}</p>
)}
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800 max-w-md">
<p className="text-sm text-blue-700 dark:text-blue-300 text-center">
<strong>Tip:</strong> Run <code className="bg-blue-100 dark:bg-blue-900 px-2 py-1 rounded font-mono text-xs">git init</code> in your project directory to initialize git source control.
<div className="p-4 bg-primary/5 rounded-xl border border-primary/10 max-w-md">
<p className="text-sm text-primary text-center">
<strong>Tip:</strong> Run <code className="bg-primary/10 px-2 py-1 rounded-md font-mono text-xs">git init</code> in your project directory to initialize git source control.
</p>
</div>
</div>
) : (
<>
{/* Tab Navigation - Only show when git is available and no files expanded */}
<div className={`flex border-b border-gray-200 dark:border-gray-700 transition-all duration-300 ease-in-out ${
expandedFiles.size === 0
? 'max-h-16 opacity-100 translate-y-0'
<div className={`flex border-b border-border/60 transition-all duration-300 ease-in-out ${
expandedFiles.size === 0
? 'max-h-16 opacity-100 translate-y-0'
: 'max-h-0 opacity-0 -translate-y-2 overflow-hidden'
}`}>
<button
onClick={() => setActiveView('changes')}
className={`flex-1 px-4 py-2 text-sm font-medium transition-colors ${
activeView === 'changes'
? 'text-blue-600 dark:text-blue-400 border-b-2 border-blue-600 dark:border-blue-400'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
? 'text-primary border-b-2 border-primary'
: 'text-muted-foreground hover:text-foreground'
}`}
>
<div className="flex items-center justify-center gap-2">
@@ -991,8 +1013,8 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
onClick={() => setActiveView('history')}
className={`flex-1 px-4 py-2 text-sm font-medium transition-colors ${
activeView === 'history'
? 'text-blue-600 dark:text-blue-400 border-b-2 border-blue-600 dark:border-blue-400'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
? 'text-primary border-b-2 border-primary'
: 'text-muted-foreground hover:text-foreground'
}`}
>
<div className="flex items-center justify-center gap-2">
@@ -1012,10 +1034,10 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
: 'max-h-0 opacity-0 -translate-y-2 overflow-hidden'
}`}>
{isMobile && isCommitAreaCollapsed ? (
<div className="px-4 py-2 border-b border-gray-200 dark:border-gray-700">
<div className="px-4 py-2 border-b border-border/60">
<button
onClick={() => setIsCommitAreaCollapsed(false)}
className="w-full flex items-center justify-center gap-2 px-3 py-2 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700"
className="w-full flex items-center justify-center gap-2 px-3 py-2 text-sm bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors"
>
<GitCommit className="w-4 h-4" />
<span>Commit {selectedFiles.size} file{selectedFiles.size !== 1 ? 's' : ''}</span>
@@ -1025,27 +1047,27 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
) : (
<>
{/* Commit Message Input */}
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<div className="px-4 py-3 border-b border-border/60">
{/* Mobile collapse button */}
{isMobile && (
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium">Commit Changes</span>
<span className="text-sm font-medium text-foreground">Commit Changes</span>
<button
onClick={() => setIsCommitAreaCollapsed(true)}
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-800 rounded"
className="p-1 hover:bg-accent rounded-lg transition-colors"
>
<ChevronDown className="w-4 h-4 rotate-180" />
</button>
</div>
)}
<div className="relative">
<textarea
ref={textareaRef}
value={commitMessage}
onChange={(e) => setCommitMessage(e.target.value)}
placeholder="Message (Ctrl+Enter to commit)"
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 resize-none pr-20"
className="w-full px-3 py-2 text-sm border border-border rounded-xl bg-background text-foreground placeholder:text-muted-foreground resize-none pr-20 focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary/30"
rows="3"
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
@@ -1057,7 +1079,7 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
<button
onClick={generateCommitMessage}
disabled={selectedFiles.size === 0 || isGeneratingMessage}
className="p-1.5 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 disabled:opacity-50 disabled:cursor-not-allowed"
className="p-1.5 text-muted-foreground hover:text-foreground disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
title="Generate commit message"
>
{isGeneratingMessage ? (
@@ -1076,16 +1098,16 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
</div>
</div>
<div className="flex items-center justify-between mt-2">
<span className="text-xs text-gray-500">
<span className="text-xs text-muted-foreground">
{selectedFiles.size} file{selectedFiles.size !== 1 ? 's' : ''} selected
</span>
<button
onClick={() => setConfirmAction({
type: 'commit',
message: `Commit ${selectedFiles.size} file${selectedFiles.size !== 1 ? 's' : ''} with message: "${commitMessage.trim()}"?`
onClick={() => setConfirmAction({
type: 'commit',
message: `Commit ${selectedFiles.size} file${selectedFiles.size !== 1 ? 's' : ''} with message: "${commitMessage.trim()}"?`
})}
disabled={!commitMessage.trim() || selectedFiles.size === 0 || isCommitting}
className="px-3 py-1 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-1"
className="px-3 py-1.5 text-sm bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-1 transition-colors"
>
<Check className="w-3 h-3" />
<span>{isCommitting ? 'Committing...' : 'Commit'}</span>
@@ -1100,12 +1122,12 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
{/* File Selection Controls - Only show in changes view and when git is working and no files expanded */}
{activeView === 'changes' && gitStatus && !gitStatus.error && (
<div className={`border-b border-gray-200 dark:border-gray-700 flex items-center justify-between transition-all duration-300 ease-in-out ${isMobile ? 'px-3 py-1.5' : 'px-4 py-2'} ${
expandedFiles.size === 0
? 'max-h-16 opacity-100 translate-y-0'
<div className={`border-b border-border/60 flex items-center justify-between transition-all duration-300 ease-in-out ${isMobile ? 'px-3 py-1.5' : 'px-4 py-2'} ${
expandedFiles.size === 0
? 'max-h-16 opacity-100 translate-y-0'
: 'max-h-0 opacity-0 -translate-y-2 overflow-hidden'
}`}>
<span className={`text-gray-600 dark:text-gray-400 ${isMobile ? 'text-xs' : 'text-xs'}`}>
<span className="text-xs text-muted-foreground">
{selectedFiles.size} of {(gitStatus?.modified?.length || 0) + (gitStatus?.added?.length || 0) + (gitStatus?.deleted?.length || 0) + (gitStatus?.untracked?.length || 0)} {isMobile ? '' : 'files'} selected
</span>
<div className={`flex ${isMobile ? 'gap-1' : 'gap-2'}`}>
@@ -1119,14 +1141,14 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
]);
setSelectedFiles(allFiles);
}}
className={`text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 ${isMobile ? 'text-xs' : 'text-xs'}`}
className="text-xs text-primary hover:text-primary/80 transition-colors"
>
{isMobile ? 'All' : 'Select All'}
</button>
<span className="text-gray-300 dark:text-gray-600">|</span>
<span className="text-border">|</span>
<button
onClick={() => setSelectedFiles(new Set())}
className={`text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 ${isMobile ? 'text-xs' : 'text-xs'}`}
className="text-xs text-primary hover:text-primary/80 transition-colors"
>
{isMobile ? 'None' : 'Deselect All'}
</button>
@@ -1136,42 +1158,42 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
{/* Status Legend Toggle - Hide on mobile by default */}
{!gitStatus?.error && !isMobile && (
<div className="border-b border-gray-200 dark:border-gray-700">
<div className="border-b border-border/60">
<button
onClick={() => setShowLegend(!showLegend)}
className="w-full px-4 py-2 bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-750 text-xs text-gray-600 dark:text-gray-400 flex items-center justify-center gap-1"
className="w-full px-4 py-2 bg-muted/30 hover:bg-muted/50 text-xs text-muted-foreground flex items-center justify-center gap-1 transition-colors"
>
<Info className="w-3 h-3" />
<span>File Status Guide</span>
{showLegend ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
</button>
{showLegend && (
<div className="px-4 py-3 bg-gray-50 dark:bg-gray-800 text-xs">
<div className="px-4 py-3 bg-muted/30 text-xs">
<div className={`${isMobile ? 'grid grid-cols-2 gap-3 justify-items-center' : 'flex justify-center gap-6'}`}>
<div className="flex items-center gap-2">
<span className="inline-flex items-center justify-center w-5 h-5 bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300 rounded border border-yellow-200 dark:border-yellow-800 font-bold text-xs">
<span className="inline-flex items-center justify-center w-5 h-5 bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300 rounded border border-yellow-200 dark:border-yellow-800/50 font-bold text-[10px]">
M
</span>
<span className="text-gray-600 dark:text-gray-400 italic">Modified</span>
<span className="text-muted-foreground italic">Modified</span>
</div>
<div className="flex items-center gap-2">
<span className="inline-flex items-center justify-center w-5 h-5 bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300 rounded border border-green-200 dark:border-green-800 font-bold text-xs">
<span className="inline-flex items-center justify-center w-5 h-5 bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300 rounded border border-green-200 dark:border-green-800/50 font-bold text-[10px]">
A
</span>
<span className="text-gray-600 dark:text-gray-400 italic">Added</span>
<span className="text-muted-foreground italic">Added</span>
</div>
<div className="flex items-center gap-2">
<span className="inline-flex items-center justify-center w-5 h-5 bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300 rounded border border-red-200 dark:border-red-800 font-bold text-xs">
<span className="inline-flex items-center justify-center w-5 h-5 bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300 rounded border border-red-200 dark:border-red-800/50 font-bold text-[10px]">
D
</span>
<span className="text-gray-600 dark:text-gray-400 italic">Deleted</span>
<span className="text-muted-foreground italic">Deleted</span>
</div>
<div className="flex items-center gap-2">
<span className="inline-flex items-center justify-center w-5 h-5 bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300 rounded border border-gray-300 dark:border-gray-600 font-bold text-xs">
<span className="inline-flex items-center justify-center w-5 h-5 bg-muted text-muted-foreground rounded border border-border font-bold text-[10px]">
U
</span>
<span className="text-gray-600 dark:text-gray-400 italic">Untracked</span>
<span className="text-muted-foreground italic">Untracked</span>
</div>
</div>
</div>
@@ -1186,19 +1208,21 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
<div className={`flex-1 overflow-y-auto ${isMobile ? 'pb-mobile-nav' : ''}`}>
{isLoading ? (
<div className="flex items-center justify-center h-32">
<RefreshCw className="w-6 h-6 animate-spin text-gray-400" />
<RefreshCw className="w-5 h-5 animate-spin text-muted-foreground" />
</div>
) : gitStatus?.hasCommits === false ? (
<div className="flex flex-col items-center justify-center p-8 text-center">
<GitBranch className="w-16 h-16 mb-4 opacity-30 text-gray-400 dark:text-gray-500" />
<h3 className="text-lg font-medium mb-2 text-gray-900 dark:text-white">No commits yet</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-6 max-w-md">
<div className="w-14 h-14 rounded-2xl bg-muted/50 flex items-center justify-center mb-4">
<GitBranch className="w-7 h-7 text-muted-foreground/50" />
</div>
<h3 className="text-lg font-medium mb-2 text-foreground">No commits yet</h3>
<p className="text-sm text-muted-foreground mb-6 max-w-md">
This repository doesn't have any commits yet. Create your first commit to start tracking changes.
</p>
<button
onClick={createInitialCommit}
disabled={isCreatingInitialCommit}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 transition-colors"
>
{isCreatingInitialCommit ? (
<>
@@ -1214,8 +1238,8 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
</button>
</div>
) : !gitStatus || (!gitStatus.modified?.length && !gitStatus.added?.length && !gitStatus.deleted?.length && !gitStatus.untracked?.length) ? (
<div className="flex flex-col items-center justify-center h-32 text-gray-500 dark:text-gray-400">
<GitCommit className="w-12 h-12 mb-2 opacity-50" />
<div className="flex flex-col items-center justify-center h-32 text-muted-foreground">
<GitCommit className="w-10 h-10 mb-2 opacity-40" />
<p className="text-sm">No changes detected</p>
</div>
) : (
@@ -1234,11 +1258,11 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
<div className={`flex-1 overflow-y-auto ${isMobile ? 'pb-mobile-nav' : ''}`}>
{isLoading ? (
<div className="flex items-center justify-center h-32">
<RefreshCw className="w-6 h-6 animate-spin text-gray-400" />
<RefreshCw className="w-5 h-5 animate-spin text-muted-foreground" />
</div>
) : recentCommits.length === 0 ? (
<div className="flex flex-col items-center justify-center h-32 text-gray-500 dark:text-gray-400">
<History className="w-12 h-12 mb-2 opacity-50" />
<div className="flex flex-col items-center justify-center h-32 text-muted-foreground">
<History className="w-10 h-10 mb-2 opacity-40" />
<p className="text-sm">No commits found</p>
</div>
) : (
@@ -1252,12 +1276,12 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
{/* New Branch Modal */}
{showNewBranchModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="fixed inset-0 bg-black bg-opacity-50" onClick={() => setShowNewBranchModal(false)} />
<div className="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full">
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setShowNewBranchModal(false)} />
<div className="relative bg-card border border-border rounded-xl shadow-2xl max-w-md w-full overflow-hidden">
<div className="p-6">
<h3 className="text-lg font-semibold mb-4">Create New Branch</h3>
<h3 className="text-lg font-semibold text-foreground mb-4">Create New Branch</h3>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
<label className="block text-sm font-medium text-foreground/80 mb-2">
Branch Name
</label>
<input
@@ -1270,11 +1294,11 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
}
}}
placeholder="feature/new-feature"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full px-3 py-2 border border-border rounded-xl bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary/30"
autoFocus
/>
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mb-4">
<div className="text-xs text-muted-foreground mb-4">
This will create a new branch from the current branch ({currentBranch})
</div>
<div className="flex justify-end space-x-3">
@@ -1283,14 +1307,14 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
setShowNewBranchModal(false);
setNewBranchName('');
}}
className="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md"
className="px-4 py-2 text-sm text-muted-foreground hover:text-foreground hover:bg-accent rounded-lg transition-colors"
>
Cancel
</button>
<button
onClick={createBranch}
disabled={!newBranchName.trim() || isCreatingBranch}
className="px-4 py-2 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-2"
className="px-4 py-2 text-sm bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-2 transition-colors"
>
{isCreatingBranch ? (
<>
@@ -1313,44 +1337,44 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
{/* Confirmation Modal */}
{confirmAction && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="fixed inset-0 bg-black bg-opacity-50" onClick={() => setConfirmAction(null)} />
<div className="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full">
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setConfirmAction(null)} />
<div className="relative bg-card border border-border rounded-xl shadow-2xl max-w-md w-full overflow-hidden">
<div className="p-6">
<div className="flex items-center mb-4">
<div className={`p-2 rounded-full mr-3 ${
(confirmAction.type === 'discard' || confirmAction.type === 'delete') ? 'bg-red-100 dark:bg-red-900' : 'bg-yellow-100 dark:bg-yellow-900'
(confirmAction.type === 'discard' || confirmAction.type === 'delete') ? 'bg-red-100 dark:bg-red-900/30' : 'bg-yellow-100 dark:bg-yellow-900/30'
}`}>
<AlertTriangle className={`w-5 h-5 ${
(confirmAction.type === 'discard' || confirmAction.type === 'delete') ? 'text-red-600 dark:text-red-400' : 'text-yellow-600 dark:text-yellow-400'
}`} />
</div>
<h3 className="text-lg font-semibold">
{confirmAction.type === 'discard' ? 'Discard Changes' :
<h3 className="text-lg font-semibold text-foreground">
{confirmAction.type === 'discard' ? 'Discard Changes' :
confirmAction.type === 'delete' ? 'Delete File' :
confirmAction.type === 'commit' ? 'Confirm Commit' :
confirmAction.type === 'pull' ? 'Confirm Pull' :
confirmAction.type === 'commit' ? 'Confirm Commit' :
confirmAction.type === 'pull' ? 'Confirm Pull' :
confirmAction.type === 'publish' ? 'Publish Branch' : 'Confirm Push'}
</h3>
</div>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-6">
<p className="text-sm text-muted-foreground mb-6">
{confirmAction.message}
</p>
<div className="flex justify-end space-x-3">
<button
onClick={() => setConfirmAction(null)}
className="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md"
className="px-4 py-2 text-sm text-muted-foreground hover:text-foreground hover:bg-accent rounded-lg transition-colors"
>
Cancel
</button>
<button
onClick={confirmAndExecute}
className={`px-4 py-2 text-sm text-white rounded-md ${
className={`px-4 py-2 text-sm text-white rounded-lg transition-colors ${
(confirmAction.type === 'discard' || confirmAction.type === 'delete')
? 'bg-red-600 hover:bg-red-700'
? 'bg-red-600 hover:bg-red-700'
: confirmAction.type === 'commit'
? 'bg-blue-600 hover:bg-blue-700'
? 'bg-primary hover:bg-primary/90'
: confirmAction.type === 'pull'
? 'bg-green-600 hover:bg-green-700'
: confirmAction.type === 'publish'
@@ -1399,4 +1423,4 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
);
}
export default GitPanel;
export default GitPanel;

View File

@@ -3,8 +3,10 @@ import { Button } from './ui/button';
import { Input } from './ui/input';
import { GitBranch, Check } from 'lucide-react';
import { authenticatedFetch } from '../utils/api';
import { useTranslation } from 'react-i18next';
function GitSettings() {
const { t } = useTranslation('settings');
const [gitName, setGitName] = useState('');
const [gitEmail, setGitEmail] = useState('');
const [gitConfigLoading, setGitConfigLoading] = useState(false);
@@ -61,17 +63,17 @@ function GitSettings() {
<div>
<div className="flex items-center gap-2 mb-4">
<GitBranch className="h-5 w-5" />
<h3 className="text-lg font-semibold">Git Configuration</h3>
<h3 className="text-lg font-semibold">{t('git.title')}</h3>
</div>
<p className="text-sm text-muted-foreground mb-4">
Configure your git identity for commits. These settings will be applied globally via <code className="bg-muted px-2 py-0.5 rounded text-xs">git config --global</code>
{t('git.description')}
</p>
<div className="p-4 border rounded-lg bg-card space-y-3">
<div>
<label htmlFor="settings-git-name" className="block text-sm font-medium text-foreground mb-2">
Git Name
{t('git.name.label')}
</label>
<Input
id="settings-git-name"
@@ -83,13 +85,13 @@ function GitSettings() {
className="w-full"
/>
<p className="mt-1 text-xs text-muted-foreground">
Your name for git commits
{t('git.name.help')}
</p>
</div>
<div>
<label htmlFor="settings-git-email" className="block text-sm font-medium text-foreground mb-2">
Git Email
{t('git.email.label')}
</label>
<Input
id="settings-git-email"
@@ -101,7 +103,7 @@ function GitSettings() {
className="w-full"
/>
<p className="mt-1 text-xs text-muted-foreground">
Your email for git commits
{t('git.email.help')}
</p>
</div>
@@ -110,13 +112,13 @@ function GitSettings() {
onClick={saveGitConfig}
disabled={gitConfigSaving || !gitName || !gitEmail}
>
{gitConfigSaving ? 'Saving...' : 'Save Configuration'}
{gitConfigSaving ? t('git.actions.saving') : t('git.actions.save')}
</Button>
{saveStatus === 'success' && (
<div className="text-sm text-green-600 dark:text-green-400 flex items-center gap-2">
<Check className="w-4 h-4" />
Saved successfully
{t('git.status.success')}
</div>
)}
</div>

View File

@@ -0,0 +1,74 @@
/**
* Language Selector Component
*
* A dropdown component for selecting the application language.
* Automatically updates the i18n language and persists to localStorage.
*
* Props:
* @param {boolean} compact - If true, uses compact style (default: false)
*/
import { useTranslation } from 'react-i18next';
import { Languages } from 'lucide-react';
import { languages } from '../i18n/languages';
function LanguageSelector({ compact = false }) {
const { i18n, t } = useTranslation('settings');
const handleLanguageChange = (event) => {
const newLanguage = event.target.value;
i18n.changeLanguage(newLanguage);
};
// Compact style for QuickSettingsPanel
if (compact) {
return (
<div className="flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors border border-transparent hover:border-gray-300 dark:hover:border-gray-600">
<span className="flex items-center gap-2 text-sm text-gray-900 dark:text-white">
<Languages className="h-4 w-4 text-gray-600 dark:text-gray-400" />
{t('account.language')}
</span>
<select
value={i18n.language}
onChange={handleLanguageChange}
className="w-[100px] text-sm bg-gray-50 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 text-gray-900 dark:text-gray-100 rounded-lg focus:ring-blue-500 focus:border-blue-500 p-2 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400"
>
{languages.map((lang) => (
<option key={lang.value} value={lang.value}>
{lang.nativeName}
</option>
))}
</select>
</div>
);
}
// Full style for Settings page
return (
<div className="bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-gray-900 dark:text-gray-100 mb-1">
{t('account.languageLabel')}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
{t('account.languageDescription')}
</div>
</div>
<select
value={i18n.language}
onChange={handleLanguageChange}
className="text-sm bg-gray-50 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 text-gray-900 dark:text-gray-100 rounded-lg focus:ring-blue-500 focus:border-blue-500 p-2 w-36"
>
{languages.map((lang) => (
<option key={lang.value} value={lang.value}>
{lang.nativeName}
</option>
))}
</select>
</div>
</div>
);
}
export default LanguageSelector;

View File

@@ -1,32 +1,34 @@
import React, { useState } from 'react';
import { useAuth } from '../contexts/AuthContext';
import { MessageSquare } from 'lucide-react';
import { useTranslation } from 'react-i18next';
const LoginForm = () => {
const { t } = useTranslation('auth');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const { login } = useAuth();
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
if (!username || !password) {
setError('Please enter both username and password');
setError(t('errors.requiredFields'));
return;
}
setIsLoading(true);
const result = await login(username, password);
if (!result.success) {
setError(result.error);
}
setIsLoading(false);
};
@@ -41,9 +43,9 @@ const LoginForm = () => {
<MessageSquare className="w-8 h-8 text-primary-foreground" />
</div>
</div>
<h1 className="text-2xl font-bold text-foreground">Welcome Back</h1>
<h1 className="text-2xl font-bold text-foreground">{t('login.title')}</h1>
<p className="text-muted-foreground mt-2">
Sign in to your Claude Code UI account
{t('login.description')}
</p>
</div>
@@ -51,7 +53,7 @@ const LoginForm = () => {
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="username" className="block text-sm font-medium text-foreground mb-1">
Username
{t('login.username')}
</label>
<input
type="text"
@@ -59,7 +61,7 @@ const LoginForm = () => {
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full px-3 py-2 border border-border rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Enter your username"
placeholder={t('login.placeholders.username')}
required
disabled={isLoading}
/>
@@ -67,7 +69,7 @@ const LoginForm = () => {
<div>
<label htmlFor="password" className="block text-sm font-medium text-foreground mb-1">
Password
{t('login.password')}
</label>
<input
type="password"
@@ -75,7 +77,7 @@ const LoginForm = () => {
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-3 py-2 border border-border rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Enter your password"
placeholder={t('login.placeholders.password')}
required
disabled={isLoading}
/>
@@ -92,7 +94,7 @@ const LoginForm = () => {
disabled={isLoading}
className="w-full bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-medium py-2 px-4 rounded-md transition-colors duration-200"
>
{isLoading ? 'Signing in...' : 'Sign In'}
{isLoading ? t('login.loading') : t('login.submit')}
</button>
</form>

View File

@@ -1,5 +1,6 @@
import { X } from 'lucide-react';
import StandaloneShell from './StandaloneShell';
import { IS_PLATFORM } from '../constants/config';
/**
* Reusable login modal component for Claude, Cursor, and Codex CLI authentication
@@ -11,6 +12,7 @@ import StandaloneShell from './StandaloneShell';
* @param {Object} props.project - Project object containing name and path information
* @param {Function} props.onComplete - Callback when login process completes (receives exitCode)
* @param {string} props.customCommand - Optional custom command to override defaults
* @param {boolean} props.isAuthenticated - Whether user is already authenticated (for re-auth flow)
*/
function LoginModal({
isOpen,
@@ -18,24 +20,23 @@ function LoginModal({
provider = 'claude',
project,
onComplete,
customCommand
customCommand,
isAuthenticated = false
}) {
if (!isOpen) return null;
const getCommand = () => {
if (customCommand) return customCommand;
const isPlatform = import.meta.env.VITE_IS_PLATFORM === 'true';
switch (provider) {
case 'claude':
return 'claude setup-token --dangerously-skip-permissions';
return isAuthenticated ? 'claude setup-token --dangerously-skip-permissions' : 'claude /exit --dangerously-skip-permissions';
case 'cursor':
return 'cursor-agent login';
case 'codex':
return isPlatform ? 'codex login --device-auth' : 'codex login';
return IS_PLATFORM ? 'codex login --device-auth' : 'codex login';
default:
return 'claude setup-token --dangerously-skip-permissions';
return isAuthenticated ? 'claude setup-token --dangerously-skip-permissions' : 'claude /exit --dangerously-skip-permissions';
}
};
@@ -56,9 +57,7 @@ function LoginModal({
if (onComplete) {
onComplete(exitCode);
}
if (exitCode === 0) {
onClose();
}
// Keep modal open so users can read login output and close explicitly.
};
return (

View File

@@ -1,684 +0,0 @@
/*
* MainContent.jsx - Main Content Area with Session Protection Props Passthrough
*
* SESSION PROTECTION PASSTHROUGH:
* ===============================
*
* This component serves as a passthrough layer for Session Protection functions:
* - Receives session management functions from App.jsx
* - Passes them down to ChatInterface.jsx
*
* No session protection logic is implemented here - it's purely a props bridge.
*/
import React, { useState, useEffect, useRef } from 'react';
import ChatInterface from './ChatInterface';
import FileTree from './FileTree';
import CodeEditor from './CodeEditor';
import StandaloneShell from './StandaloneShell';
import GitPanel from './GitPanel';
import ErrorBoundary from './ErrorBoundary';
import ClaudeLogo from './ClaudeLogo';
import CursorLogo from './CursorLogo';
import TaskList from './TaskList';
import TaskDetail from './TaskDetail';
import PRDEditor from './PRDEditor';
import Tooltip from './Tooltip';
import { useTaskMaster } from '../contexts/TaskMasterContext';
import { useTasksSettings } from '../contexts/TasksSettingsContext';
import { api } from '../utils/api';
function MainContent({
selectedProject,
selectedSession,
activeTab,
setActiveTab,
ws,
sendMessage,
messages,
isMobile,
isPWA,
onMenuClick,
isLoading,
onInputFocusChange,
// Session Protection Props: Functions passed down from App.jsx to manage active session state
// These functions control when project updates are paused during active conversations
onSessionActive, // Mark session as active when user sends message
onSessionInactive, // Mark session as inactive when conversation completes/aborts
onSessionProcessing, // Mark session as processing (thinking/working)
onSessionNotProcessing, // Mark session as not processing (finished thinking)
processingSessions, // Set of session IDs currently processing
onReplaceTemporarySession, // Replace temporary session ID with real session ID from WebSocket
onNavigateToSession, // Navigate to a specific session (for Claude CLI session duplication workaround)
onShowSettings, // Show tools settings panel
autoExpandTools, // Auto-expand tool accordions
showRawParameters, // Show raw parameters in tool accordions
showThinking, // Show thinking/reasoning sections
autoScrollToBottom, // Auto-scroll to bottom when new messages arrive
sendByCtrlEnter, // Send by Ctrl+Enter mode for East Asian language input
externalMessageUpdate // Trigger for external CLI updates to current session
}) {
const [editingFile, setEditingFile] = useState(null);
const [selectedTask, setSelectedTask] = useState(null);
const [showTaskDetail, setShowTaskDetail] = useState(false);
const [editorWidth, setEditorWidth] = useState(600);
const [isResizing, setIsResizing] = useState(false);
const [editorExpanded, setEditorExpanded] = useState(false);
const resizeRef = useRef(null);
// PRD Editor state
const [showPRDEditor, setShowPRDEditor] = useState(false);
const [selectedPRD, setSelectedPRD] = useState(null);
const [existingPRDs, setExistingPRDs] = useState([]);
const [prdNotification, setPRDNotification] = useState(null);
// TaskMaster context
const { tasks, currentProject, refreshTasks, setCurrentProject } = useTaskMaster();
const { tasksEnabled, isTaskMasterInstalled, isTaskMasterReady } = useTasksSettings();
// Only show tasks tab if TaskMaster is installed and enabled
const shouldShowTasksTab = tasksEnabled && isTaskMasterInstalled;
// Sync selectedProject with TaskMaster context
useEffect(() => {
if (selectedProject && selectedProject !== currentProject) {
setCurrentProject(selectedProject);
}
}, [selectedProject, currentProject, setCurrentProject]);
// Switch away from tasks tab when tasks are disabled or TaskMaster is not installed
useEffect(() => {
if (!shouldShowTasksTab && activeTab === 'tasks') {
setActiveTab('chat');
}
}, [shouldShowTasksTab, activeTab, setActiveTab]);
// Load existing PRDs when current project changes
useEffect(() => {
const loadExistingPRDs = async () => {
if (!currentProject?.name) {
setExistingPRDs([]);
return;
}
try {
const response = await api.get(`/taskmaster/prd/${encodeURIComponent(currentProject.name)}`);
if (response.ok) {
const data = await response.json();
setExistingPRDs(data.prdFiles || []);
} else {
setExistingPRDs([]);
}
} catch (error) {
console.error('Failed to load existing PRDs:', error);
setExistingPRDs([]);
}
};
loadExistingPRDs();
}, [currentProject?.name]);
const handleFileOpen = (filePath, diffInfo = null) => {
// Create a file object that CodeEditor expects
const file = {
name: filePath.split('/').pop(),
path: filePath,
projectName: selectedProject?.name,
diffInfo: diffInfo // Pass along diff information if available
};
setEditingFile(file);
};
const handleCloseEditor = () => {
setEditingFile(null);
setEditorExpanded(false);
};
const handleToggleEditorExpand = () => {
setEditorExpanded(!editorExpanded);
};
const handleTaskClick = (task) => {
// If task is just an ID (from dependency click), find the full task object
if (typeof task === 'object' && task.id && !task.title) {
const fullTask = tasks?.find(t => t.id === task.id);
if (fullTask) {
setSelectedTask(fullTask);
setShowTaskDetail(true);
}
} else {
setSelectedTask(task);
setShowTaskDetail(true);
}
};
const handleTaskDetailClose = () => {
setShowTaskDetail(false);
setSelectedTask(null);
};
const handleTaskStatusChange = (taskId, newStatus) => {
// This would integrate with TaskMaster API to update task status
console.log('Update task status:', taskId, newStatus);
refreshTasks?.();
};
// Handle resize functionality
const handleMouseDown = (e) => {
if (isMobile) return; // Disable resize on mobile
setIsResizing(true);
e.preventDefault();
};
useEffect(() => {
const handleMouseMove = (e) => {
if (!isResizing) return;
const container = resizeRef.current?.parentElement;
if (!container) return;
const containerRect = container.getBoundingClientRect();
const newWidth = containerRect.right - e.clientX;
// Min width: 300px, Max width: 80% of container
const minWidth = 300;
const maxWidth = containerRect.width * 0.8;
if (newWidth >= minWidth && newWidth <= maxWidth) {
setEditorWidth(newWidth);
}
};
const handleMouseUp = () => {
setIsResizing(false);
};
if (isResizing) {
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
}
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
};
}, [isResizing]);
if (isLoading) {
return (
<div className="h-full flex flex-col">
{/* Header with menu button for mobile */}
{isMobile && (
<div
className="bg-background border-b border-border p-2 sm:p-3 pwa-header-safe flex-shrink-0"
>
<button
onClick={onMenuClick}
className="p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-700 pwa-menu-button"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
</div>
)}
<div className="flex-1 flex items-center justify-center">
<div className="text-center text-gray-500 dark:text-gray-400">
<div className="w-12 h-12 mx-auto mb-4">
<div
className="w-full h-full rounded-full border-4 border-gray-200 border-t-blue-500"
style={{
animation: 'spin 1s linear infinite',
WebkitAnimation: 'spin 1s linear infinite',
MozAnimation: 'spin 1s linear infinite'
}}
/>
</div>
<h2 className="text-xl font-semibold mb-2">Loading Claude Code UI</h2>
<p>Setting up your workspace...</p>
</div>
</div>
</div>
);
}
if (!selectedProject) {
return (
<div className="h-full flex flex-col">
{/* Header with menu button for mobile */}
{isMobile && (
<div
className="bg-background border-b border-border p-2 sm:p-3 pwa-header-safe flex-shrink-0"
>
<button
onClick={onMenuClick}
className="p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-700 pwa-menu-button"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
</div>
)}
<div className="flex-1 flex items-center justify-center">
<div className="text-center text-gray-500 dark:text-gray-400 max-w-md mx-auto px-6">
<div className="w-16 h-16 mx-auto mb-6 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center">
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-5l-2-2H5a2 2 0 00-2 2z" />
</svg>
</div>
<h2 className="text-2xl font-semibold mb-3 text-gray-900 dark:text-white">Choose Your Project</h2>
<p className="text-gray-600 dark:text-gray-300 mb-6 leading-relaxed">
Select a project from the sidebar to start coding with Claude. Each project contains your chat sessions and file history.
</p>
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4 border border-blue-200 dark:border-blue-800">
<p className="text-sm text-blue-700 dark:text-blue-300">
💡 <strong>Tip:</strong> {isMobile ? 'Tap the menu button above to access projects' : 'Create a new project by clicking the folder icon in the sidebar'}
</p>
</div>
</div>
</div>
</div>
);
}
return (
<div className="h-full flex flex-col">
{/* Header with tabs */}
<div
className="bg-background border-b border-border p-2 sm:p-3 pwa-header-safe flex-shrink-0"
>
<div className="flex items-center justify-between relative">
<div className="flex items-center space-x-2 min-w-0 flex-1">
{isMobile && (
<button
onClick={onMenuClick}
onTouchStart={(e) => {
e.preventDefault();
onMenuClick();
}}
className="p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-700 touch-manipulation active:scale-95 pwa-menu-button flex-shrink-0"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
)}
<div className="min-w-0 flex items-center gap-2 flex-1 overflow-x-auto scrollbar-hide">
{activeTab === 'chat' && selectedSession && (
<div className="w-5 h-5 flex-shrink-0 flex items-center justify-center">
{selectedSession.__provider === 'cursor' ? (
<CursorLogo className="w-4 h-4" />
) : (
<ClaudeLogo className="w-4 h-4" />
)}
</div>
)}
<div className="min-w-0 flex-1">
{activeTab === 'chat' && selectedSession ? (
<div className="min-w-0">
<h2 className="text-sm sm:text-base font-semibold text-gray-900 dark:text-white whitespace-nowrap overflow-x-auto scrollbar-hide">
{selectedSession.__provider === 'cursor' ? (selectedSession.name || 'Untitled Session') : (selectedSession.summary || 'New Session')}
</h2>
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
{selectedProject.displayName}
</div>
</div>
) : activeTab === 'chat' && !selectedSession ? (
<div className="min-w-0">
<h2 className="text-sm sm:text-base font-semibold text-gray-900 dark:text-white">
New Session
</h2>
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
{selectedProject.displayName}
</div>
</div>
) : (
<div className="min-w-0">
<h2 className="text-sm sm:text-base font-semibold text-gray-900 dark:text-white">
{activeTab === 'files' ? 'Project Files' :
activeTab === 'git' ? 'Source Control' :
(activeTab === 'tasks' && shouldShowTasksTab) ? 'TaskMaster' :
'Project'}
</h2>
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
{selectedProject.displayName}
</div>
</div>
)}
</div>
</div>
</div>
{/* Modern Tab Navigation - Right Side */}
<div className="flex-shrink-0 hidden sm:block">
<div className="relative flex bg-gray-100 dark:bg-gray-800 rounded-lg p-1">
<Tooltip content="Chat" position="bottom">
<button
onClick={() => setActiveTab('chat')}
className={`relative px-2 sm:px-3 py-1.5 text-xs sm:text-sm font-medium rounded-md ${
activeTab === 'chat'
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-200 dark:hover:bg-gray-700'
}`}
>
<span className="flex items-center gap-1 sm:gap-1.5">
<svg className="w-3 sm:w-3.5 h-3 sm:h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
<span className="hidden md:hidden lg:inline">Chat</span>
</span>
</button>
</Tooltip>
<Tooltip content="Shell" position="bottom">
<button
onClick={() => setActiveTab('shell')}
className={`relative px-2 sm:px-3 py-1.5 text-xs sm:text-sm font-medium rounded-md transition-all duration-200 ${
activeTab === 'shell'
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-200 dark:hover:bg-gray-700'
}`}
>
<span className="flex items-center gap-1 sm:gap-1.5">
<svg className="w-3 sm:w-3.5 h-3 sm:h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
<span className="hidden md:hidden lg:inline">Shell</span>
</span>
</button>
</Tooltip>
<Tooltip content="Files" position="bottom">
<button
onClick={() => setActiveTab('files')}
className={`relative px-2 sm:px-3 py-1.5 text-xs sm:text-sm font-medium rounded-md transition-all duration-200 ${
activeTab === 'files'
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-200 dark:hover:bg-gray-700'
}`}
>
<span className="flex items-center gap-1 sm:gap-1.5">
<svg className="w-3 sm:w-3.5 h-3 sm:h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-5l-2-2H5a2 2 0 00-2 2z" />
</svg>
<span className="hidden md:hidden lg:inline">Files</span>
</span>
</button>
</Tooltip>
<Tooltip content="Source Control" position="bottom">
<button
onClick={() => setActiveTab('git')}
className={`relative px-2 sm:px-3 py-1.5 text-xs sm:text-sm font-medium rounded-md transition-all duration-200 ${
activeTab === 'git'
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-200 dark:hover:bg-gray-700'
}`}
>
<span className="flex items-center gap-1 sm:gap-1.5">
<svg className="w-3 sm:w-3.5 h-3 sm:h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<span className="hidden md:hidden lg:inline">Source Control</span>
</span>
</button>
</Tooltip>
{shouldShowTasksTab && (
<Tooltip content="Tasks" position="bottom">
<button
onClick={() => setActiveTab('tasks')}
className={`relative px-2 sm:px-3 py-1.5 text-xs sm:text-sm font-medium rounded-md transition-all duration-200 ${
activeTab === 'tasks'
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-200 dark:hover:bg-gray-700'
}`}
>
<span className="flex items-center gap-1 sm:gap-1.5">
<svg className="w-3 sm:w-3.5 h-3 sm:h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
</svg>
<span className="hidden md:hidden lg:inline">Tasks</span>
</span>
</button>
</Tooltip>
)}
{/* <button
onClick={() => setActiveTab('preview')}
className={`relative px-2 sm:px-3 py-1.5 text-xs sm:text-sm font-medium rounded-md transition-all duration-200 ${
activeTab === 'preview'
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-200 dark:hover:bg-gray-700'
}`}
>
<span className="flex items-center gap-1 sm:gap-1.5">
<svg className="w-3 sm:w-3.5 h-3 sm:h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
</svg>
<span className="hidden sm:inline">Preview</span>
</span>
</button> */}
</div>
</div>
</div>
</div>
{/* Content Area with Right Sidebar */}
<div className="flex-1 flex min-h-0 overflow-hidden">
{/* Main Content */}
<div className={`flex-1 flex flex-col min-h-0 overflow-hidden ${editingFile ? 'mr-0' : ''} ${editorExpanded ? 'hidden' : ''}`}>
<div className={`h-full ${activeTab === 'chat' ? 'block' : 'hidden'}`}>
<ErrorBoundary showDetails={true}>
<ChatInterface
selectedProject={selectedProject}
selectedSession={selectedSession}
ws={ws}
sendMessage={sendMessage}
messages={messages}
onFileOpen={handleFileOpen}
onInputFocusChange={onInputFocusChange}
onSessionActive={onSessionActive}
onSessionInactive={onSessionInactive}
onSessionProcessing={onSessionProcessing}
onSessionNotProcessing={onSessionNotProcessing}
processingSessions={processingSessions}
onReplaceTemporarySession={onReplaceTemporarySession}
onNavigateToSession={onNavigateToSession}
onShowSettings={onShowSettings}
autoExpandTools={autoExpandTools}
showRawParameters={showRawParameters}
showThinking={showThinking}
autoScrollToBottom={autoScrollToBottom}
sendByCtrlEnter={sendByCtrlEnter}
externalMessageUpdate={externalMessageUpdate}
onShowAllTasks={tasksEnabled ? () => setActiveTab('tasks') : null}
/>
</ErrorBoundary>
</div>
{activeTab === 'files' && (
<div className="h-full overflow-hidden">
<FileTree selectedProject={selectedProject} />
</div>
)}
{activeTab === 'shell' && (
<div className="h-full w-full overflow-hidden">
<StandaloneShell
project={selectedProject}
session={selectedSession}
showHeader={false}
/>
</div>
)}
{activeTab === 'git' && (
<div className="h-full overflow-hidden">
<GitPanel selectedProject={selectedProject} isMobile={isMobile} onFileOpen={handleFileOpen} />
</div>
)}
{shouldShowTasksTab && (
<div className={`h-full ${activeTab === 'tasks' ? 'block' : 'hidden'}`}>
<div className="h-full flex flex-col overflow-hidden">
<TaskList
tasks={tasks || []}
onTaskClick={handleTaskClick}
showParentTasks={true}
className="flex-1 overflow-y-auto p-4"
currentProject={currentProject}
onTaskCreated={refreshTasks}
onShowPRDEditor={(prd = null) => {
setSelectedPRD(prd);
setShowPRDEditor(true);
}}
existingPRDs={existingPRDs}
onRefreshPRDs={(showNotification = false) => {
// Reload existing PRDs
if (currentProject?.name) {
api.get(`/taskmaster/prd/${encodeURIComponent(currentProject.name)}`)
.then(response => response.ok ? response.json() : Promise.reject())
.then(data => {
setExistingPRDs(data.prdFiles || []);
if (showNotification) {
setPRDNotification('PRD saved successfully!');
setTimeout(() => setPRDNotification(null), 3000);
}
})
.catch(error => console.error('Failed to refresh PRDs:', error));
}
}}
/>
</div>
</div>
)}
<div className={`h-full overflow-hidden ${activeTab === 'preview' ? 'block' : 'hidden'}`}>
{/* <LivePreviewPanel
selectedProject={selectedProject}
serverStatus={serverStatus}
serverUrl={serverUrl}
availableScripts={availableScripts}
onStartServer={(script) => {
sendMessage({
type: 'server:start',
projectPath: selectedProject?.fullPath,
script: script
});
}}
onStopServer={() => {
sendMessage({
type: 'server:stop',
projectPath: selectedProject?.fullPath
});
}}
onScriptSelect={setCurrentScript}
currentScript={currentScript}
isMobile={isMobile}
serverLogs={serverLogs}
onClearLogs={() => setServerLogs([])}
/> */}
</div>
</div>
{/* Code Editor Right Sidebar - Desktop only, Mobile uses modal */}
{editingFile && !isMobile && (
<>
{/* Resize Handle - Hidden when expanded */}
{!editorExpanded && (
<div
ref={resizeRef}
onMouseDown={handleMouseDown}
className="flex-shrink-0 w-1 bg-gray-200 dark:bg-gray-700 hover:bg-blue-500 dark:hover:bg-blue-600 cursor-col-resize transition-colors relative group"
title="Drag to resize"
>
{/* Visual indicator on hover */}
<div className="absolute inset-y-0 left-1/2 -translate-x-1/2 w-1 bg-blue-500 dark:bg-blue-600 opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
)}
{/* Editor Sidebar */}
<div
className={`flex-shrink-0 border-l border-gray-200 dark:border-gray-700 h-full overflow-hidden ${editorExpanded ? 'flex-1' : ''}`}
style={editorExpanded ? {} : { width: `${editorWidth}px` }}
>
<CodeEditor
file={editingFile}
onClose={handleCloseEditor}
projectPath={selectedProject?.path}
isSidebar={true}
isExpanded={editorExpanded}
onToggleExpand={handleToggleEditorExpand}
/>
</div>
</>
)}
</div>
{/* Code Editor Modal for Mobile */}
{editingFile && isMobile && (
<CodeEditor
file={editingFile}
onClose={handleCloseEditor}
projectPath={selectedProject?.path}
isSidebar={false}
/>
)}
{/* Task Detail Modal */}
{shouldShowTasksTab && showTaskDetail && selectedTask && (
<TaskDetail
task={selectedTask}
isOpen={showTaskDetail}
onClose={handleTaskDetailClose}
onStatusChange={handleTaskStatusChange}
onTaskClick={handleTaskClick}
/>
)}
{/* PRD Editor Modal */}
{showPRDEditor && (
<PRDEditor
project={currentProject}
projectPath={currentProject?.fullPath || currentProject?.path}
onClose={() => {
setShowPRDEditor(false);
setSelectedPRD(null);
}}
isNewFile={!selectedPRD?.isExisting}
file={{
name: selectedPRD?.name || 'prd.txt',
content: selectedPRD?.content || ''
}}
onSave={async () => {
setShowPRDEditor(false);
setSelectedPRD(null);
// Reload existing PRDs with notification
try {
const response = await api.get(`/taskmaster/prd/${encodeURIComponent(currentProject.name)}`);
if (response.ok) {
const data = await response.json();
setExistingPRDs(data.prdFiles || []);
setPRDNotification('PRD saved successfully!');
setTimeout(() => setPRDNotification(null), 3000);
}
} catch (error) {
console.error('Failed to refresh PRDs:', error);
}
refreshTasks?.();
}}
/>
)}
{/* PRD Notification */}
{prdNotification && (
<div className="fixed bottom-4 right-4 z-50 animate-in slide-in-from-bottom-2 duration-300">
<div className="bg-green-600 text-white px-4 py-3 rounded-lg shadow-lg flex items-center gap-3">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className="font-medium">{prdNotification}</span>
</div>
</div>
)}
</div>
);
}
export default React.memo(MainContent);

View File

@@ -1,74 +1,90 @@
import React from 'react';
import { MessageSquare, Folder, Terminal, GitBranch, Globe, CheckSquare } from 'lucide-react';
import { MessageSquare, Folder, Terminal, GitBranch, ClipboardCheck } from 'lucide-react';
import { useTasksSettings } from '../contexts/TasksSettingsContext';
import { useTaskMaster } from '../contexts/TaskMasterContext';
function MobileNav({ activeTab, setActiveTab, isInputFocused }) {
const { tasksEnabled } = useTasksSettings();
const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings();
const shouldShowTasksTab = Boolean(tasksEnabled && isTaskMasterInstalled);
const navItems = [
{
id: 'chat',
icon: MessageSquare,
label: 'Chat',
onClick: () => setActiveTab('chat')
},
{
id: 'shell',
icon: Terminal,
label: 'Shell',
onClick: () => setActiveTab('shell')
},
{
id: 'files',
icon: Folder,
label: 'Files',
onClick: () => setActiveTab('files')
},
{
id: 'git',
icon: GitBranch,
label: 'Git',
onClick: () => setActiveTab('git')
},
// Conditionally add tasks tab if enabled
...(tasksEnabled ? [{
...(shouldShowTasksTab ? [{
id: 'tasks',
icon: CheckSquare,
icon: ClipboardCheck,
label: 'Tasks',
onClick: () => setActiveTab('tasks')
}] : [])
];
return (
<div
className={`fixed bottom-0 left-0 right-0 bg-background border-t border-border z-50 ios-bottom-safe transform transition-transform duration-300 ease-in-out shadow-lg ${
className={`fixed bottom-0 left-0 right-0 z-50 px-3 pb-[max(8px,env(safe-area-inset-bottom))] transform transition-transform duration-300 ease-in-out ${
isInputFocused ? 'translate-y-full' : 'translate-y-0'
}`}
>
<div className="flex items-center justify-around py-1">
{navItems.map((item) => {
const Icon = item.icon;
const isActive = activeTab === item.id;
return (
<button
key={item.id}
onClick={item.onClick}
onTouchStart={(e) => {
e.preventDefault();
item.onClick();
}}
className={`flex items-center justify-center p-2 rounded-lg min-h-[40px] min-w-[40px] relative touch-manipulation ${
isActive
? 'text-blue-600 dark:text-blue-400'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
}`}
aria-label={item.id}
>
<Icon className="w-5 h-5" />
{isActive && (
<div className="absolute top-0 left-1/2 transform -translate-x-1/2 w-6 h-0.5 bg-blue-600 dark:bg-blue-400 rounded-full" />
)}
</button>
);
})}
<div className="nav-glass mobile-nav-float rounded-2xl border border-border/30">
<div className="flex items-center justify-around px-1 py-1.5 gap-0.5">
{navItems.map((item) => {
const Icon = item.icon;
const isActive = activeTab === item.id;
return (
<button
key={item.id}
onClick={item.onClick}
onTouchStart={(e) => {
e.preventDefault();
item.onClick();
}}
className={`flex flex-col items-center justify-center gap-0.5 px-3 py-2 rounded-xl flex-1 relative touch-manipulation transition-all duration-200 active:scale-95 ${
isActive
? 'text-primary'
: 'text-muted-foreground hover:text-foreground'
}`}
aria-label={item.label}
aria-current={isActive ? 'page' : undefined}
>
{isActive && (
<div className="absolute inset-0 bg-primary/8 dark:bg-primary/12 rounded-xl" />
)}
<Icon
className={`relative z-10 transition-all duration-200 ${isActive ? 'w-5 h-5' : 'w-[18px] h-[18px]'}`}
strokeWidth={isActive ? 2.4 : 1.8}
/>
<span className={`relative z-10 text-[10px] font-medium transition-all duration-200 ${isActive ? 'opacity-100' : 'opacity-60'}`}>
{item.label}
</span>
</button>
);
})}
</div>
</div>
</div>
);
}
export default MobileNav;
export default MobileNav;

View File

@@ -6,6 +6,7 @@ import CodexLogo from './CodexLogo';
import LoginModal from './LoginModal';
import { authenticatedFetch } from '../utils/api';
import { useAuth } from '../contexts/AuthContext';
import { IS_PLATFORM } from '../constants/config';
const Onboarding = ({ onComplete }) => {
const [currentStep, setCurrentStep] = useState(0);
@@ -15,7 +16,7 @@ const Onboarding = ({ onComplete }) => {
const [error, setError] = useState('');
const [activeLoginProvider, setActiveLoginProvider] = useState(null);
const [selectedProject] = useState({ name: 'default', fullPath: '' });
const [selectedProject] = useState({ name: 'default', fullPath: IS_PLATFORM ? '/workspace' : '' });
const [claudeAuthStatus, setClaudeAuthStatus] = useState({
authenticated: false,

View File

@@ -1,13 +1,15 @@
import React, { useState, useEffect } from 'react';
import { X, FolderPlus, GitBranch, Key, ChevronRight, ChevronLeft, Check, Loader2, AlertCircle } from 'lucide-react';
import { X, FolderPlus, GitBranch, Key, ChevronRight, ChevronLeft, Check, Loader2, AlertCircle, FolderOpen, Eye, EyeOff, Plus } from 'lucide-react';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { api } from '../utils/api';
import { useTranslation } from 'react-i18next';
const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
const { t } = useTranslation();
// Wizard state
const [step, setStep] = useState(1); // 1: Choose type, 2: Configure, 3: Confirm
const [workspaceType, setWorkspaceType] = useState(null); // 'existing' or 'new'
const [workspaceType, setWorkspaceType] = useState('existing'); // 'existing' or 'new' - default to 'existing'
// Form state
const [workspacePath, setWorkspacePath] = useState('');
@@ -23,6 +25,15 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
const [loadingTokens, setLoadingTokens] = useState(false);
const [pathSuggestions, setPathSuggestions] = useState([]);
const [showPathDropdown, setShowPathDropdown] = useState(false);
const [showFolderBrowser, setShowFolderBrowser] = useState(false);
const [browserCurrentPath, setBrowserCurrentPath] = useState('~');
const [browserFolders, setBrowserFolders] = useState([]);
const [loadingFolders, setLoadingFolders] = useState(false);
const [showHiddenFolders, setShowHiddenFolders] = useState(false);
const [showNewFolderInput, setShowNewFolderInput] = useState(false);
const [newFolderName, setNewFolderName] = useState('');
const [creatingFolder, setCreatingFolder] = useState(false);
const [cloneProgress, setCloneProgress] = useState('');
// Load available GitHub tokens when needed
useEffect(() => {
@@ -71,9 +82,10 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
const data = await response.json();
if (data.suggestions) {
// Filter suggestions based on the input
// Filter suggestions based on the input, excluding exact match
const filtered = data.suggestions.filter(s =>
s.path.toLowerCase().startsWith(inputPath.toLowerCase())
s.path.toLowerCase().startsWith(inputPath.toLowerCase()) &&
s.path.toLowerCase() !== inputPath.toLowerCase()
);
setPathSuggestions(filtered.slice(0, 5));
setShowPathDropdown(filtered.length > 0);
@@ -88,13 +100,13 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
if (step === 1) {
if (!workspaceType) {
setError('Please select whether you have an existing workspace or want to create a new one');
setError(t('projectWizard.errors.selectType'));
return;
}
setStep(2);
} else if (step === 2) {
if (!workspacePath.trim()) {
setError('Please provide a workspace path');
setError(t('projectWizard.errors.providePath'));
return;
}
@@ -111,32 +123,69 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
const handleCreate = async () => {
setIsCreating(true);
setError(null);
setCloneProgress('');
try {
if (workspaceType === 'new' && githubUrl) {
const params = new URLSearchParams({
path: workspacePath.trim(),
githubUrl: githubUrl.trim(),
});
if (tokenMode === 'stored' && selectedGithubToken) {
params.append('githubTokenId', selectedGithubToken);
} else if (tokenMode === 'new' && newGithubToken) {
params.append('newGithubToken', newGithubToken.trim());
}
const token = localStorage.getItem('auth-token');
const url = `/api/projects/clone-progress?${params}${token ? `&token=${token}` : ''}`;
await new Promise((resolve, reject) => {
const eventSource = new EventSource(url);
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'progress') {
setCloneProgress(data.message);
} else if (data.type === 'complete') {
eventSource.close();
if (onProjectCreated) {
onProjectCreated(data.project);
}
onClose();
resolve();
} else if (data.type === 'error') {
eventSource.close();
reject(new Error(data.message));
}
} catch (e) {
console.error('Error parsing SSE event:', e);
}
};
eventSource.onerror = () => {
eventSource.close();
reject(new Error('Connection lost during clone'));
};
});
return;
}
const payload = {
workspaceType,
path: workspacePath.trim(),
};
// Add GitHub info if creating new workspace with GitHub URL
if (workspaceType === 'new' && githubUrl) {
payload.githubUrl = githubUrl.trim();
if (tokenMode === 'stored' && selectedGithubToken) {
payload.githubTokenId = parseInt(selectedGithubToken);
} else if (tokenMode === 'new' && newGithubToken) {
payload.newGithubToken = newGithubToken.trim();
}
}
const response = await api.createWorkspace(payload);
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Failed to create workspace');
throw new Error(data.details || data.error || t('projectWizard.errors.failedToCreate'));
}
// Success!
if (onProjectCreated) {
onProjectCreated(data.project);
}
@@ -144,7 +193,7 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
onClose();
} catch (error) {
console.error('Error creating workspace:', error);
setError(error.message || 'Failed to create workspace');
setError(error.message || t('projectWizard.errors.failedToCreate'));
} finally {
setIsCreating(false);
}
@@ -155,6 +204,60 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
setShowPathDropdown(false);
};
const openFolderBrowser = async () => {
setShowFolderBrowser(true);
await loadBrowserFolders('~');
};
const loadBrowserFolders = async (path) => {
try {
setLoadingFolders(true);
const response = await api.browseFilesystem(path);
const data = await response.json();
setBrowserCurrentPath(data.path || path);
setBrowserFolders(data.suggestions || []);
} catch (error) {
console.error('Error loading folders:', error);
} finally {
setLoadingFolders(false);
}
};
const selectFolder = (folderPath, advanceToConfirm = false) => {
setWorkspacePath(folderPath);
setShowFolderBrowser(false);
if (advanceToConfirm) {
setStep(3);
}
};
const navigateToFolder = async (folderPath) => {
await loadBrowserFolders(folderPath);
};
const createNewFolder = async () => {
if (!newFolderName.trim()) return;
setCreatingFolder(true);
setError(null);
try {
const separator = browserCurrentPath.includes('\\') ? '\\' : '/';
const folderPath = `${browserCurrentPath}${separator}${newFolderName.trim()}`;
const response = await api.createFolder(folderPath);
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || t('projectWizard.errors.failedToCreateFolder', 'Failed to create folder'));
}
setNewFolderName('');
setShowNewFolderInput(false);
await loadBrowserFolders(data.path || folderPath);
} catch (error) {
console.error('Error creating folder:', error);
setError(error.message || t('projectWizard.errors.failedToCreateFolder', 'Failed to create folder'));
} finally {
setCreatingFolder(false);
}
};
return (
<div className="fixed top-0 left-0 right-0 bottom-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-[60] p-0 sm:p-4">
<div className="bg-white dark:bg-gray-800 rounded-none sm:rounded-lg shadow-xl w-full h-full sm:h-auto sm:max-w-2xl border-0 sm:border border-gray-200 dark:border-gray-700 overflow-y-auto">
@@ -165,7 +268,7 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
<FolderPlus className="w-4 h-4 text-blue-600 dark:text-blue-400" />
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
Create New Project
{t('projectWizard.title')}
</h3>
</div>
<button
@@ -195,7 +298,7 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
{s < step ? <Check className="w-4 h-4" /> : s}
</div>
<span className="text-sm font-medium text-gray-700 dark:text-gray-300 hidden sm:inline">
{s === 1 ? 'Type' : s === 2 ? 'Configure' : 'Confirm'}
{s === 1 ? t('projectWizard.steps.type') : s === 2 ? t('projectWizard.steps.configure') : t('projectWizard.steps.confirm')}
</span>
</div>
{s < 3 && (
@@ -227,7 +330,7 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
<div className="space-y-4">
<div>
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
Do you already have a workspace, or would you like to create a new one?
{t('projectWizard.step1.question')}
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Existing Workspace */}
@@ -245,10 +348,10 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
</div>
<div className="flex-1">
<h5 className="font-semibold text-gray-900 dark:text-white mb-1">
Existing Workspace
{t('projectWizard.step1.existing.title')}
</h5>
<p className="text-sm text-gray-600 dark:text-gray-400">
I already have a workspace on my server and just need to add it to the project list
{t('projectWizard.step1.existing.description')}
</p>
</div>
</div>
@@ -269,10 +372,10 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
</div>
<div className="flex-1">
<h5 className="font-semibold text-gray-900 dark:text-white mb-1">
New Workspace
{t('projectWizard.step1.new.title')}
</h5>
<p className="text-sm text-gray-600 dark:text-gray-400">
Create a new workspace, optionally clone from a GitHub repository
{t('projectWizard.step1.new.description')}
</p>
</div>
</div>
@@ -288,35 +391,46 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
{/* Workspace Path */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{workspaceType === 'existing' ? 'Workspace Path' : 'Where should the workspace be created?'}
{workspaceType === 'existing' ? t('projectWizard.step2.existingPath') : t('projectWizard.step2.newPath')}
</label>
<div className="relative">
<Input
type="text"
value={workspacePath}
onChange={(e) => setWorkspacePath(e.target.value)}
placeholder={workspaceType === 'existing' ? '/path/to/existing/workspace' : '/path/to/new/workspace'}
className="w-full"
/>
{showPathDropdown && pathSuggestions.length > 0 && (
<div className="absolute z-10 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg max-h-60 overflow-y-auto">
{pathSuggestions.map((suggestion, index) => (
<button
key={index}
onClick={() => selectPathSuggestion(suggestion)}
className="w-full px-4 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-700 text-sm"
>
<div className="font-medium text-gray-900 dark:text-white">{suggestion.name}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">{suggestion.path}</div>
</button>
))}
</div>
)}
<div className="relative flex gap-2">
<div className="flex-1 relative">
<Input
type="text"
value={workspacePath}
onChange={(e) => setWorkspacePath(e.target.value)}
placeholder={workspaceType === 'existing' ? '/path/to/existing/workspace' : '/path/to/new/workspace'}
className="w-full"
/>
{showPathDropdown && pathSuggestions.length > 0 && (
<div className="absolute z-10 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg max-h-60 overflow-y-auto">
{pathSuggestions.map((suggestion, index) => (
<button
key={index}
onClick={() => selectPathSuggestion(suggestion)}
className="w-full px-4 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-700 text-sm"
>
<div className="font-medium text-gray-900 dark:text-white">{suggestion.name}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">{suggestion.path}</div>
</button>
))}
</div>
)}
</div>
<Button
type="button"
variant="outline"
onClick={openFolderBrowser}
className="px-3"
title="Browse folders"
>
<FolderOpen className="w-4 h-4" />
</Button>
</div>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{workspaceType === 'existing'
? 'Full path to your existing workspace directory'
: 'Full path where the new workspace will be created'}
? t('projectWizard.step2.existingHelp')
: t('projectWizard.step2.newHelp')}
</p>
</div>
@@ -325,7 +439,7 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
<>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
GitHub URL (Optional)
{t('projectWizard.step2.githubUrl')}
</label>
<Input
type="text"
@@ -335,21 +449,21 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
className="w-full"
/>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Leave empty to create an empty workspace, or provide a GitHub URL to clone
{t('projectWizard.step2.githubHelp')}
</p>
</div>
{/* GitHub Token (only if GitHub URL is provided) */}
{githubUrl && (
{/* GitHub Token (only for HTTPS URLs - SSH uses SSH keys) */}
{githubUrl && !githubUrl.startsWith('git@') && !githubUrl.startsWith('ssh://') && (
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
<div className="flex items-start gap-3 mb-4">
<Key className="w-5 h-5 text-gray-600 dark:text-gray-400 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h5 className="font-medium text-gray-900 dark:text-white mb-1">
GitHub Authentication (Optional)
{t('projectWizard.step2.githubAuth')}
</h5>
<p className="text-sm text-gray-600 dark:text-gray-400">
Only required for private repositories. Public repos can be cloned without authentication.
{t('projectWizard.step2.githubAuthHelp')}
</p>
</div>
</div>
@@ -357,7 +471,7 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
{loadingTokens ? (
<div className="flex items-center gap-2 text-sm text-gray-500">
<Loader2 className="w-4 h-4 animate-spin" />
Loading stored tokens...
{t('projectWizard.step2.loadingTokens')}
</div>
) : availableTokens.length > 0 ? (
<>
@@ -371,7 +485,7 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
}`}
>
Stored Token
{t('projectWizard.step2.storedToken')}
</button>
<button
onClick={() => setTokenMode('new')}
@@ -381,7 +495,7 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
}`}
>
New Token
{t('projectWizard.step2.newToken')}
</button>
<button
onClick={() => {
@@ -395,21 +509,21 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
}`}
>
None (Public)
{t('projectWizard.step2.nonePublic')}
</button>
</div>
{tokenMode === 'stored' ? (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Select Token
{t('projectWizard.step2.selectToken')}
</label>
<select
value={selectedGithubToken}
onChange={(e) => setSelectedGithubToken(e.target.value)}
className="w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-sm"
>
<option value="">-- Select a token --</option>
<option value="">{t('projectWizard.step2.selectTokenPlaceholder')}</option>
{availableTokens.map((token) => (
<option key={token.id} value={token.id}>
{token.credential_name}
@@ -420,7 +534,7 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
) : tokenMode === 'new' ? (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
GitHub Token
{t('projectWizard.step2.newToken')}
</label>
<Input
type="password"
@@ -430,7 +544,7 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
className="w-full"
/>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
This token will be used only for this operation
{t('projectWizard.step2.tokenHelp')}
</p>
</div>
) : null}
@@ -439,23 +553,23 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
<div className="space-y-4">
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-3 border border-blue-200 dark:border-blue-800">
<p className="text-sm text-blue-800 dark:text-blue-200">
💡 <strong>Public repositories</strong> don't require authentication. You can skip providing a token if cloning a public repo.
{t('projectWizard.step2.publicRepoInfo')}
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
GitHub Token (Optional for Public Repos)
{t('projectWizard.step2.optionalTokenPublic')}
</label>
<Input
type="password"
value={newGithubToken}
onChange={(e) => setNewGithubToken(e.target.value)}
placeholder="ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx (leave empty for public repos)"
placeholder={t('projectWizard.step2.tokenPublicPlaceholder')}
className="w-full"
/>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
No stored tokens available. You can add tokens in Settings → API Keys for easier reuse.
{t('projectWizard.step2.noTokensHelp')}
</p>
</div>
</div>
@@ -472,17 +586,17 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
<div className="space-y-4">
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-3">
Review Your Configuration
{t('projectWizard.step3.reviewConfig')}
</h4>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-600 dark:text-gray-400">Workspace Type:</span>
<span className="text-gray-600 dark:text-gray-400">{t('projectWizard.step3.workspaceType')}</span>
<span className="font-medium text-gray-900 dark:text-white">
{workspaceType === 'existing' ? 'Existing Workspace' : 'New Workspace'}
{workspaceType === 'existing' ? t('projectWizard.step3.existingWorkspace') : t('projectWizard.step3.newWorkspace')}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-600 dark:text-gray-400">Path:</span>
<span className="text-gray-600 dark:text-gray-400">{t('projectWizard.step3.path')}</span>
<span className="font-mono text-xs text-gray-900 dark:text-white break-all">
{workspacePath}
</span>
@@ -490,19 +604,21 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
{workspaceType === 'new' && githubUrl && (
<>
<div className="flex justify-between text-sm">
<span className="text-gray-600 dark:text-gray-400">Clone From:</span>
<span className="text-gray-600 dark:text-gray-400">{t('projectWizard.step3.cloneFrom')}</span>
<span className="font-mono text-xs text-gray-900 dark:text-white break-all">
{githubUrl}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-600 dark:text-gray-400">Authentication:</span>
<span className="text-gray-600 dark:text-gray-400">{t('projectWizard.step3.authentication')}</span>
<span className="text-xs text-gray-900 dark:text-white">
{tokenMode === 'stored' && selectedGithubToken
? `Using stored token: ${availableTokens.find(t => t.id.toString() === selectedGithubToken)?.credential_name || 'Unknown'}`
? `${t('projectWizard.step3.usingStoredToken')} ${availableTokens.find(t => t.id.toString() === selectedGithubToken)?.credential_name || 'Unknown'}`
: tokenMode === 'new' && newGithubToken
? 'Using provided token'
: 'No authentication'}
? t('projectWizard.step3.usingProvidedToken')
: (githubUrl.startsWith('git@') || githubUrl.startsWith('ssh://'))
? t('projectWizard.step3.sshKey', 'SSH Key')
: t('projectWizard.step3.noAuthentication')}
</span>
</div>
</>
@@ -511,13 +627,22 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
</div>
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4 border border-blue-200 dark:border-blue-800">
<p className="text-sm text-blue-800 dark:text-blue-200">
{workspaceType === 'existing'
? 'The workspace will be added to your project list and will be available for Claude/Cursor sessions.'
: githubUrl
? 'A new workspace will be created and the repository will be cloned from GitHub.'
: 'An empty workspace directory will be created at the specified path.'}
</p>
{isCreating && cloneProgress ? (
<div className="space-y-2">
<p className="text-sm font-medium text-blue-800 dark:text-blue-200">{t('projectWizard.step3.cloningRepository', 'Cloning repository...')}</p>
<code className="block text-xs font-mono text-blue-700 dark:text-blue-300 whitespace-pre-wrap break-all">
{cloneProgress}
</code>
</div>
) : (
<p className="text-sm text-blue-800 dark:text-blue-200">
{workspaceType === 'existing'
? t('projectWizard.step3.existingInfo')
: githubUrl
? t('projectWizard.step3.newWithClone')
: t('projectWizard.step3.newEmpty')}
</p>
)}
</div>
</div>
)}
@@ -531,11 +656,11 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
disabled={isCreating}
>
{step === 1 ? (
'Cancel'
t('projectWizard.buttons.cancel')
) : (
<>
<ChevronLeft className="w-4 h-4 mr-1" />
Back
{t('projectWizard.buttons.back')}
</>
)}
</Button>
@@ -547,22 +672,202 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
{isCreating ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Creating...
{githubUrl ? t('projectWizard.buttons.cloning', 'Cloning...') : t('projectWizard.buttons.creating')}
</>
) : step === 3 ? (
<>
<Check className="w-4 h-4 mr-1" />
Create Project
{t('projectWizard.buttons.createProject')}
</>
) : (
<>
Next
{t('projectWizard.buttons.next')}
<ChevronRight className="w-4 h-4 ml-1" />
</>
)}
</Button>
</div>
</div>
{/* Folder Browser Modal */}
{showFolderBrowser && (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-[70] p-4">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-2xl max-h-[80vh] border border-gray-200 dark:border-gray-700 flex flex-col">
{/* Browser Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-blue-100 dark:bg-blue-900/50 rounded-lg flex items-center justify-center">
<FolderOpen className="w-4 h-4 text-blue-600 dark:text-blue-400" />
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
Select Folder
</h3>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setShowHiddenFolders(!showHiddenFolders)}
className={`p-2 rounded-md transition-colors ${
showHiddenFolders
? 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/30'
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
title={showHiddenFolders ? 'Hide hidden folders' : 'Show hidden folders'}
>
{showHiddenFolders ? <Eye className="w-5 h-5" /> : <EyeOff className="w-5 h-5" />}
</button>
<button
onClick={() => setShowNewFolderInput(!showNewFolderInput)}
className={`p-2 rounded-md transition-colors ${
showNewFolderInput
? 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/30'
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
title="Create new folder"
>
<Plus className="w-5 h-5" />
</button>
<button
onClick={() => setShowFolderBrowser(false)}
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
{/* New Folder Input */}
{showNewFolderInput && (
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700 bg-blue-50 dark:bg-blue-900/20">
<div className="flex items-center gap-2">
<Input
type="text"
value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)}
placeholder="New folder name"
className="flex-1"
onKeyDown={(e) => {
if (e.key === 'Enter') createNewFolder();
if (e.key === 'Escape') {
setShowNewFolderInput(false);
setNewFolderName('');
}
}}
autoFocus
/>
<Button
size="sm"
onClick={createNewFolder}
disabled={!newFolderName.trim() || creatingFolder}
>
{creatingFolder ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Create'}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => {
setShowNewFolderInput(false);
setNewFolderName('');
}}
>
Cancel
</Button>
</div>
</div>
)}
{/* Folder List */}
<div className="flex-1 overflow-y-auto p-4">
{loadingFolders ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-gray-400" />
</div>
) : (
<div className="space-y-1">
{/* Parent Directory - check for Windows root (e.g., C:\) and Unix root */}
{browserCurrentPath !== '~' && browserCurrentPath !== '/' && !/^[A-Za-z]:\\?$/.test(browserCurrentPath) && (
<button
onClick={() => {
const lastSlash = Math.max(browserCurrentPath.lastIndexOf('/'), browserCurrentPath.lastIndexOf('\\'));
let parentPath;
if (lastSlash <= 0) {
parentPath = '/';
} else if (lastSlash === 2 && /^[A-Za-z]:/.test(browserCurrentPath)) {
parentPath = browserCurrentPath.substring(0, 3);
} else {
parentPath = browserCurrentPath.substring(0, lastSlash);
}
navigateToFolder(parentPath);
}}
className="w-full px-4 py-3 text-left hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg flex items-center gap-3"
>
<FolderOpen className="w-5 h-5 text-gray-400" />
<span className="font-medium text-gray-700 dark:text-gray-300">..</span>
</button>
)}
{/* Folders */}
{browserFolders.length === 0 ? (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
No subfolders found
</div>
) : (
browserFolders
.filter(folder => showHiddenFolders || !folder.name.startsWith('.'))
.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()))
.map((folder, index) => (
<div key={index} className="flex items-center gap-2">
<button
onClick={() => navigateToFolder(folder.path)}
className="flex-1 px-4 py-3 text-left hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg flex items-center gap-3"
>
<FolderPlus className="w-5 h-5 text-blue-500" />
<span className="font-medium text-gray-900 dark:text-white">{folder.name}</span>
</button>
<Button
variant="ghost"
size="sm"
onClick={() => selectFolder(folder.path, workspaceType === 'existing')}
className="text-xs px-3"
>
Select
</Button>
</div>
))
)}
</div>
)}
</div>
{/* Browser Footer with Current Path */}
<div className="border-t border-gray-200 dark:border-gray-700">
<div className="px-4 py-3 bg-gray-50 dark:bg-gray-900/50 flex items-center gap-2">
<span className="text-sm text-gray-600 dark:text-gray-400">Path:</span>
<code className="text-sm font-mono text-gray-900 dark:text-white flex-1 truncate">
{browserCurrentPath}
</code>
</div>
<div className="flex items-center justify-end gap-2 p-4">
<Button
variant="outline"
onClick={() => {
setShowFolderBrowser(false);
setShowNewFolderInput(false);
setNewFolderName('');
}}
>
Cancel
</Button>
<Button
variant="outline"
onClick={() => selectFolder(browserCurrentPath, workspaceType === 'existing')}
>
Use this folder
</Button>
</div>
</div>
</div>
</div>
)}
</div>
);
};

View File

@@ -4,6 +4,7 @@ import SetupForm from './SetupForm';
import LoginForm from './LoginForm';
import Onboarding from './Onboarding';
import { MessageSquare } from 'lucide-react';
import { IS_PLATFORM } from '../constants/config';
const LoadingScreen = () => (
<div className="min-h-screen bg-background flex items-center justify-center p-4">
@@ -27,7 +28,7 @@ const LoadingScreen = () => (
const ProtectedRoute = ({ children }) => {
const { user, isLoading, needsSetup, hasCompletedOnboarding, refreshOnboardingStatus } = useAuth();
if (import.meta.env.VITE_IS_PLATFORM === 'true') {
if (IS_PLATFORM) {
if (isLoading) {
return <LoadingScreen />;
}

View File

@@ -15,30 +15,29 @@ import {
Languages,
GripVertical
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import DarkModeToggle from './DarkModeToggle';
import { useTheme } from '../contexts/ThemeContext';
const QuickSettingsPanel = ({
isOpen,
onToggle,
autoExpandTools,
onAutoExpandChange,
showRawParameters,
onShowRawParametersChange,
showThinking,
onShowThinkingChange,
autoScrollToBottom,
onAutoScrollChange,
sendByCtrlEnter,
onSendByCtrlEnterChange,
isMobile
}) => {
const [localIsOpen, setLocalIsOpen] = useState(isOpen);
import { useUiPreferences } from '../hooks/useUiPreferences';
import { useTheme } from '../contexts/ThemeContext';
import LanguageSelector from './LanguageSelector';
import { useDeviceSettings } from '../hooks/useDeviceSettings';
const QuickSettingsPanel = () => {
const { t } = useTranslation('settings');
const [isOpen, setIsOpen] = useState(false);
const [whisperMode, setWhisperMode] = useState(() => {
return localStorage.getItem('whisperMode') || 'default';
});
const { isDarkMode } = useTheme();
const { isMobile } = useDeviceSettings({ trackPWA: false });
const { preferences, setPreference } = useUiPreferences();
const { autoExpandTools, showRawParameters, showThinking, autoScrollToBottom, sendByCtrlEnter } = preferences;
// Draggable handle state
const [handlePosition, setHandlePosition] = useState(() => {
const saved = localStorage.getItem('quickSettingsHandlePosition');
@@ -63,10 +62,6 @@ const QuickSettingsPanel = ({
const constraintsRef = useRef({ min: 10, max: 90 }); // Percentage constraints
const dragThreshold = 5; // Pixels to move before it's considered a drag
useEffect(() => {
setLocalIsOpen(isOpen);
}, [isOpen]);
// Save handle position to localStorage when it changes
useEffect(() => {
localStorage.setItem('quickSettingsHandlePosition', JSON.stringify({ y: handlePosition }));
@@ -203,9 +198,7 @@ const QuickSettingsPanel = ({
return;
}
const newState = !localIsOpen;
setLocalIsOpen(newState);
onToggle(newState);
setIsOpen((previous) => !previous);
};
return (
@@ -223,19 +216,19 @@ const QuickSettingsPanel = ({
handleDragStart(e);
}}
className={`fixed ${
localIsOpen ? 'right-64' : 'right-0'
isOpen ? 'right-64' : 'right-0'
} z-50 ${isDragging ? '' : 'transition-all duration-150 ease-out'} bg-white dark:bg-gray-800 border ${
isDragging ? 'border-blue-500 dark:border-blue-400' : 'border-gray-200 dark:border-gray-700'
} rounded-l-md p-2 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors shadow-lg ${
isDragging ? 'cursor-grabbing' : 'cursor-pointer'
} touch-none`}
style={{ ...getPositionStyle(), touchAction: 'none', WebkitTouchCallout: 'none', WebkitUserSelect: 'none' }}
aria-label={isDragging ? 'Dragging handle' : localIsOpen ? 'Close settings panel' : 'Open settings panel'}
title={isDragging ? 'Dragging...' : 'Click to toggle, drag to move'}
aria-label={isDragging ? t('quickSettings.dragHandle.dragging') : isOpen ? t('quickSettings.dragHandle.closePanel') : t('quickSettings.dragHandle.openPanel')}
title={isDragging ? t('quickSettings.dragHandle.draggingStatus') : t('quickSettings.dragHandle.toggleAndMove')}
>
{isDragging ? (
<GripVertical className="h-5 w-5 text-blue-500 dark:text-blue-400" />
) : localIsOpen ? (
) : isOpen ? (
<ChevronRight className="h-5 w-5 text-gray-600 dark:text-gray-400" />
) : (
<ChevronLeft className="h-5 w-5 text-gray-600 dark:text-gray-400" />
@@ -245,7 +238,7 @@ const QuickSettingsPanel = ({
{/* Panel */}
<div
className={`fixed top-0 right-0 h-full w-64 bg-background border-l border-border shadow-xl transform transition-transform duration-150 ease-out z-40 ${
localIsOpen ? 'translate-x-0' : 'translate-x-full'
isOpen ? 'translate-x-0' : 'translate-x-full'
} ${isMobile ? 'h-screen' : ''}`}
>
<div className="h-full flex flex-col">
@@ -253,7 +246,7 @@ const QuickSettingsPanel = ({
<div className="p-4 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<Settings2 className="h-5 w-5 text-gray-600 dark:text-gray-400" />
Quick Settings
{t('quickSettings.title')}
</h3>
</div>
@@ -261,30 +254,35 @@ const QuickSettingsPanel = ({
<div className={`flex-1 overflow-y-auto overflow-x-hidden p-4 space-y-6 bg-background ${isMobile ? 'pb-mobile-nav' : ''}`}>
{/* Appearance Settings */}
<div className="space-y-2">
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">Appearance</h4>
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">{t('quickSettings.sections.appearance')}</h4>
<div className="flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors border border-transparent hover:border-gray-300 dark:hover:border-gray-600">
<span className="flex items-center gap-2 text-sm text-gray-900 dark:text-white">
{isDarkMode ? <Moon className="h-4 w-4 text-gray-600 dark:text-gray-400" /> : <Sun className="h-4 w-4 text-gray-600 dark:text-gray-400" />}
Dark Mode
{t('quickSettings.darkMode')}
</span>
<DarkModeToggle />
</div>
{/* Language Selector */}
<div>
<LanguageSelector compact={true} />
</div>
</div>
{/* Tool Display Settings */}
<div className="space-y-2">
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">Tool Display</h4>
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">{t('quickSettings.sections.toolDisplay')}</h4>
<label className="flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer transition-colors border border-transparent hover:border-gray-300 dark:hover:border-gray-600">
<span className="flex items-center gap-2 text-sm text-gray-900 dark:text-white">
<Maximize2 className="h-4 w-4 text-gray-600 dark:text-gray-400" />
Auto-expand tools
{t('quickSettings.autoExpandTools')}
</span>
<input
type="checkbox"
checked={autoExpandTools}
onChange={(e) => onAutoExpandChange(e.target.checked)}
onChange={(e) => setPreference('autoExpandTools', e.target.checked)}
className="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 focus:ring-2 dark:focus:ring-blue-400 bg-gray-100 dark:bg-gray-800 checked:bg-blue-600 dark:checked:bg-blue-600"
/>
</label>
@@ -292,12 +290,12 @@ const QuickSettingsPanel = ({
<label className="flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer transition-colors border border-transparent hover:border-gray-300 dark:hover:border-gray-600">
<span className="flex items-center gap-2 text-sm text-gray-900 dark:text-white">
<Eye className="h-4 w-4 text-gray-600 dark:text-gray-400" />
Show raw parameters
{t('quickSettings.showRawParameters')}
</span>
<input
type="checkbox"
checked={showRawParameters}
onChange={(e) => onShowRawParametersChange(e.target.checked)}
onChange={(e) => setPreference('showRawParameters', e.target.checked)}
className="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 focus:ring-2 dark:focus:ring-blue-400 bg-gray-100 dark:bg-gray-800 checked:bg-blue-600 dark:checked:bg-blue-600"
/>
</label>
@@ -305,29 +303,29 @@ const QuickSettingsPanel = ({
<label className="flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer transition-colors border border-transparent hover:border-gray-300 dark:hover:border-gray-600">
<span className="flex items-center gap-2 text-sm text-gray-900 dark:text-white">
<Brain className="h-4 w-4 text-gray-600 dark:text-gray-400" />
Show thinking
{t('quickSettings.showThinking')}
</span>
<input
type="checkbox"
checked={showThinking}
onChange={(e) => onShowThinkingChange(e.target.checked)}
onChange={(e) => setPreference('showThinking', e.target.checked)}
className="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 focus:ring-2 dark:focus:ring-blue-400 bg-gray-100 dark:bg-gray-800 checked:bg-blue-600 dark:checked:bg-blue-600"
/>
</label>
</div>
{/* View Options */}
<div className="space-y-2">
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">View Options</h4>
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">{t('quickSettings.sections.viewOptions')}</h4>
<label className="flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer transition-colors border border-transparent hover:border-gray-300 dark:hover:border-gray-600">
<span className="flex items-center gap-2 text-sm text-gray-900 dark:text-white">
<ArrowDown className="h-4 w-4 text-gray-600 dark:text-gray-400" />
Auto-scroll to bottom
{t('quickSettings.autoScrollToBottom')}
</span>
<input
type="checkbox"
checked={autoScrollToBottom}
onChange={(e) => onAutoScrollChange(e.target.checked)}
onChange={(e) => setPreference('autoScrollToBottom', e.target.checked)}
className="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 focus:ring-2 dark:focus:ring-blue-400 bg-gray-100 dark:bg-gray-800 checked:bg-blue-600 dark:checked:bg-blue-600"
/>
</label>
@@ -335,28 +333,28 @@ const QuickSettingsPanel = ({
{/* Input Settings */}
<div className="space-y-2">
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">Input Settings</h4>
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">{t('quickSettings.sections.inputSettings')}</h4>
<label className="flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer transition-colors border border-transparent hover:border-gray-300 dark:hover:border-gray-600">
<span className="flex items-center gap-2 text-sm text-gray-900 dark:text-white">
<Languages className="h-4 w-4 text-gray-600 dark:text-gray-400" />
Send by Ctrl+Enter
{t('quickSettings.sendByCtrlEnter')}
</span>
<input
type="checkbox"
checked={sendByCtrlEnter}
onChange={(e) => onSendByCtrlEnterChange(e.target.checked)}
onChange={(e) => setPreference('sendByCtrlEnter', e.target.checked)}
className="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 focus:ring-2 dark:focus:ring-blue-400 bg-gray-100 dark:bg-gray-800 checked:bg-blue-600 dark:checked:bg-blue-600"
/>
</label>
<p className="text-xs text-gray-500 dark:text-gray-400 ml-3">
When enabled, pressing Ctrl+Enter will send the message instead of just Enter. This is useful for IME users to avoid accidental sends.
{t('quickSettings.sendByCtrlEnterDescription')}
</p>
</div>
{/* Whisper Dictation Settings - HIDDEN */}
<div className="space-y-2" style={{ display: 'none' }}>
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">Whisper Dictation</h4>
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">{t('quickSettings.sections.whisperDictation')}</h4>
<div className="space-y-2">
<label className="flex items-start p-3 rounded-lg bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer transition-colors border border-transparent hover:border-gray-300 dark:hover:border-gray-600">
@@ -375,10 +373,10 @@ const QuickSettingsPanel = ({
<div className="ml-3 flex-1">
<span className="flex items-center gap-2 text-sm font-medium text-gray-900 dark:text-white">
<Mic className="h-4 w-4 text-gray-600 dark:text-gray-400" />
Default Mode
{t('quickSettings.whisper.modes.default')}
</span>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Direct transcription of your speech
{t('quickSettings.whisper.modes.defaultDescription')}
</p>
</div>
</label>
@@ -399,10 +397,10 @@ const QuickSettingsPanel = ({
<div className="ml-3 flex-1">
<span className="flex items-center gap-2 text-sm font-medium text-gray-900 dark:text-white">
<Sparkles className="h-4 w-4 text-gray-600 dark:text-gray-400" />
Prompt Enhancement
{t('quickSettings.whisper.modes.prompt')}
</span>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Transform rough ideas into clear, detailed AI prompts
{t('quickSettings.whisper.modes.promptDescription')}
</p>
</div>
</label>
@@ -423,10 +421,10 @@ const QuickSettingsPanel = ({
<div className="ml-3 flex-1">
<span className="flex items-center gap-2 text-sm font-medium text-gray-900 dark:text-white">
<FileText className="h-4 w-4 text-gray-600 dark:text-gray-400" />
Vibe Mode
{t('quickSettings.whisper.modes.vibe')}
</span>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Format ideas as clear agent instructions with details
{t('quickSettings.whisper.modes.vibeDescription')}
</p>
</div>
</label>
@@ -437,7 +435,7 @@ const QuickSettingsPanel = ({
</div>
{/* Backdrop */}
{localIsOpen && (
{isOpen && (
<div
className="fixed inset-0 bg-background/80 backdrop-blur-sm z-30 transition-opacity duration-150 ease-out"
onClick={handleToggle}

View File

@@ -0,0 +1,24 @@
import type { SessionProvider } from '../types/app';
import ClaudeLogo from './ClaudeLogo';
import CodexLogo from './CodexLogo';
import CursorLogo from './CursorLogo';
type SessionProviderLogoProps = {
provider?: SessionProvider | string | null;
className?: string;
};
export default function SessionProviderLogo({
provider = 'claude',
className = 'w-5 h-5',
}: SessionProviderLogoProps) {
if (provider === 'cursor') {
return <CursorLogo className={className} />;
}
if (provider === 'codex') {
return <CodexLogo className={className} />;
}
return <ClaudeLogo className={className} />;
}

View File

@@ -4,9 +4,7 @@ import { Input } from './ui/input';
import { Badge } from './ui/badge';
import { X, Plus, Settings as SettingsIcon, Shield, AlertTriangle, Moon, Sun, Server, Edit3, Trash2, Globe, Terminal, Zap, FolderOpen, LogIn, Key, GitBranch, Check } from 'lucide-react';
import { useTheme } from '../contexts/ThemeContext';
import ClaudeLogo from './ClaudeLogo';
import CursorLogo from './CursorLogo';
import CodexLogo from './CodexLogo';
import { useTranslation } from 'react-i18next';
import CredentialsSettings from './CredentialsSettings';
import GitSettings from './GitSettings';
import TasksSettings from './TasksSettings';
@@ -18,9 +16,11 @@ import AgentListItem from './settings/AgentListItem';
import AccountContent from './settings/AccountContent';
import PermissionsContent from './settings/PermissionsContent';
import McpServersContent from './settings/McpServersContent';
import LanguageSelector from './LanguageSelector';
function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }) {
const { isDarkMode, toggleDarkMode } = useTheme();
const { t } = useTranslation('settings');
const [allowedTools, setAllowedTools] = useState([]);
const [disallowedTools, setDisallowedTools] = useState([]);
const [newAllowedTool, setNewAllowedTool] = useState('');
@@ -947,7 +947,7 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }) {
<div className="flex items-center gap-3">
<SettingsIcon className="w-5 h-5 md:w-6 md:h-6 text-blue-600" />
<h2 className="text-lg md:text-xl font-semibold text-foreground">
Settings
{t('title')}
</h2>
</div>
<Button
@@ -972,7 +972,7 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }) {
: 'border-transparent text-muted-foreground hover:text-foreground'
}`}
>
Agents
{t('mainTabs.agents')}
</button>
<button
onClick={() => setActiveTab('appearance')}
@@ -982,7 +982,7 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }) {
: 'border-transparent text-muted-foreground hover:text-foreground'
}`}
>
Appearance
{t('mainTabs.appearance')}
</button>
<button
onClick={() => setActiveTab('git')}
@@ -993,7 +993,7 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }) {
}`}
>
<GitBranch className="w-4 h-4 inline mr-2" />
Git
{t('mainTabs.git')}
</button>
<button
onClick={() => setActiveTab('api')}
@@ -1004,7 +1004,7 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }) {
}`}
>
<Key className="w-4 h-4 inline mr-2" />
API & Tokens
{t('mainTabs.apiTokens')}
</button>
<button
onClick={() => setActiveTab('tasks')}
@@ -1014,7 +1014,7 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }) {
: 'border-transparent text-muted-foreground hover:text-foreground'
}`}
>
Tasks
{t('mainTabs.tasks')}
</button>
</div>
</div>
@@ -1032,10 +1032,10 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }) {
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-foreground">
Dark Mode
{t('appearanceSettings.darkMode.label')}
</div>
<div className="text-sm text-muted-foreground">
Toggle between light and dark themes
{t('appearanceSettings.darkMode.description')}
</div>
</div>
<button
@@ -1062,16 +1062,21 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }) {
</div>
</div>
{/* Language Selector */}
<div className="space-y-4">
<LanguageSelector />
</div>
{/* Project Sorting */}
<div className="space-y-4">
<div className="bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-foreground">
Project Sorting
{t('appearanceSettings.projectSorting.label')}
</div>
<div className="text-sm text-muted-foreground">
How projects are ordered in the sidebar
{t('appearanceSettings.projectSorting.description')}
</div>
</div>
<select
@@ -1079,8 +1084,8 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }) {
onChange={(e) => setProjectSortOrder(e.target.value)}
className="text-sm bg-gray-50 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 text-gray-900 dark:text-gray-100 rounded-lg focus:ring-blue-500 focus:border-blue-500 p-2 w-32"
>
<option value="name">Alphabetical</option>
<option value="date">Recent Activity</option>
<option value="name">{t('appearanceSettings.projectSorting.alphabetical')}</option>
<option value="date">{t('appearanceSettings.projectSorting.recentActivity')}</option>
</select>
</div>
</div>
@@ -1088,17 +1093,17 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }) {
{/* Code Editor Settings */}
<div className="space-y-4">
<h3 className="text-lg font-semibold text-foreground">Code Editor</h3>
<h3 className="text-lg font-semibold text-foreground">{t('appearanceSettings.codeEditor.title')}</h3>
{/* Editor Theme */}
<div className="bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-foreground">
Editor Theme
{t('appearanceSettings.codeEditor.theme.label')}
</div>
<div className="text-sm text-muted-foreground">
Default theme for the code editor
{t('appearanceSettings.codeEditor.theme.description')}
</div>
</div>
<button
@@ -1129,10 +1134,10 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }) {
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-foreground">
Word Wrap
{t('appearanceSettings.codeEditor.wordWrap.label')}
</div>
<div className="text-sm text-muted-foreground">
Enable word wrapping by default in the editor
{t('appearanceSettings.codeEditor.wordWrap.description')}
</div>
</div>
<button
@@ -1157,10 +1162,10 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }) {
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-foreground">
Show Minimap
{t('appearanceSettings.codeEditor.showMinimap.label')}
</div>
<div className="text-sm text-muted-foreground">
Display a minimap for easier navigation in diff view
{t('appearanceSettings.codeEditor.showMinimap.description')}
</div>
</div>
<button
@@ -1185,10 +1190,10 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }) {
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-foreground">
Show Line Numbers
{t('appearanceSettings.codeEditor.lineNumbers.label')}
</div>
<div className="text-sm text-muted-foreground">
Display line numbers in the editor
{t('appearanceSettings.codeEditor.lineNumbers.description')}
</div>
</div>
<button
@@ -1213,10 +1218,10 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }) {
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-foreground">
Font Size
{t('appearanceSettings.codeEditor.fontSize.label')}
</div>
<div className="text-sm text-muted-foreground">
Editor font size in pixels
{t('appearanceSettings.codeEditor.fontSize.description')}
</div>
</div>
<select
@@ -1313,7 +1318,7 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }) {
: 'border-transparent text-muted-foreground hover:text-foreground'
}`}
>
Account
{t('tabs.account')}
</button>
<button
onClick={() => setSelectedCategory('permissions')}
@@ -1323,7 +1328,7 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }) {
: 'border-transparent text-muted-foreground hover:text-foreground'
}`}
>
Permissions
{t('tabs.permissions')}
</button>
<button
onClick={() => setSelectedCategory('mcp')}
@@ -1333,7 +1338,7 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }) {
: 'border-transparent text-muted-foreground hover:text-foreground'
}`}
>
MCP Servers
{t('tabs.mcpServers')}
</button>
</div>
</div>
@@ -1444,7 +1449,7 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }) {
<div className="bg-background border border-border rounded-lg w-full max-w-2xl max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between p-4 border-b border-border">
<h3 className="text-lg font-medium text-foreground">
{editingMcpServer ? 'Edit MCP Server' : 'Add MCP Server'}
{editingMcpServer ? t('mcpForm.title.edit') : t('mcpForm.title.add')}
</h3>
<Button variant="ghost" size="sm" onClick={resetMcpForm}>
<X className="w-4 h-4" />
@@ -1464,7 +1469,7 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }) {
: 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700'
}`}
>
Form Input
{t('mcpForm.importMode.form')}
</button>
<button
type="button"
@@ -1475,7 +1480,7 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }) {
: 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700'
}`}
>
JSON Import
{t('mcpForm.importMode.json')}
</button>
</div>
)}
@@ -1484,12 +1489,12 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }) {
{mcpFormData.importMode === 'form' && editingMcpServer && (
<div className="bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-700 rounded-lg p-3">
<label className="block text-sm font-medium text-foreground mb-2">
Scope
{t('mcpForm.scope.label')}
</label>
<div className="flex items-center gap-2">
{mcpFormData.scope === 'user' ? <Globe className="w-4 h-4" /> : <FolderOpen className="w-4 h-4" />}
<span className="text-sm">
{mcpFormData.scope === 'user' ? 'User (Global)' : 'Project (Local)'}
{mcpFormData.scope === 'user' ? t('mcpForm.scope.userGlobal') : t('mcpForm.scope.projectLocal')}
</span>
{mcpFormData.scope === 'local' && mcpFormData.projectPath && (
<span className="text-xs text-muted-foreground">
@@ -1498,7 +1503,7 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }) {
)}
</div>
<p className="text-xs text-muted-foreground mt-2">
Scope cannot be changed when editing an existing server
{t('mcpForm.scope.cannotChange')}
</p>
</div>
)}
@@ -1508,7 +1513,7 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }) {
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-foreground mb-2">
Scope *
{t('mcpForm.scope.label')} *
</label>
<div className="flex gap-2">
<button
@@ -1522,7 +1527,7 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }) {
>
<div className="flex items-center justify-center gap-2">
<Globe className="w-4 h-4" />
<span>User (Global)</span>
<span>{t('mcpForm.scope.userGlobal')}</span>
</div>
</button>
<button
@@ -1536,14 +1541,14 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }) {
>
<div className="flex items-center justify-center gap-2">
<FolderOpen className="w-4 h-4" />
<span>Project (Local)</span>
<span>{t('mcpForm.scope.projectLocal')}</span>
</div>
</button>
</div>
<p className="text-xs text-muted-foreground mt-2">
{mcpFormData.scope === 'user'
? 'User scope: Available across all projects on your machine'
: 'Local scope: Only available in the selected project'
{mcpFormData.scope === 'user'
? t('mcpForm.scope.userDescription')
: t('mcpForm.scope.projectDescription')
}
</p>
</div>
@@ -1552,7 +1557,7 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }) {
{mcpFormData.scope === 'local' && !editingMcpServer && (
<div>
<label className="block text-sm font-medium text-foreground mb-2">
Project *
{t('mcpForm.fields.selectProject')} *
</label>
<select
value={mcpFormData.projectPath}
@@ -1560,7 +1565,7 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }) {
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-lg focus:ring-blue-500 focus:border-blue-500"
required={mcpFormData.scope === 'local'}
>
<option value="">Select a project...</option>
<option value="">{t('mcpForm.fields.selectProject')}...</option>
{projects.map(project => (
<option key={project.name} value={project.path || project.fullPath}>
{project.displayName || project.name}
@@ -1569,7 +1574,7 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }) {
</select>
{mcpFormData.projectPath && (
<p className="text-xs text-muted-foreground mt-1">
Path: {mcpFormData.projectPath}
{t('mcpForm.projectPath', { path: mcpFormData.projectPath })}
</p>
)}
</div>
@@ -1581,22 +1586,22 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }) {
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className={mcpFormData.importMode === 'json' ? 'md:col-span-2' : ''}>
<label className="block text-sm font-medium text-foreground mb-2">
Server Name *
{t('mcpForm.fields.serverName')} *
</label>
<Input
value={mcpFormData.name}
onChange={(e) => {
setMcpFormData(prev => ({...prev, name: e.target.value}));
}}
placeholder="my-server"
placeholder={t('mcpForm.placeholders.serverName')}
required
/>
</div>
{mcpFormData.importMode === 'form' && (
<div>
<label className="block text-sm font-medium text-foreground mb-2">
Transport Type *
{t('mcpForm.fields.transportType')} *
</label>
<select
value={mcpFormData.type}
@@ -1618,7 +1623,7 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }) {
{editingMcpServer && mcpFormData.raw && mcpFormData.importMode === 'form' && (
<div className="bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<h4 className="text-sm font-medium text-foreground mb-2">
Configuration Details (from {editingMcpServer.scope === 'global' ? '~/.claude.json' : 'project config'})
{t('mcpForm.configDetails', { configFile: editingMcpServer.scope === 'global' ? '~/.claude.json' : 'project config' })}
</h4>
<pre className="text-xs bg-gray-100 dark:bg-gray-800 p-3 rounded overflow-x-auto">
{JSON.stringify(mcpFormData.raw, null, 2)}
@@ -1631,7 +1636,7 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }) {
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-foreground mb-2">
JSON Configuration *
{t('mcpForm.fields.jsonConfig')} *
</label>
<textarea
value={mcpFormData.jsonInput}
@@ -1643,18 +1648,18 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }) {
const parsed = JSON.parse(e.target.value);
// Basic validation
if (!parsed.type) {
setJsonValidationError('Missing required field: type');
setJsonValidationError(t('mcpForm.validation.missingType'));
} else if (parsed.type === 'stdio' && !parsed.command) {
setJsonValidationError('stdio type requires a command field');
setJsonValidationError(t('mcpForm.validation.stdioRequiresCommand'));
} else if ((parsed.type === 'http' || parsed.type === 'sse') && !parsed.url) {
setJsonValidationError(`${parsed.type} type requires a url field`);
setJsonValidationError(t('mcpForm.validation.httpRequiresUrl', { type: parsed.type }));
} else {
setJsonValidationError('');
}
}
} catch (err) {
if (e.target.value.trim()) {
setJsonValidationError('Invalid JSON format');
setJsonValidationError(t('mcpForm.validation.invalidJson'));
} else {
setJsonValidationError('');
}
@@ -1669,7 +1674,7 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }) {
<p className="text-xs text-red-500 mt-1">{jsonValidationError}</p>
)}
<p className="text-xs text-muted-foreground mt-2">
Paste your MCP server configuration in JSON format. Example formats:
{t('mcpForm.validation.jsonHelp')}
<br /> stdio: {`{"type":"stdio","command":"npx","args":["@upstash/context7-mcp"]}`}
<br /> http/sse: {`{"type":"http","url":"https://api.example.com/mcp"}`}
</p>
@@ -1682,7 +1687,7 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }) {
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-foreground mb-2">
Command *
{t('mcpForm.fields.command')} *
</label>
<Input
value={mcpFormData.config.command}
@@ -1691,10 +1696,10 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }) {
required
/>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-2">
Arguments (one per line)
{t('mcpForm.fields.arguments')}
</label>
<textarea
value={Array.isArray(mcpFormData.config.args) ? mcpFormData.config.args.join('\n') : ''}
@@ -1710,7 +1715,7 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }) {
{mcpFormData.importMode === 'form' && (mcpFormData.type === 'sse' || mcpFormData.type === 'http') && (
<div>
<label className="block text-sm font-medium text-foreground mb-2">
URL *
{t('mcpForm.fields.url')} *
</label>
<Input
value={mcpFormData.config.url}
@@ -1726,7 +1731,7 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }) {
{mcpFormData.importMode === 'form' && (
<div>
<label className="block text-sm font-medium text-foreground mb-2">
Environment Variables (KEY=value, one per line)
{t('mcpForm.fields.envVars')}
</label>
<textarea
value={Object.entries(mcpFormData.config.env || {}).map(([k, v]) => `${k}=${v}`).join('\n')}
@@ -1750,7 +1755,7 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }) {
{mcpFormData.importMode === 'form' && (mcpFormData.type === 'sse' || mcpFormData.type === 'http') && (
<div>
<label className="block text-sm font-medium text-foreground mb-2">
Headers (KEY=value, one per line)
{t('mcpForm.fields.headers')}
</label>
<textarea
value={Object.entries(mcpFormData.config.headers || {}).map(([k, v]) => `${k}=${v}`).join('\n')}
@@ -1774,14 +1779,14 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }) {
<div className="flex justify-end gap-2 pt-4">
<Button type="button" variant="outline" onClick={resetMcpForm}>
Cancel
{t('mcpForm.actions.cancel')}
</Button>
<Button
type="submit"
disabled={mcpLoading}
<Button
type="submit"
disabled={mcpLoading}
className="bg-purple-600 hover:bg-purple-700 disabled:opacity-50"
>
{mcpLoading ? 'Saving...' : (editingMcpServer ? 'Update Server' : 'Add Server')}
{mcpLoading ? t('mcpForm.actions.saving') : (editingMcpServer ? t('mcpForm.actions.updateServer') : t('mcpForm.actions.addServer'))}
</Button>
</div>
</form>
@@ -1795,7 +1800,7 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }) {
<div className="bg-background border border-border rounded-lg w-full max-w-lg max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between p-4 border-b border-border">
<h3 className="text-lg font-medium text-foreground">
{editingCodexMcpServer ? 'Edit MCP Server' : 'Add MCP Server'}
{editingCodexMcpServer ? t('mcpForm.title.edit') : t('mcpForm.title.add')}
</h3>
<Button variant="ghost" size="sm" onClick={resetCodexMcpForm}>
<X className="w-4 h-4" />
@@ -1805,19 +1810,19 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }) {
<form onSubmit={handleCodexMcpSubmit} className="p-4 space-y-4">
<div>
<label className="block text-sm font-medium text-foreground mb-2">
Server Name *
{t('mcpForm.fields.serverName')} *
</label>
<Input
value={codexMcpFormData.name}
onChange={(e) => setCodexMcpFormData(prev => ({...prev, name: e.target.value}))}
placeholder="my-mcp-server"
placeholder={t('mcpForm.placeholders.serverName')}
required
/>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-2">
Command *
{t('mcpForm.fields.command')} *
</label>
<Input
value={codexMcpFormData.config?.command || ''}
@@ -1832,7 +1837,7 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }) {
<div>
<label className="block text-sm font-medium text-foreground mb-2">
Arguments (one per line)
{t('mcpForm.fields.arguments')}
</label>
<textarea
value={(codexMcpFormData.config?.args || []).join('\n')}
@@ -1848,7 +1853,7 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }) {
<div>
<label className="block text-sm font-medium text-foreground mb-2">
Environment Variables (KEY=VALUE, one per line)
{t('mcpForm.fields.envVars')}
</label>
<textarea
value={Object.entries(codexMcpFormData.config?.env || {}).map(([k, v]) => `${k}=${v}`).join('\n')}
@@ -1873,14 +1878,14 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }) {
<div className="flex justify-end gap-2 pt-4 border-t border-border">
<Button type="button" variant="outline" onClick={resetCodexMcpForm}>
Cancel
{t('mcpForm.actions.cancel')}
</Button>
<Button
type="submit"
disabled={codexMcpLoading || !codexMcpFormData.name || !codexMcpFormData.config?.command}
className="bg-green-600 hover:bg-green-700 text-white"
>
{codexMcpLoading ? 'Saving...' : (editingCodexMcpServer ? 'Update Server' : 'Add Server')}
{codexMcpLoading ? t('mcpForm.actions.saving') : (editingCodexMcpServer ? t('mcpForm.actions.updateServer') : t('mcpForm.actions.addServer'))}
</Button>
</div>
</form>
@@ -1911,7 +1916,7 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }) {
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
Settings saved successfully!
{t('saveStatus.success')}
</div>
)}
{saveStatus === 'error' && (
@@ -1919,31 +1924,31 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }) {
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
Failed to save settings
{t('saveStatus.error')}
</div>
)}
</div>
<div className="flex items-center gap-3 order-1 sm:order-2">
<Button
variant="outline"
onClick={onClose}
<Button
variant="outline"
onClick={onClose}
disabled={isSaving}
className="flex-1 sm:flex-none h-10 touch-manipulation"
>
Cancel
{t('footerActions.cancel')}
</Button>
<Button
onClick={saveSettings}
<Button
onClick={saveSettings}
disabled={isSaving}
className="flex-1 sm:flex-none h-10 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 touch-manipulation"
>
{isSaving ? (
<div className="flex items-center gap-2">
<div className="w-4 h-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
Saving...
{t('saveStatus.saving')}
</div>
) : (
'Save Settings'
t('footerActions.save')
)}
</Button>
</div>
@@ -1958,6 +1963,12 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }) {
provider={loginProvider}
project={selectedProject}
onComplete={handleLoginComplete}
isAuthenticated={
loginProvider === 'claude' ? claudeAuthStatus.authenticated :
loginProvider === 'cursor' ? cursorAuthStatus.authenticated :
loginProvider === 'codex' ? codexAuthStatus.authenticated :
false
}
/>
</div>
);

View File

@@ -4,6 +4,8 @@ import { FitAddon } from '@xterm/addon-fit';
import { WebglAddon } from '@xterm/addon-webgl';
import { WebLinksAddon } from '@xterm/addon-web-links';
import '@xterm/xterm/css/xterm.css';
import { useTranslation } from 'react-i18next';
import { IS_PLATFORM } from '../constants/config';
const xtermStyles = `
.xterm .xterm-screen {
@@ -24,7 +26,39 @@ if (typeof document !== 'undefined') {
document.head.appendChild(styleSheet);
}
function fallbackCopyToClipboard(text) {
if (!text || typeof document === 'undefined') return false;
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.setAttribute('readonly', '');
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
textarea.style.pointerEvents = 'none';
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
let copied = false;
try {
copied = document.execCommand('copy');
} catch {
copied = false;
} finally {
document.body.removeChild(textarea);
}
return copied;
}
const CODEX_DEVICE_AUTH_URL = 'https://auth.openai.com/codex/device';
function isCodexLoginCommand(command) {
return typeof command === 'string' && /\bcodex\s+login\b/i.test(command);
}
function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell = false, onProcessComplete, minimal = false, autoConnect = false }) {
const { t } = useTranslation('chat');
const terminalRef = useRef(null);
const terminal = useRef(null);
const fitAddon = useRef(null);
@@ -34,12 +68,16 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
const [isRestarting, setIsRestarting] = useState(false);
const [lastSessionId, setLastSessionId] = useState(null);
const [isConnecting, setIsConnecting] = useState(false);
const [authUrl, setAuthUrl] = useState('');
const [authUrlCopyStatus, setAuthUrlCopyStatus] = useState('idle');
const [isAuthPanelHidden, setIsAuthPanelHidden] = useState(false);
const selectedProjectRef = useRef(selectedProject);
const selectedSessionRef = useRef(selectedSession);
const initialCommandRef = useRef(initialCommand);
const isPlainShellRef = useRef(isPlainShell);
const onProcessCompleteRef = useRef(onProcessComplete);
const authUrlRef = useRef('');
useEffect(() => {
selectedProjectRef.current = selectedProject;
@@ -49,14 +87,49 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
onProcessCompleteRef.current = onProcessComplete;
});
const openAuthUrlInBrowser = useCallback((url = authUrlRef.current) => {
if (!url) return false;
const popup = window.open(url, '_blank', 'noopener,noreferrer');
if (popup) {
try {
popup.opener = null;
} catch {
// Ignore cross-origin restrictions when trying to null opener
}
return true;
}
return false;
}, []);
const copyAuthUrlToClipboard = useCallback(async (url = authUrlRef.current) => {
if (!url) return false;
let copied = false;
try {
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(url);
copied = true;
}
} catch {
copied = false;
}
if (!copied) {
copied = fallbackCopyToClipboard(url);
}
return copied;
}, []);
const connectWebSocket = useCallback(async () => {
if (isConnecting || isConnected) return;
try {
const isPlatform = import.meta.env.VITE_IS_PLATFORM === 'true';
let wsUrl;
if (isPlatform) {
if (IS_PLATFORM) {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
wsUrl = `${protocol}//${window.location.host}/shell`;
} else {
@@ -75,6 +148,10 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
ws.current.onopen = () => {
setIsConnected(true);
setIsConnecting(false);
authUrlRef.current = '';
setAuthUrl('');
setAuthUrlCopyStatus('idle');
setIsAuthPanelHidden(false);
setTimeout(() => {
if (fitAddon.current && terminal.current) {
@@ -117,8 +194,18 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
if (terminal.current) {
terminal.current.write(output);
}
} else if (data.type === 'auth_url' && data.url) {
authUrlRef.current = data.url;
setAuthUrl(data.url);
setAuthUrlCopyStatus('idle');
setIsAuthPanelHidden(false);
} else if (data.type === 'url_open') {
window.open(data.url, '_blank');
if (data.url) {
authUrlRef.current = data.url;
setAuthUrl(data.url);
setAuthUrlCopyStatus('idle');
setIsAuthPanelHidden(false);
}
}
} catch (error) {
console.error('[Shell] Error handling WebSocket message:', error, event.data);
@@ -128,6 +215,8 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
ws.current.onclose = (event) => {
setIsConnected(false);
setIsConnecting(false);
setAuthUrlCopyStatus('idle');
setIsAuthPanelHidden(false);
if (terminal.current) {
terminal.current.clear();
@@ -143,7 +232,7 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
setIsConnected(false);
setIsConnecting(false);
}
}, [isConnecting, isConnected]);
}, [isConnecting, isConnected, openAuthUrlInBrowser]);
const connectToShell = useCallback(() => {
if (!isInitialized || isConnected || isConnecting) return;
@@ -164,6 +253,10 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
setIsConnected(false);
setIsConnecting(false);
authUrlRef.current = '';
setAuthUrl('');
setAuthUrlCopyStatus('idle');
setIsAuthPanelHidden(false);
}, []);
const sessionDisplayName = useMemo(() => {
@@ -199,6 +292,10 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
setIsConnected(false);
setIsInitialized(false);
authUrlRef.current = '';
setAuthUrl('');
setAuthUrlCopyStatus('idle');
setIsAuthPanelHidden(false);
setTimeout(() => {
setIsRestarting(false);
@@ -232,7 +329,7 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
tabStopWidth: 4,
windowsMode: false,
macOptionIsMeta: true,
macOptionClickForcesSelection: false,
macOptionClickForcesSelection: true,
theme: {
background: '#1e1e1e',
foreground: '#d4d4d4',
@@ -270,7 +367,10 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
const webLinksAddon = new WebLinksAddon();
terminal.current.loadAddon(fitAddon.current);
terminal.current.loadAddon(webLinksAddon);
// Disable xterm link auto-detection in minimal (login) mode to avoid partial wrapped URL links.
if (!minimal) {
terminal.current.loadAddon(webLinksAddon);
}
// Note: ClipboardAddon removed - we handle clipboard operations manually in attachCustomKeyEventHandler
try {
@@ -282,12 +382,45 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
terminal.current.open(terminalRef.current);
terminal.current.attachCustomKeyEventHandler((event) => {
if ((event.ctrlKey || event.metaKey) && event.key === 'c' && terminal.current.hasSelection()) {
const activeAuthUrl = isCodexLoginCommand(initialCommandRef.current)
? CODEX_DEVICE_AUTH_URL
: authUrlRef.current;
if (
event.type === 'keydown' &&
minimal &&
isPlainShellRef.current &&
activeAuthUrl &&
!event.ctrlKey &&
!event.metaKey &&
!event.altKey &&
event.key?.toLowerCase() === 'c'
) {
copyAuthUrlToClipboard(activeAuthUrl).catch(() => {});
}
if (
event.type === 'keydown' &&
(event.ctrlKey || event.metaKey) &&
event.key?.toLowerCase() === 'c' &&
terminal.current.hasSelection()
) {
event.preventDefault();
event.stopPropagation();
document.execCommand('copy');
return false;
}
if ((event.ctrlKey || event.metaKey) && event.key === 'v') {
if (
event.type === 'keydown' &&
(event.ctrlKey || event.metaKey) &&
event.key?.toLowerCase() === 'v'
) {
// Block native browser/xterm paste so clipboard data is only sent after
// the explicit clipboard-read flow resolves (avoids duplicate pastes).
event.preventDefault();
event.stopPropagation();
navigator.clipboard.readText().then(text => {
if (ws.current && ws.current.readyState === WebSocket.OPEN) {
ws.current.send(JSON.stringify({
@@ -357,7 +490,7 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
terminal.current = null;
}
};
}, [selectedProject?.path || selectedProject?.fullPath, isRestarting]);
}, [selectedProject?.path || selectedProject?.fullPath, isRestarting, minimal, copyAuthUrlToClipboard]);
useEffect(() => {
if (!autoConnect || !isInitialized || isConnecting || isConnected) return;
@@ -373,17 +506,80 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
</div>
<h3 className="text-lg font-semibold mb-2">Select a Project</h3>
<p>Choose a project to open an interactive shell in that directory</p>
<h3 className="text-lg font-semibold mb-2">{t('shell.selectProject.title')}</h3>
<p>{t('shell.selectProject.description')}</p>
</div>
</div>
);
}
if (minimal) {
const displayAuthUrl = isCodexLoginCommand(initialCommand)
? CODEX_DEVICE_AUTH_URL
: authUrl;
const hasAuthUrl = Boolean(displayAuthUrl);
const showMobileAuthPanel = hasAuthUrl && !isAuthPanelHidden;
const showMobileAuthPanelToggle = hasAuthUrl && isAuthPanelHidden;
return (
<div className="h-full w-full bg-gray-900">
<div className="h-full w-full bg-gray-900 relative">
<div ref={terminalRef} className="h-full w-full focus:outline-none" style={{ outline: 'none' }} />
{showMobileAuthPanel && (
<div className="absolute inset-x-0 bottom-14 z-20 border-t border-gray-700/80 bg-gray-900/95 p-3 backdrop-blur-sm md:hidden">
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-2">
<p className="text-xs text-gray-300">Open or copy the login URL:</p>
<button
type="button"
onClick={() => setIsAuthPanelHidden(true)}
className="rounded bg-gray-700 px-2 py-1 text-[10px] font-medium uppercase tracking-wide text-gray-100 hover:bg-gray-600"
>
Hide
</button>
</div>
<input
type="text"
value={displayAuthUrl}
readOnly
onClick={(event) => event.currentTarget.select()}
className="w-full rounded border border-gray-600 bg-gray-800 px-2 py-1 text-xs text-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500"
aria-label="Authentication URL"
/>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => {
openAuthUrlInBrowser(displayAuthUrl);
}}
className="flex-1 rounded bg-blue-600 px-3 py-2 text-xs font-medium text-white hover:bg-blue-700"
>
Open URL
</button>
<button
type="button"
onClick={async () => {
const copied = await copyAuthUrlToClipboard(displayAuthUrl);
setAuthUrlCopyStatus(copied ? 'copied' : 'failed');
}}
className="flex-1 rounded bg-gray-700 px-3 py-2 text-xs font-medium text-white hover:bg-gray-600"
>
{authUrlCopyStatus === 'copied' ? 'Copied' : 'Copy URL'}
</button>
</div>
</div>
</div>
)}
{showMobileAuthPanelToggle && (
<div className="absolute bottom-14 right-3 z-20 md:hidden">
<button
type="button"
onClick={() => setIsAuthPanelHidden(false)}
className="rounded bg-gray-800/95 px-3 py-2 text-xs font-medium text-gray-100 shadow-lg backdrop-blur-sm hover:bg-gray-700"
>
Show login URL
</button>
</div>
)}
</div>
);
}
@@ -400,13 +596,13 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
</span>
)}
{!selectedSession && (
<span className="text-xs text-gray-400">(New Session)</span>
<span className="text-xs text-gray-400">{t('shell.status.newSession')}</span>
)}
{!isInitialized && (
<span className="text-xs text-yellow-400">(Initializing...)</span>
<span className="text-xs text-yellow-400">{t('shell.status.initializing')}</span>
)}
{isRestarting && (
<span className="text-xs text-blue-400">(Restarting...)</span>
<span className="text-xs text-blue-400">{t('shell.status.restarting')}</span>
)}
</div>
<div className="flex items-center space-x-3">
@@ -414,12 +610,12 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
<button
onClick={disconnectFromShell}
className="px-3 py-1 text-xs bg-red-600 text-white rounded hover:bg-red-700 flex items-center space-x-1"
title="Disconnect from shell"
title={t('shell.actions.disconnectTitle')}
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
<span>Disconnect</span>
<span>{t('shell.actions.disconnect')}</span>
</button>
)}
@@ -427,12 +623,12 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
onClick={restartShell}
disabled={isRestarting || isConnected}
className="text-xs text-gray-400 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-1"
title="Restart Shell (disconnect first)"
title={t('shell.actions.restartTitle')}
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<span>Restart</span>
<span>{t('shell.actions.restart')}</span>
</button>
</div>
</div>
@@ -443,7 +639,7 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
{!isInitialized && (
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-90">
<div className="text-white">Loading terminal...</div>
<div className="text-white">{t('shell.loading')}</div>
</div>
)}
@@ -453,19 +649,19 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
<button
onClick={connectToShell}
className="px-6 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors flex items-center justify-center space-x-2 text-base font-medium w-full sm:w-auto"
title="Connect to shell"
title={t('shell.actions.connectTitle')}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<span>Continue in Shell</span>
<span>{t('shell.actions.connect')}</span>
</button>
<p className="text-gray-400 text-sm mt-3 px-2">
{isPlainShell ?
`Run ${initialCommand || 'command'} in ${selectedProject.displayName}` :
t('shell.runCommand', { command: initialCommand || t('shell.defaultCommand'), projectName: selectedProject.displayName }) :
selectedSession ?
`Resume session: ${sessionDisplayNameLong}...` :
'Start a new Claude session'
t('shell.resumeSession', { displayName: sessionDisplayNameLong }) :
t('shell.startSession')
}
</p>
</div>
@@ -477,12 +673,12 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
<div className="text-center max-w-sm w-full">
<div className="flex items-center justify-center space-x-3 text-yellow-400">
<div className="w-6 h-6 animate-spin rounded-full border-2 border-yellow-400 border-t-transparent"></div>
<span className="text-base font-medium">Connecting to shell...</span>
<span className="text-base font-medium">{t('shell.connecting')}</span>
</div>
<p className="text-gray-400 text-sm mt-3 px-2">
{isPlainShell ?
`Running ${initialCommand || 'command'} in ${selectedProject.displayName}` :
`Starting Claude CLI in ${selectedProject.displayName}`
t('shell.runCommand', { command: initialCommand || t('shell.defaultCommand'), projectName: selectedProject.displayName }) :
t('shell.startCli', { projectName: selectedProject.displayName })
}
</p>
</div>
@@ -493,4 +689,4 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
);
}
export default Shell;
export default Shell;

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,9 @@
import { Zap } from 'lucide-react';
import { useTasksSettings } from '../contexts/TasksSettingsContext';
import { useTranslation } from 'react-i18next';
function TasksSettings() {
const { t } = useTranslation('settings');
const {
tasksEnabled,
setTasksEnabled,
@@ -16,7 +18,7 @@ function TasksSettings() {
<div className="bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="flex items-center gap-3">
<div className="animate-spin w-5 h-5 border-2 border-blue-600 border-t-transparent rounded-full"></div>
<span className="text-sm text-muted-foreground">Checking TaskMaster installation...</span>
<span className="text-sm text-muted-foreground">{t('tasks.checking')}</span>
</div>
</div>
) : (
@@ -32,13 +34,13 @@ function TasksSettings() {
</div>
<div className="flex-1">
<div className="font-medium text-orange-900 dark:text-orange-100 mb-2">
TaskMaster AI CLI Not Installed
{t('tasks.notInstalled.title')}
</div>
<div className="text-sm text-orange-800 dark:text-orange-200 space-y-3">
<p>TaskMaster CLI is required to use task management features. Install it to get started:</p>
<p>{t('tasks.notInstalled.description')}</p>
<div className="bg-orange-100 dark:bg-orange-900/50 rounded-lg p-3 font-mono text-sm">
<code>npm install -g task-master-ai</code>
<code>{t('tasks.notInstalled.installCommand')}</code>
</div>
<div>
@@ -51,7 +53,7 @@ function TasksSettings() {
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 0C4.477 0 0 4.484 0 10.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0110 4.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.203 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.942.359.31.678.921.678 1.856 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0020 10.017C20 4.484 15.522 0 10 0z" clipRule="evenodd" />
</svg>
View on GitHub
{t('tasks.notInstalled.viewOnGitHub')}
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
@@ -59,11 +61,11 @@ function TasksSettings() {
</div>
<div className="space-y-2">
<p className="font-medium">After installation:</p>
<p className="font-medium">{t('tasks.notInstalled.afterInstallation')}</p>
<ol className="list-decimal list-inside space-y-1 text-xs">
<li>Restart this application</li>
<li>TaskMaster features will automatically become available</li>
<li>Use <code className="bg-orange-100 dark:bg-orange-800 px-1 rounded">task-master init</code> in your project directory</li>
<li>{t('tasks.notInstalled.steps.restart')}</li>
<li>{t('tasks.notInstalled.steps.autoAvailable')}</li>
<li>{t('tasks.notInstalled.steps.initCommand')}</li>
</ol>
</div>
</div>
@@ -79,10 +81,10 @@ function TasksSettings() {
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-foreground">
Enable TaskMaster Integration
{t('tasks.settings.enableLabel')}
</div>
<div className="text-sm text-muted-foreground mt-1">
Show TaskMaster tasks, banners, and sidebar indicators across the interface
{t('tasks.settings.enableDescription')}
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer">

View File

@@ -10,12 +10,12 @@ const TodoList = ({ todos, isResult = false }) => {
const getStatusIcon = (status) => {
switch (status) {
case 'completed':
return <CheckCircle2 className="w-4 h-4 text-green-500 dark:text-green-400" />;
return <CheckCircle2 className="w-3.5 h-3.5 text-green-500 dark:text-green-400" />;
case 'in_progress':
return <Clock className="w-4 h-4 text-blue-500 dark:text-blue-400" />;
return <Clock className="w-3.5 h-3.5 text-blue-500 dark:text-blue-400" />;
case 'pending':
default:
return <Circle className="w-4 h-4 text-gray-400 dark:text-gray-500" />;
return <Circle className="w-3.5 h-3.5 text-gray-400 dark:text-gray-500" />;
}
};
@@ -44,38 +44,38 @@ const TodoList = ({ todos, isResult = false }) => {
};
return (
<div className="space-y-3">
<div className="space-y-1.5">
{isResult && (
<div className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
<div className="text-xs font-medium text-gray-600 dark:text-gray-400 mb-1.5">
Todo List ({todos.length} {todos.length === 1 ? 'item' : 'items'})
</div>
)}
{todos.map((todo, index) => (
<div
key={todo.id || `todo-${index}`}
className="flex items-start gap-3 p-3 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm hover:shadow-md dark:shadow-gray-900/50 transition-shadow"
className="flex items-start gap-2 p-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded transition-colors"
>
<div className="flex-shrink-0 mt-0.5">
{getStatusIcon(todo.status)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2 mb-2">
<p className={`text-sm font-medium ${todo.status === 'completed' ? 'line-through text-gray-500 dark:text-gray-400' : 'text-gray-900 dark:text-gray-100'}`}>
<div className="flex items-start justify-between gap-2 mb-0.5">
<p className={`text-xs font-medium ${todo.status === 'completed' ? 'line-through text-gray-500 dark:text-gray-400' : 'text-gray-900 dark:text-gray-100'}`}>
{todo.content}
</p>
<div className="flex gap-1 flex-shrink-0">
<Badge
variant="outline"
className={`text-xs px-2 py-0.5 ${getPriorityColor(todo.priority)}`}
className={`text-[10px] px-1.5 py-px ${getPriorityColor(todo.priority)}`}
>
{todo.priority}
</Badge>
<Badge
variant="outline"
className={`text-xs px-2 py-0.5 ${getStatusColor(todo.status)}`}
className={`text-[10px] px-1.5 py-px ${getStatusColor(todo.status)}`}
>
{todo.status.replace('_', ' ')}
</Badge>
@@ -88,4 +88,4 @@ const TodoList = ({ todos, isResult = false }) => {
);
};
export default TodoList;
export default TodoList;

View File

@@ -0,0 +1,144 @@
import { useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import Sidebar from '../sidebar/view/Sidebar';
import MainContent from '../main-content/view/MainContent';
import MobileNav from '../MobileNav';
import { useWebSocket } from '../../contexts/WebSocketContext';
import { useDeviceSettings } from '../../hooks/useDeviceSettings';
import { useSessionProtection } from '../../hooks/useSessionProtection';
import { useProjectsState } from '../../hooks/useProjectsState';
export default function AppContent() {
const navigate = useNavigate();
const { sessionId } = useParams<{ sessionId?: string }>();
const { t } = useTranslation('common');
const { isMobile } = useDeviceSettings({ trackPWA: false });
const { ws, sendMessage, latestMessage } = useWebSocket();
const {
activeSessions,
processingSessions,
markSessionAsActive,
markSessionAsInactive,
markSessionAsProcessing,
markSessionAsNotProcessing,
replaceTemporarySession,
} = useSessionProtection();
const {
selectedProject,
selectedSession,
activeTab,
sidebarOpen,
isLoadingProjects,
isInputFocused,
externalMessageUpdate,
setActiveTab,
setSidebarOpen,
setIsInputFocused,
setShowSettings,
openSettings,
fetchProjects,
sidebarSharedProps,
} = useProjectsState({
sessionId,
navigate,
latestMessage,
isMobile,
activeSessions,
});
useEffect(() => {
window.refreshProjects = fetchProjects;
return () => {
if (window.refreshProjects === fetchProjects) {
delete window.refreshProjects;
}
};
}, [fetchProjects]);
useEffect(() => {
window.openSettings = openSettings;
return () => {
if (window.openSettings === openSettings) {
delete window.openSettings;
}
};
}, [openSettings]);
return (
<div className="fixed inset-0 flex bg-background">
{!isMobile ? (
<div className="h-full flex-shrink-0 border-r border-border/50">
<Sidebar {...sidebarSharedProps} />
</div>
) : (
<div
className={`fixed inset-0 z-50 flex transition-all duration-150 ease-out ${sidebarOpen ? 'opacity-100 visible' : 'opacity-0 invisible'
}`}
>
<button
className="fixed inset-0 bg-background/60 backdrop-blur-sm transition-opacity duration-150 ease-out"
onClick={(event) => {
event.stopPropagation();
setSidebarOpen(false);
}}
onTouchStart={(event) => {
event.preventDefault();
event.stopPropagation();
setSidebarOpen(false);
}}
aria-label={t('versionUpdate.ariaLabels.closeSidebar')}
/>
<div
className={`relative w-[85vw] max-w-sm sm:w-80 h-full bg-card border-r border-border/40 transform transition-transform duration-150 ease-out ${sidebarOpen ? 'translate-x-0' : '-translate-x-full'
}`}
onClick={(event) => event.stopPropagation()}
onTouchStart={(event) => event.stopPropagation()}
>
<Sidebar {...sidebarSharedProps} />
</div>
</div>
)}
<div className={`flex-1 flex flex-col min-w-0 ${isMobile && !isInputFocused ? 'pb-mobile-nav' : ''}`}>
<MainContent
selectedProject={selectedProject}
selectedSession={selectedSession}
activeTab={activeTab}
setActiveTab={setActiveTab}
ws={ws}
sendMessage={sendMessage}
latestMessage={latestMessage}
isMobile={isMobile}
onMenuClick={() => setSidebarOpen(true)}
isLoading={isLoadingProjects}
onInputFocusChange={setIsInputFocused}
onSessionActive={markSessionAsActive}
onSessionInactive={markSessionAsInactive}
onSessionProcessing={markSessionAsProcessing}
onSessionNotProcessing={markSessionAsNotProcessing}
processingSessions={processingSessions}
onReplaceTemporarySession={replaceTemporarySession}
onNavigateToSession={(targetSessionId: string) => navigate(`/session/${targetSessionId}`)}
onShowSettings={() => setShowSettings(true)}
externalMessageUpdate={externalMessageUpdate}
/>
</div>
{isMobile && (
<MobileNav
activeTab={activeTab}
setActiveTab={setActiveTab}
isInputFocused={isInputFocused}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,44 @@
import { Brain, Zap, Sparkles, Atom } from 'lucide-react';
export const thinkingModes = [
{
id: 'none',
name: 'Standard',
description: 'Regular Claude response',
icon: null,
prefix: '',
color: 'text-gray-600'
},
{
id: 'think',
name: 'Think',
description: 'Basic extended thinking',
icon: Brain,
prefix: 'think',
color: 'text-blue-600'
},
{
id: 'think-hard',
name: 'Think Hard',
description: 'More thorough evaluation',
icon: Zap,
prefix: 'think hard',
color: 'text-purple-600'
},
{
id: 'think-harder',
name: 'Think Harder',
description: 'Deep analysis with alternatives',
icon: Sparkles,
prefix: 'think harder',
color: 'text-indigo-600'
},
{
id: 'ultrathink',
name: 'Ultrathink',
description: 'Maximum thinking budget',
icon: Atom,
prefix: 'ultrathink',
color: 'text-red-600'
}
];

View File

@@ -0,0 +1,957 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type {
ChangeEvent,
ClipboardEvent,
Dispatch,
FormEvent,
KeyboardEvent,
MouseEvent,
SetStateAction,
TouchEvent,
} from 'react';
import { useDropzone } from 'react-dropzone';
import { authenticatedFetch } from '../../../utils/api';
import { thinkingModes } from '../constants/thinkingModes';
import { grantClaudeToolPermission } from '../utils/chatPermissions';
import { safeLocalStorage } from '../utils/chatStorage';
import type {
ChatMessage,
PendingPermissionRequest,
PermissionMode,
} from '../types/types';
import { useFileMentions } from './useFileMentions';
import { type SlashCommand, useSlashCommands } from './useSlashCommands';
import type { Project, ProjectSession, SessionProvider } from '../../../types/app';
import { escapeRegExp } from '../utils/chatFormatting';
type PendingViewSession = {
sessionId: string | null;
startedAt: number;
};
interface UseChatComposerStateArgs {
selectedProject: Project | null;
selectedSession: ProjectSession | null;
currentSessionId: string | null;
provider: SessionProvider;
permissionMode: PermissionMode | string;
cyclePermissionMode: () => void;
cursorModel: string;
claudeModel: string;
codexModel: string;
isLoading: boolean;
canAbortSession: boolean;
tokenBudget: Record<string, unknown> | null;
sendMessage: (message: unknown) => void;
sendByCtrlEnter?: boolean;
onSessionActive?: (sessionId?: string | null) => void;
onInputFocusChange?: (focused: boolean) => void;
onFileOpen?: (filePath: string, diffInfo?: unknown) => void;
onShowSettings?: () => void;
pendingViewSessionRef: { current: PendingViewSession | null };
scrollToBottom: () => void;
setChatMessages: Dispatch<SetStateAction<ChatMessage[]>>;
setSessionMessages?: Dispatch<SetStateAction<any[]>>;
setIsLoading: (loading: boolean) => void;
setCanAbortSession: (canAbort: boolean) => void;
setClaudeStatus: (status: { text: string; tokens: number; can_interrupt: boolean } | null) => void;
setIsUserScrolledUp: (isScrolledUp: boolean) => void;
setPendingPermissionRequests: Dispatch<SetStateAction<PendingPermissionRequest[]>>;
}
interface MentionableFile {
name: string;
path: string;
}
interface CommandExecutionResult {
type: 'builtin' | 'custom';
action?: string;
data?: any;
content?: string;
hasBashCommands?: boolean;
hasFileIncludes?: boolean;
}
const createFakeSubmitEvent = () => {
return { preventDefault: () => undefined } as unknown as FormEvent<HTMLFormElement>;
};
const isTemporarySessionId = (sessionId: string | null | undefined) =>
Boolean(sessionId && sessionId.startsWith('new-session-'));
export function useChatComposerState({
selectedProject,
selectedSession,
currentSessionId,
provider,
permissionMode,
cyclePermissionMode,
cursorModel,
claudeModel,
codexModel,
isLoading,
canAbortSession,
tokenBudget,
sendMessage,
sendByCtrlEnter,
onSessionActive,
onInputFocusChange,
onFileOpen,
onShowSettings,
pendingViewSessionRef,
scrollToBottom,
setChatMessages,
setSessionMessages,
setIsLoading,
setCanAbortSession,
setClaudeStatus,
setIsUserScrolledUp,
setPendingPermissionRequests,
}: UseChatComposerStateArgs) {
const [input, setInput] = useState(() => {
if (typeof window !== 'undefined' && selectedProject) {
return safeLocalStorage.getItem(`draft_input_${selectedProject.name}`) || '';
}
return '';
});
const [attachedImages, setAttachedImages] = useState<File[]>([]);
const [uploadingImages, setUploadingImages] = useState<Map<string, number>>(new Map());
const [imageErrors, setImageErrors] = useState<Map<string, string>>(new Map());
const [isTextareaExpanded, setIsTextareaExpanded] = useState(false);
const [thinkingMode, setThinkingMode] = useState('none');
const textareaRef = useRef<HTMLTextAreaElement>(null);
const inputHighlightRef = useRef<HTMLDivElement>(null);
const handleSubmitRef = useRef<
((event: FormEvent<HTMLFormElement> | MouseEvent | TouchEvent | KeyboardEvent<HTMLTextAreaElement>) => Promise<void>) | null
>(null);
const inputValueRef = useRef(input);
const handleBuiltInCommand = useCallback(
(result: CommandExecutionResult) => {
const { action, data } = result;
switch (action) {
case 'clear':
setChatMessages([]);
setSessionMessages?.([]);
break;
case 'help':
setChatMessages((previous) => [
...previous,
{
type: 'assistant',
content: data.content,
timestamp: Date.now(),
},
]);
break;
case 'model':
setChatMessages((previous) => [
...previous,
{
type: 'assistant',
content: `**Current Model**: ${data.current.model}\n\n**Available Models**:\n\nClaude: ${data.available.claude.join(', ')}\n\nCursor: ${data.available.cursor.join(', ')}`,
timestamp: Date.now(),
},
]);
break;
case 'cost': {
const costMessage = `**Token Usage**: ${data.tokenUsage.used.toLocaleString()} / ${data.tokenUsage.total.toLocaleString()} (${data.tokenUsage.percentage}%)\n\n**Estimated Cost**:\n- Input: $${data.cost.input}\n- Output: $${data.cost.output}\n- **Total**: $${data.cost.total}\n\n**Model**: ${data.model}`;
setChatMessages((previous) => [
...previous,
{ type: 'assistant', content: costMessage, timestamp: Date.now() },
]);
break;
}
case 'status': {
const statusMessage = `**System Status**\n\n- Version: ${data.version}\n- Uptime: ${data.uptime}\n- Model: ${data.model}\n- Provider: ${data.provider}\n- Node.js: ${data.nodeVersion}\n- Platform: ${data.platform}`;
setChatMessages((previous) => [
...previous,
{ type: 'assistant', content: statusMessage, timestamp: Date.now() },
]);
break;
}
case 'memory':
if (data.error) {
setChatMessages((previous) => [
...previous,
{
type: 'assistant',
content: `⚠️ ${data.message}`,
timestamp: Date.now(),
},
]);
} else {
setChatMessages((previous) => [
...previous,
{
type: 'assistant',
content: `📝 ${data.message}\n\nPath: \`${data.path}\``,
timestamp: Date.now(),
},
]);
if (data.exists && onFileOpen) {
onFileOpen(data.path);
}
}
break;
case 'config':
onShowSettings?.();
break;
case 'rewind':
if (data.error) {
setChatMessages((previous) => [
...previous,
{
type: 'assistant',
content: `⚠️ ${data.message}`,
timestamp: Date.now(),
},
]);
} else {
setChatMessages((previous) => previous.slice(0, -data.steps * 2));
setChatMessages((previous) => [
...previous,
{
type: 'assistant',
content: `${data.message}`,
timestamp: Date.now(),
},
]);
}
break;
default:
console.warn('Unknown built-in command action:', action);
}
},
[onFileOpen, onShowSettings, setChatMessages, setSessionMessages],
);
const handleCustomCommand = useCallback(async (result: CommandExecutionResult) => {
const { content, hasBashCommands } = result;
if (hasBashCommands) {
const confirmed = window.confirm(
'This command contains bash commands that will be executed. Do you want to proceed?',
);
if (!confirmed) {
setChatMessages((previous) => [
...previous,
{
type: 'assistant',
content: '❌ Command execution cancelled',
timestamp: Date.now(),
},
]);
return;
}
}
const commandContent = content || '';
setInput(commandContent);
inputValueRef.current = commandContent;
// Defer submit to next tick so the command text is reflected in UI before dispatching.
setTimeout(() => {
if (handleSubmitRef.current) {
handleSubmitRef.current(createFakeSubmitEvent());
}
}, 0);
}, [setChatMessages]);
const executeCommand = useCallback(
async (command: SlashCommand) => {
if (!command || !selectedProject) {
return;
}
try {
const commandMatch = input.match(new RegExp(`${escapeRegExp(command.name)}\\s*(.*)`));
const args =
commandMatch && commandMatch[1] ? commandMatch[1].trim().split(/\s+/) : [];
const context = {
projectPath: selectedProject.fullPath || selectedProject.path,
projectName: selectedProject.name,
sessionId: currentSessionId,
provider,
model: provider === 'cursor' ? cursorModel : provider === 'codex' ? codexModel : claudeModel,
tokenUsage: tokenBudget,
};
const response = await authenticatedFetch('/api/commands/execute', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
commandName: command.name,
commandPath: command.path,
args,
context,
}),
});
if (!response.ok) {
let errorMessage = `Failed to execute command (${response.status})`;
try {
const errorData = await response.json();
errorMessage = errorData?.message || errorData?.error || errorMessage;
} catch {
// Ignore JSON parse failures and use fallback message.
}
throw new Error(errorMessage);
}
const result = (await response.json()) as CommandExecutionResult;
if (result.type === 'builtin') {
handleBuiltInCommand(result);
setInput('');
inputValueRef.current = '';
} else if (result.type === 'custom') {
await handleCustomCommand(result);
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
console.error('Error executing command:', error);
setChatMessages((previous) => [
...previous,
{
type: 'assistant',
content: `Error executing command: ${message}`,
timestamp: Date.now(),
},
]);
}
},
[
claudeModel,
codexModel,
currentSessionId,
cursorModel,
handleBuiltInCommand,
handleCustomCommand,
input,
provider,
selectedProject,
setChatMessages,
tokenBudget,
],
);
const {
slashCommandsCount,
filteredCommands,
frequentCommands,
commandQuery,
showCommandMenu,
selectedCommandIndex,
resetCommandMenuState,
handleCommandSelect,
handleToggleCommandMenu,
handleCommandInputChange,
handleCommandMenuKeyDown,
} = useSlashCommands({
selectedProject,
input,
setInput,
textareaRef,
onExecuteCommand: executeCommand,
});
const {
showFileDropdown,
filteredFiles,
selectedFileIndex,
renderInputWithMentions,
selectFile,
setCursorPosition,
handleFileMentionsKeyDown,
} = useFileMentions({
selectedProject,
input,
setInput,
textareaRef,
});
const syncInputOverlayScroll = useCallback((target: HTMLTextAreaElement) => {
if (!inputHighlightRef.current || !target) {
return;
}
inputHighlightRef.current.scrollTop = target.scrollTop;
inputHighlightRef.current.scrollLeft = target.scrollLeft;
}, []);
const handleImageFiles = useCallback((files: File[]) => {
const validFiles = files.filter((file) => {
try {
if (!file || typeof file !== 'object') {
console.warn('Invalid file object:', file);
return false;
}
if (!file.type || !file.type.startsWith('image/')) {
return false;
}
if (!file.size || file.size > 5 * 1024 * 1024) {
const fileName = file.name || 'Unknown file';
setImageErrors((previous) => {
const next = new Map(previous);
next.set(fileName, 'File too large (max 5MB)');
return next;
});
return false;
}
return true;
} catch (error) {
console.error('Error validating file:', error, file);
return false;
}
});
if (validFiles.length > 0) {
setAttachedImages((previous) => [...previous, ...validFiles].slice(0, 5));
}
}, []);
const handlePaste = useCallback(
(event: ClipboardEvent<HTMLTextAreaElement>) => {
const items = Array.from(event.clipboardData.items);
items.forEach((item) => {
if (!item.type.startsWith('image/')) {
return;
}
const file = item.getAsFile();
if (file) {
handleImageFiles([file]);
}
});
if (items.length === 0 && event.clipboardData.files.length > 0) {
const files = Array.from(event.clipboardData.files);
const imageFiles = files.filter((file) => file.type.startsWith('image/'));
if (imageFiles.length > 0) {
handleImageFiles(imageFiles);
}
}
},
[handleImageFiles],
);
const { getRootProps, getInputProps, isDragActive, open } = useDropzone({
accept: {
'image/*': ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'],
},
maxSize: 5 * 1024 * 1024,
maxFiles: 5,
onDrop: handleImageFiles,
noClick: true,
noKeyboard: true,
});
const handleSubmit = useCallback(
async (
event: FormEvent<HTMLFormElement> | MouseEvent | TouchEvent | KeyboardEvent<HTMLTextAreaElement>,
) => {
event.preventDefault();
const currentInput = inputValueRef.current;
if (!currentInput.trim() || isLoading || !selectedProject) {
return;
}
let messageContent = currentInput;
const selectedThinkingMode = thinkingModes.find((mode: { id: string; prefix?: string }) => mode.id === thinkingMode);
if (selectedThinkingMode && selectedThinkingMode.prefix) {
messageContent = `${selectedThinkingMode.prefix}: ${currentInput}`;
}
let uploadedImages: unknown[] = [];
if (attachedImages.length > 0) {
const formData = new FormData();
attachedImages.forEach((file) => {
formData.append('images', file);
});
try {
const response = await authenticatedFetch(`/api/projects/${selectedProject.name}/upload-images`, {
method: 'POST',
headers: {},
body: formData,
});
if (!response.ok) {
throw new Error('Failed to upload images');
}
const result = await response.json();
uploadedImages = result.images;
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
console.error('Image upload failed:', error);
setChatMessages((previous) => [
...previous,
{
type: 'error',
content: `Failed to upload images: ${message}`,
timestamp: new Date(),
},
]);
return;
}
}
const userMessage: ChatMessage = {
type: 'user',
content: currentInput,
images: uploadedImages as any,
timestamp: new Date(),
};
setChatMessages((previous) => [...previous, userMessage]);
setIsLoading(true);
setCanAbortSession(true);
setClaudeStatus({
text: 'Processing',
tokens: 0,
can_interrupt: true,
});
setIsUserScrolledUp(false);
setTimeout(() => scrollToBottom(), 100);
const effectiveSessionId =
currentSessionId || selectedSession?.id || sessionStorage.getItem('cursorSessionId');
const sessionToActivate = effectiveSessionId || `new-session-${Date.now()}`;
if (!effectiveSessionId && !selectedSession?.id) {
if (typeof window !== 'undefined') {
// Reset stale pending IDs from previous interrupted runs before creating a new one.
sessionStorage.removeItem('pendingSessionId');
}
pendingViewSessionRef.current = { sessionId: null, startedAt: Date.now() };
}
onSessionActive?.(sessionToActivate);
const getToolsSettings = () => {
try {
const settingsKey =
provider === 'cursor'
? 'cursor-tools-settings'
: provider === 'codex'
? 'codex-settings'
: 'claude-settings';
const savedSettings = safeLocalStorage.getItem(settingsKey);
if (savedSettings) {
return JSON.parse(savedSettings);
}
} catch (error) {
console.error('Error loading tools settings:', error);
}
return {
allowedTools: [],
disallowedTools: [],
skipPermissions: false,
};
};
const toolsSettings = getToolsSettings();
const resolvedProjectPath = selectedProject.fullPath || selectedProject.path || '';
if (provider === 'cursor') {
sendMessage({
type: 'cursor-command',
command: messageContent,
sessionId: effectiveSessionId,
options: {
cwd: resolvedProjectPath,
projectPath: resolvedProjectPath,
sessionId: effectiveSessionId,
resume: Boolean(effectiveSessionId),
model: cursorModel,
skipPermissions: toolsSettings?.skipPermissions || false,
toolsSettings,
},
});
} else if (provider === 'codex') {
sendMessage({
type: 'codex-command',
command: messageContent,
sessionId: effectiveSessionId,
options: {
cwd: resolvedProjectPath,
projectPath: resolvedProjectPath,
sessionId: effectiveSessionId,
resume: Boolean(effectiveSessionId),
model: codexModel,
permissionMode: permissionMode === 'plan' ? 'default' : permissionMode,
},
});
} else {
sendMessage({
type: 'claude-command',
command: messageContent,
options: {
projectPath: resolvedProjectPath,
cwd: resolvedProjectPath,
sessionId: effectiveSessionId,
resume: Boolean(effectiveSessionId),
toolsSettings,
permissionMode,
model: claudeModel,
images: uploadedImages,
},
});
}
setInput('');
inputValueRef.current = '';
resetCommandMenuState();
setAttachedImages([]);
setUploadingImages(new Map());
setImageErrors(new Map());
setIsTextareaExpanded(false);
setThinkingMode('none');
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
}
safeLocalStorage.removeItem(`draft_input_${selectedProject.name}`);
},
[
attachedImages,
claudeModel,
codexModel,
currentSessionId,
cursorModel,
isLoading,
onSessionActive,
pendingViewSessionRef,
permissionMode,
provider,
resetCommandMenuState,
scrollToBottom,
selectedProject,
selectedSession?.id,
sendMessage,
setCanAbortSession,
setChatMessages,
setClaudeStatus,
setIsLoading,
setIsUserScrolledUp,
thinkingMode,
],
);
useEffect(() => {
handleSubmitRef.current = handleSubmit;
}, [handleSubmit]);
useEffect(() => {
inputValueRef.current = input;
}, [input]);
useEffect(() => {
if (!selectedProject) {
return;
}
const savedInput = safeLocalStorage.getItem(`draft_input_${selectedProject.name}`) || '';
setInput((previous) => {
const next = previous === savedInput ? previous : savedInput;
inputValueRef.current = next;
return next;
});
}, [selectedProject?.name]);
useEffect(() => {
if (!selectedProject) {
return;
}
if (input !== '') {
safeLocalStorage.setItem(`draft_input_${selectedProject.name}`, input);
} else {
safeLocalStorage.removeItem(`draft_input_${selectedProject.name}`);
}
}, [input, selectedProject]);
useEffect(() => {
if (!textareaRef.current) {
return;
}
// Re-run when input changes so restored drafts get the same autosize behavior as typed text.
textareaRef.current.style.height = 'auto';
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
const lineHeight = parseInt(window.getComputedStyle(textareaRef.current).lineHeight);
const expanded = textareaRef.current.scrollHeight > lineHeight * 2;
setIsTextareaExpanded(expanded);
}, [input]);
useEffect(() => {
if (!textareaRef.current || input.trim()) {
return;
}
textareaRef.current.style.height = 'auto';
setIsTextareaExpanded(false);
}, [input]);
const handleInputChange = useCallback(
(event: ChangeEvent<HTMLTextAreaElement>) => {
const newValue = event.target.value;
const cursorPos = event.target.selectionStart;
setInput(newValue);
inputValueRef.current = newValue;
setCursorPosition(cursorPos);
if (!newValue.trim()) {
event.target.style.height = 'auto';
setIsTextareaExpanded(false);
resetCommandMenuState();
return;
}
handleCommandInputChange(newValue, cursorPos);
},
[handleCommandInputChange, resetCommandMenuState, setCursorPosition],
);
const handleKeyDown = useCallback(
(event: KeyboardEvent<HTMLTextAreaElement>) => {
if (handleCommandMenuKeyDown(event)) {
return;
}
if (handleFileMentionsKeyDown(event)) {
return;
}
if (event.key === 'Tab' && !showFileDropdown && !showCommandMenu) {
event.preventDefault();
cyclePermissionMode();
return;
}
if (event.key === 'Enter') {
if (event.nativeEvent.isComposing) {
return;
}
if ((event.ctrlKey || event.metaKey) && !event.shiftKey) {
event.preventDefault();
handleSubmit(event);
} else if (!event.shiftKey && !event.ctrlKey && !event.metaKey && !sendByCtrlEnter) {
event.preventDefault();
handleSubmit(event);
}
}
},
[
cyclePermissionMode,
handleCommandMenuKeyDown,
handleFileMentionsKeyDown,
handleSubmit,
sendByCtrlEnter,
showCommandMenu,
showFileDropdown,
],
);
const handleTextareaClick = useCallback(
(event: MouseEvent<HTMLTextAreaElement>) => {
setCursorPosition(event.currentTarget.selectionStart);
},
[setCursorPosition],
);
const handleTextareaInput = useCallback(
(event: FormEvent<HTMLTextAreaElement>) => {
const target = event.currentTarget;
target.style.height = 'auto';
target.style.height = `${target.scrollHeight}px`;
setCursorPosition(target.selectionStart);
syncInputOverlayScroll(target);
const lineHeight = parseInt(window.getComputedStyle(target).lineHeight);
setIsTextareaExpanded(target.scrollHeight > lineHeight * 2);
},
[setCursorPosition, syncInputOverlayScroll],
);
const handleClearInput = useCallback(() => {
setInput('');
inputValueRef.current = '';
resetCommandMenuState();
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
textareaRef.current.focus();
}
setIsTextareaExpanded(false);
}, [resetCommandMenuState]);
const handleAbortSession = useCallback(() => {
if (!canAbortSession) {
return;
}
const pendingSessionId =
typeof window !== 'undefined' ? sessionStorage.getItem('pendingSessionId') : null;
const cursorSessionId =
typeof window !== 'undefined' ? sessionStorage.getItem('cursorSessionId') : null;
const candidateSessionIds = [
currentSessionId,
pendingViewSessionRef.current?.sessionId || null,
pendingSessionId,
provider === 'cursor' ? cursorSessionId : null,
selectedSession?.id || null,
];
const targetSessionId =
candidateSessionIds.find((sessionId) => Boolean(sessionId) && !isTemporarySessionId(sessionId)) || null;
if (!targetSessionId) {
console.warn('Abort requested but no concrete session ID is available yet.');
return;
}
sendMessage({
type: 'abort-session',
sessionId: targetSessionId,
provider,
});
}, [canAbortSession, currentSessionId, pendingViewSessionRef, provider, selectedSession?.id, sendMessage]);
const handleTranscript = useCallback((text: string) => {
if (!text.trim()) {
return;
}
setInput((previousInput) => {
const newInput = previousInput.trim() ? `${previousInput} ${text}` : text;
inputValueRef.current = newInput;
setTimeout(() => {
if (!textareaRef.current) {
return;
}
textareaRef.current.style.height = 'auto';
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
const lineHeight = parseInt(window.getComputedStyle(textareaRef.current).lineHeight);
setIsTextareaExpanded(textareaRef.current.scrollHeight > lineHeight * 2);
}, 0);
return newInput;
});
}, []);
const handleGrantToolPermission = useCallback(
(suggestion: { entry: string; toolName: string }) => {
if (!suggestion || provider !== 'claude') {
return { success: false };
}
return grantClaudeToolPermission(suggestion.entry);
},
[provider],
);
const handlePermissionDecision = useCallback(
(
requestIds: string | string[],
decision: { allow?: boolean; message?: string; rememberEntry?: string | null; updatedInput?: unknown },
) => {
const ids = Array.isArray(requestIds) ? requestIds : [requestIds];
const validIds = ids.filter(Boolean);
if (validIds.length === 0) {
return;
}
validIds.forEach((requestId) => {
sendMessage({
type: 'claude-permission-response',
requestId,
allow: Boolean(decision?.allow),
updatedInput: decision?.updatedInput,
message: decision?.message,
rememberEntry: decision?.rememberEntry,
});
});
setPendingPermissionRequests((previous) => {
const next = previous.filter((request) => !validIds.includes(request.requestId));
if (next.length === 0) {
setClaudeStatus(null);
}
return next;
});
},
[sendMessage, setClaudeStatus, setPendingPermissionRequests],
);
const handleInputFocusChange = useCallback(
(focused: boolean) => {
onInputFocusChange?.(focused);
},
[onInputFocusChange],
);
return {
input,
setInput,
textareaRef,
inputHighlightRef,
isTextareaExpanded,
thinkingMode,
setThinkingMode,
slashCommandsCount,
filteredCommands,
frequentCommands,
commandQuery,
showCommandMenu,
selectedCommandIndex,
resetCommandMenuState,
handleCommandSelect,
handleToggleCommandMenu,
showFileDropdown,
filteredFiles: filteredFiles as MentionableFile[],
selectedFileIndex,
renderInputWithMentions,
selectFile,
attachedImages,
setAttachedImages,
uploadingImages,
imageErrors,
getRootProps,
getInputProps,
isDragActive,
openImagePicker: open,
handleSubmit,
handleInputChange,
handleKeyDown,
handlePaste,
handleTextareaClick,
handleTextareaInput,
syncInputOverlayScroll,
handleClearInput,
handleAbortSession,
handleTranscript,
handlePermissionDecision,
handleGrantToolPermission,
handleInputFocusChange,
};
}

View File

@@ -0,0 +1,114 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { authenticatedFetch } from '../../../utils/api';
import { CLAUDE_MODELS, CODEX_MODELS, CURSOR_MODELS } from '../../../../shared/modelConstants';
import type { PendingPermissionRequest, PermissionMode, Provider } from '../types/types';
import type { ProjectSession, SessionProvider } from '../../../types/app';
interface UseChatProviderStateArgs {
selectedSession: ProjectSession | null;
}
export function useChatProviderState({ selectedSession }: UseChatProviderStateArgs) {
const [permissionMode, setPermissionMode] = useState<PermissionMode>('default');
const [pendingPermissionRequests, setPendingPermissionRequests] = useState<PendingPermissionRequest[]>([]);
const [provider, setProvider] = useState<SessionProvider>(() => {
return (localStorage.getItem('selected-provider') as SessionProvider) || 'claude';
});
const [cursorModel, setCursorModel] = useState<string>(() => {
return localStorage.getItem('cursor-model') || CURSOR_MODELS.DEFAULT;
});
const [claudeModel, setClaudeModel] = useState<string>(() => {
return localStorage.getItem('claude-model') || CLAUDE_MODELS.DEFAULT;
});
const [codexModel, setCodexModel] = useState<string>(() => {
return localStorage.getItem('codex-model') || CODEX_MODELS.DEFAULT;
});
const lastProviderRef = useRef(provider);
useEffect(() => {
if (!selectedSession?.id) {
return;
}
const savedMode = localStorage.getItem(`permissionMode-${selectedSession.id}`);
setPermissionMode((savedMode as PermissionMode) || 'default');
}, [selectedSession?.id]);
useEffect(() => {
if (!selectedSession?.__provider || selectedSession.__provider === provider) {
return;
}
setProvider(selectedSession.__provider);
localStorage.setItem('selected-provider', selectedSession.__provider);
}, [provider, selectedSession]);
useEffect(() => {
if (lastProviderRef.current === provider) {
return;
}
setPendingPermissionRequests([]);
lastProviderRef.current = provider;
}, [provider]);
useEffect(() => {
setPendingPermissionRequests((previous) =>
previous.filter((request) => !request.sessionId || request.sessionId === selectedSession?.id),
);
}, [selectedSession?.id]);
useEffect(() => {
if (provider !== 'cursor') {
return;
}
authenticatedFetch('/api/cursor/config')
.then((response) => response.json())
.then((data) => {
if (!data.success || !data.config?.model?.modelId) {
return;
}
const modelId = data.config.model.modelId as string;
if (!localStorage.getItem('cursor-model')) {
setCursorModel(modelId);
}
})
.catch((error) => {
console.error('Error loading Cursor config:', error);
});
}, [provider]);
const cyclePermissionMode = useCallback(() => {
const modes: PermissionMode[] =
provider === 'codex'
? ['default', 'acceptEdits', 'bypassPermissions']
: ['default', 'acceptEdits', 'bypassPermissions', 'plan'];
const currentIndex = modes.indexOf(permissionMode);
const nextIndex = (currentIndex + 1) % modes.length;
const nextMode = modes[nextIndex];
setPermissionMode(nextMode);
if (selectedSession?.id) {
localStorage.setItem(`permissionMode-${selectedSession.id}`, nextMode);
}
}, [permissionMode, provider, selectedSession?.id]);
return {
provider,
setProvider,
cursorModel,
setCursorModel,
claudeModel,
setClaudeModel,
codexModel,
setCodexModel,
permissionMode,
setPermissionMode,
pendingPermissionRequests,
setPendingPermissionRequests,
cyclePermissionMode,
};
}

View File

@@ -0,0 +1,956 @@
import { useEffect, useRef } from 'react';
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
import { decodeHtmlEntities, formatUsageLimitText } from '../utils/chatFormatting';
import { safeLocalStorage } from '../utils/chatStorage';
import type { ChatMessage, PendingPermissionRequest } from '../types/types';
import type { Project, ProjectSession, SessionProvider } from '../../../types/app';
type PendingViewSession = {
sessionId: string | null;
startedAt: number;
};
type LatestChatMessage = {
type?: string;
data?: any;
sessionId?: string;
requestId?: string;
toolName?: string;
input?: unknown;
context?: unknown;
error?: string;
tool?: string;
exitCode?: number;
isProcessing?: boolean;
actualSessionId?: string;
[key: string]: any;
};
interface UseChatRealtimeHandlersArgs {
latestMessage: LatestChatMessage | null;
provider: SessionProvider;
selectedProject: Project | null;
selectedSession: ProjectSession | null;
currentSessionId: string | null;
setCurrentSessionId: (sessionId: string | null) => void;
setChatMessages: Dispatch<SetStateAction<ChatMessage[]>>;
setIsLoading: (loading: boolean) => void;
setCanAbortSession: (canAbort: boolean) => void;
setClaudeStatus: (status: { text: string; tokens: number; can_interrupt: boolean } | null) => void;
setTokenBudget: (budget: Record<string, unknown> | null) => void;
setIsSystemSessionChange: (isSystemSessionChange: boolean) => void;
setPendingPermissionRequests: Dispatch<SetStateAction<PendingPermissionRequest[]>>;
pendingViewSessionRef: MutableRefObject<PendingViewSession | null>;
streamBufferRef: MutableRefObject<string>;
streamTimerRef: MutableRefObject<number | null>;
onSessionInactive?: (sessionId?: string | null) => void;
onSessionProcessing?: (sessionId?: string | null) => void;
onSessionNotProcessing?: (sessionId?: string | null) => void;
onReplaceTemporarySession?: (sessionId?: string | null) => void;
onNavigateToSession?: (sessionId: string) => void;
}
const appendStreamingChunk = (
setChatMessages: Dispatch<SetStateAction<ChatMessage[]>>,
chunk: string,
newline = false,
) => {
if (!chunk) {
return;
}
setChatMessages((previous) => {
const updated = [...previous];
const lastIndex = updated.length - 1;
const last = updated[lastIndex];
if (last && last.type === 'assistant' && !last.isToolUse && last.isStreaming) {
const nextContent = newline
? last.content
? `${last.content}\n${chunk}`
: chunk
: `${last.content || ''}${chunk}`;
// Clone the message instead of mutating in place so React can reliably detect state updates.
updated[lastIndex] = { ...last, content: nextContent };
} else {
updated.push({ type: 'assistant', content: chunk, timestamp: new Date(), isStreaming: true });
}
return updated;
});
};
const finalizeStreamingMessage = (setChatMessages: Dispatch<SetStateAction<ChatMessage[]>>) => {
setChatMessages((previous) => {
const updated = [...previous];
const lastIndex = updated.length - 1;
const last = updated[lastIndex];
if (last && last.type === 'assistant' && last.isStreaming) {
// Clone the message instead of mutating in place so React can reliably detect state updates.
updated[lastIndex] = { ...last, isStreaming: false };
}
return updated;
});
};
export function useChatRealtimeHandlers({
latestMessage,
provider,
selectedProject,
selectedSession,
currentSessionId,
setCurrentSessionId,
setChatMessages,
setIsLoading,
setCanAbortSession,
setClaudeStatus,
setTokenBudget,
setIsSystemSessionChange,
setPendingPermissionRequests,
pendingViewSessionRef,
streamBufferRef,
streamTimerRef,
onSessionInactive,
onSessionProcessing,
onSessionNotProcessing,
onReplaceTemporarySession,
onNavigateToSession,
}: UseChatRealtimeHandlersArgs) {
const lastProcessedMessageRef = useRef<LatestChatMessage | null>(null);
useEffect(() => {
if (!latestMessage) {
return;
}
// Guard against duplicate processing when dependency updates occur without a new message object.
if (lastProcessedMessageRef.current === latestMessage) {
return;
}
lastProcessedMessageRef.current = latestMessage;
const messageData = latestMessage.data?.message || latestMessage.data;
const structuredMessageData =
messageData && typeof messageData === 'object' ? (messageData as Record<string, any>) : null;
const rawStructuredData =
latestMessage.data && typeof latestMessage.data === 'object'
? (latestMessage.data as Record<string, any>)
: null;
const globalMessageTypes = ['projects_updated', 'taskmaster-project-updated', 'session-created'];
const isGlobalMessage = globalMessageTypes.includes(String(latestMessage.type));
const lifecycleMessageTypes = new Set([
'claude-complete',
'codex-complete',
'cursor-result',
'session-aborted',
'claude-error',
'cursor-error',
'codex-error',
]);
const isClaudeSystemInit =
latestMessage.type === 'claude-response' &&
structuredMessageData &&
structuredMessageData.type === 'system' &&
structuredMessageData.subtype === 'init';
const isCursorSystemInit =
latestMessage.type === 'cursor-system' &&
rawStructuredData &&
rawStructuredData.type === 'system' &&
rawStructuredData.subtype === 'init';
const systemInitSessionId = isClaudeSystemInit
? structuredMessageData?.session_id
: isCursorSystemInit
? rawStructuredData?.session_id
: null;
const activeViewSessionId =
selectedSession?.id || currentSessionId || pendingViewSessionRef.current?.sessionId || null;
const isSystemInitForView =
systemInitSessionId && (!activeViewSessionId || systemInitSessionId === activeViewSessionId);
const shouldBypassSessionFilter = isGlobalMessage || Boolean(isSystemInitForView);
const isUnscopedError =
!latestMessage.sessionId &&
pendingViewSessionRef.current &&
!pendingViewSessionRef.current.sessionId &&
(latestMessage.type === 'claude-error' ||
latestMessage.type === 'cursor-error' ||
latestMessage.type === 'codex-error');
const handleBackgroundLifecycle = (sessionId?: string) => {
if (!sessionId) {
return;
}
onSessionInactive?.(sessionId);
onSessionNotProcessing?.(sessionId);
};
const collectSessionIds = (...sessionIds: Array<string | null | undefined>) =>
Array.from(
new Set(
sessionIds.filter((sessionId): sessionId is string => typeof sessionId === 'string' && sessionId.length > 0),
),
);
const clearLoadingIndicators = () => {
setIsLoading(false);
setCanAbortSession(false);
setClaudeStatus(null);
};
const markSessionsAsCompleted = (...sessionIds: Array<string | null | undefined>) => {
const normalizedSessionIds = collectSessionIds(...sessionIds);
normalizedSessionIds.forEach((sessionId) => {
onSessionInactive?.(sessionId);
onSessionNotProcessing?.(sessionId);
});
};
if (!shouldBypassSessionFilter) {
if (!activeViewSessionId) {
if (latestMessage.sessionId && lifecycleMessageTypes.has(String(latestMessage.type))) {
handleBackgroundLifecycle(latestMessage.sessionId);
}
if (!isUnscopedError) {
return;
}
}
if (!latestMessage.sessionId && !isUnscopedError) {
return;
}
if (latestMessage.sessionId !== activeViewSessionId) {
if (latestMessage.sessionId && lifecycleMessageTypes.has(String(latestMessage.type))) {
handleBackgroundLifecycle(latestMessage.sessionId);
}
console.log(
'Skipping message for different session:',
latestMessage.sessionId,
'current:',
activeViewSessionId,
);
return;
}
}
switch (latestMessage.type) {
case 'session-created':
if (latestMessage.sessionId && !currentSessionId) {
sessionStorage.setItem('pendingSessionId', latestMessage.sessionId);
if (pendingViewSessionRef.current && !pendingViewSessionRef.current.sessionId) {
pendingViewSessionRef.current.sessionId = latestMessage.sessionId;
}
setIsSystemSessionChange(true);
onReplaceTemporarySession?.(latestMessage.sessionId);
setPendingPermissionRequests((previous) =>
previous.map((request) =>
request.sessionId ? request : { ...request, sessionId: latestMessage.sessionId },
),
);
}
break;
case 'token-budget':
if (latestMessage.data) {
setTokenBudget(latestMessage.data);
}
break;
case 'claude-response': {
if (messageData && typeof messageData === 'object' && messageData.type) {
if (messageData.type === 'content_block_delta' && messageData.delta?.text) {
const decodedText = decodeHtmlEntities(messageData.delta.text);
streamBufferRef.current += decodedText;
if (!streamTimerRef.current) {
streamTimerRef.current = window.setTimeout(() => {
const chunk = streamBufferRef.current;
streamBufferRef.current = '';
streamTimerRef.current = null;
appendStreamingChunk(setChatMessages, chunk, false);
}, 100);
}
return;
}
if (messageData.type === 'content_block_stop') {
if (streamTimerRef.current) {
clearTimeout(streamTimerRef.current);
streamTimerRef.current = null;
}
const chunk = streamBufferRef.current;
streamBufferRef.current = '';
appendStreamingChunk(setChatMessages, chunk, false);
finalizeStreamingMessage(setChatMessages);
return;
}
}
if (
structuredMessageData?.type === 'system' &&
structuredMessageData.subtype === 'init' &&
structuredMessageData.session_id &&
currentSessionId &&
structuredMessageData.session_id !== currentSessionId &&
isSystemInitForView
) {
console.log('Claude CLI session duplication detected:', {
originalSession: currentSessionId,
newSession: structuredMessageData.session_id,
});
setIsSystemSessionChange(true);
onNavigateToSession?.(structuredMessageData.session_id);
return;
}
if (
structuredMessageData?.type === 'system' &&
structuredMessageData.subtype === 'init' &&
structuredMessageData.session_id &&
!currentSessionId &&
isSystemInitForView
) {
console.log('New session init detected:', {
newSession: structuredMessageData.session_id,
});
setIsSystemSessionChange(true);
onNavigateToSession?.(structuredMessageData.session_id);
return;
}
if (
structuredMessageData?.type === 'system' &&
structuredMessageData.subtype === 'init' &&
structuredMessageData.session_id &&
currentSessionId &&
structuredMessageData.session_id === currentSessionId &&
isSystemInitForView
) {
console.log('System init message for current session, ignoring');
return;
}
if (structuredMessageData && Array.isArray(structuredMessageData.content)) {
structuredMessageData.content.forEach((part: any) => {
if (part.type === 'tool_use') {
const toolInput = part.input ? JSON.stringify(part.input, null, 2) : '';
setChatMessages((previous) => [
...previous,
{
type: 'assistant',
content: '',
timestamp: new Date(),
isToolUse: true,
toolName: part.name,
toolInput,
toolId: part.id,
toolResult: null,
},
]);
return;
}
if (part.type === 'text' && part.text?.trim()) {
let content = decodeHtmlEntities(part.text);
content = formatUsageLimitText(content);
setChatMessages((previous) => [
...previous,
{
type: 'assistant',
content,
timestamp: new Date(),
},
]);
}
});
} else if (structuredMessageData && typeof structuredMessageData.content === 'string' && structuredMessageData.content.trim()) {
let content = decodeHtmlEntities(structuredMessageData.content);
content = formatUsageLimitText(content);
setChatMessages((previous) => [
...previous,
{
type: 'assistant',
content,
timestamp: new Date(),
},
]);
}
if (structuredMessageData?.role === 'user' && Array.isArray(structuredMessageData.content)) {
structuredMessageData.content.forEach((part: any) => {
if (part.type !== 'tool_result') {
return;
}
setChatMessages((previous) =>
previous.map((message) => {
if (message.isToolUse && message.toolId === part.tool_use_id) {
return {
...message,
toolResult: {
content: part.content,
isError: part.is_error,
timestamp: new Date(),
},
};
}
return message;
}),
);
});
}
break;
}
case 'claude-output': {
const cleaned = String(latestMessage.data || '');
if (cleaned.trim()) {
streamBufferRef.current += streamBufferRef.current ? `\n${cleaned}` : cleaned;
if (!streamTimerRef.current) {
streamTimerRef.current = window.setTimeout(() => {
const chunk = streamBufferRef.current;
streamBufferRef.current = '';
streamTimerRef.current = null;
appendStreamingChunk(setChatMessages, chunk, true);
}, 100);
}
}
break;
}
case 'claude-interactive-prompt':
// Interactive prompts are parsed/rendered as text in the UI.
// Normalize to string to keep ChatMessage.content shape consistent.
{
const interactiveContent =
typeof latestMessage.data === 'string'
? latestMessage.data
: JSON.stringify(latestMessage.data ?? '', null, 2);
setChatMessages((previous) => [
...previous,
{
type: 'assistant',
content: interactiveContent,
timestamp: new Date(),
isInteractivePrompt: true,
},
]);
}
break;
case 'claude-permission-request':
if (provider !== 'claude' || !latestMessage.requestId) {
break;
}
{
const requestId = latestMessage.requestId;
setPendingPermissionRequests((previous) => {
if (previous.some((request) => request.requestId === requestId)) {
return previous;
}
return [
...previous,
{
requestId,
toolName: latestMessage.toolName || 'UnknownTool',
input: latestMessage.input,
context: latestMessage.context,
sessionId: latestMessage.sessionId || null,
receivedAt: new Date(),
},
];
});
}
setIsLoading(true);
setCanAbortSession(true);
setClaudeStatus({
text: 'Waiting for permission',
tokens: 0,
can_interrupt: true,
});
break;
case 'claude-permission-cancelled':
if (!latestMessage.requestId) {
break;
}
setPendingPermissionRequests((previous) =>
previous.filter((request) => request.requestId !== latestMessage.requestId),
);
break;
case 'claude-error':
setChatMessages((previous) => [
...previous,
{
type: 'error',
content: `Error: ${latestMessage.error}`,
timestamp: new Date(),
},
]);
break;
case 'cursor-system':
try {
const cursorData = latestMessage.data;
if (
cursorData &&
cursorData.type === 'system' &&
cursorData.subtype === 'init' &&
cursorData.session_id
) {
if (!isSystemInitForView) {
return;
}
if (currentSessionId && cursorData.session_id !== currentSessionId) {
console.log('Cursor session switch detected:', {
originalSession: currentSessionId,
newSession: cursorData.session_id,
});
setIsSystemSessionChange(true);
onNavigateToSession?.(cursorData.session_id);
return;
}
if (!currentSessionId) {
console.log('Cursor new session init detected:', { newSession: cursorData.session_id });
setIsSystemSessionChange(true);
onNavigateToSession?.(cursorData.session_id);
return;
}
}
} catch (error) {
console.warn('Error handling cursor-system message:', error);
}
break;
case 'cursor-user':
break;
case 'cursor-tool-use':
setChatMessages((previous) => [
...previous,
{
type: 'assistant',
content: `Using tool: ${latestMessage.tool} ${
latestMessage.input ? `with ${latestMessage.input}` : ''
}`,
timestamp: new Date(),
isToolUse: true,
toolName: latestMessage.tool,
toolInput: latestMessage.input,
},
]);
break;
case 'cursor-error':
setChatMessages((previous) => [
...previous,
{
type: 'error',
content: `Cursor error: ${latestMessage.error || 'Unknown error'}`,
timestamp: new Date(),
},
]);
break;
case 'cursor-result': {
const cursorCompletedSessionId = latestMessage.sessionId || currentSessionId;
const pendingCursorSessionId = sessionStorage.getItem('pendingSessionId');
clearLoadingIndicators();
markSessionsAsCompleted(
cursorCompletedSessionId,
currentSessionId,
selectedSession?.id,
pendingCursorSessionId,
);
try {
const resultData = latestMessage.data || {};
const textResult = typeof resultData.result === 'string' ? resultData.result : '';
if (streamTimerRef.current) {
clearTimeout(streamTimerRef.current);
streamTimerRef.current = null;
}
const pendingChunk = streamBufferRef.current;
streamBufferRef.current = '';
setChatMessages((previous) => {
const updated = [...previous];
const lastIndex = updated.length - 1;
const last = updated[lastIndex];
if (last && last.type === 'assistant' && !last.isToolUse && last.isStreaming) {
const finalContent =
textResult && textResult.trim()
? textResult
: `${last.content || ''}${pendingChunk || ''}`;
// Clone the message instead of mutating in place so React can reliably detect state updates.
updated[lastIndex] = { ...last, content: finalContent, isStreaming: false };
} else if (textResult && textResult.trim()) {
updated.push({
type: resultData.is_error ? 'error' : 'assistant',
content: textResult,
timestamp: new Date(),
isStreaming: false,
});
}
return updated;
});
} catch (error) {
console.warn('Error handling cursor-result message:', error);
}
if (cursorCompletedSessionId && !currentSessionId && cursorCompletedSessionId === pendingCursorSessionId) {
setCurrentSessionId(cursorCompletedSessionId);
sessionStorage.removeItem('pendingSessionId');
if (window.refreshProjects) {
setTimeout(() => window.refreshProjects?.(), 500);
}
}
break;
}
case 'cursor-output':
try {
const raw = String(latestMessage.data ?? '');
const cleaned = raw
.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '')
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '')
.trim();
if (cleaned) {
streamBufferRef.current += streamBufferRef.current ? `\n${cleaned}` : cleaned;
if (!streamTimerRef.current) {
streamTimerRef.current = window.setTimeout(() => {
const chunk = streamBufferRef.current;
streamBufferRef.current = '';
streamTimerRef.current = null;
appendStreamingChunk(setChatMessages, chunk, true);
}, 100);
}
}
} catch (error) {
console.warn('Error handling cursor-output message:', error);
}
break;
case 'claude-complete': {
const pendingSessionId = sessionStorage.getItem('pendingSessionId');
const completedSessionId =
latestMessage.sessionId || currentSessionId || pendingSessionId;
clearLoadingIndicators();
markSessionsAsCompleted(
completedSessionId,
currentSessionId,
selectedSession?.id,
pendingSessionId,
);
if (pendingSessionId && !currentSessionId && latestMessage.exitCode === 0) {
setCurrentSessionId(pendingSessionId);
sessionStorage.removeItem('pendingSessionId');
console.log('New session complete, ID set to:', pendingSessionId);
}
if (selectedProject && latestMessage.exitCode === 0) {
safeLocalStorage.removeItem(`chat_messages_${selectedProject.name}`);
}
setPendingPermissionRequests([]);
break;
}
case 'codex-response': {
const codexData = latestMessage.data;
if (!codexData) {
break;
}
if (codexData.type === 'item') {
switch (codexData.itemType) {
case 'agent_message':
if (codexData.message?.content?.trim()) {
const content = decodeHtmlEntities(codexData.message.content);
setChatMessages((previous) => [
...previous,
{
type: 'assistant',
content,
timestamp: new Date(),
},
]);
}
break;
case 'reasoning':
if (codexData.message?.content?.trim()) {
const content = decodeHtmlEntities(codexData.message.content);
setChatMessages((previous) => [
...previous,
{
type: 'assistant',
content,
timestamp: new Date(),
isThinking: true,
},
]);
}
break;
case 'command_execution':
if (codexData.command) {
setChatMessages((previous) => [
...previous,
{
type: 'assistant',
content: '',
timestamp: new Date(),
isToolUse: true,
toolName: 'Bash',
toolInput: codexData.command,
toolResult: codexData.output || null,
exitCode: codexData.exitCode,
},
]);
}
break;
case 'file_change':
if (codexData.changes?.length > 0) {
const changesList = codexData.changes
.map((change: { kind: string; path: string }) => `${change.kind}: ${change.path}`)
.join('\n');
setChatMessages((previous) => [
...previous,
{
type: 'assistant',
content: '',
timestamp: new Date(),
isToolUse: true,
toolName: 'FileChanges',
toolInput: changesList,
toolResult: {
content: `Status: ${codexData.status}`,
isError: false,
},
},
]);
}
break;
case 'mcp_tool_call':
setChatMessages((previous) => [
...previous,
{
type: 'assistant',
content: '',
timestamp: new Date(),
isToolUse: true,
toolName: `${codexData.server}:${codexData.tool}`,
toolInput: JSON.stringify(codexData.arguments, null, 2),
toolResult: codexData.result
? JSON.stringify(codexData.result, null, 2)
: codexData.error?.message || null,
},
]);
break;
case 'error':
if (codexData.message?.content) {
setChatMessages((previous) => [
...previous,
{
type: 'error',
content: codexData.message.content,
timestamp: new Date(),
},
]);
}
break;
default:
console.log('[Codex] Unhandled item type:', codexData.itemType, codexData);
}
}
if (codexData.type === 'turn_complete') {
clearLoadingIndicators();
markSessionsAsCompleted(latestMessage.sessionId, currentSessionId, selectedSession?.id);
}
if (codexData.type === 'turn_failed') {
clearLoadingIndicators();
markSessionsAsCompleted(latestMessage.sessionId, currentSessionId, selectedSession?.id);
setChatMessages((previous) => [
...previous,
{
type: 'error',
content: codexData.error?.message || 'Turn failed',
timestamp: new Date(),
},
]);
}
break;
}
case 'codex-complete': {
const codexPendingSessionId = sessionStorage.getItem('pendingSessionId');
const codexActualSessionId = latestMessage.actualSessionId || codexPendingSessionId;
const codexCompletedSessionId =
latestMessage.sessionId || currentSessionId || codexPendingSessionId;
clearLoadingIndicators();
markSessionsAsCompleted(
codexCompletedSessionId,
codexActualSessionId,
currentSessionId,
selectedSession?.id,
codexPendingSessionId,
);
if (codexPendingSessionId && !currentSessionId) {
setCurrentSessionId(codexActualSessionId);
setIsSystemSessionChange(true);
if (codexActualSessionId) {
onNavigateToSession?.(codexActualSessionId);
}
sessionStorage.removeItem('pendingSessionId');
console.log('Codex session complete, ID set to:', codexPendingSessionId);
}
if (selectedProject) {
safeLocalStorage.removeItem(`chat_messages_${selectedProject.name}`);
}
break;
}
case 'codex-error':
setIsLoading(false);
setCanAbortSession(false);
setChatMessages((previous) => [
...previous,
{
type: 'error',
content: latestMessage.error || 'An error occurred with Codex',
timestamp: new Date(),
},
]);
break;
case 'session-aborted': {
const pendingSessionId =
typeof window !== 'undefined' ? sessionStorage.getItem('pendingSessionId') : null;
const abortedSessionId = latestMessage.sessionId || currentSessionId;
const abortSucceeded = latestMessage.success !== false;
if (abortSucceeded) {
clearLoadingIndicators();
markSessionsAsCompleted(abortedSessionId, currentSessionId, selectedSession?.id, pendingSessionId);
if (pendingSessionId && (!abortedSessionId || pendingSessionId === abortedSessionId)) {
sessionStorage.removeItem('pendingSessionId');
}
setPendingPermissionRequests([]);
setChatMessages((previous) => [
...previous,
{
type: 'assistant',
content: 'Session interrupted by user.',
timestamp: new Date(),
},
]);
} else {
setChatMessages((previous) => [
...previous,
{
type: 'error',
content: 'Stop request failed. The session is still running.',
timestamp: new Date(),
},
]);
}
break;
}
case 'session-status': {
const statusSessionId = latestMessage.sessionId;
const isCurrentSession =
statusSessionId === currentSessionId || (selectedSession && statusSessionId === selectedSession.id);
if (isCurrentSession && latestMessage.isProcessing) {
setIsLoading(true);
setCanAbortSession(true);
onSessionProcessing?.(statusSessionId);
}
break;
}
case 'claude-status': {
const statusData = latestMessage.data;
if (!statusData) {
break;
}
const statusInfo: { text: string; tokens: number; can_interrupt: boolean } = {
text: 'Working...',
tokens: 0,
can_interrupt: true,
};
if (statusData.message) {
statusInfo.text = statusData.message;
} else if (statusData.status) {
statusInfo.text = statusData.status;
} else if (typeof statusData === 'string') {
statusInfo.text = statusData;
}
if (statusData.tokens) {
statusInfo.tokens = statusData.tokens;
} else if (statusData.token_count) {
statusInfo.tokens = statusData.token_count;
}
if (statusData.can_interrupt !== undefined) {
statusInfo.can_interrupt = statusData.can_interrupt;
}
setClaudeStatus(statusInfo);
setIsLoading(true);
setCanAbortSession(statusInfo.can_interrupt);
break;
}
default:
break;
}
}, [
latestMessage,
provider,
selectedProject,
selectedSession,
currentSessionId,
setCurrentSessionId,
setChatMessages,
setIsLoading,
setCanAbortSession,
setClaudeStatus,
setTokenBudget,
setIsSystemSessionChange,
setPendingPermissionRequests,
onSessionInactive,
onSessionProcessing,
onSessionNotProcessing,
onReplaceTemporarySession,
onNavigateToSession,
]);
}

View File

@@ -0,0 +1,612 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import type { MutableRefObject } from 'react';
import { api, authenticatedFetch } from '../../../utils/api';
import type { ChatMessage, Provider } from '../types/types';
import type { Project, ProjectSession } from '../../../types/app';
import { safeLocalStorage } from '../utils/chatStorage';
import {
convertCursorSessionMessages,
convertSessionMessages,
createCachedDiffCalculator,
type DiffCalculator,
} from '../utils/messageTransforms';
const MESSAGES_PER_PAGE = 20;
const INITIAL_VISIBLE_MESSAGES = 100;
type PendingViewSession = {
sessionId: string | null;
startedAt: number;
};
interface UseChatSessionStateArgs {
selectedProject: Project | null;
selectedSession: ProjectSession | null;
ws: WebSocket | null;
sendMessage: (message: unknown) => void;
autoScrollToBottom?: boolean;
externalMessageUpdate?: number;
processingSessions?: Set<string>;
resetStreamingState: () => void;
pendingViewSessionRef: MutableRefObject<PendingViewSession | null>;
}
interface ScrollRestoreState {
height: number;
top: number;
}
export function useChatSessionState({
selectedProject,
selectedSession,
ws,
sendMessage,
autoScrollToBottom,
externalMessageUpdate,
processingSessions,
resetStreamingState,
pendingViewSessionRef,
}: UseChatSessionStateArgs) {
const [chatMessages, setChatMessages] = useState<ChatMessage[]>(() => {
if (typeof window !== 'undefined' && selectedProject) {
const saved = safeLocalStorage.getItem(`chat_messages_${selectedProject.name}`);
if (saved) {
try {
return JSON.parse(saved) as ChatMessage[];
} catch {
console.error('Failed to parse saved chat messages, resetting');
safeLocalStorage.removeItem(`chat_messages_${selectedProject.name}`);
return [];
}
}
return [];
}
return [];
});
const [isLoading, setIsLoading] = useState(false);
const [currentSessionId, setCurrentSessionId] = useState<string | null>(selectedSession?.id || null);
const [sessionMessages, setSessionMessages] = useState<any[]>([]);
const [isLoadingSessionMessages, setIsLoadingSessionMessages] = useState(false);
const [isLoadingMoreMessages, setIsLoadingMoreMessages] = useState(false);
const [hasMoreMessages, setHasMoreMessages] = useState(false);
const [totalMessages, setTotalMessages] = useState(0);
const [isSystemSessionChange, setIsSystemSessionChange] = useState(false);
const [canAbortSession, setCanAbortSession] = useState(false);
const [isUserScrolledUp, setIsUserScrolledUp] = useState(false);
const [tokenBudget, setTokenBudget] = useState<Record<string, unknown> | null>(null);
const [visibleMessageCount, setVisibleMessageCount] = useState(INITIAL_VISIBLE_MESSAGES);
const [claudeStatus, setClaudeStatus] = useState<{ text: string; tokens: number; can_interrupt: boolean } | null>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const isLoadingSessionRef = useRef(false);
const isLoadingMoreRef = useRef(false);
const topLoadLockRef = useRef(false);
const pendingScrollRestoreRef = useRef<ScrollRestoreState | null>(null);
const pendingInitialScrollRef = useRef(true);
const messagesOffsetRef = useRef(0);
const scrollPositionRef = useRef({ height: 0, top: 0 });
const createDiff = useMemo<DiffCalculator>(() => createCachedDiffCalculator(), []);
const loadSessionMessages = useCallback(
async (projectName: string, sessionId: string, loadMore = false, provider: Provider | string = 'claude') => {
if (!projectName || !sessionId) {
return [] as any[];
}
const isInitialLoad = !loadMore;
if (isInitialLoad) {
setIsLoadingSessionMessages(true);
} else {
setIsLoadingMoreMessages(true);
}
try {
const currentOffset = loadMore ? messagesOffsetRef.current : 0;
const response = await (api.sessionMessages as any)(
projectName,
sessionId,
MESSAGES_PER_PAGE,
currentOffset,
provider,
);
if (!response.ok) {
throw new Error('Failed to load session messages');
}
const data = await response.json();
if (isInitialLoad && data.tokenUsage) {
setTokenBudget(data.tokenUsage);
}
if (data.hasMore !== undefined) {
const loadedCount = data.messages?.length || 0;
setHasMoreMessages(Boolean(data.hasMore));
setTotalMessages(Number(data.total || 0));
messagesOffsetRef.current = currentOffset + loadedCount;
return data.messages || [];
}
const messages = data.messages || [];
setHasMoreMessages(false);
setTotalMessages(messages.length);
messagesOffsetRef.current = messages.length;
return messages;
} catch (error) {
console.error('Error loading session messages:', error);
return [];
} finally {
if (isInitialLoad) {
setIsLoadingSessionMessages(false);
} else {
setIsLoadingMoreMessages(false);
}
}
},
[],
);
const loadCursorSessionMessages = useCallback(async (projectPath: string, sessionId: string) => {
if (!projectPath || !sessionId) {
return [] as ChatMessage[];
}
setIsLoadingSessionMessages(true);
try {
const url = `/api/cursor/sessions/${encodeURIComponent(sessionId)}?projectPath=${encodeURIComponent(projectPath)}`;
const response = await authenticatedFetch(url);
if (!response.ok) {
return [];
}
const data = await response.json();
const blobs = (data?.session?.messages || []) as any[];
return convertCursorSessionMessages(blobs, projectPath);
} catch (error) {
console.error('Error loading Cursor session messages:', error);
return [];
} finally {
setIsLoadingSessionMessages(false);
}
}, []);
const convertedMessages = useMemo(() => {
return convertSessionMessages(sessionMessages);
}, [sessionMessages]);
const scrollToBottom = useCallback(() => {
const container = scrollContainerRef.current;
if (!container) {
return;
}
container.scrollTop = container.scrollHeight;
}, []);
const isNearBottom = useCallback(() => {
const container = scrollContainerRef.current;
if (!container) {
return false;
}
const { scrollTop, scrollHeight, clientHeight } = container;
return scrollHeight - scrollTop - clientHeight < 50;
}, []);
const loadOlderMessages = useCallback(
async (container: HTMLDivElement) => {
if (!container || isLoadingMoreRef.current || isLoadingMoreMessages) {
return false;
}
if (!hasMoreMessages || !selectedSession || !selectedProject) {
return false;
}
const sessionProvider = selectedSession.__provider || 'claude';
if (sessionProvider === 'cursor') {
return false;
}
isLoadingMoreRef.current = true;
const previousScrollHeight = container.scrollHeight;
const previousScrollTop = container.scrollTop;
try {
const moreMessages = await loadSessionMessages(
selectedProject.name,
selectedSession.id,
true,
sessionProvider,
);
if (moreMessages.length === 0) {
return false;
}
pendingScrollRestoreRef.current = {
height: previousScrollHeight,
top: previousScrollTop,
};
setSessionMessages((previous) => [...moreMessages, ...previous]);
// Keep the rendered window in sync with top-pagination so newly loaded history becomes visible.
setVisibleMessageCount((previousCount) => previousCount + moreMessages.length);
return true;
} finally {
isLoadingMoreRef.current = false;
}
},
[hasMoreMessages, isLoadingMoreMessages, loadSessionMessages, selectedProject, selectedSession],
);
const handleScroll = useCallback(async () => {
const container = scrollContainerRef.current;
if (!container) {
return;
}
const nearBottom = isNearBottom();
setIsUserScrolledUp(!nearBottom);
const scrolledNearTop = container.scrollTop < 100;
if (!scrolledNearTop) {
topLoadLockRef.current = false;
return;
}
if (topLoadLockRef.current) {
// After a top-load restore, release the lock once user has moved away from absolute top.
if (container.scrollTop > 20) {
topLoadLockRef.current = false;
}
return;
}
const didLoad = await loadOlderMessages(container);
if (didLoad) {
topLoadLockRef.current = true;
}
}, [isNearBottom, loadOlderMessages]);
useLayoutEffect(() => {
if (!pendingScrollRestoreRef.current || !scrollContainerRef.current) {
return;
}
const { height, top } = pendingScrollRestoreRef.current;
const container = scrollContainerRef.current;
const newScrollHeight = container.scrollHeight;
const scrollDiff = newScrollHeight - height;
container.scrollTop = top + Math.max(scrollDiff, 0);
pendingScrollRestoreRef.current = null;
}, [chatMessages.length]);
useEffect(() => {
pendingInitialScrollRef.current = true;
topLoadLockRef.current = false;
pendingScrollRestoreRef.current = null;
setVisibleMessageCount(INITIAL_VISIBLE_MESSAGES);
setIsUserScrolledUp(false);
}, [selectedProject?.name, selectedSession?.id]);
useEffect(() => {
if (!pendingInitialScrollRef.current || !scrollContainerRef.current || isLoadingSessionMessages) {
return;
}
if (chatMessages.length === 0) {
pendingInitialScrollRef.current = false;
return;
}
pendingInitialScrollRef.current = false;
setTimeout(() => {
scrollToBottom();
}, 200);
}, [chatMessages.length, isLoadingSessionMessages, scrollToBottom]);
useEffect(() => {
const loadMessages = async () => {
if (selectedSession && selectedProject) {
const provider = (localStorage.getItem('selected-provider') as Provider) || 'claude';
isLoadingSessionRef.current = true;
const sessionChanged = currentSessionId !== null && currentSessionId !== selectedSession.id;
if (sessionChanged) {
if (!isSystemSessionChange) {
resetStreamingState();
pendingViewSessionRef.current = null;
setChatMessages([]);
setSessionMessages([]);
setClaudeStatus(null);
setCanAbortSession(false);
}
messagesOffsetRef.current = 0;
setHasMoreMessages(false);
setTotalMessages(0);
setTokenBudget(null);
setIsLoading(false);
if (ws) {
sendMessage({
type: 'check-session-status',
sessionId: selectedSession.id,
provider,
});
}
} else if (currentSessionId === null) {
messagesOffsetRef.current = 0;
setHasMoreMessages(false);
setTotalMessages(0);
if (ws) {
sendMessage({
type: 'check-session-status',
sessionId: selectedSession.id,
provider,
});
}
}
if (provider === 'cursor') {
setCurrentSessionId(selectedSession.id);
sessionStorage.setItem('cursorSessionId', selectedSession.id);
if (!isSystemSessionChange) {
const projectPath = selectedProject.fullPath || selectedProject.path || '';
const converted = await loadCursorSessionMessages(projectPath, selectedSession.id);
setSessionMessages([]);
setChatMessages(converted);
} else {
setIsSystemSessionChange(false);
}
} else {
setCurrentSessionId(selectedSession.id);
if (!isSystemSessionChange) {
const messages = await loadSessionMessages(
selectedProject.name,
selectedSession.id,
false,
selectedSession.__provider || 'claude',
);
setSessionMessages(messages);
} else {
setIsSystemSessionChange(false);
}
}
} else {
if (!isSystemSessionChange) {
resetStreamingState();
pendingViewSessionRef.current = null;
setChatMessages([]);
setSessionMessages([]);
setClaudeStatus(null);
setCanAbortSession(false);
setIsLoading(false);
}
setCurrentSessionId(null);
sessionStorage.removeItem('cursorSessionId');
messagesOffsetRef.current = 0;
setHasMoreMessages(false);
setTotalMessages(0);
setTokenBudget(null);
}
setTimeout(() => {
isLoadingSessionRef.current = false;
}, 250);
};
loadMessages();
}, [
// Intentionally exclude currentSessionId: this effect sets it and should not retrigger another full load.
isSystemSessionChange,
loadCursorSessionMessages,
loadSessionMessages,
pendingViewSessionRef,
resetStreamingState,
selectedProject,
selectedSession,
sendMessage,
ws,
]);
useEffect(() => {
if (!externalMessageUpdate || !selectedSession || !selectedProject) {
return;
}
const reloadExternalMessages = async () => {
try {
const provider = (localStorage.getItem('selected-provider') as Provider) || 'claude';
if (provider === 'cursor') {
const projectPath = selectedProject.fullPath || selectedProject.path || '';
const converted = await loadCursorSessionMessages(projectPath, selectedSession.id);
setSessionMessages([]);
setChatMessages(converted);
return;
}
const messages = await loadSessionMessages(
selectedProject.name,
selectedSession.id,
false,
selectedSession.__provider || 'claude',
);
setSessionMessages(messages);
const shouldAutoScroll = Boolean(autoScrollToBottom) && isNearBottom();
if (shouldAutoScroll) {
setTimeout(() => scrollToBottom(), 200);
}
} catch (error) {
console.error('Error reloading messages from external update:', error);
}
};
reloadExternalMessages();
}, [
autoScrollToBottom,
externalMessageUpdate,
isNearBottom,
loadCursorSessionMessages,
loadSessionMessages,
scrollToBottom,
selectedProject,
selectedSession,
]);
useEffect(() => {
if (selectedSession?.id) {
pendingViewSessionRef.current = null;
}
}, [pendingViewSessionRef, selectedSession?.id]);
useEffect(() => {
if (sessionMessages.length > 0) {
setChatMessages(convertedMessages);
}
}, [convertedMessages, sessionMessages.length]);
useEffect(() => {
if (selectedProject && chatMessages.length > 0) {
safeLocalStorage.setItem(`chat_messages_${selectedProject.name}`, JSON.stringify(chatMessages));
}
}, [chatMessages, selectedProject]);
useEffect(() => {
if (!selectedProject || !selectedSession?.id || selectedSession.id.startsWith('new-session-')) {
setTokenBudget(null);
return;
}
const sessionProvider = selectedSession.__provider || 'claude';
if (sessionProvider !== 'claude') {
return;
}
const fetchInitialTokenUsage = async () => {
try {
const url = `/api/projects/${selectedProject.name}/sessions/${selectedSession.id}/token-usage`;
const response = await authenticatedFetch(url);
if (response.ok) {
const data = await response.json();
setTokenBudget(data);
} else {
setTokenBudget(null);
}
} catch (error) {
console.error('Failed to fetch initial token usage:', error);
}
};
fetchInitialTokenUsage();
}, [selectedProject, selectedSession?.id, selectedSession?.__provider]);
const visibleMessages = useMemo(() => {
if (chatMessages.length <= visibleMessageCount) {
return chatMessages;
}
return chatMessages.slice(-visibleMessageCount);
}, [chatMessages, visibleMessageCount]);
useEffect(() => {
if (!autoScrollToBottom && scrollContainerRef.current) {
const container = scrollContainerRef.current;
scrollPositionRef.current = {
height: container.scrollHeight,
top: container.scrollTop,
};
}
});
useEffect(() => {
if (!scrollContainerRef.current || chatMessages.length === 0) {
return;
}
if (isLoadingMoreRef.current || isLoadingMoreMessages || pendingScrollRestoreRef.current) {
return;
}
if (autoScrollToBottom) {
if (!isUserScrolledUp) {
setTimeout(() => scrollToBottom(), 50);
}
return;
}
const container = scrollContainerRef.current;
const prevHeight = scrollPositionRef.current.height;
const prevTop = scrollPositionRef.current.top;
const newHeight = container.scrollHeight;
const heightDiff = newHeight - prevHeight;
if (heightDiff > 0 && prevTop > 0) {
container.scrollTop = prevTop + heightDiff;
}
}, [autoScrollToBottom, chatMessages.length, isLoadingMoreMessages, isUserScrolledUp, scrollToBottom]);
useEffect(() => {
const container = scrollContainerRef.current;
if (!container) {
return;
}
container.addEventListener('scroll', handleScroll);
return () => container.removeEventListener('scroll', handleScroll);
}, [handleScroll]);
useEffect(() => {
const activeViewSessionId = selectedSession?.id || currentSessionId;
if (!activeViewSessionId || !processingSessions) {
return;
}
const shouldBeProcessing = processingSessions.has(activeViewSessionId);
if (shouldBeProcessing && !isLoading) {
setIsLoading(true);
setCanAbortSession(true);
}
}, [currentSessionId, isLoading, processingSessions, selectedSession?.id]);
const loadEarlierMessages = useCallback(() => {
setVisibleMessageCount((previousCount) => previousCount + 100);
}, []);
return {
chatMessages,
setChatMessages,
isLoading,
setIsLoading,
currentSessionId,
setCurrentSessionId,
sessionMessages,
setSessionMessages,
isLoadingSessionMessages,
isLoadingMoreMessages,
hasMoreMessages,
totalMessages,
isSystemSessionChange,
setIsSystemSessionChange,
canAbortSession,
setCanAbortSession,
isUserScrolledUp,
setIsUserScrolledUp,
tokenBudget,
setTokenBudget,
visibleMessageCount,
visibleMessages,
loadEarlierMessages,
claudeStatus,
setClaudeStatus,
createDiff,
scrollContainerRef,
scrollToBottom,
isNearBottom,
handleScroll,
loadSessionMessages,
loadCursorSessionMessages,
};
}

View File

@@ -0,0 +1,268 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import type { Dispatch, KeyboardEvent, RefObject, SetStateAction } from 'react';
import { api } from '../../../utils/api';
import { escapeRegExp } from '../utils/chatFormatting';
import type { Project } from '../../../types/app';
interface ProjectFileNode {
name: string;
type: 'file' | 'directory';
path?: string;
children?: ProjectFileNode[];
}
export interface MentionableFile {
name: string;
path: string;
relativePath?: string;
}
interface UseFileMentionsOptions {
selectedProject: Project | null;
input: string;
setInput: Dispatch<SetStateAction<string>>;
textareaRef: RefObject<HTMLTextAreaElement>;
}
const flattenFileTree = (files: ProjectFileNode[], basePath = ''): MentionableFile[] => {
let flattened: MentionableFile[] = [];
files.forEach((file) => {
const fullPath = basePath ? `${basePath}/${file.name}` : file.name;
if (file.type === 'directory' && file.children) {
flattened = flattened.concat(flattenFileTree(file.children, fullPath));
return;
}
if (file.type === 'file') {
flattened.push({
name: file.name,
path: fullPath,
relativePath: file.path,
});
}
});
return flattened;
};
export function useFileMentions({ selectedProject, input, setInput, textareaRef }: UseFileMentionsOptions) {
const [fileList, setFileList] = useState<MentionableFile[]>([]);
const [fileMentions, setFileMentions] = useState<string[]>([]);
const [filteredFiles, setFilteredFiles] = useState<MentionableFile[]>([]);
const [showFileDropdown, setShowFileDropdown] = useState(false);
const [selectedFileIndex, setSelectedFileIndex] = useState(-1);
const [cursorPosition, setCursorPosition] = useState(0);
const [atSymbolPosition, setAtSymbolPosition] = useState(-1);
useEffect(() => {
const abortController = new AbortController();
const fetchProjectFiles = async () => {
const projectName = selectedProject?.name;
setFileList([]);
setFilteredFiles([]);
if (!projectName) {
return;
}
try {
const response = await api.getFiles(projectName, { signal: abortController.signal });
if (!response.ok) {
return;
}
const files = (await response.json()) as ProjectFileNode[];
setFileList(flattenFileTree(files));
} catch (error) {
// Ignore aborts from rapid project switches; we only care about the latest request.
if ((error as { name?: string })?.name === 'AbortError') {
return;
}
console.error('Error fetching files:', error);
}
};
fetchProjectFiles();
return () => {
abortController.abort();
};
}, [selectedProject?.name]);
useEffect(() => {
const textBeforeCursor = input.slice(0, cursorPosition);
const lastAtIndex = textBeforeCursor.lastIndexOf('@');
if (lastAtIndex === -1) {
setShowFileDropdown(false);
setAtSymbolPosition(-1);
return;
}
const textAfterAt = textBeforeCursor.slice(lastAtIndex + 1);
if (textAfterAt.includes(' ')) {
setShowFileDropdown(false);
setAtSymbolPosition(-1);
return;
}
setAtSymbolPosition(lastAtIndex);
setShowFileDropdown(true);
setSelectedFileIndex(-1);
const matchingFiles = fileList
.filter(
(file) =>
file.name.toLowerCase().includes(textAfterAt.toLowerCase()) ||
file.path.toLowerCase().includes(textAfterAt.toLowerCase()),
)
.slice(0, 10);
setFilteredFiles(matchingFiles);
}, [input, cursorPosition, fileList]);
const activeFileMentions = useMemo(() => {
if (!input || fileMentions.length === 0) {
return [];
}
return fileMentions.filter((path) => input.includes(path));
}, [fileMentions, input]);
const sortedFileMentions = useMemo(() => {
if (activeFileMentions.length === 0) {
return [];
}
const uniqueMentions = Array.from(new Set(activeFileMentions));
return uniqueMentions.sort((mentionA, mentionB) => mentionB.length - mentionA.length);
}, [activeFileMentions]);
const fileMentionRegex = useMemo(() => {
if (sortedFileMentions.length === 0) {
return null;
}
const pattern = sortedFileMentions.map(escapeRegExp).join('|');
return new RegExp(`(${pattern})`, 'g');
}, [sortedFileMentions]);
const fileMentionSet = useMemo(() => new Set(sortedFileMentions), [sortedFileMentions]);
const renderInputWithMentions = useCallback(
(text: string) => {
if (!text) {
return '';
}
if (!fileMentionRegex) {
return text;
}
const parts = text.split(fileMentionRegex);
return parts.map((part, index) =>
fileMentionSet.has(part) ? (
<span
key={`mention-${index}`}
className="bg-blue-200/70 -ml-0.5 dark:bg-blue-300/40 px-0.5 rounded-md box-decoration-clone text-transparent"
>
{part}
</span>
) : (
<span key={`text-${index}`}>{part}</span>
),
);
},
[fileMentionRegex, fileMentionSet],
);
const selectFile = useCallback(
(file: MentionableFile) => {
const textBeforeAt = input.slice(0, atSymbolPosition);
const textAfterAtQuery = input.slice(atSymbolPosition);
const spaceIndex = textAfterAtQuery.indexOf(' ');
const textAfterQuery = spaceIndex !== -1 ? textAfterAtQuery.slice(spaceIndex) : '';
const newInput = `${textBeforeAt}${file.path} ${textAfterQuery}`;
const newCursorPosition = textBeforeAt.length + file.path.length + 1;
if (textareaRef.current && !textareaRef.current.matches(':focus')) {
textareaRef.current.focus();
}
setInput(newInput);
setCursorPosition(newCursorPosition);
setFileMentions((previousMentions) =>
previousMentions.includes(file.path) ? previousMentions : [...previousMentions, file.path],
);
setShowFileDropdown(false);
setAtSymbolPosition(-1);
if (!textareaRef.current) {
return;
}
requestAnimationFrame(() => {
if (!textareaRef.current) {
return;
}
textareaRef.current.setSelectionRange(newCursorPosition, newCursorPosition);
if (!textareaRef.current.matches(':focus')) {
textareaRef.current.focus();
}
});
},
[input, atSymbolPosition, textareaRef, setInput],
);
const handleFileMentionsKeyDown = useCallback(
(event: KeyboardEvent<HTMLTextAreaElement>): boolean => {
if (!showFileDropdown || filteredFiles.length === 0) {
return false;
}
if (event.key === 'ArrowDown') {
event.preventDefault();
setSelectedFileIndex((previousIndex) =>
previousIndex < filteredFiles.length - 1 ? previousIndex + 1 : 0,
);
return true;
}
if (event.key === 'ArrowUp') {
event.preventDefault();
setSelectedFileIndex((previousIndex) =>
previousIndex > 0 ? previousIndex - 1 : filteredFiles.length - 1,
);
return true;
}
if (event.key === 'Tab' || event.key === 'Enter') {
event.preventDefault();
if (selectedFileIndex >= 0) {
selectFile(filteredFiles[selectedFileIndex]);
} else if (filteredFiles.length > 0) {
selectFile(filteredFiles[0]);
}
return true;
}
if (event.key === 'Escape') {
event.preventDefault();
setShowFileDropdown(false);
return true;
}
return false;
},
[showFileDropdown, filteredFiles, selectedFileIndex, selectFile],
);
return {
showFileDropdown,
filteredFiles,
selectedFileIndex,
renderInputWithMentions,
selectFile,
setCursorPosition,
handleFileMentionsKeyDown,
};
}

View File

@@ -0,0 +1,375 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { Dispatch, KeyboardEvent, RefObject, SetStateAction } from 'react';
import Fuse from 'fuse.js';
import { authenticatedFetch } from '../../../utils/api';
import { safeLocalStorage } from '../utils/chatStorage';
import type { Project } from '../../../types/app';
const COMMAND_QUERY_DEBOUNCE_MS = 150;
export interface SlashCommand {
name: string;
description?: string;
namespace?: string;
path?: string;
type?: string;
metadata?: Record<string, unknown>;
[key: string]: unknown;
}
interface UseSlashCommandsOptions {
selectedProject: Project | null;
input: string;
setInput: Dispatch<SetStateAction<string>>;
textareaRef: RefObject<HTMLTextAreaElement>;
onExecuteCommand: (command: SlashCommand) => void | Promise<void>;
}
const getCommandHistoryKey = (projectName: string) => `command_history_${projectName}`;
const readCommandHistory = (projectName: string): Record<string, number> => {
const history = safeLocalStorage.getItem(getCommandHistoryKey(projectName));
if (!history) {
return {};
}
try {
return JSON.parse(history);
} catch (error) {
console.error('Error parsing command history:', error);
return {};
}
};
const saveCommandHistory = (projectName: string, history: Record<string, number>) => {
safeLocalStorage.setItem(getCommandHistoryKey(projectName), JSON.stringify(history));
};
const isPromiseLike = (value: unknown): value is Promise<unknown> =>
Boolean(value) && typeof (value as Promise<unknown>).then === 'function';
export function useSlashCommands({
selectedProject,
input,
setInput,
textareaRef,
onExecuteCommand,
}: UseSlashCommandsOptions) {
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
const [filteredCommands, setFilteredCommands] = useState<SlashCommand[]>([]);
const [showCommandMenu, setShowCommandMenu] = useState(false);
const [commandQuery, setCommandQuery] = useState('');
const [selectedCommandIndex, setSelectedCommandIndex] = useState(-1);
const [slashPosition, setSlashPosition] = useState(-1);
const commandQueryTimerRef = useRef<number | null>(null);
const clearCommandQueryTimer = useCallback(() => {
if (commandQueryTimerRef.current !== null) {
window.clearTimeout(commandQueryTimerRef.current);
commandQueryTimerRef.current = null;
}
}, []);
const resetCommandMenuState = useCallback(() => {
setShowCommandMenu(false);
setSlashPosition(-1);
setCommandQuery('');
setSelectedCommandIndex(-1);
clearCommandQueryTimer();
}, [clearCommandQueryTimer]);
useEffect(() => {
const fetchCommands = async () => {
if (!selectedProject) {
setSlashCommands([]);
setFilteredCommands([]);
return;
}
try {
const response = await authenticatedFetch('/api/commands/list', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
projectPath: selectedProject.path,
}),
});
if (!response.ok) {
throw new Error('Failed to fetch commands');
}
const data = await response.json();
const allCommands: SlashCommand[] = [
...((data.builtIn || []) as SlashCommand[]).map((command) => ({
...command,
type: 'built-in',
})),
...((data.custom || []) as SlashCommand[]).map((command) => ({
...command,
type: 'custom',
})),
];
const parsedHistory = readCommandHistory(selectedProject.name);
const sortedCommands = [...allCommands].sort((commandA, commandB) => {
const commandAUsage = parsedHistory[commandA.name] || 0;
const commandBUsage = parsedHistory[commandB.name] || 0;
return commandBUsage - commandAUsage;
});
setSlashCommands(sortedCommands);
} catch (error) {
console.error('Error fetching slash commands:', error);
setSlashCommands([]);
}
};
fetchCommands();
}, [selectedProject]);
useEffect(() => {
if (!showCommandMenu) {
setSelectedCommandIndex(-1);
}
}, [showCommandMenu]);
const fuse = useMemo(() => {
if (!slashCommands.length) {
return null;
}
return new Fuse(slashCommands, {
keys: [
{ name: 'name', weight: 2 },
{ name: 'description', weight: 1 },
],
threshold: 0.4,
includeScore: true,
minMatchCharLength: 1,
});
}, [slashCommands]);
useEffect(() => {
if (!commandQuery) {
setFilteredCommands(slashCommands);
return;
}
if (!fuse) {
setFilteredCommands([]);
return;
}
const results = fuse.search(commandQuery);
setFilteredCommands(results.map((result) => result.item));
}, [commandQuery, slashCommands, fuse]);
const frequentCommands = useMemo(() => {
if (!selectedProject || slashCommands.length === 0) {
return [];
}
const parsedHistory = readCommandHistory(selectedProject.name);
return slashCommands
.map((command) => ({
...command,
usageCount: parsedHistory[command.name] || 0,
}))
.filter((command) => command.usageCount > 0)
.sort((commandA, commandB) => commandB.usageCount - commandA.usageCount)
.slice(0, 5);
}, [selectedProject, slashCommands]);
const trackCommandUsage = useCallback(
(command: SlashCommand) => {
if (!selectedProject) {
return;
}
const parsedHistory = readCommandHistory(selectedProject.name);
parsedHistory[command.name] = (parsedHistory[command.name] || 0) + 1;
saveCommandHistory(selectedProject.name, parsedHistory);
},
[selectedProject],
);
const selectCommandFromKeyboard = useCallback(
(command: SlashCommand) => {
const textBeforeSlash = input.slice(0, slashPosition);
const textAfterSlash = input.slice(slashPosition);
const spaceIndex = textAfterSlash.indexOf(' ');
const textAfterQuery = spaceIndex !== -1 ? textAfterSlash.slice(spaceIndex) : '';
const newInput = `${textBeforeSlash}${command.name} ${textAfterQuery}`;
setInput(newInput);
resetCommandMenuState();
const executionResult = onExecuteCommand(command);
if (isPromiseLike(executionResult)) {
executionResult.catch(() => {
// Keep behavior silent; execution errors are handled by caller.
});
}
},
[input, slashPosition, setInput, resetCommandMenuState, onExecuteCommand],
);
const handleCommandSelect = useCallback(
(command: SlashCommand | null, index: number, isHover: boolean) => {
if (!command || !selectedProject) {
return;
}
if (isHover) {
setSelectedCommandIndex(index);
return;
}
trackCommandUsage(command);
const executionResult = onExecuteCommand(command);
if (isPromiseLike(executionResult)) {
executionResult.then(() => {
resetCommandMenuState();
});
executionResult.catch(() => {
// Keep behavior silent; execution errors are handled by caller.
});
} else {
resetCommandMenuState();
}
},
[selectedProject, trackCommandUsage, onExecuteCommand, resetCommandMenuState],
);
const handleToggleCommandMenu = useCallback(() => {
const isOpening = !showCommandMenu;
setShowCommandMenu(isOpening);
setCommandQuery('');
setSelectedCommandIndex(-1);
if (isOpening) {
setFilteredCommands(slashCommands);
}
textareaRef.current?.focus();
}, [showCommandMenu, slashCommands, textareaRef]);
const handleCommandInputChange = useCallback(
(newValue: string, cursorPos: number) => {
if (!newValue.trim()) {
resetCommandMenuState();
return;
}
const textBeforeCursor = newValue.slice(0, cursorPos);
const backticksBefore = (textBeforeCursor.match(/```/g) || []).length;
const inCodeBlock = backticksBefore % 2 === 1;
if (inCodeBlock) {
resetCommandMenuState();
return;
}
const slashPattern = /(^|\s)\/(\S*)$/;
const match = textBeforeCursor.match(slashPattern);
if (!match) {
resetCommandMenuState();
return;
}
const slashPos = (match.index || 0) + match[1].length;
const query = match[2];
setSlashPosition(slashPos);
setShowCommandMenu(true);
setSelectedCommandIndex(-1);
clearCommandQueryTimer();
commandQueryTimerRef.current = window.setTimeout(() => {
setCommandQuery(query);
}, COMMAND_QUERY_DEBOUNCE_MS);
},
[resetCommandMenuState, clearCommandQueryTimer],
);
const handleCommandMenuKeyDown = useCallback(
(event: KeyboardEvent<HTMLTextAreaElement>): boolean => {
if (!showCommandMenu) {
return false;
}
if (!filteredCommands.length) {
if (event.key === 'Escape') {
event.preventDefault();
resetCommandMenuState();
return true;
}
return false;
}
if (event.key === 'ArrowDown') {
event.preventDefault();
setSelectedCommandIndex((previousIndex) =>
previousIndex < filteredCommands.length - 1 ? previousIndex + 1 : 0,
);
return true;
}
if (event.key === 'ArrowUp') {
event.preventDefault();
setSelectedCommandIndex((previousIndex) =>
previousIndex > 0 ? previousIndex - 1 : filteredCommands.length - 1,
);
return true;
}
if (event.key === 'Tab' || event.key === 'Enter') {
event.preventDefault();
if (selectedCommandIndex >= 0) {
selectCommandFromKeyboard(filteredCommands[selectedCommandIndex]);
} else if (filteredCommands.length > 0) {
selectCommandFromKeyboard(filteredCommands[0]);
}
return true;
}
if (event.key === 'Escape') {
event.preventDefault();
resetCommandMenuState();
return true;
}
return false;
},
[showCommandMenu, filteredCommands, resetCommandMenuState, selectCommandFromKeyboard, selectedCommandIndex],
);
useEffect(
() => () => {
clearCommandQueryTimer();
},
[clearCommandQueryTimer],
);
return {
slashCommands,
slashCommandsCount: slashCommands.length,
filteredCommands,
frequentCommands,
commandQuery,
showCommandMenu,
selectedCommandIndex,
resetCommandMenuState,
handleCommandSelect,
handleToggleCommandMenu,
handleCommandInputChange,
handleCommandMenuKeyDown,
};
}

View File

@@ -0,0 +1,224 @@
# Tool Rendering System
## Overview
Config-driven architecture for rendering tool executions in chat. All tool display behavior is defined in `toolConfigs.ts` — no scattered conditionals. Two base display patterns: **OneLineDisplay** for compact tools, **CollapsibleDisplay** for tools with expandable content.
Non-error tool results route through `ToolRenderer` with `mode="result"` (single source of truth). Error results are handled inline in `MessageComponent` with a red error box.
---
## Architecture
```
tools/
├── components/
│ ├── OneLineDisplay.tsx # Compact one-line tool display
│ ├── CollapsibleDisplay.tsx # Expandable tool display (uses children pattern)
│ ├── CollapsibleSection.tsx # <details>/<summary> wrapper
│ ├── ContentRenderers/
│ │ ├── DiffViewer.tsx # File diff viewer (memoized)
│ │ ├── MarkdownContent.tsx # Markdown renderer
│ │ ├── FileListContent.tsx # Comma-separated clickable file list
│ │ ├── TodoListContent.tsx # Todo items with status badges
│ │ ├── TaskListContent.tsx # Task tracker with progress bar
│ │ └── TextContent.tsx # Plain text / JSON / code
├── configs/
│ └── toolConfigs.ts # All tool configs + ToolDisplayConfig type
├── ToolRenderer.tsx # Main router (React.memo wrapped)
└── README.md
```
---
## Display Patterns
### OneLineDisplay
Used by: Bash, Read, Grep, Glob, TodoRead, TaskCreate, TaskUpdate, TaskGet
Renders as a single line with `border-l-2` accent. Supports multiple rendering modes based on `action`:
- **terminal** (`style: 'terminal'`) — Dark pill around command text, green `$` prompt
- **open-file** — Shows filename only (truncated from full path), clickable to open
- **jump-to-results** — Shows pattern with anchor link to result section
- **copy** — Shows value with hover copy button
- **none** — Plain display
```tsx
<OneLineDisplay
toolName="Read"
icon="terminal" // Optional icon or style keyword
label="Read" // Tool label
value="/path/to/file.ts" // Main display value
secondary="description" // Optional secondary text (italic)
action="open-file" // Action type
onAction={() => ...} // Click handler
colorScheme={{ // Per-tool colors
primary: 'text-...',
border: 'border-...',
icon: 'text-...'
}}
resultId="tool-result-x" // For jump-to-results anchor
toolResult={...} // For conditional jump arrow
toolId="x" // Tool use ID
/>
```
### CollapsibleDisplay
Used by: Edit, Write, ApplyPatch, Grep/Glob results, TodoWrite, TaskList/TaskGet results, ExitPlanMode, Default
Wraps `CollapsibleSection` (`<details>`/`<summary>`) with a `border-l-2` accent colored by tool category. Accepts **children** directly (not contentProps).
```tsx
<CollapsibleDisplay
toolName="Edit"
toolId="123"
title="filename.ts" // Section title (can be clickable)
defaultOpen={false}
onTitleClick={() => ...} // Makes title a clickable link (for edit tools)
showRawParameters={true} // Show raw JSON toggle
rawContent="..." // Raw JSON string
toolCategory="edit" // Drives border color
>
<DiffViewer {...} /> // Content as children
</CollapsibleDisplay>
```
**Tool category colors** (via `border-l-2`):
| Category | Tools | Color |
|----------|-------|-------|
| `edit` | Edit, Write, ApplyPatch | amber |
| `bash` | Bash | green |
| `search` | Grep, Glob | gray |
| `todo` | TodoWrite, TodoRead | violet |
| `task` | TaskCreate/Update/List/Get | violet |
| `plan` | ExitPlanMode | indigo |
| `default` | everything else | neutral gray |
---
## Content Renderers
Specialized components for different content types, rendered as children of `CollapsibleDisplay`:
| contentType | Component | Used by |
|---|---|---|
| `diff` | `DiffViewer` | Edit, Write, ApplyPatch |
| `markdown` | `MarkdownContent` | ExitPlanMode |
| `file-list` | `FileListContent` | Grep/Glob results |
| `todo-list` | `TodoListContent` | TodoWrite, TodoRead |
| `task` | `TaskListContent` | TaskList, TaskGet results |
| `text` | `TextContent` | Default fallback |
| `success-message` | inline SVG | TodoWrite result |
---
## Adding a New Tool
**Step 1:** Add config to `configs/toolConfigs.ts`
```typescript
MyTool: {
input: {
type: 'one-line', // or 'collapsible'
label: 'MyTool',
getValue: (input) => input.some_field,
action: 'open-file',
colorScheme: {
primary: 'text-purple-600 dark:text-purple-400',
border: 'border-purple-400 dark:border-purple-500'
}
},
result: {
hideOnSuccess: true // Only show errors
}
}
```
**Step 2:** If the tool needs a category color, add it to `getToolCategory()` in `ToolRenderer.tsx`.
**That's it.** The ToolRenderer auto-routes based on config.
---
## Configuration Reference
### ToolDisplayConfig
```typescript
interface ToolDisplayConfig {
input: {
type: 'one-line' | 'collapsible' | 'hidden';
// One-line
icon?: string;
label?: string;
getValue?: (input) => string;
getSecondary?: (input) => string | undefined;
action?: 'copy' | 'open-file' | 'jump-to-results' | 'none';
style?: string; // 'terminal' for Bash
wrapText?: boolean;
colorScheme?: {
primary?: string;
secondary?: string;
background?: string;
border?: string;
icon?: string;
};
// Collapsible
title?: string | ((input) => string);
defaultOpen?: boolean;
contentType?: 'diff' | 'markdown' | 'file-list' | 'todo-list' | 'text' | 'task';
getContentProps?: (input, helpers?) => any;
actionButton?: 'none';
};
result?: {
hidden?: boolean; // Never show
hideOnSuccess?: boolean; // Only show errors
type?: 'one-line' | 'collapsible' | 'special';
title?: string | ((result) => string);
defaultOpen?: boolean;
contentType?: 'markdown' | 'file-list' | 'todo-list' | 'text' | 'success-message' | 'task';
getMessage?: (result) => string;
getContentProps?: (result) => any;
};
}
```
---
## All Configured Tools
| Tool | Input | Result | Notes |
|------|-------|--------|-------|
| Bash | terminal one-line | hide success | Dark command pill, green accent |
| Read | one-line (open-file) | hidden | Shows filename, clicks to open |
| Edit | collapsible (diff) | hide success | Amber border, clickable filename |
| Write | collapsible (diff) | hide success | "New" badge on diff |
| ApplyPatch | collapsible (diff) | hide success | "Patch" badge on diff |
| Grep | one-line (jump) | collapsible file-list | Collapsed by default |
| Glob | one-line (jump) | collapsible file-list | Collapsed by default |
| TodoWrite | collapsible (todo-list) | success message | |
| TodoRead | one-line | collapsible todo-list | |
| TaskCreate | one-line | hide success | Shows task subject |
| TaskUpdate | one-line | hide success | Shows `#id → status` |
| TaskList | one-line | collapsible task | Progress bar, status icons |
| TaskGet | one-line | collapsible task | Task details with status |
| ExitPlanMode | collapsible (markdown) | collapsible markdown | Also registered as `exit_plan_mode` |
| Default | collapsible (code) | collapsible text | Fallback for unknown tools |
---
## Performance
- **ToolRenderer** is wrapped with `React.memo` — skips re-render when props haven't changed
- **parsedData** is memoized with `useMemo` — JSON parsing only runs when input changes
- **DiffViewer** memoizes `createDiff()` — expensive diff computation cached
- **MessageComponent** caches `localStorage` reads and timestamp formatting via `useMemo`
- Tool results route through `ToolRenderer` (no duplicate rendering paths)
- `CollapsibleDisplay` uses children pattern (no wasteful contentProps indirection)
- Configs are static module-level objects — zero runtime overhead for lookups

View File

@@ -0,0 +1,219 @@
import React, { memo, useMemo, useCallback } from 'react';
import { getToolConfig } from './configs/toolConfigs';
import { OneLineDisplay, CollapsibleDisplay, DiffViewer, MarkdownContent, FileListContent, TodoListContent, TaskListContent, TextContent, QuestionAnswerContent } from './components';
import type { Project } from '../../../types/app';
type DiffLine = {
type: string;
content: string;
lineNum: number;
};
interface ToolRendererProps {
toolName: string;
toolInput: any;
toolResult?: any;
toolId?: string;
mode: 'input' | 'result';
onFileOpen?: (filePath: string, diffInfo?: any) => void;
createDiff?: (oldStr: string, newStr: string) => DiffLine[];
selectedProject?: Project | null;
autoExpandTools?: boolean;
showRawParameters?: boolean;
rawToolInput?: string;
}
function getToolCategory(toolName: string): string {
if (['Edit', 'Write', 'ApplyPatch'].includes(toolName)) return 'edit';
if (['Grep', 'Glob'].includes(toolName)) return 'search';
if (toolName === 'Bash') return 'bash';
if (['TodoWrite', 'TodoRead'].includes(toolName)) return 'todo';
if (['TaskCreate', 'TaskUpdate', 'TaskList', 'TaskGet'].includes(toolName)) return 'task';
if (toolName === 'Task') return 'agent'; // Subagent task
if (toolName === 'exit_plan_mode' || toolName === 'ExitPlanMode') return 'plan';
if (toolName === 'AskUserQuestion') return 'question';
return 'default';
}
/**
* Main tool renderer router
* Routes to OneLineDisplay or CollapsibleDisplay based on tool config
*/
export const ToolRenderer: React.FC<ToolRendererProps> = memo(({
toolName,
toolInput,
toolResult,
toolId,
mode,
onFileOpen,
createDiff,
selectedProject,
autoExpandTools = false,
showRawParameters = false,
rawToolInput
}) => {
const config = getToolConfig(toolName);
const displayConfig: any = mode === 'input' ? config.input : config.result;
const parsedData = useMemo(() => {
try {
const rawData = mode === 'input' ? toolInput : toolResult;
return typeof rawData === 'string' ? JSON.parse(rawData) : rawData;
} catch {
return mode === 'input' ? toolInput : toolResult;
}
}, [mode, toolInput, toolResult]);
const handleAction = useCallback(() => {
if (displayConfig?.action === 'open-file' && onFileOpen) {
const value = displayConfig.getValue?.(parsedData) || '';
onFileOpen(value);
}
}, [displayConfig, parsedData, onFileOpen]);
// Keep hooks above this guard so hook call order stays stable across renders.
if (!displayConfig) return null;
if (displayConfig.type === 'one-line') {
const value = displayConfig.getValue?.(parsedData) || '';
const secondary = displayConfig.getSecondary?.(parsedData);
return (
<OneLineDisplay
toolName={toolName}
toolResult={toolResult}
toolId={toolId}
icon={displayConfig.icon}
label={displayConfig.label}
value={value}
secondary={secondary}
action={displayConfig.action}
onAction={handleAction}
style={displayConfig.style}
wrapText={displayConfig.wrapText}
colorScheme={displayConfig.colorScheme}
resultId={mode === 'input' ? `tool-result-${toolId}` : undefined}
/>
);
}
if (displayConfig.type === 'collapsible') {
const title = typeof displayConfig.title === 'function'
? displayConfig.title(parsedData)
: displayConfig.title || 'Details';
const defaultOpen = displayConfig.defaultOpen !== undefined
? displayConfig.defaultOpen
: autoExpandTools;
const contentProps = displayConfig.getContentProps?.(parsedData, {
selectedProject,
createDiff,
onFileOpen
}) || {};
// Build the content component based on contentType
let contentComponent: React.ReactNode = null;
switch (displayConfig.contentType) {
case 'diff':
if (createDiff) {
contentComponent = (
<DiffViewer
{...contentProps}
createDiff={createDiff}
onFileClick={() => onFileOpen?.(contentProps.filePath)}
/>
);
}
break;
case 'markdown':
contentComponent = <MarkdownContent content={contentProps.content || ''} />;
break;
case 'file-list':
contentComponent = (
<FileListContent
files={contentProps.files || []}
onFileClick={onFileOpen}
title={contentProps.title}
/>
);
break;
case 'todo-list':
if (contentProps.todos?.length > 0) {
contentComponent = (
<TodoListContent
todos={contentProps.todos}
isResult={contentProps.isResult}
/>
);
}
break;
case 'task':
contentComponent = <TaskListContent content={contentProps.content || ''} />;
break;
case 'question-answer':
contentComponent = (
<QuestionAnswerContent
questions={contentProps.questions || []}
answers={contentProps.answers || {}}
/>
);
break;
case 'text':
contentComponent = (
<TextContent
content={contentProps.content || ''}
format={contentProps.format || 'plain'}
/>
);
break;
case 'success-message': {
const msg = displayConfig.getMessage?.(parsedData) || 'Success';
contentComponent = (
<div className="flex items-center gap-1.5 text-xs text-green-600 dark:text-green-400">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
{msg}
</div>
);
break;
}
}
// For edit tools, make the title (filename) clickable to open the file
const handleTitleClick = (toolName === 'Edit' || toolName === 'Write' || toolName === 'ApplyPatch') && contentProps.filePath && onFileOpen
? () => onFileOpen(contentProps.filePath, {
old_string: contentProps.oldContent,
new_string: contentProps.newContent
})
: undefined;
return (
<CollapsibleDisplay
toolName={toolName}
toolId={toolId}
title={title}
defaultOpen={defaultOpen}
onTitleClick={handleTitleClick}
showRawParameters={mode === 'input' && showRawParameters}
rawContent={rawToolInput}
toolCategory={getToolCategory(toolName)}
>
{contentComponent}
</CollapsibleDisplay>
);
}
return null;
});
ToolRenderer.displayName = 'ToolRenderer';

View File

@@ -0,0 +1,77 @@
import React from 'react';
import { CollapsibleSection } from './CollapsibleSection';
interface CollapsibleDisplayProps {
toolName: string;
toolId?: string;
title: string;
defaultOpen?: boolean;
action?: React.ReactNode;
onTitleClick?: () => void;
children: React.ReactNode;
showRawParameters?: boolean;
rawContent?: string;
className?: string;
toolCategory?: string;
}
const borderColorMap: Record<string, string> = {
edit: 'border-l-amber-500 dark:border-l-amber-400',
search: 'border-l-gray-400 dark:border-l-gray-500',
bash: 'border-l-green-500 dark:border-l-green-400',
todo: 'border-l-violet-500 dark:border-l-violet-400',
task: 'border-l-violet-500 dark:border-l-violet-400',
agent: 'border-l-purple-500 dark:border-l-purple-400',
plan: 'border-l-indigo-500 dark:border-l-indigo-400',
question: 'border-l-blue-500 dark:border-l-blue-400',
default: 'border-l-gray-300 dark:border-l-gray-600',
};
export const CollapsibleDisplay: React.FC<CollapsibleDisplayProps> = ({
toolName,
title,
defaultOpen = false,
action,
onTitleClick,
children,
showRawParameters = false,
rawContent,
className = '',
toolCategory
}) => {
// Fall back to default styling for unknown/new categories so className never includes "undefined".
const borderColor = borderColorMap[toolCategory || 'default'] || borderColorMap.default;
return (
<div className={`border-l-2 ${borderColor} pl-3 py-0.5 my-1 ${className}`}>
<CollapsibleSection
title={title}
toolName={toolName}
open={defaultOpen}
action={action}
onTitleClick={onTitleClick}
>
{children}
{showRawParameters && rawContent && (
<details className="relative mt-2 group/raw">
<summary className="flex items-center gap-1.5 text-[11px] text-gray-400 dark:text-gray-500 cursor-pointer hover:text-gray-600 dark:hover:text-gray-300 py-0.5">
<svg
className="w-2.5 h-2.5 transition-transform duration-150 group-open/raw:rotate-90"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
raw params
</summary>
<pre className="mt-1 text-[11px] bg-gray-50 dark:bg-gray-900/50 border border-gray-200/40 dark:border-gray-700/40 p-2 rounded whitespace-pre-wrap break-words overflow-hidden text-gray-600 dark:text-gray-400 font-mono">
{rawContent}
</pre>
</details>
)}
</CollapsibleSection>
</div>
);
};

View File

@@ -0,0 +1,61 @@
import React from 'react';
interface CollapsibleSectionProps {
title: string;
toolName?: string;
open?: boolean;
action?: React.ReactNode;
onTitleClick?: () => void;
children: React.ReactNode;
className?: string;
}
/**
* Reusable collapsible section with consistent styling
*/
export const CollapsibleSection: React.FC<CollapsibleSectionProps> = ({
title,
toolName,
open = false,
action,
onTitleClick,
children,
className = ''
}) => {
return (
<details className={`relative group/details ${className}`} open={open}>
<summary className="flex items-center gap-1.5 text-xs cursor-pointer py-0.5 select-none">
<svg
className="w-3 h-3 text-gray-400 dark:text-gray-500 transition-transform duration-150 group-open/details:rotate-90 flex-shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
{toolName && (
<span className="font-medium text-gray-500 dark:text-gray-400 flex-shrink-0">{toolName}</span>
)}
{toolName && (
<span className="text-gray-300 dark:text-gray-600 text-[10px] flex-shrink-0">/</span>
)}
{onTitleClick ? (
<button
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onTitleClick(); }}
className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 font-mono hover:underline truncate flex-1 text-left transition-colors"
>
{title}
</button>
) : (
<span className="text-gray-600 dark:text-gray-400 truncate flex-1">
{title}
</span>
)}
{action && <span className="flex-shrink-0 ml-1">{action}</span>}
</summary>
<div className="mt-1.5 pl-[18px]">
{children}
</div>
</details>
);
};

View File

@@ -0,0 +1,56 @@
import React from 'react';
interface FileListItem {
path: string;
onClick?: () => void;
}
interface FileListContentProps {
files: string[] | FileListItem[];
onFileClick?: (filePath: string) => void;
title?: string;
}
/**
* Renders a compact comma-separated list of clickable file names
* Used by: Grep/Glob results
*/
export const FileListContent: React.FC<FileListContentProps> = ({
files,
onFileClick,
title
}) => {
return (
<div>
{title && (
<div className="text-[11px] text-gray-500 dark:text-gray-400 mb-1">
{title}
</div>
)}
<div className="flex flex-wrap gap-x-1 gap-y-0.5 max-h-48 overflow-y-auto">
{files.map((file, index) => {
const filePath = typeof file === 'string' ? file : file.path;
const fileName = filePath.split('/').pop() || filePath;
const handleClick = typeof file === 'string'
? () => onFileClick?.(file)
: file.onClick;
return (
<span key={index} className="inline-flex items-center">
<button
onClick={handleClick}
className="text-[11px] font-mono text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 hover:underline transition-colors"
title={filePath}
>
{fileName}
</button>
{index < files.length - 1 && (
<span className="text-gray-300 dark:text-gray-600 text-[10px] ml-1">,</span>
)}
</span>
);
})}
</div>
</div>
);
};

View File

@@ -0,0 +1,22 @@
import React from 'react';
import { Markdown } from '../../../view/subcomponents/Markdown';
interface MarkdownContentProps {
content: string;
className?: string;
}
/**
* Renders markdown content with proper styling
* Used by: exit_plan_mode, long text results, etc.
*/
export const MarkdownContent: React.FC<MarkdownContentProps> = ({
content,
className = 'mt-1 prose prose-sm max-w-none dark:prose-invert'
}) => {
return (
<Markdown className={className}>
{content}
</Markdown>
);
};

View File

@@ -0,0 +1,187 @@
import React, { useState } from 'react';
import type { Question } from '../../../types/types';
interface QuestionAnswerContentProps {
questions: Question[];
answers: Record<string, string>;
className?: string;
}
// Exception to the stateless ContentRenderer pattern: multi-question navigation requires local state.
export const QuestionAnswerContent: React.FC<QuestionAnswerContentProps> = ({
questions,
answers,
className = '',
}) => {
const [expandedIdx, setExpandedIdx] = useState<number | null>(null);
if (!questions || questions.length === 0) {
return null;
}
const hasAnyAnswer = Object.keys(answers || {}).length > 0;
const total = questions.length;
return (
<div className={`space-y-2 ${className}`}>
{questions.map((q, idx) => {
const answer = answers?.[q.question];
const answerLabels = answer ? answer.split(', ') : [];
const skipped = !answer;
const isExpanded = expandedIdx === idx;
return (
<div
key={idx}
className="rounded-lg border border-gray-150 dark:border-gray-700/50 bg-gray-50/50 dark:bg-gray-800/30 overflow-hidden"
>
<button
type="button"
onClick={() => setExpandedIdx(isExpanded ? null : idx)}
className="w-full text-left px-3 py-2 flex items-start gap-2.5 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors"
>
<div className={`mt-0.5 flex-shrink-0 w-4 h-4 rounded-full flex items-center justify-center ${
answerLabels.length > 0
? 'bg-blue-100 dark:bg-blue-900/40'
: 'bg-gray-100 dark:bg-gray-800'
}`}>
{answerLabels.length > 0 ? (
<svg className="w-2.5 h-2.5 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={3}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
) : (
<div className="w-1.5 h-1.5 rounded-full bg-gray-300 dark:bg-gray-600" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
{q.header && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[9px] font-semibold uppercase tracking-wider bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 border border-blue-100/80 dark:border-blue-800/40">
{q.header}
</span>
)}
{total > 1 && (
<span className="text-[10px] tabular-nums text-gray-400 dark:text-gray-500">
{idx + 1}/{total}
</span>
)}
</div>
<div className="text-xs text-gray-600 dark:text-gray-400 mt-0.5 leading-snug">
{q.question}
</div>
{!isExpanded && answerLabels.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1.5">
{answerLabels.map((lbl) => {
const isCustom = !q.options.some(o => o.label === lbl);
return (
<span
key={lbl}
className="inline-flex items-center gap-1 text-[11px] px-1.5 py-0.5 rounded-md bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 font-medium"
>
{lbl}
{isCustom && (
<span className="text-[9px] text-blue-400 dark:text-blue-500 font-normal">(custom)</span>
)}
</span>
);
})}
</div>
)}
{!isExpanded && skipped && hasAnyAnswer && (
<span className="inline-block mt-1 text-[10px] text-gray-400 dark:text-gray-500 italic">
Skipped
</span>
)}
</div>
<svg
className={`w-3.5 h-3.5 mt-0.5 text-gray-400 dark:text-gray-500 flex-shrink-0 transition-transform duration-200 ${
isExpanded ? 'rotate-180' : ''
}`}
fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
{isExpanded && (
<div className="px-3 pb-2.5 pt-0.5 border-t border-gray-100 dark:border-gray-700/40">
<div className="space-y-1 ml-6.5">
{q.options.map((opt) => {
const wasSelected = answerLabels.includes(opt.label);
return (
<div
key={opt.label}
className={`flex items-start gap-2 px-2.5 py-1.5 rounded-lg text-[12px] ${
wasSelected
? 'bg-blue-50/80 dark:bg-blue-900/20 border border-blue-200/60 dark:border-blue-800/40'
: 'text-gray-400 dark:text-gray-500'
}`}
>
<div className={`mt-0.5 flex-shrink-0 w-3.5 h-3.5 ${q.multiSelect ? 'rounded-[3px]' : 'rounded-full'} border-[1.5px] flex items-center justify-center ${
wasSelected
? 'border-blue-500 dark:border-blue-400 bg-blue-500 dark:bg-blue-500'
: 'border-gray-300 dark:border-gray-600'
}`}>
{wasSelected && (
<svg className="w-2 h-2 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={3}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
)}
</div>
<div className="flex-1 min-w-0">
<span className={wasSelected ? 'text-gray-900 dark:text-gray-100 font-medium' : ''}>
{opt.label}
</span>
{opt.description && (
<span className={`block text-[11px] mt-0.5 ${
wasSelected ? 'text-blue-600/70 dark:text-blue-300/70' : 'text-gray-400 dark:text-gray-600'
}`}>
{opt.description}
</span>
)}
</div>
</div>
);
})}
{answerLabels.filter(lbl => !q.options.some(o => o.label === lbl)).map(lbl => (
<div
key={lbl}
className="flex items-start gap-2 px-2.5 py-1.5 rounded-lg text-[12px] bg-blue-50/80 dark:bg-blue-900/20 border border-blue-200/60 dark:border-blue-800/40"
>
<div className={`mt-0.5 flex-shrink-0 w-3.5 h-3.5 ${q.multiSelect ? 'rounded-[3px]' : 'rounded-full'} border-[1.5px] border-blue-500 dark:border-blue-400 bg-blue-500 dark:bg-blue-500 flex items-center justify-center`}>
<svg className="w-2 h-2 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={3}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
<div className="flex-1 min-w-0">
<span className="text-gray-900 dark:text-gray-100 font-medium">{lbl}</span>
<span className="text-[10px] text-blue-500 dark:text-blue-400 ml-1">(custom)</span>
</div>
</div>
))}
{skipped && hasAnyAnswer && (
<div className="text-[11px] text-gray-400 dark:text-gray-500 italic px-2.5 py-1">
No answer provided
</div>
)}
</div>
</div>
)}
</div>
);
})}
{!hasAnyAnswer && total === 1 && (
<div className="text-[11px] text-gray-400 dark:text-gray-500 italic">
Skipped
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,125 @@
import React from 'react';
interface TaskItem {
id: string;
subject: string;
status: 'pending' | 'in_progress' | 'completed';
owner?: string;
blockedBy?: string[];
}
interface TaskListContentProps {
content: string;
}
function parseTaskContent(content: string): TaskItem[] {
const tasks: TaskItem[] = [];
const lines = content.split('\n');
for (const line of lines) {
// Match patterns like: #15. [in_progress] Subject here
// or: - #15 [in_progress] Subject (owner: agent)
// or: #15. Subject here (status: in_progress)
const match = line.match(/#(\d+)\.?\s*(?:\[(\w+)\]\s*)?(.+?)(?:\s*\((?:owner:\s*\w+)?\))?$/);
if (match) {
const [, id, status, subject] = match;
const blockedMatch = line.match(/blockedBy:\s*\[([^\]]*)\]/);
tasks.push({
id,
subject: subject.trim(),
status: (status as TaskItem['status']) || 'pending',
blockedBy: blockedMatch ? blockedMatch[1].split(',').map(s => s.trim()).filter(Boolean) : undefined
});
}
}
return tasks;
}
const statusConfig = {
completed: {
icon: (
<svg className="w-3.5 h-3.5 text-green-500 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
textClass: 'line-through text-gray-400 dark:text-gray-500',
badgeClass: 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 border-green-200 dark:border-green-800'
},
in_progress: {
icon: (
<svg className="w-3.5 h-3.5 text-blue-500 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
textClass: 'text-gray-900 dark:text-gray-100',
badgeClass: 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 border-blue-200 dark:border-blue-800'
},
pending: {
icon: (
<svg className="w-3.5 h-3.5 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="9" strokeWidth={2} />
</svg>
),
textClass: 'text-gray-700 dark:text-gray-300',
badgeClass: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 border-gray-200 dark:border-gray-700'
}
};
/**
* Renders task list results with proper status icons and compact layout
* Parses text content from TaskList/TaskGet results
*/
export const TaskListContent: React.FC<TaskListContentProps> = ({ content }) => {
const tasks = parseTaskContent(content);
// If we couldn't parse any tasks, fall back to text display
if (tasks.length === 0) {
return (
<pre className="text-[11px] font-mono text-gray-600 dark:text-gray-400 whitespace-pre-wrap">
{content}
</pre>
);
}
const completed = tasks.filter(t => t.status === 'completed').length;
const total = tasks.length;
return (
<div>
<div className="flex items-center gap-2 mb-1.5">
<span className="text-[11px] text-gray-500 dark:text-gray-400">
{completed}/{total} completed
</span>
<div className="flex-1 h-1 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div
className="h-full bg-green-500 dark:bg-green-400 rounded-full transition-all"
style={{ width: `${total > 0 ? (completed / total) * 100 : 0}%` }}
/>
</div>
</div>
<div className="space-y-px">
{tasks.map((task) => {
const config = statusConfig[task.status] || statusConfig.pending;
return (
<div
key={task.id}
className="flex items-center gap-1.5 py-0.5 group"
>
<span className="flex-shrink-0">{config.icon}</span>
<span className="text-[11px] font-mono text-gray-400 dark:text-gray-500 flex-shrink-0">
#{task.id}
</span>
<span className={`text-xs truncate flex-1 ${config.textClass}`}>
{task.subject}
</span>
<span className={`text-[10px] px-1 py-px rounded border flex-shrink-0 ${config.badgeClass}`}>
{task.status.replace('_', ' ')}
</span>
</div>
);
})}
</div>
</div>
);
};

View File

@@ -0,0 +1,48 @@
import React from 'react';
interface TextContentProps {
content: string;
format?: 'plain' | 'json' | 'code';
className?: string;
}
/**
* Renders plain text, JSON, or code content
* Used by: Raw parameters, generic text results, JSON responses
*/
export const TextContent: React.FC<TextContentProps> = ({
content,
format = 'plain',
className = ''
}) => {
if (format === 'json') {
let formattedJson = content;
try {
const parsed = JSON.parse(content);
formattedJson = JSON.stringify(parsed, null, 2);
} catch (e) {
// If parsing fails, use original content
}
return (
<pre className={`mt-1 text-xs bg-gray-900 dark:bg-gray-950 text-gray-100 p-2.5 rounded overflow-x-auto font-mono ${className}`}>
{formattedJson}
</pre>
);
}
if (format === 'code') {
return (
<pre className={`mt-1 text-xs bg-gray-50 dark:bg-gray-800/50 border border-gray-200/50 dark:border-gray-700/50 p-2 rounded whitespace-pre-wrap break-words overflow-hidden text-gray-700 dark:text-gray-300 font-mono ${className}`}>
{content}
</pre>
);
}
// Plain text
return (
<div className={`mt-1 text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap ${className}`}>
{content}
</div>
);
};

View File

@@ -0,0 +1,23 @@
import React from 'react';
import TodoList from '../../../../TodoList';
interface TodoListContentProps {
todos: Array<{
id?: string;
content: string;
status: string;
priority?: string;
}>;
isResult?: boolean;
}
/**
* Renders a todo list
* Used by: TodoWrite, TodoRead
*/
export const TodoListContent: React.FC<TodoListContentProps> = ({
todos,
isResult = false
}) => {
return <TodoList todos={todos} isResult={isResult} />;
};

View File

@@ -0,0 +1,6 @@
export { MarkdownContent } from './MarkdownContent';
export { FileListContent } from './FileListContent';
export { TodoListContent } from './TodoListContent';
export { TaskListContent } from './TaskListContent';
export { TextContent } from './TextContent';
export { QuestionAnswerContent } from './QuestionAnswerContent';

View File

@@ -0,0 +1,88 @@
import React, { useMemo } from 'react';
type DiffLine = {
type: string;
content: string;
lineNum: number;
};
interface DiffViewerProps {
oldContent: string;
newContent: string;
filePath: string;
createDiff: (oldStr: string, newStr: string) => DiffLine[];
onFileClick?: () => void;
badge?: string;
badgeColor?: 'gray' | 'green';
}
/**
* Compact diff viewer — VS Code-style
*/
export const DiffViewer: React.FC<DiffViewerProps> = ({
oldContent,
newContent,
filePath,
createDiff,
onFileClick,
badge = 'Diff',
badgeColor = 'gray'
}) => {
const badgeClasses = badgeColor === 'green'
? 'bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400'
: 'bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400';
const diffLines = useMemo(
() => createDiff(oldContent, newContent),
[createDiff, oldContent, newContent]
);
return (
<div className="border border-gray-200/60 dark:border-gray-700/50 rounded overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-2.5 py-1 bg-gray-50/80 dark:bg-gray-800/40 border-b border-gray-200/60 dark:border-gray-700/50">
{onFileClick ? (
<button
onClick={onFileClick}
className="text-[11px] font-mono text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 truncate cursor-pointer transition-colors"
>
{filePath}
</button>
) : (
<span className="text-[11px] font-mono text-gray-600 dark:text-gray-400 truncate">
{filePath}
</span>
)}
<span className={`text-[10px] font-medium px-1.5 py-px rounded ${badgeClasses} flex-shrink-0 ml-2`}>
{badge}
</span>
</div>
{/* Diff lines */}
<div className="text-[11px] font-mono leading-[18px]">
{diffLines.map((diffLine, i) => (
<div key={i} className="flex">
<span
className={`w-6 text-center select-none flex-shrink-0 ${
diffLine.type === 'removed'
? 'bg-red-50 dark:bg-red-950/30 text-red-400 dark:text-red-500'
: 'bg-green-50 dark:bg-green-950/30 text-green-400 dark:text-green-500'
}`}
>
{diffLine.type === 'removed' ? '-' : '+'}
</span>
<span
className={`px-2 flex-1 whitespace-pre-wrap ${
diffLine.type === 'removed'
? 'bg-red-50/50 dark:bg-red-950/20 text-red-800 dark:text-red-200'
: 'bg-green-50/50 dark:bg-green-950/20 text-green-800 dark:text-green-200'
}`}
>
{diffLine.content}
</span>
</div>
))}
</div>
</div>
);
};

View File

@@ -0,0 +1,384 @@
import React, { useState, useCallback, useRef, useEffect } from 'react';
import type { PermissionPanelProps } from '../../configs/permissionPanelRegistry';
import type { Question } from '../../../types/types';
export const AskUserQuestionPanel: React.FC<PermissionPanelProps> = ({
request,
onDecision,
}) => {
const input = request.input as { questions?: Question[] } | undefined;
const questions: Question[] = input?.questions || [];
const [currentStep, setCurrentStep] = useState(0);
const [selections, setSelections] = useState<Map<number, Set<string>>>(() => new Map());
const [otherTexts, setOtherTexts] = useState<Map<number, string>>(() => new Map());
const [otherActive, setOtherActive] = useState<Map<number, boolean>>(() => new Map());
const [mounted, setMounted] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const otherInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
requestAnimationFrame(() => setMounted(true));
}, []);
// Focus the container for keyboard events when step changes
useEffect(() => {
if (!otherActive.get(currentStep)) {
containerRef.current?.focus();
}
}, [currentStep, otherActive]);
useEffect(() => {
if (otherActive.get(currentStep)) {
otherInputRef.current?.focus();
}
}, [otherActive, currentStep]);
const toggleOption = useCallback((qIdx: number, label: string, multiSelect: boolean) => {
setSelections(prev => {
const next = new Map(prev);
const current = new Set(next.get(qIdx) || []);
if (multiSelect) {
if (current.has(label)) current.delete(label);
else current.add(label);
} else {
current.clear();
current.add(label);
setOtherActive(p => { const n = new Map(p); n.set(qIdx, false); return n; });
}
next.set(qIdx, current);
return next;
});
}, []);
const toggleOther = useCallback((qIdx: number, multiSelect: boolean) => {
setOtherActive(prev => {
const next = new Map(prev);
const wasActive = next.get(qIdx) || false;
next.set(qIdx, !wasActive);
if (!multiSelect && !wasActive) {
setSelections(p => { const n = new Map(p); n.set(qIdx, new Set()); return n; });
}
return next;
});
}, []);
const setOtherText = useCallback((qIdx: number, text: string) => {
setOtherTexts(prev => { const next = new Map(prev); next.set(qIdx, text); return next; });
}, []);
const buildAnswers = useCallback(() => {
const answers: Record<string, string> = {};
questions.forEach((q, idx) => {
const selected = Array.from(selections.get(idx) || []);
const isOther = otherActive.get(idx) || false;
const otherText = (otherTexts.get(idx) || '').trim();
if (isOther && otherText) selected.push(otherText);
if (selected.length > 0) answers[q.question] = selected.join(', ');
});
return answers;
}, [questions, selections, otherActive, otherTexts]);
const handleSubmit = useCallback(() => {
onDecision(request.requestId, { allow: true, updatedInput: { ...input, answers: buildAnswers() } });
}, [onDecision, request.requestId, input, buildAnswers]);
const handleSkip = useCallback(() => {
onDecision(request.requestId, { allow: true, updatedInput: { ...input, answers: {} } });
}, [onDecision, request.requestId, input]);
// Keyboard handler for number keys and navigation
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
// Don't capture keys when typing in the "Other" input
if (e.target instanceof HTMLInputElement) return;
const q = questions[currentStep];
if (!q) return;
const multi = q.multiSelect || false;
const optCount = q.options.length;
// Number keys 1-9 for options
const num = parseInt(e.key);
if (!isNaN(num) && num >= 1 && num <= optCount) {
e.preventDefault();
toggleOption(currentStep, q.options[num - 1].label, multi);
return;
}
// 0 for "Other"
if (e.key === '0') {
e.preventDefault();
toggleOther(currentStep, multi);
return;
}
// Enter to advance / submit
if (e.key === 'Enter') {
e.preventDefault();
const isLast = currentStep === questions.length - 1;
if (isLast) handleSubmit();
else setCurrentStep(s => s + 1);
return;
}
// Escape to skip
if (e.key === 'Escape') {
e.preventDefault();
handleSkip();
return;
}
}, [currentStep, questions, toggleOption, toggleOther, handleSubmit, handleSkip]);
if (questions.length === 0) return null;
const total = questions.length;
const isSingle = total === 1;
const q = questions[currentStep];
const multi = q.multiSelect || false;
const selected = selections.get(currentStep) || new Set<string>();
const isOtherOn = otherActive.get(currentStep) || false;
const isLast = currentStep === total - 1;
const isFirst = currentStep === 0;
const hasCurrentSelection = selected.size > 0 || (isOtherOn && (otherTexts.get(currentStep) || '').trim().length > 0);
return (
<div
ref={containerRef}
tabIndex={-1}
onKeyDown={handleKeyDown}
className={`w-full outline-none transition-all duration-500 ease-out ${
mounted ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-3'
}`}
>
<div className="relative overflow-hidden rounded-2xl border border-gray-200/80 dark:border-gray-700/50 bg-white dark:bg-gray-800/90 shadow-lg dark:shadow-2xl">
{/* Accent line */}
<div className="absolute top-0 left-0 right-0 h-[2px] bg-gradient-to-r from-blue-500 via-cyan-400 to-teal-400" />
{/* Header + Question — compact */}
<div className="px-4 pt-3.5 pb-2">
<div className="flex items-center gap-2.5 mb-1.5">
{/* Question icon */}
<div className="relative flex-shrink-0">
<div className="w-6 h-6 rounded-lg bg-gradient-to-br from-blue-500/10 to-cyan-500/10 dark:from-blue-400/15 dark:to-cyan-400/15 flex items-center justify-center">
<svg className="w-3.5 h-3.5 text-blue-600 dark:text-blue-400" fill="none" viewBox="0 0 24 24" strokeWidth={1.75} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827m0 3h.01" />
</svg>
</div>
<div className="absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full bg-cyan-400 dark:bg-cyan-500 animate-pulse" />
</div>
<div className="flex items-center gap-2 min-w-0 flex-1">
<span className="text-[10px] font-medium tracking-wide uppercase text-gray-400 dark:text-gray-500">
Claude needs your input
</span>
{q.header && (
<span className="inline-flex items-center px-1.5 py-px rounded text-[9px] font-semibold uppercase tracking-wider bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 border border-blue-100 dark:border-blue-800/50">
{q.header}
</span>
)}
</div>
{/* Step counter */}
{!isSingle && (
<span className="text-[10px] tabular-nums text-gray-400 dark:text-gray-500 flex-shrink-0">
{currentStep + 1}/{total}
</span>
)}
</div>
{/* Progress dots (multi-question) */}
{!isSingle && (
<div className="flex items-center gap-1 mb-2">
{questions.map((_, i) => (
<button
key={i}
type="button"
onClick={() => setCurrentStep(i)}
className={`h-[3px] rounded-full transition-all duration-300 ${
i === currentStep
? 'w-5 bg-blue-500 dark:bg-blue-400'
: i < currentStep
? 'w-2.5 bg-blue-300 dark:bg-blue-600'
: 'w-2.5 bg-gray-200 dark:bg-gray-700'
}`}
/>
))}
</div>
)}
{/* Question text */}
<p className="text-[14px] leading-snug font-medium text-gray-900 dark:text-gray-100">
{q.question}
</p>
{multi && (
<span className="text-[10px] text-gray-400 dark:text-gray-500">Select all that apply</span>
)}
</div>
{/* Options — tight spacing */}
<div className="px-4 pb-2 max-h-48 overflow-y-auto scrollbar-thin" role={multi ? 'group' : 'radiogroup'} aria-label={q.question}>
<div className="space-y-1">
{q.options.map((opt, optIdx) => {
const isSelected = selected.has(opt.label);
return (
<button
key={opt.label}
type="button"
onClick={() => toggleOption(currentStep, opt.label, multi)}
className={`group w-full text-left flex items-center gap-2.5 px-3 py-2 rounded-lg border transition-all duration-150 ${
isSelected
? 'border-blue-300 dark:border-blue-600 bg-blue-50/80 dark:bg-blue-900/25 ring-1 ring-blue-200/50 dark:ring-blue-700/30'
: 'border-gray-200 dark:border-gray-700/60 hover:border-gray-300 dark:hover:border-gray-600 hover:bg-gray-50/60 dark:hover:bg-gray-750/50'
}`}
>
{/* Keyboard hint */}
<kbd className={`flex-shrink-0 w-5 h-5 rounded text-[10px] font-mono flex items-center justify-center transition-all duration-150 ${
isSelected
? 'bg-blue-500 dark:bg-blue-500 text-white font-semibold'
: 'bg-gray-100 dark:bg-gray-800 text-gray-400 dark:text-gray-500 border border-gray-200 dark:border-gray-700 group-hover:border-gray-300 dark:group-hover:border-gray-600'
}`}>
{optIdx + 1}
</kbd>
<div className="flex-1 min-w-0">
<div className={`text-[13px] leading-tight transition-colors duration-150 ${
isSelected
? 'text-gray-900 dark:text-gray-100 font-medium'
: 'text-gray-700 dark:text-gray-300'
}`}>
{opt.label}
</div>
{opt.description && (
<div className={`text-[11px] leading-snug transition-colors duration-150 ${
isSelected
? 'text-blue-600/70 dark:text-blue-300/70'
: 'text-gray-400 dark:text-gray-500'
}`}>
{opt.description}
</div>
)}
</div>
{/* Selection check */}
{isSelected && (
<svg className="w-4 h-4 text-blue-500 dark:text-blue-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
)}
</button>
);
})}
{/* "Other" option */}
<button
type="button"
onClick={() => toggleOther(currentStep, multi)}
className={`group w-full text-left flex items-center gap-2.5 px-3 py-2 rounded-lg border transition-all duration-150 ${
isOtherOn
? 'border-blue-300 dark:border-blue-600 bg-blue-50/80 dark:bg-blue-900/25 ring-1 ring-blue-200/50 dark:ring-blue-700/30'
: 'border-dashed border-gray-200 dark:border-gray-700/60 hover:border-gray-300 dark:hover:border-gray-600 hover:bg-gray-50/60 dark:hover:bg-gray-750/50'
}`}
>
<kbd className={`flex-shrink-0 w-5 h-5 rounded text-[10px] font-mono flex items-center justify-center transition-all duration-150 ${
isOtherOn
? 'bg-blue-500 dark:bg-blue-500 text-white font-semibold'
: 'bg-gray-100 dark:bg-gray-800 text-gray-400 dark:text-gray-500 border border-gray-200 dark:border-gray-700 group-hover:border-gray-300 dark:group-hover:border-gray-600'
}`}>
0
</kbd>
<span className={`text-[13px] leading-tight transition-colors ${
isOtherOn
? 'text-gray-900 dark:text-gray-100 font-medium'
: 'text-gray-500 dark:text-gray-400'
}`}>
Other...
</span>
{isOtherOn && (
<svg className="w-4 h-4 text-blue-500 dark:text-blue-400 flex-shrink-0 ml-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
)}
</button>
{/* Other text input — inline */}
{isOtherOn && (
<div className="pl-[30px] pr-0.5">
<div className="relative">
<input
ref={otherInputRef}
type="text"
value={otherTexts.get(currentStep) || ''}
onChange={(e) => setOtherText(currentStep, e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
if (isLast) handleSubmit();
else setCurrentStep(s => s + 1);
}
// Prevent container keydown from firing
e.stopPropagation();
}}
placeholder="Type your answer..."
className="w-full text-[13px] rounded-lg border-0 bg-gray-50 dark:bg-gray-900/60 text-gray-900 dark:text-gray-100 px-3 py-1.5 outline-none ring-1 ring-gray-200 dark:ring-gray-700 focus:ring-2 focus:ring-blue-400 dark:focus:ring-blue-500 placeholder:text-gray-400 dark:placeholder:text-gray-600 transition-shadow duration-200"
/>
<kbd className="absolute right-2 top-1/2 -translate-y-1/2 text-[9px] font-mono text-gray-300 dark:text-gray-600 bg-gray-100 dark:bg-gray-800 px-1 py-0.5 rounded border border-gray-200 dark:border-gray-700">
Enter
</kbd>
</div>
</div>
)}
</div>
</div>
{/* Footer — compact */}
<div className="px-4 py-2 border-t border-gray-100 dark:border-gray-700/50 bg-gray-50/50 dark:bg-gray-800/50 flex items-center justify-between gap-2">
<button
type="button"
onClick={handleSkip}
className="text-[11px] text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
>
{isSingle ? 'Skip' : 'Skip all'}
<span className="ml-1 text-[9px] text-gray-300 dark:text-gray-600">Esc</span>
</button>
<div className="flex items-center gap-1.5">
{!isSingle && !isFirst && (
<button
type="button"
onClick={() => setCurrentStep(s => s - 1)}
className="inline-flex items-center gap-0.5 text-[11px] font-medium px-2.5 py-1.5 rounded-lg text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700/60 transition-all duration-150"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
Back
</button>
)}
{isLast ? (
<button
type="button"
onClick={handleSubmit}
disabled={!hasCurrentSelection && !Object.keys(buildAnswers()).length}
className="inline-flex items-center gap-1 text-[11px] font-semibold px-3.5 py-1.5 rounded-lg bg-gradient-to-r from-blue-600 to-blue-500 dark:from-blue-500 dark:to-blue-600 text-white shadow-sm hover:shadow-md disabled:opacity-30 disabled:cursor-not-allowed disabled:shadow-none transition-all duration-200"
>
Submit
<span className="text-[9px] opacity-70 font-mono ml-0.5">Enter</span>
</button>
) : (
<button
type="button"
onClick={() => setCurrentStep(s => s + 1)}
className="inline-flex items-center gap-1 text-[11px] font-semibold px-3.5 py-1.5 rounded-lg bg-gradient-to-r from-blue-600 to-blue-500 dark:from-blue-500 dark:to-blue-600 text-white shadow-sm hover:shadow-md transition-all duration-200"
>
Next
<span className="text-[9px] opacity-70 font-mono ml-0.5">Enter</span>
</button>
)}
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1 @@
export { AskUserQuestionPanel } from './AskUserQuestionPanel';

View File

@@ -0,0 +1,233 @@
import React, { useState } from 'react';
type ActionType = 'copy' | 'open-file' | 'jump-to-results' | 'none';
interface OneLineDisplayProps {
toolName: string;
icon?: string;
label?: string;
value: string;
secondary?: string;
action?: ActionType;
onAction?: () => void;
style?: string;
wrapText?: boolean;
colorScheme?: {
primary?: string;
secondary?: string;
background?: string;
border?: string;
icon?: string;
};
resultId?: string;
toolResult?: any;
toolId?: string;
}
// Fallback for environments where the async Clipboard API is unavailable or blocked.
const copyWithLegacyExecCommand = (text: string): boolean => {
if (typeof document === 'undefined' || !document.body) {
return false;
}
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.setAttribute('readonly', '');
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
textarea.style.left = '-9999px';
document.body.appendChild(textarea);
textarea.select();
textarea.setSelectionRange(0, text.length);
let copied = false;
try {
copied = document.execCommand('copy');
} catch {
copied = false;
} finally {
document.body.removeChild(textarea);
}
return copied;
};
const copyTextToClipboard = async (text: string): Promise<boolean> => {
if (
typeof navigator !== 'undefined' &&
typeof window !== 'undefined' &&
window.isSecureContext &&
navigator.clipboard?.writeText
) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
// Fall back below when writeText is rejected (permissions/insecure contexts/browser limits).
}
}
return copyWithLegacyExecCommand(text);
};
/**
* Unified one-line display for simple tool inputs and results
* Used by: Bash, Read, Grep/Glob (minimized), TodoRead, etc.
*/
export const OneLineDisplay: React.FC<OneLineDisplayProps> = ({
toolName,
icon,
label,
value,
secondary,
action = 'none',
onAction,
style,
wrapText = false,
colorScheme = {
primary: 'text-gray-700 dark:text-gray-300',
secondary: 'text-gray-500 dark:text-gray-400',
background: '',
border: 'border-gray-300 dark:border-gray-600',
icon: 'text-gray-500 dark:text-gray-400'
},
resultId,
toolResult,
toolId
}) => {
const [copied, setCopied] = useState(false);
const isTerminal = style === 'terminal';
const handleAction = async () => {
if (action === 'copy' && value) {
const didCopy = await copyTextToClipboard(value);
if (!didCopy) {
return;
}
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} else if (onAction) {
onAction();
}
};
const renderCopyButton = () => (
<button
onClick={handleAction}
className="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-all ml-1 flex-shrink-0"
title="Copy to clipboard"
aria-label="Copy to clipboard"
>
{copied ? (
<svg className="w-3 h-3 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
) : (
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
)}
</button>
);
// Terminal style: dark pill only around the command
if (isTerminal) {
return (
<div className="group my-1">
<div className="flex items-start gap-2">
<div className="flex items-center gap-1.5 flex-shrink-0 pt-0.5">
<svg className="w-3 h-3 text-green-500 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
<div className="flex-1 min-w-0 flex items-start gap-2">
<div className="bg-gray-900 dark:bg-black rounded px-2.5 py-1 flex-1 min-w-0">
<code className={`text-xs text-green-400 font-mono ${wrapText ? 'whitespace-pre-wrap break-all' : 'block truncate'}`}>
<span className="text-green-600 dark:text-green-500 select-none">$ </span>{value}
</code>
</div>
{action === 'copy' && renderCopyButton()}
</div>
</div>
{secondary && (
<div className="ml-7 mt-1">
<span className="text-[11px] text-gray-400 dark:text-gray-500 italic">
{secondary}
</span>
</div>
)}
</div>
);
}
// File open style - show filename only, full path on hover
if (action === 'open-file') {
const displayName = value.split('/').pop() || value;
return (
<div className={`group flex items-center gap-1.5 border-l-2 ${colorScheme.border} pl-3 py-0.5 my-0.5`}>
<span className="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0">{label || toolName}</span>
<span className="text-gray-300 dark:text-gray-600 text-[10px]">/</span>
<button
onClick={handleAction}
className="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 font-mono hover:underline transition-colors truncate"
title={value}
>
{displayName}
</button>
</div>
);
}
// Search / jump-to-results style
if (action === 'jump-to-results') {
return (
<div className={`group flex items-center gap-1.5 border-l-2 ${colorScheme.border} pl-3 py-0.5 my-0.5`}>
<span className="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0">{label || toolName}</span>
<span className="text-gray-300 dark:text-gray-600 text-[10px]">/</span>
<span className={`text-xs font-mono truncate flex-1 min-w-0 ${colorScheme.primary}`}>
{value}
</span>
{secondary && (
<span className="text-[11px] text-gray-400 dark:text-gray-500 italic flex-shrink-0">
{secondary}
</span>
)}
{toolResult && (
<a
href={`#tool-result-${toolId}`}
className="flex-shrink-0 text-[11px] text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors flex items-center gap-0.5"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</a>
)}
</div>
);
}
// Default one-line style
return (
<div className={`group flex items-center gap-1.5 ${colorScheme.background || ''} border-l-2 ${colorScheme.border} pl-3 py-0.5 my-0.5`}>
{icon && icon !== 'terminal' && (
<span className={`${colorScheme.icon} flex-shrink-0 text-xs`}>{icon}</span>
)}
{!icon && (label || toolName) && (
<span className="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0">{label || toolName}</span>
)}
{(icon || label || toolName) && (
<span className="text-gray-300 dark:text-gray-600 text-[10px]">/</span>
)}
<span className={`text-xs font-mono ${wrapText ? 'whitespace-pre-wrap break-all' : 'truncate'} flex-1 min-w-0 ${colorScheme.primary}`}>
{value}
</span>
{secondary && (
<span className={`text-[11px] ${colorScheme.secondary} italic flex-shrink-0`}>
{secondary}
</span>
)}
{action === 'copy' && renderCopyButton()}
</div>
);
};

View File

@@ -0,0 +1,6 @@
export { CollapsibleSection } from './CollapsibleSection';
export { DiffViewer } from './DiffViewer';
export { OneLineDisplay } from './OneLineDisplay';
export { CollapsibleDisplay } from './CollapsibleDisplay';
export * from './ContentRenderers';
export * from './InteractiveRenderers';

View File

@@ -0,0 +1,25 @@
import type { ComponentType } from 'react';
import type { PendingPermissionRequest } from '../../types/types';
export interface PermissionPanelProps {
request: PendingPermissionRequest;
onDecision: (
requestIds: string | string[],
decision: { allow?: boolean; message?: string; updatedInput?: unknown },
) => void;
}
const registry: Record<string, ComponentType<PermissionPanelProps>> = {};
export function registerPermissionPanel(
toolName: string,
component: ComponentType<PermissionPanelProps>,
): void {
registry[toolName] = component;
}
export function getPermissionPanel(
toolName: string,
): ComponentType<PermissionPanelProps> | null {
return registry[toolName] || null;
}

View File

@@ -0,0 +1,597 @@
/**
* Centralized tool configuration registry
* Defines display behavior for all tool types
*/
export interface ToolDisplayConfig {
input: {
type: 'one-line' | 'collapsible' | 'hidden';
// One-line config
icon?: string;
label?: string;
getValue?: (input: any) => string;
getSecondary?: (input: any) => string | undefined;
action?: 'copy' | 'open-file' | 'jump-to-results' | 'none';
style?: string;
wrapText?: boolean;
colorScheme?: {
primary?: string;
secondary?: string;
background?: string;
border?: string;
icon?: string;
};
// Collapsible config
title?: string | ((input: any) => string);
defaultOpen?: boolean;
contentType?: 'diff' | 'markdown' | 'file-list' | 'todo-list' | 'text' | 'task' | 'question-answer';
getContentProps?: (input: any, helpers?: any) => any;
actionButton?: 'file-button' | 'none';
};
result?: {
hidden?: boolean;
hideOnSuccess?: boolean;
type?: 'one-line' | 'collapsible' | 'special';
title?: string | ((result: any) => string);
defaultOpen?: boolean;
// Special result handlers
contentType?: 'markdown' | 'file-list' | 'todo-list' | 'text' | 'success-message' | 'task' | 'question-answer';
getMessage?: (result: any) => string;
getContentProps?: (result: any) => any;
};
}
export const TOOL_CONFIGS: Record<string, ToolDisplayConfig> = {
// ============================================================================
// COMMAND TOOLS
// ============================================================================
Bash: {
input: {
type: 'one-line',
icon: 'terminal',
getValue: (input) => input.command,
getSecondary: (input) => input.description,
action: 'copy',
style: 'terminal',
wrapText: true,
colorScheme: {
primary: 'text-green-400 font-mono',
secondary: 'text-gray-400',
background: '',
border: 'border-green-500 dark:border-green-400',
icon: 'text-green-500 dark:text-green-400'
}
},
result: {
hideOnSuccess: true,
type: 'special'
}
},
// ============================================================================
// FILE OPERATION TOOLS
// ============================================================================
Read: {
input: {
type: 'one-line',
label: 'Read',
getValue: (input) => input.file_path || '',
action: 'open-file',
colorScheme: {
primary: 'text-gray-700 dark:text-gray-300',
background: '',
border: 'border-gray-300 dark:border-gray-600',
icon: 'text-gray-500 dark:text-gray-400'
}
},
result: {
hidden: true
}
},
Edit: {
input: {
type: 'collapsible',
title: (input) => {
const filename = input.file_path?.split('/').pop() || input.file_path || 'file';
return `${filename}`;
},
defaultOpen: false,
contentType: 'diff',
actionButton: 'none',
getContentProps: (input) => ({
oldContent: input.old_string,
newContent: input.new_string,
filePath: input.file_path,
badge: 'Edit',
badgeColor: 'gray'
})
},
result: {
hideOnSuccess: true
}
},
Write: {
input: {
type: 'collapsible',
title: (input) => {
const filename = input.file_path?.split('/').pop() || input.file_path || 'file';
return `${filename}`;
},
defaultOpen: false,
contentType: 'diff',
actionButton: 'none',
getContentProps: (input) => ({
oldContent: '',
newContent: input.content,
filePath: input.file_path,
badge: 'New',
badgeColor: 'green'
})
},
result: {
hideOnSuccess: true
}
},
ApplyPatch: {
input: {
type: 'collapsible',
title: (input) => {
const filename = input.file_path?.split('/').pop() || input.file_path || 'file';
return `${filename}`;
},
defaultOpen: false,
contentType: 'diff',
actionButton: 'none',
getContentProps: (input) => ({
oldContent: input.old_string,
newContent: input.new_string,
filePath: input.file_path,
badge: 'Patch',
badgeColor: 'gray'
})
},
result: {
hideOnSuccess: true
}
},
// ============================================================================
// SEARCH TOOLS
// ============================================================================
Grep: {
input: {
type: 'one-line',
label: 'Grep',
getValue: (input) => input.pattern,
getSecondary: (input) => input.path ? `in ${input.path}` : undefined,
action: 'jump-to-results',
colorScheme: {
primary: 'text-gray-700 dark:text-gray-300',
secondary: 'text-gray-500 dark:text-gray-400',
background: '',
border: 'border-gray-400 dark:border-gray-500',
icon: 'text-gray-500 dark:text-gray-400'
}
},
result: {
type: 'collapsible',
defaultOpen: false,
title: (result) => {
const toolData = result.toolUseResult || {};
const count = toolData.numFiles || toolData.filenames?.length || 0;
return `Found ${count} ${count === 1 ? 'file' : 'files'}`;
},
contentType: 'file-list',
getContentProps: (result) => {
const toolData = result.toolUseResult || {};
return {
files: toolData.filenames || []
};
}
}
},
Glob: {
input: {
type: 'one-line',
label: 'Glob',
getValue: (input) => input.pattern,
getSecondary: (input) => input.path ? `in ${input.path}` : undefined,
action: 'jump-to-results',
colorScheme: {
primary: 'text-gray-700 dark:text-gray-300',
secondary: 'text-gray-500 dark:text-gray-400',
background: '',
border: 'border-gray-400 dark:border-gray-500',
icon: 'text-gray-500 dark:text-gray-400'
}
},
result: {
type: 'collapsible',
defaultOpen: false,
title: (result) => {
const toolData = result.toolUseResult || {};
const count = toolData.numFiles || toolData.filenames?.length || 0;
return `Found ${count} ${count === 1 ? 'file' : 'files'}`;
},
contentType: 'file-list',
getContentProps: (result) => {
const toolData = result.toolUseResult || {};
return {
files: toolData.filenames || []
};
}
}
},
// ============================================================================
// TODO TOOLS
// ============================================================================
TodoWrite: {
input: {
type: 'collapsible',
title: 'Updating todo list',
defaultOpen: false,
contentType: 'todo-list',
getContentProps: (input) => ({
todos: input.todos
})
},
result: {
type: 'collapsible',
contentType: 'success-message',
getMessage: () => 'Todo list updated'
}
},
TodoRead: {
input: {
type: 'one-line',
label: 'TodoRead',
getValue: () => 'reading list',
action: 'none',
colorScheme: {
primary: 'text-gray-500 dark:text-gray-400',
border: 'border-violet-400 dark:border-violet-500'
}
},
result: {
type: 'collapsible',
contentType: 'todo-list',
getContentProps: (result) => {
try {
const content = String(result.content || '');
let todos = null;
if (content.startsWith('[')) {
todos = JSON.parse(content);
}
return { todos, isResult: true };
} catch (e) {
return { todos: [], isResult: true };
}
}
}
},
// ============================================================================
// TASK TOOLS (TaskCreate, TaskUpdate, TaskList, TaskGet)
// ============================================================================
TaskCreate: {
input: {
type: 'one-line',
label: 'Task',
getValue: (input) => input.subject || 'Creating task',
getSecondary: (input) => input.status || undefined,
action: 'none',
colorScheme: {
primary: 'text-gray-700 dark:text-gray-300',
border: 'border-violet-400 dark:border-violet-500',
icon: 'text-violet-500 dark:text-violet-400'
}
},
result: {
hideOnSuccess: true
}
},
TaskUpdate: {
input: {
type: 'one-line',
label: 'Task',
getValue: (input) => {
const parts = [];
if (input.taskId) parts.push(`#${input.taskId}`);
if (input.status) parts.push(input.status);
if (input.subject) parts.push(`"${input.subject}"`);
return parts.join(' → ') || 'updating';
},
action: 'none',
colorScheme: {
primary: 'text-gray-700 dark:text-gray-300',
border: 'border-violet-400 dark:border-violet-500',
icon: 'text-violet-500 dark:text-violet-400'
}
},
result: {
hideOnSuccess: true
}
},
TaskList: {
input: {
type: 'one-line',
label: 'Tasks',
getValue: () => 'listing tasks',
action: 'none',
colorScheme: {
primary: 'text-gray-500 dark:text-gray-400',
border: 'border-violet-400 dark:border-violet-500',
icon: 'text-violet-500 dark:text-violet-400'
}
},
result: {
type: 'collapsible',
defaultOpen: true,
title: 'Task list',
contentType: 'task',
getContentProps: (result) => ({
content: String(result?.content || '')
})
}
},
TaskGet: {
input: {
type: 'one-line',
label: 'Task',
getValue: (input) => input.taskId ? `#${input.taskId}` : 'fetching',
action: 'none',
colorScheme: {
primary: 'text-gray-700 dark:text-gray-300',
border: 'border-violet-400 dark:border-violet-500',
icon: 'text-violet-500 dark:text-violet-400'
}
},
result: {
type: 'collapsible',
defaultOpen: true,
title: 'Task details',
contentType: 'task',
getContentProps: (result) => ({
content: String(result?.content || '')
})
}
},
// ============================================================================
// SUBAGENT TASK TOOL
// ============================================================================
Task: {
input: {
type: 'collapsible',
title: (input) => {
const subagentType = input.subagent_type || 'Agent';
const description = input.description || 'Running task';
return `Subagent / ${subagentType}: ${description}`;
},
defaultOpen: true,
contentType: 'markdown',
getContentProps: (input) => {
// If only prompt exists (and required fields), show just the prompt
// Otherwise show all available fields
const hasOnlyPrompt = input.prompt &&
!input.model &&
!input.resume;
if (hasOnlyPrompt) {
return {
content: input.prompt || ''
};
}
// Format multiple fields
const parts = [];
if (input.model) {
parts.push(`**Model:** ${input.model}`);
}
if (input.prompt) {
parts.push(`**Prompt:**\n${input.prompt}`);
}
if (input.resume) {
parts.push(`**Resuming from:** ${input.resume}`);
}
return {
content: parts.join('\n\n')
};
},
colorScheme: {
border: 'border-purple-500 dark:border-purple-400',
icon: 'text-purple-500 dark:text-purple-400'
}
},
result: {
type: 'collapsible',
title: (result) => {
// Check if result has content with type array (agent results often have this structure)
if (result && result.content && Array.isArray(result.content)) {
return 'Subagent Response';
}
return 'Subagent Result';
},
defaultOpen: true,
contentType: 'markdown',
getContentProps: (result) => {
// Handle agent results which may have complex structure
if (result && result.content) {
// If content is an array (typical for agent responses with multiple text blocks)
if (Array.isArray(result.content)) {
const textContent = result.content
.filter((item: any) => item.type === 'text')
.map((item: any) => item.text)
.join('\n\n');
return { content: textContent || 'No response text' };
}
// If content is already a string
return { content: String(result.content) };
}
// Fallback to string representation
return { content: String(result || 'No response') };
}
}
},
// ============================================================================
// INTERACTIVE TOOLS
// ============================================================================
AskUserQuestion: {
input: {
type: 'collapsible',
title: (input: any) => {
const count = input.questions?.length || 0;
const hasAnswers = input.answers && Object.keys(input.answers).length > 0;
if (count === 1) {
const header = input.questions[0]?.header || 'Question';
return hasAnswers ? `${header} — answered` : header;
}
return hasAnswers ? `${count} questions — answered` : `${count} questions`;
},
defaultOpen: true,
contentType: 'question-answer',
getContentProps: (input: any) => ({
questions: input.questions || [],
answers: input.answers || {}
}),
},
result: {
hideOnSuccess: true
}
},
// ============================================================================
// PLAN TOOLS
// ============================================================================
exit_plan_mode: {
input: {
type: 'collapsible',
title: 'Implementation plan',
defaultOpen: true,
contentType: 'markdown',
getContentProps: (input) => ({
content: input.plan?.replace(/\\n/g, '\n') || input.plan
})
},
result: {
type: 'collapsible',
contentType: 'markdown',
getContentProps: (result) => {
try {
let parsed = result.content;
if (typeof parsed === 'string') {
parsed = JSON.parse(parsed);
}
return {
content: parsed.plan?.replace(/\\n/g, '\n') || parsed.plan
};
} catch (e) {
return { content: '' };
}
}
}
},
// Also register as ExitPlanMode (the actual tool name used by Claude)
ExitPlanMode: {
input: {
type: 'collapsible',
title: 'Implementation plan',
defaultOpen: true,
contentType: 'markdown',
getContentProps: (input) => ({
content: input.plan?.replace(/\\n/g, '\n') || input.plan
})
},
result: {
type: 'collapsible',
contentType: 'markdown',
getContentProps: (result) => {
try {
let parsed = result.content;
if (typeof parsed === 'string') {
parsed = JSON.parse(parsed);
}
return {
content: parsed.plan?.replace(/\\n/g, '\n') || parsed.plan
};
} catch (e) {
return { content: '' };
}
}
}
},
// ============================================================================
// DEFAULT FALLBACK
// ============================================================================
Default: {
input: {
type: 'collapsible',
title: 'Parameters',
defaultOpen: false,
contentType: 'text',
getContentProps: (input) => ({
content: typeof input === 'string' ? input : JSON.stringify(input, null, 2),
format: 'code'
})
},
result: {
type: 'collapsible',
contentType: 'text',
getContentProps: (result) => ({
content: String(result?.content || ''),
format: 'plain'
})
}
}
};
/**
* Get configuration for a tool, with fallback to default
*/
export function getToolConfig(toolName: string): ToolDisplayConfig {
return TOOL_CONFIGS[toolName] || TOOL_CONFIGS.Default;
}
/**
* Check if a tool result should be hidden
*/
export function shouldHideToolResult(toolName: string, toolResult: any): boolean {
const config = getToolConfig(toolName);
if (!config.result) return false;
// Always hidden
if (config.result.hidden) return true;
// Hide on success only
if (config.result.hideOnSuccess && toolResult && !toolResult.isError) {
return true;
}
return false;
}

View File

@@ -0,0 +1,3 @@
export { ToolRenderer } from './ToolRenderer';
export { getToolConfig, shouldHideToolResult } from './configs/toolConfigs';
export * from './components';

View File

@@ -0,0 +1,104 @@
import type { Project, ProjectSession, SessionProvider } from '../../../types/app';
export type Provider = SessionProvider;
export type PermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan';
export interface ChatImage {
data: string;
name: string;
}
export interface ToolResult {
content?: unknown;
isError?: boolean;
timestamp?: string | number | Date;
toolUseResult?: unknown;
[key: string]: unknown;
}
export interface ChatMessage {
type: string;
content?: string;
timestamp: string | number | Date;
images?: ChatImage[];
reasoning?: string;
isThinking?: boolean;
isStreaming?: boolean;
isInteractivePrompt?: boolean;
isToolUse?: boolean;
toolName?: string;
toolInput?: unknown;
toolResult?: ToolResult | null;
toolId?: string;
toolCallId?: string;
[key: string]: unknown;
}
export interface ClaudeSettings {
allowedTools: string[];
disallowedTools: string[];
skipPermissions: boolean;
projectSortOrder: string;
lastUpdated?: string;
[key: string]: unknown;
}
export interface ClaudePermissionSuggestion {
toolName: string;
entry: string;
isAllowed: boolean;
}
export interface PermissionGrantResult {
success: boolean;
alreadyAllowed?: boolean;
updatedSettings?: ClaudeSettings;
}
export interface PendingPermissionRequest {
requestId: string;
toolName: string;
input?: unknown;
context?: unknown;
sessionId?: string | null;
receivedAt?: Date;
}
export interface QuestionOption {
label: string;
description?: string;
}
export interface Question {
question: string;
header?: string;
options: QuestionOption[];
multiSelect?: boolean;
}
export interface ChatInterfaceProps {
selectedProject: Project | null;
selectedSession: ProjectSession | null;
ws: WebSocket | null;
sendMessage: (message: unknown) => void;
latestMessage: any;
onFileOpen?: (filePath: string, diffInfo?: any) => void;
onInputFocusChange?: (focused: boolean) => void;
onSessionActive?: (sessionId?: string | null) => void;
onSessionInactive?: (sessionId?: string | null) => void;
onSessionProcessing?: (sessionId?: string | null) => void;
onSessionNotProcessing?: (sessionId?: string | null) => void;
processingSessions?: Set<string>;
onReplaceTemporarySession?: (sessionId?: string | null) => void;
onNavigateToSession?: (targetSessionId: string) => void;
onShowSettings?: () => void;
autoExpandTools?: boolean;
showRawParameters?: boolean;
showThinking?: boolean;
autoScrollToBottom?: boolean;
sendByCtrlEnter?: boolean;
externalMessageUpdate?: number;
onTaskClick?: (...args: unknown[]) => void;
onShowAllTasks?: (() => void) | null;
}

View File

@@ -0,0 +1,86 @@
export function decodeHtmlEntities(text: string) {
if (!text) return text;
return text
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&amp;/g, '&');
}
export function normalizeInlineCodeFences(text: string) {
if (!text || typeof text !== 'string') return text;
try {
return text.replace(/```\s*([^\n\r]+?)\s*```/g, '`$1`');
} catch {
return text;
}
}
export function unescapeWithMathProtection(text: string) {
if (!text || typeof text !== 'string') return text;
const mathBlocks: string[] = [];
const placeholderPrefix = '__MATH_BLOCK_';
const placeholderSuffix = '__';
let processedText = text.replace(/\$\$([\s\S]*?)\$\$|\$([^\$\n]+?)\$/g, (match) => {
const index = mathBlocks.length;
mathBlocks.push(match);
return `${placeholderPrefix}${index}${placeholderSuffix}`;
});
processedText = processedText.replace(/\\n/g, '\n').replace(/\\t/g, '\t').replace(/\\r/g, '\r');
processedText = processedText.replace(
new RegExp(`${placeholderPrefix}(\\d+)${placeholderSuffix}`, 'g'),
(match, index) => {
return mathBlocks[parseInt(index, 10)];
},
);
return processedText;
}
export function escapeRegExp(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
export function formatUsageLimitText(text: string) {
try {
if (typeof text !== 'string') return text;
return text.replace(/Claude AI usage limit reached\|(\d{10,13})/g, (match, ts) => {
let timestampMs = parseInt(ts, 10);
if (!Number.isFinite(timestampMs)) return match;
if (timestampMs < 1e12) timestampMs *= 1000;
const reset = new Date(timestampMs);
const timeStr = new Intl.DateTimeFormat(undefined, {
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(reset);
const offsetMinutesLocal = -reset.getTimezoneOffset();
const sign = offsetMinutesLocal >= 0 ? '+' : '-';
const abs = Math.abs(offsetMinutesLocal);
const offH = Math.floor(abs / 60);
const offM = abs % 60;
const gmt = `GMT${sign}${offH}${offM ? ':' + String(offM).padStart(2, '0') : ''}`;
const tzId = Intl.DateTimeFormat().resolvedOptions().timeZone || '';
const cityRaw = tzId.split('/').pop() || '';
const city = cityRaw
.replace(/_/g, ' ')
.toLowerCase()
.replace(/\b\w/g, (char) => char.toUpperCase());
const tzHuman = city ? `${gmt} (${city})` : gmt;
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const dateReadable = `${reset.getDate()} ${months[reset.getMonth()]} ${reset.getFullYear()}`;
return `Claude usage limit reached. Your limit will reset at **${timeStr} ${tzHuman}** - ${dateReadable}`;
});
} catch {
return text;
}
}

View File

@@ -0,0 +1,64 @@
import { safeJsonParse } from '../../../lib/utils.js';
import type { ChatMessage, ClaudePermissionSuggestion, PermissionGrantResult } from '../types/types.js';
import { CLAUDE_SETTINGS_KEY, getClaudeSettings, safeLocalStorage } from './chatStorage';
export function buildClaudeToolPermissionEntry(toolName?: string, toolInput?: unknown) {
if (!toolName) return null;
if (toolName !== 'Bash') return toolName;
const parsed = safeJsonParse(toolInput);
const command = typeof parsed?.command === 'string' ? parsed.command.trim() : '';
if (!command) return toolName;
const tokens = command.split(/\s+/);
if (tokens.length === 0) return toolName;
if (tokens[0] === 'git' && tokens[1]) {
return `Bash(${tokens[0]} ${tokens[1]}:*)`;
}
return `Bash(${tokens[0]}:*)`;
}
export function formatToolInputForDisplay(input: unknown) {
if (input === undefined || input === null) return '';
if (typeof input === 'string') return input;
try {
return JSON.stringify(input, null, 2);
} catch {
return String(input);
}
}
export function getClaudePermissionSuggestion(
message: ChatMessage | null | undefined,
provider: string,
): ClaudePermissionSuggestion | null {
if (provider !== 'claude') return null;
if (!message?.toolResult?.isError) return null;
const toolName = message?.toolName;
const entry = buildClaudeToolPermissionEntry(toolName, message.toolInput);
if (!entry) return null;
const settings = getClaudeSettings();
const isAllowed = settings.allowedTools.includes(entry);
return { toolName: toolName || 'UnknownTool', entry, isAllowed };
}
export function grantClaudeToolPermission(entry: string | null): PermissionGrantResult {
if (!entry) return { success: false };
const settings = getClaudeSettings();
const alreadyAllowed = settings.allowedTools.includes(entry);
const nextAllowed = alreadyAllowed ? settings.allowedTools : [...settings.allowedTools, entry];
const nextDisallowed = settings.disallowedTools.filter((tool) => tool !== entry);
const updatedSettings = {
...settings,
allowedTools: nextAllowed,
disallowedTools: nextDisallowed,
lastUpdated: new Date().toISOString(),
};
safeLocalStorage.setItem(CLAUDE_SETTINGS_KEY, JSON.stringify(updatedSettings));
return { success: true, alreadyAllowed, updatedSettings };
}

View File

@@ -0,0 +1,105 @@
import type { ClaudeSettings } from '../types/types';
export const CLAUDE_SETTINGS_KEY = 'claude-settings';
export const safeLocalStorage = {
setItem: (key: string, value: string) => {
try {
if (key.startsWith('chat_messages_') && typeof value === 'string') {
try {
const parsed = JSON.parse(value);
if (Array.isArray(parsed) && parsed.length > 50) {
const truncated = parsed.slice(-50);
value = JSON.stringify(truncated);
}
} catch (parseError) {
console.warn('Could not parse chat messages for truncation:', parseError);
}
}
localStorage.setItem(key, value);
} catch (error: any) {
if (error?.name === 'QuotaExceededError') {
console.warn('localStorage quota exceeded, clearing old data');
const keys = Object.keys(localStorage);
const chatKeys = keys.filter((k) => k.startsWith('chat_messages_')).sort();
if (chatKeys.length > 3) {
chatKeys.slice(0, chatKeys.length - 3).forEach((k) => {
localStorage.removeItem(k);
});
}
const draftKeys = keys.filter((k) => k.startsWith('draft_input_'));
draftKeys.forEach((k) => {
localStorage.removeItem(k);
});
try {
localStorage.setItem(key, value);
} catch (retryError) {
console.error('Failed to save to localStorage even after cleanup:', retryError);
if (key.startsWith('chat_messages_') && typeof value === 'string') {
try {
const parsed = JSON.parse(value);
if (Array.isArray(parsed) && parsed.length > 10) {
const minimal = parsed.slice(-10);
localStorage.setItem(key, JSON.stringify(minimal));
}
} catch (finalError) {
console.error('Final save attempt failed:', finalError);
}
}
}
} else {
console.error('localStorage error:', error);
}
}
},
getItem: (key: string): string | null => {
try {
return localStorage.getItem(key);
} catch (error) {
console.error('localStorage getItem error:', error);
return null;
}
},
removeItem: (key: string) => {
try {
localStorage.removeItem(key);
} catch (error) {
console.error('localStorage removeItem error:', error);
}
},
};
export function getClaudeSettings(): ClaudeSettings {
const raw = safeLocalStorage.getItem(CLAUDE_SETTINGS_KEY);
if (!raw) {
return {
allowedTools: [],
disallowedTools: [],
skipPermissions: false,
projectSortOrder: 'name',
};
}
try {
const parsed = JSON.parse(raw);
return {
...parsed,
allowedTools: Array.isArray(parsed.allowedTools) ? parsed.allowedTools : [],
disallowedTools: Array.isArray(parsed.disallowedTools) ? parsed.disallowedTools : [],
skipPermissions: Boolean(parsed.skipPermissions),
projectSortOrder: parsed.projectSortOrder || 'name',
};
} catch {
return {
allowedTools: [],
disallowedTools: [],
skipPermissions: false,
projectSortOrder: 'name',
};
}
}

View File

@@ -0,0 +1,38 @@
import type { ChatMessage } from '../types/types';
const toMessageKeyPart = (value: unknown): string | null => {
if (typeof value !== 'string' && typeof value !== 'number') {
return null;
}
const normalized = String(value).trim();
return normalized.length > 0 ? normalized : null;
};
export const getIntrinsicMessageKey = (message: ChatMessage): string | null => {
const candidates = [
message.id,
message.messageId,
message.toolId,
message.toolCallId,
message.blobId,
message.rowid,
message.sequence,
];
for (const candidate of candidates) {
const keyPart = toMessageKeyPart(candidate);
if (keyPart) {
return `message-${message.type}-${keyPart}`;
}
}
const timestamp = new Date(message.timestamp).getTime();
if (!Number.isFinite(timestamp)) {
return null;
}
const contentPreview = typeof message.content === 'string' ? message.content.slice(0, 48) : '';
const toolName = typeof message.toolName === 'string' ? message.toolName : '';
return `message-${message.type}-${timestamp}-${toolName}-${contentPreview}`;
};

View File

@@ -0,0 +1,523 @@
import type { ChatMessage } from '../types/types';
import { decodeHtmlEntities, unescapeWithMathProtection } from './chatFormatting';
export interface DiffLine {
type: 'added' | 'removed';
content: string;
lineNum: number;
}
export type DiffCalculator = (oldStr: string, newStr: string) => DiffLine[];
type CursorBlob = {
id?: string;
sequence?: number;
rowid?: number;
content?: any;
};
const asArray = <T>(value: unknown): T[] => (Array.isArray(value) ? (value as T[]) : []);
const normalizeToolInput = (value: unknown): string => {
if (value === null || value === undefined || value === '') {
return '';
}
if (typeof value === 'string') {
return value;
}
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
};
const toAbsolutePath = (projectPath: string, filePath?: string) => {
if (!filePath) {
return filePath;
}
return filePath.startsWith('/') ? filePath : `${projectPath}/${filePath}`;
};
export const calculateDiff = (oldStr: string, newStr: string): DiffLine[] => {
const oldLines = oldStr.split('\n');
const newLines = newStr.split('\n');
// Use LCS alignment so insertions/deletions don't cascade into a full-file "changed" diff.
const lcsTable: number[][] = Array.from({ length: oldLines.length + 1 }, () =>
new Array<number>(newLines.length + 1).fill(0),
);
for (let oldIndex = oldLines.length - 1; oldIndex >= 0; oldIndex -= 1) {
for (let newIndex = newLines.length - 1; newIndex >= 0; newIndex -= 1) {
if (oldLines[oldIndex] === newLines[newIndex]) {
lcsTable[oldIndex][newIndex] = lcsTable[oldIndex + 1][newIndex + 1] + 1;
} else {
lcsTable[oldIndex][newIndex] = Math.max(
lcsTable[oldIndex + 1][newIndex],
lcsTable[oldIndex][newIndex + 1],
);
}
}
}
const diffLines: DiffLine[] = [];
let oldIndex = 0;
let newIndex = 0;
while (oldIndex < oldLines.length && newIndex < newLines.length) {
const oldLine = oldLines[oldIndex];
const newLine = newLines[newIndex];
if (oldLine === newLine) {
oldIndex += 1;
newIndex += 1;
continue;
}
if (lcsTable[oldIndex + 1][newIndex] >= lcsTable[oldIndex][newIndex + 1]) {
diffLines.push({ type: 'removed', content: oldLine, lineNum: oldIndex + 1 });
oldIndex += 1;
continue;
}
diffLines.push({ type: 'added', content: newLine, lineNum: newIndex + 1 });
newIndex += 1;
}
while (oldIndex < oldLines.length) {
diffLines.push({ type: 'removed', content: oldLines[oldIndex], lineNum: oldIndex + 1 });
oldIndex += 1;
}
while (newIndex < newLines.length) {
diffLines.push({ type: 'added', content: newLines[newIndex], lineNum: newIndex + 1 });
newIndex += 1;
}
return diffLines;
};
export const createCachedDiffCalculator = (): DiffCalculator => {
const cache = new Map<string, DiffLine[]>();
return (oldStr: string, newStr: string) => {
const key = JSON.stringify([oldStr, newStr]);
const cached = cache.get(key);
if (cached) {
return cached;
}
const calculated = calculateDiff(oldStr, newStr);
cache.set(key, calculated);
if (cache.size > 100) {
const firstKey = cache.keys().next().value;
if (firstKey) {
cache.delete(firstKey);
}
}
return calculated;
};
};
export const convertCursorSessionMessages = (blobs: CursorBlob[], projectPath: string): ChatMessage[] => {
const converted: ChatMessage[] = [];
const toolUseMap: Record<string, ChatMessage> = {};
for (let blobIdx = 0; blobIdx < blobs.length; blobIdx += 1) {
const blob = blobs[blobIdx];
const content = blob.content;
let text = '';
let role: ChatMessage['type'] = 'assistant';
let reasoningText: string | null = null;
try {
if (content?.role && content?.content) {
if (content.role === 'system') {
continue;
}
if (content.role === 'tool') {
const toolItems = asArray<any>(content.content);
for (const item of toolItems) {
if (item?.type !== 'tool-result') {
continue;
}
const toolName = item.toolName === 'ApplyPatch' ? 'Edit' : item.toolName || 'Unknown Tool';
const toolCallId = item.toolCallId || content.id;
const result = item.result || '';
if (toolCallId && toolUseMap[toolCallId]) {
toolUseMap[toolCallId].toolResult = {
content: result,
isError: false,
};
} else {
converted.push({
type: 'assistant',
content: '',
timestamp: new Date(Date.now() + blobIdx * 1000),
blobId: blob.id,
sequence: blob.sequence,
rowid: blob.rowid,
isToolUse: true,
toolName,
toolId: toolCallId,
toolInput: normalizeToolInput(null),
toolResult: {
content: result,
isError: false,
},
});
}
}
continue;
}
role = content.role === 'user' ? 'user' : 'assistant';
if (Array.isArray(content.content)) {
const textParts: string[] = [];
for (const part of content.content) {
if (part?.type === 'text' && part?.text) {
textParts.push(decodeHtmlEntities(part.text));
continue;
}
if (part?.type === 'reasoning' && part?.text) {
reasoningText = decodeHtmlEntities(part.text);
continue;
}
if (part?.type === 'tool-call' || part?.type === 'tool_use') {
if (textParts.length > 0 || reasoningText) {
converted.push({
type: role,
content: textParts.join('\n'),
reasoning: reasoningText ?? undefined,
timestamp: new Date(Date.now() + blobIdx * 1000),
blobId: blob.id,
sequence: blob.sequence,
rowid: blob.rowid,
});
textParts.length = 0;
reasoningText = null;
}
const toolNameRaw = part.toolName || part.name || 'Unknown Tool';
const toolName = toolNameRaw === 'ApplyPatch' ? 'Edit' : toolNameRaw;
const toolId = part.toolCallId || part.id || `tool_${blobIdx}`;
let toolInput = part.args || part.input;
if (toolName === 'Edit' && part.args) {
if (part.args.patch) {
const patchLines = String(part.args.patch).split('\n');
const oldLines: string[] = [];
const newLines: string[] = [];
let inPatch = false;
patchLines.forEach((line) => {
if (line.startsWith('@@')) {
inPatch = true;
return;
}
if (!inPatch) {
return;
}
if (line.startsWith('-')) {
oldLines.push(line.slice(1));
} else if (line.startsWith('+')) {
newLines.push(line.slice(1));
} else if (line.startsWith(' ')) {
oldLines.push(line.slice(1));
newLines.push(line.slice(1));
}
});
toolInput = {
file_path: toAbsolutePath(projectPath, part.args.file_path),
old_string: oldLines.join('\n') || part.args.patch,
new_string: newLines.join('\n') || part.args.patch,
};
} else {
toolInput = part.args;
}
} else if (toolName === 'Read' && part.args) {
const filePath = part.args.path || part.args.file_path;
toolInput = {
file_path: toAbsolutePath(projectPath, filePath),
};
} else if (toolName === 'Write' && part.args) {
const filePath = part.args.path || part.args.file_path;
toolInput = {
file_path: toAbsolutePath(projectPath, filePath),
content: part.args.contents || part.args.content,
};
}
const toolMessage: ChatMessage = {
type: 'assistant',
content: '',
timestamp: new Date(Date.now() + blobIdx * 1000),
blobId: blob.id,
sequence: blob.sequence,
rowid: blob.rowid,
isToolUse: true,
toolName,
toolId,
toolInput: normalizeToolInput(toolInput),
toolResult: null,
};
converted.push(toolMessage);
toolUseMap[toolId] = toolMessage;
continue;
}
if (typeof part === 'string') {
textParts.push(part);
}
}
if (textParts.length > 0) {
text = textParts.join('\n');
if (reasoningText && !text) {
converted.push({
type: role,
content: '',
reasoning: reasoningText,
timestamp: new Date(Date.now() + blobIdx * 1000),
blobId: blob.id,
sequence: blob.sequence,
rowid: blob.rowid,
});
text = '';
}
} else {
text = '';
}
} else if (typeof content.content === 'string') {
text = content.content;
}
} else if (content?.message?.role && content?.message?.content) {
if (content.message.role === 'system') {
continue;
}
role = content.message.role === 'user' ? 'user' : 'assistant';
if (Array.isArray(content.message.content)) {
text = content.message.content
.map((part: any) => (typeof part === 'string' ? part : part?.text || ''))
.filter(Boolean)
.join('\n');
} else if (typeof content.message.content === 'string') {
text = content.message.content;
}
}
} catch (error) {
console.log('Error parsing blob content:', error);
}
if (text && text.trim()) {
const message: ChatMessage = {
type: role,
content: text,
timestamp: new Date(Date.now() + blobIdx * 1000),
blobId: blob.id,
sequence: blob.sequence,
rowid: blob.rowid,
};
if (reasoningText) {
message.reasoning = reasoningText;
}
converted.push(message);
}
}
converted.sort((messageA, messageB) => {
if (messageA.sequence !== undefined && messageB.sequence !== undefined) {
return Number(messageA.sequence) - Number(messageB.sequence);
}
if (messageA.rowid !== undefined && messageB.rowid !== undefined) {
return Number(messageA.rowid) - Number(messageB.rowid);
}
return new Date(messageA.timestamp).getTime() - new Date(messageB.timestamp).getTime();
});
return converted;
};
export const convertSessionMessages = (rawMessages: any[]): ChatMessage[] => {
const converted: ChatMessage[] = [];
const toolResults = new Map<
string,
{ content: unknown; isError: boolean; timestamp: Date; toolUseResult: unknown }
>();
rawMessages.forEach((message) => {
if (message.message?.role === 'user' && Array.isArray(message.message?.content)) {
message.message.content.forEach((part: any) => {
if (part.type !== 'tool_result') {
return;
}
toolResults.set(part.tool_use_id, {
content: part.content,
isError: Boolean(part.is_error),
timestamp: new Date(message.timestamp || Date.now()),
toolUseResult: message.toolUseResult || null,
});
});
}
});
rawMessages.forEach((message) => {
if (message.message?.role === 'user' && message.message?.content) {
let content = '';
if (Array.isArray(message.message.content)) {
const textParts: string[] = [];
message.message.content.forEach((part: any) => {
if (part.type === 'text') {
textParts.push(decodeHtmlEntities(part.text));
}
});
content = textParts.join('\n');
} else if (typeof message.message.content === 'string') {
content = decodeHtmlEntities(message.message.content);
} else {
content = decodeHtmlEntities(String(message.message.content));
}
const shouldSkip =
!content ||
content.startsWith('<command-name>') ||
content.startsWith('<command-message>') ||
content.startsWith('<command-args>') ||
content.startsWith('<local-command-stdout>') ||
content.startsWith('<system-reminder>') ||
content.startsWith('Caveat:') ||
content.startsWith('This session is being continued from a previous') ||
content.startsWith('[Request interrupted');
if (!shouldSkip) {
// Parse <task-notification> blocks into compact system messages
const taskNotifRegex = /<task-notification>\s*<task-id>[^<]*<\/task-id>\s*<output-file>[^<]*<\/output-file>\s*<status>([^<]*)<\/status>\s*<summary>([^<]*)<\/summary>\s*<\/task-notification>/g;
const taskNotifMatch = taskNotifRegex.exec(content);
if (taskNotifMatch) {
const status = taskNotifMatch[1]?.trim() || 'completed';
const summary = taskNotifMatch[2]?.trim() || 'Background task finished';
converted.push({
type: 'assistant',
content: summary,
timestamp: message.timestamp || new Date().toISOString(),
isTaskNotification: true,
taskStatus: status,
});
} else {
converted.push({
type: 'user',
content: unescapeWithMathProtection(content),
timestamp: message.timestamp || new Date().toISOString(),
});
}
}
return;
}
if (message.type === 'thinking' && message.message?.content) {
converted.push({
type: 'assistant',
content: unescapeWithMathProtection(message.message.content),
timestamp: message.timestamp || new Date().toISOString(),
isThinking: true,
});
return;
}
if (message.type === 'tool_use' && message.toolName) {
converted.push({
type: 'assistant',
content: '',
timestamp: message.timestamp || new Date().toISOString(),
isToolUse: true,
toolName: message.toolName,
toolInput: normalizeToolInput(message.toolInput),
toolCallId: message.toolCallId,
});
return;
}
if (message.type === 'tool_result') {
for (let index = converted.length - 1; index >= 0; index -= 1) {
const convertedMessage = converted[index];
if (!convertedMessage.isToolUse || convertedMessage.toolResult) {
continue;
}
if (!message.toolCallId || convertedMessage.toolCallId === message.toolCallId) {
convertedMessage.toolResult = {
content: message.output || '',
isError: false,
};
break;
}
}
return;
}
if (message.message?.role === 'assistant' && message.message?.content) {
if (Array.isArray(message.message.content)) {
message.message.content.forEach((part: any) => {
if (part.type === 'text') {
let text = part.text;
if (typeof text === 'string') {
text = unescapeWithMathProtection(text);
}
converted.push({
type: 'assistant',
content: text,
timestamp: message.timestamp || new Date().toISOString(),
});
return;
}
if (part.type === 'tool_use') {
const toolResult = toolResults.get(part.id);
converted.push({
type: 'assistant',
content: '',
timestamp: message.timestamp || new Date().toISOString(),
isToolUse: true,
toolName: part.name,
toolInput: normalizeToolInput(part.input),
toolResult: toolResult
? {
content:
typeof toolResult.content === 'string'
? toolResult.content
: JSON.stringify(toolResult.content),
isError: toolResult.isError,
toolUseResult: toolResult.toolUseResult,
}
: null,
toolError: toolResult?.isError || false,
toolResultTimestamp: toolResult?.timestamp || new Date(),
});
}
});
return;
}
if (typeof message.message.content === 'string') {
converted.push({
type: 'assistant',
content: unescapeWithMathProtection(message.message.content),
timestamp: message.timestamp || new Date().toISOString(),
});
}
}
});
return converted;
};

View File

@@ -0,0 +1,384 @@
import React, { useCallback, useEffect, useRef } from 'react';
import QuickSettingsPanel from '../../QuickSettingsPanel';
import { useTasksSettings } from '../../../contexts/TasksSettingsContext';
import { useTranslation } from 'react-i18next';
import ChatMessagesPane from './subcomponents/ChatMessagesPane';
import ChatComposer from './subcomponents/ChatComposer';
import type { ChatInterfaceProps } from '../types/types';
import { useChatProviderState } from '../hooks/useChatProviderState';
import { useChatSessionState } from '../hooks/useChatSessionState';
import { useChatRealtimeHandlers } from '../hooks/useChatRealtimeHandlers';
import { useChatComposerState } from '../hooks/useChatComposerState';
import type { Provider } from '../types/types';
type PendingViewSession = {
sessionId: string | null;
startedAt: number;
};
function ChatInterface({
selectedProject,
selectedSession,
ws,
sendMessage,
latestMessage,
onFileOpen,
onInputFocusChange,
onSessionActive,
onSessionInactive,
onSessionProcessing,
onSessionNotProcessing,
processingSessions,
onReplaceTemporarySession,
onNavigateToSession,
onShowSettings,
autoExpandTools,
showRawParameters,
showThinking,
autoScrollToBottom,
sendByCtrlEnter,
externalMessageUpdate,
onShowAllTasks,
}: ChatInterfaceProps) {
const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings();
const { t } = useTranslation('chat');
const streamBufferRef = useRef('');
const streamTimerRef = useRef<number | null>(null);
const pendingViewSessionRef = useRef<PendingViewSession | null>(null);
const resetStreamingState = useCallback(() => {
if (streamTimerRef.current) {
clearTimeout(streamTimerRef.current);
streamTimerRef.current = null;
}
streamBufferRef.current = '';
}, []);
const {
provider,
setProvider,
cursorModel,
setCursorModel,
claudeModel,
setClaudeModel,
codexModel,
setCodexModel,
permissionMode,
pendingPermissionRequests,
setPendingPermissionRequests,
cyclePermissionMode,
} = useChatProviderState({
selectedSession,
});
const {
chatMessages,
setChatMessages,
isLoading,
setIsLoading,
currentSessionId,
setCurrentSessionId,
sessionMessages,
setSessionMessages,
isLoadingSessionMessages,
isLoadingMoreMessages,
hasMoreMessages,
totalMessages,
isSystemSessionChange,
setIsSystemSessionChange,
canAbortSession,
setCanAbortSession,
isUserScrolledUp,
setIsUserScrolledUp,
tokenBudget,
setTokenBudget,
visibleMessageCount,
visibleMessages,
loadEarlierMessages,
claudeStatus,
setClaudeStatus,
createDiff,
scrollContainerRef,
scrollToBottom,
handleScroll,
} = useChatSessionState({
selectedProject,
selectedSession,
ws,
sendMessage,
autoScrollToBottom,
externalMessageUpdate,
processingSessions,
resetStreamingState,
pendingViewSessionRef,
});
const {
input,
setInput,
textareaRef,
inputHighlightRef,
isTextareaExpanded,
thinkingMode,
setThinkingMode,
slashCommandsCount,
filteredCommands,
frequentCommands,
commandQuery,
showCommandMenu,
selectedCommandIndex,
resetCommandMenuState,
handleCommandSelect,
handleToggleCommandMenu,
showFileDropdown,
filteredFiles,
selectedFileIndex,
renderInputWithMentions,
selectFile,
attachedImages,
setAttachedImages,
uploadingImages,
imageErrors,
getRootProps,
getInputProps,
isDragActive,
openImagePicker,
handleSubmit,
handleInputChange,
handleKeyDown,
handlePaste,
handleTextareaClick,
handleTextareaInput,
syncInputOverlayScroll,
handleClearInput,
handleAbortSession,
handleTranscript,
handlePermissionDecision,
handleGrantToolPermission,
handleInputFocusChange,
} = useChatComposerState({
selectedProject,
selectedSession,
currentSessionId,
provider,
permissionMode,
cyclePermissionMode,
cursorModel,
claudeModel,
codexModel,
isLoading,
canAbortSession,
tokenBudget,
sendMessage,
sendByCtrlEnter,
onSessionActive,
onInputFocusChange,
onFileOpen,
onShowSettings,
pendingViewSessionRef,
scrollToBottom,
setChatMessages,
setSessionMessages,
setIsLoading,
setCanAbortSession,
setClaudeStatus,
setIsUserScrolledUp,
setPendingPermissionRequests,
});
useChatRealtimeHandlers({
latestMessage,
provider,
selectedProject,
selectedSession,
currentSessionId,
setCurrentSessionId,
setChatMessages,
setIsLoading,
setCanAbortSession,
setClaudeStatus,
setTokenBudget,
setIsSystemSessionChange,
setPendingPermissionRequests,
pendingViewSessionRef,
streamBufferRef,
streamTimerRef,
onSessionInactive,
onSessionProcessing,
onSessionNotProcessing,
onReplaceTemporarySession,
onNavigateToSession,
});
useEffect(() => {
if (!isLoading || !canAbortSession) {
return;
}
const handleGlobalEscape = (event: KeyboardEvent) => {
if (event.key !== 'Escape' || event.repeat || event.defaultPrevented) {
return;
}
event.preventDefault();
handleAbortSession();
};
document.addEventListener('keydown', handleGlobalEscape, { capture: true });
return () => {
document.removeEventListener('keydown', handleGlobalEscape, { capture: true });
};
}, [canAbortSession, handleAbortSession, isLoading]);
useEffect(() => {
const processingSessionId = selectedSession?.id || currentSessionId;
if (processingSessionId && isLoading && onSessionProcessing) {
onSessionProcessing(processingSessionId);
}
}, [currentSessionId, isLoading, onSessionProcessing, selectedSession?.id]);
useEffect(() => {
return () => {
resetStreamingState();
};
}, [resetStreamingState]);
if (!selectedProject) {
const selectedProviderLabel =
provider === 'cursor'
? t('messageTypes.cursor')
: provider === 'codex'
? t('messageTypes.codex')
: t('messageTypes.claude');
return (
<div className="flex items-center justify-center h-full">
<div className="text-center text-muted-foreground">
<p className="text-sm">
{t('projectSelection.startChatWithProvider', {
provider: selectedProviderLabel,
defaultValue: 'Select a project to start chatting with {{provider}}',
})}
</p>
</div>
</div>
);
}
return (
<>
<div className="h-full flex flex-col">
<ChatMessagesPane
scrollContainerRef={scrollContainerRef}
onWheel={handleScroll}
onTouchMove={handleScroll}
isLoadingSessionMessages={isLoadingSessionMessages}
chatMessages={chatMessages}
selectedSession={selectedSession}
currentSessionId={currentSessionId}
provider={provider}
setProvider={(nextProvider) => setProvider(nextProvider as Provider)}
textareaRef={textareaRef}
claudeModel={claudeModel}
setClaudeModel={setClaudeModel}
cursorModel={cursorModel}
setCursorModel={setCursorModel}
codexModel={codexModel}
setCodexModel={setCodexModel}
tasksEnabled={tasksEnabled}
isTaskMasterInstalled={isTaskMasterInstalled}
onShowAllTasks={onShowAllTasks}
setInput={setInput}
isLoadingMoreMessages={isLoadingMoreMessages}
hasMoreMessages={hasMoreMessages}
totalMessages={totalMessages}
sessionMessagesCount={sessionMessages.length}
visibleMessageCount={visibleMessageCount}
visibleMessages={visibleMessages}
loadEarlierMessages={loadEarlierMessages}
createDiff={createDiff}
onFileOpen={onFileOpen}
onShowSettings={onShowSettings}
onGrantToolPermission={handleGrantToolPermission}
autoExpandTools={autoExpandTools}
showRawParameters={showRawParameters}
showThinking={showThinking}
selectedProject={selectedProject}
isLoading={isLoading}
/>
<ChatComposer
pendingPermissionRequests={pendingPermissionRequests}
handlePermissionDecision={handlePermissionDecision}
handleGrantToolPermission={handleGrantToolPermission}
claudeStatus={claudeStatus}
isLoading={isLoading}
onAbortSession={handleAbortSession}
provider={provider}
permissionMode={permissionMode}
onModeSwitch={cyclePermissionMode}
thinkingMode={thinkingMode}
setThinkingMode={setThinkingMode}
tokenBudget={tokenBudget}
slashCommandsCount={slashCommandsCount}
onToggleCommandMenu={handleToggleCommandMenu}
hasInput={Boolean(input.trim())}
onClearInput={handleClearInput}
isUserScrolledUp={isUserScrolledUp}
hasMessages={chatMessages.length > 0}
onScrollToBottom={scrollToBottom}
onSubmit={handleSubmit}
isDragActive={isDragActive}
attachedImages={attachedImages}
onRemoveImage={(index) =>
setAttachedImages((previous) =>
previous.filter((_, currentIndex) => currentIndex !== index),
)
}
uploadingImages={uploadingImages}
imageErrors={imageErrors}
showFileDropdown={showFileDropdown}
filteredFiles={filteredFiles}
selectedFileIndex={selectedFileIndex}
onSelectFile={selectFile}
filteredCommands={filteredCommands}
selectedCommandIndex={selectedCommandIndex}
onCommandSelect={handleCommandSelect}
onCloseCommandMenu={resetCommandMenuState}
isCommandMenuOpen={showCommandMenu}
frequentCommands={commandQuery ? [] : frequentCommands}
getRootProps={getRootProps as (...args: unknown[]) => Record<string, unknown>}
getInputProps={getInputProps as (...args: unknown[]) => Record<string, unknown>}
openImagePicker={openImagePicker}
inputHighlightRef={inputHighlightRef}
renderInputWithMentions={renderInputWithMentions}
textareaRef={textareaRef}
input={input}
onInputChange={handleInputChange}
onTextareaClick={handleTextareaClick}
onTextareaKeyDown={handleKeyDown}
onTextareaPaste={handlePaste}
onTextareaScrollSync={syncInputOverlayScroll}
onTextareaInput={handleTextareaInput}
onInputFocusChange={handleInputFocusChange}
placeholder={t('input.placeholder', {
provider:
provider === 'cursor'
? t('messageTypes.cursor')
: provider === 'codex'
? t('messageTypes.codex')
: t('messageTypes.claude'),
})}
isTextareaExpanded={isTextareaExpanded}
sendByCtrlEnter={sendByCtrlEnter}
onTranscript={handleTranscript}
/>
</div>
<QuickSettingsPanel />
</>
);
}
export default React.memo(ChatInterface);

View File

@@ -0,0 +1,37 @@
import { SessionProvider } from '../../../../types/app';
import SessionProviderLogo from '../../../SessionProviderLogo';
import type { Provider } from '../../types/types';
type AssistantThinkingIndicatorProps = {
selectedProvider: SessionProvider;
}
export default function AssistantThinkingIndicator({ selectedProvider }: AssistantThinkingIndicatorProps) {
return (
<div className="chat-message assistant">
<div className="w-full">
<div className="flex items-center space-x-3 mb-2">
<div className="w-8 h-8 rounded-full flex items-center justify-center text-white text-sm flex-shrink-0 p-1 bg-transparent">
<SessionProviderLogo provider={selectedProvider} className="w-full h-full" />
</div>
<div className="text-sm font-medium text-gray-900 dark:text-white">
{selectedProvider === 'cursor' ? 'Cursor' : selectedProvider === 'codex' ? 'Codex' : 'Claude'}
</div>
</div>
<div className="w-full text-sm text-gray-500 dark:text-gray-400 pl-3 sm:pl-0">
<div className="flex items-center space-x-1">
<div className="animate-pulse">.</div>
<div className="animate-pulse" style={{ animationDelay: '0.2s' }}>
.
</div>
<div className="animate-pulse" style={{ animationDelay: '0.4s' }}>
.
</div>
<span className="ml-2">Thinking...</span>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,353 @@
import CommandMenu from '../../../CommandMenu';
import ClaudeStatus from '../../../ClaudeStatus';
import { MicButton } from '../../../MicButton.jsx';
import ImageAttachment from './ImageAttachment';
import PermissionRequestsBanner from './PermissionRequestsBanner';
import ChatInputControls from './ChatInputControls';
import { useTranslation } from 'react-i18next';
import type {
ChangeEvent,
ClipboardEvent,
Dispatch,
FormEvent,
KeyboardEvent,
MouseEvent,
ReactNode,
RefObject,
SetStateAction,
TouchEvent,
} from 'react';
import type { PendingPermissionRequest, PermissionMode, Provider } from '../../types/types';
interface MentionableFile {
name: string;
path: string;
}
interface SlashCommand {
name: string;
description?: string;
namespace?: string;
path?: string;
type?: string;
metadata?: Record<string, unknown>;
[key: string]: unknown;
}
interface ChatComposerProps {
pendingPermissionRequests: PendingPermissionRequest[];
handlePermissionDecision: (
requestIds: string | string[],
decision: { allow?: boolean; message?: string; rememberEntry?: string | null; updatedInput?: unknown },
) => void;
handleGrantToolPermission: (suggestion: { entry: string; toolName: string }) => { success: boolean };
claudeStatus: { text: string; tokens: number; can_interrupt: boolean } | null;
isLoading: boolean;
onAbortSession: () => void;
provider: Provider | string;
permissionMode: PermissionMode | string;
onModeSwitch: () => void;
thinkingMode: string;
setThinkingMode: Dispatch<SetStateAction<string>>;
tokenBudget: { used?: number; total?: number } | null;
slashCommandsCount: number;
onToggleCommandMenu: () => void;
hasInput: boolean;
onClearInput: () => void;
isUserScrolledUp: boolean;
hasMessages: boolean;
onScrollToBottom: () => void;
onSubmit: (event: FormEvent<HTMLFormElement> | MouseEvent<HTMLButtonElement> | TouchEvent<HTMLButtonElement>) => void;
isDragActive: boolean;
attachedImages: File[];
onRemoveImage: (index: number) => void;
uploadingImages: Map<string, number>;
imageErrors: Map<string, string>;
showFileDropdown: boolean;
filteredFiles: MentionableFile[];
selectedFileIndex: number;
onSelectFile: (file: MentionableFile) => void;
filteredCommands: SlashCommand[];
selectedCommandIndex: number;
onCommandSelect: (command: SlashCommand, index: number, isHover: boolean) => void;
onCloseCommandMenu: () => void;
isCommandMenuOpen: boolean;
frequentCommands: SlashCommand[];
getRootProps: (...args: unknown[]) => Record<string, unknown>;
getInputProps: (...args: unknown[]) => Record<string, unknown>;
openImagePicker: () => void;
inputHighlightRef: RefObject<HTMLDivElement>;
renderInputWithMentions: (text: string) => ReactNode;
textareaRef: RefObject<HTMLTextAreaElement>;
input: string;
onInputChange: (event: ChangeEvent<HTMLTextAreaElement>) => void;
onTextareaClick: (event: MouseEvent<HTMLTextAreaElement>) => void;
onTextareaKeyDown: (event: KeyboardEvent<HTMLTextAreaElement>) => void;
onTextareaPaste: (event: ClipboardEvent<HTMLTextAreaElement>) => void;
onTextareaScrollSync: (target: HTMLTextAreaElement) => void;
onTextareaInput: (event: FormEvent<HTMLTextAreaElement>) => void;
onInputFocusChange?: (focused: boolean) => void;
placeholder: string;
isTextareaExpanded: boolean;
sendByCtrlEnter?: boolean;
onTranscript: (text: string) => void;
}
export default function ChatComposer({
pendingPermissionRequests,
handlePermissionDecision,
handleGrantToolPermission,
claudeStatus,
isLoading,
onAbortSession,
provider,
permissionMode,
onModeSwitch,
thinkingMode,
setThinkingMode,
tokenBudget,
slashCommandsCount,
onToggleCommandMenu,
hasInput,
onClearInput,
isUserScrolledUp,
hasMessages,
onScrollToBottom,
onSubmit,
isDragActive,
attachedImages,
onRemoveImage,
uploadingImages,
imageErrors,
showFileDropdown,
filteredFiles,
selectedFileIndex,
onSelectFile,
filteredCommands,
selectedCommandIndex,
onCommandSelect,
onCloseCommandMenu,
isCommandMenuOpen,
frequentCommands,
getRootProps,
getInputProps,
openImagePicker,
inputHighlightRef,
renderInputWithMentions,
textareaRef,
input,
onInputChange,
onTextareaClick,
onTextareaKeyDown,
onTextareaPaste,
onTextareaScrollSync,
onTextareaInput,
onInputFocusChange,
placeholder,
isTextareaExpanded,
sendByCtrlEnter,
onTranscript,
}: ChatComposerProps) {
const { t } = useTranslation('chat');
const AnyCommandMenu = CommandMenu as any;
const textareaRect = textareaRef.current?.getBoundingClientRect();
const commandMenuPosition = {
top: textareaRect ? Math.max(16, textareaRect.top - 316) : 0,
left: textareaRect ? textareaRect.left : 16,
bottom: textareaRect ? window.innerHeight - textareaRect.top + 8 : 90,
};
// Detect if the AskUserQuestion interactive panel is active
const hasQuestionPanel = pendingPermissionRequests.some(
(r) => r.toolName === 'AskUserQuestion'
);
return (
<div className="p-2 sm:p-4 md:p-4 flex-shrink-0 pb-2 sm:pb-4 md:pb-6">
{!hasQuestionPanel && (
<div className="flex-1">
<ClaudeStatus
status={claudeStatus}
isLoading={isLoading}
onAbort={onAbortSession}
provider={provider}
/>
</div>
)}
<div className="max-w-4xl mx-auto mb-3">
<PermissionRequestsBanner
pendingPermissionRequests={pendingPermissionRequests}
handlePermissionDecision={handlePermissionDecision}
handleGrantToolPermission={handleGrantToolPermission}
/>
{!hasQuestionPanel && <ChatInputControls
permissionMode={permissionMode}
onModeSwitch={onModeSwitch}
provider={provider}
thinkingMode={thinkingMode}
setThinkingMode={setThinkingMode}
tokenBudget={tokenBudget}
slashCommandsCount={slashCommandsCount}
onToggleCommandMenu={onToggleCommandMenu}
hasInput={hasInput}
onClearInput={onClearInput}
isUserScrolledUp={isUserScrolledUp}
hasMessages={hasMessages}
onScrollToBottom={onScrollToBottom}
/>}
</div>
{!hasQuestionPanel && <form onSubmit={onSubmit as (event: FormEvent<HTMLFormElement>) => void} className="relative max-w-4xl mx-auto">
{isDragActive && (
<div className="absolute inset-0 bg-primary/15 border-2 border-dashed border-primary/50 rounded-2xl flex items-center justify-center z-50">
<div className="bg-card rounded-xl p-4 shadow-lg border border-border/30">
<svg className="w-8 h-8 text-primary mx-auto mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
<p className="text-sm font-medium">Drop images here</p>
</div>
</div>
)}
{attachedImages.length > 0 && (
<div className="mb-2 p-2 bg-muted/40 rounded-xl">
<div className="flex flex-wrap gap-2">
{attachedImages.map((file, index) => (
<ImageAttachment
key={index}
file={file}
onRemove={() => onRemoveImage(index)}
uploadProgress={uploadingImages.get(file.name)}
error={imageErrors.get(file.name)}
/>
))}
</div>
</div>
)}
{showFileDropdown && filteredFiles.length > 0 && (
<div className="absolute bottom-full left-0 right-0 mb-2 bg-card/95 backdrop-blur-md border border-border/50 rounded-xl shadow-lg max-h-48 overflow-y-auto z-50">
{filteredFiles.map((file, index) => (
<div
key={file.path}
className={`px-4 py-3 cursor-pointer border-b border-border/30 last:border-b-0 touch-manipulation ${
index === selectedFileIndex
? 'bg-primary/8 text-primary'
: 'hover:bg-accent/50 text-foreground'
}`}
onMouseDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onSelectFile(file);
}}
>
<div className="font-medium text-sm">{file.name}</div>
<div className="text-xs text-muted-foreground font-mono">{file.path}</div>
</div>
))}
</div>
)}
<AnyCommandMenu
commands={filteredCommands}
selectedIndex={selectedCommandIndex}
onSelect={onCommandSelect}
onClose={onCloseCommandMenu}
position={commandMenuPosition}
isOpen={isCommandMenuOpen}
frequentCommands={frequentCommands}
/>
<div
{...getRootProps()}
className={`relative bg-card/80 backdrop-blur-sm rounded-2xl shadow-sm border border-border/50 focus-within:shadow-md focus-within:border-primary/30 focus-within:ring-1 focus-within:ring-primary/15 transition-all duration-200 overflow-hidden ${
isTextareaExpanded ? 'chat-input-expanded' : ''
}`}
>
<input {...getInputProps()} />
<div ref={inputHighlightRef} aria-hidden="true" className="absolute inset-0 pointer-events-none overflow-hidden rounded-2xl">
<div className="chat-input-placeholder block w-full pl-12 pr-20 sm:pr-40 py-1.5 sm:py-4 text-transparent text-base leading-6 whitespace-pre-wrap break-words">
{renderInputWithMentions(input)}
</div>
</div>
<div className="relative z-10">
<textarea
ref={textareaRef}
value={input}
onChange={onInputChange}
onClick={onTextareaClick}
onKeyDown={onTextareaKeyDown}
onPaste={onTextareaPaste}
onScroll={(event) => onTextareaScrollSync(event.target as HTMLTextAreaElement)}
onFocus={() => onInputFocusChange?.(true)}
onBlur={() => onInputFocusChange?.(false)}
onInput={onTextareaInput}
placeholder={placeholder}
disabled={isLoading}
className="chat-input-placeholder block w-full pl-12 pr-20 sm:pr-40 py-1.5 sm:py-4 bg-transparent rounded-2xl focus:outline-none text-foreground placeholder-muted-foreground/50 disabled:opacity-50 resize-none min-h-[50px] sm:min-h-[80px] max-h-[40vh] sm:max-h-[300px] overflow-y-auto text-base leading-6 transition-all duration-200"
style={{ height: '50px' }}
/>
<button
type="button"
onClick={openImagePicker}
className="absolute left-2 top-1/2 transform -translate-y-1/2 p-2 hover:bg-accent/60 rounded-xl transition-colors"
title={t('input.attachImages')}
>
<svg className="w-5 h-5 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
</button>
<div className="absolute right-16 sm:right-16 top-1/2 transform -translate-y-1/2" style={{ display: 'none' }}>
<MicButton onTranscript={onTranscript} className="w-10 h-10 sm:w-10 sm:h-10" />
</div>
<button
type="submit"
disabled={!input.trim() || isLoading}
onMouseDown={(event) => {
event.preventDefault();
onSubmit(event);
}}
onTouchStart={(event) => {
event.preventDefault();
onSubmit(event);
}}
className="absolute right-2 top-1/2 transform -translate-y-1/2 w-10 h-10 sm:w-11 sm:h-11 bg-primary hover:bg-primary/90 disabled:bg-muted disabled:text-muted-foreground disabled:cursor-not-allowed rounded-xl flex items-center justify-center transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-primary/30 focus:ring-offset-1 focus:ring-offset-background"
>
<svg className="w-4 h-4 sm:w-[18px] sm:h-[18px] text-primary-foreground transform rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.2} d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
</svg>
</button>
<div
className={`absolute bottom-1 left-12 right-14 sm:right-40 text-xs text-muted-foreground/50 pointer-events-none hidden sm:block transition-opacity duration-200 ${
input.trim() ? 'opacity-0' : 'opacity-100'
}`}
>
{sendByCtrlEnter ? t('input.hintText.ctrlEnter') : t('input.hintText.enter')}
</div>
</div>
</div>
</form>}
</div>
);
}

View File

@@ -0,0 +1,137 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import ThinkingModeSelector from './ThinkingModeSelector';
import TokenUsagePie from './TokenUsagePie';
import type { PermissionMode, Provider } from '../../types/types';
interface ChatInputControlsProps {
permissionMode: PermissionMode | string;
onModeSwitch: () => void;
provider: Provider | string;
thinkingMode: string;
setThinkingMode: React.Dispatch<React.SetStateAction<string>>;
tokenBudget: { used?: number; total?: number } | null;
slashCommandsCount: number;
onToggleCommandMenu: () => void;
hasInput: boolean;
onClearInput: () => void;
isUserScrolledUp: boolean;
hasMessages: boolean;
onScrollToBottom: () => void;
}
export default function ChatInputControls({
permissionMode,
onModeSwitch,
provider,
thinkingMode,
setThinkingMode,
tokenBudget,
slashCommandsCount,
onToggleCommandMenu,
hasInput,
onClearInput,
isUserScrolledUp,
hasMessages,
onScrollToBottom,
}: ChatInputControlsProps) {
const { t } = useTranslation('chat');
return (
<div className="flex items-center justify-center gap-2 sm:gap-3 flex-wrap">
<button
type="button"
onClick={onModeSwitch}
className={`px-2.5 py-1 sm:px-3 sm:py-1.5 rounded-lg text-xs sm:text-sm font-medium border transition-all duration-200 ${
permissionMode === 'default'
? 'bg-muted/50 text-muted-foreground border-border/60 hover:bg-muted'
: permissionMode === 'acceptEdits'
? 'bg-green-50 dark:bg-green-900/15 text-green-700 dark:text-green-300 border-green-300/60 dark:border-green-600/40 hover:bg-green-100 dark:hover:bg-green-900/25'
: permissionMode === 'bypassPermissions'
? 'bg-orange-50 dark:bg-orange-900/15 text-orange-700 dark:text-orange-300 border-orange-300/60 dark:border-orange-600/40 hover:bg-orange-100 dark:hover:bg-orange-900/25'
: 'bg-primary/5 text-primary border-primary/20 hover:bg-primary/10'
}`}
title={t('input.clickToChangeMode')}
>
<div className="flex items-center gap-1.5">
<div
className={`w-1.5 h-1.5 rounded-full ${
permissionMode === 'default'
? 'bg-muted-foreground'
: permissionMode === 'acceptEdits'
? 'bg-green-500'
: permissionMode === 'bypassPermissions'
? 'bg-orange-500'
: 'bg-primary'
}`}
/>
<span>
{permissionMode === 'default' && t('codex.modes.default')}
{permissionMode === 'acceptEdits' && t('codex.modes.acceptEdits')}
{permissionMode === 'bypassPermissions' && t('codex.modes.bypassPermissions')}
{permissionMode === 'plan' && t('codex.modes.plan')}
</span>
</div>
</button>
{provider === 'claude' && (
<ThinkingModeSelector selectedMode={thinkingMode} onModeChange={setThinkingMode} onClose={() => {}} className="" />
)}
<TokenUsagePie used={tokenBudget?.used || 0} total={tokenBudget?.total || parseInt(import.meta.env.VITE_CONTEXT_WINDOW) || 160000} />
<button
type="button"
onClick={onToggleCommandMenu}
className="relative w-7 h-7 sm:w-8 sm:h-8 text-muted-foreground hover:text-foreground rounded-lg flex items-center justify-center transition-colors hover:bg-accent/60"
title={t('input.showAllCommands')}
>
<svg className="w-4 h-4 sm:w-5 sm:h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z"
/>
</svg>
{slashCommandsCount > 0 && (
<span
className="absolute -top-1 -right-1 bg-primary text-primary-foreground text-[10px] font-bold rounded-full w-4 h-4 sm:w-5 sm:h-5 flex items-center justify-center"
>
{slashCommandsCount}
</span>
)}
</button>
{hasInput && (
<button
type="button"
onClick={onClearInput}
className="w-7 h-7 sm:w-8 sm:h-8 bg-card hover:bg-accent/60 border border-border/50 rounded-lg flex items-center justify-center transition-all duration-200 group shadow-sm"
title={t('input.clearInput', { defaultValue: 'Clear input' })}
>
<svg
className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-muted-foreground group-hover:text-foreground transition-colors"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
{isUserScrolledUp && hasMessages && (
<button
onClick={onScrollToBottom}
className="w-7 h-7 sm:w-8 sm:h-8 bg-primary hover:bg-primary/90 text-primary-foreground rounded-lg shadow-sm flex items-center justify-center transition-all duration-200 hover:scale-105"
title={t('input.scrollToBottom', { defaultValue: 'Scroll to bottom' })}
>
<svg className="w-3.5 h-3.5 sm:w-4 sm:h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 14l-7 7m0 0l-7-7m7 7V3" />
</svg>
</button>
)}
</div>
);
}

View File

@@ -0,0 +1,208 @@
import { useTranslation } from 'react-i18next';
import { useCallback, useRef } from 'react';
import type { Dispatch, RefObject, SetStateAction } from 'react';
import MessageComponent from './MessageComponent';
import ProviderSelectionEmptyState from './ProviderSelectionEmptyState';
import type { ChatMessage } from '../../types/types';
import type { Project, ProjectSession, SessionProvider } from '../../../../types/app';
import AssistantThinkingIndicator from './AssistantThinkingIndicator';
import { getIntrinsicMessageKey } from '../../utils/messageKeys';
interface ChatMessagesPaneProps {
scrollContainerRef: RefObject<HTMLDivElement>;
onWheel: () => void;
onTouchMove: () => void;
isLoadingSessionMessages: boolean;
chatMessages: ChatMessage[];
selectedSession: ProjectSession | null;
currentSessionId: string | null;
provider: SessionProvider;
setProvider: (provider: SessionProvider) => void;
textareaRef: RefObject<HTMLTextAreaElement>;
claudeModel: string;
setClaudeModel: (model: string) => void;
cursorModel: string;
setCursorModel: (model: string) => void;
codexModel: string;
setCodexModel: (model: string) => void;
tasksEnabled: boolean;
isTaskMasterInstalled: boolean | null;
onShowAllTasks?: (() => void) | null;
setInput: Dispatch<SetStateAction<string>>;
isLoadingMoreMessages: boolean;
hasMoreMessages: boolean;
totalMessages: number;
sessionMessagesCount: number;
visibleMessageCount: number;
visibleMessages: ChatMessage[];
loadEarlierMessages: () => void;
createDiff: any;
onFileOpen?: (filePath: string, diffInfo?: unknown) => void;
onShowSettings?: () => void;
onGrantToolPermission: (suggestion: { entry: string; toolName: string }) => { success: boolean };
autoExpandTools?: boolean;
showRawParameters?: boolean;
showThinking?: boolean;
selectedProject: Project;
isLoading: boolean;
}
export default function ChatMessagesPane({
scrollContainerRef,
onWheel,
onTouchMove,
isLoadingSessionMessages,
chatMessages,
selectedSession,
currentSessionId,
provider,
setProvider,
textareaRef,
claudeModel,
setClaudeModel,
cursorModel,
setCursorModel,
codexModel,
setCodexModel,
tasksEnabled,
isTaskMasterInstalled,
onShowAllTasks,
setInput,
isLoadingMoreMessages,
hasMoreMessages,
totalMessages,
sessionMessagesCount,
visibleMessageCount,
visibleMessages,
loadEarlierMessages,
createDiff,
onFileOpen,
onShowSettings,
onGrantToolPermission,
autoExpandTools,
showRawParameters,
showThinking,
selectedProject,
isLoading,
}: ChatMessagesPaneProps) {
const { t } = useTranslation('chat');
const messageKeyMapRef = useRef<WeakMap<ChatMessage, string>>(new WeakMap());
const allocatedKeysRef = useRef<Set<string>>(new Set());
const generatedMessageKeyCounterRef = useRef(0);
// Keep keys stable across prepends so existing MessageComponent instances retain local state.
const getMessageKey = useCallback((message: ChatMessage) => {
const existingKey = messageKeyMapRef.current.get(message);
if (existingKey) {
return existingKey;
}
const intrinsicKey = getIntrinsicMessageKey(message);
let candidateKey = intrinsicKey;
if (!candidateKey || allocatedKeysRef.current.has(candidateKey)) {
do {
generatedMessageKeyCounterRef.current += 1;
candidateKey = intrinsicKey
? `${intrinsicKey}-${generatedMessageKeyCounterRef.current}`
: `message-generated-${generatedMessageKeyCounterRef.current}`;
} while (allocatedKeysRef.current.has(candidateKey));
}
allocatedKeysRef.current.add(candidateKey);
messageKeyMapRef.current.set(message, candidateKey);
return candidateKey;
}, []);
return (
<div
ref={scrollContainerRef}
onWheel={onWheel}
onTouchMove={onTouchMove}
className="flex-1 overflow-y-auto overflow-x-hidden px-0 py-3 sm:p-4 space-y-3 sm:space-y-4 relative"
>
{isLoadingSessionMessages && chatMessages.length === 0 ? (
<div className="text-center text-gray-500 dark:text-gray-400 mt-8">
<div className="flex items-center justify-center space-x-2">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-gray-400" />
<p>{t('session.loading.sessionMessages')}</p>
</div>
</div>
) : chatMessages.length === 0 ? (
<ProviderSelectionEmptyState
selectedSession={selectedSession}
currentSessionId={currentSessionId}
provider={provider}
setProvider={setProvider}
textareaRef={textareaRef}
claudeModel={claudeModel}
setClaudeModel={setClaudeModel}
cursorModel={cursorModel}
setCursorModel={setCursorModel}
codexModel={codexModel}
setCodexModel={setCodexModel}
tasksEnabled={tasksEnabled}
isTaskMasterInstalled={isTaskMasterInstalled}
onShowAllTasks={onShowAllTasks}
setInput={setInput}
/>
) : (
<>
{isLoadingMoreMessages && (
<div className="text-center text-gray-500 dark:text-gray-400 py-3">
<div className="flex items-center justify-center space-x-2">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-gray-400" />
<p className="text-sm">{t('session.loading.olderMessages')}</p>
</div>
</div>
)}
{hasMoreMessages && !isLoadingMoreMessages && (
<div className="text-center text-gray-500 dark:text-gray-400 text-sm py-2 border-b border-gray-200 dark:border-gray-700">
{totalMessages > 0 && (
<span>
{t('session.messages.showingOf', { shown: sessionMessagesCount, total: totalMessages })} |
<span className="text-xs">{t('session.messages.scrollToLoad')}</span>
</span>
)}
</div>
)}
{!hasMoreMessages && chatMessages.length > visibleMessageCount && (
<div className="text-center text-gray-500 dark:text-gray-400 text-sm py-2 border-b border-gray-200 dark:border-gray-700">
{t('session.messages.showingLast', { count: visibleMessageCount, total: chatMessages.length })} |
<button className="ml-1 text-blue-600 hover:text-blue-700 underline" onClick={loadEarlierMessages}>
{t('session.messages.loadEarlier')}
</button>
</div>
)}
{visibleMessages.map((message, index) => {
const prevMessage = index > 0 ? visibleMessages[index - 1] : null;
return (
<MessageComponent
key={getMessageKey(message)}
message={message}
index={index}
prevMessage={prevMessage}
createDiff={createDiff}
onFileOpen={onFileOpen}
onShowSettings={onShowSettings}
onGrantToolPermission={onGrantToolPermission}
autoExpandTools={autoExpandTools}
showRawParameters={showRawParameters}
showThinking={showThinking}
selectedProject={selectedProject}
provider={provider}
/>
);
})}
</>
)}
{isLoading && <AssistantThinkingIndicator selectedProvider={provider} />}
</div>
);
}

View File

@@ -0,0 +1,50 @@
import { useEffect, useState } from 'react';
interface ImageAttachmentProps {
file: File;
onRemove: () => void;
uploadProgress?: number;
error?: string;
}
const ImageAttachment = ({ file, onRemove, uploadProgress, error }: ImageAttachmentProps) => {
const [preview, setPreview] = useState<string | undefined>(undefined);
useEffect(() => {
const url = URL.createObjectURL(file);
setPreview(url);
return () => URL.revokeObjectURL(url);
}, [file]);
return (
<div className="relative group">
<img src={preview} alt={file.name} className="w-20 h-20 object-cover rounded" />
{uploadProgress !== undefined && uploadProgress < 100 && (
<div className="absolute inset-0 bg-black/50 flex items-center justify-center">
<div className="text-white text-xs">{uploadProgress}%</div>
</div>
)}
{error && (
<div className="absolute inset-0 bg-red-500/50 flex items-center justify-center">
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
)}
<button
type="button"
onClick={onRemove}
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full p-1 opacity-100 sm:opacity-0 sm:group-hover:opacity-100 focus:opacity-100 transition-opacity"
aria-label="Remove image"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
);
};
export default ImageAttachment;

View File

@@ -0,0 +1,188 @@
import React, { useMemo, useState } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
import rehypeKatex from 'rehype-katex';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { useTranslation } from 'react-i18next';
import { normalizeInlineCodeFences } from '../../utils/chatFormatting';
type MarkdownProps = {
children: React.ReactNode;
className?: string;
};
type CodeBlockProps = {
node?: any;
inline?: boolean;
className?: string;
children?: React.ReactNode;
};
const CodeBlock = ({ node, inline, className, children, ...props }: CodeBlockProps) => {
const { t } = useTranslation('chat');
const [copied, setCopied] = useState(false);
const raw = Array.isArray(children) ? children.join('') : String(children ?? '');
const looksMultiline = /[\r\n]/.test(raw);
const inlineDetected = inline || (node && node.type === 'inlineCode');
const shouldInline = inlineDetected || !looksMultiline;
if (shouldInline) {
return (
<code
className={`font-mono text-[0.9em] px-1.5 py-0.5 rounded-md bg-gray-100 text-gray-900 border border-gray-200 dark:bg-gray-800/60 dark:text-gray-100 dark:border-gray-700 whitespace-pre-wrap break-words ${
className || ''
}`}
{...props}
>
{children}
</code>
);
}
const match = /language-(\w+)/.exec(className || '');
const language = match ? match[1] : 'text';
const textToCopy = raw;
const handleCopy = () => {
const doSet = () => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
};
try {
if (navigator && navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(textToCopy).then(doSet).catch(() => {
const ta = document.createElement('textarea');
ta.value = textToCopy;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
try {
document.execCommand('copy');
} catch {}
document.body.removeChild(ta);
doSet();
});
} else {
const ta = document.createElement('textarea');
ta.value = textToCopy;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
try {
document.execCommand('copy');
} catch {}
document.body.removeChild(ta);
doSet();
}
} catch {}
};
return (
<div className="relative group my-2">
{language && language !== 'text' && (
<div className="absolute top-2 left-3 z-10 text-xs text-gray-400 font-medium uppercase">{language}</div>
)}
<button
type="button"
onClick={handleCopy}
className="absolute top-2 right-2 z-10 opacity-0 group-hover:opacity-100 focus:opacity-100 active:opacity-100 transition-opacity text-xs px-2 py-1 rounded-md bg-gray-700/80 hover:bg-gray-700 text-white border border-gray-600"
title={copied ? t('codeBlock.copied') : t('codeBlock.copyCode')}
aria-label={copied ? t('codeBlock.copied') : t('codeBlock.copyCode')}
>
{copied ? (
<span className="flex items-center gap-1">
<svg className="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
{t('codeBlock.copied')}
</span>
) : (
<span className="flex items-center gap-1">
<svg
className="w-3.5 h-3.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"></path>
</svg>
{t('codeBlock.copy')}
</span>
)}
</button>
<SyntaxHighlighter
language={language}
style={oneDark}
customStyle={{
margin: 0,
borderRadius: '0.5rem',
fontSize: '0.875rem',
padding: language && language !== 'text' ? '2rem 1rem 1rem 1rem' : '1rem',
}}
codeTagProps={{
style: {
fontFamily:
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
},
}}
>
{raw}
</SyntaxHighlighter>
</div>
);
};
const markdownComponents = {
code: CodeBlock,
blockquote: ({ children }: { children?: React.ReactNode }) => (
<blockquote className="border-l-4 border-gray-300 dark:border-gray-600 pl-4 italic text-gray-600 dark:text-gray-400 my-2">
{children}
</blockquote>
),
a: ({ href, children }: { href?: string; children?: React.ReactNode }) => (
<a href={href} className="text-blue-600 dark:text-blue-400 hover:underline" target="_blank" rel="noopener noreferrer">
{children}
</a>
),
p: ({ children }: { children?: React.ReactNode }) => <div className="mb-2 last:mb-0">{children}</div>,
table: ({ children }: { children?: React.ReactNode }) => (
<div className="overflow-x-auto my-2">
<table className="min-w-full border-collapse border border-gray-200 dark:border-gray-700">{children}</table>
</div>
),
thead: ({ children }: { children?: React.ReactNode }) => <thead className="bg-gray-50 dark:bg-gray-800">{children}</thead>,
th: ({ children }: { children?: React.ReactNode }) => (
<th className="px-3 py-2 text-left text-sm font-semibold border border-gray-200 dark:border-gray-700">{children}</th>
),
td: ({ children }: { children?: React.ReactNode }) => (
<td className="px-3 py-2 align-top text-sm border border-gray-200 dark:border-gray-700">{children}</td>
),
};
export function Markdown({ children, className }: MarkdownProps) {
const content = normalizeInlineCodeFences(String(children ?? ''));
const remarkPlugins = useMemo(() => [remarkGfm, remarkMath], []);
const rehypePlugins = useMemo(() => [rehypeKatex], []);
return (
<div className={className}>
<ReactMarkdown remarkPlugins={remarkPlugins} rehypePlugins={rehypePlugins} components={markdownComponents as any}>
{content}
</ReactMarkdown>
</div>
);
}

View File

@@ -0,0 +1,454 @@
import React, { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import SessionProviderLogo from '../../../SessionProviderLogo';
import type {
ChatMessage,
ClaudePermissionSuggestion,
PermissionGrantResult,
Provider,
} from '../../types/types';
import { Markdown } from './Markdown';
import { formatUsageLimitText } from '../../utils/chatFormatting';
import { getClaudePermissionSuggestion } from '../../utils/chatPermissions';
import type { Project } from '../../../../types/app';
import { ToolRenderer, shouldHideToolResult } from '../../tools';
type DiffLine = {
type: string;
content: string;
lineNum: number;
};
interface MessageComponentProps {
message: ChatMessage;
index: number;
prevMessage: ChatMessage | null;
createDiff: (oldStr: string, newStr: string) => DiffLine[];
onFileOpen?: (filePath: string, diffInfo?: unknown) => void;
onShowSettings?: () => void;
onGrantToolPermission?: (suggestion: ClaudePermissionSuggestion) => PermissionGrantResult | null | undefined;
autoExpandTools?: boolean;
showRawParameters?: boolean;
showThinking?: boolean;
selectedProject?: Project | null;
provider: Provider | string;
}
type InteractiveOption = {
number: string;
text: string;
isSelected: boolean;
};
type PermissionGrantState = 'idle' | 'granted' | 'error';
const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFileOpen, onShowSettings, onGrantToolPermission, autoExpandTools, showRawParameters, showThinking, selectedProject, provider }: MessageComponentProps) => {
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<HTMLDivElement | null>(null);
const [isExpanded, setIsExpanded] = React.useState(false);
const permissionSuggestion = getClaudePermissionSuggestion(message, provider);
const [permissionGrantState, setPermissionGrantState] = React.useState<PermissionGrantState>('idle');
React.useEffect(() => {
setPermissionGrantState('idle');
}, [permissionSuggestion?.entry, message.toolId]);
React.useEffect(() => {
const node = messageRef.current;
if (!autoExpandTools || !node || !message.isToolUse) return;
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting && !isExpanded) {
setIsExpanded(true);
const details = node.querySelectorAll<HTMLDetailsElement>('details');
details.forEach((detail) => {
detail.open = true;
});
}
});
},
{ threshold: 0.1 }
);
observer.observe(node);
return () => {
observer.unobserve(node);
};
}, [autoExpandTools, isExpanded, message.isToolUse]);
const formattedTime = useMemo(() => new Date(message.timestamp).toLocaleTimeString(), [message.timestamp]);
const shouldHideThinkingMessage = Boolean(message.isThinking && !showThinking);
if (shouldHideThinkingMessage) {
return null;
}
return (
<div
ref={messageRef}
className={`chat-message ${message.type} ${isGrouped ? 'grouped' : ''} ${message.type === 'user' ? 'flex justify-end px-3 sm:px-0' : 'px-3 sm:px-0'}`}
>
{message.type === 'user' ? (
/* User message bubble on the right */
<div className="flex items-end space-x-0 sm:space-x-3 w-full sm:w-auto sm:max-w-[85%] md:max-w-md lg:max-w-lg xl:max-w-xl">
<div className="bg-blue-600 text-white rounded-2xl rounded-br-md px-3 sm:px-4 py-2 shadow-sm flex-1 sm:flex-initial">
<div className="text-sm whitespace-pre-wrap break-words">
{message.content}
</div>
{message.images && message.images.length > 0 && (
<div className="mt-2 grid grid-cols-2 gap-2">
{message.images.map((img, idx) => (
<img
key={img.name || idx}
src={img.data}
alt={img.name}
className="rounded-lg max-w-full h-auto cursor-pointer hover:opacity-90 transition-opacity"
onClick={() => window.open(img.data, '_blank')}
/>
))}
</div>
)}
<div className="text-xs text-blue-100 mt-1 text-right">
{formattedTime}
</div>
</div>
{!isGrouped && (
<div className="hidden sm:flex w-8 h-8 bg-blue-600 rounded-full items-center justify-center text-white text-sm flex-shrink-0">
U
</div>
)}
</div>
) : message.isTaskNotification ? (
/* Compact task notification on the left */
<div className="w-full">
<div className="flex items-center gap-2 py-0.5">
<span className={`inline-block w-1.5 h-1.5 rounded-full flex-shrink-0 ${message.taskStatus === 'completed' ? 'bg-green-400 dark:bg-green-500' : 'bg-amber-400 dark:bg-amber-500'}`} />
<span className="text-xs text-gray-500 dark:text-gray-400">{message.content}</span>
</div>
</div>
) : (
/* Claude/Error/Tool messages on the left */
<div className="w-full">
{!isGrouped && (
<div className="flex items-center space-x-3 mb-2">
{message.type === 'error' ? (
<div className="w-8 h-8 bg-red-600 rounded-full flex items-center justify-center text-white text-sm flex-shrink-0">
!
</div>
) : message.type === 'tool' ? (
<div className="w-8 h-8 bg-gray-600 dark:bg-gray-700 rounded-full flex items-center justify-center text-white text-sm flex-shrink-0">
🔧
</div>
) : (
<div className="w-8 h-8 rounded-full flex items-center justify-center text-white text-sm flex-shrink-0 p-1">
<SessionProviderLogo provider={provider} className="w-full h-full" />
</div>
)}
<div className="text-sm font-medium text-gray-900 dark:text-white">
{message.type === 'error' ? t('messageTypes.error') : message.type === 'tool' ? t('messageTypes.tool') : (provider === 'cursor' ? t('messageTypes.cursor') : provider === 'codex' ? t('messageTypes.codex') : t('messageTypes.claude'))}
</div>
</div>
)}
<div className="w-full">
{message.isToolUse ? (
<>
<div className="flex flex-col">
<div className="flex flex-col">
<Markdown className="prose prose-sm max-w-none dark:prose-invert">
{String(message.displayText || '')}
</Markdown>
</div>
</div>
{message.toolInput && (
<ToolRenderer
toolName={message.toolName || 'UnknownTool'}
toolInput={message.toolInput}
toolResult={message.toolResult}
toolId={message.toolId}
mode="input"
onFileOpen={onFileOpen}
createDiff={createDiff}
selectedProject={selectedProject}
autoExpandTools={autoExpandTools}
showRawParameters={showRawParameters}
rawToolInput={typeof message.toolInput === 'string' ? message.toolInput : undefined}
/>
)}
{/* Tool Result Section */}
{message.toolResult && !shouldHideToolResult(message.toolName || 'UnknownTool', message.toolResult) && (
message.toolResult.isError ? (
// Error results - red error box with content
<div
id={`tool-result-${message.toolId}`}
className="relative mt-2 p-3 rounded border scroll-mt-4 bg-red-50/50 dark:bg-red-950/10 border-red-200/60 dark:border-red-800/40"
>
<div className="relative flex items-center gap-1.5 mb-2">
<svg className="w-4 h-4 text-red-500 dark:text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
<span className="text-xs font-medium text-red-700 dark:text-red-300">{t('messageTypes.error')}</span>
</div>
<div className="relative text-sm text-red-900 dark:text-red-100">
<Markdown className="prose prose-sm max-w-none prose-red dark:prose-invert">
{String(message.toolResult.content || '')}
</Markdown>
{permissionSuggestion && (
<div className="mt-4 border-t border-red-200/60 dark:border-red-800/60 pt-3">
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => {
if (!onGrantToolPermission) return;
const result = onGrantToolPermission(permissionSuggestion);
if (result?.success) {
setPermissionGrantState('granted');
} else {
setPermissionGrantState('error');
}
}}
disabled={permissionSuggestion.isAllowed || permissionGrantState === 'granted'}
className={`inline-flex items-center gap-2 px-3 py-1.5 rounded-md text-xs font-medium border transition-colors ${
permissionSuggestion.isAllowed || permissionGrantState === 'granted'
? 'bg-green-100 dark:bg-green-900/30 border-green-300/70 dark:border-green-800/60 text-green-800 dark:text-green-200 cursor-default'
: 'bg-white/80 dark:bg-gray-900/40 border-red-300/70 dark:border-red-800/60 text-red-700 dark:text-red-200 hover:bg-white dark:hover:bg-gray-900/70'
}`}
>
{permissionSuggestion.isAllowed || permissionGrantState === 'granted'
? t('permissions.added')
: t('permissions.grant', { tool: permissionSuggestion.toolName })}
</button>
{onShowSettings && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); onShowSettings(); }}
className="text-xs text-red-700 dark:text-red-200 underline hover:text-red-800 dark:hover:text-red-100"
>
{t('permissions.openSettings')}
</button>
)}
</div>
<div className="mt-2 text-xs text-red-700/90 dark:text-red-200/80">
{t('permissions.addTo', { entry: permissionSuggestion.entry })}
</div>
{permissionGrantState === 'error' && (
<div className="mt-2 text-xs text-red-700 dark:text-red-200">
{t('permissions.error')}
</div>
)}
{(permissionSuggestion.isAllowed || permissionGrantState === 'granted') && (
<div className="mt-2 text-xs text-green-700 dark:text-green-200">
{t('permissions.retry')}
</div>
)}
</div>
)}
</div>
</div>
) : (
// Non-error results - route through ToolRenderer (single source of truth)
<div id={`tool-result-${message.toolId}`} className="scroll-mt-4">
<ToolRenderer
toolName={message.toolName || 'UnknownTool'}
toolInput={message.toolInput}
toolResult={message.toolResult}
toolId={message.toolId}
mode="result"
onFileOpen={onFileOpen}
createDiff={createDiff}
selectedProject={selectedProject}
autoExpandTools={autoExpandTools}
/>
</div>
)
)}
</>
) : message.isInteractivePrompt ? (
// Special handling for interactive prompts
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4">
<div className="flex items-start gap-3">
<div className="w-8 h-8 bg-amber-500 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5">
<svg className="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div className="flex-1">
<h4 className="font-semibold text-amber-900 dark:text-amber-100 text-base mb-3">
{t('interactive.title')}
</h4>
{(() => {
const lines = (message.content || '').split('\n').filter((line) => line.trim());
const questionLine = lines.find((line) => line.includes('?')) || lines[0] || '';
const options: InteractiveOption[] = [];
// 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 (
<>
<p className="text-sm text-amber-800 dark:text-amber-200 mb-4">
{questionLine}
</p>
{/* Option buttons */}
<div className="space-y-2 mb-4">
{options.map((option) => (
<button
key={option.number}
className={`w-full text-left px-4 py-3 rounded-lg border-2 transition-all ${
option.isSelected
? 'bg-amber-600 dark:bg-amber-700 text-white border-amber-600 dark:border-amber-700 shadow-md'
: 'bg-white dark:bg-gray-800 text-amber-900 dark:text-amber-100 border-amber-300 dark:border-amber-700'
} cursor-not-allowed opacity-75`}
disabled
>
<div className="flex items-center gap-3">
<span className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold ${
option.isSelected
? 'bg-white/20'
: 'bg-amber-100 dark:bg-amber-800/50'
}`}>
{option.number}
</span>
<span className="text-sm sm:text-base font-medium flex-1">
{option.text}
</span>
{option.isSelected && (
<span className="text-lg"></span>
)}
</div>
</button>
))}
</div>
<div className="bg-amber-100 dark:bg-amber-800/30 rounded-lg p-3">
<p className="text-amber-900 dark:text-amber-100 text-sm font-medium mb-1">
{t('interactive.waiting')}
</p>
<p className="text-amber-800 dark:text-amber-200 text-xs">
{t('interactive.instruction')}
</p>
</div>
</>
);
})()}
</div>
</div>
</div>
) : message.isThinking ? (
/* Thinking messages - collapsible by default */
<div className="text-sm text-gray-700 dark:text-gray-300">
<details className="group">
<summary className="cursor-pointer text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 font-medium flex items-center gap-2">
<svg className="w-3 h-3 transition-transform group-open:rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
<span>{t('thinking.emoji')}</span>
</summary>
<div className="mt-2 pl-4 border-l-2 border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400 text-sm">
<Markdown className="prose prose-sm max-w-none dark:prose-invert prose-gray">
{message.content}
</Markdown>
</div>
</details>
</div>
) : (
<div className="text-sm text-gray-700 dark:text-gray-300">
{/* Thinking accordion for reasoning */}
{showThinking && message.reasoning && (
<details className="mb-3">
<summary className="cursor-pointer text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 font-medium">
{t('thinking.emoji')}
</summary>
<div className="mt-2 pl-4 border-l-2 border-gray-300 dark:border-gray-600 italic text-gray-600 dark:text-gray-400 text-sm">
<div className="whitespace-pre-wrap">
{message.reasoning}
</div>
</div>
</details>
)}
{(() => {
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 (
<div className="my-2">
<div className="flex items-center gap-2 mb-2 text-sm text-gray-600 dark:text-gray-400">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
<span className="font-medium">{t('json.response')}</span>
</div>
<div className="bg-gray-800 dark:bg-gray-900 border border-gray-600/30 dark:border-gray-700 rounded-lg overflow-hidden">
<pre className="p-4 overflow-x-auto">
<code className="text-gray-100 dark:text-gray-200 text-sm font-mono block whitespace-pre">
{formatted}
</code>
</pre>
</div>
</div>
);
} catch {
// Not valid JSON, fall through to normal rendering
}
}
// Normal rendering for non-JSON content
return message.type === 'assistant' ? (
<Markdown className="prose prose-sm max-w-none dark:prose-invert prose-gray">
{content}
</Markdown>
) : (
<div className="whitespace-pre-wrap">
{content}
</div>
);
})()}
</div>
)}
{!isGrouped && (
<div className="text-[11px] text-gray-400 dark:text-gray-500 mt-1">
{formattedTime}
</div>
)}
</div>
</div>
)}
</div>
);
});
export default MessageComponent;

View File

@@ -0,0 +1,124 @@
import React from 'react';
import type { PendingPermissionRequest } from '../../types/types';
import { buildClaudeToolPermissionEntry, formatToolInputForDisplay } from '../../utils/chatPermissions';
import { getClaudeSettings } from '../../utils/chatStorage';
import { getPermissionPanel, registerPermissionPanel } from '../../tools/configs/permissionPanelRegistry';
import { AskUserQuestionPanel } from '../../tools/components/InteractiveRenderers';
registerPermissionPanel('AskUserQuestion', AskUserQuestionPanel);
interface PermissionRequestsBannerProps {
pendingPermissionRequests: PendingPermissionRequest[];
handlePermissionDecision: (
requestIds: string | string[],
decision: { allow?: boolean; message?: string; rememberEntry?: string | null; updatedInput?: unknown },
) => void;
handleGrantToolPermission: (suggestion: { entry: string; toolName: string }) => { success: boolean };
}
export default function PermissionRequestsBanner({
pendingPermissionRequests,
handlePermissionDecision,
handleGrantToolPermission,
}: PermissionRequestsBannerProps) {
if (!pendingPermissionRequests.length) {
return null;
}
return (
<div className="mb-3 space-y-2">
{pendingPermissionRequests.map((request) => {
const CustomPanel = getPermissionPanel(request.toolName);
if (CustomPanel) {
return (
<CustomPanel
key={request.requestId}
request={request}
onDecision={handlePermissionDecision}
/>
);
}
const rawInput = formatToolInputForDisplay(request.input);
const permissionEntry = buildClaudeToolPermissionEntry(request.toolName, rawInput);
const settings = getClaudeSettings();
const alreadyAllowed = permissionEntry ? settings.allowedTools.includes(permissionEntry) : false;
const rememberLabel = alreadyAllowed ? 'Allow (saved)' : 'Allow & remember';
const matchingRequestIds = permissionEntry
? pendingPermissionRequests
.filter(
(item) =>
buildClaudeToolPermissionEntry(item.toolName, formatToolInputForDisplay(item.input)) === permissionEntry,
)
.map((item) => item.requestId)
: [request.requestId];
return (
<div
key={request.requestId}
className="rounded-lg border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20 p-3 shadow-sm"
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="text-sm font-semibold text-amber-900 dark:text-amber-100">Permission required</div>
<div className="text-xs text-amber-800 dark:text-amber-200">
Tool: <span className="font-mono">{request.toolName}</span>
</div>
</div>
{permissionEntry && (
<div className="text-xs text-amber-700 dark:text-amber-300">
Allow rule: <span className="font-mono">{permissionEntry}</span>
</div>
)}
</div>
{rawInput && (
<details className="mt-2">
<summary className="cursor-pointer text-xs text-amber-800 dark:text-amber-200 hover:text-amber-900 dark:hover:text-amber-100">
View tool input
</summary>
<pre className="mt-2 max-h-40 overflow-auto rounded-md bg-white/80 dark:bg-gray-900/60 border border-amber-200/60 dark:border-amber-800/60 p-2 text-xs text-amber-900 dark:text-amber-100 whitespace-pre-wrap">
{rawInput}
</pre>
</details>
)}
<div className="mt-3 flex flex-wrap gap-2">
<button
type="button"
onClick={() => handlePermissionDecision(request.requestId, { allow: true })}
className="inline-flex items-center gap-2 rounded-md bg-amber-600 text-white text-xs font-medium px-3 py-1.5 hover:bg-amber-700 transition-colors"
>
Allow once
</button>
<button
type="button"
onClick={() => {
if (permissionEntry && !alreadyAllowed) {
handleGrantToolPermission({ entry: permissionEntry, toolName: request.toolName });
}
handlePermissionDecision(matchingRequestIds, { allow: true, rememberEntry: permissionEntry });
}}
className={`inline-flex items-center gap-2 rounded-md text-xs font-medium px-3 py-1.5 border transition-colors ${
permissionEntry
? 'border-amber-300 text-amber-800 hover:bg-amber-100 dark:border-amber-700 dark:text-amber-100 dark:hover:bg-amber-900/30'
: 'border-gray-300 text-gray-400 cursor-not-allowed'
}`}
disabled={!permissionEntry}
>
{rememberLabel}
</button>
<button
type="button"
onClick={() => handlePermissionDecision(request.requestId, { allow: false, message: 'User denied tool use' })}
className="inline-flex items-center gap-2 rounded-md text-xs font-medium px-3 py-1.5 border border-red-300 text-red-700 hover:bg-red-50 dark:border-red-800 dark:text-red-200 dark:hover:bg-red-900/30 transition-colors"
>
Deny
</button>
</div>
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,222 @@
import React from 'react';
import { Check, ChevronDown } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import SessionProviderLogo from '../../../SessionProviderLogo';
import NextTaskBanner from '../../../NextTaskBanner.jsx';
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../../../../shared/modelConstants';
import type { ProjectSession, SessionProvider } from '../../../../types/app';
interface ProviderSelectionEmptyStateProps {
selectedSession: ProjectSession | null;
currentSessionId: string | null;
provider: SessionProvider;
setProvider: (next: SessionProvider) => void;
textareaRef: React.RefObject<HTMLTextAreaElement>;
claudeModel: string;
setClaudeModel: (model: string) => void;
cursorModel: string;
setCursorModel: (model: string) => void;
codexModel: string;
setCodexModel: (model: string) => void;
tasksEnabled: boolean;
isTaskMasterInstalled: boolean | null;
onShowAllTasks?: (() => void) | null;
setInput: React.Dispatch<React.SetStateAction<string>>;
}
type ProviderDef = {
id: SessionProvider;
name: string;
infoKey: string;
accent: string;
ring: string;
check: string;
};
const PROVIDERS: ProviderDef[] = [
{
id: 'claude',
name: 'Claude Code',
infoKey: 'providerSelection.providerInfo.anthropic',
accent: 'border-primary',
ring: 'ring-primary/15',
check: 'bg-primary text-primary-foreground',
},
{
id: 'cursor',
name: 'Cursor',
infoKey: 'providerSelection.providerInfo.cursorEditor',
accent: 'border-violet-500 dark:border-violet-400',
ring: 'ring-violet-500/15',
check: 'bg-violet-500 text-white',
},
{
id: 'codex',
name: 'Codex',
infoKey: 'providerSelection.providerInfo.openai',
accent: 'border-emerald-600 dark:border-emerald-400',
ring: 'ring-emerald-600/15',
check: 'bg-emerald-600 dark:bg-emerald-500 text-white',
},
];
function getModelConfig(p: SessionProvider) {
if (p === 'claude') return CLAUDE_MODELS;
if (p === 'codex') return CODEX_MODELS;
return CURSOR_MODELS;
}
function getModelValue(p: SessionProvider, c: string, cu: string, co: string) {
if (p === 'claude') return c;
if (p === 'codex') return co;
return cu;
}
export default function ProviderSelectionEmptyState({
selectedSession,
currentSessionId,
provider,
setProvider,
textareaRef,
claudeModel,
setClaudeModel,
cursorModel,
setCursorModel,
codexModel,
setCodexModel,
tasksEnabled,
isTaskMasterInstalled,
onShowAllTasks,
setInput,
}: ProviderSelectionEmptyStateProps) {
const { t } = useTranslation('chat');
const nextTaskPrompt = t('tasks.nextTaskPrompt', { defaultValue: 'Start the next task' });
const selectProvider = (next: SessionProvider) => {
setProvider(next);
localStorage.setItem('selected-provider', next);
setTimeout(() => textareaRef.current?.focus(), 100);
};
const handleModelChange = (value: string) => {
if (provider === 'claude') { setClaudeModel(value); localStorage.setItem('claude-model', value); }
else if (provider === 'codex') { setCodexModel(value); localStorage.setItem('codex-model', value); }
else { setCursorModel(value); localStorage.setItem('cursor-model', value); }
};
const modelConfig = getModelConfig(provider);
const currentModel = getModelValue(provider, claudeModel, cursorModel, codexModel);
/* ── New session — provider picker ── */
if (!selectedSession && !currentSessionId) {
return (
<div className="flex items-center justify-center h-full px-4">
<div className="w-full max-w-md">
{/* Heading */}
<div className="text-center mb-8">
<h2 className="text-lg sm:text-xl font-semibold text-foreground tracking-tight">
{t('providerSelection.title')}
</h2>
<p className="text-[13px] text-muted-foreground mt-1">
{t('providerSelection.description')}
</p>
</div>
{/* Provider cards — horizontal row, equal width */}
<div className="grid grid-cols-3 gap-2 sm:gap-2.5 mb-6">
{PROVIDERS.map((p) => {
const active = provider === p.id;
return (
<button
key={p.id}
onClick={() => selectProvider(p.id)}
className={`
relative flex flex-col items-center gap-2.5 pt-5 pb-4 px-2
rounded-xl border-[1.5px] transition-all duration-150
active:scale-[0.97]
${active
? `${p.accent} ${p.ring} ring-2 bg-card shadow-sm`
: 'border-border bg-card/60 hover:bg-card hover:border-border/80'
}
`}
>
<SessionProviderLogo
provider={p.id}
className={`w-9 h-9 transition-transform duration-150 ${active ? 'scale-110' : ''}`}
/>
<div className="text-center">
<p className="text-[13px] font-semibold text-foreground leading-none">{p.name}</p>
<p className="text-[10px] text-muted-foreground mt-1 leading-tight">{t(p.infoKey)}</p>
</div>
{/* Check badge */}
{active && (
<div className={`absolute -top-1 -right-1 w-[18px] h-[18px] rounded-full ${p.check} flex items-center justify-center shadow-sm`}>
<Check className="w-2.5 h-2.5" strokeWidth={3} />
</div>
)}
</button>
);
})}
</div>
{/* Model picker — appears after provider is chosen */}
<div className={`transition-all duration-200 ${provider ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-1 pointer-events-none'}`}>
<div className="flex items-center justify-center gap-2 mb-5">
<span className="text-xs text-muted-foreground">{t('providerSelection.selectModel')}</span>
<div className="relative">
<select
value={currentModel}
onChange={(e) => handleModelChange(e.target.value)}
tabIndex={-1}
className="appearance-none pl-3 pr-7 py-1.5 text-xs font-medium bg-muted/50 border border-border/60 rounded-lg text-foreground cursor-pointer hover:bg-muted transition-colors focus:outline-none focus:ring-2 focus:ring-primary/20"
>
{modelConfig.OPTIONS.map(({ value, label }: { value: string; label: string }) => (
<option key={value} value={value}>{label}</option>
))}
</select>
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 w-3 h-3 text-muted-foreground pointer-events-none" />
</div>
</div>
<p className="text-center text-xs text-muted-foreground/70">
{provider === 'claude'
? t('providerSelection.readyPrompt.claude', { model: claudeModel })
: provider === 'cursor'
? t('providerSelection.readyPrompt.cursor', { model: cursorModel })
: provider === 'codex'
? t('providerSelection.readyPrompt.codex', { model: codexModel })
: t('providerSelection.readyPrompt.default')}
</p>
</div>
{/* Task banner */}
{provider && tasksEnabled && isTaskMasterInstalled && (
<div className="mt-5">
<NextTaskBanner onStartTask={() => setInput(nextTaskPrompt)} onShowAllTasks={onShowAllTasks} />
</div>
)}
</div>
</div>
);
}
/* ── Existing session — continue prompt ── */
if (selectedSession) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center px-6 max-w-md">
<p className="text-lg font-semibold text-foreground mb-1.5">{t('session.continue.title')}</p>
<p className="text-sm text-muted-foreground leading-relaxed">{t('session.continue.description')}</p>
{tasksEnabled && isTaskMasterInstalled && (
<div className="mt-5">
<NextTaskBanner onStartTask={() => setInput(nextTaskPrompt)} onShowAllTasks={onShowAllTasks} />
</div>
)}
</div>
</div>
);
}
return null;
}

View File

@@ -0,0 +1,145 @@
import { useState, useRef, useEffect } from 'react';
import { Brain, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { thinkingModes } from '../../constants/thinkingModes';
type ThinkingModeSelectorProps = {
selectedMode: string;
onModeChange: (modeId: string) => void;
onClose?: () => void;
className?: string;
};
function ThinkingModeSelector({ selectedMode, onModeChange, onClose, className = '' }: ThinkingModeSelectorProps) {
const { t } = useTranslation('chat');
// Mapping from mode ID to translation key
const modeKeyMap: Record<string, string> = {
'think-hard': 'thinkHard',
'think-harder': 'thinkHarder'
};
// Create translated modes for display
const translatedModes = thinkingModes.map(mode => {
const modeKey = modeKeyMap[mode.id] || mode.id;
return {
...mode,
name: t(`thinkingMode.modes.${modeKey}.name`),
description: t(`thinkingMode.modes.${modeKey}.description`),
prefix: t(`thinkingMode.modes.${modeKey}.prefix`)
};
});
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
if (onClose) onClose();
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [onClose]);
const currentMode = translatedModes.find(mode => mode.id === selectedMode) || translatedModes[0];
const IconComponent = currentMode.icon || Brain;
return (
<div className={`relative ${className}`} ref={dropdownRef}>
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
className={`w-10 h-10 sm:w-10 sm:h-10 rounded-full flex items-center justify-center transition-all duration-200 ${selectedMode === 'none'
? 'bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600'
: 'bg-blue-100 hover:bg-blue-200 dark:bg-blue-900 dark:hover:bg-blue-800'
}`}
title={t('thinkingMode.buttonTitle', { mode: currentMode.name })}
>
<IconComponent className={`w-5 h-5 ${currentMode.color}`} />
</button>
{isOpen && (
<div className="absolute bottom-full right-0 mb-2 w-64 bg-white dark:bg-gray-800 rounded-lg shadow-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
<div className="p-3 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
{t('thinkingMode.selector.title')}
</h3>
<button
onClick={() => {
setIsOpen(false);
if (onClose) onClose();
}}
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
>
<X className="w-4 h-4 text-gray-500" />
</button>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{t('thinkingMode.selector.description')}
</p>
</div>
<div className="py-1">
{translatedModes.map((mode) => {
const ModeIcon = mode.icon;
const isSelected = mode.id === selectedMode;
return (
<button
key={mode.id}
onClick={() => {
onModeChange(mode.id);
setIsOpen(false);
if (onClose) onClose();
}}
className={`w-full px-4 py-3 text-left hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors ${isSelected ? 'bg-gray-50 dark:bg-gray-700' : ''
}`}
>
<div className="flex items-start gap-3">
<div className={`mt-0.5 ${mode.icon ? mode.color : 'text-gray-400'}`}>
{ModeIcon ? <ModeIcon className="w-5 h-5" /> : <div className="w-5 h-5" />}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className={`font-medium text-sm ${isSelected ? 'text-gray-900 dark:text-white' : 'text-gray-700 dark:text-gray-300'
}`}>
{mode.name}
</span>
{isSelected && (
<span className="text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 px-2 py-0.5 rounded">
{t('thinkingMode.selector.active')}
</span>
)}
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{mode.description}
</p>
{mode.prefix && (
<code className="text-xs bg-gray-100 dark:bg-gray-700 px-1.5 py-0.5 rounded mt-1 inline-block">
{mode.prefix}
</code>
)}
</div>
</div>
</button>
);
})}
</div>
<div className="p-3 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900">
<p className="text-xs text-gray-600 dark:text-gray-400">
<strong>Tip:</strong> {t('thinkingMode.selector.tip')}
</p>
</div>
</div>
)}
</div>
);
}
export default ThinkingModeSelector;

View File

@@ -1,9 +1,12 @@
import React from 'react';
type TokenUsagePieProps = {
used: number;
total: number;
};
function TokenUsagePie({ used, total }) {
export default function TokenUsagePie({ used, total }: TokenUsagePieProps) {
// Token usage visualization component
// Only bail out on missing values or nonpositive totals; allow used===0 to render 0%
if (used == null || total == null || total <= 0) return null;
if (used == null || total == null || total <= 0) return null;
const percentage = Math.min(100, (used / total) * 100);
const radius = 10;
@@ -48,6 +51,4 @@ function TokenUsagePie({ used, total }) {
</span>
</div>
);
}
export default TokenUsagePie;
}

View File

@@ -0,0 +1,110 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { MouseEvent as ReactMouseEvent } from 'react';
import type { Project } from '../../../types/app';
import type { DiffInfo, EditingFile } from '../types/types';
type UseEditorSidebarOptions = {
selectedProject: Project | null;
isMobile: boolean;
initialWidth?: number;
};
export function useEditorSidebar({
selectedProject,
isMobile,
initialWidth = 600,
}: UseEditorSidebarOptions) {
const [editingFile, setEditingFile] = useState<EditingFile | null>(null);
const [editorWidth, setEditorWidth] = useState(initialWidth);
const [editorExpanded, setEditorExpanded] = useState(false);
const [isResizing, setIsResizing] = useState(false);
const resizeHandleRef = useRef<HTMLDivElement | null>(null);
const handleFileOpen = useCallback(
(filePath: string, diffInfo: DiffInfo | null = null) => {
const normalizedPath = filePath.replace(/\\/g, '/');
const fileName = normalizedPath.split('/').pop() || filePath;
setEditingFile({
name: fileName,
path: filePath,
projectName: selectedProject?.name,
diffInfo,
});
},
[selectedProject?.name],
);
const handleCloseEditor = useCallback(() => {
setEditingFile(null);
setEditorExpanded(false);
}, []);
const handleToggleEditorExpand = useCallback(() => {
setEditorExpanded((prev) => !prev);
}, []);
const handleResizeStart = useCallback(
(event: ReactMouseEvent<HTMLDivElement>) => {
if (isMobile) {
return;
}
setIsResizing(true);
event.preventDefault();
},
[isMobile],
);
useEffect(() => {
const handleMouseMove = (event: globalThis.MouseEvent) => {
if (!isResizing) {
return;
}
const container = resizeHandleRef.current?.parentElement;
if (!container) {
return;
}
const containerRect = container.getBoundingClientRect();
const newWidth = containerRect.right - event.clientX;
const minWidth = 300;
const maxWidth = containerRect.width * 0.8;
if (newWidth >= minWidth && newWidth <= maxWidth) {
setEditorWidth(newWidth);
}
};
const handleMouseUp = () => {
setIsResizing(false);
};
if (isResizing) {
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
}
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
};
}, [isResizing]);
return {
editingFile,
editorWidth,
editorExpanded,
resizeHandleRef,
handleFileOpen,
handleCloseEditor,
handleToggleEditorExpand,
handleResizeStart,
};
}

View File

@@ -0,0 +1,50 @@
import { useCallback, useRef } from 'react';
import type { MouseEvent, TouchEvent } from 'react';
type MenuEvent = MouseEvent<HTMLButtonElement> | TouchEvent<HTMLButtonElement>;
export function useMobileMenuHandlers(onMenuClick: () => void) {
const suppressNextMenuClickRef = useRef(false);
const openMobileMenu = useCallback(
(event?: MenuEvent) => {
if (event) {
event.preventDefault();
event.stopPropagation();
}
onMenuClick();
},
[onMenuClick],
);
const handleMobileMenuTouchEnd = useCallback(
(event: TouchEvent<HTMLButtonElement>) => {
suppressNextMenuClickRef.current = true;
openMobileMenu(event);
window.setTimeout(() => {
suppressNextMenuClickRef.current = false;
}, 350);
},
[openMobileMenu],
);
const handleMobileMenuClick = useCallback(
(event: MouseEvent<HTMLButtonElement>) => {
if (suppressNextMenuClickRef.current) {
event.preventDefault();
event.stopPropagation();
return;
}
openMobileMenu(event);
},
[openMobileMenu],
);
return {
handleMobileMenuClick,
handleMobileMenuTouchEnd,
};
}

View File

@@ -0,0 +1,108 @@
import type { Dispatch, MouseEvent, RefObject, SetStateAction } from 'react';
import type { AppTab, Project, ProjectSession } from '../../../types/app';
export type SessionLifecycleHandler = (sessionId?: string | null) => void;
export interface DiffInfo {
old_string?: string;
new_string?: string;
[key: string]: unknown;
}
export interface EditingFile {
name: string;
path: string;
projectName?: string;
diffInfo?: DiffInfo | null;
[key: string]: unknown;
}
export interface TaskMasterTask {
id: string | number;
title?: string;
description?: string;
status?: string;
priority?: string;
details?: string;
testStrategy?: string;
parentId?: string | number;
dependencies?: Array<string | number>;
subtasks?: TaskMasterTask[];
[key: string]: unknown;
}
export interface TaskReference {
id: string | number;
title?: string;
[key: string]: unknown;
}
export type TaskSelection = TaskMasterTask | TaskReference;
export interface PrdFile {
name: string;
content?: string;
isExisting?: boolean;
[key: string]: unknown;
}
export interface MainContentProps {
selectedProject: Project | null;
selectedSession: ProjectSession | null;
activeTab: AppTab;
setActiveTab: Dispatch<SetStateAction<AppTab>>;
ws: WebSocket | null;
sendMessage: (message: unknown) => void;
latestMessage: unknown;
isMobile: boolean;
onMenuClick: () => void;
isLoading: boolean;
onInputFocusChange: (focused: boolean) => void;
onSessionActive: SessionLifecycleHandler;
onSessionInactive: SessionLifecycleHandler;
onSessionProcessing: SessionLifecycleHandler;
onSessionNotProcessing: SessionLifecycleHandler;
processingSessions: Set<string>;
onReplaceTemporarySession: SessionLifecycleHandler;
onNavigateToSession: (targetSessionId: string) => void;
onShowSettings: () => void;
externalMessageUpdate: number;
}
export interface MainContentHeaderProps {
activeTab: AppTab;
setActiveTab: Dispatch<SetStateAction<AppTab>>;
selectedProject: Project;
selectedSession: ProjectSession | null;
shouldShowTasksTab: boolean;
isMobile: boolean;
onMenuClick: () => void;
}
export interface MainContentStateViewProps {
mode: 'loading' | 'empty';
isMobile: boolean;
onMenuClick: () => void;
}
export interface MobileMenuButtonProps {
onMenuClick: () => void;
compact?: boolean;
}
export interface EditorSidebarProps {
editingFile: EditingFile | null;
isMobile: boolean;
editorExpanded: boolean;
editorWidth: number;
resizeHandleRef: RefObject<HTMLDivElement>;
onResizeStart: (event: MouseEvent<HTMLDivElement>) => void;
onCloseEditor: () => void;
onToggleEditorExpand: () => void;
projectPath?: string;
fillSpace?: boolean;
}
export interface TaskMasterPanelProps {
isVisible: boolean;
}

View File

@@ -0,0 +1,182 @@
import React, { useEffect } from 'react';
import ChatInterface from '../../chat/view/ChatInterface';
import FileTree from '../../FileTree';
import StandaloneShell from '../../StandaloneShell';
import GitPanel from '../../GitPanel';
import ErrorBoundary from '../../ErrorBoundary';
import MainContentHeader from './subcomponents/MainContentHeader';
import MainContentStateView from './subcomponents/MainContentStateView';
import EditorSidebar from './subcomponents/EditorSidebar';
import TaskMasterPanel from './subcomponents/TaskMasterPanel';
import type { MainContentProps } from '../types/types';
import { useTaskMaster } from '../../../contexts/TaskMasterContext';
import { useTasksSettings } from '../../../contexts/TasksSettingsContext';
import { useUiPreferences } from '../../../hooks/useUiPreferences';
import { useEditorSidebar } from '../hooks/useEditorSidebar';
import type { Project } from '../../../types/app';
const AnyStandaloneShell = StandaloneShell as any;
const AnyGitPanel = GitPanel as any;
type TaskMasterContextValue = {
currentProject?: Project | null;
setCurrentProject?: ((project: Project) => void) | null;
};
type TasksSettingsContextValue = {
tasksEnabled: boolean;
isTaskMasterInstalled: boolean | null;
isTaskMasterReady: boolean | null;
};
function MainContent({
selectedProject,
selectedSession,
activeTab,
setActiveTab,
ws,
sendMessage,
latestMessage,
isMobile,
onMenuClick,
isLoading,
onInputFocusChange,
onSessionActive,
onSessionInactive,
onSessionProcessing,
onSessionNotProcessing,
processingSessions,
onReplaceTemporarySession,
onNavigateToSession,
onShowSettings,
externalMessageUpdate,
}: MainContentProps) {
const { preferences } = useUiPreferences();
const { autoExpandTools, showRawParameters, showThinking, autoScrollToBottom, sendByCtrlEnter } = preferences;
const { currentProject, setCurrentProject } = useTaskMaster() as TaskMasterContextValue;
const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings() as TasksSettingsContextValue;
const shouldShowTasksTab = Boolean(tasksEnabled && isTaskMasterInstalled);
const {
editingFile,
editorWidth,
editorExpanded,
resizeHandleRef,
handleFileOpen,
handleCloseEditor,
handleToggleEditorExpand,
handleResizeStart,
} = useEditorSidebar({
selectedProject,
isMobile,
});
useEffect(() => {
if (selectedProject && selectedProject !== currentProject) {
setCurrentProject?.(selectedProject);
}
}, [selectedProject, currentProject, setCurrentProject]);
useEffect(() => {
if (!shouldShowTasksTab && activeTab === 'tasks') {
setActiveTab('chat');
}
}, [shouldShowTasksTab, activeTab, setActiveTab]);
if (isLoading) {
return <MainContentStateView mode="loading" isMobile={isMobile} onMenuClick={onMenuClick} />;
}
if (!selectedProject) {
return <MainContentStateView mode="empty" isMobile={isMobile} onMenuClick={onMenuClick} />;
}
return (
<div className="h-full flex flex-col">
<MainContentHeader
activeTab={activeTab}
setActiveTab={setActiveTab}
selectedProject={selectedProject}
selectedSession={selectedSession}
shouldShowTasksTab={shouldShowTasksTab}
isMobile={isMobile}
onMenuClick={onMenuClick}
/>
<div className="flex-1 flex min-h-0 overflow-hidden">
<div className={`flex flex-col min-h-0 overflow-hidden ${editorExpanded ? 'hidden' : ''} ${activeTab === 'files' && editingFile ? 'w-[280px] flex-shrink-0' : 'flex-1'}`}>
<div className={`h-full ${activeTab === 'chat' ? 'block' : 'hidden'}`}>
<ErrorBoundary showDetails>
<ChatInterface
selectedProject={selectedProject}
selectedSession={selectedSession}
ws={ws}
sendMessage={sendMessage}
latestMessage={latestMessage}
onFileOpen={handleFileOpen}
onInputFocusChange={onInputFocusChange}
onSessionActive={onSessionActive}
onSessionInactive={onSessionInactive}
onSessionProcessing={onSessionProcessing}
onSessionNotProcessing={onSessionNotProcessing}
processingSessions={processingSessions}
onReplaceTemporarySession={onReplaceTemporarySession}
onNavigateToSession={onNavigateToSession}
onShowSettings={onShowSettings}
autoExpandTools={autoExpandTools}
showRawParameters={showRawParameters}
showThinking={showThinking}
autoScrollToBottom={autoScrollToBottom}
sendByCtrlEnter={sendByCtrlEnter}
externalMessageUpdate={externalMessageUpdate}
onShowAllTasks={tasksEnabled ? () => setActiveTab('tasks') : null}
/>
</ErrorBoundary>
</div>
{activeTab === 'files' && (
<div className="h-full overflow-hidden">
<FileTree selectedProject={selectedProject} onFileOpen={handleFileOpen} />
</div>
)}
{activeTab === 'shell' && (
<div className="h-full w-full overflow-hidden">
<AnyStandaloneShell project={selectedProject} session={selectedSession} showHeader={false} />
</div>
)}
{activeTab === 'git' && (
<div className="h-full overflow-hidden">
<AnyGitPanel selectedProject={selectedProject} isMobile={isMobile} onFileOpen={handleFileOpen} />
</div>
)}
{shouldShowTasksTab && <TaskMasterPanel isVisible={activeTab === 'tasks'} />}
<div className={`h-full overflow-hidden ${activeTab === 'preview' ? 'block' : 'hidden'}`} />
</div>
<EditorSidebar
editingFile={editingFile}
isMobile={isMobile}
editorExpanded={editorExpanded}
editorWidth={editorWidth}
resizeHandleRef={resizeHandleRef}
onResizeStart={handleResizeStart}
onCloseEditor={handleCloseEditor}
onToggleEditorExpand={handleToggleEditorExpand}
projectPath={selectedProject.path}
fillSpace={activeTab === 'files'}
/>
</div>
</div>
);
}
export default React.memo(MainContent);

View File

@@ -0,0 +1,70 @@
import { useState } from 'react';
import CodeEditor from '../../../CodeEditor';
import type { EditorSidebarProps } from '../../types/types';
const AnyCodeEditor = CodeEditor as any;
export default function EditorSidebar({
editingFile,
isMobile,
editorExpanded,
editorWidth,
resizeHandleRef,
onResizeStart,
onCloseEditor,
onToggleEditorExpand,
projectPath,
fillSpace,
}: EditorSidebarProps) {
const [poppedOut, setPoppedOut] = useState(false);
if (!editingFile) {
return null;
}
if (isMobile || poppedOut) {
return (
<AnyCodeEditor
file={editingFile}
onClose={() => {
setPoppedOut(false);
onCloseEditor();
}}
projectPath={projectPath}
isSidebar={false}
/>
);
}
const useFlex = editorExpanded || fillSpace;
return (
<>
{!editorExpanded && (
<div
ref={resizeHandleRef}
onMouseDown={onResizeStart}
className="flex-shrink-0 w-1 bg-gray-200 dark:bg-gray-700 hover:bg-blue-500 dark:hover:bg-blue-600 cursor-col-resize transition-colors relative group"
title="Drag to resize"
>
<div className="absolute inset-y-0 left-1/2 -translate-x-1/2 w-1 bg-blue-500 dark:bg-blue-600 opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
)}
<div
className={`flex-shrink-0 border-l border-gray-200 dark:border-gray-700 h-full overflow-hidden ${useFlex ? 'flex-1' : ''}`}
style={useFlex ? undefined : { width: `${editorWidth}px` }}
>
<AnyCodeEditor
file={editingFile}
onClose={onCloseEditor}
projectPath={projectPath}
isSidebar
isExpanded={editorExpanded}
onToggleExpand={onToggleEditorExpand}
onPopOut={() => setPoppedOut(true)}
/>
</div>
</>
);
}

View File

@@ -0,0 +1,38 @@
import MobileMenuButton from './MobileMenuButton';
import MainContentTabSwitcher from './MainContentTabSwitcher';
import MainContentTitle from './MainContentTitle';
import type { MainContentHeaderProps } from '../../types/types';
export default function MainContentHeader({
activeTab,
setActiveTab,
selectedProject,
selectedSession,
shouldShowTasksTab,
isMobile,
onMenuClick,
}: MainContentHeaderProps) {
return (
<div className="bg-background border-b border-border/60 px-3 py-1.5 sm:px-4 sm:py-2 pwa-header-safe flex-shrink-0">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 min-w-0 flex-1">
{isMobile && <MobileMenuButton onMenuClick={onMenuClick} />}
<MainContentTitle
activeTab={activeTab}
selectedProject={selectedProject}
selectedSession={selectedSession}
shouldShowTasksTab={shouldShowTasksTab}
/>
</div>
<div className="flex-shrink-0 hidden sm:block">
<MainContentTabSwitcher
activeTab={activeTab}
setActiveTab={setActiveTab}
shouldShowTasksTab={shouldShowTasksTab}
/>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,54 @@
import { Folder } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import MobileMenuButton from './MobileMenuButton';
import type { MainContentStateViewProps } from '../../types/types';
export default function MainContentStateView({ mode, isMobile, onMenuClick }: MainContentStateViewProps) {
const { t } = useTranslation();
const isLoading = mode === 'loading';
return (
<div className="h-full flex flex-col">
{isMobile && (
<div className="bg-background/80 backdrop-blur-sm border-b border-border/50 p-2 sm:p-3 pwa-header-safe flex-shrink-0">
<MobileMenuButton onMenuClick={onMenuClick} compact />
</div>
)}
{isLoading ? (
<div className="flex-1 flex items-center justify-center">
<div className="text-center text-muted-foreground">
<div className="w-10 h-10 mx-auto mb-4">
<div
className="w-full h-full rounded-full border-[3px] border-muted border-t-primary"
style={{
animation: 'spin 1s linear infinite',
WebkitAnimation: 'spin 1s linear infinite',
MozAnimation: 'spin 1s linear infinite',
}}
/>
</div>
<h2 className="text-lg font-semibold text-foreground mb-1">{t('mainContent.loading')}</h2>
<p className="text-sm">{t('mainContent.settingUpWorkspace')}</p>
</div>
</div>
) : (
<div className="flex-1 flex items-center justify-center">
<div className="text-center max-w-md mx-auto px-6">
<div className="w-14 h-14 mx-auto mb-5 bg-muted/50 rounded-2xl flex items-center justify-center">
<Folder className="w-7 h-7 text-muted-foreground" />
</div>
<h2 className="text-xl font-semibold mb-2 text-foreground">{t('mainContent.chooseProject')}</h2>
<p className="text-sm text-muted-foreground mb-5 leading-relaxed">{t('mainContent.selectProjectDescription')}</p>
<div className="bg-primary/5 rounded-xl p-3.5 border border-primary/10">
<p className="text-sm text-primary">
<strong>{t('mainContent.tip')}:</strong> {isMobile ? t('mainContent.createProjectMobile') : t('mainContent.createProjectDesktop')}
</p>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,65 @@
import { MessageSquare, Terminal, Folder, GitBranch, ClipboardCheck, type LucideIcon } from 'lucide-react';
import Tooltip from '../../../Tooltip';
import type { AppTab } from '../../../../types/app';
import type { Dispatch, SetStateAction } from 'react';
import { useTranslation } from 'react-i18next';
type MainContentTabSwitcherProps = {
activeTab: AppTab;
setActiveTab: Dispatch<SetStateAction<AppTab>>;
shouldShowTasksTab: boolean;
};
type TabDefinition = {
id: AppTab;
labelKey: string;
icon: LucideIcon;
};
const BASE_TABS: TabDefinition[] = [
{ id: 'chat', labelKey: 'tabs.chat', icon: MessageSquare },
{ id: 'shell', labelKey: 'tabs.shell', icon: Terminal },
{ id: 'files', labelKey: 'tabs.files', icon: Folder },
{ id: 'git', labelKey: 'tabs.git', icon: GitBranch },
];
const TASKS_TAB: TabDefinition = {
id: 'tasks',
labelKey: 'tabs.tasks',
icon: ClipboardCheck,
};
export default function MainContentTabSwitcher({
activeTab,
setActiveTab,
shouldShowTasksTab,
}: MainContentTabSwitcherProps) {
const { t } = useTranslation();
const tabs = shouldShowTasksTab ? [...BASE_TABS, TASKS_TAB] : BASE_TABS;
return (
<div className="inline-flex items-center bg-muted/60 rounded-lg p-[3px] gap-[2px]">
{tabs.map((tab) => {
const Icon = tab.icon;
const isActive = tab.id === activeTab;
return (
<Tooltip key={tab.id} content={t(tab.labelKey)} position="bottom">
<button
onClick={() => setActiveTab(tab.id)}
className={`relative flex items-center gap-1.5 px-2.5 py-[5px] text-xs font-medium rounded-md transition-all duration-150 ${
isActive
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
}`}
>
<Icon className="w-3.5 h-3.5" strokeWidth={isActive ? 2.2 : 1.8} />
<span className="hidden lg:inline">{t(tab.labelKey)}</span>
</button>
</Tooltip>
);
})}
</div>
);
}

View File

@@ -0,0 +1,79 @@
import { useTranslation } from 'react-i18next';
import SessionProviderLogo from '../../../SessionProviderLogo';
import type { AppTab, Project, ProjectSession } from '../../../../types/app';
type MainContentTitleProps = {
activeTab: AppTab;
selectedProject: Project;
selectedSession: ProjectSession | null;
shouldShowTasksTab: boolean;
};
function getTabTitle(activeTab: AppTab, shouldShowTasksTab: boolean, t: (key: string) => string) {
if (activeTab === 'files') {
return t('mainContent.projectFiles');
}
if (activeTab === 'git') {
return t('tabs.git');
}
if (activeTab === 'tasks' && shouldShowTasksTab) {
return 'TaskMaster';
}
return 'Project';
}
function getSessionTitle(session: ProjectSession): string {
if (session.__provider === 'cursor') {
return (session.name as string) || 'Untitled Session';
}
return (session.summary as string) || 'New Session';
}
export default function MainContentTitle({
activeTab,
selectedProject,
selectedSession,
shouldShowTasksTab,
}: MainContentTitleProps) {
const { t } = useTranslation();
const showSessionIcon = activeTab === 'chat' && Boolean(selectedSession);
const showChatNewSession = activeTab === 'chat' && !selectedSession;
return (
<div className="min-w-0 flex items-center gap-2 flex-1 overflow-x-auto scrollbar-hide">
{showSessionIcon && (
<div className="w-5 h-5 flex-shrink-0 flex items-center justify-center">
<SessionProviderLogo provider={selectedSession?.__provider} className="w-4 h-4" />
</div>
)}
<div className="min-w-0 flex-1">
{activeTab === 'chat' && selectedSession ? (
<div className="min-w-0">
<h2 className="text-sm font-semibold text-foreground whitespace-nowrap overflow-x-auto scrollbar-hide leading-tight">
{getSessionTitle(selectedSession)}
</h2>
<div className="text-[11px] text-muted-foreground truncate leading-tight">{selectedProject.displayName}</div>
</div>
) : showChatNewSession ? (
<div className="min-w-0">
<h2 className="text-sm font-semibold text-foreground leading-tight">{t('mainContent.newSession')}</h2>
<div className="text-[11px] text-muted-foreground truncate leading-tight">{selectedProject.displayName}</div>
</div>
) : (
<div className="min-w-0">
<h2 className="text-sm font-semibold text-foreground leading-tight">
{getTabTitle(activeTab, shouldShowTasksTab, t)}
</h2>
<div className="text-[11px] text-muted-foreground truncate leading-tight">{selectedProject.displayName}</div>
</div>
)}
</div>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More