Compare commits

..

40 Commits

Author SHA1 Message Date
simosmik
6be7045f17 fix: notifications orchestrator and add a notification when first enabled 2026-03-03 16:28:12 +00:00
Simos Mikelatos
d032a37c01 Merge branch 'main' into feat/notifications 2026-03-03 15:51:08 +01:00
simosmik
964d8e3231 Release 1.22.0 2026-03-03 14:50:44 +00:00
simosmik
84d4634735 feat: add community button in the app 2026-03-03 14:44:08 +00:00
simosmik
14d17ae104 update readme with discord 2026-03-03 13:53:04 +00:00
simosmik
5d55e65727 default to false for webpush notifications and translations for the button 2026-03-03 13:30:19 +00:00
Simos Mikelatos
909ff05118 Merge branch 'main' into feat/notifications 2026-03-03 14:24:04 +01:00
simosmik
b5bbf11524 fix(sw): prevent caching of API requests and WebSocket upgrades 2026-03-03 13:23:24 +00:00
simosmik
855e22f917 fix: missing translation label 2026-03-03 13:18:08 +00:00
朱见
97689588aa feat: Advanced file editor and file tree improvements (#444)
# Features
- File drag and drop upload: Support uploading files and folders via drag and drop
- Binary file handling: Detect binary files and display a friendly message instead of trying to edit them
- Folder download: Download folders as ZIP files (using JSZip library)
- Context menu integration: Full right-click context menu for file operations (rename, delete, copy path, download, new file/folder)
2026-03-03 15:19:46 +03:00
viper151
bc52d9ea28 Merge branch 'main' into feat/notifications 2026-03-02 16:04:55 +01:00
Menny Even Danan
503c384685 chore: add Gemini-CLI support to README (#453) 2026-03-02 10:56:36 +03:00
louis-thorp-datacom
506d43144b fix(claude): move model usage log to result message only (#454)
The modelUsage debug log ran on every streamed SDK message, but
modelUsage is only populated on result messages. This produced
repeated "Model was sent using: []" console output for every
non-result message during streaming.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 10:49:06 +03:00
Xì Gà
9e22f42a3d feat: update document title based on selected project (#448)
Show dynamic browser tab title based on selected project's name, post-fixed with "CloudCLI UI" when a project is selected, improving navigation across tabs.
2026-02-27 18:51:26 +03:00
Xì Gà
9c0e864532 fix(claude): correct project encoded path (#451)
fix wrong regex replace of Claude project path

related #447, reopen due to forced-push

to reproduce error steps, let's try

Create a folder with @ in name like @test
Add this folder as new project in CloudCLI
Choose Claude tool in new Session
Star by typing sth 'hi'
In the dev tools, you will see errors ajax response said that session does not find for 'some-session-id'

The main problem is current encode path doesn't encode '@' to '-' as Claude did
I reversed code Claude-SDK, file 'cli.js' to find exactly regex (using in PR) that used to encode path under ~/.claude/projects/<encoded-project-name-path/<session-id>.jsonl
2026-02-27 18:46:23 +03:00
simosmik
d19b1e949f Release 1.21.0 2026-02-27 15:41:20 +00:00
Xì Gà
b359c51527 feat: add copy icon for user messages (#449)
* feat: add copy icon for user messages

Expose a copy control on user chat bubbles so previous content can be reused quickly.

* fix: Copy control is effectively hidden on touch devices

* fix: copyTextToClipboard doesn't need timer

---------

Co-authored-by: dev <dev@host.local>
Co-authored-by: Haileyesus <118998054+blackmammoth@users.noreply.github.com>
2026-02-27 18:28:10 +03:00
simosmik
8339b8e624 Merge branch 'main' into feat/notifications
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 14:47:27 +00:00
simosmik
061f0fd297 feat: introduce notification system and claude notifications 2026-02-27 14:44:44 +00:00
Menny Even Danan
a367edd515 feat: Google's gemini-cli integration (#422)
* feat: integrate Gemini AI agent provider

- Core Backend: Ported gemini-cli.js and gemini-response-handler.js to establish the CLI bridge. Registered 'gemini' as an active provider within index.js.
- Core Frontend: Extended QuickSettingsPanel.jsx, Settings.jsx, and AgentListItem.jsx to render the Gemini provider option, models (gemini-pro, gemini-flash, etc.), and handle OAuth states.
- WebSocket Pipeline: Added support for gemini-command executions in backend and payload processing of gemini-response and gemini-error streams in useChatRealtimeHandlers.ts. Resolved JSON double-stringification and sessionId stripping issues in the transmission handler.
- Platform Compatibility: Added scripts/fix-node-pty.js postinstall script and modified posix_spawnp calls with sh -c wrapper to prevent ENOEXEC and MacOS permission errors when spawning the gemini headless binary.
- UX & Design: Imported official Google Gemini branding via GeminiLogo.jsx and gemini-ai-icon.svg. Updated translations (chat.json) for en, zh-CN, and ko locales.

* fix: propagate gemini permission mode from settings to cli

- Added Gemini Permissions UI in Settings to toggle Auto Edit and YOLO modes
- Synced gemini permission mode to localStorage
- Passed permissionMode in useChatComposerState for Gemini commands
- Mapped frontend permission modes to --yolo and --approval-mode options in gemini-cli.js

* feat(gemini): Refactor Gemini CLI integration to use stream-json

- Replaced regex buffering text-system with NDJSON stream parsing
- Added fallback for restricted models like gemini-3.1-pro-preview

* feat(gemini): Render tool_use and tool_result UI bubbles

- Forwarded gemini tool NDJSON objects to the websocket
- Added React state handlers in useChatRealtimeHandlers to match Claude's tool UI behavior

* feat(gemini): Add native session resumption and UI token tracking

- Captured cliSessionId from init events to map ClaudeCodeUI's chat sessionId directly into Gemini's internal session manager.
- Updated gemini-cli.js spawn arguments to append the --resume proxy flag instead of naively dumping the accumulated chat history into the command prompt.
- Handled result stream objects by proxying total_tokens back into the frontend's claude-status tracker to natively populate the UI label.
- Eliminated gemini-3 model proxy filter entirely.

* fix(gemini): Fix static 'Claude' name rendering in chat UI header

- Added "gemini": "Gemini" translation strings to messageTypes across English, Korean, and Chinese loc dictionaries.
- Updated AssistantThinkingIndicator and MessageComponent ternary checks to identify provider === 'gemini' and render the appropriate brand label instead of statically defaulting to Claude.

* feat: Add Gemini session persistence API mapping and Sidebar UI

* fix(gemini): Watch ~/.gemini/sessions for live UI updates

Added the .gemini/sessions directory to PROVIDER_WATCH_PATHS so that Chokidar emits projects_updated websocket events when new Gemini sessions are created or modified, fixing live sidebar updates.

* fix(gemini): Fix Gemini authentication status display in Settings UI

- Injected 'checkGeminiAuthStatus' into the Settings.jsx React effect hook so that the UI can poll and render the 'geminiAuthStatus' state.
- Updated 'checkGeminiCredentials()' inside server/routes/cli-auth.js to read from '~/.gemini/oauth_creds.json' and '~/.gemini/google_accounts.json', resolving the email address correctly.

* Use logo-only icon for gemini

* feat(gemini): Add Gemini 3 preview models to UI selection list

* Fix Gemini CLI session resume bug and PR #422 review nitpicks

* Fix Gemini tool calls disappearing from UI after completion

* fix(gemini): resolve outstanding PR #422 feedback and stabilize gemini CLI timeouts

* fix(gemini): resolve resume flag and shell session initialization issues

This commit addresses the remaining PR comments for the Gemini CLI integration:

- Moves the `--resume` flag logic outside the prompt command block, ensuring Gemini sessions correctly resume even when a new prompt isn't passed.

- Updates `handleShellConnection` to correctly lookup the native `cliSessionId` from the internal `sessionId` when spawning Gemini sessions in a plain shell.

- Refactors dynamic import of `sessionManager.js` back to a native static import for code consistency.

* chore: fix TypeScript errors and remove gemini CLI dependency

* fix: use cross-spawn on Windows to resolve gemini.cmd correctly

---------

Co-authored-by: Haileyesus <118998054+blackmammoth@users.noreply.github.com>
2026-02-27 17:36:35 +03:00
Haileyesus
917c353115 chore: upgrade @anthropic-ai/claude-agent-sdk to version 0.2.59 and add model usage logging (#446) 2026-02-26 20:32:31 +01:00
Haileyesus
4ab94fce42 chore: upgrade better-sqlite to latest version to support node 25 (#445)
Co-authored-by: Haileyesus <something@gmail.com>
Co-authored-by: viper151 <simosmik@gmail.com>
2026-02-26 15:45:25 +03:00
PaloSP
e3b689214f feat: persist active tab across reloads via localStorage (#414)
* feat: persist active tab across reloads via localStorage (closes #387)

Remember the last active tab in localStorage instead of always resetting
to 'chat'. Also stop force-switching to chat tab on session change,
so users stay on their preferred tab (shell, git, etc.).

* fix: validate localStorage tab value and add try-catch for restricted contexts

Address CodeRabbit feedback: validate stored activeTab against known
tab IDs before using it, and wrap localStorage access in try-catch
to prevent crashes in restricted environments.
2026-02-26 14:46:01 +03:00
viper151
1f903baf2c Update README with Trendshift badge and language options
Added a Trendshift badge and language links to the README.
2026-02-25 17:12:31 +01:00
Haileyesus
5e3a7b69d7 Refactor Settings, FileTree, GitPanel, Shell, and CodeEditor components (#402) 2026-02-25 17:07:07 +01:00
Matthew Lloyd
23801e9cc1 fix: add support for Codex in the shell (#424)
* fix: add support for Codex in the shell

* fix: harden codex shell session resume command
2026-02-23 23:36:58 +01:00
simosmik
4f6ff9260d Release 1.20.1 2026-02-23 22:23:33 +00:00
viper151
49061bc7a3 Update DEFAULT model version to gpt-5.3-codex (#426) 2026-02-23 23:13:50 +01:00
simosmik
50e097d4ac feat: migrate legacy database to new location and improve last login update handling 2026-02-23 22:12:00 +00:00
simosmik
f986004319 feat: implement install mode detection and update commands in version upgrade process 2026-02-23 21:55:53 +00:00
simosmik
f488a346ef Release 1.19.1 2026-02-23 21:29:06 +00:00
simosmik
82efac4704 fix: add prepublishOnly script to build before publishing 2026-02-23 21:27:45 +00:00
viper151
81697d0e73 Update DEFAULT model version to gpt-5.3-codex (#417) 2026-02-23 16:51:29 +01:00
simosmik
27bf09b0c1 Release 1.19.0 2026-02-23 11:56:33 +00:00
Haileyesus
7ccbc8d92d chore: update @anthropic-ai/claude-agent-sdk to version 0.1.77 in package-lock.json (#410) 2026-02-23 07:09:07 +01:00
Vadim Trunov
597e9c54b7 fix: slash commands with arguments bypass command execution (#392)
* fix: intercept slash commands in handleSubmit to pass arguments correctly

When a user types a slash command with arguments (e.g. `/feature implement dark mode`)
and presses Enter, the command was not being intercepted as a slash command. Instead,
the raw text was sent as a regular message to the Claude API, which responded with
"Unknown skill: feature".

Root cause: the command autocomplete menu (useSlashCommands) detects commands via the
regex `/(^|\s)\/(\S*)$/` which only matches when the cursor is right after the command
name with no spaces. As soon as the user types a space to add arguments, the pattern
stops matching, the menu closes, and pressing Enter falls through to handleSubmit which
sends the text as a plain message — completely bypassing command execution.

This fix adds a slash command interceptor at the top of handleSubmit:
- Checks if the trimmed input starts with `/`
- Extracts the command name (text before the first space)
- Looks up the command in the loaded slashCommands list
- If found, delegates to executeCommand() which properly extracts arguments
  via regex and sends them to the backend /api/commands/execute endpoint
- The backend then replaces $ARGUMENTS, $1, $2 etc. in the command template

Changes:
- Added `slashCommands` to the destructured return of useSlashCommands hook
- Added slash command interception logic in handleSubmit before message dispatch
- Added `executeCommand` and `slashCommands` to handleSubmit dependency array

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

* fix: address review — pass rawInput param and cleanup UI state

- Pass trimmedInput to executeCommand to avoid stale closure reads
- Add UI cleanup (images, command menu, textarea) before early return
- Update executeCommand signature to accept optional rawInput parameter

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 16:25:02 +03:00
mjfork
cccd915c33 feat: add HOST environment variable for configurable bind address (#360)
* feat: add HOST environment variable for configurable bind address

Allow users to specify which host/IP address the servers should bind to
via the HOST environment variable. Defaults to 0.0.0.0 (all interfaces)
to maintain backward compatibility.

This enables users to restrict the server to localhost only (127.0.0.1)
for security purposes or bind to a specific network interface.

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

* fix: use correct proxy host when HOST is set to specific interface

When HOST is set to a specific interface (e.g., 192.168.1.100), the Vite
proxy needs to connect to that same host, not localhost. This fix:

- Adds proxyHost logic that uses localhost when HOST=0.0.0.0, otherwise
  uses the specific HOST value for proxy targets
- Adds DISPLAY_HOST to show a user-friendly URL in console output
  (0.0.0.0 isn't a connectable address)

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

---------

Co-authored-by: Michael Fork <mjfork@users.noreply.github.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Haileyesus <118998054+blackmammoth@users.noreply.github.com>
2026-02-20 21:23:10 +03:00
viper151
0207a1f3a3 Feat: subagent tool grouping (#398)
* fix(mobile): prevent bottom padding removal on input focus

* fix: change subagent rendering

* fix: subagent task name
2026-02-19 17:32:45 +01:00
Feraudet Cyril
38a593c97f fix(macos): fix node-pty posix_spawnp error with postinstall script (#347)
* fix(macos): fix node-pty posix_spawnp error with postinstall script

Add postinstall script that fixes spawn-helper permissions on macOS.
The node-pty package ships spawn-helper without execute permissions (644),
causing "posix_spawnp failed" errors when spawning terminal processes.

Closes #284
2026-02-18 12:21:00 +01:00
simosmik
fc369d047e refactor(releases): Create a contributing guide and proper release notes using a release-it plugin 2026-02-18 09:45:14 +00:00
232 changed files with 19065 additions and 9664 deletions

View File

@@ -21,6 +21,10 @@ PORT=3001
#Frontend port
VITE_PORT=5173
# Host/IP to bind servers to (default: 0.0.0.0 for all interfaces)
# Use 127.0.0.1 to restrict to localhost only
HOST=0.0.0.0
# Uncomment the following line if you have a custom claude cli path other than the default "claude"
# CLAUDE_CLI_PATH=claude

View File

@@ -2,20 +2,39 @@
"git": {
"commitMessage": "Release ${version}",
"tagName": "v${version}",
"changelog": "git log --pretty=format:\"* %s (%h)\" ${from}...${to}"
"requireBranch": "main",
"requireCleanWorkingDir": true
},
"npm": {
"publish": true
},
"github": {
"release": true,
"releaseName": "CloudCLI UI v${version}",
"releaseNotes": {
"commit": "* ${commit.subject} (${sha}){ - thanks @${author.login}!}",
"excludeMatches": ["viper151"]
}
"releaseName": "CloudCLI UI v${version}"
},
"hooks": {
"before:init": ["npm run build"]
},
"plugins": {
"@release-it/conventional-changelog": {
"infile": "CHANGELOG.md",
"header": "# Changelog\n\nAll notable changes to CloudCLI UI will be documented in this file.\n",
"preset": {
"name": "conventionalcommits",
"types": [
{ "type": "feat", "section": "New Features" },
{ "type": "feature", "section": "New Features" },
{ "type": "fix", "section": "Bug Fixes" },
{ "type": "perf", "section": "Performance" },
{ "type": "refactor", "section": "Refactoring" },
{ "type": "docs", "section": "Documentation" },
{ "type": "style", "section": "Styling" },
{ "type": "chore", "section": "Maintenance" },
{ "type": "ci", "section": "CI/CD" },
{ "type": "test", "section": "Tests" },
{ "type": "build", "section": "Build" }
]
}
}
}
}
}

72
CHANGELOG.md Normal file
View File

@@ -0,0 +1,72 @@
# Changelog
All notable changes to CloudCLI UI will be documented in this file.
## [1.22.0](https://github.com/siteboon/claudecodeui/compare/v1.21.0...v1.22.0) (2026-03-03)
### New Features
* add community button in the app ([84d4634](https://github.com/siteboon/claudecodeui/commit/84d4634735f9ee13ac1c20faa0e7e31f1b77cae8))
* Advanced file editor and file tree improvements ([#444](https://github.com/siteboon/claudecodeui/issues/444)) ([9768958](https://github.com/siteboon/claudecodeui/commit/97689588aa2e8240ba4373da5f42ab444c772e72))
* update document title based on selected project ([#448](https://github.com/siteboon/claudecodeui/issues/448)) ([9e22f42](https://github.com/siteboon/claudecodeui/commit/9e22f42a3d3a781f448ddac9d133292fe103bb8c))
### Bug Fixes
* **claude:** correct project encoded path ([#451](https://github.com/siteboon/claudecodeui/issues/451)) ([9c0e864](https://github.com/siteboon/claudecodeui/commit/9c0e864532dcc5ce7ee890d3b4db722872db2b54)), closes [#447](https://github.com/siteboon/claudecodeui/issues/447)
* **claude:** move model usage log to result message only ([#454](https://github.com/siteboon/claudecodeui/issues/454)) ([506d431](https://github.com/siteboon/claudecodeui/commit/506d43144b3ec3155c3e589e7e803862c4a8f83a))
* missing translation label ([855e22f](https://github.com/siteboon/claudecodeui/commit/855e22f9176a71daa51de716370af7f19d55bfb4))
### Maintenance
* add Gemini-CLI support to README ([#453](https://github.com/siteboon/claudecodeui/issues/453)) ([503c384](https://github.com/siteboon/claudecodeui/commit/503c3846850fb843781979b0c0e10a24b07e1a4b))
## [1.21.0](https://github.com/siteboon/claudecodeui/compare/v1.20.1...v1.21.0) (2026-02-27)
### New Features
* add copy icon for user messages ([#449](https://github.com/siteboon/claudecodeui/issues/449)) ([b359c51](https://github.com/siteboon/claudecodeui/commit/b359c515277b4266fde2fb9a29b5356949c07c4f))
* Google's gemini-cli integration ([#422](https://github.com/siteboon/claudecodeui/issues/422)) ([a367edd](https://github.com/siteboon/claudecodeui/commit/a367edd51578608b3281373cb4a95169dbf17f89))
* persist active tab across reloads via localStorage ([#414](https://github.com/siteboon/claudecodeui/issues/414)) ([e3b6892](https://github.com/siteboon/claudecodeui/commit/e3b689214f11d549ffe1b3a347476d58f25c5aca)), closes [#387](https://github.com/siteboon/claudecodeui/issues/387)
### Bug Fixes
* add support for Codex in the shell ([#424](https://github.com/siteboon/claudecodeui/issues/424)) ([23801e9](https://github.com/siteboon/claudecodeui/commit/23801e9cc15d2b8d1bfc6e39aee2fae93226d1ad))
### Maintenance
* upgrade @anthropic-ai/claude-agent-sdk to version 0.2.59 and add model usage logging ([#446](https://github.com/siteboon/claudecodeui/issues/446)) ([917c353](https://github.com/siteboon/claudecodeui/commit/917c353115653ee288bf97be01f62fad24123cbc))
* upgrade better-sqlite to latest version to support node 25 ([#445](https://github.com/siteboon/claudecodeui/issues/445)) ([4ab94fc](https://github.com/siteboon/claudecodeui/commit/4ab94fce4257e1e20370fa83fa4c0f6fadbb8a2b))
## [1.20.1](https://github.com/siteboon/claudecodeui/compare/v1.19.1...v1.20.1) (2026-02-23)
### New Features
* implement install mode detection and update commands in version upgrade process ([f986004](https://github.com/siteboon/claudecodeui/commit/f986004319207b068431f9f6adf338a8ce8decfc))
* migrate legacy database to new location and improve last login update handling ([50e097d](https://github.com/siteboon/claudecodeui/commit/50e097d4ac498aa9f1803ef3564843721833dc19))
## [1.19.1](https://github.com/siteboon/claudecodeui/compare/v1.19.0...v1.19.1) (2026-02-23)
### Bug Fixes
* add prepublishOnly script to build before publishing ([82efac4](https://github.com/siteboon/claudecodeui/commit/82efac4704cab11ed8d1a05fe84f41312140b223))
## [1.19.0](https://github.com/siteboon/claudecodeui/compare/v1.18.2...v1.19.0) (2026-02-23)
### New Features
* add HOST environment variable for configurable bind address ([#360](https://github.com/siteboon/claudecodeui/issues/360)) ([cccd915](https://github.com/siteboon/claudecodeui/commit/cccd915c336192216b6e6f68e2b5f3ece0ccf966))
* subagent tool grouping ([#398](https://github.com/siteboon/claudecodeui/issues/398)) ([0207a1f](https://github.com/siteboon/claudecodeui/commit/0207a1f3a3c87f1c6c1aee8213be999b23289386))
### Bug Fixes
* **macos:** fix node-pty posix_spawnp error with postinstall script ([#347](https://github.com/siteboon/claudecodeui/issues/347)) ([38a593c](https://github.com/siteboon/claudecodeui/commit/38a593c97fdb2bb7f051e09e8e99c16035448655)), closes [#284](https://github.com/siteboon/claudecodeui/issues/284)
* slash commands with arguments bypass command execution ([#392](https://github.com/siteboon/claudecodeui/issues/392)) ([597e9c5](https://github.com/siteboon/claudecodeui/commit/597e9c54b76e7c6cd1947299c668c78d24019cab))
### Refactoring
* **releases:** Create a contributing guide and proper release notes using a release-it plugin ([fc369d0](https://github.com/siteboon/claudecodeui/commit/fc369d047e13cba9443fe36c0b6bb2ce3beaf61c))
### Maintenance
* update @anthropic-ai/claude-agent-sdk to version 0.1.77 in package-lock.json ([#410](https://github.com/siteboon/claudecodeui/issues/410)) ([7ccbc8d](https://github.com/siteboon/claudecodeui/commit/7ccbc8d92d440e18c157b656c9ea2635044a64f6))

156
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,156 @@
# Contributing to CloudCLI UI
Thanks for your interest in contributing to CloudCLI UI! Before you start, please take a moment to read through this guide.
## Before You Start
- **Search first.** Check [existing issues](https://github.com/siteboon/claudecodeui/issues) and [pull requests](https://github.com/siteboon/claudecodeui/pulls) to avoid duplicating work.
- **Discuss first** for new features. Open an [issue](https://github.com/siteboon/claudecodeui/issues/new) to discuss your idea before investing time in implementation. We may already have plans or opinions on how it should work.
- **Bug fixes are always welcome.** If you spot a bug, feel free to open a PR directly.
## Prerequisites
- [Node.js](https://nodejs.org/) 22 or later
- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) installed and configured
## Getting Started
1. Fork the repository
2. Clone your fork:
```bash
git clone https://github.com/<your-username>/claudecodeui.git
cd claudecodeui
```
3. Install dependencies:
```bash
npm install
```
4. Start the development server:
```bash
npm run dev
```
5. Create a branch for your changes:
```bash
git checkout -b feat/your-feature-name
```
## Project Structure
```
claudecodeui/
├── src/ # React frontend (Vite + Tailwind)
│ ├── components/ # UI components
│ ├── contexts/ # React context providers
│ ├── hooks/ # Custom React hooks
│ ├── i18n/ # Internationalization and translations
│ ├── lib/ # Shared frontend libraries
│ ├── types/ # TypeScript type definitions
│ └── utils/ # Frontend utilities
├── server/ # Express backend
│ ├── routes/ # API route handlers
│ ├── middleware/ # Express middleware
│ ├── database/ # SQLite database layer
│ └── tools/ # CLI tool integrations
├── shared/ # Code shared between client and server
└── public/ # Static assets, icons, PWA manifest
```
## Development Workflow
- `npm run dev` — Start both the frontend and backend in development mode
- `npm run build` — Create a production build
- `npm run server` — Start only the backend server
- `npm run client` — Start only the Vite dev server
## Making Changes
### Bug Fixes
- Reference the issue number in your PR if one exists
- Describe how to reproduce the bug in your PR description
- Add a screenshot or recording for visual bugs
### New Features
- Keep the scope focused — one feature per PR
- Include screenshots or recordings for UI changes
### Documentation
- Documentation improvements are always welcome
- Keep language clear and concise
## Commit Convention
We follow [Conventional Commits](https://conventionalcommits.org/) to generate release notes automatically. Every commit message should follow this format:
```
<type>(optional scope): <description>
```
Use imperative, present tense: "add feature" not "added feature" or "adds feature".
### Types
| Type | Description |
|------|-------------|
| `feat` | A new feature |
| `fix` | A bug fix |
| `perf` | A performance improvement |
| `refactor` | Code change that neither fixes a bug nor adds a feature |
| `docs` | Documentation only |
| `style` | CSS, formatting, visual changes |
| `chore` | Maintenance, dependencies, config |
| `ci` | CI/CD pipeline changes |
| `test` | Adding or updating tests |
| `build` | Build system changes |
### Examples
```bash
feat: add conversation search
feat(i18n): add Japanese language support
fix: redirect unauthenticated users to login
fix(editor): syntax highlighting for .env files
perf: lazy load code editor component
refactor(chat): extract message list component
docs: update API configuration guide
```
### Breaking Changes
Add `!` after the type or include `BREAKING CHANGE:` in the commit footer:
```bash
feat!: redesign settings page layout
```
## Pull Requests
- Give your PR a clear, descriptive title following the commit convention above
- Fill in the PR description with what changed and why
- Link any related issues
- Include screenshots for UI changes
- Make sure the build passes (`npm run build`)
- Keep PRs focused — avoid unrelated changes
## Releases
Releases are managed by maintainers using [release-it](https://github.com/release-it/release-it) with the [conventional changelog plugin](https://github.com/release-it/conventional-changelog).
```bash
npm run release # interactive (prompts for version bump)
npm run release -- patch # patch release
npm run release -- minor # minor release
```
This automatically:
- Bumps the version based on commit types (`feat` = minor, `fix` = patch)
- Generates categorized release notes
- Updates `CHANGELOG.md`
- Creates a git tag and GitHub Release
- Publishes to npm
## License
By contributing, you agree that your contributions will be licensed under the [GPL-3.0 License](LICENSE).

View File

@@ -289,31 +289,7 @@ Claude Code、Cursor、Codex のセッションが利用可能な場合、自動
### コントリビューション
コントリビューションを歓迎します!以下のガイドラインに従ってください
#### はじめに
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 の改善** - より良いユーザー体験
- **パフォーマンス最適化** - より高速に
コントリビューションを歓迎します!コミット規約、開発ワークフロー、リリースプロセスの詳細は [Contributing Guide](CONTRIBUTING.md) をご覧ください
## トラブルシューティング

View File

@@ -288,31 +288,7 @@ Claude Code, Cursor 또는 Codex 세션을 사용할 수 있을 때 자동으로
### 기여하기
기여를 환영합니다! 다음 가이드라인을 따라주세요:
#### 시작하기
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 개선** - 더 나은 사용자 경험
- **성능 최적화** - 더 빠르게 만들기
기여를 환영합니다! 커밋 규칙, 개발 워크플로우, 릴리스 프로세스에 대한 자세한 내용은 [Contributing Guide](CONTRIBUTING.md)를 참조해주세요.
## 문제 해결

View File

@@ -1,10 +1,19 @@
<div align="center">
<img src="public/logo.svg" alt="Claude Code UI" width="64" height="64">
<img src="public/logo.svg" alt="CloudCLI UI" width="64" height="64">
<h1>Cloud CLI (aka Claude Code UI)</h1>
</div>
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.
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), [Codex](https://developers.openai.com/codex), and [Gemini-CLI](https://geminicli.com/). You can use it locally or remotely to view your active projects and sessions and make changes to them from everywhere (mobile or desktop). This gives you a proper interface that works everywhere.
<p align="center">
<a href="https://cloudcli.ai">CloudCLI Cloud</a> · <a href="https://discord.gg/buxwujPNRE">Discord</a> · <a href="https://github.com/siteboon/claudecodeui/issues">Bug Reports</a> · <a href="CONTRIBUTING.md">Contributing</a>
</p>
<p align="center">
<a href="https://discord.gg/buxwujPNRE"><img src="https://img.shields.io/badge/Discord-Join%20Community-5865F2?logo=discord&logoColor=white" alt="Join our Discord"></a>
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</p>
<div align="right"><i><b>English</b> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">中文</a> · <a href="./README.ja.md">日本語</a></i></div>
@@ -43,26 +52,35 @@ A desktop and mobile UI for [Claude Code](https://docs.anthropic.com/en/docs/cla
## Features
- **Responsive Design** - Works seamlessly across desktop, tablet, and mobile so you can also use Claude Code, Cursor, or Codex from mobile
- **Interactive Chat Interface** - Built-in chat interface for seamless communication with Claude Code, Cursor, or Codex
- **Integrated Shell Terminal** - Direct access to Claude Code, Cursor CLI, or Codex through built-in shell functionality
- **Responsive Design** - Works seamlessly across desktop, tablet, and mobile so you can also use Agents from mobile
- **Interactive Chat Interface** - Built-in chat interface for seamless communication with the Agents
- **Integrated Shell Terminal** - Direct access to the Agents CLI through built-in shell functionality
- **File Explorer** - Interactive file tree with syntax highlighting and live editing
- **Git Explorer** - View, stage and commit your changes. You can also switch branches
- **Session Management** - Resume conversations, manage multiple sessions, and track history
- **TaskMaster AI Integration** *(Optional)* - Advanced project management with AI-powered task planning, PRD parsing, and workflow automation
- **Model Compatibility** - Works with Claude Sonnet 4.5, Opus 4.5, and GPT-5.2
- **Model Compatibility** - Works with Claude Sonnet 4.5, Opus 4.5, GPT-5.2, and Gemini.
## Quick Start
### Prerequisites
### CloudCLI Cloud (Recommended)
The fastest way to get started — no local setup required. Get a fully managed, containerized development environment accessible from the web, mobile app, API, or your favorite IDE.
**[Get started with CloudCLI Cloud](https://cloudcli.ai)**
### Self-Hosted (Open Source)
#### Prerequisites
- [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
- [Codex](https://developers.openai.com/codex) installed and configured, and/or
- [Gemini-CLI](https://geminicli.com/) installed and configured
### One-click Operation (Recommended)
#### One-click Operation
No installation required, direct operation:
@@ -119,7 +137,7 @@ cloudcli status # Show current configuration
### Run as Background Service (Recommended for Production)
For production use, run Claude Code UI as a background service using PM2 (Process Manager 2):
For production use, run CloudCLI as a background service using PM2 (Process Manager 2):
#### Install PM2
@@ -143,7 +161,7 @@ pm2 start cloudcli --name "claude-code-ui" -- --port 8080
#### Auto-Start on System Boot
To make Claude Code UI start automatically when your system boots:
To make CloudCLI UI start automatically when your system boots:
```bash
# Generate startup script for your platform
@@ -207,7 +225,7 @@ To use Claude Code's full functionality, you'll need to manually enable tools:
## TaskMaster AI Integration *(Optional)*
Claude Code UI supports **[TaskMaster AI](https://github.com/eyaltoledano/claude-task-master)** (aka claude-task-master) integration for advanced project management and AI-powered task planning.
CloudCLI UI supports **[TaskMaster AI](https://github.com/eyaltoledano/claude-task-master)** (aka claude-task-master) integration for advanced project management and AI-powered task planning.
It provides
- AI-powered task generation from PRDs (Product Requirements Documents)
@@ -278,7 +296,7 @@ session counts
### Backend (Node.js + Express)
- **Express Server** - RESTful API with static file serving
- **WebSocket Server** - Communication for chats and project refresh
- **Agent Integration (Claude Code / Cursor CLI / Codex)** - Process spawning and management
- **Agent Integration (Claude Code / Cursor CLI / Codex / Gemini CLI)** - Process spawning and management
- **File System API** - Exposing file browser for projects
### Frontend (React + Vite)
@@ -291,31 +309,7 @@ session counts
### Contributing
We welcome contributions! Please follow these guidelines:
#### Getting Started
1. **Fork** the repository
2. **Clone** your fork: `git clone <your-fork-url>`
3. **Install** dependencies: `npm install`
4. **Create** a feature branch: `git checkout -b feature/amazing-feature`
#### Development Process
1. **Make your changes** following the existing code style
2. **Test thoroughly** - ensure all features work correctly
3. **Run quality checks**: `npm run lint && npm run format`
4. **Commit** with descriptive messages following [Conventional Commits](https://conventionalcommits.org/)
5. **Push** to your branch: `git push origin feature/amazing-feature`
6. **Submit** a Pull Request with:
- Clear description of changes
- Screenshots for UI changes
- Test results if applicable
#### What to Contribute
- **Bug fixes** - Help us improve stability
- **New features** - Enhance functionality (discuss in issues first)
- **Documentation** - Improve guides and API docs
- **UI/UX improvements** - Better user experience
- **Performance optimizations** - Make it faster
We welcome contributions! Please see our [Contributing Guide](CONTRIBUTING.md) for details on commit conventions, development workflow, and release process.
## Troubleshooting
@@ -350,6 +344,7 @@ This project is open source and free to use, modify, and distribute under the GP
- **[Claude Code](https://docs.anthropic.com/en/docs/claude-code)** - Anthropic's official CLI
- **[Cursor CLI](https://docs.cursor.com/en/cli/overview)** - Cursor's official CLI
- **[Codex](https://developers.openai.com/codex)** - OpenAI Codex
- **[Gemini-CLI](https://geminicli.com/)** - Google Gemini CLI
- **[React](https://react.dev/)** - User interface library
- **[Vite](https://vitejs.dev/)** - Fast build tool and dev server
- **[Tailwind CSS](https://tailwindcss.com/)** - Utility-first CSS framework
@@ -359,6 +354,8 @@ This project is open source and free to use, modify, and distribute under the GP
## Support & Community
### Stay Updated
- **[Join our Discord](https://discord.gg/buxwujPNRE)** - Get help, share feedback, and connect with the community
- **[CloudCLI Cloud](https://cloudcli.ai)** - Try the hosted cloud version
- **Star** this repository to show support
- **Watch** for updates and new releases
- **Follow** the project for announcements

View File

@@ -290,31 +290,7 @@ Claude Code UI 支持 **[TaskMaster AI](https://github.com/eyaltoledano/claude-t
### 贡献
我们欢迎贡献!请遵循以下指南:
#### 入门
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 改进** - 更好的用户体验
- **性能优化** - 让它更快
我们欢迎贡献!有关提交规范、开发流程和发布流程的详细信息,请参阅 [Contributing Guide](CONTRIBUTING.md)。
## 故障排除

844
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "@siteboon/claude-code-ui",
"version": "1.18.2",
"version": "1.22.0",
"description": "A web-based UI for Claude Code CLI",
"type": "module",
"main": "server/index.js",
@@ -12,6 +12,7 @@
"server/",
"shared/",
"dist/",
"scripts/",
"README.md"
],
"homepage": "https://cloudcli.ai",
@@ -30,7 +31,9 @@
"preview": "vite preview",
"typecheck": "tsc --noEmit -p tsconfig.json",
"start": "npm run build && npm run server",
"release": "./release.sh"
"release": "./release.sh",
"prepublishOnly": "npm run build",
"postinstall": "node scripts/fix-node-pty.js"
},
"keywords": [
"claude code",
@@ -42,7 +45,7 @@
"author": "CloudCLI UI Contributors",
"license": "GPL-3.0",
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.1.71",
"@anthropic-ai/claude-agent-sdk": "^0.2.59",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-html": "^6.4.9",
"@codemirror/lang-javascript": "^6.2.4",
@@ -63,7 +66,7 @@
"@xterm/addon-webgl": "^0.18.0",
"@xterm/xterm": "^5.5.0",
"bcrypt": "^6.0.0",
"better-sqlite3": "^12.2.0",
"better-sqlite3": "^12.6.2",
"chokidar": "^4.0.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -75,6 +78,7 @@
"i18next": "^25.7.4",
"i18next-browser-languagedetector": "^8.2.0",
"jsonwebtoken": "^9.0.2",
"jszip": "^3.10.1",
"katex": "^0.16.25",
"lucide-react": "^0.515.0",
"mime-types": "^3.0.1",
@@ -84,6 +88,7 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-dropzone": "^14.2.3",
"react-error-boundary": "^4.1.2",
"react-i18next": "^16.5.3",
"react-markdown": "^10.1.0",
"react-router-dom": "^6.8.1",
@@ -95,9 +100,11 @@
"sqlite": "^5.1.1",
"sqlite3": "^5.1.7",
"tailwind-merge": "^3.3.1",
"web-push": "^3.6.7",
"ws": "^8.14.2"
},
"devDependencies": {
"@release-it/conventional-changelog": "^10.0.5",
"@types/node": "^22.19.7",
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",

View File

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Gemini</title><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="#3186FF"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-fill-0)"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-fill-1)"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-fill-2)"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-fill-0" x1="7" x2="11" y1="15.5" y2="12"><stop stop-color="#08B962"></stop><stop offset="1" stop-color="#08B962" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-fill-1" x1="8" x2="11.5" y1="5.5" y2="11"><stop stop-color="#F94543"></stop><stop offset="1" stop-color="#F94543" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-fill-2" x1="3.5" x2="17.5" y1="13.5" y2="12"><stop stop-color="#FABC12"></stop><stop offset=".46" stop-color="#FABC12" stop-opacity="0"></stop></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@@ -19,14 +19,17 @@ self.addEventListener('install', event => {
// Fetch event
self.addEventListener('fetch', event => {
// Never cache API requests or WebSocket upgrades
if (event.request.url.includes('/api/') || event.request.url.includes('/ws')) {
return;
}
event.respondWith(
caches.match(event.request)
.then(response => {
// Return cached response if found
if (response) {
return response;
}
// Otherwise fetch from network
return fetch(event.request);
}
)
@@ -46,4 +49,53 @@ self.addEventListener('activate', event => {
);
})
);
});
self.clients.claim();
});
// Push notification event
self.addEventListener('push', event => {
if (!event.data) return;
let payload;
try {
payload = event.data.json();
} catch {
payload = { title: 'Claude Code UI', body: event.data.text() };
}
const options = {
body: payload.body || '',
icon: '/logo-256.png',
badge: '/logo-128.png',
data: payload.data || {},
tag: payload.data?.code || 'default',
renotify: true
};
event.waitUntil(
self.registration.showNotification(payload.title || 'Claude Code UI', options)
);
});
// Notification click event
self.addEventListener('notificationclick', event => {
event.notification.close();
const sessionId = event.notification.data?.sessionId;
const urlPath = sessionId ? `/session/${sessionId}` : '/';
event.waitUntil(
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then(clientList => {
for (const client of clientList) {
if (client.url.includes(self.location.origin)) {
client.focus();
if (sessionId) {
client.navigate(self.location.origin + urlPath);
}
return;
}
}
return self.clients.openWindow(urlPath);
})
);
});

67
scripts/fix-node-pty.js Normal file
View File

@@ -0,0 +1,67 @@
#!/usr/bin/env node
/**
* Fix node-pty spawn-helper permissions on macOS
*
* This script fixes a known issue with node-pty where the spawn-helper
* binary is shipped without execute permissions, causing "posix_spawnp failed" errors.
*
* @see https://github.com/microsoft/node-pty/issues/850
* @module scripts/fix-node-pty
*/
import { promises as fs } from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
/**
* Fixes the spawn-helper binary permissions for node-pty on macOS.
*
* The node-pty package ships the spawn-helper binary without execute permissions
* (644 instead of 755), which causes "posix_spawnp failed" errors when trying
* to spawn terminal processes.
*
* This function:
* 1. Checks if running on macOS (darwin)
* 2. Locates spawn-helper binaries for both arm64 and x64 architectures
* 3. Sets execute permissions (755) on each binary found
*
* @async
* @function fixSpawnHelper
* @returns {Promise<void>} Resolves when permissions are fixed or skipped
* @example
* // Run as postinstall script
* await fixSpawnHelper();
*/
async function fixSpawnHelper() {
const nodeModulesPath = path.join(__dirname, '..', 'node_modules', 'node-pty', 'prebuilds');
// Only run on macOS
if (process.platform !== 'darwin') {
return;
}
const darwinDirs = ['darwin-arm64', 'darwin-x64'];
for (const dir of darwinDirs) {
const spawnHelperPath = path.join(nodeModulesPath, dir, 'spawn-helper');
try {
// Check if file exists
await fs.access(spawnHelperPath);
// Make it executable (755)
await fs.chmod(spawnHelperPath, 0o755);
console.log(`[postinstall] Fixed permissions for ${spawnHelperPath}`);
} catch (err) {
// File doesn't exist or other error - ignore
if (err.code !== 'ENOENT') {
console.warn(`[postinstall] Warning: Could not fix ${spawnHelperPath}: ${err.message}`);
}
}
}
}
fixSpawnHelper().catch(console.error);

View File

@@ -18,6 +18,7 @@ import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
import { CLAUDE_MODELS } from '../shared/modelConstants.js';
import { createNotificationEvent, notifyUserIfEnabled } from './services/notification-orchestrator.js';
const activeSessions = new Map();
const pendingToolApprovals = new Map();
@@ -250,7 +251,13 @@ function getAllSessions() {
* @returns {Object} Transformed message ready for WebSocket
*/
function transformMessage(sdkMessage) {
// Pass-through; SDK messages match frontend format.
// Extract parent_tool_use_id for subagent tool grouping
if (sdkMessage.parent_tool_use_id) {
return {
...sdkMessage,
parentToolUseId: sdkMessage.parent_tool_use_id
};
}
return sdkMessage;
}
@@ -455,6 +462,14 @@ async function queryClaudeSDK(command, options = {}, ws) {
let tempImagePaths = [];
let tempDir = null;
const emitNotification = (event) => {
notifyUserIfEnabled({
userId: ws?.userId || null,
writer: ws,
event
});
};
try {
// Map CLI options to SDK format
const sdkOptions = mapCliOptionsToSDK(options);
@@ -471,6 +486,42 @@ async function queryClaudeSDK(command, options = {}, ws) {
tempImagePaths = imageResult.tempImagePaths;
tempDir = imageResult.tempDir;
sdkOptions.hooks = {
Notification: [{
matcher: '',
hooks: [async (input) => {
const message = typeof input?.message === 'string' ? input.message : 'Claude requires your attention.';
emitNotification(createNotificationEvent({
provider: 'claude',
sessionId: capturedSessionId || sessionId || null,
kind: 'action_required',
code: 'agent.notification',
meta: { message },
severity: 'warning',
requiresUserAction: true,
dedupeKey: `claude:hook:notification:${capturedSessionId || sessionId || 'none'}:${message}`
}));
return {};
}]
}],
Stop: [{
matcher: '',
hooks: [async (input) => {
const stopReason = typeof input?.stop_reason === 'string' ? input.stop_reason : 'completed';
emitNotification(createNotificationEvent({
provider: 'claude',
sessionId: capturedSessionId || sessionId || null,
kind: 'stop',
code: 'run.stopped',
meta: { stopReason },
severity: 'info',
dedupeKey: `claude:hook:stop:${capturedSessionId || sessionId || 'none'}:${stopReason}`
}));
return {};
}]
}]
};
sdkOptions.canUseTool = async (toolName, input, context) => {
const requiresInteraction = TOOLS_REQUIRING_INTERACTION.has(toolName);
@@ -502,6 +553,16 @@ async function queryClaudeSDK(command, options = {}, ws) {
input,
sessionId: capturedSessionId || sessionId || null
});
emitNotification(createNotificationEvent({
provider: 'claude',
sessionId: capturedSessionId || sessionId || null,
kind: 'action_required',
code: 'permission.required',
meta: { toolName },
severity: 'warning',
requiresUserAction: true,
dedupeKey: `claude:permission:${capturedSessionId || sessionId || 'none'}:${requestId}`
}));
const decision = await waitForToolApproval(requestId, {
timeoutMs: requiresInteraction ? 0 : undefined,
@@ -542,10 +603,22 @@ async function queryClaudeSDK(command, options = {}, ws) {
const prevStreamTimeout = process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT;
process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT = '300000';
const queryInstance = query({
prompt: finalCommand,
options: sdkOptions
});
let queryInstance;
try {
queryInstance = query({
prompt: finalCommand,
options: sdkOptions
});
} catch (hookError) {
// Older/newer SDK versions may not accept hook shapes yet.
// Keep notification behavior operational via runtime events even if hook registration fails.
console.warn('Failed to initialize Claude query with hooks, retrying without hooks:', hookError?.message || hookError);
delete sdkOptions.hooks;
queryInstance = query({
prompt: finalCommand,
options: sdkOptions
});
}
// Restore immediately — Query constructor already captured the value
if (prevStreamTimeout !== undefined) {
@@ -597,6 +670,10 @@ async function queryClaudeSDK(command, options = {}, ws) {
// Extract and send token budget updates from result messages
if (message.type === 'result') {
const models = Object.keys(message.modelUsage || {});
if (models.length > 0) {
console.log("---> Model was sent using:", models);
}
const tokenBudget = extractTokenBudget(message);
if (tokenBudget) {
console.log('Token budget from modelUsage:', tokenBudget);
@@ -644,6 +721,15 @@ async function queryClaudeSDK(command, options = {}, ws) {
error: error.message,
sessionId: capturedSessionId || sessionId || null
});
emitNotification(createNotificationEvent({
provider: 'claude',
sessionId: capturedSessionId || sessionId || null,
kind: 'error',
code: 'run.failed',
meta: { error: error.message },
severity: 'error',
dedupeKey: `claude:error:${capturedSessionId || sessionId || 'none'}:${error.message}`
}));
throw error;
}

View File

@@ -40,6 +40,22 @@ if (process.env.DATABASE_PATH) {
}
}
// As part of 1.19.2 we are introducing a new location for auth.db. The below handles exisitng moving legacy database from install directory to new location
const LEGACY_DB_PATH = path.join(__dirname, 'auth.db');
if (DB_PATH !== LEGACY_DB_PATH && !fs.existsSync(DB_PATH) && fs.existsSync(LEGACY_DB_PATH)) {
try {
fs.copyFileSync(LEGACY_DB_PATH, DB_PATH);
console.log(`[MIGRATION] Copied database from ${LEGACY_DB_PATH} to ${DB_PATH}`);
for (const suffix of ['-wal', '-shm']) {
if (fs.existsSync(LEGACY_DB_PATH + suffix)) {
fs.copyFileSync(LEGACY_DB_PATH + suffix, DB_PATH + suffix);
}
}
} catch (err) {
console.warn(`[MIGRATION] Could not copy legacy database: ${err.message}`);
}
}
// Create database connection
const db = new Database(DB_PATH);
@@ -75,6 +91,36 @@ const runMigrations = () => {
db.exec('ALTER TABLE users ADD COLUMN has_completed_onboarding BOOLEAN DEFAULT 0');
}
db.exec(`
CREATE TABLE IF NOT EXISTS user_notification_preferences (
user_id INTEGER PRIMARY KEY,
preferences_json TEXT NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`);
db.exec(`
CREATE TABLE IF NOT EXISTS vapid_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_key TEXT NOT NULL,
private_key TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
db.exec(`
CREATE TABLE IF NOT EXISTS push_subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
endpoint TEXT NOT NULL UNIQUE,
keys_p256dh TEXT NOT NULL,
keys_auth TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`);
console.log('Database migrations completed successfully');
} catch (error) {
console.error('Error running migrations:', error.message);
@@ -128,12 +174,12 @@ const userDb = {
}
},
// Update last login time
// Update last login time (non-fatal — logged but not thrown)
updateLastLogin: (userId) => {
try {
db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?').run(userId);
} catch (err) {
throw err;
console.warn('Failed to update last login:', err.message);
}
},
@@ -332,6 +378,116 @@ const credentialsDb = {
}
};
const DEFAULT_NOTIFICATION_PREFERENCES = {
channels: {
inApp: false,
webPush: false
},
events: {
actionRequired: true,
stop: true,
error: true
}
};
const normalizeNotificationPreferences = (value) => {
const source = value && typeof value === 'object' ? value : {};
return {
channels: {
inApp: source.channels?.inApp === true,
webPush: source.channels?.webPush === true
},
events: {
actionRequired: source.events?.actionRequired !== false,
stop: source.events?.stop !== false,
error: source.events?.error !== false
}
};
};
const notificationPreferencesDb = {
getPreferences: (userId) => {
try {
const row = db.prepare('SELECT preferences_json FROM user_notification_preferences WHERE user_id = ?').get(userId);
if (!row) {
const defaults = normalizeNotificationPreferences(DEFAULT_NOTIFICATION_PREFERENCES);
db.prepare(
'INSERT INTO user_notification_preferences (user_id, preferences_json, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)'
).run(userId, JSON.stringify(defaults));
return defaults;
}
let parsed;
try {
parsed = JSON.parse(row.preferences_json);
} catch {
parsed = DEFAULT_NOTIFICATION_PREFERENCES;
}
return normalizeNotificationPreferences(parsed);
} catch (err) {
throw err;
}
},
updatePreferences: (userId, preferences) => {
try {
const normalized = normalizeNotificationPreferences(preferences);
db.prepare(
`INSERT INTO user_notification_preferences (user_id, preferences_json, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(user_id) DO UPDATE SET
preferences_json = excluded.preferences_json,
updated_at = CURRENT_TIMESTAMP`
).run(userId, JSON.stringify(normalized));
return normalized;
} catch (err) {
throw err;
}
}
};
const pushSubscriptionsDb = {
saveSubscription: (userId, endpoint, keysP256dh, keysAuth) => {
try {
db.prepare(
`INSERT INTO push_subscriptions (user_id, endpoint, keys_p256dh, keys_auth)
VALUES (?, ?, ?, ?)
ON CONFLICT(endpoint) DO UPDATE SET
user_id = excluded.user_id,
keys_p256dh = excluded.keys_p256dh,
keys_auth = excluded.keys_auth`
).run(userId, endpoint, keysP256dh, keysAuth);
} catch (err) {
throw err;
}
},
getSubscriptions: (userId) => {
try {
return db.prepare('SELECT endpoint, keys_p256dh, keys_auth FROM push_subscriptions WHERE user_id = ?').all(userId);
} catch (err) {
throw err;
}
},
removeSubscription: (endpoint) => {
try {
db.prepare('DELETE FROM push_subscriptions WHERE endpoint = ?').run(endpoint);
} catch (err) {
throw err;
}
},
removeAllForUser: (userId) => {
try {
db.prepare('DELETE FROM push_subscriptions WHERE user_id = ?').run(userId);
} catch (err) {
throw err;
}
}
};
// Backward compatibility - keep old names pointing to new system
const githubTokensDb = {
createGithubToken: (userId, tokenName, githubToken, description = null) => {
@@ -357,5 +513,7 @@ export {
userDb,
apiKeysDb,
credentialsDb,
notificationPreferencesDb,
pushSubscriptionsDb,
githubTokensDb // Backward compatibility
};
};

View File

@@ -49,4 +49,31 @@ CREATE TABLE IF NOT EXISTS user_credentials (
CREATE INDEX IF NOT EXISTS idx_user_credentials_user_id ON user_credentials(user_id);
CREATE INDEX IF NOT EXISTS idx_user_credentials_type ON user_credentials(credential_type);
CREATE INDEX IF NOT EXISTS idx_user_credentials_active ON user_credentials(is_active);
CREATE INDEX IF NOT EXISTS idx_user_credentials_active ON user_credentials(is_active);
-- User notification preferences (backend-owned, provider-agnostic)
CREATE TABLE IF NOT EXISTS user_notification_preferences (
user_id INTEGER PRIMARY KEY,
preferences_json TEXT NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
-- VAPID key pair for Web Push notifications
CREATE TABLE IF NOT EXISTS vapid_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_key TEXT NOT NULL,
private_key TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- Browser push subscriptions
CREATE TABLE IF NOT EXISTS push_subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
endpoint TEXT NOT NULL UNIQUE,
keys_p256dh TEXT NOT NULL,
keys_auth TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);

455
server/gemini-cli.js Normal file
View File

@@ -0,0 +1,455 @@
import { spawn } from 'child_process';
import crossSpawn from 'cross-spawn';
// Use cross-spawn on Windows for correct .cmd resolution (same pattern as cursor-cli.js)
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
import { getSessions, getSessionMessages } from './projects.js';
import sessionManager from './sessionManager.js';
import GeminiResponseHandler from './gemini-response-handler.js';
let activeGeminiProcesses = new Map(); // Track active processes by session ID
async function spawnGemini(command, options = {}, ws) {
const { sessionId, projectPath, cwd, resume, toolsSettings, permissionMode, images } = options;
let capturedSessionId = sessionId; // Track session ID throughout the process
let sessionCreatedSent = false; // Track if we've already sent session-created event
let assistantBlocks = []; // Accumulate the full response blocks including tools
// Use tools settings passed from frontend, or defaults
const settings = toolsSettings || {
allowedTools: [],
disallowedTools: [],
skipPermissions: false
};
// Build Gemini CLI command - start with print/resume flags first
const args = [];
// Add prompt flag with command if we have a command
if (command && command.trim()) {
args.push('--prompt', command);
}
// If we have a sessionId, we want to resume
if (sessionId) {
const session = sessionManager.getSession(sessionId);
if (session && session.cliSessionId) {
args.push('--resume', session.cliSessionId);
}
}
// Use cwd (actual project directory) instead of projectPath (Gemini's metadata directory)
// Clean the path by removing any non-printable characters
const cleanPath = (cwd || projectPath || process.cwd()).replace(/[^\x20-\x7E]/g, '').trim();
const workingDir = cleanPath;
// Handle images by saving them to temporary files and passing paths to Gemini
const tempImagePaths = [];
let tempDir = null;
if (images && images.length > 0) {
try {
// Create temp directory in the project directory so Gemini can access it
tempDir = path.join(workingDir, '.tmp', 'images', Date.now().toString());
await fs.mkdir(tempDir, { recursive: true });
// Save each image to a temp file
for (const [index, image] of images.entries()) {
// Extract base64 data and mime type
const matches = image.data.match(/^data:([^;]+);base64,(.+)$/);
if (!matches) {
continue;
}
const [, mimeType, base64Data] = matches;
const extension = mimeType.split('/')[1] || 'png';
const filename = `image_${index}.${extension}`;
const filepath = path.join(tempDir, filename);
// Write base64 data to file
await fs.writeFile(filepath, Buffer.from(base64Data, 'base64'));
tempImagePaths.push(filepath);
}
// Include the full image paths in the prompt for Gemini to reference
// Gemini CLI can read images from file paths in the prompt
if (tempImagePaths.length > 0 && command && command.trim()) {
const imageNote = `\n\n[Images given: ${tempImagePaths.length} images are located at the following paths:]\n${tempImagePaths.map((p, i) => `${i + 1}. ${p}`).join('\n')}`;
const modifiedCommand = command + imageNote;
// Update the command in args
const promptIndex = args.indexOf('--prompt');
if (promptIndex !== -1 && args[promptIndex + 1] === command) {
args[promptIndex + 1] = modifiedCommand;
} else if (promptIndex !== -1) {
// If we're using context, update the full prompt
args[promptIndex + 1] = args[promptIndex + 1] + imageNote;
}
}
} catch (error) {
console.error('Error processing images for Gemini:', error);
}
}
// Add basic flags for Gemini
if (options.debug) {
args.push('--debug');
}
// Add MCP config flag only if MCP servers are configured
try {
const geminiConfigPath = path.join(os.homedir(), '.gemini.json');
let hasMcpServers = false;
try {
await fs.access(geminiConfigPath);
const geminiConfigRaw = await fs.readFile(geminiConfigPath, 'utf8');
const geminiConfig = JSON.parse(geminiConfigRaw);
// Check global MCP servers
if (geminiConfig.mcpServers && Object.keys(geminiConfig.mcpServers).length > 0) {
hasMcpServers = true;
}
// Check project-specific MCP servers
if (!hasMcpServers && geminiConfig.geminiProjects) {
const currentProjectPath = process.cwd();
const projectConfig = geminiConfig.geminiProjects[currentProjectPath];
if (projectConfig && projectConfig.mcpServers && Object.keys(projectConfig.mcpServers).length > 0) {
hasMcpServers = true;
}
}
} catch (e) {
// Ignore if file doesn't exist or isn't parsable
}
if (hasMcpServers) {
args.push('--mcp-config', geminiConfigPath);
}
} catch (error) {
// Ignore outer errors
}
// Add model for all sessions (both new and resumed)
let modelToUse = options.model || 'gemini-2.5-flash';
args.push('--model', modelToUse);
args.push('--output-format', 'stream-json');
// Handle approval modes and allowed tools
if (settings.skipPermissions || options.skipPermissions || permissionMode === 'yolo') {
args.push('--yolo');
} else if (permissionMode === 'auto_edit') {
args.push('--approval-mode', 'auto_edit');
} else if (permissionMode === 'plan') {
args.push('--approval-mode', 'plan');
}
if (settings.allowedTools && settings.allowedTools.length > 0) {
args.push('--allowed-tools', settings.allowedTools.join(','));
}
// Try to find gemini in PATH first, then fall back to environment variable
const geminiPath = process.env.GEMINI_PATH || 'gemini';
console.log('Spawning Gemini CLI:', geminiPath, args.join(' '));
console.log('Working directory:', workingDir);
let spawnCmd = geminiPath;
let spawnArgs = args;
// On non-Windows platforms, wrap the execution in a shell to avoid ENOEXEC
// which happens when the target is a script lacking a shebang.
if (os.platform() !== 'win32') {
spawnCmd = 'sh';
// Use exec to replace the shell process, ensuring signals hit gemini directly
spawnArgs = ['-c', 'exec "$0" "$@"', geminiPath, ...args];
}
return new Promise((resolve, reject) => {
const geminiProcess = spawnFunction(spawnCmd, spawnArgs, {
cwd: workingDir,
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env } // Inherit all environment variables
});
// Attach temp file info to process for cleanup later
geminiProcess.tempImagePaths = tempImagePaths;
geminiProcess.tempDir = tempDir;
// Store process reference for potential abort
const processKey = capturedSessionId || sessionId || Date.now().toString();
activeGeminiProcesses.set(processKey, geminiProcess);
// Store sessionId on the process object for debugging
geminiProcess.sessionId = processKey;
// Close stdin to signal we're done sending input
geminiProcess.stdin.end();
// Add timeout handler
let hasReceivedOutput = false;
const timeoutMs = 120000; // 120 seconds for slower models
let timeout;
const startTimeout = () => {
if (timeout) clearTimeout(timeout);
timeout = setTimeout(() => {
const socketSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : (capturedSessionId || sessionId || processKey);
ws.send({
type: 'gemini-error',
sessionId: socketSessionId,
error: `Gemini CLI timeout - no response received for ${timeoutMs / 1000} seconds`
});
try {
geminiProcess.kill('SIGTERM');
} catch (e) { }
}, timeoutMs);
};
startTimeout();
// Save user message to session when starting
if (command && capturedSessionId) {
sessionManager.addMessage(capturedSessionId, 'user', command);
}
// Create response handler for NDJSON buffering
let responseHandler;
if (ws) {
responseHandler = new GeminiResponseHandler(ws, {
onContentFragment: (content) => {
if (assistantBlocks.length > 0 && assistantBlocks[assistantBlocks.length - 1].type === 'text') {
assistantBlocks[assistantBlocks.length - 1].text += content;
} else {
assistantBlocks.push({ type: 'text', text: content });
}
},
onToolUse: (event) => {
assistantBlocks.push({
type: 'tool_use',
id: event.tool_id,
name: event.tool_name,
input: event.parameters
});
},
onToolResult: (event) => {
if (capturedSessionId) {
if (assistantBlocks.length > 0) {
sessionManager.addMessage(capturedSessionId, 'assistant', [...assistantBlocks]);
assistantBlocks = [];
}
sessionManager.addMessage(capturedSessionId, 'user', [{
type: 'tool_result',
tool_use_id: event.tool_id,
content: event.output === undefined ? null : event.output,
is_error: event.status === 'error'
}]);
}
},
onInit: (event) => {
if (capturedSessionId) {
const sess = sessionManager.getSession(capturedSessionId);
if (sess && !sess.cliSessionId) {
sess.cliSessionId = event.session_id;
sessionManager.saveSession(capturedSessionId);
}
}
}
});
}
// Handle stdout
geminiProcess.stdout.on('data', (data) => {
const rawOutput = data.toString();
hasReceivedOutput = true;
startTimeout(); // Re-arm the timeout
// For new sessions, create a session ID FIRST
if (!sessionId && !sessionCreatedSent && !capturedSessionId) {
capturedSessionId = `gemini_${Date.now()}`;
sessionCreatedSent = true;
// Create session in session manager
sessionManager.createSession(capturedSessionId, cwd || process.cwd());
// Save the user message now that we have a session ID
if (command) {
sessionManager.addMessage(capturedSessionId, 'user', command);
}
// Update process key with captured session ID
if (processKey !== capturedSessionId) {
activeGeminiProcesses.delete(processKey);
activeGeminiProcesses.set(capturedSessionId, geminiProcess);
}
ws.setSessionId && typeof ws.setSessionId === 'function' && ws.setSessionId(capturedSessionId);
ws.send({
type: 'session-created',
sessionId: capturedSessionId
});
// Emit fake system init so the frontend immediately navigates and saves the session
ws.send({
type: 'claude-response',
sessionId: capturedSessionId,
data: {
type: 'system',
subtype: 'init',
session_id: capturedSessionId
}
});
}
if (responseHandler) {
responseHandler.processData(rawOutput);
} else if (rawOutput) {
// Fallback to direct sending for raw CLI mode without WS
if (assistantBlocks.length > 0 && assistantBlocks[assistantBlocks.length - 1].type === 'text') {
assistantBlocks[assistantBlocks.length - 1].text += rawOutput;
} else {
assistantBlocks.push({ type: 'text', text: rawOutput });
}
const socketSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : (capturedSessionId || sessionId);
ws.send({
type: 'gemini-response',
sessionId: socketSessionId,
data: {
type: 'message',
content: rawOutput
}
});
}
});
// Handle stderr
geminiProcess.stderr.on('data', (data) => {
const errorMsg = data.toString();
// Filter out deprecation warnings and "Loaded cached credentials" message
if (errorMsg.includes('[DEP0040]') ||
errorMsg.includes('DeprecationWarning') ||
errorMsg.includes('--trace-deprecation') ||
errorMsg.includes('Loaded cached credentials')) {
return;
}
const socketSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : (capturedSessionId || sessionId);
ws.send({
type: 'gemini-error',
sessionId: socketSessionId,
error: errorMsg
});
});
// Handle process completion
geminiProcess.on('close', async (code) => {
clearTimeout(timeout);
// Flush any remaining buffered content
if (responseHandler) {
responseHandler.forceFlush();
responseHandler.destroy();
}
// Clean up process reference
const finalSessionId = capturedSessionId || sessionId || processKey;
activeGeminiProcesses.delete(finalSessionId);
// Save assistant response to session if we have one
if (finalSessionId && assistantBlocks.length > 0) {
sessionManager.addMessage(finalSessionId, 'assistant', assistantBlocks);
}
ws.send({
type: 'claude-complete', // Use claude-complete for compatibility with UI
sessionId: finalSessionId,
exitCode: code,
isNewSession: !sessionId && !!command // Flag to indicate this was a new session
});
// Clean up temporary image files if any
if (geminiProcess.tempImagePaths && geminiProcess.tempImagePaths.length > 0) {
for (const imagePath of geminiProcess.tempImagePaths) {
await fs.unlink(imagePath).catch(err => { });
}
if (geminiProcess.tempDir) {
await fs.rm(geminiProcess.tempDir, { recursive: true, force: true }).catch(err => { });
}
}
if (code === 0) {
resolve();
} else {
reject(new Error(code === null ? 'Gemini CLI process was terminated or timed out' : `Gemini CLI exited with code ${code}`));
}
});
// Handle process errors
geminiProcess.on('error', (error) => {
// Clean up process reference on error
const finalSessionId = capturedSessionId || sessionId || processKey;
activeGeminiProcesses.delete(finalSessionId);
const errorSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : finalSessionId;
ws.send({
type: 'gemini-error',
sessionId: errorSessionId,
error: error.message
});
reject(error);
});
});
}
function abortGeminiSession(sessionId) {
let geminiProc = activeGeminiProcesses.get(sessionId);
let processKey = sessionId;
if (!geminiProc) {
for (const [key, proc] of activeGeminiProcesses.entries()) {
if (proc.sessionId === sessionId) {
geminiProc = proc;
processKey = key;
break;
}
}
}
if (geminiProc) {
try {
geminiProc.kill('SIGTERM');
setTimeout(() => {
if (activeGeminiProcesses.has(processKey)) {
try {
geminiProc.kill('SIGKILL');
} catch (e) { }
}
}, 2000); // Wait 2 seconds before force kill
return true;
} catch (error) {
return false;
}
}
return false;
}
function isGeminiSessionActive(sessionId) {
return activeGeminiProcesses.has(sessionId);
}
function getActiveGeminiSessions() {
return Array.from(activeGeminiProcesses.keys());
}
export {
spawnGemini,
abortGeminiSession,
isGeminiSessionActive,
getActiveGeminiSessions
};

View File

@@ -0,0 +1,140 @@
// Gemini Response Handler - JSON Stream processing
class GeminiResponseHandler {
constructor(ws, options = {}) {
this.ws = ws;
this.buffer = '';
this.onContentFragment = options.onContentFragment || null;
this.onInit = options.onInit || null;
this.onToolUse = options.onToolUse || null;
this.onToolResult = options.onToolResult || null;
}
// Process incoming raw data from Gemini stream-json
processData(data) {
this.buffer += data;
// Split by newline
const lines = this.buffer.split('\n');
// Keep the last incomplete line in the buffer
this.buffer = lines.pop() || '';
for (const line of lines) {
if (!line.trim()) continue;
try {
const event = JSON.parse(line);
this.handleEvent(event);
} catch (err) {
// Not a JSON line, probably debug output or CLI warnings
// console.error('[Gemini Handler] Non-JSON line ignored:', line);
}
}
}
handleEvent(event) {
const socketSessionId = typeof this.ws.getSessionId === 'function' ? this.ws.getSessionId() : null;
if (event.type === 'init') {
if (this.onInit) {
this.onInit(event);
}
return;
}
if (event.type === 'message' && event.role === 'assistant') {
const content = event.content || '';
// Notify the parent CLI handler of accumulated text
if (this.onContentFragment && content) {
this.onContentFragment(content);
}
let payload = {
type: 'gemini-response',
data: {
type: 'message',
content: content,
isPartial: event.delta === true
}
};
if (socketSessionId) payload.sessionId = socketSessionId;
this.ws.send(payload);
}
else if (event.type === 'tool_use') {
if (this.onToolUse) {
this.onToolUse(event);
}
let payload = {
type: 'gemini-tool-use',
toolName: event.tool_name,
toolId: event.tool_id,
parameters: event.parameters || {}
};
if (socketSessionId) payload.sessionId = socketSessionId;
this.ws.send(payload);
}
else if (event.type === 'tool_result') {
if (this.onToolResult) {
this.onToolResult(event);
}
let payload = {
type: 'gemini-tool-result',
toolId: event.tool_id,
status: event.status,
output: event.output || ''
};
if (socketSessionId) payload.sessionId = socketSessionId;
this.ws.send(payload);
}
else if (event.type === 'result') {
// Send a finalize message string
let payload = {
type: 'gemini-response',
data: {
type: 'message',
content: '',
isPartial: false
}
};
if (socketSessionId) payload.sessionId = socketSessionId;
this.ws.send(payload);
if (event.stats && event.stats.total_tokens) {
let statsPayload = {
type: 'claude-status',
data: {
status: 'Complete',
tokens: event.stats.total_tokens
}
};
if (socketSessionId) statsPayload.sessionId = socketSessionId;
this.ws.send(statsPayload);
}
}
else if (event.type === 'error') {
let payload = {
type: 'gemini-error',
error: event.error || event.message || 'Unknown Gemini streaming error'
};
if (socketSessionId) payload.sessionId = socketSessionId;
this.ws.send(payload);
}
}
forceFlush() {
// If the buffer has content, try to parse it one last time
if (this.buffer.trim()) {
try {
const event = JSON.parse(this.buffer);
this.handleEvent(event);
} catch (err) { }
}
}
destroy() {
this.buffer = '';
}
}
export default GeminiResponseHandler;

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
// Load environment variables from .env before other imports execute.
import fs from 'fs';
import os from 'os';
import path from 'path';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
@@ -22,3 +23,7 @@ try {
} catch (e) {
console.log('No .env file found or error reading it:', e.message);
}
if (!process.env.DATABASE_PATH) {
process.env.DATABASE_PATH = path.join(os.homedir(), '.cloudcli', 'auth.db');
}

View File

@@ -85,7 +85,7 @@ const authenticateWebSocket = (token) => {
try {
const user = userDb.getFirstUser();
if (user) {
return { userId: user.id, username: user.username };
return { id: user.id, userId: user.id, username: user.username };
}
return null;
} catch (error) {
@@ -101,7 +101,10 @@ const authenticateWebSocket = (token) => {
try {
const decoded = jwt.verify(token, JWT_SECRET);
return decoded;
return {
...decoded,
id: decoded.userId
};
} catch (error) {
console.error('WebSocket token verification error:', error);
return null;
@@ -114,4 +117,4 @@ export {
generateToken,
authenticateWebSocket,
JWT_SECRET
};
};

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,7 @@ import { addProjectManually } from '../projects.js';
import { queryClaudeSDK } from '../claude-sdk.js';
import { spawnCursor } from '../cursor-cli.js';
import { queryCodex } from '../openai-codex.js';
import { spawnGemini } from '../gemini-cli.js';
import { Octokit } from '@octokit/rest';
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../shared/modelConstants.js';
import { IS_PLATFORM } from '../constants/config.js';
@@ -629,7 +630,7 @@ class ResponseCollector {
* - Source for auto-generated branch names (if createBranch=true and no branchName)
* - Fallback for PR title if no commits are made
*
* @param {string} provider - (Optional) AI provider to use. Options: 'claude' | 'cursor'
* @param {string} provider - (Optional) AI provider to use. Options: 'claude' | 'cursor' | 'codex' | 'gemini'
* Default: 'claude'
*
* @param {boolean} stream - (Optional) Enable Server-Sent Events (SSE) streaming for real-time updates.
@@ -747,7 +748,7 @@ class ResponseCollector {
* Input Validations (400 Bad Request):
* - Either githubUrl OR projectPath must be provided (not neither)
* - message must be non-empty string
* - provider must be 'claude' or 'cursor'
* - provider must be 'claude', 'cursor', 'codex', or 'gemini'
* - createBranch/createPR requires githubUrl OR projectPath (not neither)
* - branchName must pass Git naming rules (if provided)
*
@@ -855,8 +856,8 @@ router.post('/', validateExternalApiKey, async (req, res) => {
return res.status(400).json({ error: 'message is required' });
}
if (!['claude', 'cursor', 'codex'].includes(provider)) {
return res.status(400).json({ error: 'provider must be "claude", "cursor", or "codex"' });
if (!['claude', 'cursor', 'codex', 'gemini'].includes(provider)) {
return res.status(400).json({ error: 'provider must be "claude", "cursor", "codex", or "gemini"' });
}
// Validate GitHub branch/PR creation requirements
@@ -971,6 +972,16 @@ router.post('/', validateExternalApiKey, async (req, res) => {
model: model || CODEX_MODELS.DEFAULT,
permissionMode: 'bypassPermissions'
}, writer);
} else if (provider === 'gemini') {
console.log('✨ Starting Gemini CLI session');
await spawnGemini(message.trim(), {
projectPath: finalProjectPath,
cwd: finalProjectPath,
sessionId: null,
model: model,
skipPermissions: true // CLI mode bypasses permissions
}, writer);
}
// Handle GitHub branch and PR creation after successful agent completion

View File

@@ -53,11 +53,11 @@ router.post('/register', async (req, res) => {
// Generate token
const token = generateToken(user);
// Update last login
db.prepare('COMMIT').run();
// Update last login (non-fatal, outside transaction)
userDb.updateLastLogin(user.id);
db.prepare('COMMIT').run();
res.json({
success: true,
user: { id: user.id, username: user.username },

View File

@@ -74,6 +74,46 @@ router.get('/codex/status', async (req, res) => {
}
});
router.get('/gemini/status', async (req, res) => {
try {
const result = await checkGeminiCredentials();
res.json({
authenticated: result.authenticated,
email: result.email,
error: result.error
});
} catch (error) {
console.error('Error checking Gemini auth status:', error);
res.status(500).json({
authenticated: false,
email: null,
error: error.message
});
}
});
/**
* Checks Claude authentication credentials using two methods with priority order:
*
* Priority 1: ANTHROPIC_API_KEY environment variable
* Priority 2: ~/.claude/.credentials.json OAuth tokens
*
* The Claude Agent SDK prioritizes environment variables over authenticated subscriptions.
* This matching behavior ensures consistency with how the SDK authenticates.
*
* References:
* - https://support.claude.com/en/articles/12304248-managing-api-key-environment-variables-in-claude-code
* "Claude Code prioritizes environment variable API keys over authenticated subscriptions"
* - https://platform.claude.com/docs/en/agent-sdk/overview
* SDK authentication documentation
*
* @returns {Promise<Object>} Authentication status with { authenticated, email, method }
* - authenticated: boolean indicating if valid credentials exist
* - email: user email or auth method identifier
* - method: 'api_key' for env var, 'credentials_file' for OAuth tokens
*/
async function checkClaudeCredentials() {
try {
const credPath = path.join(os.homedir(), '.claude', '.credentials.json');
@@ -260,4 +300,78 @@ async function checkCodexCredentials() {
}
}
async function checkGeminiCredentials() {
if (process.env.GEMINI_API_KEY && process.env.GEMINI_API_KEY.trim()) {
return {
authenticated: true,
email: 'API Key Auth'
};
}
try {
const credsPath = path.join(os.homedir(), '.gemini', 'oauth_creds.json');
const content = await fs.readFile(credsPath, 'utf8');
const creds = JSON.parse(content);
if (creds.access_token) {
let email = 'OAuth Session';
try {
// Validate token against Google API
const tokenRes = await fetch(`https://oauth2.googleapis.com/tokeninfo?access_token=${creds.access_token}`);
if (tokenRes.ok) {
const tokenInfo = await tokenRes.json();
if (tokenInfo.email) {
email = tokenInfo.email;
}
} else if (!creds.refresh_token) {
// Token invalid and no refresh token available
return {
authenticated: false,
email: null,
error: 'Access token invalid and no refresh token found'
};
} else {
// Token might be expired but we have a refresh token, so CLI will refresh it
try {
const accPath = path.join(os.homedir(), '.gemini', 'google_accounts.json');
const accContent = await fs.readFile(accPath, 'utf8');
const accounts = JSON.parse(accContent);
if (accounts.active) {
email = accounts.active;
}
} catch (e) { }
}
} catch (e) {
// Network error, fallback to checking local accounts file
try {
const accPath = path.join(os.homedir(), '.gemini', 'google_accounts.json');
const accContent = await fs.readFile(accPath, 'utf8');
const accounts = JSON.parse(accContent);
if (accounts.active) {
email = accounts.active;
}
} catch (err) { }
}
return {
authenticated: true,
email: email
};
}
return {
authenticated: false,
email: null,
error: 'No valid tokens found in oauth_creds'
};
} catch (error) {
return {
authenticated: false,
email: null,
error: 'Gemini CLI not configured'
};
}
}
export default router;

46
server/routes/gemini.js Normal file
View File

@@ -0,0 +1,46 @@
import express from 'express';
import sessionManager from '../sessionManager.js';
const router = express.Router();
router.get('/sessions/:sessionId/messages', async (req, res) => {
try {
const { sessionId } = req.params;
if (!sessionId || typeof sessionId !== 'string' || !/^[a-zA-Z0-9_.-]{1,100}$/.test(sessionId)) {
return res.status(400).json({ success: false, error: 'Invalid session ID format' });
}
const messages = sessionManager.getSessionMessages(sessionId);
res.json({
success: true,
messages: messages,
total: messages.length,
hasMore: false,
offset: 0,
limit: messages.length
});
} catch (error) {
console.error('Error fetching Gemini session messages:', error);
res.status(500).json({ success: false, error: error.message });
}
});
router.delete('/sessions/:sessionId', async (req, res) => {
try {
const { sessionId } = req.params;
if (!sessionId || typeof sessionId !== 'string' || !/^[a-zA-Z0-9_.-]{1,100}$/.test(sessionId)) {
return res.status(400).json({ success: false, error: 'Invalid session ID format' });
}
await sessionManager.deleteSession(sessionId);
res.json({ success: true });
} catch (error) {
console.error(`Error deleting Gemini session ${req.params.sessionId}:`, error);
res.status(500).json({ success: false, error: error.message });
}
});
export default router;

View File

@@ -1,5 +1,7 @@
import express from 'express';
import { apiKeysDb, credentialsDb } from '../database/db.js';
import { apiKeysDb, credentialsDb, notificationPreferencesDb, pushSubscriptionsDb } from '../database/db.js';
import { getPublicKey } from '../services/vapid-keys.js';
import { createNotificationEvent, notifyUserIfEnabled } from '../services/notification-orchestrator.js';
const router = express.Router();
@@ -175,4 +177,100 @@ router.patch('/credentials/:credentialId/toggle', async (req, res) => {
}
});
// ===============================
// Notification Preferences
// ===============================
router.get('/notification-preferences', async (req, res) => {
try {
const preferences = notificationPreferencesDb.getPreferences(req.user.id);
res.json({ success: true, preferences });
} catch (error) {
console.error('Error fetching notification preferences:', error);
res.status(500).json({ error: 'Failed to fetch notification preferences' });
}
});
router.put('/notification-preferences', async (req, res) => {
try {
const preferences = notificationPreferencesDb.updatePreferences(req.user.id, req.body || {});
res.json({ success: true, preferences });
} catch (error) {
console.error('Error saving notification preferences:', error);
res.status(500).json({ error: 'Failed to save notification preferences' });
}
});
// ===============================
// Push Subscription Management
// ===============================
router.get('/push/vapid-public-key', async (req, res) => {
try {
const publicKey = getPublicKey();
res.json({ publicKey });
} catch (error) {
console.error('Error fetching VAPID public key:', error);
res.status(500).json({ error: 'Failed to fetch VAPID public key' });
}
});
router.post('/push/subscribe', async (req, res) => {
try {
const { endpoint, keys } = req.body;
if (!endpoint || !keys?.p256dh || !keys?.auth) {
return res.status(400).json({ error: 'Missing subscription fields' });
}
pushSubscriptionsDb.saveSubscription(req.user.id, endpoint, keys.p256dh, keys.auth);
// Enable webPush in preferences so the confirmation goes through the full pipeline
const currentPrefs = notificationPreferencesDb.getPreferences(req.user.id);
if (!currentPrefs?.channels?.webPush) {
notificationPreferencesDb.updatePreferences(req.user.id, {
...currentPrefs,
channels: { ...currentPrefs?.channels, webPush: true },
});
}
res.json({ success: true });
// Send a confirmation push through the full notification pipeline
const event = createNotificationEvent({
provider: 'system',
kind: 'info',
code: 'push.enabled',
meta: { message: 'Push notifications are now enabled!' },
severity: 'info'
});
notifyUserIfEnabled({ userId: req.user.id, event });
} catch (error) {
console.error('Error saving push subscription:', error);
res.status(500).json({ error: 'Failed to save push subscription' });
}
});
router.post('/push/unsubscribe', async (req, res) => {
try {
const { endpoint } = req.body;
if (!endpoint) {
return res.status(400).json({ error: 'Missing endpoint' });
}
pushSubscriptionsDb.removeSubscription(endpoint);
// Disable webPush in preferences to match subscription state
const currentPrefs = notificationPreferencesDb.getPreferences(req.user.id);
if (currentPrefs?.channels?.webPush) {
notificationPreferencesDb.updatePreferences(req.user.id, {
...currentPrefs,
channels: { ...currentPrefs.channels, webPush: false },
});
}
res.json({ success: true });
} catch (error) {
console.error('Error removing push subscription:', error);
res.status(500).json({ error: 'Failed to remove push subscription' });
}
});
export default router;

View File

@@ -0,0 +1,137 @@
import webPush from 'web-push';
import { notificationPreferencesDb, pushSubscriptionsDb } from '../database/db.js';
const KIND_TO_PREF_KEY = {
action_required: 'actionRequired',
stop: 'stop',
error: 'error'
};
const recentEventKeys = new Map();
const DEDUPE_WINDOW_MS = 20000;
const cleanupOldEventKeys = () => {
const now = Date.now();
for (const [key, timestamp] of recentEventKeys.entries()) {
if (now - timestamp > DEDUPE_WINDOW_MS) {
recentEventKeys.delete(key);
}
}
};
function shouldSendPush(preferences, event) {
const webPushEnabled = Boolean(preferences?.channels?.webPush);
const prefEventKey = KIND_TO_PREF_KEY[event.kind];
const eventEnabled = prefEventKey ? Boolean(preferences?.events?.[prefEventKey]) : true;
return webPushEnabled && eventEnabled;
}
function isDuplicate(event) {
cleanupOldEventKeys();
const key = event.dedupeKey || `${event.provider}:${event.kind || 'info'}:${event.code || 'generic'}:${event.sessionId || 'none'}`;
if (recentEventKeys.has(key)) {
return true;
}
recentEventKeys.set(key, Date.now());
return false;
}
function createNotificationEvent({
provider,
sessionId = null,
kind = 'info',
code = 'generic.info',
meta = {},
severity = 'info',
dedupeKey = null,
requiresUserAction = false
}) {
return {
provider,
sessionId,
kind,
code,
meta,
severity,
requiresUserAction,
dedupeKey,
createdAt: new Date().toISOString()
};
}
function buildPushBody(event) {
const CODE_MAP = {
'permission.required': event.meta?.toolName
? `Action Required: Tool "${event.meta.toolName}" needs approval`
: 'Action Required: A tool needs your approval',
'run.stopped': event.meta?.stopReason || 'Run Stopped: The run has stopped',
'run.failed': event.meta?.error ? `Run Failed: ${event.meta.error}` : 'Run Failed: The run encountered an error',
'agent.notification': event.meta?.message ? String(event.meta.message) : 'You have a new notification',
'push.enabled': 'Push notifications are now enabled!'
};
return {
title: 'Claude Code UI',
body: CODE_MAP[event.code] || 'You have a new notification',
data: {
sessionId: event.sessionId || null,
code: event.code
}
};
}
async function sendWebPush(userId, event) {
const subscriptions = pushSubscriptionsDb.getSubscriptions(userId);
if (!subscriptions.length) return;
const payload = JSON.stringify(buildPushBody(event));
const results = await Promise.allSettled(
subscriptions.map((sub) =>
webPush.sendNotification(
{
endpoint: sub.endpoint,
keys: {
p256dh: sub.keys_p256dh,
auth: sub.keys_auth
}
},
payload
)
)
);
// Clean up gone subscriptions (410 Gone or 404)
results.forEach((result, index) => {
if (result.status === 'rejected') {
const statusCode = result.reason?.statusCode;
if (statusCode === 410 || statusCode === 404) {
pushSubscriptionsDb.removeSubscription(subscriptions[index].endpoint);
}
}
});
}
function notifyUserIfEnabled({ userId, event }) {
if (!userId || !event) {
return;
}
const preferences = notificationPreferencesDb.getPreferences(userId);
if (!shouldSendPush(preferences, event)) {
return;
}
if (isDuplicate(event)) {
return;
}
sendWebPush(userId, event).catch((err) => {
console.error('Web push send error:', err);
});
}
export {
createNotificationEvent,
notifyUserIfEnabled
};

View File

@@ -0,0 +1,35 @@
import webPush from 'web-push';
import { db } from '../database/db.js';
let cachedKeys = null;
function ensureVapidKeys() {
if (cachedKeys) return cachedKeys;
const row = db.prepare('SELECT public_key, private_key FROM vapid_keys ORDER BY id DESC LIMIT 1').get();
if (row) {
cachedKeys = { publicKey: row.public_key, privateKey: row.private_key };
return cachedKeys;
}
const keys = webPush.generateVAPIDKeys();
db.prepare('INSERT INTO vapid_keys (public_key, private_key) VALUES (?, ?)').run(keys.publicKey, keys.privateKey);
cachedKeys = keys;
return cachedKeys;
}
function getPublicKey() {
return ensureVapidKeys().publicKey;
}
function configureWebPush() {
const keys = ensureVapidKeys();
webPush.setVapidDetails(
'mailto:noreply@claudecodeui.local',
keys.publicKey,
keys.privateKey
);
console.log('Web Push notifications configured');
}
export { ensureVapidKeys, getPublicKey, configureWebPush };

226
server/sessionManager.js Normal file
View File

@@ -0,0 +1,226 @@
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
class SessionManager {
constructor() {
// Store sessions in memory with conversation history
this.sessions = new Map();
this.maxSessions = 100;
this.sessionsDir = path.join(os.homedir(), '.gemini', 'sessions');
this.ready = this.init();
}
async init() {
await this.initSessionsDir();
await this.loadSessions();
}
async initSessionsDir() {
try {
await fs.mkdir(this.sessionsDir, { recursive: true });
} catch (error) {
// console.error('Error creating sessions directory:', error);
}
}
// Create a new session
createSession(sessionId, projectPath) {
const session = {
id: sessionId,
projectPath: projectPath,
messages: [],
createdAt: new Date(),
lastActivity: new Date()
};
// Evict oldest session from memory if we exceed limit
if (this.sessions.size >= this.maxSessions) {
const oldestKey = this.sessions.keys().next().value;
if (oldestKey) this.sessions.delete(oldestKey);
}
this.sessions.set(sessionId, session);
this.saveSession(sessionId);
return session;
}
// Add a message to session
addMessage(sessionId, role, content) {
let session = this.sessions.get(sessionId);
if (!session) {
// Create session if it doesn't exist
session = this.createSession(sessionId, '');
}
const message = {
role: role, // 'user' or 'assistant'
content: content,
timestamp: new Date()
};
session.messages.push(message);
session.lastActivity = new Date();
this.saveSession(sessionId);
return session;
}
// Get session by ID
getSession(sessionId) {
return this.sessions.get(sessionId);
}
// Get all sessions for a project
getProjectSessions(projectPath) {
const sessions = [];
for (const [id, session] of this.sessions) {
if (session.projectPath === projectPath) {
sessions.push({
id: session.id,
summary: this.getSessionSummary(session),
messageCount: session.messages.length,
lastActivity: session.lastActivity
});
}
}
return sessions.sort((a, b) =>
new Date(b.lastActivity) - new Date(a.lastActivity)
);
}
// Get session summary
getSessionSummary(session) {
if (session.messages.length === 0) {
return 'New Session';
}
// Find first user message
const firstUserMessage = session.messages.find(m => m.role === 'user');
if (firstUserMessage) {
const content = firstUserMessage.content;
return content.length > 50 ? content.substring(0, 50) + '...' : content;
}
return 'New Session';
}
// Build conversation context for Gemini
buildConversationContext(sessionId, maxMessages = 10) {
const session = this.sessions.get(sessionId);
if (!session || session.messages.length === 0) {
return '';
}
// Get last N messages for context
const recentMessages = session.messages.slice(-maxMessages);
let context = 'Here is the conversation history:\n\n';
for (const msg of recentMessages) {
if (msg.role === 'user') {
context += `User: ${msg.content}\n`;
} else {
context += `Assistant: ${msg.content}\n`;
}
}
context += '\nBased on the conversation history above, please answer the following:\n';
return context;
}
// Prevent path traversal
_safeFilePath(sessionId) {
const safeId = String(sessionId).replace(/[/\\]|\.\./g, '');
return path.join(this.sessionsDir, `${safeId}.json`);
}
// Save session to disk
async saveSession(sessionId) {
const session = this.sessions.get(sessionId);
if (!session) return;
try {
const filePath = this._safeFilePath(sessionId);
await fs.writeFile(filePath, JSON.stringify(session, null, 2));
} catch (error) {
// console.error('Error saving session:', error);
}
}
// Load sessions from disk
async loadSessions() {
try {
const files = await fs.readdir(this.sessionsDir);
for (const file of files) {
if (file.endsWith('.json')) {
try {
const filePath = path.join(this.sessionsDir, file);
const data = await fs.readFile(filePath, 'utf8');
const session = JSON.parse(data);
// Convert dates
session.createdAt = new Date(session.createdAt);
session.lastActivity = new Date(session.lastActivity);
session.messages.forEach(msg => {
msg.timestamp = new Date(msg.timestamp);
});
this.sessions.set(session.id, session);
} catch (error) {
// console.error(`Error loading session ${file}:`, error);
}
}
}
// Enforce eviction after loading to prevent massive memory usage
while (this.sessions.size > this.maxSessions) {
const oldestKey = this.sessions.keys().next().value;
if (oldestKey) this.sessions.delete(oldestKey);
}
} catch (error) {
// console.error('Error loading sessions:', error);
}
}
// Delete a session
async deleteSession(sessionId) {
this.sessions.delete(sessionId);
try {
const filePath = this._safeFilePath(sessionId);
await fs.unlink(filePath);
} catch (error) {
// console.error('Error deleting session file:', error);
}
}
// Get session messages for display
getSessionMessages(sessionId) {
const session = this.sessions.get(sessionId);
if (!session) return [];
return session.messages.map(msg => ({
type: 'message',
message: {
role: msg.role,
content: msg.content
},
timestamp: msg.timestamp.toISOString()
}));
}
}
// Singleton instance
const sessionManager = new SessionManager();
export const ready = sessionManager.ready;
export default sessionManager;

View File

@@ -63,5 +63,24 @@ export const CODEX_MODELS = {
{ value: 'o4-mini', label: 'O4-mini' }
],
DEFAULT: 'gpt-5.2'
DEFAULT: 'gpt-5.3-codex'
};
/**
* Gemini Models
*/
export const GEMINI_MODELS = {
OPTIONS: [
{ value: 'gemini-3.1-pro-preview', label: 'Gemini 3.1 Pro Preview' },
{ value: 'gemini-3-pro-preview', label: 'Gemini 3 Pro Preview' },
{ value: 'gemini-3-flash-preview', label: 'Gemini 3 Flash Preview' },
{ value: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' },
{ value: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro' },
{ value: 'gemini-2.0-flash-lite', label: 'Gemini 2.0 Flash Lite' },
{ value: 'gemini-2.0-flash', label: 'Gemini 2.0 Flash' },
{ value: 'gemini-2.0-pro-exp', label: 'Gemini 2.0 Pro Experimental' },
{ value: 'gemini-2.0-flash-thinking-exp', label: 'Gemini 2.0 Flash Thinking' }
],
DEFAULT: 'gemini-2.5-flash'
};

View File

@@ -1,373 +0,0 @@
import { useState, useEffect } from 'react';
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);
const [showNewKeyForm, setShowNewKeyForm] = useState(false);
const [showNewTokenForm, setShowNewTokenForm] = useState(false);
const [newKeyName, setNewKeyName] = useState('');
const [newTokenName, setNewTokenName] = useState('');
const [newGithubToken, setNewGithubToken] = useState('');
const [showToken, setShowToken] = useState({});
const [copiedKey, setCopiedKey] = useState(null);
const [newlyCreatedKey, setNewlyCreatedKey] = useState(null);
useEffect(() => {
fetchData();
}, []);
const fetchData = async () => {
try {
setLoading(true);
// Fetch API keys
const apiKeysRes = await authenticatedFetch('/api/settings/api-keys');
const apiKeysData = await apiKeysRes.json();
setApiKeys(apiKeysData.apiKeys || []);
// Fetch GitHub tokens
const githubRes = await authenticatedFetch('/api/settings/credentials?type=github_token');
const githubData = await githubRes.json();
setGithubTokens(githubData.credentials || []);
} catch (error) {
console.error('Error fetching settings:', error);
} finally {
setLoading(false);
}
};
const createApiKey = async () => {
if (!newKeyName.trim()) return;
try {
const res = await authenticatedFetch('/api/settings/api-keys', {
method: 'POST',
body: JSON.stringify({ keyName: newKeyName })
});
const data = await res.json();
if (data.success) {
setNewlyCreatedKey(data.apiKey);
setNewKeyName('');
setShowNewKeyForm(false);
fetchData();
}
} catch (error) {
console.error('Error creating API key:', error);
}
};
const deleteApiKey = async (keyId) => {
if (!confirm(t('apiKeys.confirmDelete'))) return;
try {
await authenticatedFetch(`/api/settings/api-keys/${keyId}`, {
method: 'DELETE'
});
fetchData();
} catch (error) {
console.error('Error deleting API key:', error);
}
};
const toggleApiKey = async (keyId, isActive) => {
try {
await authenticatedFetch(`/api/settings/api-keys/${keyId}/toggle`, {
method: 'PATCH',
body: JSON.stringify({ isActive: !isActive })
});
fetchData();
} catch (error) {
console.error('Error toggling API key:', error);
}
};
const createGithubToken = async () => {
if (!newTokenName.trim() || !newGithubToken.trim()) return;
try {
const res = await authenticatedFetch('/api/settings/credentials', {
method: 'POST',
body: JSON.stringify({
credentialName: newTokenName,
credentialType: 'github_token',
credentialValue: newGithubToken
})
});
const data = await res.json();
if (data.success) {
setNewTokenName('');
setNewGithubToken('');
setShowNewTokenForm(false);
fetchData();
}
} catch (error) {
console.error('Error creating GitHub token:', error);
}
};
const deleteGithubToken = async (tokenId) => {
if (!confirm(t('apiKeys.github.confirmDelete'))) return;
try {
await authenticatedFetch(`/api/settings/credentials/${tokenId}`, {
method: 'DELETE'
});
fetchData();
} catch (error) {
console.error('Error deleting GitHub token:', error);
}
};
const toggleGithubToken = async (tokenId, isActive) => {
try {
await authenticatedFetch(`/api/settings/credentials/${tokenId}/toggle`, {
method: 'PATCH',
body: JSON.stringify({ isActive: !isActive })
});
fetchData();
} catch (error) {
console.error('Error toggling GitHub token:', error);
}
};
const copyToClipboard = (text, id) => {
navigator.clipboard.writeText(text);
setCopiedKey(id);
setTimeout(() => setCopiedKey(null), 2000);
};
if (loading) {
return <div className="text-muted-foreground">{t('apiKeys.loading')}</div>;
}
return (
<div className="space-y-8">
{/* 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">{t('apiKeys.newKey.alertTitle')}</h4>
<p className="text-sm text-muted-foreground mb-3">
{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">
{newlyCreatedKey.apiKey}
</code>
<Button
size="sm"
variant="outline"
onClick={() => copyToClipboard(newlyCreatedKey.apiKey, 'new')}
>
{copiedKey === 'new' ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
<Button
size="sm"
variant="ghost"
className="mt-3"
onClick={() => setNewlyCreatedKey(null)}
>
{t('apiKeys.newKey.iveSavedIt')}
</Button>
</div>
)}
{/* API Keys Section */}
<div>
<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">{t('apiKeys.title')}</h3>
</div>
<Button
size="sm"
onClick={() => setShowNewKeyForm(!showNewKeyForm)}
>
<Plus className="h-4 w-4 mr-1" />
{t('apiKeys.newButton')}
</Button>
</div>
<p className="text-sm text-muted-foreground mb-4">
{t('apiKeys.description')}
</p>
{showNewKeyForm && (
<div className="mb-4 p-4 border rounded-lg bg-card">
<Input
placeholder={t('apiKeys.form.placeholder')}
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
className="mb-2"
/>
<div className="flex gap-2">
<Button onClick={createApiKey}>{t('apiKeys.form.createButton')}</Button>
<Button variant="outline" onClick={() => setShowNewKeyForm(false)}>
{t('apiKeys.form.cancelButton')}
</Button>
</div>
</div>
)}
<div className="space-y-2">
{apiKeys.length === 0 ? (
<p className="text-sm text-muted-foreground italic">{t('apiKeys.empty')}</p>
) : (
apiKeys.map((key) => (
<div
key={key.id}
className="flex items-center justify-between p-3 border rounded-lg"
>
<div className="flex-1">
<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">
{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">
<Button
size="sm"
variant={key.is_active ? 'outline' : 'secondary'}
onClick={() => toggleApiKey(key.id, key.is_active)}
>
{key.is_active ? t('apiKeys.status.active') : t('apiKeys.status.inactive')}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => deleteApiKey(key.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))
)}
</div>
</div>
{/* GitHub Tokens Section */}
<div>
<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">{t('apiKeys.github.title')}</h3>
</div>
<Button
size="sm"
onClick={() => setShowNewTokenForm(!showNewTokenForm)}
>
<Plus className="h-4 w-4 mr-1" />
{t('apiKeys.github.addButton')}
</Button>
</div>
<p className="text-sm text-muted-foreground mb-4">
{t('apiKeys.github.description')}
</p>
{showNewTokenForm && (
<div className="mb-4 p-4 border rounded-lg bg-card">
<Input
placeholder={t('apiKeys.github.form.namePlaceholder')}
value={newTokenName}
onChange={(e) => setNewTokenName(e.target.value)}
className="mb-2"
/>
<div className="relative">
<Input
type={showToken['new'] ? 'text' : 'password'}
placeholder={t('apiKeys.github.form.tokenPlaceholder')}
value={newGithubToken}
onChange={(e) => setNewGithubToken(e.target.value)}
className="mb-2 pr-10"
/>
<button
type="button"
onClick={() => setShowToken({ ...showToken, new: !showToken['new'] })}
className="absolute right-3 top-2.5 text-muted-foreground hover:text-foreground"
>
{showToken['new'] ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
<div className="flex gap-2">
<Button onClick={createGithubToken}>{t('apiKeys.github.form.addButton')}</Button>
<Button variant="outline" onClick={() => {
setShowNewTokenForm(false);
setNewTokenName('');
setNewGithubToken('');
}}>
{t('apiKeys.github.form.cancelButton')}
</Button>
</div>
</div>
)}
<div className="space-y-2">
{githubTokens.length === 0 ? (
<p className="text-sm text-muted-foreground italic">{t('apiKeys.github.empty')}</p>
) : (
githubTokens.map((token) => (
<div
key={token.id}
className="flex items-center justify-between p-3 border rounded-lg"
>
<div className="flex-1">
<div className="font-medium">{token.credential_name}</div>
<div className="text-xs text-muted-foreground mt-1">
{t('apiKeys.github.added')} {new Date(token.created_at).toLocaleDateString()}
</div>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
variant={token.is_active ? 'outline' : 'secondary'}
onClick={() => toggleGithubToken(token.id, token.is_active)}
>
{token.is_active ? t('apiKeys.status.active') : t('apiKeys.status.inactive')}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => deleteGithubToken(token.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))
)}
</div>
</div>
{/* Documentation Link */}
<div className="p-4 bg-muted/50 rounded-lg">
<h4 className="font-semibold mb-2">{t('apiKeys.documentation.title')}</h4>
<p className="text-sm text-muted-foreground mb-3">
{t('apiKeys.documentation.description')}
</p>
<a
href="/EXTERNAL_API.md"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-primary hover:underline"
>
{t('apiKeys.documentation.viewLink')}
</a>
</div>
</div>
);
}
export default ApiKeysSettings;

View File

@@ -1,875 +0,0 @@
import React, { useState, useEffect, useRef, useMemo } from 'react';
import CodeMirror from '@uiw/react-codemirror';
import { javascript } from '@codemirror/lang-javascript';
import { python } from '@codemirror/lang-python';
import { html } from '@codemirror/lang-html';
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, 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';
// 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);
const [isFullscreen, setIsFullscreen] = useState(false);
const [isDarkMode, setIsDarkMode] = useState(() => {
const savedTheme = localStorage.getItem('codeEditorTheme');
return savedTheme ? savedTheme === 'dark' : true;
});
const [saveSuccess, setSaveSuccess] = useState(false);
const [showDiff, setShowDiff] = useState(!!file.diffInfo);
const [wordWrap, setWordWrap] = useState(() => {
return localStorage.getItem('codeEditorWordWrap') === 'true';
});
const [minimapEnabled, setMinimapEnabled] = useState(() => {
return localStorage.getItem('codeEditorShowMinimap') !== 'false';
});
const [showLineNumbers, setShowLineNumbers] = useState(() => {
return localStorage.getItem('codeEditorLineNumbers') !== 'false';
});
const [fontSize, setFontSize] = useState(() => {
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 [];
const gutters = {};
return [
showMinimap.compute(['doc'], (state) => {
// Get actual chunks from merge view
const chunksData = getChunks(state);
const chunks = chunksData?.chunks || [];
// Clear previous gutters
Object.keys(gutters).forEach(key => delete gutters[key]);
// Mark lines that are part of chunks
chunks.forEach(chunk => {
// Mark the lines in the B side (current document)
const fromLine = state.doc.lineAt(chunk.fromB).number;
const toLine = state.doc.lineAt(Math.min(chunk.toB, state.doc.length)).number;
for (let lineNum = fromLine; lineNum <= toLine; lineNum++) {
gutters[lineNum] = isDarkMode ? 'rgba(34, 197, 94, 0.8)' : 'rgba(34, 197, 94, 1)';
}
});
return {
create: () => ({ dom: document.createElement('div') }),
displayText: 'blocks',
showOverlay: 'always',
gutters: [gutters]
};
})
];
}, [file.diffInfo, showDiff, minimapEnabled, isDarkMode]);
// Create extension to scroll to first chunk on mount
const scrollToFirstChunkExtension = useMemo(() => {
if (!file.diffInfo || !showDiff) return [];
return [
ViewPlugin.fromClass(class {
constructor(view) {
// Delay to ensure merge view is fully initialized
setTimeout(() => {
const chunksData = getChunks(view.state);
const chunks = chunksData?.chunks || [];
if (chunks.length > 0) {
const firstChunk = chunks[0];
// Scroll to the first chunk
view.dispatch({
effects: EditorView.scrollIntoView(firstChunk.fromB, { y: 'center' })
});
}
}, 100);
}
update() {}
destroy() {}
})
];
}, [file.diffInfo, showDiff]);
// 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';
let currentIndex = 0;
const updatePanel = () => {
// Check if we have diff info and it's enabled
const hasDiff = file.diffInfo && showDiff;
const chunksData = hasDiff ? getChunks(view.state) : null;
const chunks = chunksData?.chunks || [];
const chunkCount = chunks.length;
// Build the toolbar HTML
let toolbarHTML = '<div style="display: flex; align-items: center; justify-content: space-between; width: 100%;">';
// Left side - diff navigation (if applicable)
toolbarHTML += '<div style="display: flex; align-items: center; gap: 8px;">';
if (hasDiff) {
toolbarHTML += `
<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="${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>
</button>
`;
}
toolbarHTML += '</div>';
// Right side - action buttons
toolbarHTML += '<div style="display: flex; align-items: center; gap: 4px;">';
// 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 ? 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" />' :
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />'
}
</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 ? 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" />' :
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" />'
}
</svg>
</button>
`;
}
toolbarHTML += '</div>';
toolbarHTML += '</div>';
dom.innerHTML = toolbarHTML;
if (hasDiff) {
const prevBtn = dom.querySelector('.cm-diff-nav-prev');
const nextBtn = dom.querySelector('.cm-diff-nav-next');
prevBtn?.addEventListener('click', () => {
if (chunks.length === 0) return;
currentIndex = currentIndex > 0 ? currentIndex - 1 : chunks.length - 1;
const chunk = chunks[currentIndex];
if (chunk) {
view.dispatch({
effects: EditorView.scrollIntoView(chunk.fromB, { y: 'center' })
});
}
updatePanel();
});
nextBtn?.addEventListener('click', () => {
if (chunks.length === 0) return;
currentIndex = currentIndex < chunks.length - 1 ? currentIndex + 1 : 0;
const chunk = chunks[currentIndex];
if (chunk) {
view.dispatch({
effects: EditorView.scrollIntoView(chunk.fromB, { y: 'center' })
});
}
updatePanel();
});
}
if (file.diffInfo) {
const toggleDiffBtn = dom.querySelector('.cm-toggle-diff-btn');
toggleDiffBtn?.addEventListener('click', () => {
setShowDiff(!showDiff);
});
}
if (isSidebar && onPopOut) {
const popoutBtn = dom.querySelector('.cm-popout-btn');
popoutBtn?.addEventListener('click', () => {
onPopOut();
});
}
if (isSidebar && onToggleExpand) {
const expandBtn = dom.querySelector('.cm-expand-btn');
expandBtn?.addEventListener('click', () => {
onToggleExpand();
});
}
};
updatePanel();
return {
top: true,
dom,
update: updatePanel
};
};
return [showPanel.of(createPanel)];
}, [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':
case 'jsx':
case 'ts':
case 'tsx':
return [javascript({ jsx: true, typescript: ext.includes('ts') })];
case 'py':
return [python()];
case 'html':
case 'htm':
return [html()];
case 'css':
case 'scss':
case 'less':
return [css()];
case 'json':
return [json()];
case 'md':
case 'markdown':
return [markdown()];
case 'env':
return [envLanguage];
default:
return [];
}
};
// Load file content
useEffect(() => {
const loadFileContent = async () => {
try {
setLoading(true);
// If we have diffInfo with both old and new content, we can show the diff directly
// This handles both GitPanel (full content) and ChatInterface (full content from API)
if (file.diffInfo && file.diffInfo.new_string !== undefined && file.diffInfo.old_string !== undefined) {
// Use the new_string as the content to display
// The unifiedMergeView will compare it against old_string
setContent(file.diffInfo.new_string);
setLoading(false);
return;
}
// Otherwise, load from disk
const response = await api.readFile(file.projectName, file.path);
if (!response.ok) {
throw new Error(`Failed to load file: ${response.status} ${response.statusText}`);
}
const data = await response.json();
setContent(data.content);
} catch (error) {
console.error('Error loading file:', error);
setContent(`// Error loading file: ${error.message}\n// File: ${file.name}\n// Path: ${file.path}`);
} finally {
setLoading(false);
}
};
loadFileContent();
}, [file, projectPath]);
const handleSave = async () => {
setSaving(true);
try {
console.log('Saving file:', {
projectName: file.projectName,
path: file.path,
contentLength: content?.length
});
const response = await api.saveFile(file.projectName, file.path, content);
console.log('Save response:', {
status: response.status,
ok: response.ok,
contentType: response.headers.get('content-type')
});
if (!response.ok) {
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
const errorData = await response.json();
throw new Error(errorData.error || `Save failed: ${response.status}`);
} else {
const textError = await response.text();
console.error('Non-JSON error response:', textError);
throw new Error(`Save failed: ${response.status} ${response.statusText}`);
}
}
const result = await response.json();
console.log('Save successful:', result);
setSaveSuccess(true);
setTimeout(() => setSaveSuccess(false), 2000);
} catch (error) {
console.error('Error saving file:', error);
alert(`Error saving file: ${error.message}`);
} finally {
setSaving(false);
}
};
const handleDownload = () => {
const blob = new Blob([content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = file.name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
const toggleFullscreen = () => {
setIsFullscreen(!isFullscreen);
};
// Save theme preference to localStorage
useEffect(() => {
localStorage.setItem('codeEditorTheme', isDarkMode ? 'dark' : 'light');
}, [isDarkMode]);
// Save word wrap preference to localStorage
useEffect(() => {
localStorage.setItem('codeEditorWordWrap', wordWrap.toString());
}, [wordWrap]);
// Listen for settings changes from the Settings modal
useEffect(() => {
const handleStorageChange = () => {
const newTheme = localStorage.getItem('codeEditorTheme');
if (newTheme) {
setIsDarkMode(newTheme === 'dark');
}
const newWordWrap = localStorage.getItem('codeEditorWordWrap');
if (newWordWrap !== null) {
setWordWrap(newWordWrap === 'true');
}
const newShowMinimap = localStorage.getItem('codeEditorShowMinimap');
if (newShowMinimap !== null) {
setMinimapEnabled(newShowMinimap !== 'false');
}
const newShowLineNumbers = localStorage.getItem('codeEditorLineNumbers');
if (newShowLineNumbers !== null) {
setShowLineNumbers(newShowLineNumbers !== 'false');
}
const newFontSize = localStorage.getItem('codeEditorFontSize');
if (newFontSize) {
setFontSize(newFontSize);
}
};
// Listen for storage events (changes from other tabs/windows)
window.addEventListener('storage', handleStorageChange);
// Custom event for same-window updates
window.addEventListener('codeEditorSettingsChanged', handleStorageChange);
return () => {
window.removeEventListener('storage', handleStorageChange);
window.removeEventListener('codeEditorSettingsChanged', handleStorageChange);
};
}, []);
// Handle keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e) => {
if (e.ctrlKey || e.metaKey) {
if (e.key === 's') {
e.preventDefault();
handleSave();
} else if (e.key === 'Escape') {
e.preventDefault();
onClose();
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [content]);
if (loading) {
return (
<>
<style>
{`
.code-editor-loading {
background-color: ${isDarkMode ? '#111827' : '#ffffff'} !important;
}
.code-editor-loading:hover {
background-color: ${isDarkMode ? '#111827' : '#ffffff'} !important;
}
`}
</style>
{isSidebar ? (
<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">{t('loading', { fileName: file.name })}</span>
</div>
</div>
) : (
<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">{t('loading', { fileName: file.name })}</span>
</div>
</div>
</div>
)}
</>
);
}
return (
<>
<style>
{`
/* Light background for full line changes */
.cm-deletedChunk {
background-color: ${isDarkMode ? 'rgba(239, 68, 68, 0.15)' : 'rgba(255, 235, 235, 1)'} !important;
border-left: 3px solid ${isDarkMode ? 'rgba(239, 68, 68, 0.6)' : 'rgb(239, 68, 68)'} !important;
padding-left: 4px !important;
}
.cm-insertedChunk {
background-color: ${isDarkMode ? 'rgba(34, 197, 94, 0.15)' : 'rgba(230, 255, 237, 1)'} !important;
border-left: 3px solid ${isDarkMode ? 'rgba(34, 197, 94, 0.6)' : 'rgb(34, 197, 94)'} !important;
padding-left: 4px !important;
}
/* Override linear-gradient underline and use solid darker background for partial changes */
.cm-editor.cm-merge-b .cm-changedText {
background: ${isDarkMode ? 'rgba(34, 197, 94, 0.4)' : 'rgba(34, 197, 94, 0.3)'} !important;
padding-top: 2px !important;
padding-bottom: 2px !important;
margin-top: -2px !important;
margin-bottom: -2px !important;
}
.cm-editor .cm-deletedChunk .cm-changedText {
background: ${isDarkMode ? 'rgba(239, 68, 68, 0.4)' : 'rgba(239, 68, 68, 0.3)'} !important;
padding-top: 2px !important;
padding-bottom: 2px !important;
margin-top: -2px !important;
margin-bottom: -2px !important;
}
/* Minimap gutter styling */
.cm-gutter.cm-gutter-minimap {
background-color: ${isDarkMode ? '#1e1e1e' : '#f5f5f5'};
}
/* Editor toolbar panel styling */
.cm-editor-toolbar-panel {
padding: 4px 10px;
background-color: ${isDarkMode ? '#1f2937' : '#ffffff'};
border-bottom: 1px solid ${isDarkMode ? '#374151' : '#e5e7eb'};
color: ${isDarkMode ? '#d1d5db' : '#374151'};
font-size: 12px;
}
.cm-diff-nav-btn,
.cm-toolbar-btn {
padding: 3px;
background: transparent;
border: none;
cursor: pointer;
border-radius: 4px;
display: inline-flex;
align-items: center;
justify-content: center;
color: inherit;
transition: background-color 0.2s;
}
.cm-diff-nav-btn:hover,
.cm-toolbar-btn:hover {
background-color: ${isDarkMode ? '#374151' : '#f3f4f6'};
}
.cm-diff-nav-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
`}
</style>
<div className={isSidebar ?
'w-full h-full flex flex-col' :
`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' : ''}`}>
<div className={isSidebar ?
'bg-background flex flex-col w-full h-full' :
`bg-background shadow-2xl flex flex-col ${
// Mobile: always fullscreen, Desktop: modal sizing
'w-full h-full md:rounded-lg md:shadow-2xl' +
(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 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="text-sm font-medium text-gray-900 dark:text-white truncate">{file.name}</h3>
{file.diffInfo && (
<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-xs text-gray-500 dark:text-gray-400 truncate">{file.path}</p>
</div>
</div>
<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-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-4 h-4" />
</button>
<button
onClick={handleSave}
disabled={saving}
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
? '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-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-4 h-4" />
)}
</button>
{!isSidebar && (
<button
onClick={toggleFullscreen}
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>
)}
<button
onClick={onClose}
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-4 h-4" />
</button>
</div>
</div>
{/* Editor / Markdown Preview */}
<div className="flex-1 overflow-hidden">
{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 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-xs text-gray-500 dark:text-gray-400">
{t('footer.shortcuts')}
</div>
</div>
</div>
</div>
</>
);
}
export default CodeEditor;

View File

@@ -1,367 +0,0 @@
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 (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 menuRef = useRef(null);
const selectedItemRef = useRef(null);
// Calculate responsive menu positioning.
// Mobile: dock above chat input. Desktop: clamp to viewport.
const getMenuPosition = () => {
const isMobile = window.innerWidth < 640;
const viewportHeight = window.innerHeight;
if (isMobile) {
// 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.
left: '16px',
right: '16px',
width: 'auto',
maxWidth: 'calc(100vw - 32px)',
maxHeight: 'min(50vh, 300px)', // Limit to smaller of 50vh or 300px.
};
}
// 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',
};
};
const menuPosition = getMenuPosition();
// Close menu when clicking outside.
useEffect(() => {
const handleClickOutside = (event) => {
if (menuRef.current && !menuRef.current.contains(event.target) && isOpen) {
onClose();
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}
return undefined;
}, [isOpen, onClose]);
// Keep selected keyboard item visible while navigating.
useEffect(() => {
if (selectedItemRef.current && menuRef.current) {
const menuRect = menuRef.current.getBoundingClientRect();
const itemRect = selectedItemRef.current.getBoundingClientRect();
if (itemRect.bottom > menuRect.bottom) {
selectedItemRef.current.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
} else if (itemRect.top < menuRect.top) {
selectedItemRef.current.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
}
}, [selectedIndex]);
if (!isOpen) {
return null;
}
// Show a message if no commands are available.
if (commands.length === 0) {
return (
<div
ref={menuRef}
className="command-menu command-menu-empty"
style={{
...menuPosition,
maxHeight: '300px',
borderRadius: '8px',
boxShadow: '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
zIndex: 1000,
padding: '20px',
opacity: 1,
transform: 'translateY(0)',
transition: 'opacity 150ms ease-in-out, transform 150ms ease-in-out',
textAlign: 'center',
}}
>
No commands available
</div>
);
}
// Add frequent commands as a special group if provided.
const hasFrequentCommands = frequentCommands.length > 0;
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] = [];
}
groups[namespace].push(command);
return groups;
}, {});
// Add frequent commands as a separate group.
if (hasFrequentCommands) {
groupedCommands.frequent = frequentCommands;
}
// 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 namespaceLabels = {
frequent: '\u2B50 Frequently Used',
builtin: 'Built-in Commands',
project: 'Project Commands',
user: 'User Commands',
other: 'Other Commands',
};
// 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 (
<div
ref={menuRef}
role="listbox"
aria-label="Available commands"
className="command-menu"
style={{
...menuPosition,
maxHeight: '300px',
overflowY: 'auto',
borderRadius: '8px',
boxShadow: '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
zIndex: 1000,
padding: '8px',
opacity: isOpen ? 1 : 0,
transform: isOpen ? 'translateY(0)' : 'translateY(-10px)',
transition: 'opacity 150ms ease-in-out, transform 150ms ease-in-out',
}}
>
{orderedNamespaces.map((namespace) => (
<div key={namespace} className="command-group">
{orderedNamespaces.length > 1 && (
<div
style={{
fontSize: '11px',
fontWeight: 600,
textTransform: 'uppercase',
color: '#6b7280',
padding: '8px 12px 4px',
letterSpacing: '0.05em',
}}
>
{namespaceLabels[namespace] || namespace}
</div>
)}
{groupedCommands[namespace].map((command) => {
const commandKey = getCommandKey(command);
const commandIndex = commandIndexByKey.get(commandKey) ?? -1;
const isSelected = commandIndex === selectedIndex;
return (
<div
key={`${namespace}-${command.name}-${command.path || ''}`}
ref={isSelected ? selectedItemRef : null}
role="option"
aria-selected={isSelected}
className="command-item"
onMouseEnter={() => {
if (onSelect && commandIndex >= 0) {
onSelect(command, commandIndex, true);
}
}}
onClick={() => {
if (onSelect) {
onSelect(command, commandIndex, false);
}
}}
style={{
display: 'flex',
alignItems: 'flex-start',
padding: '10px 12px',
borderRadius: '6px',
cursor: 'pointer',
backgroundColor: isSelected ? '#eff6ff' : 'transparent',
transition: 'background-color 100ms ease-in-out',
marginBottom: '2px',
}}
// Prevent textarea blur when clicking a menu item.
onMouseDown={(e) => e.preventDefault()}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
marginBottom: command.description ? '4px' : 0,
}}
>
{/* Command icon based on namespace */}
<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 */}
<span
style={{
fontWeight: 600,
fontSize: '14px',
color: '#111827',
fontFamily: 'monospace',
}}
>
{command.name}
</span>
{/* Command metadata badge */}
{command.metadata?.type && (
<span
className="command-metadata-badge"
style={{
fontSize: '10px',
padding: '2px 6px',
borderRadius: '4px',
backgroundColor: '#f3f4f6',
color: '#6b7280',
fontWeight: 500,
}}
>
{command.metadata.type}
</span>
)}
</div>
{/* Command description */}
{command.description && (
<div
style={{
fontSize: '13px',
color: '#6b7280',
marginLeft: '24px',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{command.description}
</div>
)}
</div>
{/* Selection indicator */}
{isSelected && (
<span
style={{
marginLeft: '8px',
color: '#3b82f6',
fontSize: '12px',
fontWeight: 600,
}}
>
{'\u21B5'}
</span>
)}
</div>
);
})}
</div>
))}
{/* Default light mode styles */}
<style>{`
.command-menu {
background-color: white;
border: 1px solid #e5e7eb;
}
.command-menu-empty {
color: #6b7280;
}
@media (prefers-color-scheme: dark) {
.command-menu {
background-color: #1f2937 !important;
border: 1px solid #374151 !important;
}
.command-menu-empty {
color: #9ca3af !important;
}
.command-item[aria-selected="true"] {
background-color: #1e40af !important;
}
.command-item span:not(.command-metadata-badge) {
color: #f3f4f6 !important;
}
.command-metadata-badge {
background-color: #f3f4f6 !important;
color: #6b7280 !important;
}
.command-item div {
color: #d1d5db !important;
}
.command-group > div:first-child {
color: #9ca3af !important;
}
}
`}</style>
</div>
);
};
export default CommandMenu;

View File

@@ -1,421 +0,0 @@
import { useState, useEffect } from 'react';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { Key, Plus, Trash2, Eye, EyeOff, Copy, Check, Github, ExternalLink } from 'lucide-react';
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);
const [showNewKeyForm, setShowNewKeyForm] = useState(false);
const [showNewGithubForm, setShowNewGithubForm] = useState(false);
const [newKeyName, setNewKeyName] = useState('');
const [newGithubName, setNewGithubName] = useState('');
const [newGithubToken, setNewGithubToken] = useState('');
const [newGithubDescription, setNewGithubDescription] = useState('');
const [showToken, setShowToken] = useState({});
const [copiedKey, setCopiedKey] = useState(null);
const [newlyCreatedKey, setNewlyCreatedKey] = useState(null);
// Version check hook
const { updateAvailable, latestVersion, releaseInfo } = useVersionCheck('siteboon', 'claudecodeui');
useEffect(() => {
fetchData();
}, []);
const fetchData = async () => {
try {
setLoading(true);
// Fetch API keys
const apiKeysRes = await authenticatedFetch('/api/settings/api-keys');
const apiKeysData = await apiKeysRes.json();
setApiKeys(apiKeysData.apiKeys || []);
// Fetch GitHub credentials only
const credentialsRes = await authenticatedFetch('/api/settings/credentials?type=github_token');
const credentialsData = await credentialsRes.json();
setGithubCredentials(credentialsData.credentials || []);
} catch (error) {
console.error('Error fetching settings:', error);
} finally {
setLoading(false);
}
};
const createApiKey = async () => {
if (!newKeyName.trim()) return;
try {
const res = await authenticatedFetch('/api/settings/api-keys', {
method: 'POST',
body: JSON.stringify({ keyName: newKeyName })
});
const data = await res.json();
if (data.success) {
setNewlyCreatedKey(data.apiKey);
setNewKeyName('');
setShowNewKeyForm(false);
fetchData();
}
} catch (error) {
console.error('Error creating API key:', error);
}
};
const deleteApiKey = async (keyId) => {
if (!confirm(t('apiKeys.confirmDelete'))) return;
try {
await authenticatedFetch(`/api/settings/api-keys/${keyId}`, {
method: 'DELETE'
});
fetchData();
} catch (error) {
console.error('Error deleting API key:', error);
}
};
const toggleApiKey = async (keyId, isActive) => {
try {
await authenticatedFetch(`/api/settings/api-keys/${keyId}/toggle`, {
method: 'PATCH',
body: JSON.stringify({ isActive: !isActive })
});
fetchData();
} catch (error) {
console.error('Error toggling API key:', error);
}
};
const createGithubCredential = async () => {
if (!newGithubName.trim() || !newGithubToken.trim()) return;
try {
const res = await authenticatedFetch('/api/settings/credentials', {
method: 'POST',
body: JSON.stringify({
credentialName: newGithubName,
credentialType: 'github_token',
credentialValue: newGithubToken,
description: newGithubDescription
})
});
const data = await res.json();
if (data.success) {
setNewGithubName('');
setNewGithubToken('');
setNewGithubDescription('');
setShowNewGithubForm(false);
fetchData();
}
} catch (error) {
console.error('Error creating GitHub credential:', error);
}
};
const deleteGithubCredential = async (credentialId) => {
if (!confirm(t('apiKeys.github.confirmDelete'))) return;
try {
await authenticatedFetch(`/api/settings/credentials/${credentialId}`, {
method: 'DELETE'
});
fetchData();
} catch (error) {
console.error('Error deleting GitHub credential:', error);
}
};
const toggleGithubCredential = async (credentialId, isActive) => {
try {
await authenticatedFetch(`/api/settings/credentials/${credentialId}/toggle`, {
method: 'PATCH',
body: JSON.stringify({ isActive: !isActive })
});
fetchData();
} catch (error) {
console.error('Error toggling GitHub credential:', error);
}
};
const copyToClipboard = (text, id) => {
navigator.clipboard.writeText(text);
setCopiedKey(id);
setTimeout(() => setCopiedKey(null), 2000);
};
if (loading) {
return <div className="text-muted-foreground">{t('apiKeys.loading')}</div>;
}
return (
<div className="space-y-8">
{/* 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">{t('apiKeys.newKey.alertTitle')}</h4>
<p className="text-sm text-muted-foreground mb-3">
{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">
{newlyCreatedKey.apiKey}
</code>
<Button
size="sm"
variant="outline"
onClick={() => copyToClipboard(newlyCreatedKey.apiKey, 'new')}
>
{copiedKey === 'new' ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
<Button
size="sm"
variant="ghost"
className="mt-3"
onClick={() => setNewlyCreatedKey(null)}
>
{t('apiKeys.newKey.iveSavedIt')}
</Button>
</div>
)}
{/* API Keys Section */}
<div>
<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">{t('apiKeys.title')}</h3>
</div>
<Button
size="sm"
onClick={() => setShowNewKeyForm(!showNewKeyForm)}
>
<Plus className="h-4 w-4 mr-1" />
{t('apiKeys.newButton')}
</Button>
</div>
<div className="mb-4">
<p className="text-sm text-muted-foreground mb-2">
{t('apiKeys.description')}
</p>
<a
href="/api-docs.html"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-primary hover:underline inline-flex items-center gap-1"
>
{t('apiKeys.apiDocsLink')}
<ExternalLink className="h-3 w-3" />
</a>
</div>
{showNewKeyForm && (
<div className="mb-4 p-4 border rounded-lg bg-card">
<Input
placeholder={t('apiKeys.form.placeholder')}
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
className="mb-2"
/>
<div className="flex gap-2">
<Button onClick={createApiKey}>{t('apiKeys.form.createButton')}</Button>
<Button variant="outline" onClick={() => setShowNewKeyForm(false)}>
{t('apiKeys.form.cancelButton')}
</Button>
</div>
</div>
)}
<div className="space-y-2">
{apiKeys.length === 0 ? (
<p className="text-sm text-muted-foreground italic">{t('apiKeys.empty')}</p>
) : (
apiKeys.map((key) => (
<div
key={key.id}
className="flex items-center justify-between p-3 border rounded-lg"
>
<div className="flex-1">
<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">
{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">
<Button
size="sm"
variant={key.is_active ? 'outline' : 'secondary'}
onClick={() => toggleApiKey(key.id, key.is_active)}
>
{key.is_active ? t('apiKeys.status.active') : t('apiKeys.status.inactive')}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => deleteApiKey(key.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))
)}
</div>
</div>
{/* GitHub Credentials Section */}
<div>
<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">{t('apiKeys.github.title')}</h3>
</div>
<Button
size="sm"
onClick={() => setShowNewGithubForm(!showNewGithubForm)}
>
<Plus className="h-4 w-4 mr-1" />
{t('apiKeys.github.addButton')}
</Button>
</div>
<p className="text-sm text-muted-foreground mb-4">
{t('apiKeys.github.descriptionAlt')}
</p>
{showNewGithubForm && (
<div className="mb-4 p-4 border rounded-lg bg-card space-y-3">
<Input
placeholder={t('apiKeys.github.form.namePlaceholder')}
value={newGithubName}
onChange={(e) => setNewGithubName(e.target.value)}
/>
<div className="relative">
<Input
type={showToken['new'] ? 'text' : 'password'}
placeholder={t('apiKeys.github.form.tokenPlaceholder')}
value={newGithubToken}
onChange={(e) => setNewGithubToken(e.target.value)}
className="pr-10"
/>
<button
type="button"
onClick={() => setShowToken({ ...showToken, new: !showToken['new'] })}
className="absolute right-3 top-2.5 text-muted-foreground hover:text-foreground"
>
{showToken['new'] ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
<Input
placeholder={t('apiKeys.github.form.descriptionPlaceholder')}
value={newGithubDescription}
onChange={(e) => setNewGithubDescription(e.target.value)}
/>
<div className="flex gap-2">
<Button onClick={createGithubCredential}>{t('apiKeys.github.form.addButton')}</Button>
<Button variant="outline" onClick={() => {
setShowNewGithubForm(false);
setNewGithubName('');
setNewGithubToken('');
setNewGithubDescription('');
}}>
{t('apiKeys.github.form.cancelButton')}
</Button>
</div>
<a
href="https://github.com/settings/tokens"
target="_blank"
rel="noopener noreferrer"
className="text-xs text-primary hover:underline block"
>
{t('apiKeys.github.form.howToCreate')}
</a>
</div>
)}
<div className="space-y-2">
{githubCredentials.length === 0 ? (
<p className="text-sm text-muted-foreground italic">{t('apiKeys.github.empty')}</p>
) : (
githubCredentials.map((credential) => (
<div
key={credential.id}
className="flex items-center justify-between p-3 border rounded-lg"
>
<div className="flex-1">
<div className="font-medium">{credential.credential_name}</div>
{credential.description && (
<div className="text-xs text-muted-foreground">{credential.description}</div>
)}
<div className="text-xs text-muted-foreground mt-1">
{t('apiKeys.github.added')} {new Date(credential.created_at).toLocaleDateString()}
</div>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
variant={credential.is_active ? 'outline' : 'secondary'}
onClick={() => toggleGithubCredential(credential.id, credential.is_active)}
>
{credential.is_active ? t('apiKeys.status.active') : t('apiKeys.status.inactive')}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => deleteGithubCredential(credential.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))
)}
</div>
</div>
{/* Version Information */}
<div className="pt-6 border-t border-border/50">
<div className="flex items-center justify-between text-xs italic text-muted-foreground/60">
<a
href={releaseInfo?.htmlUrl || 'https://github.com/siteboon/claudecodeui/releases'}
target="_blank"
rel="noopener noreferrer"
className="hover:text-muted-foreground transition-colors"
>
v{version}
</a>
{updateAvailable && latestVersion && (
<a
href={releaseInfo?.htmlUrl || 'https://github.com/siteboon/claudecodeui/releases'}
target="_blank"
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]">{t('apiKeys.version.updateAvailable', { version: latestVersion })}</span>
<ExternalLink className="h-2.5 w-2.5" />
</a>
)}
</div>
</div>
</div>
);
}
export default CredentialsSettings;

View File

@@ -1,35 +0,0 @@
import React from 'react';
import { useTheme } from '../contexts/ThemeContext';
function DarkModeToggle() {
const { isDarkMode, toggleDarkMode } = useTheme();
return (
<button
onClick={toggleDarkMode}
className="relative inline-flex h-8 w-14 items-center rounded-full bg-gray-200 dark:bg-gray-700 transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
role="switch"
aria-checked={isDarkMode}
aria-label="Toggle dark mode"
>
<span className="sr-only">Toggle dark mode</span>
<span
className={`${
isDarkMode ? 'translate-x-7' : 'translate-x-1'
} inline-block h-6 w-6 transform rounded-full bg-white shadow-lg transition-transform duration-200 flex items-center justify-center`}
>
{isDarkMode ? (
<svg className="w-3.5 h-3.5 text-gray-700" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
) : (
<svg className="w-3.5 h-3.5 text-yellow-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
)}
</span>
</button>
);
}
export default DarkModeToggle;

View File

@@ -0,0 +1,48 @@
import { Moon, Sun } from 'lucide-react';
import { useTheme } from '../contexts/ThemeContext';
type DarkModeToggleProps = {
checked?: boolean;
onToggle?: (nextValue: boolean) => void;
ariaLabel?: string;
};
function DarkModeToggle({ checked, onToggle, ariaLabel = 'Toggle dark mode' }: DarkModeToggleProps) {
const { isDarkMode, toggleDarkMode } = useTheme();
const isControlled = typeof checked === 'boolean' && typeof onToggle === 'function';
const isEnabled = isControlled ? checked : isDarkMode;
const handleToggle = () => {
if (isControlled) {
onToggle(!isEnabled);
return;
}
toggleDarkMode();
};
return (
<button
onClick={handleToggle}
className="relative inline-flex h-8 w-14 items-center rounded-full bg-gray-200 dark:bg-gray-700 transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
role="switch"
aria-checked={isEnabled}
aria-label={ariaLabel}
>
<span className="sr-only">{ariaLabel}</span>
<span
className={`${
isEnabled ? 'translate-x-7' : 'translate-x-1'
} h-6 w-6 transform rounded-full bg-white shadow-lg transition-transform duration-200 flex items-center justify-center`}
>
{isEnabled ? (
<Moon className="h-3.5 w-3.5 text-gray-700" />
) : (
<Sun className="h-3.5 w-3.5 text-yellow-500" />
)}
</span>
</button>
);
}
export default DarkModeToggle;

View File

@@ -1,73 +1,77 @@
import React from 'react';
import React, { useCallback, useState } from 'react';
import { ErrorBoundary as ReactErrorBoundary } from 'react-error-boundary';
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null, errorInfo: null };
}
static getDerivedStateFromError(error) {
// Update state so the next render will show the fallback UI
return { hasError: true };
}
componentDidCatch(error, errorInfo) {
// Log the error details
console.error('ErrorBoundary caught an error:', error, errorInfo);
// You can also log the error to an error reporting service here
this.setState({
error: error,
errorInfo: errorInfo
});
}
render() {
if (this.state.hasError) {
// Fallback UI
return (
<div className="flex flex-col items-center justify-center p-8 text-center">
<div className="bg-red-50 border border-red-200 rounded-lg p-6 max-w-md">
<div className="flex items-center mb-4">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
</div>
<h3 className="ml-3 text-sm font-medium text-red-800">
Something went wrong
</h3>
</div>
<div className="text-sm text-red-700">
<p className="mb-2">An error occurred while loading the chat interface.</p>
{this.props.showDetails && this.state.error && (
<details className="mt-4">
<summary className="cursor-pointer text-xs font-mono">Error Details</summary>
<pre className="mt-2 text-xs bg-red-100 p-2 rounded overflow-auto max-h-40">
{this.state.error.toString()}
{this.state.errorInfo && this.state.errorInfo.componentStack}
</pre>
</details>
)}
</div>
<div className="mt-4">
<button
onClick={() => {
this.setState({ hasError: false, error: null, errorInfo: null });
if (this.props.onRetry) this.props.onRetry();
}}
className="bg-red-600 text-white px-4 py-2 rounded text-sm hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500"
>
Try Again
</button>
</div>
function ErrorFallback({ error, resetErrorBoundary, showDetails, componentStack }) {
return (
<div className="flex flex-col items-center justify-center p-8 text-center">
<div className="bg-red-50 border border-red-200 rounded-lg p-6 max-w-md">
<div className="flex items-center mb-4">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
</div>
<h3 className="ml-3 text-sm font-medium text-red-800">
Something went wrong
</h3>
</div>
);
}
return this.props.children;
}
<div className="text-sm text-red-700">
<p className="mb-2">An error occurred while loading the chat interface.</p>
{showDetails && error && (
<details className="mt-4">
<summary className="cursor-pointer text-xs font-mono">Error Details</summary>
<pre className="mt-2 text-xs bg-red-100 p-2 rounded overflow-auto max-h-40">
{error.toString()}
{componentStack}
</pre>
</details>
)}
</div>
<div className="mt-4">
<button
onClick={resetErrorBoundary}
className="bg-red-600 text-white px-4 py-2 rounded text-sm hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500"
>
Try Again
</button>
</div>
</div>
</div>
);
}
export default ErrorBoundary;
function ErrorBoundary({ children, showDetails = false, onRetry = undefined, resetKeys = undefined }) {
const [componentStack, setComponentStack] = useState(null);
const handleError = useCallback((error, errorInfo) => {
console.error('ErrorBoundary caught an error:', error, errorInfo);
setComponentStack(errorInfo?.componentStack || null);
}, []);
const handleReset = useCallback(() => {
setComponentStack(null);
onRetry?.();
}, [onRetry]);
const renderFallback = useCallback(({ error, resetErrorBoundary }) => (
<ErrorFallback
error={error}
resetErrorBoundary={resetErrorBoundary}
showDetails={showDetails}
componentStack={componentStack}
/>
), [showDetails, componentStack]);
return (
<ReactErrorBoundary
fallbackRender={renderFallback}
onError={handleError}
onReset={handleReset}
resetKeys={resetKeys}
>
{children}
</ReactErrorBoundary>
);
}
export default ErrorBoundary;

View File

@@ -0,0 +1,312 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import {
FileText,
FolderPlus,
Pencil,
Trash2,
Copy,
Download,
RefreshCw
} from 'lucide-react';
import { cn } from '../lib/utils';
/**
* FileContextMenu Component
* Right-click context menu for file/directory operations
*/
const FileContextMenu = ({
children,
item,
onRename,
onDelete,
onNewFile,
onNewFolder,
onRefresh,
onCopyPath,
onDownload,
isLoading = false,
className = ''
}) => {
const { t } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
const [position, setPosition] = useState({ x: 0, y: 0 });
const menuRef = useRef(null);
const triggerRef = useRef(null);
const isDirectory = item?.type === 'directory';
const isFile = item?.type === 'file';
const isBackground = !item; // Clicked on empty space
// Handle right-click
const handleContextMenu = useCallback((e) => {
e.preventDefault();
e.stopPropagation();
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX;
const y = e.clientY;
// Adjust position if menu would go off screen
const menuWidth = 200;
const menuHeight = 300;
let adjustedX = x;
let adjustedY = y;
if (x + menuWidth > window.innerWidth) {
adjustedX = window.innerWidth - menuWidth - 10;
}
if (y + menuHeight > window.innerHeight) {
adjustedY = window.innerHeight - menuHeight - 10;
}
setPosition({ x: adjustedX, y: adjustedY });
setIsOpen(true);
}, []);
// Close menu
const closeMenu = useCallback(() => {
setIsOpen(false);
}, []);
// Close on click outside
useEffect(() => {
const handleClickOutside = (e) => {
if (menuRef.current && !menuRef.current.contains(e.target)) {
closeMenu();
}
};
const handleEscape = (e) => {
if (e.key === 'Escape') {
closeMenu();
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('keydown', handleEscape);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('keydown', handleEscape);
};
}, [isOpen, closeMenu]);
// Handle keyboard navigation
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e) => {
const menuItems = menuRef.current?.querySelectorAll('[role="menuitem"]');
if (!menuItems || menuItems.length === 0) return;
const currentIndex = Array.from(menuItems).findIndex(
(item) => item === document.activeElement
);
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
const nextIndex = currentIndex < menuItems.length - 1 ? currentIndex + 1 : 0;
menuItems[nextIndex]?.focus();
break;
case 'ArrowUp':
e.preventDefault();
const prevIndex = currentIndex > 0 ? currentIndex - 1 : menuItems.length - 1;
menuItems[prevIndex]?.focus();
break;
case 'Enter':
case ' ':
if (document.activeElement?.hasAttribute('role', 'menuitem')) {
e.preventDefault();
document.activeElement.click();
}
break;
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen]);
// Handle action click
const handleAction = (action, ...args) => {
closeMenu();
action?.(...args);
};
// Menu item component
const MenuItem = ({ icon: Icon, label, onClick, danger = false, disabled = false, shortcut }) => (
<button
role="menuitem"
tabIndex={disabled ? -1 : 0}
disabled={disabled || isLoading}
onClick={() => handleAction(onClick)}
className={cn(
'w-full flex items-center gap-3 px-3 py-2 text-sm text-left rounded-md transition-colors',
'focus:outline-none focus:bg-accent',
disabled
? 'opacity-50 cursor-not-allowed'
: danger
? 'text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950'
: 'hover:bg-accent',
isLoading && 'pointer-events-none'
)}
>
{Icon && <Icon className="w-4 h-4 flex-shrink-0" />}
<span className="flex-1">{label}</span>
{shortcut && (
<span className="text-xs text-muted-foreground font-mono">{shortcut}</span>
)}
</button>
);
// Menu divider
const MenuDivider = () => (
<div className="h-px bg-border my-1 mx-2" />
);
// Build menu items based on context
const renderMenuItems = () => {
if (isFile) {
return (
<>
<MenuItem
icon={Pencil}
label={t('fileTree.context.rename', 'Rename')}
onClick={() => onRename?.(item)}
/>
<MenuItem
icon={Trash2}
label={t('fileTree.context.delete', 'Delete')}
onClick={() => onDelete?.(item)}
danger
/>
<MenuDivider />
<MenuItem
icon={Copy}
label={t('fileTree.context.copyPath', 'Copy Path')}
onClick={() => onCopyPath?.(item)}
/>
<MenuItem
icon={Download}
label={t('fileTree.context.download', 'Download')}
onClick={() => onDownload?.(item)}
/>
</>
);
}
if (isDirectory) {
return (
<>
<MenuItem
icon={FileText}
label={t('fileTree.context.newFile', 'New File')}
onClick={() => onNewFile?.(item.path)}
/>
<MenuItem
icon={FolderPlus}
label={t('fileTree.context.newFolder', 'New Folder')}
onClick={() => onNewFolder?.(item.path)}
/>
<MenuDivider />
<MenuItem
icon={Pencil}
label={t('fileTree.context.rename', 'Rename')}
onClick={() => onRename?.(item)}
/>
<MenuItem
icon={Trash2}
label={t('fileTree.context.delete', 'Delete')}
onClick={() => onDelete?.(item)}
danger
/>
<MenuDivider />
<MenuItem
icon={Copy}
label={t('fileTree.context.copyPath', 'Copy Path')}
onClick={() => onCopyPath?.(item)}
/>
<MenuItem
icon={Download}
label={t('fileTree.context.download', 'Download')}
onClick={() => onDownload?.(item)}
/>
</>
);
}
// Background context (empty space)
return (
<>
<MenuItem
icon={FileText}
label={t('fileTree.context.newFile', 'New File')}
onClick={() => onNewFile?.('')}
/>
<MenuItem
icon={FolderPlus}
label={t('fileTree.context.newFolder', 'New Folder')}
onClick={() => onNewFolder?.('')}
/>
<MenuDivider />
<MenuItem
icon={RefreshCw}
label={t('fileTree.context.refresh', 'Refresh')}
onClick={onRefresh}
/>
</>
);
};
return (
<>
{/* Trigger element */}
<div
ref={triggerRef}
onContextMenu={handleContextMenu}
className={cn('contents', className)}
>
{children}
</div>
{/* Context menu portal */}
{isOpen && (
<div
ref={menuRef}
role="menu"
aria-label={t('fileTree.context.menuLabel', 'File context menu')}
style={{
position: 'fixed',
left: position.x,
top: position.y,
zIndex: 9999
}}
className={cn(
'min-w-[180px] py-1 px-1',
'bg-popover border border-border rounded-lg shadow-lg',
'animate-in fade-in-0 zoom-in-95',
'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95'
)}
>
{isLoading ? (
<div className="flex items-center justify-center py-4">
<RefreshCw className="w-4 h-4 animate-spin text-muted-foreground" />
<span className="ml-2 text-sm text-muted-foreground">
{t('fileTree.context.loading', 'Loading...')}
</span>
</div>
) : (
renderMenuItems()
)}
</div>
)}
</>
);
};
export default FileContextMenu;

View File

@@ -1,729 +0,0 @@
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,
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 ImageViewer from './ImageViewer';
import { api } from '../utils/api';
// ─── 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 [selectedImage, setSelectedImage] = useState(null);
const [viewMode, setViewMode] = useState('detailed');
const [searchQuery, setSearchQuery] = useState('');
const [filteredFiles, setFilteredFiles] = useState([]);
useEffect(() => {
if (selectedProject) {
fetchFiles();
}
}, [selectedProject]);
useEffect(() => {
const savedViewMode = localStorage.getItem('file-tree-view-mode');
if (savedViewMode && ['simple', 'detailed', 'compact'].includes(savedViewMode)) {
setViewMode(savedViewMode);
}
}, []);
useEffect(() => {
if (!searchQuery.trim()) {
setFilteredFiles(files);
} else {
const filtered = filterFiles(files, searchQuery.toLowerCase());
setFilteredFiles(filtered);
const expandMatches = (items) => {
items.forEach(item => {
if (item.type === 'directory' && item.children && item.children.length > 0) {
setExpandedDirs(prev => new Set(prev.add(item.path)));
expandMatches(item.children);
}
});
};
expandMatches(filtered);
}
}, [files, searchQuery]);
const filterFiles = (items, query) => {
return items.reduce((filtered, item) => {
const matchesName = item.name.toLowerCase().includes(query);
let filteredChildren = [];
if (item.type === 'directory' && item.children) {
filteredChildren = filterFiles(item.children, query);
}
if (matchesName || filteredChildren.length > 0) {
filtered.push({
...item,
children: filteredChildren
});
}
return filtered;
}, []);
};
const fetchFiles = async () => {
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) {
console.error('❌ Error fetching files:', error);
setFiles([]);
} finally {
setLoading(false);
}
};
const toggleDirectory = (path) => {
const newExpanded = new Set(expandedDirs);
if (newExpanded.has(path)) {
newExpanded.delete(path);
} else {
newExpanded.add(path);
}
setExpandedDirs(newExpanded);
};
const changeViewMode = (mode) => {
setViewMode(mode);
localStorage.setItem('file-tree-view-mode', mode);
};
const formatFileSize = (bytes) => {
if (!bytes || bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
};
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 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) => {
const ext = filename.split('.').pop()?.toLowerCase();
const imageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'bmp'];
return imageExtensions.includes(ext);
};
const getFileIcon = (filename) => {
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);
}
};
// ── 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) => {
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',
)}
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-sm text-muted-foreground tabular-nums">
{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 || ''}
</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>
);
});
};
// ─── Compact View ──────────────────────────────────────────────────
const renderCompactView = (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 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-sm 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>
)}
</div>
);
});
};
// ─── Loading state ─────────────────────────────────────────────────
if (loading) {
return (
<div className="h-full flex items-center justify-center">
<div className="text-muted-foreground text-sm">
{t('fileTree.loading')}
</div>
</div>
);
}
// ─── Main render ───────────────────────────────────────────────────
return (
<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">
{t('fileTree.files')}
</h3>
<div className="flex gap-0.5">
<Button
variant={viewMode === 'simple' ? 'default' : 'ghost'}
size="sm"
className="h-7 w-7 p-0"
onClick={() => changeViewMode('simple')}
title={t('fileTree.simpleView')}
>
<List className="w-3.5 h-3.5" />
</Button>
<Button
variant={viewMode === 'compact' ? 'default' : 'ghost'}
size="sm"
className="h-7 w-7 p-0"
onClick={() => changeViewMode('compact')}
title={t('fileTree.compactView')}
>
<Eye className="w-3.5 h-3.5" />
</Button>
<Button
variant={viewMode === 'detailed' ? 'default' : 'ghost'}
size="sm"
className="h-7 w-7 p-0"
onClick={() => changeViewMode('detailed')}
title={t('fileTree.detailedView')}
>
<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-3.5 h-3.5 text-muted-foreground" />
<Input
type="text"
placeholder={t('fileTree.searchPlaceholder')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-8 pr-8 h-8 text-sm"
/>
{searchQuery && (
<Button
variant="ghost"
size="sm"
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={t('fileTree.clearSearch')}
>
<X className="w-3 h-3" />
</Button>
)}
</div>
</div>
{/* Column Headers for Detailed View */}
{viewMode === 'detailed' && filteredFiles.length > 0 && (
<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 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">{t('fileTree.noFilesFound')}</h4>
<p className="text-sm text-muted-foreground">
{t('fileTree.checkProjectPath')}
</p>
</div>
) : filteredFiles.length === 0 && searchQuery ? (
<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">
<Search className="w-6 h-6 text-muted-foreground" />
</div>
<h4 className="font-medium text-foreground mb-1">{t('fileTree.noMatchesFound')}</h4>
<p className="text-sm text-muted-foreground">
{t('fileTree.tryDifferentSearch')}
</p>
</div>
) : (
<div>
{viewMode === 'simple' && renderFileTree(filteredFiles)}
{viewMode === 'compact' && renderCompactView(filteredFiles)}
{viewMode === 'detailed' && renderDetailedView(filteredFiles)}
</div>
)}
</ScrollArea>
{/* Image Viewer Modal */}
{selectedImage && (
<ImageViewer
file={selectedImage}
onClose={() => setSelectedImage(null)}
/>
)}
</div>
);
}
export default FileTree;

View File

@@ -0,0 +1,9 @@
import React from 'react';
const GeminiLogo = ({className = 'w-5 h-5'}) => {
return (
<img src="/icons/gemini-ai-icon.svg" alt="Gemini" className={className} />
);
};
export default GeminiLogo;

View File

@@ -0,0 +1,90 @@
import React, { useState, useEffect } from 'react';
import { cn } from '../lib/utils';
function GeminiStatus({ status, onAbort, isLoading }) {
const [elapsedTime, setElapsedTime] = useState(0);
const [animationPhase, setAnimationPhase] = useState(0);
// Update elapsed time every second
useEffect(() => {
if (!isLoading) {
setElapsedTime(0);
return;
}
const startTime = Date.now();
const timer = setInterval(() => {
const elapsed = Math.floor((Date.now() - startTime) / 1000);
setElapsedTime(elapsed);
}, 1000);
return () => clearInterval(timer);
}, [isLoading]);
// Animate the status indicator
useEffect(() => {
if (!isLoading) return;
const timer = setInterval(() => {
setAnimationPhase(prev => (prev + 1) % 4);
}, 500);
return () => clearInterval(timer);
}, [isLoading]);
if (!isLoading) return null;
// Clever action words that cycle
const actionWords = ['Thinking', 'Processing', 'Analyzing', 'Working', 'Computing', 'Reasoning'];
const actionIndex = Math.floor(elapsedTime / 3) % actionWords.length;
// Parse status data
const statusText = status?.text || actionWords[actionIndex];
const canInterrupt = status?.can_interrupt !== false;
// Animation characters
const spinners = ['✻', '✹', '✸', '✶'];
const currentSpinner = spinners[animationPhase];
return (
<div className="w-full mb-6 animate-in slide-in-from-bottom duration-300">
<div className="flex items-center justify-between max-w-4xl mx-auto bg-gradient-to-r from-cyan-900 to-blue-900 dark:from-cyan-950 dark:to-blue-950 text-white rounded-lg shadow-lg px-4 py-3">
<div className="flex-1">
<div className="flex items-center gap-3">
{/* Animated spinner */}
<span className={cn(
"text-xl transition-all duration-500",
animationPhase % 2 === 0 ? "text-cyan-400 scale-110" : "text-cyan-300"
)}>
{currentSpinner}
</span>
{/* Status text - first line */}
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">{statusText}...</span>
<span className="text-gray-400 text-sm">({elapsedTime}s)</span>
</div>
</div>
</div>
</div>
{/* Interrupt button */}
{canInterrupt && onAbort && (
<button
type="button"
onClick={onAbort}
className="ml-3 text-xs bg-red-600 hover:bg-red-700 text-white px-2.5 py-1 sm:px-3 sm:py-1.5 rounded-md transition-colors flex items-center gap-1.5 flex-shrink-0"
>
<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 className="hidden sm:inline">Stop</span>
</button>
)}
</div>
</div>
);
}
export default GeminiStatus;

File diff suppressed because it is too large Load Diff

View File

@@ -1,131 +0,0 @@
import { useState, useEffect } from 'react';
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);
const [gitConfigSaving, setGitConfigSaving] = useState(false);
const [saveStatus, setSaveStatus] = useState(null);
useEffect(() => {
loadGitConfig();
}, []);
const loadGitConfig = async () => {
try {
setGitConfigLoading(true);
const response = await authenticatedFetch('/api/user/git-config');
if (response.ok) {
const data = await response.json();
setGitName(data.gitName || '');
setGitEmail(data.gitEmail || '');
}
} catch (error) {
console.error('Error loading git config:', error);
} finally {
setGitConfigLoading(false);
}
};
const saveGitConfig = async () => {
try {
setGitConfigSaving(true);
const response = await authenticatedFetch('/api/user/git-config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ gitName, gitEmail })
});
if (response.ok) {
setSaveStatus('success');
setTimeout(() => setSaveStatus(null), 3000);
} else {
const data = await response.json();
setSaveStatus('error');
console.error('Failed to save git config:', data.error);
}
} catch (error) {
console.error('Error saving git config:', error);
setSaveStatus('error');
} finally {
setGitConfigSaving(false);
}
};
return (
<div className="space-y-8">
<div>
<div className="flex items-center gap-2 mb-4">
<GitBranch className="h-5 w-5" />
<h3 className="text-lg font-semibold">{t('git.title')}</h3>
</div>
<p className="text-sm text-muted-foreground mb-4">
{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">
{t('git.name.label')}
</label>
<Input
id="settings-git-name"
type="text"
value={gitName}
onChange={(e) => setGitName(e.target.value)}
placeholder="John Doe"
disabled={gitConfigLoading}
className="w-full"
/>
<p className="mt-1 text-xs text-muted-foreground">
{t('git.name.help')}
</p>
</div>
<div>
<label htmlFor="settings-git-email" className="block text-sm font-medium text-foreground mb-2">
{t('git.email.label')}
</label>
<Input
id="settings-git-email"
type="email"
value={gitEmail}
onChange={(e) => setGitEmail(e.target.value)}
placeholder="john@example.com"
disabled={gitConfigLoading}
className="w-full"
/>
<p className="mt-1 text-xs text-muted-foreground">
{t('git.email.help')}
</p>
</div>
<div className="flex items-center gap-2">
<Button
onClick={saveGitConfig}
disabled={gitConfigSaving || !gitName || !gitEmail}
>
{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" />
{t('git.status.success')}
</div>
)}
</div>
</div>
</div>
</div>
);
}
export default GitSettings;

View File

@@ -1,14 +1,14 @@
import { X } from 'lucide-react';
import StandaloneShell from './StandaloneShell';
import { X, ExternalLink, KeyRound } from 'lucide-react';
import StandaloneShell from './standalone-shell/view/StandaloneShell';
import { IS_PLATFORM } from '../constants/config';
/**
* Reusable login modal component for Claude, Cursor, and Codex CLI authentication
* Reusable login modal component for Claude, Cursor, Codex, and Gemini CLI authentication
*
* @param {Object} props
* @param {boolean} props.isOpen - Whether the modal is visible
* @param {Function} props.onClose - Callback when modal is closed
* @param {'claude'|'cursor'|'codex'} props.provider - Which CLI provider to authenticate with
* @param {'claude'|'cursor'|'codex'|'gemini'} props.provider - Which CLI provider to authenticate with
* @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
@@ -36,6 +36,9 @@ function LoginModal({
return 'cursor-agent login';
case 'codex':
return IS_PLATFORM ? 'codex login --device-auth' : 'codex login';
case 'gemini':
// No explicit interactive login command for gemini CLI exists yet similar to Claude, so we'll just check status or instruct the user to configure `.gemini.json`
return 'gemini status';
default:
return isAuthenticated ? 'claude setup-token --dangerously-skip-permissions' : isOnboarding ? 'claude /exit --dangerously-skip-permissions' : 'claude /login --dangerously-skip-permissions';
}
@@ -49,6 +52,8 @@ function LoginModal({
return 'Cursor CLI Login';
case 'codex':
return 'Codex CLI Login';
case 'gemini':
return 'Gemini CLI Configuration';
default:
return 'CLI Login';
}
@@ -77,12 +82,68 @@ function LoginModal({
</button>
</div>
<div className="flex-1 overflow-hidden">
<StandaloneShell
project={project}
command={getCommand()}
onComplete={handleComplete}
minimal={true}
/>
{provider === 'gemini' ? (
<div className="flex flex-col items-center justify-center h-full p-8 text-center bg-gray-50 dark:bg-gray-900/50">
<div className="w-16 h-16 bg-blue-100 dark:bg-blue-900/30 rounded-full flex items-center justify-center mb-6">
<KeyRound className="w-8 h-8 text-blue-600 dark:text-blue-400" />
</div>
<h4 className="text-xl font-medium text-gray-900 dark:text-white mb-3">
Setup Gemini API Access
</h4>
<p className="text-gray-600 dark:text-gray-400 max-w-md mb-8">
The Gemini CLI requires an API key to function. Unlike Claude, you'll need to configure this directly in your terminal first.
</p>
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl p-6 max-w-lg w-full text-left shadow-sm">
<ol className="space-y-4">
<li className="flex gap-4">
<div className="flex-shrink-0 w-6 h-6 rounded-full bg-blue-100 dark:bg-blue-900/50 text-blue-600 dark:text-blue-400 flex items-center justify-center text-sm font-medium">
1
</div>
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white mb-1">Get your API Key</p>
<a
href="https://aistudio.google.com/app/apikey"
target="_blank"
rel="noreferrer"
className="text-sm text-blue-600 dark:text-blue-400 hover:underline flex items-center gap-1 inline-flex"
>
Google AI Studio <ExternalLink className="w-3 h-3" />
</a>
</div>
</li>
<li className="flex gap-4">
<div className="flex-shrink-0 w-6 h-6 rounded-full bg-blue-100 dark:bg-blue-900/50 text-blue-600 dark:text-blue-400 flex items-center justify-center text-sm font-medium">
2
</div>
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white mb-1">Run configuration</p>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">Open your terminal and run:</p>
<code className="block bg-gray-100 dark:bg-gray-900 px-3 py-2 rounded text-sm text-pink-600 dark:text-pink-400 font-mono">
gemini config set api_key YOUR_KEY
</code>
</div>
</li>
</ol>
</div>
<button
onClick={onClose}
className="mt-8 px-6 py-2.5 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors"
>
Done
</button>
</div>
) : (
<StandaloneShell
project={project}
command={getCommand()}
onComplete={handleComplete}
minimal={true}
/>
)}
</div>
</div>
</div>

View File

@@ -1,272 +0,0 @@
import React, { useState, useEffect, useRef } from 'react';
import { Mic, Loader2, Brain } from 'lucide-react';
import { transcribeWithWhisper } from '../utils/whisper';
export function MicButton({ onTranscript, className = '' }) {
const [state, setState] = useState('idle'); // idle, recording, transcribing, processing
const [error, setError] = useState(null);
const [isSupported, setIsSupported] = useState(true);
const mediaRecorderRef = useRef(null);
const streamRef = useRef(null);
const chunksRef = useRef([]);
const lastTapRef = useRef(0);
// Check microphone support on mount
useEffect(() => {
const checkSupport = () => {
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
setIsSupported(false);
setError('Microphone not supported. Please use HTTPS or a modern browser.');
return;
}
// Additional check for secure context
if (location.protocol !== 'https:' && location.hostname !== 'localhost') {
setIsSupported(false);
setError('Microphone requires HTTPS. Please use a secure connection.');
return;
}
setIsSupported(true);
setError(null);
};
checkSupport();
}, []);
// Start recording
const startRecording = async () => {
try {
console.log('Starting recording...');
setError(null);
chunksRef.current = [];
// Check if getUserMedia is available
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
throw new Error('Microphone access not available. Please use HTTPS or a supported browser.');
}
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
streamRef.current = stream;
const mimeType = MediaRecorder.isTypeSupported('audio/webm') ? 'audio/webm' : 'audio/mp4';
const recorder = new MediaRecorder(stream, { mimeType });
mediaRecorderRef.current = recorder;
recorder.ondataavailable = (e) => {
if (e.data.size > 0) {
chunksRef.current.push(e.data);
}
};
recorder.onstop = async () => {
console.log('Recording stopped, creating blob...');
const blob = new Blob(chunksRef.current, { type: mimeType });
// Clean up stream
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop());
streamRef.current = null;
}
// Start transcribing
setState('transcribing');
// Check if we're in an enhancement mode
const whisperMode = window.localStorage.getItem('whisperMode') || 'default';
const isEnhancementMode = whisperMode === 'prompt' || whisperMode === 'vibe' || whisperMode === 'instructions' || whisperMode === 'architect';
// Set up a timer to switch to processing state for enhancement modes
let processingTimer;
if (isEnhancementMode) {
processingTimer = setTimeout(() => {
setState('processing');
}, 2000); // Switch to processing after 2 seconds
}
try {
const text = await transcribeWithWhisper(blob);
if (text && onTranscript) {
onTranscript(text);
}
} catch (err) {
console.error('Transcription error:', err);
setError(err.message);
} finally {
if (processingTimer) {
clearTimeout(processingTimer);
}
setState('idle');
}
};
recorder.start();
setState('recording');
console.log('Recording started successfully');
} catch (err) {
console.error('Failed to start recording:', err);
// Provide specific error messages based on error type
let errorMessage = 'Microphone access failed';
if (err.name === 'NotAllowedError') {
errorMessage = 'Microphone access denied. Please allow microphone permissions.';
} else if (err.name === 'NotFoundError') {
errorMessage = 'No microphone found. Please check your audio devices.';
} else if (err.name === 'NotSupportedError') {
errorMessage = 'Microphone not supported by this browser.';
} else if (err.name === 'NotReadableError') {
errorMessage = 'Microphone is being used by another application.';
} else if (err.message.includes('HTTPS')) {
errorMessage = err.message;
}
setError(errorMessage);
setState('idle');
}
};
// Stop recording
const stopRecording = () => {
console.log('Stopping recording...');
if (mediaRecorderRef.current && mediaRecorderRef.current.state === 'recording') {
mediaRecorderRef.current.stop();
// Don't set state here - let the onstop handler do it
} else {
// If recorder isn't in recording state, force cleanup
console.log('Recorder not in recording state, forcing cleanup');
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop());
streamRef.current = null;
}
setState('idle');
}
};
// Handle button click
const handleClick = (e) => {
// Prevent double firing on mobile
if (e) {
e.preventDefault();
e.stopPropagation();
}
// Don't proceed if microphone is not supported
if (!isSupported) {
return;
}
// Debounce for mobile double-tap issue
const now = Date.now();
if (now - lastTapRef.current < 300) {
console.log('Ignoring rapid tap');
return;
}
lastTapRef.current = now;
console.log('Button clicked, current state:', state);
if (state === 'idle') {
startRecording();
} else if (state === 'recording') {
stopRecording();
}
// Do nothing if transcribing or processing
};
// Clean up on unmount
useEffect(() => {
return () => {
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop());
}
};
}, []);
// Button appearance based on state
const getButtonAppearance = () => {
if (!isSupported) {
return {
icon: <Mic className="w-5 h-5" />,
className: 'bg-gray-400 cursor-not-allowed',
disabled: true
};
}
switch (state) {
case 'recording':
return {
icon: <Mic className="w-5 h-5 text-white" />,
className: 'bg-red-500 hover:bg-red-600 animate-pulse',
disabled: false
};
case 'transcribing':
return {
icon: <Loader2 className="w-5 h-5 animate-spin" />,
className: 'bg-blue-500 hover:bg-blue-600',
disabled: true
};
case 'processing':
return {
icon: <Brain className="w-5 h-5 animate-pulse" />,
className: 'bg-purple-500 hover:bg-purple-600',
disabled: true
};
default: // idle
return {
icon: <Mic className="w-5 h-5" />,
className: 'bg-gray-700 hover:bg-gray-600',
disabled: false
};
}
};
const { icon, className: buttonClass, disabled } = getButtonAppearance();
return (
<div className="relative">
<button
type="button"
style={{
backgroundColor: state === 'recording' ? '#ef4444' :
state === 'transcribing' ? '#3b82f6' :
state === 'processing' ? '#a855f7' :
'#374151'
}}
className={`
flex items-center justify-center
w-12 h-12 rounded-full
text-white transition-all duration-200
focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500
dark:ring-offset-gray-800
touch-action-manipulation
${disabled ? 'cursor-not-allowed opacity-75' : 'cursor-pointer'}
${state === 'recording' ? 'animate-pulse' : ''}
hover:opacity-90
${className}
`}
onClick={handleClick}
disabled={disabled}
>
{icon}
</button>
{error && (
<div className="absolute top-full mt-2 left-1/2 transform -translate-x-1/2
bg-red-500 text-white text-xs px-2 py-1 rounded whitespace-nowrap z-10
animate-fade-in">
{error}
</div>
)}
{state === 'recording' && (
<div className="absolute -inset-1 rounded-full border-2 border-red-500 animate-ping pointer-events-none" />
)}
{state === 'processing' && (
<div className="absolute -inset-1 rounded-full border-2 border-purple-500 animate-ping pointer-events-none" />
)}
</div>
);
}

View File

@@ -3,7 +3,7 @@ import { ArrowRight, List, Clock, Flag, CheckCircle, Circle, AlertCircle, Pause,
import { cn } from '../lib/utils';
import { useTaskMaster } from '../contexts/TaskMasterContext';
import { api } from '../utils/api';
import Shell from './Shell';
import Shell from './shell/view/Shell';
import TaskDetail from './TaskDetail';
const NextTaskBanner = ({ onShowAllTasks, onStartTask, className = '' }) => {

View File

@@ -1,8 +1,6 @@
import React, { useState, useEffect, useRef } from 'react';
import { ChevronRight, ChevronLeft, Check, GitBranch, User, Mail, LogIn, ExternalLink, Loader2 } from 'lucide-react';
import ClaudeLogo from './ClaudeLogo';
import CursorLogo from './CursorLogo';
import CodexLogo from './CodexLogo';
import SessionProviderLogo from './llm-logo-provider/SessionProviderLogo';
import LoginModal from './LoginModal';
import { authenticatedFetch } from '../utils/api';
import { useAuth } from '../contexts/AuthContext';
@@ -39,6 +37,13 @@ const Onboarding = ({ onComplete }) => {
error: null
});
const [geminiAuthStatus, setGeminiAuthStatus] = useState({
authenticated: false,
email: null,
loading: true,
error: null
});
const { user } = useAuth();
const prevActiveLoginProviderRef = useRef(undefined);
@@ -71,22 +76,23 @@ const Onboarding = ({ onComplete }) => {
checkClaudeAuthStatus();
checkCursorAuthStatus();
checkCodexAuthStatus();
checkGeminiAuthStatus();
}
}, [activeLoginProvider]);
const checkClaudeAuthStatus = async () => {
const checkProviderAuthStatus = async (provider, setter) => {
try {
const response = await authenticatedFetch('/api/cli/claude/status');
const response = await authenticatedFetch(`/api/cli/${provider}/status`);
if (response.ok) {
const data = await response.json();
setClaudeAuthStatus({
setter({
authenticated: data.authenticated,
email: data.email,
loading: false,
error: data.error || null
});
} else {
setClaudeAuthStatus({
setter({
authenticated: false,
email: null,
loading: false,
@@ -94,8 +100,8 @@ const Onboarding = ({ onComplete }) => {
});
}
} catch (error) {
console.error('Error checking Claude auth status:', error);
setClaudeAuthStatus({
console.error(`Error checking ${provider} auth status:`, error);
setter({
authenticated: false,
email: null,
loading: false,
@@ -104,69 +110,15 @@ const Onboarding = ({ onComplete }) => {
}
};
const checkCursorAuthStatus = async () => {
try {
const response = await authenticatedFetch('/api/cli/cursor/status');
if (response.ok) {
const data = await response.json();
setCursorAuthStatus({
authenticated: data.authenticated,
email: data.email,
loading: false,
error: data.error || null
});
} else {
setCursorAuthStatus({
authenticated: false,
email: null,
loading: false,
error: 'Failed to check authentication status'
});
}
} catch (error) {
console.error('Error checking Cursor auth status:', error);
setCursorAuthStatus({
authenticated: false,
email: null,
loading: false,
error: error.message
});
}
};
const checkCodexAuthStatus = async () => {
try {
const response = await authenticatedFetch('/api/cli/codex/status');
if (response.ok) {
const data = await response.json();
setCodexAuthStatus({
authenticated: data.authenticated,
email: data.email,
loading: false,
error: data.error || null
});
} else {
setCodexAuthStatus({
authenticated: false,
email: null,
loading: false,
error: 'Failed to check authentication status'
});
}
} catch (error) {
console.error('Error checking Codex auth status:', error);
setCodexAuthStatus({
authenticated: false,
email: null,
loading: false,
error: error.message
});
}
};
const checkClaudeAuthStatus = () => checkProviderAuthStatus('claude', setClaudeAuthStatus);
const checkCursorAuthStatus = () => checkProviderAuthStatus('cursor', setCursorAuthStatus);
const checkCodexAuthStatus = () => checkProviderAuthStatus('codex', setCodexAuthStatus);
const checkGeminiAuthStatus = () => checkProviderAuthStatus('gemini', setGeminiAuthStatus);
const handleClaudeLogin = () => setActiveLoginProvider('claude');
const handleCursorLogin = () => setActiveLoginProvider('cursor');
const handleCodexLogin = () => setActiveLoginProvider('codex');
const handleGeminiLogin = () => setActiveLoginProvider('gemini');
const handleLoginComplete = (exitCode) => {
if (exitCode === 0) {
@@ -176,6 +128,8 @@ const Onboarding = ({ onComplete }) => {
checkCursorAuthStatus();
} else if (activeLoginProvider === 'codex') {
checkCodexAuthStatus();
} else if (activeLoginProvider === 'gemini') {
checkGeminiAuthStatus();
}
}
};
@@ -339,15 +293,14 @@ const Onboarding = ({ onComplete }) => {
{/* Agent Cards Grid */}
<div className="space-y-3">
{/* Claude */}
<div className={`border rounded-lg p-4 transition-colors ${
claudeAuthStatus.authenticated
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800'
: 'border-border bg-card'
}`}>
<div className={`border rounded-lg p-4 transition-colors ${claudeAuthStatus.authenticated
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800'
: 'border-border bg-card'
}`}>
<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-full flex items-center justify-center">
<ClaudeLogo size={20} />
<SessionProviderLogo provider="claude" className="w-5 h-5" />
</div>
<div>
<div className="font-medium text-foreground flex items-center gap-2">
@@ -356,7 +309,7 @@ const Onboarding = ({ onComplete }) => {
</div>
<div className="text-xs text-muted-foreground">
{claudeAuthStatus.loading ? 'Checking...' :
claudeAuthStatus.authenticated ? claudeAuthStatus.email || 'Connected' : 'Not connected'}
claudeAuthStatus.authenticated ? claudeAuthStatus.email || 'Connected' : 'Not connected'}
</div>
</div>
</div>
@@ -372,15 +325,14 @@ const Onboarding = ({ onComplete }) => {
</div>
{/* Cursor */}
<div className={`border rounded-lg p-4 transition-colors ${
cursorAuthStatus.authenticated
? 'bg-purple-50 dark:bg-purple-900/20 border-purple-200 dark:border-purple-800'
: 'border-border bg-card'
}`}>
<div className={`border rounded-lg p-4 transition-colors ${cursorAuthStatus.authenticated
? 'bg-purple-50 dark:bg-purple-900/20 border-purple-200 dark:border-purple-800'
: 'border-border bg-card'
}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-purple-100 dark:bg-purple-900/30 rounded-full flex items-center justify-center">
<CursorLogo size={20} />
<SessionProviderLogo provider="cursor" className="w-5 h-5" />
</div>
<div>
<div className="font-medium text-foreground flex items-center gap-2">
@@ -389,7 +341,7 @@ const Onboarding = ({ onComplete }) => {
</div>
<div className="text-xs text-muted-foreground">
{cursorAuthStatus.loading ? 'Checking...' :
cursorAuthStatus.authenticated ? cursorAuthStatus.email || 'Connected' : 'Not connected'}
cursorAuthStatus.authenticated ? cursorAuthStatus.email || 'Connected' : 'Not connected'}
</div>
</div>
</div>
@@ -405,15 +357,14 @@ const Onboarding = ({ onComplete }) => {
</div>
{/* Codex */}
<div className={`border rounded-lg p-4 transition-colors ${
codexAuthStatus.authenticated
? 'bg-gray-100 dark:bg-gray-800/50 border-gray-300 dark:border-gray-600'
: 'border-border bg-card'
}`}>
<div className={`border rounded-lg p-4 transition-colors ${codexAuthStatus.authenticated
? 'bg-gray-100 dark:bg-gray-800/50 border-gray-300 dark:border-gray-600'
: 'border-border bg-card'
}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center">
<CodexLogo className="w-5 h-5" />
<SessionProviderLogo provider="codex" className="w-5 h-5" />
</div>
<div>
<div className="font-medium text-foreground flex items-center gap-2">
@@ -422,7 +373,7 @@ const Onboarding = ({ onComplete }) => {
</div>
<div className="text-xs text-muted-foreground">
{codexAuthStatus.loading ? 'Checking...' :
codexAuthStatus.authenticated ? codexAuthStatus.email || 'Connected' : 'Not connected'}
codexAuthStatus.authenticated ? codexAuthStatus.email || 'Connected' : 'Not connected'}
</div>
</div>
</div>
@@ -436,6 +387,38 @@ const Onboarding = ({ onComplete }) => {
)}
</div>
</div>
{/* Gemini */}
<div className={`border rounded-lg p-4 transition-colors ${geminiAuthStatus.authenticated
? 'bg-teal-50 dark:bg-teal-900/20 border-teal-200 dark:border-teal-800'
: 'border-border bg-card'
}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-teal-100 dark:bg-teal-900/30 rounded-full flex items-center justify-center">
<SessionProviderLogo provider="gemini" className="w-5 h-5 text-teal-600 dark:text-teal-400" />
</div>
<div>
<div className="font-medium text-foreground flex items-center gap-2">
Gemini
{geminiAuthStatus.authenticated && <Check className="w-4 h-4 text-green-500" />}
</div>
<div className="text-xs text-muted-foreground">
{geminiAuthStatus.loading ? 'Checking...' :
geminiAuthStatus.authenticated ? geminiAuthStatus.email || 'Connected' : 'Not connected'}
</div>
</div>
</div>
{!geminiAuthStatus.authenticated && !geminiAuthStatus.loading && (
<button
onClick={handleGeminiLogin}
className="bg-teal-600 hover:bg-teal-700 text-white text-sm font-medium py-2 px-4 rounded-lg transition-colors"
>
Login
</button>
)}
</div>
</div>
</div>
<div className="text-center text-sm text-muted-foreground pt-2">
@@ -454,7 +437,7 @@ const Onboarding = ({ onComplete }) => {
case 0:
return gitName.trim() && gitEmail.trim() && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(gitEmail);
case 1:
return true;
return true;
default:
return false;
}
@@ -470,11 +453,10 @@ const Onboarding = ({ onComplete }) => {
{steps.map((step, index) => (
<React.Fragment key={index}>
<div className="flex flex-col items-center flex-1">
<div className={`w-12 h-12 rounded-full flex items-center justify-center border-2 transition-colors duration-200 ${
index < currentStep ? 'bg-green-500 border-green-500 text-white' :
<div className={`w-12 h-12 rounded-full flex items-center justify-center border-2 transition-colors duration-200 ${index < currentStep ? 'bg-green-500 border-green-500 text-white' :
index === currentStep ? 'bg-blue-600 border-blue-600 text-white' :
'bg-background border-border text-muted-foreground'
}`}>
'bg-background border-border text-muted-foreground'
}`}>
{index < currentStep ? (
<Check className="w-6 h-6" />
) : typeof step.icon === 'function' ? (
@@ -484,9 +466,8 @@ const Onboarding = ({ onComplete }) => {
)}
</div>
<div className="mt-2 text-center">
<p className={`text-sm font-medium ${
index === currentStep ? 'text-foreground' : 'text-muted-foreground'
}`}>
<p className={`text-sm font-medium ${index === currentStep ? 'text-foreground' : 'text-muted-foreground'
}`}>
{step.title}
</p>
{step.required && (
@@ -495,9 +476,8 @@ const Onboarding = ({ onComplete }) => {
</div>
</div>
{index < steps.length - 1 && (
<div className={`flex-1 h-0.5 mx-2 transition-colors duration-200 ${
index < currentStep ? 'bg-green-500' : 'bg-border'
}`} />
<div className={`flex-1 h-0.5 mx-2 transition-colors duration-200 ${index < currentStep ? 'bg-green-500' : 'bg-border'
}`} />
)}
</React.Fragment>
))}

File diff suppressed because it is too large Load Diff

View File

@@ -1,692 +0,0 @@
import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react';
import { Terminal } from '@xterm/xterm';
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 {
outline: none !important;
}
.xterm:focus .xterm-screen {
outline: none !important;
}
.xterm-screen:focus {
outline: none !important;
}
`;
if (typeof document !== 'undefined') {
const styleSheet = document.createElement('style');
styleSheet.type = 'text/css';
styleSheet.innerText = xtermStyles;
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);
const ws = useRef(null);
const [isConnected, setIsConnected] = useState(false);
const [isInitialized, setIsInitialized] = useState(false);
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;
selectedSessionRef.current = selectedSession;
initialCommandRef.current = initialCommand;
isPlainShellRef.current = 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 {
let wsUrl;
if (IS_PLATFORM) {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
wsUrl = `${protocol}//${window.location.host}/shell`;
} else {
const token = localStorage.getItem('auth-token');
if (!token) {
console.error('No authentication token found for Shell WebSocket connection');
return;
}
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
wsUrl = `${protocol}//${window.location.host}/shell?token=${encodeURIComponent(token)}`;
}
ws.current = new WebSocket(wsUrl);
ws.current.onopen = () => {
setIsConnected(true);
setIsConnecting(false);
authUrlRef.current = '';
setAuthUrl('');
setAuthUrlCopyStatus('idle');
setIsAuthPanelHidden(false);
setTimeout(() => {
if (fitAddon.current && terminal.current) {
fitAddon.current.fit();
ws.current.send(JSON.stringify({
type: 'init',
projectPath: selectedProjectRef.current.fullPath || selectedProjectRef.current.path,
sessionId: isPlainShellRef.current ? null : selectedSessionRef.current?.id,
hasSession: isPlainShellRef.current ? false : !!selectedSessionRef.current,
provider: isPlainShellRef.current ? 'plain-shell' : (selectedSessionRef.current?.__provider || 'claude'),
cols: terminal.current.cols,
rows: terminal.current.rows,
initialCommand: initialCommandRef.current,
isPlainShell: isPlainShellRef.current
}));
}
}, 100);
};
ws.current.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'output') {
let output = data.data;
if (isPlainShellRef.current && onProcessCompleteRef.current) {
const cleanOutput = output.replace(/\x1b\[[0-9;]*m/g, '');
if (cleanOutput.includes('Process exited with code 0')) {
onProcessCompleteRef.current(0);
} else if (cleanOutput.match(/Process exited with code (\d+)/)) {
const exitCode = parseInt(cleanOutput.match(/Process exited with code (\d+)/)[1]);
if (exitCode !== 0) {
onProcessCompleteRef.current(exitCode);
}
}
}
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') {
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);
}
};
ws.current.onclose = (event) => {
setIsConnected(false);
setIsConnecting(false);
setAuthUrlCopyStatus('idle');
setIsAuthPanelHidden(false);
if (terminal.current) {
terminal.current.clear();
terminal.current.write('\x1b[2J\x1b[H');
}
};
ws.current.onerror = (error) => {
setIsConnected(false);
setIsConnecting(false);
};
} catch (error) {
setIsConnected(false);
setIsConnecting(false);
}
}, [isConnecting, isConnected, openAuthUrlInBrowser]);
const connectToShell = useCallback(() => {
if (!isInitialized || isConnected || isConnecting) return;
setIsConnecting(true);
connectWebSocket();
}, [isInitialized, isConnected, isConnecting, connectWebSocket]);
const disconnectFromShell = useCallback(() => {
if (ws.current) {
ws.current.close();
ws.current = null;
}
if (terminal.current) {
terminal.current.clear();
terminal.current.write('\x1b[2J\x1b[H');
}
setIsConnected(false);
setIsConnecting(false);
authUrlRef.current = '';
setAuthUrl('');
setAuthUrlCopyStatus('idle');
setIsAuthPanelHidden(false);
}, []);
const sessionDisplayName = useMemo(() => {
if (!selectedSession) return null;
return selectedSession.__provider === 'cursor'
? (selectedSession.name || 'Untitled Session')
: (selectedSession.summary || 'New Session');
}, [selectedSession]);
const sessionDisplayNameShort = useMemo(() => {
if (!sessionDisplayName) return null;
return sessionDisplayName.slice(0, 30);
}, [sessionDisplayName]);
const sessionDisplayNameLong = useMemo(() => {
if (!sessionDisplayName) return null;
return sessionDisplayName.slice(0, 50);
}, [sessionDisplayName]);
const restartShell = () => {
setIsRestarting(true);
if (ws.current) {
ws.current.close();
ws.current = null;
}
if (terminal.current) {
terminal.current.dispose();
terminal.current = null;
fitAddon.current = null;
}
setIsConnected(false);
setIsInitialized(false);
authUrlRef.current = '';
setAuthUrl('');
setAuthUrlCopyStatus('idle');
setIsAuthPanelHidden(false);
setTimeout(() => {
setIsRestarting(false);
}, 200);
};
useEffect(() => {
const currentSessionId = selectedSession?.id || null;
if (lastSessionId !== null && lastSessionId !== currentSessionId && isInitialized) {
disconnectFromShell();
}
setLastSessionId(currentSessionId);
}, [selectedSession?.id, isInitialized, disconnectFromShell]);
useEffect(() => {
if (!terminalRef.current || !selectedProject || isRestarting || terminal.current) {
return;
}
terminal.current = new Terminal({
cursorBlink: true,
fontSize: 14,
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
allowProposedApi: true,
allowTransparency: false,
convertEol: true,
scrollback: 10000,
tabStopWidth: 4,
windowsMode: false,
macOptionIsMeta: true,
macOptionClickForcesSelection: true,
theme: {
background: '#1e1e1e',
foreground: '#d4d4d4',
cursor: '#ffffff',
cursorAccent: '#1e1e1e',
selection: '#264f78',
selectionForeground: '#ffffff',
black: '#000000',
red: '#cd3131',
green: '#0dbc79',
yellow: '#e5e510',
blue: '#2472c8',
magenta: '#bc3fbc',
cyan: '#11a8cd',
white: '#e5e5e5',
brightBlack: '#666666',
brightRed: '#f14c4c',
brightGreen: '#23d18b',
brightYellow: '#f5f543',
brightBlue: '#3b8eea',
brightMagenta: '#d670d6',
brightCyan: '#29b8db',
brightWhite: '#ffffff',
extendedAnsi: [
'#000000', '#800000', '#008000', '#808000',
'#000080', '#800080', '#008080', '#c0c0c0',
'#808080', '#ff0000', '#00ff00', '#ffff00',
'#0000ff', '#ff00ff', '#00ffff', '#ffffff'
]
}
});
fitAddon.current = new FitAddon();
const webglAddon = new WebglAddon();
const webLinksAddon = new WebLinksAddon();
terminal.current.loadAddon(fitAddon.current);
// 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 {
terminal.current.loadAddon(webglAddon);
} catch (error) {
console.warn('[Shell] WebGL renderer unavailable, using Canvas fallback');
}
terminal.current.open(terminalRef.current);
terminal.current.attachCustomKeyEventHandler((event) => {
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.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({
type: 'input',
data: text
}));
}
}).catch(() => {});
return false;
}
return true;
});
setTimeout(() => {
if (fitAddon.current) {
fitAddon.current.fit();
if (terminal.current && ws.current && ws.current.readyState === WebSocket.OPEN) {
ws.current.send(JSON.stringify({
type: 'resize',
cols: terminal.current.cols,
rows: terminal.current.rows
}));
}
}
}, 100);
setIsInitialized(true);
terminal.current.onData((data) => {
if (ws.current && ws.current.readyState === WebSocket.OPEN) {
ws.current.send(JSON.stringify({
type: 'input',
data: data
}));
}
});
const resizeObserver = new ResizeObserver(() => {
if (fitAddon.current && terminal.current) {
setTimeout(() => {
fitAddon.current.fit();
if (ws.current && ws.current.readyState === WebSocket.OPEN) {
ws.current.send(JSON.stringify({
type: 'resize',
cols: terminal.current.cols,
rows: terminal.current.rows
}));
}
}, 50);
}
});
if (terminalRef.current) {
resizeObserver.observe(terminalRef.current);
}
return () => {
resizeObserver.disconnect();
if (ws.current && (ws.current.readyState === WebSocket.OPEN || ws.current.readyState === WebSocket.CONNECTING)) {
ws.current.close();
}
ws.current = null;
if (terminal.current) {
terminal.current.dispose();
terminal.current = null;
}
};
}, [selectedProject?.path || selectedProject?.fullPath, isRestarting, minimal, copyAuthUrlToClipboard]);
useEffect(() => {
if (!autoConnect || !isInitialized || isConnecting || isConnected) return;
connectToShell();
}, [autoConnect, isInitialized, isConnecting, isConnected, connectToShell]);
if (!selectedProject) {
return (
<div className="h-full flex items-center justify-center">
<div className="text-center text-gray-500 dark:text-gray-400">
<div className="w-16 h-16 mx-auto mb-4 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="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">{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 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>
);
}
return (
<div className="h-full flex flex-col bg-gray-900 w-full">
<div className="flex-shrink-0 bg-gray-800 border-b border-gray-700 px-4 py-2">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div className={`w-2 h-2 rounded-full ${isConnected ? 'bg-green-500' : 'bg-red-500'}`} />
{selectedSession && (
<span className="text-xs text-blue-300">
({sessionDisplayNameShort}...)
</span>
)}
{!selectedSession && (
<span className="text-xs text-gray-400">{t('shell.status.newSession')}</span>
)}
{!isInitialized && (
<span className="text-xs text-yellow-400">{t('shell.status.initializing')}</span>
)}
{isRestarting && (
<span className="text-xs text-blue-400">{t('shell.status.restarting')}</span>
)}
</div>
<div className="flex items-center space-x-3">
{isConnected && (
<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={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>{t('shell.actions.disconnect')}</span>
</button>
)}
<button
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={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>{t('shell.actions.restart')}</span>
</button>
</div>
</div>
</div>
<div className="flex-1 p-2 overflow-hidden relative">
<div ref={terminalRef} className="h-full w-full focus:outline-none" style={{ outline: 'none' }} />
{!isInitialized && (
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-90">
<div className="text-white">{t('shell.loading')}</div>
</div>
)}
{isInitialized && !isConnected && !isConnecting && (
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-90 p-4">
<div className="text-center max-w-sm w-full">
<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={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>{t('shell.actions.connect')}</span>
</button>
<p className="text-gray-400 text-sm mt-3 px-2">
{isPlainShell ?
t('shell.runCommand', { command: initialCommand || t('shell.defaultCommand'), projectName: selectedProject.displayName }) :
selectedSession ?
t('shell.resumeSession', { displayName: sessionDisplayNameLong }) :
t('shell.startSession')
}
</p>
</div>
</div>
)}
{isConnecting && (
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-90 p-4">
<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">{t('shell.connecting')}</span>
</div>
<p className="text-gray-400 text-sm mt-3 px-2">
{isPlainShell ?
t('shell.runCommand', { command: initialCommand || t('shell.defaultCommand'), projectName: selectedProject.displayName }) :
t('shell.startCli', { projectName: selectedProject.displayName })
}
</p>
</div>
</div>
)}
</div>
</div>
);
}
export default Shell;

View File

@@ -1,105 +0,0 @@
import React, { useState, useCallback } from 'react';
import Shell from './Shell.jsx';
/**
* Generic Shell wrapper that can be used in tabs, modals, and other contexts.
* Provides a flexible API for both standalone and session-based usage.
*
* @param {Object} project - Project object with name, fullPath/path, displayName
* @param {Object} session - Session object (optional, for tab usage)
* @param {string} command - Initial command to run (optional)
* @param {boolean} isPlainShell - Use plain shell mode vs Claude CLI (default: auto-detect)
* @param {boolean} autoConnect - Whether to auto-connect when mounted (default: true)
* @param {function} onComplete - Callback when process completes (receives exitCode)
* @param {function} onClose - Callback for close button (optional)
* @param {string} title - Custom header title (optional)
* @param {string} className - Additional CSS classes
* @param {boolean} showHeader - Whether to show custom header (default: true)
* @param {boolean} compact - Use compact layout (default: false)
* @param {boolean} minimal - Use minimal mode: no header, no overlays, auto-connect (default: false)
*/
function StandaloneShell({
project,
session = null,
command = null,
isPlainShell = null,
autoConnect = true,
onComplete = null,
onClose = null,
title = null,
className = "",
showHeader = true,
compact = false,
minimal = false
}) {
const [isCompleted, setIsCompleted] = useState(false);
const shouldUsePlainShell = isPlainShell !== null ? isPlainShell : (command !== null);
const handleProcessComplete = useCallback((exitCode) => {
setIsCompleted(true);
if (onComplete) {
onComplete(exitCode);
}
}, [onComplete]);
if (!project) {
return (
<div className={`h-full flex items-center justify-center ${className}`}>
<div className="text-center text-gray-500 dark:text-gray-400">
<div className="w-16 h-16 mx-auto mb-4 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="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 002 2z" />
</svg>
</div>
<h3 className="text-lg font-semibold mb-2">No Project Selected</h3>
<p>A project is required to open a shell</p>
</div>
</div>
);
}
return (
<div className={`h-full w-full flex flex-col ${className}`}>
{/* Optional custom header */}
{!minimal && showHeader && title && (
<div className="flex-shrink-0 bg-gray-800 border-b border-gray-700 px-4 py-2">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<h3 className="text-sm font-medium text-gray-200">{title}</h3>
{isCompleted && (
<span className="text-xs text-green-400">(Completed)</span>
)}
</div>
{onClose && (
<button
onClick={onClose}
className="text-gray-400 hover:text-white"
title="Close"
>
<svg className="w-4 h-4" 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>
</div>
)}
{/* Shell component wrapper */}
<div className="flex-1 w-full min-h-0">
<Shell
selectedProject={project}
selectedSession={session}
initialCommand={command}
isPlainShell={shouldUsePlainShell}
onProcessComplete={handleProcessComplete}
minimal={minimal}
autoConnect={minimal ? true : autoConnect}
/>
</div>
</div>
);
}
export default StandaloneShell;

View File

@@ -4,6 +4,7 @@ import { cn } from '../lib/utils';
import TaskIndicator from './TaskIndicator';
import { api } from '../utils/api';
import { useTaskMaster } from '../contexts/TaskMasterContext';
import { copyTextToClipboard } from '../utils/clipboard';
const TaskDetail = ({
task,
@@ -79,7 +80,7 @@ const TaskDetail = ({
};
const copyTaskId = () => {
navigator.clipboard.writeText(task.id.toString());
copyTextToClipboard(task.id.toString());
};
const getStatusConfig = (status) => {

View File

@@ -1,10 +1,10 @@
import React, { useState, useMemo, useEffect } from 'react';
import React, { useState, useMemo, useEffect, useRef } from 'react';
import { Search, Filter, ArrowUpDown, ArrowUp, ArrowDown, List, Grid, ChevronDown, Columns, Plus, Settings, Terminal, FileText, HelpCircle, X } from 'lucide-react';
import { cn } from '../lib/utils';
import TaskCard from './TaskCard';
import CreateTaskModal from './CreateTaskModal';
import { useTaskMaster } from '../contexts/TaskMasterContext';
import Shell from './Shell';
import Shell from './shell/view/Shell';
import { api } from '../utils/api';
import { useTranslation } from 'react-i18next';
@@ -32,6 +32,7 @@ const TaskList = ({
const [showHelpGuide, setShowHelpGuide] = useState(false);
const [isTaskMasterComplete, setIsTaskMasterComplete] = useState(false);
const [showPRDDropdown, setShowPRDDropdown] = useState(false);
const dropdownRef = useRef(null);
const { projectTaskMaster, refreshProjects, refreshTasks, setCurrentProject } = useTaskMaster();
const { t } = useTranslation('tasks');
@@ -39,7 +40,11 @@ const TaskList = ({
// Close PRD dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event) => {
if (showPRDDropdown && !event.target.closest('.relative')) {
if (
showPRDDropdown &&
dropdownRef.current &&
!dropdownRef.current.contains(event.target)
) {
setShowPRDDropdown(false);
}
};
@@ -48,6 +53,31 @@ const TaskList = ({
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [showPRDDropdown]);
const loadPRDOptions = async (prd, closeDropdown = false) => {
if (!currentProject) {
return;
}
try {
const response = await api.get(`/taskmaster/prd/${encodeURIComponent(currentProject.name)}/${encodeURIComponent(prd.name)}`);
if (response.ok) {
const prdData = await response.json();
onShowPRDEditor?.({
name: prd.name,
content: prdData.content,
isExisting: true
});
if (closeDropdown) {
setShowPRDDropdown(false);
}
} else {
console.error('Failed to load PRD:', response.statusText);
}
} catch (error) {
console.error('Error loading PRD:', error);
}
};
// Get unique status values from tasks
const statuses = useMemo(() => {
const statusSet = new Set(tasks.map(task => task.status).filter(Boolean));
@@ -309,23 +339,8 @@ const TaskList = ({
{existingPRDs.map((prd) => (
<button
key={prd.name}
onClick={async () => {
try {
// Load the PRD content from the API
const response = await api.get(`/taskmaster/prd/${encodeURIComponent(currentProject.name)}/${encodeURIComponent(prd.name)}`);
if (response.ok) {
const prdData = await response.json();
onShowPRDEditor?.({
name: prd.name,
content: prdData.content,
isExisting: true
});
} else {
console.error('Failed to load PRD:', response.statusText);
}
} catch (error) {
console.error('Error loading PRD:', error);
}
onClick={() => {
void loadPRDOptions(prd);
}}
className="inline-flex items-center gap-1 text-xs bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 px-2 py-1 rounded hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
>
@@ -589,7 +604,7 @@ const TaskList = ({
</button>
{/* PRD Management */}
<div className="relative">
<div ref={dropdownRef} className="relative">
{existingPRDs.length > 0 ? (
// Dropdown when PRDs exist
<div className="relative">
@@ -624,21 +639,8 @@ const TaskList = ({
{existingPRDs.map((prd) => (
<button
key={prd.name}
onClick={async () => {
try {
const response = await api.get(`/taskmaster/prd/${encodeURIComponent(currentProject.name)}/${encodeURIComponent(prd.name)}`);
if (response.ok) {
const prdData = await response.json();
onShowPRDEditor?.({
name: prd.name,
content: prdData.content,
isExisting: true
});
setShowPRDDropdown(false);
}
} catch (error) {
console.error('Error loading PRD:', error);
}
onClick={() => {
void loadPRDOptions(prd, true);
}}
className="w-full text-left px-3 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded flex items-center gap-2"
title={t('prd.modified', { date: new Date(prd.modified).toLocaleDateString() })}
@@ -1050,4 +1052,4 @@ const TaskList = ({
);
};
export default TaskList;
export default TaskList;

View File

@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
import { X, ChevronRight, ChevronLeft, CheckCircle, AlertCircle, Settings, Server, FileText, Sparkles, ExternalLink, Copy } from 'lucide-react';
import { cn } from '../lib/utils';
import { api } from '../utils/api';
import { copyTextToClipboard } from '../utils/clipboard';
const TaskMasterSetupWizard = ({
isOpen = true,
@@ -175,7 +176,7 @@ const TaskMasterSetupWizard = ({
}
}
}`;
navigator.clipboard.writeText(mcpConfig);
copyTextToClipboard(mcpConfig);
};
const renderStepContent = () => {

View File

@@ -1,109 +1,3 @@
import { Zap } from 'lucide-react';
import { useTasksSettings } from '../contexts/TasksSettingsContext';
import { useTranslation } from 'react-i18next';
import TasksSettingsTab from './settings/view/tabs/tasks-settings/TasksSettingsTab';
function TasksSettings() {
const { t } = useTranslation('settings');
const {
tasksEnabled,
setTasksEnabled,
isTaskMasterInstalled,
isCheckingInstallation
} = useTasksSettings();
return (
<div className="space-y-8">
{/* Installation Status Check */}
{isCheckingInstallation ? (
<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">{t('tasks.checking')}</span>
</div>
</div>
) : (
<>
{/* TaskMaster Not Installed Warning */}
{!isTaskMasterInstalled && (
<div className="bg-orange-50 dark:bg-orange-950/50 border border-orange-200 dark:border-orange-800 rounded-lg p-4">
<div className="flex items-start gap-3">
<div className="w-8 h-8 bg-orange-100 dark:bg-orange-900 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5">
<svg className="w-4 h-4 text-orange-600 dark:text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
</div>
<div className="flex-1">
<div className="font-medium text-orange-900 dark:text-orange-100 mb-2">
{t('tasks.notInstalled.title')}
</div>
<div className="text-sm text-orange-800 dark:text-orange-200 space-y-3">
<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>{t('tasks.notInstalled.installCommand')}</code>
</div>
<div>
<a
href="https://github.com/eyaltoledano/claude-task-master"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 font-medium text-sm"
>
<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>
{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>
</a>
</div>
<div className="space-y-2">
<p className="font-medium">{t('tasks.notInstalled.afterInstallation')}</p>
<ol className="list-decimal list-inside space-y-1 text-xs">
<li>{t('tasks.notInstalled.steps.restart')}</li>
<li>{t('tasks.notInstalled.steps.autoAvailable')}</li>
<li>{t('tasks.notInstalled.steps.initCommand')}</li>
</ol>
</div>
</div>
</div>
</div>
</div>
)}
{/* TaskMaster Settings */}
{isTaskMasterInstalled && (
<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">
{t('tasks.settings.enableLabel')}
</div>
<div className="text-sm text-muted-foreground mt-1">
{t('tasks.settings.enableDescription')}
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={tasksEnabled}
onChange={(e) => setTasksEnabled(e.target.checked)}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
</label>
</div>
</div>
</div>
)}
</>
)}
</div>
);
}
export default TasksSettings;
export default TasksSettingsTab;

View File

@@ -106,7 +106,7 @@ export default function AppContent() {
</div>
)}
<div className={`flex-1 flex flex-col min-w-0 ${isMobile && !isInputFocused ? 'pb-mobile-nav' : ''}`}>
<div className={`flex-1 flex flex-col min-w-0 ${isMobile ? 'pb-mobile-nav' : ''}`}>
<MainContent
selectedProject={selectedProject}
selectedSession={selectedSession}

View File

@@ -41,12 +41,14 @@ interface UseChatComposerStateArgs {
cursorModel: string;
claudeModel: string;
codexModel: string;
geminiModel: string;
isLoading: boolean;
canAbortSession: boolean;
tokenBudget: Record<string, unknown> | null;
sendMessage: (message: unknown) => void;
sendByCtrlEnter?: boolean;
onSessionActive?: (sessionId?: string | null) => void;
onSessionProcessing?: (sessionId?: string | null) => void;
onInputFocusChange?: (focused: boolean) => void;
onFileOpen?: (filePath: string, diffInfo?: unknown) => void;
onShowSettings?: () => void;
@@ -92,12 +94,14 @@ export function useChatComposerState({
cursorModel,
claudeModel,
codexModel,
geminiModel,
isLoading,
canAbortSession,
tokenBudget,
sendMessage,
sendByCtrlEnter,
onSessionActive,
onSessionProcessing,
onInputFocusChange,
onFileOpen,
onShowSettings,
@@ -271,13 +275,14 @@ export function useChatComposerState({
}, [setChatMessages]);
const executeCommand = useCallback(
async (command: SlashCommand) => {
async (command: SlashCommand, rawInput?: string) => {
if (!command || !selectedProject) {
return;
}
try {
const commandMatch = input.match(new RegExp(`${escapeRegExp(command.name)}\\s*(.*)`));
const effectiveInput = rawInput ?? input;
const commandMatch = effectiveInput.match(new RegExp(`${escapeRegExp(command.name)}\\s*(.*)`));
const args =
commandMatch && commandMatch[1] ? commandMatch[1].trim().split(/\s+/) : [];
@@ -286,7 +291,7 @@ export function useChatComposerState({
projectName: selectedProject.name,
sessionId: currentSessionId,
provider,
model: provider === 'cursor' ? cursorModel : provider === 'codex' ? codexModel : claudeModel,
model: provider === 'cursor' ? cursorModel : provider === 'codex' ? codexModel : provider === 'gemini' ? geminiModel : claudeModel,
tokenUsage: tokenBudget,
};
@@ -340,6 +345,7 @@ export function useChatComposerState({
codexModel,
currentSessionId,
cursorModel,
geminiModel,
handleBuiltInCommand,
handleCustomCommand,
input,
@@ -351,6 +357,7 @@ export function useChatComposerState({
);
const {
slashCommands,
slashCommandsCount,
filteredCommands,
frequentCommands,
@@ -473,6 +480,28 @@ export function useChatComposerState({
return;
}
// Intercept slash commands: if input starts with /commandName, execute as command with args
const trimmedInput = currentInput.trim();
if (trimmedInput.startsWith('/')) {
const firstSpace = trimmedInput.indexOf(' ');
const commandName = firstSpace > 0 ? trimmedInput.slice(0, firstSpace) : trimmedInput;
const matchedCommand = slashCommands.find((cmd: SlashCommand) => cmd.name === commandName);
if (matchedCommand) {
executeCommand(matchedCommand, trimmedInput);
setInput('');
inputValueRef.current = '';
setAttachedImages([]);
setUploadingImages(new Map());
setImageErrors(new Map());
resetCommandMenuState();
setIsTextareaExpanded(false);
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
}
return;
}
}
let messageContent = currentInput;
const selectedThinkingMode = thinkingModes.find((mode: { id: string; prefix?: string }) => mode.id === thinkingMode);
if (selectedThinkingMode && selectedThinkingMode.prefix) {
@@ -545,6 +574,9 @@ export function useChatComposerState({
pendingViewSessionRef.current = { sessionId: null, startedAt: Date.now() };
}
onSessionActive?.(sessionToActivate);
if (effectiveSessionId && !isTemporarySessionId(effectiveSessionId)) {
onSessionProcessing?.(effectiveSessionId);
}
const getToolsSettings = () => {
try {
@@ -552,8 +584,10 @@ export function useChatComposerState({
provider === 'cursor'
? 'cursor-tools-settings'
: provider === 'codex'
? 'codex-settings'
: 'claude-settings';
? 'codex-settings'
: provider === 'gemini'
? 'gemini-settings'
: 'claude-settings';
const savedSettings = safeLocalStorage.getItem(settingsKey);
if (savedSettings) {
return JSON.parse(savedSettings);
@@ -601,6 +635,21 @@ export function useChatComposerState({
permissionMode: permissionMode === 'plan' ? 'default' : permissionMode,
},
});
} else if (provider === 'gemini') {
sendMessage({
type: 'gemini-command',
command: messageContent,
sessionId: effectiveSessionId,
options: {
cwd: resolvedProjectPath,
projectPath: resolvedProjectPath,
sessionId: effectiveSessionId,
resume: Boolean(effectiveSessionId),
model: geminiModel,
permissionMode,
toolsSettings,
},
});
} else {
sendMessage({
type: 'claude-command',
@@ -639,8 +688,11 @@ export function useChatComposerState({
codexModel,
currentSessionId,
cursorModel,
executeCommand,
geminiModel,
isLoading,
onSessionActive,
onSessionProcessing,
pendingViewSessionRef,
permissionMode,
provider,
@@ -654,6 +706,7 @@ export function useChatComposerState({
setClaudeStatus,
setIsLoading,
setIsUserScrolledUp,
slashCommands,
thinkingMode,
],
);
@@ -903,8 +956,11 @@ export function useChatComposerState({
[sendMessage, setClaudeStatus, setPendingPermissionRequests],
);
const [isInputFocused, setIsInputFocused] = useState(false);
const handleInputFocusChange = useCallback(
(focused: boolean) => {
setIsInputFocused(focused);
onInputFocusChange?.(focused);
},
[onInputFocusChange],
@@ -953,5 +1009,6 @@ export function useChatComposerState({
handlePermissionDecision,
handleGrantToolPermission,
handleInputFocusChange,
isInputFocused,
};
}

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { authenticatedFetch } from '../../../utils/api';
import { CLAUDE_MODELS, CODEX_MODELS, CURSOR_MODELS } from '../../../../shared/modelConstants';
import { CLAUDE_MODELS, CODEX_MODELS, CURSOR_MODELS, GEMINI_MODELS } from '../../../../shared/modelConstants';
import type { PendingPermissionRequest, PermissionMode, Provider } from '../types/types';
import type { ProjectSession, SessionProvider } from '../../../types/app';
@@ -23,6 +23,9 @@ export function useChatProviderState({ selectedSession }: UseChatProviderStateAr
const [codexModel, setCodexModel] = useState<string>(() => {
return localStorage.getItem('codex-model') || CODEX_MODELS.DEFAULT;
});
const [geminiModel, setGeminiModel] = useState<string>(() => {
return localStorage.getItem('gemini-model') || GEMINI_MODELS.DEFAULT;
});
const lastProviderRef = useRef(provider);
@@ -105,6 +108,8 @@ export function useChatProviderState({ selectedSession }: UseChatProviderStateAr
setClaudeModel,
codexModel,
setCodexModel,
geminiModel,
setGeminiModel,
permissionMode,
setPermissionMode,
pendingPermissionRequests,

View File

@@ -145,6 +145,7 @@ export function useChatRealtimeHandlers({
'claude-error',
'cursor-error',
'codex-error',
'gemini-error',
]);
const isClaudeSystemInit =
@@ -162,8 +163,8 @@ export function useChatRealtimeHandlers({
const systemInitSessionId = isClaudeSystemInit
? structuredMessageData?.session_id
: isCursorSystemInit
? rawStructuredData?.session_id
: null;
? rawStructuredData?.session_id
: null;
const activeViewSessionId =
selectedSession?.id || currentSessionId || pendingViewSessionRef.current?.sessionId || null;
@@ -176,7 +177,8 @@ export function useChatRealtimeHandlers({
!pendingViewSessionRef.current.sessionId &&
(latestMessage.type === 'claude-error' ||
latestMessage.type === 'cursor-error' ||
latestMessage.type === 'codex-error');
latestMessage.type === 'codex-error' ||
latestMessage.type === 'gemini-error');
const handleBackgroundLifecycle = (sessionId?: string) => {
if (!sessionId) {
@@ -225,12 +227,6 @@ export function useChatRealtimeHandlers({
if (latestMessage.sessionId && lifecycleMessageTypes.has(String(latestMessage.type))) {
handleBackgroundLifecycle(latestMessage.sessionId);
}
console.log(
'Skipping message for different session:',
latestMessage.sessionId,
'current:',
activeViewSessionId,
);
return;
}
}
@@ -297,11 +293,6 @@ export function useChatRealtimeHandlers({
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;
@@ -314,10 +305,6 @@ export function useChatRealtimeHandlers({
!currentSessionId &&
isSystemInitForView
) {
console.log('New session init detected:', {
newSession: structuredMessageData.session_id,
});
setIsSystemSessionChange(true);
onNavigateToSession?.(structuredMessageData.session_id);
return;
@@ -331,14 +318,47 @@ export function useChatRealtimeHandlers({
structuredMessageData.session_id === currentSessionId &&
isSystemInitForView
) {
console.log('System init message for current session, ignoring');
return;
}
if (structuredMessageData && Array.isArray(structuredMessageData.content)) {
const parentToolUseId = rawStructuredData?.parentToolUseId;
structuredMessageData.content.forEach((part: any) => {
if (part.type === 'tool_use') {
const toolInput = part.input ? JSON.stringify(part.input, null, 2) : '';
// Check if this is a child tool from a subagent
if (parentToolUseId) {
setChatMessages((previous) =>
previous.map((message) => {
if (message.toolId === parentToolUseId && message.isSubagentContainer) {
const childTool = {
toolId: part.id,
toolName: part.name,
toolInput: part.input,
toolResult: null,
timestamp: new Date(),
};
const existingChildren = message.subagentState?.childTools || [];
return {
...message,
subagentState: {
childTools: [...existingChildren, childTool],
currentToolIndex: existingChildren.length,
isComplete: false,
},
};
}
return message;
}),
);
return;
}
// Check if this is a Task tool (subagent container)
const isSubagentContainer = part.name === 'Task';
setChatMessages((previous) => [
...previous,
{
@@ -350,6 +370,10 @@ export function useChatRealtimeHandlers({
toolInput,
toolId: part.id,
toolResult: null,
isSubagentContainer,
subagentState: isSubagentContainer
? { childTools: [], currentToolIndex: -1, isComplete: false }
: undefined,
},
]);
return;
@@ -382,6 +406,8 @@ export function useChatRealtimeHandlers({
}
if (structuredMessageData?.role === 'user' && Array.isArray(structuredMessageData.content)) {
const parentToolUseId = rawStructuredData?.parentToolUseId;
structuredMessageData.content.forEach((part: any) => {
if (part.type !== 'tool_result') {
return;
@@ -389,8 +415,32 @@ export function useChatRealtimeHandlers({
setChatMessages((previous) =>
previous.map((message) => {
if (message.isToolUse && message.toolId === part.tool_use_id) {
// Handle child tool results (route to parent's subagentState)
if (parentToolUseId && message.toolId === parentToolUseId && message.isSubagentContainer) {
return {
...message,
subagentState: {
...message.subagentState!,
childTools: message.subagentState!.childTools.map((child) => {
if (child.toolId === part.tool_use_id) {
return {
...child,
toolResult: {
content: part.content,
isError: part.is_error,
timestamp: new Date(),
},
};
}
return child;
}),
},
};
}
// Handle normal tool results (including parent Task tool completion)
if (message.isToolUse && message.toolId === part.tool_use_id) {
const result = {
...message,
toolResult: {
content: part.content,
@@ -398,6 +448,14 @@ export function useChatRealtimeHandlers({
timestamp: new Date(),
},
};
// Mark subagent as complete when parent Task receives its result
if (message.isSubagentContainer && message.subagentState) {
result.subagentState = {
...message.subagentState,
isComplete: true,
};
}
return result;
}
return message;
}),
@@ -511,17 +569,12 @@ export function useChatRealtimeHandlers({
}
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;
@@ -540,9 +593,8 @@ export function useChatRealtimeHandlers({
...previous,
{
type: 'assistant',
content: `Using tool: ${latestMessage.tool} ${
latestMessage.input ? `with ${latestMessage.input}` : ''
}`,
content: `Using tool: ${latestMessage.tool} ${latestMessage.input ? `with ${latestMessage.input}` : ''
}`,
timestamp: new Date(),
isToolUse: true,
toolName: latestMessage.tool,
@@ -825,7 +877,6 @@ export function useChatRealtimeHandlers({
onNavigateToSession?.(codexActualSessionId);
}
sessionStorage.removeItem('pendingSessionId');
console.log('Codex session complete, ID set to:', codexPendingSessionId);
}
if (selectedProject) {
@@ -847,6 +898,91 @@ export function useChatRealtimeHandlers({
]);
break;
case 'gemini-response': {
const geminiData = latestMessage.data;
if (geminiData && geminiData.type === 'message' && typeof geminiData.content === 'string') {
const content = decodeHtmlEntities(geminiData.content);
if (content) {
streamBufferRef.current += streamBufferRef.current ? `\n${content}` : content;
}
if (!geminiData.isPartial) {
// Immediate flush and finalization for the last chunk
if (streamTimerRef.current) {
clearTimeout(streamTimerRef.current);
streamTimerRef.current = null;
}
const chunk = streamBufferRef.current;
streamBufferRef.current = '';
if (chunk) {
appendStreamingChunk(setChatMessages, chunk, true);
}
finalizeStreamingMessage(setChatMessages);
} else if (!streamTimerRef.current && streamBufferRef.current) {
streamTimerRef.current = window.setTimeout(() => {
const chunk = streamBufferRef.current;
streamBufferRef.current = '';
streamTimerRef.current = null;
if (chunk) {
appendStreamingChunk(setChatMessages, chunk, true);
}
}, 100);
}
}
break;
}
case 'gemini-error':
setIsLoading(false);
setCanAbortSession(false);
setChatMessages((previous) => [
...previous,
{
type: 'error',
content: latestMessage.error || 'An error occurred with Gemini',
timestamp: new Date(),
},
]);
break;
case 'gemini-tool-use':
setChatMessages((previous) => [
...previous,
{
type: 'assistant',
content: '',
timestamp: new Date(),
isToolUse: true,
toolName: latestMessage.toolName,
toolInput: latestMessage.parameters ? JSON.stringify(latestMessage.parameters, null, 2) : '',
toolId: latestMessage.toolId,
toolResult: null,
}
]);
break;
case 'gemini-tool-result':
setChatMessages((previous) =>
previous.map((message) => {
if (message.isToolUse && message.toolId === latestMessage.toolId) {
return {
...message,
toolResult: {
content: latestMessage.output || `Status: ${latestMessage.status}`,
isError: latestMessage.status === 'error',
timestamp: new Date(),
},
};
}
return message;
}),
);
break;
case 'session-aborted': {
const pendingSessionId =
typeof window !== 'undefined' ? sessionStorage.getItem('pendingSessionId') : null;
@@ -884,12 +1020,26 @@ export function useChatRealtimeHandlers({
case 'session-status': {
const statusSessionId = latestMessage.sessionId;
if (!statusSessionId) {
break;
}
const isCurrentSession =
statusSessionId === currentSessionId || (selectedSession && statusSessionId === selectedSession.id);
if (isCurrentSession && latestMessage.isProcessing) {
setIsLoading(true);
setCanAbortSession(true);
if (latestMessage.isProcessing) {
onSessionProcessing?.(statusSessionId);
if (isCurrentSession) {
setIsLoading(true);
setCanAbortSession(true);
}
break;
}
onSessionInactive?.(statusSessionId);
onSessionNotProcessing?.(statusSessionId);
if (isCurrentSession) {
clearLoadingIndicators();
}
break;
}

View File

@@ -22,7 +22,7 @@ interface UseSlashCommandsOptions {
input: string;
setInput: Dispatch<SetStateAction<string>>;
textareaRef: RefObject<HTMLTextAreaElement>;
onExecuteCommand: (command: SlashCommand) => void | Promise<void>;
onExecuteCommand: (command: SlashCommand, rawInput?: string) => void | Promise<void>;
}
const getCommandHistoryKey = (projectName: string) => `command_history_${projectName}`;

View File

@@ -1,7 +1,8 @@
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 { OneLineDisplay, CollapsibleDisplay, DiffViewer, MarkdownContent, FileListContent, TodoListContent, TaskListContent, TextContent, QuestionAnswerContent, SubagentContainer } from './components';
import type { Project } from '../../../types/app';
import type { SubagentChildTool } from '../types/types';
type DiffLine = {
type: string;
@@ -21,6 +22,12 @@ interface ToolRendererProps {
autoExpandTools?: boolean;
showRawParameters?: boolean;
rawToolInput?: string;
isSubagentContainer?: boolean;
subagentState?: {
childTools: SubagentChildTool[];
currentToolIndex: number;
isComplete: boolean;
};
}
function getToolCategory(toolName: string): string {
@@ -50,8 +57,24 @@ export const ToolRenderer: React.FC<ToolRendererProps> = memo(({
selectedProject,
autoExpandTools = false,
showRawParameters = false,
rawToolInput
rawToolInput,
isSubagentContainer,
subagentState
}) => {
// Route subagent containers to dedicated component
if (isSubagentContainer && subagentState) {
if (mode === 'result') {
return null;
}
return (
<SubagentContainer
toolInput={toolInput}
toolResult={toolResult}
subagentState={subagentState}
/>
);
}
const config = getToolConfig(toolName);
const displayConfig: any = mode === 'input' ? config.input : config.result;

View File

@@ -24,7 +24,7 @@ export const CollapsibleSection: React.FC<CollapsibleSectionProps> = ({
}) => {
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">
<summary className="flex items-center gap-1.5 text-xs cursor-pointer py-0.5 select-none group-open/details:sticky group-open/details:top-0 group-open/details:z-10 group-open/details:bg-background group-open/details:-mx-1 group-open/details:px-1">
<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"

View File

@@ -1,9 +1,9 @@
import React, { useState } from 'react';
import { copyTextToClipboard } from '../../../../utils/clipboard';
type ActionType = 'copy' | 'open-file' | 'jump-to-results' | 'none';
interface OneLineDisplayProps {
toolName: string;
icon?: string;
label?: string;
@@ -25,52 +25,6 @@ interface OneLineDisplayProps {
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.
@@ -92,7 +46,6 @@ export const OneLineDisplay: React.FC<OneLineDisplayProps> = ({
border: 'border-gray-300 dark:border-gray-600',
icon: 'text-gray-500 dark:text-gray-400'
},
resultId,
toolResult,
toolId
}) => {

View File

@@ -0,0 +1,180 @@
import React from 'react';
import { CollapsibleSection } from './CollapsibleSection';
import type { SubagentChildTool } from '../../types/types';
interface SubagentContainerProps {
toolInput: unknown;
toolResult?: { content?: unknown; isError?: boolean } | null;
subagentState: {
childTools: SubagentChildTool[];
currentToolIndex: number;
isComplete: boolean;
};
}
const getCompactToolDisplay = (toolName: string, toolInput: unknown): string => {
const input = typeof toolInput === 'string' ? (() => {
try { return JSON.parse(toolInput); } catch { return {}; }
})() : (toolInput || {});
switch (toolName) {
case 'Read':
case 'Write':
case 'Edit':
case 'ApplyPatch':
return input.file_path?.split('/').pop() || input.file_path || '';
case 'Grep':
case 'Glob':
return input.pattern || '';
case 'Bash':
const cmd = input.command || '';
return cmd.length > 40 ? `${cmd.slice(0, 40)}...` : cmd;
case 'Task':
return input.description || input.subagent_type || '';
case 'WebFetch':
case 'WebSearch':
return input.url || input.query || '';
default:
return '';
}
};
export const SubagentContainer: React.FC<SubagentContainerProps> = ({
toolInput,
toolResult,
subagentState,
}) => {
const parsedInput = typeof toolInput === 'string' ? (() => {
try { return JSON.parse(toolInput); } catch { return {}; }
})() : (toolInput || {});
const subagentType = parsedInput?.subagent_type || 'Agent';
const description = parsedInput?.description || 'Running task';
const prompt = parsedInput?.prompt || '';
const { childTools, currentToolIndex, isComplete } = subagentState;
const currentTool = currentToolIndex >= 0 ? childTools[currentToolIndex] : null;
const title = `Subagent / ${subagentType}: ${description}`;
return (
<div className="border-l-2 border-l-purple-500 dark:border-l-purple-400 pl-3 py-0.5 my-1">
<CollapsibleSection
title={title}
toolName="Task"
open={false}
>
{/* Prompt/request to the subagent */}
{prompt && (
<div className="text-xs text-gray-600 dark:text-gray-400 mb-2 whitespace-pre-wrap break-words line-clamp-4">
{prompt}
</div>
)}
{/* Current tool indicator (while running) */}
{currentTool && !isComplete && (
<div className="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400 mt-1">
<span className="animate-pulse w-1.5 h-1.5 rounded-full bg-purple-500 dark:bg-purple-400 flex-shrink-0" />
<span className="text-gray-400 dark:text-gray-500">Currently:</span>
<span className="font-medium text-gray-600 dark:text-gray-300">{currentTool.toolName}</span>
{getCompactToolDisplay(currentTool.toolName, currentTool.toolInput) && (
<>
<span className="text-gray-300 dark:text-gray-600">/</span>
<span className="font-mono truncate text-gray-500 dark:text-gray-400">
{getCompactToolDisplay(currentTool.toolName, currentTool.toolInput)}
</span>
</>
)}
</div>
)}
{/* Completion status */}
{isComplete && (
<div className="flex items-center gap-1.5 text-xs text-green-600 dark:text-green-400 mt-1">
<svg className="w-3 h-3 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span>Completed ({childTools.length} {childTools.length === 1 ? 'tool' : 'tools'})</span>
</div>
)}
{/* Tool history (collapsed) */}
{childTools.length > 0 && (
<details className="mt-2 group/history">
<summary className="cursor-pointer text-[11px] text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 flex items-center gap-1">
<svg
className="w-2.5 h-2.5 transition-transform duration-150 group-open/history: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>
<span>View tool history ({childTools.length})</span>
</summary>
<div className="mt-1 pl-3 border-l border-gray-200 dark:border-gray-700 space-y-0.5">
{childTools.map((child, index) => (
<div key={child.toolId} className="flex items-center gap-1.5 text-[11px] text-gray-500 dark:text-gray-400">
<span className="text-gray-400 dark:text-gray-500 w-4 text-right flex-shrink-0">{index + 1}.</span>
<span className="font-medium">{child.toolName}</span>
{getCompactToolDisplay(child.toolName, child.toolInput) && (
<span className="font-mono truncate text-gray-400 dark:text-gray-500">
{getCompactToolDisplay(child.toolName, child.toolInput)}
</span>
)}
{child.toolResult?.isError && (
<span className="text-red-500 flex-shrink-0">(error)</span>
)}
</div>
))}
</div>
</details>
)}
{/* Final result */}
{isComplete && toolResult && (
<div className="mt-2 text-xs text-gray-600 dark:text-gray-400">
{(() => {
let content = toolResult.content;
// Handle JSON string that needs parsing
if (typeof content === 'string') {
try {
const parsed = JSON.parse(content);
if (Array.isArray(parsed)) {
// Extract text from array format like [{"type":"text","text":"..."}]
const textParts = parsed
.filter((p: any) => p.type === 'text' && p.text)
.map((p: any) => p.text);
if (textParts.length > 0) {
content = textParts.join('\n');
}
}
} catch {
// Not JSON, use as-is
}
} else if (Array.isArray(content)) {
// Direct array format
const textParts = content
.filter((p: any) => p.type === 'text' && p.text)
.map((p: any) => p.text);
if (textParts.length > 0) {
content = textParts.join('\n');
}
}
return typeof content === 'string' ? (
<div className="whitespace-pre-wrap break-words line-clamp-6">
{content}
</div>
) : content ? (
<pre className="whitespace-pre-wrap break-words line-clamp-6 font-mono text-[11px]">
{JSON.stringify(content, null, 2)}
</pre>
) : null;
})()}
</div>
)}
</CollapsibleSection>
</div>
);
};

View File

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

View File

@@ -383,7 +383,7 @@ export const TOOL_CONFIGS: Record<string, ToolDisplayConfig> = {
const description = input.description || 'Running task';
return `Subagent / ${subagentType}: ${description}`;
},
defaultOpen: true,
defaultOpen: false,
contentType: 'markdown',
getContentProps: (input) => {
// If only prompt exists (and required fields), show just the prompt
@@ -424,14 +424,8 @@ export const TOOL_CONFIGS: Record<string, ToolDisplayConfig> = {
},
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,
title: 'Subagent result',
defaultOpen: false,
contentType: 'markdown',
getContentProps: (result) => {
// Handle agent results which may have complex structure

View File

@@ -17,6 +17,14 @@ export interface ToolResult {
[key: string]: unknown;
}
export interface SubagentChildTool {
toolId: string;
toolName: string;
toolInput: unknown;
toolResult?: ToolResult | null;
timestamp: Date;
}
export interface ChatMessage {
type: string;
content?: string;
@@ -32,6 +40,12 @@ export interface ChatMessage {
toolResult?: ToolResult | null;
toolId?: string;
toolCallId?: string;
isSubagentContainer?: boolean;
subagentState?: {
childTools: SubagentChildTool[];
currentToolIndex: number;
isComplete: boolean;
};
[key: string]: unknown;
}

View File

@@ -354,7 +354,7 @@ export const convertSessionMessages = (rawMessages: any[]): ChatMessage[] => {
const converted: ChatMessage[] = [];
const toolResults = new Map<
string,
{ content: unknown; isError: boolean; timestamp: Date; toolUseResult: unknown }
{ content: unknown; isError: boolean; timestamp: Date; toolUseResult: unknown; subagentTools?: unknown[] }
>();
rawMessages.forEach((message) => {
@@ -368,6 +368,7 @@ export const convertSessionMessages = (rawMessages: any[]): ChatMessage[] => {
isError: Boolean(part.is_error),
timestamp: new Date(message.timestamp || Date.now()),
toolUseResult: message.toolUseResult || null,
subagentTools: message.subagentTools,
});
});
}
@@ -484,6 +485,22 @@ export const convertSessionMessages = (rawMessages: any[]): ChatMessage[] => {
if (part.type === 'tool_use') {
const toolResult = toolResults.get(part.id);
const isSubagentContainer = part.name === 'Task';
// Build child tools from server-provided subagentTools data
const childTools: import('../types/types').SubagentChildTool[] = [];
if (isSubagentContainer && toolResult?.subagentTools && Array.isArray(toolResult.subagentTools)) {
for (const tool of toolResult.subagentTools as any[]) {
childTools.push({
toolId: tool.toolId,
toolName: tool.toolName,
toolInput: tool.toolInput,
toolResult: tool.toolResult || null,
timestamp: new Date(tool.timestamp || Date.now()),
});
}
}
converted.push({
type: 'assistant',
content: '',
@@ -491,6 +508,7 @@ export const convertSessionMessages = (rawMessages: any[]): ChatMessage[] => {
isToolUse: true,
toolName: part.name,
toolInput: normalizeToolInput(part.input),
toolId: part.id,
toolResult: toolResult
? {
content:
@@ -503,6 +521,14 @@ export const convertSessionMessages = (rawMessages: any[]): ChatMessage[] => {
: null,
toolError: toolResult?.isError || false,
toolResultTimestamp: toolResult?.timestamp || new Date(),
isSubagentContainer,
subagentState: isSubagentContainer
? {
childTools,
currentToolIndex: childTools.length > 0 ? childTools.length - 1 : -1,
isComplete: Boolean(toolResult),
}
: undefined,
});
}
});

View File

@@ -64,6 +64,8 @@ function ChatInterface({
setClaudeModel,
codexModel,
setCodexModel,
geminiModel,
setGeminiModel,
permissionMode,
pendingPermissionRequests,
setPendingPermissionRequests,
@@ -163,6 +165,7 @@ function ChatInterface({
handlePermissionDecision,
handleGrantToolPermission,
handleInputFocusChange,
isInputFocused,
} = useChatComposerState({
selectedProject,
selectedSession,
@@ -173,12 +176,14 @@ function ChatInterface({
cursorModel,
claudeModel,
codexModel,
geminiModel,
isLoading,
canAbortSession,
tokenBudget,
sendMessage,
sendByCtrlEnter,
onSessionActive,
onSessionProcessing,
onInputFocusChange,
onFileOpen,
onShowSettings,
@@ -237,13 +242,6 @@ function ChatInterface({
};
}, [canAbortSession, handleAbortSession, isLoading]);
useEffect(() => {
const processingSessionId = selectedSession?.id || currentSessionId;
if (processingSessionId && isLoading && onSessionProcessing) {
onSessionProcessing(processingSessionId);
}
}, [currentSessionId, isLoading, onSessionProcessing, selectedSession?.id]);
useEffect(() => {
return () => {
resetStreamingState();
@@ -256,7 +254,9 @@ function ChatInterface({
? t('messageTypes.cursor')
: provider === 'codex'
? t('messageTypes.codex')
: t('messageTypes.claude');
: provider === 'gemini'
? t('messageTypes.gemini')
: t('messageTypes.claude');
return (
<div className="flex items-center justify-center h-full">
@@ -292,6 +292,8 @@ function ChatInterface({
setCursorModel={setCursorModel}
codexModel={codexModel}
setCodexModel={setCodexModel}
geminiModel={geminiModel}
setGeminiModel={setGeminiModel}
tasksEnabled={tasksEnabled}
isTaskMasterInstalled={isTaskMasterInstalled}
onShowAllTasks={onShowAllTasks}
@@ -373,13 +375,16 @@ function ChatInterface({
onTextareaScrollSync={syncInputOverlayScroll}
onTextareaInput={handleTextareaInput}
onInputFocusChange={handleInputFocusChange}
isInputFocused={isInputFocused}
placeholder={t('input.placeholder', {
provider:
provider === 'cursor'
? t('messageTypes.cursor')
: provider === 'codex'
? t('messageTypes.codex')
: t('messageTypes.claude'),
? t('messageTypes.codex')
: provider === 'gemini'
? t('messageTypes.gemini')
: t('messageTypes.claude'),
})}
isTextareaExpanded={isTextareaExpanded}
sendByCtrlEnter={sendByCtrlEnter}

View File

@@ -1,5 +1,5 @@
import { SessionProvider } from '../../../../types/app';
import SessionProviderLogo from '../../../SessionProviderLogo';
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
import type { Provider } from '../../types/types';
type AssistantThinkingIndicatorProps = {
@@ -16,7 +16,7 @@ export default function AssistantThinkingIndicator({ selectedProvider }: Assista
<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'}
{selectedProvider === 'cursor' ? 'Cursor' : selectedProvider === 'codex' ? 'Codex' : selectedProvider === 'gemini' ? 'Gemini' : 'Claude'}
</div>
</div>
<div className="w-full text-sm text-gray-500 dark:text-gray-400 pl-3 sm:pl-0">

View File

@@ -1,6 +1,6 @@
import CommandMenu from '../../../CommandMenu';
import ClaudeStatus from '../../../ClaudeStatus';
import { MicButton } from '../../../MicButton.jsx';
import CommandMenu from './CommandMenu';
import ClaudeStatus from './ClaudeStatus';
import MicButton from '../../../mic-button/view/MicButton';
import ImageAttachment from './ImageAttachment';
import PermissionRequestsBanner from './PermissionRequestsBanner';
import ChatInputControls from './ChatInputControls';
@@ -87,6 +87,7 @@ interface ChatComposerProps {
onTextareaScrollSync: (target: HTMLTextAreaElement) => void;
onTextareaInput: (event: FormEvent<HTMLTextAreaElement>) => void;
onInputFocusChange?: (focused: boolean) => void;
isInputFocused?: boolean;
placeholder: string;
isTextareaExpanded: boolean;
sendByCtrlEnter?: boolean;
@@ -143,13 +144,13 @@ export default function ChatComposer({
onTextareaScrollSync,
onTextareaInput,
onInputFocusChange,
isInputFocused,
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,
@@ -162,8 +163,13 @@ export default function ChatComposer({
(r) => r.toolName === 'AskUserQuestion'
);
// On mobile, when input is focused, float the input box at the bottom
const mobileFloatingClass = isInputFocused
? 'max-sm:fixed max-sm:bottom-0 max-sm:left-0 max-sm:right-0 max-sm:z-50 max-sm:bg-background max-sm:shadow-[0_-4px_20px_rgba(0,0,0,0.15)]'
: '';
return (
<div className="p-2 sm:p-4 md:p-4 flex-shrink-0 pb-2 sm:pb-4 md:pb-6">
<div className={`p-2 sm:p-4 md:p-4 flex-shrink-0 pb-2 sm:pb-4 md:pb-6 ${mobileFloatingClass}`}>
{!hasQuestionPanel && (
<div className="flex-1">
<ClaudeStatus
@@ -259,7 +265,7 @@ export default function ChatComposer({
</div>
)}
<AnyCommandMenu
<CommandMenu
commands={filteredCommands}
selectedIndex={selectedCommandIndex}
onSelect={onCommandSelect}

View File

@@ -26,6 +26,8 @@ interface ChatMessagesPaneProps {
setCursorModel: (model: string) => void;
codexModel: string;
setCodexModel: (model: string) => void;
geminiModel: string;
setGeminiModel: (model: string) => void;
tasksEnabled: boolean;
isTaskMasterInstalled: boolean | null;
onShowAllTasks?: (() => void) | null;
@@ -70,6 +72,8 @@ export default function ChatMessagesPane({
setCursorModel,
codexModel,
setCodexModel,
geminiModel,
setGeminiModel,
tasksEnabled,
isTaskMasterInstalled,
onShowAllTasks,
@@ -152,6 +156,8 @@ export default function ChatMessagesPane({
setCursorModel={setCursorModel}
codexModel={codexModel}
setCodexModel={setCodexModel}
geminiModel={geminiModel}
setGeminiModel={setGeminiModel}
tasksEnabled={tasksEnabled}
isTaskMasterInstalled={isTaskMasterInstalled}
onShowAllTasks={onShowAllTasks}

View File

@@ -1,12 +1,30 @@
import React, { useState, useEffect } from 'react';
import { cn } from '../lib/utils';
import { useEffect, useState } from 'react';
import { cn } from '../../../../lib/utils';
function ClaudeStatus({ status, onAbort, isLoading, provider = 'claude' }) {
type ClaudeStatusProps = {
status: {
text?: string;
tokens?: number;
can_interrupt?: boolean;
} | null;
onAbort?: () => void;
isLoading: boolean;
provider?: string;
};
const ACTION_WORDS = ['Thinking', 'Processing', 'Analyzing', 'Working', 'Computing', 'Reasoning'];
const SPINNER_CHARS = ['*', '+', 'x', '.'];
export default function ClaudeStatus({
status,
onAbort,
isLoading,
provider: _provider = 'claude',
}: ClaudeStatusProps) {
const [elapsedTime, setElapsedTime] = useState(0);
const [animationPhase, setAnimationPhase] = useState(0);
const [fakeTokens, setFakeTokens] = useState(0);
// Update elapsed time every second
useEffect(() => {
if (!isLoading) {
setElapsedTime(0);
@@ -15,81 +33,76 @@ function ClaudeStatus({ status, onAbort, isLoading, provider = 'claude' }) {
}
const startTime = Date.now();
// Calculate random token rate once (30-50 tokens per second)
const tokenRate = 30 + Math.random() * 20;
const timer = setInterval(() => {
const timer = window.setInterval(() => {
const elapsed = Math.floor((Date.now() - startTime) / 1000);
setElapsedTime(elapsed);
// Simulate token count increasing over time
setFakeTokens(Math.floor(elapsed * tokenRate));
}, 1000);
return () => clearInterval(timer);
return () => window.clearInterval(timer);
}, [isLoading]);
// Animate the status indicator
useEffect(() => {
if (!isLoading) return;
if (!isLoading) {
return;
}
const timer = setInterval(() => {
setAnimationPhase(prev => (prev + 1) % 4);
const timer = window.setInterval(() => {
setAnimationPhase((previous) => (previous + 1) % SPINNER_CHARS.length);
}, 500);
return () => clearInterval(timer);
return () => window.clearInterval(timer);
}, [isLoading]);
// Don't show if loading is false
if (!isLoading) {
return null;
}
// Note: showThinking only controls the reasoning accordion in messages, not this processing indicator
if (!isLoading) return null;
// Clever action words that cycle
const actionWords = ['Thinking', 'Processing', 'Analyzing', 'Working', 'Computing', 'Reasoning'];
const actionIndex = Math.floor(elapsedTime / 3) % actionWords.length;
// Parse status data
const statusText = status?.text || actionWords[actionIndex];
const actionIndex = Math.floor(elapsedTime / 3) % ACTION_WORDS.length;
const statusText = status?.text || ACTION_WORDS[actionIndex];
const tokens = status?.tokens || fakeTokens;
const canInterrupt = status?.can_interrupt !== false;
// Animation characters
const spinners = ['✻', '✹', '✸', '✶'];
const currentSpinner = spinners[animationPhase];
const currentSpinner = SPINNER_CHARS[animationPhase];
return (
<div className="w-full mb-3 sm:mb-6 animate-in slide-in-from-bottom duration-300">
<div className="flex items-center justify-between max-w-4xl mx-auto bg-gray-800 dark:bg-gray-900 text-white rounded-lg shadow-lg px-2.5 py-2 sm:px-4 sm:py-3 border border-gray-700 dark:border-gray-800">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 sm:gap-3">
{/* Animated spinner */}
<span className={cn(
"text-base sm:text-xl transition-all duration-500 flex-shrink-0",
animationPhase % 2 === 0 ? "text-blue-400 scale-110" : "text-blue-300"
)}>
<span
className={cn(
'text-base sm:text-xl transition-all duration-500 flex-shrink-0',
animationPhase % 2 === 0 ? 'text-blue-400 scale-110' : 'text-blue-300',
)}
>
{currentSpinner}
</span>
{/* Status text - compact for mobile */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 sm:gap-2">
<span className="font-medium text-xs sm:text-sm truncate">{statusText}...</span>
<span className="text-gray-400 text-xs sm:text-sm flex-shrink-0">({elapsedTime}s)</span>
{tokens > 0 && (
<>
<span className="text-gray-500 hidden sm:inline">·</span>
<span className="text-gray-300 text-xs sm:text-sm hidden sm:inline flex-shrink-0"> {tokens.toLocaleString()}</span>
<span className="text-gray-500 hidden sm:inline">|</span>
<span className="text-gray-300 text-xs sm:text-sm hidden sm:inline flex-shrink-0">
tokens {tokens.toLocaleString()}
</span>
</>
)}
<span className="text-gray-500 hidden sm:inline">·</span>
<span className="text-gray-500 hidden sm:inline">|</span>
<span className="text-gray-400 text-xs sm:text-sm hidden sm:inline">esc to stop</span>
</div>
</div>
</div>
</div>
{/* Interrupt button */}
{canInterrupt && onAbort && (
<button
type="button"
onClick={onAbort}
className="ml-2 sm:ml-3 text-xs bg-red-600 hover:bg-red-700 active:bg-red-800 text-white px-2 py-1 sm:px-3 sm:py-1.5 rounded-md transition-colors flex items-center gap-1 sm:gap-1.5 flex-shrink-0 font-medium"
>
@@ -103,5 +116,3 @@ function ClaudeStatus({ status, onAbort, isLoading, provider = 'claude' }) {
</div>
);
}
export default ClaudeStatus;

View File

@@ -0,0 +1,224 @@
import { useEffect, useRef } from 'react';
import type { CSSProperties } from 'react';
type CommandMenuCommand = {
name: string;
description?: string;
namespace?: string;
path?: string;
type?: string;
metadata?: { type?: string; [key: string]: unknown };
[key: string]: unknown;
};
type CommandMenuProps = {
commands?: CommandMenuCommand[];
selectedIndex?: number;
onSelect?: (command: CommandMenuCommand, index: number, isHover: boolean) => void;
onClose: () => void;
position?: { top: number; left: number; bottom?: number };
isOpen?: boolean;
frequentCommands?: CommandMenuCommand[];
};
const menuBaseStyle: CSSProperties = {
maxHeight: '300px',
overflowY: 'auto',
borderRadius: '8px',
boxShadow: '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
zIndex: 1000,
padding: '8px',
transition: 'opacity 150ms ease-in-out, transform 150ms ease-in-out',
};
const namespaceLabels: Record<string, string> = {
frequent: 'Frequently Used',
builtin: 'Built-in Commands',
project: 'Project Commands',
user: 'User Commands',
other: 'Other Commands',
};
const namespaceIcons: Record<string, string> = {
frequent: '[*]',
builtin: '[B]',
project: '[P]',
user: '[U]',
other: '[O]',
};
const getCommandKey = (command: CommandMenuCommand) =>
`${command.name}::${command.namespace || command.type || 'other'}::${command.path || ''}`;
const getNamespace = (command: CommandMenuCommand) => command.namespace || command.type || 'other';
const getMenuPosition = (position: { top: number; left: number; bottom?: number }): CSSProperties => {
if (typeof window === 'undefined') {
return { position: 'fixed', top: '16px', left: '16px' };
}
if (window.innerWidth < 640) {
return {
position: 'fixed',
bottom: `${position.bottom ?? 90}px`,
left: '16px',
right: '16px',
width: 'auto',
maxWidth: 'calc(100vw - 32px)',
maxHeight: 'min(50vh, 300px)',
};
}
return {
position: 'fixed',
top: `${Math.max(16, Math.min(position.top, window.innerHeight - 316))}px`,
left: `${position.left}px`,
width: 'min(400px, calc(100vw - 32px))',
maxWidth: 'calc(100vw - 32px)',
maxHeight: '300px',
};
};
export default function CommandMenu({
commands = [],
selectedIndex = -1,
onSelect,
onClose,
position = { top: 0, left: 0 },
isOpen = false,
frequentCommands = [],
}: CommandMenuProps) {
const menuRef = useRef<HTMLDivElement | null>(null);
const selectedItemRef = useRef<HTMLDivElement | null>(null);
const menuPosition = getMenuPosition(position);
useEffect(() => {
if (!isOpen) {
return;
}
const handleClickOutside = (event: MouseEvent) => {
if (!menuRef.current || !(event.target instanceof Node)) {
return;
}
if (!menuRef.current.contains(event.target)) {
onClose();
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [isOpen, onClose]);
useEffect(() => {
if (!selectedItemRef.current || !menuRef.current) {
return;
}
const menuRect = menuRef.current.getBoundingClientRect();
const itemRect = selectedItemRef.current.getBoundingClientRect();
if (itemRect.bottom > menuRect.bottom || itemRect.top < menuRect.top) {
selectedItemRef.current.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
}, [selectedIndex]);
if (!isOpen) {
return null;
}
const hasFrequentCommands = frequentCommands.length > 0;
const frequentCommandKeys = new Set(frequentCommands.map(getCommandKey));
const groupedCommands = commands.reduce<Record<string, CommandMenuCommand[]>>((groups, command) => {
if (hasFrequentCommands && frequentCommandKeys.has(getCommandKey(command))) {
return groups;
}
const namespace = getNamespace(command);
if (!groups[namespace]) {
groups[namespace] = [];
}
groups[namespace].push(command);
return groups;
}, {});
if (hasFrequentCommands) {
groupedCommands.frequent = frequentCommands;
}
const preferredOrder = hasFrequentCommands
? ['frequent', 'builtin', 'project', 'user', 'other']
: ['builtin', 'project', 'user', 'other'];
const extraNamespaces = Object.keys(groupedCommands).filter((namespace) => !preferredOrder.includes(namespace));
const orderedNamespaces = [...preferredOrder, ...extraNamespaces].filter((namespace) => groupedCommands[namespace]);
const commandIndexByKey = new Map<string, number>();
commands.forEach((command, index) => {
const key = getCommandKey(command);
if (!commandIndexByKey.has(key)) {
commandIndexByKey.set(key, index);
}
});
if (commands.length === 0) {
return (
<div
ref={menuRef}
className="command-menu command-menu-empty border border-gray-200 bg-white text-gray-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400"
style={{ ...menuPosition, ...menuBaseStyle, overflowY: 'hidden', padding: '20px', opacity: 1, transform: 'translateY(0)', textAlign: 'center' }}
>
No commands available
</div>
);
}
return (
<div
ref={menuRef}
role="listbox"
aria-label="Available commands"
className="command-menu border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800"
style={{ ...menuPosition, ...menuBaseStyle, opacity: 1, transform: 'translateY(0)' }}
>
{orderedNamespaces.map((namespace) => (
<div key={namespace} className="command-group">
{orderedNamespaces.length > 1 && (
<div className="px-3 pb-1 pt-2 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
{namespaceLabels[namespace] || namespace}
</div>
)}
{(groupedCommands[namespace] || []).map((command) => {
const commandKey = getCommandKey(command);
const commandIndex = commandIndexByKey.get(commandKey) ?? -1;
const isSelected = commandIndex === selectedIndex;
return (
<div
key={`${namespace}-${command.name}-${command.path || ''}`}
ref={isSelected ? selectedItemRef : null}
role="option"
aria-selected={isSelected}
className={`command-item mb-0.5 flex cursor-pointer items-start rounded-md px-3 py-2.5 transition-colors ${
isSelected ? 'bg-blue-50 dark:bg-blue-900' : 'bg-transparent'
}`}
onMouseEnter={() => onSelect && commandIndex >= 0 && onSelect(command, commandIndex, true)}
onClick={() => onSelect && commandIndex >= 0 && onSelect(command, commandIndex, false)}
onMouseDown={(event) => event.preventDefault()}
>
<div className="min-w-0 flex-1">
<div className={`flex items-center gap-2 ${command.description ? 'mb-1' : 'mb-0'}`}>
<span className="shrink-0 text-xs text-gray-500 dark:text-gray-300">{namespaceIcons[namespace] || namespaceIcons.other}</span>
<span className="font-mono text-sm font-semibold text-gray-900 dark:text-gray-100">{command.name}</span>
{command.metadata?.type && (
<span className="command-metadata-badge rounded bg-gray-100 px-1.5 py-0.5 text-[10px] font-medium text-gray-500 dark:bg-gray-700 dark:text-gray-300">
{command.metadata.type}
</span>
)}
</div>
{command.description && (
<div className="ml-6 truncate whitespace-nowrap text-[13px] text-gray-500 dark:text-gray-300">
{command.description}
</div>
)}
</div>
{isSelected && <span className="ml-2 text-xs font-semibold text-blue-500 dark:text-blue-300">{'<-'}</span>}
</div>
);
})}
</div>
))}
</div>
);
}

View File

@@ -7,6 +7,7 @@ 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';
import { copyTextToClipboard } from '../../../../utils/clipboard';
type MarkdownProps = {
children: React.ReactNode;
@@ -31,9 +32,8 @@ const CodeBlock = ({ node, inline, className, children, ...props }: CodeBlockPro
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 || ''
}`}
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}
@@ -43,43 +43,6 @@ const CodeBlock = ({ node, inline, className, children, ...props }: CodeBlockPro
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">
@@ -89,7 +52,14 @@ const CodeBlock = ({ node, inline, className, children, ...props }: CodeBlockPro
<button
type="button"
onClick={handleCopy}
onClick={() =>
copyTextToClipboard(raw).then((success) => {
if (success) {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
})
}
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')}

View File

@@ -1,6 +1,6 @@
import React, { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import SessionProviderLogo from '../../../SessionProviderLogo';
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
import type {
ChatMessage,
ClaudePermissionSuggestion,
@@ -10,6 +10,7 @@ import type {
import { Markdown } from './Markdown';
import { formatUsageLimitText } from '../../utils/chatFormatting';
import { getClaudePermissionSuggestion } from '../../utils/chatPermissions';
import { copyTextToClipboard } from '../../../../utils/clipboard';
import type { Project } from '../../../../types/app';
import { ToolRenderer, shouldHideToolResult } from '../../tools';
@@ -45,14 +46,15 @@ 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'));
((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');
const [messageCopied, setMessageCopied] = React.useState(false);
React.useEffect(() => {
@@ -100,7 +102,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
{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="bg-blue-600 text-white rounded-2xl rounded-br-md px-3 sm:px-4 py-2 shadow-sm flex-1 sm:flex-initial group">
<div className="text-sm whitespace-pre-wrap break-words">
{message.content}
</div>
@@ -117,8 +119,45 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
))}
</div>
)}
<div className="text-xs text-blue-100 mt-1 text-right">
{formattedTime}
<div className="flex items-center justify-end gap-1 mt-1 text-xs text-blue-100">
<button
type="button"
onClick={() => {
const text = String(message.content || '');
if (!text) return;
copyTextToClipboard(text).then((success) => {
if (!success) return;
setMessageCopied(true);
});
}}
title={messageCopied ? t('copyMessage.copied') : t('copyMessage.copy')}
aria-label={messageCopied ? t('copyMessage.copied') : t('copyMessage.copy')}
>
{messageCopied ? (
<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>
) : (
<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>
)}
</button>
<span>{formattedTime}</span>
</div>
</div>
{!isGrouped && (
@@ -154,11 +193,11 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
</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'))}
{message.type === 'error' ? t('messageTypes.error') : message.type === 'tool' ? t('messageTypes.tool') : (provider === 'cursor' ? t('messageTypes.cursor') : provider === 'codex' ? t('messageTypes.codex') : provider === 'gemini' ? t('messageTypes.gemini') : t('messageTypes.claude'))}
</div>
</div>
)}
<div className="w-full">
{message.isToolUse ? (
@@ -184,9 +223,11 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
autoExpandTools={autoExpandTools}
showRawParameters={showRawParameters}
rawToolInput={typeof message.toolInput === 'string' ? message.toolInput : undefined}
isSubagentContainer={message.isSubagentContainer}
subagentState={message.subagentState}
/>
)}
{/* Tool Result Section */}
{message.toolResult && !shouldHideToolResult(message.toolName || 'UnknownTool', message.toolResult) && (
message.toolResult.isError ? (
@@ -220,11 +261,10 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
}
}}
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'
}`}
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')
@@ -292,7 +332,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
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"
@@ -306,31 +346,29 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
});
}
});
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`}
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'
}`}>
<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">
@@ -343,7 +381,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
</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')}
@@ -397,7 +435,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
// Detect if content is pure JSON (starts with { or [)
const trimmedContent = content.trim();
if ((trimmedContent.startsWith('{') || trimmedContent.startsWith('[')) &&
(trimmedContent.endsWith('}') || trimmedContent.endsWith(']'))) {
(trimmedContent.endsWith('}') || trimmedContent.endsWith(']'))) {
try {
const parsed = JSON.parse(trimmedContent);
const formatted = JSON.stringify(parsed, null, 2);
@@ -437,7 +475,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
})()}
</div>
)}
{!isGrouped && (
<div className="text-[11px] text-gray-400 dark:text-gray-500 mt-1">
{formattedTime}

View File

@@ -1,9 +1,9 @@
import React from 'react';
import { Check, ChevronDown } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import SessionProviderLogo from '../../../SessionProviderLogo';
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
import NextTaskBanner from '../../../NextTaskBanner.jsx';
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../../../../shared/modelConstants';
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS, GEMINI_MODELS } from '../../../../../shared/modelConstants';
import type { ProjectSession, SessionProvider } from '../../../../types/app';
interface ProviderSelectionEmptyStateProps {
@@ -18,6 +18,8 @@ interface ProviderSelectionEmptyStateProps {
setCursorModel: (model: string) => void;
codexModel: string;
setCodexModel: (model: string) => void;
geminiModel: string;
setGeminiModel: (model: string) => void;
tasksEnabled: boolean;
isTaskMasterInstalled: boolean | null;
onShowAllTasks?: (() => void) | null;
@@ -58,17 +60,27 @@ const PROVIDERS: ProviderDef[] = [
ring: 'ring-emerald-600/15',
check: 'bg-emerald-600 dark:bg-emerald-500 text-white',
},
{
id: 'gemini',
name: 'Gemini',
infoKey: 'providerSelection.providerInfo.google',
accent: 'border-blue-500 dark:border-blue-400',
ring: 'ring-blue-500/15',
check: 'bg-blue-500 text-white',
},
];
function getModelConfig(p: SessionProvider) {
if (p === 'claude') return CLAUDE_MODELS;
if (p === 'codex') return CODEX_MODELS;
if (p === 'gemini') return GEMINI_MODELS;
return CURSOR_MODELS;
}
function getModelValue(p: SessionProvider, c: string, cu: string, co: string) {
function getModelValue(p: SessionProvider, c: string, cu: string, co: string, g: string) {
if (p === 'claude') return c;
if (p === 'codex') return co;
if (p === 'gemini') return g;
return cu;
}
@@ -84,6 +96,8 @@ export default function ProviderSelectionEmptyState({
setCursorModel,
codexModel,
setCodexModel,
geminiModel,
setGeminiModel,
tasksEnabled,
isTaskMasterInstalled,
onShowAllTasks,
@@ -101,11 +115,12 @@ export default function ProviderSelectionEmptyState({
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 if (provider === 'gemini') { setGeminiModel(value); localStorage.setItem('gemini-model', value); }
else { setCursorModel(value); localStorage.setItem('cursor-model', value); }
};
const modelConfig = getModelConfig(provider);
const currentModel = getModelValue(provider, claudeModel, cursorModel, codexModel);
const currentModel = getModelValue(provider, claudeModel, cursorModel, codexModel, geminiModel);
/* ── New session — provider picker ── */
if (!selectedSession && !currentSessionId) {
@@ -123,7 +138,7 @@ export default function ProviderSelectionEmptyState({
</div>
{/* Provider cards — horizontal row, equal width */}
<div className="grid grid-cols-3 gap-2 sm:gap-2.5 mb-6">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 sm:gap-2.5 mb-6">
{PROVIDERS.map((p) => {
const active = provider === p.id;
return (
@@ -179,13 +194,14 @@ export default function ProviderSelectionEmptyState({
</div>
<p className="text-center text-sm 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')}
{
{
claude: t('providerSelection.readyPrompt.claude', { model: claudeModel }),
cursor: t('providerSelection.readyPrompt.cursor', { model: cursorModel }),
codex: t('providerSelection.readyPrompt.codex', { model: codexModel }),
gemini: t('providerSelection.readyPrompt.gemini', { model: geminiModel }),
}[provider]
}
</p>
</div>

View File

@@ -0,0 +1,17 @@
export const CODE_EDITOR_STORAGE_KEYS = {
theme: 'codeEditorTheme',
wordWrap: 'codeEditorWordWrap',
showMinimap: 'codeEditorShowMinimap',
lineNumbers: 'codeEditorLineNumbers',
fontSize: 'codeEditorFontSize',
} as const;
export const CODE_EDITOR_DEFAULTS = {
isDarkMode: true,
wordWrap: false,
minimapEnabled: true,
showLineNumbers: true,
fontSize: '12',
} as const;
export const CODE_EDITOR_SETTINGS_CHANGED_EVENT = 'codeEditorSettingsChanged';

View File

@@ -0,0 +1,137 @@
import { useCallback, useEffect, useState } from 'react';
import { api } from '../../../utils/api';
import type { CodeEditorFile } from '../types/types';
import { isBinaryFile } from '../utils/binaryFile';
type UseCodeEditorDocumentParams = {
file: CodeEditorFile;
projectPath?: string;
};
const getErrorMessage = (error: unknown) => {
if (error instanceof Error) {
return error.message;
}
return String(error);
};
export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocumentParams) => {
const [content, setContent] = useState('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [saveSuccess, setSaveSuccess] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [isBinary, setIsBinary] = useState(false);
const fileProjectName = file.projectName ?? projectPath;
const filePath = file.path;
const fileName = file.name;
const fileDiffNewString = file.diffInfo?.new_string;
const fileDiffOldString = file.diffInfo?.old_string;
useEffect(() => {
const loadFileContent = async () => {
try {
setLoading(true);
setIsBinary(false);
// Check if file is binary by extension
if (isBinaryFile(file.name)) {
setIsBinary(true);
setLoading(false);
return;
}
// Diff payload may already include full old/new snapshots, so avoid disk read.
if (file.diffInfo && fileDiffNewString !== undefined && fileDiffOldString !== undefined) {
setContent(fileDiffNewString);
setLoading(false);
return;
}
if (!fileProjectName) {
throw new Error('Missing project identifier');
}
const response = await api.readFile(fileProjectName, filePath);
if (!response.ok) {
throw new Error(`Failed to load file: ${response.status} ${response.statusText}`);
}
const data = await response.json();
setContent(data.content);
} catch (error) {
const message = getErrorMessage(error);
console.error('Error loading file:', error);
setContent(`// Error loading file: ${message}\n// File: ${fileName}\n// Path: ${filePath}`);
} finally {
setLoading(false);
}
};
loadFileContent();
}, [file.diffInfo, file.name, fileDiffNewString, fileDiffOldString, fileName, filePath, fileProjectName]);
const handleSave = useCallback(async () => {
setSaving(true);
setSaveError(null);
try {
if (!fileProjectName) {
throw new Error('Missing project identifier');
}
const response = await api.saveFile(fileProjectName, filePath, content);
if (!response.ok) {
const contentType = response.headers.get('content-type');
if (contentType?.includes('application/json')) {
const errorData = await response.json();
throw new Error(errorData.error || `Save failed: ${response.status}`);
}
const textError = await response.text();
console.error('Non-JSON error response:', textError);
throw new Error(`Save failed: ${response.status} ${response.statusText}`);
}
await response.json();
setSaveSuccess(true);
setTimeout(() => setSaveSuccess(false), 2000);
} catch (error) {
const message = getErrorMessage(error);
console.error('Error saving file:', error);
setSaveError(message);
} finally {
setSaving(false);
}
}, [content, filePath, fileProjectName]);
const handleDownload = useCallback(() => {
const blob = new Blob([content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = file.name;
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
URL.revokeObjectURL(url);
}, [content, file.name]);
return {
content,
setContent,
loading,
saving,
saveSuccess,
saveError,
isBinary,
handleSave,
handleDownload,
};
};

View File

@@ -0,0 +1,85 @@
import { useEffect, useState } from 'react';
import {
CODE_EDITOR_DEFAULTS,
CODE_EDITOR_SETTINGS_CHANGED_EVENT,
CODE_EDITOR_STORAGE_KEYS,
} from '../constants/settings';
const readTheme = () => {
const savedTheme = localStorage.getItem(CODE_EDITOR_STORAGE_KEYS.theme);
if (!savedTheme) {
return CODE_EDITOR_DEFAULTS.isDarkMode;
}
return savedTheme === 'dark';
};
const readBoolean = (storageKey: string, defaultValue: boolean, falseValue = 'false') => {
const value = localStorage.getItem(storageKey);
if (value === null) {
return defaultValue;
}
return value !== falseValue;
};
const readWordWrap = () => {
return localStorage.getItem(CODE_EDITOR_STORAGE_KEYS.wordWrap) === 'true';
};
const readFontSize = () => {
const stored = localStorage.getItem(CODE_EDITOR_STORAGE_KEYS.fontSize);
return Number(stored ?? CODE_EDITOR_DEFAULTS.fontSize);
};
export const useCodeEditorSettings = () => {
const [isDarkMode, setIsDarkMode] = useState(readTheme);
const [wordWrap, setWordWrap] = useState(readWordWrap);
const [minimapEnabled, setMinimapEnabled] = useState(() => (
readBoolean(CODE_EDITOR_STORAGE_KEYS.showMinimap, CODE_EDITOR_DEFAULTS.minimapEnabled)
));
const [showLineNumbers, setShowLineNumbers] = useState(() => (
readBoolean(CODE_EDITOR_STORAGE_KEYS.lineNumbers, CODE_EDITOR_DEFAULTS.showLineNumbers)
));
const [fontSize, setFontSize] = useState(readFontSize);
// Keep legacy behavior where the editor writes theme and wrap settings directly.
useEffect(() => {
localStorage.setItem(CODE_EDITOR_STORAGE_KEYS.theme, isDarkMode ? 'dark' : 'light');
}, [isDarkMode]);
useEffect(() => {
localStorage.setItem(CODE_EDITOR_STORAGE_KEYS.wordWrap, String(wordWrap));
}, [wordWrap]);
useEffect(() => {
const refreshFromStorage = () => {
setIsDarkMode(readTheme());
setWordWrap(readWordWrap());
setMinimapEnabled(readBoolean(CODE_EDITOR_STORAGE_KEYS.showMinimap, CODE_EDITOR_DEFAULTS.minimapEnabled));
setShowLineNumbers(readBoolean(CODE_EDITOR_STORAGE_KEYS.lineNumbers, CODE_EDITOR_DEFAULTS.showLineNumbers));
setFontSize(readFontSize());
};
window.addEventListener('storage', refreshFromStorage);
window.addEventListener(CODE_EDITOR_SETTINGS_CHANGED_EVENT, refreshFromStorage);
return () => {
window.removeEventListener('storage', refreshFromStorage);
window.removeEventListener(CODE_EDITOR_SETTINGS_CHANGED_EVENT, refreshFromStorage);
};
}, []);
return {
isDarkMode,
setIsDarkMode,
wordWrap,
setWordWrap,
minimapEnabled,
setMinimapEnabled,
showLineNumbers,
setShowLineNumbers,
fontSize,
setFontSize,
};
};

View File

@@ -0,0 +1,37 @@
import { useEffect } from 'react';
type UseEditorKeyboardShortcutsParams = {
onSave: () => void;
onClose: () => void;
dependency: string;
};
export const useEditorKeyboardShortcuts = ({
onSave,
onClose,
dependency,
}: UseEditorKeyboardShortcutsParams) => {
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault();
onClose();
return;
}
if (!(event.ctrlKey || event.metaKey)) {
return;
}
if (event.key.toLowerCase() === 's') {
event.preventDefault();
onSave();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [dependency, onClose, onSave]);
};

View File

@@ -1,7 +1,7 @@
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';
import type { CodeEditorDiffInfo, CodeEditorFile } from '../types/types';
type UseEditorSidebarOptions = {
selectedProject: Project | null;
@@ -9,19 +9,20 @@ type UseEditorSidebarOptions = {
initialWidth?: number;
};
export function useEditorSidebar({
export const useEditorSidebar = ({
selectedProject,
isMobile,
initialWidth = 600,
}: UseEditorSidebarOptions) {
const [editingFile, setEditingFile] = useState<EditingFile | null>(null);
}: UseEditorSidebarOptions) => {
const [editingFile, setEditingFile] = useState<CodeEditorFile | null>(null);
const [editorWidth, setEditorWidth] = useState(initialWidth);
const [editorExpanded, setEditorExpanded] = useState(false);
const [isResizing, setIsResizing] = useState(false);
const [hasManualWidth, setHasManualWidth] = useState(false);
const resizeHandleRef = useRef<HTMLDivElement | null>(null);
const handleFileOpen = useCallback(
(filePath: string, diffInfo: DiffInfo | null = null) => {
(filePath: string, diffInfo: CodeEditorDiffInfo | null = null) => {
const normalizedPath = filePath.replace(/\\/g, '/');
const fileName = normalizedPath.split('/').pop() || filePath;
@@ -41,7 +42,7 @@ export function useEditorSidebar({
}, []);
const handleToggleEditorExpand = useCallback(() => {
setEditorExpanded((prev) => !prev);
setEditorExpanded((previous) => !previous);
}, []);
const handleResizeStart = useCallback(
@@ -50,6 +51,8 @@ export function useEditorSidebar({
return;
}
// After first drag interaction, the editor width is user-controlled.
setHasManualWidth(true);
setIsResizing(true);
event.preventDefault();
},
@@ -62,12 +65,15 @@ export function useEditorSidebar({
return;
}
const container = resizeHandleRef.current?.parentElement;
if (!container) {
// Get the main container (parent of EditorSidebar's parent) that contains both left content and editor
const editorContainer = resizeHandleRef.current?.parentElement;
const mainContainer = editorContainer?.parentElement;
if (!mainContainer) {
return;
}
const containerRect = container.getBoundingClientRect();
const containerRect = mainContainer.getBoundingClientRect();
// Calculate new editor width: distance from mouse to right edge of main container
const newWidth = containerRect.right - event.clientX;
const minWidth = 300;
@@ -101,10 +107,11 @@ export function useEditorSidebar({
editingFile,
editorWidth,
editorExpanded,
hasManualWidth,
resizeHandleRef,
handleFileOpen,
handleCloseEditor,
handleToggleEditorExpand,
handleResizeStart,
};
}
};

View File

@@ -0,0 +1,21 @@
export type CodeEditorDiffInfo = {
old_string?: string;
new_string?: string;
[key: string]: unknown;
};
export type CodeEditorFile = {
name: string;
path: string;
projectName?: string;
diffInfo?: CodeEditorDiffInfo | null;
[key: string]: unknown;
};
export type CodeEditorSettingsState = {
isDarkMode: boolean;
wordWrap: boolean;
minimapEnabled: boolean;
showLineNumbers: boolean;
fontSize: string;
};

View File

@@ -0,0 +1,22 @@
// Binary file extensions (images are handled by ImageViewer, not here)
const BINARY_EXTENSIONS = [
// Archives
'zip', 'tar', 'gz', 'rar', '7z', 'bz2', 'xz',
// Executables
'exe', 'dll', 'so', 'dylib', 'app', 'dmg', 'msi',
// Media
'mp3', 'mp4', 'wav', 'avi', 'mov', 'mkv', 'flv', 'wmv', 'm4a', 'ogg',
// Documents
'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'odt', 'ods', 'odp',
// Fonts
'ttf', 'otf', 'woff', 'woff2', 'eot',
// Database
'db', 'sqlite', 'sqlite3',
// Other binary
'bin', 'dat', 'iso', 'img', 'class', 'jar', 'war', 'pyc', 'pyo'
];
export const isBinaryFile = (filename: string): boolean => {
const ext = filename.split('.').pop()?.toLowerCase();
return BINARY_EXTENSIONS.includes(ext ?? '');
};

View File

@@ -0,0 +1,141 @@
import { css } from '@codemirror/lang-css';
import { html } from '@codemirror/lang-html';
import { javascript } from '@codemirror/lang-javascript';
import { json } from '@codemirror/lang-json';
import { StreamLanguage } from '@codemirror/language';
import { markdown } from '@codemirror/lang-markdown';
import { python } from '@codemirror/lang-python';
import { getChunks } from '@codemirror/merge';
import { EditorView, ViewPlugin } from '@codemirror/view';
import { showMinimap } from '@replit/codemirror-minimap';
import type { CodeEditorFile } from '../types/types';
// Lightweight lexer for `.env` files (including `.env.*` variants).
const envLanguage = StreamLanguage.define({
token(stream) {
if (stream.match(/^#.*/)) return 'comment';
if (stream.sol() && stream.match(/^[A-Za-z_][A-Za-z0-9_.]*(?==)/)) return 'variableName.definition';
if (stream.match(/^=/)) return 'operator';
if (stream.match(/^"(?:[^"\\]|\\.)*"?/)) return 'string';
if (stream.match(/^'(?:[^'\\]|\\.)*'?/)) return 'string';
if (stream.match(/^\$\{[^}]*\}?/)) return 'variableName.special';
if (stream.match(/^\$[A-Za-z_][A-Za-z0-9_]*/)) return 'variableName.special';
if (stream.match(/^\d+/)) return 'number';
stream.next();
return null;
},
});
export const getLanguageExtensions = (filename: string) => {
const lowerName = filename.toLowerCase();
if (lowerName === '.env' || lowerName.startsWith('.env.')) {
return [envLanguage];
}
const ext = filename.split('.').pop()?.toLowerCase();
switch (ext) {
case 'js':
case 'jsx':
case 'ts':
case 'tsx':
return [javascript({ jsx: true, typescript: ext.includes('ts') })];
case 'py':
return [python()];
case 'html':
case 'htm':
return [html()];
case 'css':
case 'scss':
case 'less':
return [css()];
case 'json':
return [json()];
case 'md':
case 'markdown':
return [markdown()];
case 'env':
return [envLanguage];
default:
return [];
}
};
export const createMinimapExtension = ({
file,
showDiff,
minimapEnabled,
isDarkMode,
}: {
file: CodeEditorFile;
showDiff: boolean;
minimapEnabled: boolean;
isDarkMode: boolean;
}) => {
if (!file.diffInfo || !showDiff || !minimapEnabled) {
return [];
}
const gutters: Record<number, string> = {};
return [
showMinimap.compute(['doc'], (state) => {
const chunksData = getChunks(state);
const chunks = chunksData?.chunks || [];
Object.keys(gutters).forEach((key) => {
delete gutters[Number(key)];
});
chunks.forEach((chunk) => {
const fromLine = state.doc.lineAt(chunk.fromB).number;
const toLine = state.doc.lineAt(Math.min(chunk.toB, state.doc.length)).number;
for (let lineNumber = fromLine; lineNumber <= toLine; lineNumber += 1) {
gutters[lineNumber] = isDarkMode ? 'rgba(34, 197, 94, 0.8)' : 'rgba(34, 197, 94, 1)';
}
});
return {
create: () => ({ dom: document.createElement('div') }),
displayText: 'blocks',
showOverlay: 'always',
gutters: [gutters],
};
}),
];
};
export const createScrollToFirstChunkExtension = ({
file,
showDiff,
}: {
file: CodeEditorFile;
showDiff: boolean;
}) => {
if (!file.diffInfo || !showDiff) {
return [];
}
return [
ViewPlugin.fromClass(class {
constructor(view: EditorView) {
// Wait for merge decorations so the first chunk location is stable.
setTimeout(() => {
const chunksData = getChunks(view.state);
const firstChunk = chunksData?.chunks?.[0];
if (firstChunk) {
view.dispatch({
effects: EditorView.scrollIntoView(firstChunk.fromB, { y: 'center' }),
});
}
}, 100);
}
update() {}
destroy() {}
}),
];
};

View File

@@ -0,0 +1,79 @@
export const getEditorLoadingStyles = (isDarkMode: boolean) => {
return `
.code-editor-loading {
background-color: ${isDarkMode ? '#111827' : '#ffffff'} !important;
}
.code-editor-loading:hover {
background-color: ${isDarkMode ? '#111827' : '#ffffff'} !important;
}
`;
};
export const getEditorStyles = (isDarkMode: boolean) => {
return `
.cm-deletedChunk {
background-color: ${isDarkMode ? 'rgba(239, 68, 68, 0.15)' : 'rgba(255, 235, 235, 1)'} !important;
border-left: 3px solid ${isDarkMode ? 'rgba(239, 68, 68, 0.6)' : 'rgb(239, 68, 68)'} !important;
padding-left: 4px !important;
}
.cm-insertedChunk {
background-color: ${isDarkMode ? 'rgba(34, 197, 94, 0.15)' : 'rgba(230, 255, 237, 1)'} !important;
border-left: 3px solid ${isDarkMode ? 'rgba(34, 197, 94, 0.6)' : 'rgb(34, 197, 94)'} !important;
padding-left: 4px !important;
}
.cm-editor.cm-merge-b .cm-changedText {
background: ${isDarkMode ? 'rgba(34, 197, 94, 0.4)' : 'rgba(34, 197, 94, 0.3)'} !important;
padding-top: 2px !important;
padding-bottom: 2px !important;
margin-top: -2px !important;
margin-bottom: -2px !important;
}
.cm-editor .cm-deletedChunk .cm-changedText {
background: ${isDarkMode ? 'rgba(239, 68, 68, 0.4)' : 'rgba(239, 68, 68, 0.3)'} !important;
padding-top: 2px !important;
padding-bottom: 2px !important;
margin-top: -2px !important;
margin-bottom: -2px !important;
}
.cm-gutter.cm-gutter-minimap {
background-color: ${isDarkMode ? '#1e1e1e' : '#f5f5f5'};
}
.cm-editor-toolbar-panel {
padding: 4px 10px;
background-color: ${isDarkMode ? '#1f2937' : '#ffffff'};
border-bottom: 1px solid ${isDarkMode ? '#374151' : '#e5e7eb'};
color: ${isDarkMode ? '#d1d5db' : '#374151'};
font-size: 12px;
}
.cm-diff-nav-btn,
.cm-toolbar-btn {
padding: 3px;
background: transparent;
border: none;
cursor: pointer;
border-radius: 4px;
display: inline-flex;
align-items: center;
justify-content: center;
color: inherit;
transition: background-color 0.2s;
}
.cm-diff-nav-btn:hover,
.cm-toolbar-btn:hover {
background-color: ${isDarkMode ? '#374151' : '#f3f4f6'};
}
.cm-diff-nav-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
`;
};

View File

@@ -0,0 +1,212 @@
import { getChunks } from '@codemirror/merge';
import { EditorView, showPanel } from '@codemirror/view';
import type { CodeEditorFile } from '../types/types';
type EditorToolbarLabels = {
changes: string;
previousChange: string;
nextChange: string;
hideDiff: string;
showDiff: string;
collapse: string;
expand: string;
};
type CreateEditorToolbarPanelParams = {
file: CodeEditorFile;
showDiff: boolean;
isSidebar: boolean;
isExpanded: boolean;
onToggleDiff: () => void;
onPopOut: (() => void) | null;
onToggleExpand: (() => void) | null;
labels: EditorToolbarLabels;
};
const getDiffVisibilityIcon = (showDiff: boolean) => {
if (showDiff) {
return '<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" />';
}
return '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />';
};
const getExpandIcon = (isExpanded: boolean) => {
if (isExpanded) {
return '<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" />';
}
return '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" />';
};
const escapeHtml = (value: string): string => (
value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
);
export const createEditorToolbarPanelExtension = ({
file,
showDiff,
isSidebar,
isExpanded,
onToggleDiff,
onPopOut,
onToggleExpand,
labels,
}: CreateEditorToolbarPanelParams) => {
const hasToolbarButtons = Boolean(file.diffInfo || (isSidebar && onPopOut) || (isSidebar && onToggleExpand));
if (!hasToolbarButtons) {
return [];
}
const createPanel = (view: EditorView) => {
const dom = document.createElement('div');
dom.className = 'cm-editor-toolbar-panel';
let currentIndex = 0;
const updatePanel = () => {
const hasDiff = Boolean(file.diffInfo && showDiff);
const chunksData = hasDiff ? getChunks(view.state) : null;
const chunks = chunksData?.chunks || [];
const chunkCount = chunks.length;
const maxChunkIndex = Math.max(0, chunkCount - 1);
currentIndex = Math.max(0, Math.min(currentIndex, maxChunkIndex));
const escapedLabels = {
changes: escapeHtml(labels.changes),
previousChange: escapeHtml(labels.previousChange),
nextChange: escapeHtml(labels.nextChange),
hideDiff: escapeHtml(labels.hideDiff),
showDiff: escapeHtml(labels.showDiff),
collapse: escapeHtml(labels.collapse),
expand: escapeHtml(labels.expand),
};
// Icons are static SVG path fragments controlled by this module.
const diffVisibilityIcon = getDiffVisibilityIcon(showDiff);
const expandIcon = getExpandIcon(isExpanded);
let toolbarHtml = '<div style="display: flex; align-items: center; justify-content: space-between; width: 100%;">';
toolbarHtml += '<div style="display: flex; align-items: center; gap: 8px;">';
if (hasDiff) {
toolbarHtml += `
<span style="font-weight: 500;">${chunkCount > 0 ? `${currentIndex + 1}/${chunkCount}` : '0'} ${escapedLabels.changes}</span>
<button class="cm-diff-nav-btn cm-diff-nav-prev" title="${escapedLabels.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="${escapedLabels.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>
</button>
`;
}
toolbarHtml += '</div>';
toolbarHtml += '<div style="display: flex; align-items: center; gap: 4px;">';
if (file.diffInfo) {
toolbarHtml += `
<button class="cm-toolbar-btn cm-toggle-diff-btn" title="${showDiff ? escapedLabels.hideDiff : escapedLabels.showDiff}">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
${diffVisibilityIcon}
</svg>
</button>
`;
}
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>
`;
}
if (isSidebar && onToggleExpand) {
toolbarHtml += `
<button class="cm-toolbar-btn cm-expand-btn" title="${isExpanded ? escapedLabels.collapse : escapedLabels.expand}">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
${expandIcon}
</svg>
</button>
`;
}
toolbarHtml += '</div>';
toolbarHtml += '</div>';
dom.innerHTML = toolbarHtml;
if (hasDiff) {
const previousButton = dom.querySelector<HTMLButtonElement>('.cm-diff-nav-prev');
const nextButton = dom.querySelector<HTMLButtonElement>('.cm-diff-nav-next');
previousButton?.addEventListener('click', () => {
if (chunks.length === 0) {
return;
}
currentIndex = currentIndex > 0 ? currentIndex - 1 : chunks.length - 1;
const chunk = chunks[currentIndex];
if (chunk) {
view.dispatch({
effects: EditorView.scrollIntoView(chunk.fromB, { y: 'center' }),
});
}
updatePanel();
});
nextButton?.addEventListener('click', () => {
if (chunks.length === 0) {
return;
}
currentIndex = currentIndex < chunks.length - 1 ? currentIndex + 1 : 0;
const chunk = chunks[currentIndex];
if (chunk) {
view.dispatch({
effects: EditorView.scrollIntoView(chunk.fromB, { y: 'center' }),
});
}
updatePanel();
});
}
const toggleDiffButton = dom.querySelector<HTMLButtonElement>('.cm-toggle-diff-btn');
toggleDiffButton?.addEventListener('click', onToggleDiff);
const popOutButton = dom.querySelector<HTMLButtonElement>('.cm-popout-btn');
popOutButton?.addEventListener('click', () => {
onPopOut?.();
});
const expandButton = dom.querySelector<HTMLButtonElement>('.cm-expand-btn');
expandButton?.addEventListener('click', () => {
onToggleExpand?.();
});
};
updatePanel();
return {
top: true,
dom,
update: updatePanel,
};
};
return [showPanel.of(createPanel)];
};

View File

@@ -0,0 +1,251 @@
import { EditorView } from '@codemirror/view';
import { unifiedMergeView } from '@codemirror/merge';
import type { Extension } from '@codemirror/state';
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useCodeEditorDocument } from '../hooks/useCodeEditorDocument';
import { useCodeEditorSettings } from '../hooks/useCodeEditorSettings';
import { useEditorKeyboardShortcuts } from '../hooks/useEditorKeyboardShortcuts';
import type { CodeEditorFile } from '../types/types';
import { createMinimapExtension, createScrollToFirstChunkExtension, getLanguageExtensions } from '../utils/editorExtensions';
import { getEditorStyles } from '../utils/editorStyles';
import { createEditorToolbarPanelExtension } from '../utils/editorToolbarPanel';
import CodeEditorFooter from './subcomponents/CodeEditorFooter';
import CodeEditorHeader from './subcomponents/CodeEditorHeader';
import CodeEditorLoadingState from './subcomponents/CodeEditorLoadingState';
import CodeEditorSurface from './subcomponents/CodeEditorSurface';
import CodeEditorBinaryFile from './subcomponents/CodeEditorBinaryFile';
type CodeEditorProps = {
file: CodeEditorFile;
onClose: () => void;
projectPath?: string;
isSidebar?: boolean;
isExpanded?: boolean;
onToggleExpand?: (() => void) | null;
onPopOut?: (() => void) | null;
};
export default function CodeEditor({
file,
onClose,
projectPath,
isSidebar = false,
isExpanded = false,
onToggleExpand = null,
onPopOut = null,
}: CodeEditorProps) {
const { t } = useTranslation('codeEditor');
const [isFullscreen, setIsFullscreen] = useState(false);
const [showDiff, setShowDiff] = useState(Boolean(file.diffInfo));
const [markdownPreview, setMarkdownPreview] = useState(false);
const {
isDarkMode,
wordWrap,
minimapEnabled,
showLineNumbers,
fontSize,
} = useCodeEditorSettings();
const {
content,
setContent,
loading,
saving,
saveSuccess,
saveError,
isBinary,
handleSave,
handleDownload,
} = useCodeEditorDocument({
file,
projectPath,
});
const isMarkdownFile = useMemo(() => {
const extension = file.name.split('.').pop()?.toLowerCase();
return extension === 'md' || extension === 'markdown';
}, [file.name]);
const minimapExtension = useMemo(
() => (
createMinimapExtension({
file,
showDiff,
minimapEnabled,
isDarkMode,
})
),
[file, isDarkMode, minimapEnabled, showDiff],
);
const scrollToFirstChunkExtension = useMemo(
() => createScrollToFirstChunkExtension({ file, showDiff }),
[file, showDiff],
);
const toolbarPanelExtension = useMemo(
() => (
createEditorToolbarPanelExtension({
file,
showDiff,
isSidebar,
isExpanded,
onToggleDiff: () => setShowDiff((previous) => !previous),
onPopOut,
onToggleExpand,
labels: {
changes: t('toolbar.changes'),
previousChange: t('toolbar.previousChange'),
nextChange: t('toolbar.nextChange'),
hideDiff: t('toolbar.hideDiff'),
showDiff: t('toolbar.showDiff'),
collapse: t('toolbar.collapse'),
expand: t('toolbar.expand'),
},
})
),
[file, isExpanded, isSidebar, onPopOut, onToggleExpand, showDiff, t],
);
const extensions = useMemo(() => {
const allExtensions: Extension[] = [
...getLanguageExtensions(file.name),
...toolbarPanelExtension,
];
if (file.diffInfo && showDiff && file.diffInfo.old_string !== undefined) {
allExtensions.push(
unifiedMergeView({
original: file.diffInfo.old_string,
mergeControls: false,
highlightChanges: true,
syntaxHighlightDeletions: false,
gutter: true,
}),
);
allExtensions.push(...minimapExtension);
allExtensions.push(...scrollToFirstChunkExtension);
}
if (wordWrap) {
allExtensions.push(EditorView.lineWrapping);
}
return allExtensions;
}, [
file.diffInfo,
file.name,
minimapExtension,
scrollToFirstChunkExtension,
showDiff,
toolbarPanelExtension,
wordWrap,
]);
useEditorKeyboardShortcuts({
onSave: handleSave,
onClose,
dependency: content,
});
if (loading) {
return (
<CodeEditorLoadingState
isDarkMode={isDarkMode}
isSidebar={isSidebar}
loadingText={t('loading', { fileName: file.name })}
/>
);
}
// Binary file display
if (isBinary) {
return (
<CodeEditorBinaryFile
file={file}
isSidebar={isSidebar}
isFullscreen={isFullscreen}
onClose={onClose}
onToggleFullscreen={() => setIsFullscreen((previous) => !previous)}
title={t('binaryFile.title', 'Binary File')}
message={t('binaryFile.message', 'The file "{{fileName}}" cannot be displayed in the text editor because it is a binary file.', { fileName: file.name })}
/>
);
}
const outerContainerClassName = isSidebar
? 'w-full h-full flex flex-col'
: `fixed inset-0 z-[9999] md:bg-black/50 md:flex md:items-center md:justify-center md:p-4 ${isFullscreen ? 'md:p-0' : ''}`;
const innerContainerClassName = isSidebar
? 'bg-background flex flex-col w-full h-full'
: `bg-background shadow-2xl flex flex-col w-full h-full md:rounded-lg md:shadow-2xl${
isFullscreen ? ' md:w-full md:h-full md:rounded-none' : ' md:w-full md:max-w-6xl md:h-[80vh] md:max-h-[80vh]'
}`;
return (
<>
<style>{getEditorStyles(isDarkMode)}</style>
<div className={outerContainerClassName}>
<div className={innerContainerClassName}>
<CodeEditorHeader
file={file}
isSidebar={isSidebar}
isFullscreen={isFullscreen}
isMarkdownFile={isMarkdownFile}
markdownPreview={markdownPreview}
saving={saving}
saveSuccess={saveSuccess}
onToggleMarkdownPreview={() => setMarkdownPreview((previous) => !previous)}
onOpenSettings={() => window.openSettings?.('appearance')}
onDownload={handleDownload}
onSave={handleSave}
onToggleFullscreen={() => setIsFullscreen((previous) => !previous)}
onClose={onClose}
labels={{
showingChanges: t('header.showingChanges'),
editMarkdown: t('actions.editMarkdown'),
previewMarkdown: t('actions.previewMarkdown'),
settings: t('toolbar.settings'),
download: t('actions.download'),
save: t('actions.save'),
saving: t('actions.saving'),
saved: t('actions.saved'),
fullscreen: t('actions.fullscreen'),
exitFullscreen: t('actions.exitFullscreen'),
close: t('actions.close'),
}}
/>
{saveError && (
<div className="px-3 py-1.5 text-xs text-red-700 bg-red-50 border-b border-red-200 dark:bg-red-900/20 dark:text-red-300 dark:border-red-900/40">
{saveError}
</div>
)}
<div className="flex-1 overflow-hidden">
<CodeEditorSurface
content={content}
onChange={setContent}
markdownPreview={markdownPreview}
isMarkdownFile={isMarkdownFile}
isDarkMode={isDarkMode}
fontSize={fontSize}
showLineNumbers={showLineNumbers}
extensions={extensions}
/>
</div>
<CodeEditorFooter
content={content}
linesLabel={t('footer.lines')}
charactersLabel={t('footer.characters')}
shortcutsLabel={t('footer.shortcuts')}
/>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,133 @@
import { useState, useEffect, useRef } from 'react';
import type { MouseEvent, MutableRefObject } from 'react';
import type { CodeEditorFile } from '../types/types';
import CodeEditor from './CodeEditor';
type EditorSidebarProps = {
editingFile: CodeEditorFile | null;
isMobile: boolean;
editorExpanded: boolean;
editorWidth: number;
hasManualWidth: boolean;
resizeHandleRef: MutableRefObject<HTMLDivElement | null>;
onResizeStart: (event: MouseEvent<HTMLDivElement>) => void;
onCloseEditor: () => void;
onToggleEditorExpand: () => void;
projectPath?: string;
fillSpace?: boolean;
};
// Minimum width for the left content (file tree, chat, etc.)
const MIN_LEFT_CONTENT_WIDTH = 200;
// Minimum width for the editor sidebar
const MIN_EDITOR_WIDTH = 280;
export default function EditorSidebar({
editingFile,
isMobile,
editorExpanded,
editorWidth,
hasManualWidth,
resizeHandleRef,
onResizeStart,
onCloseEditor,
onToggleEditorExpand,
projectPath,
fillSpace,
}: EditorSidebarProps) {
const [poppedOut, setPoppedOut] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const [effectiveWidth, setEffectiveWidth] = useState(editorWidth);
// Adjust editor width when container size changes to ensure buttons are always visible
useEffect(() => {
if (!editingFile || isMobile || poppedOut) return;
const updateWidth = () => {
if (!containerRef.current) return;
const parentElement = containerRef.current.parentElement;
if (!parentElement) return;
const containerWidth = parentElement.clientWidth;
// Calculate maximum allowed editor width
const maxEditorWidth = containerWidth - MIN_LEFT_CONTENT_WIDTH;
if (maxEditorWidth < MIN_EDITOR_WIDTH) {
// Not enough space - pop out the editor so user can still see everything
setPoppedOut(true);
} else if (editorWidth > maxEditorWidth) {
// Editor is too wide - constrain it to ensure left content has space
setEffectiveWidth(maxEditorWidth);
} else {
setEffectiveWidth(editorWidth);
}
};
updateWidth();
window.addEventListener('resize', updateWidth);
// Also use ResizeObserver for more accurate detection
const resizeObserver = new ResizeObserver(updateWidth);
const parentEl = containerRef.current?.parentElement;
if (parentEl) {
resizeObserver.observe(parentEl);
}
return () => {
window.removeEventListener('resize', updateWidth);
resizeObserver.disconnect();
};
}, [editingFile, isMobile, poppedOut, editorWidth]);
if (!editingFile) {
return null;
}
if (isMobile || poppedOut) {
return (
<CodeEditor
file={editingFile}
onClose={() => {
setPoppedOut(false);
onCloseEditor();
}}
projectPath={projectPath}
isSidebar={false}
/>
);
}
// In files tab, fill the remaining width unless user has dragged manually.
const useFlexLayout = editorExpanded || (fillSpace && !hasManualWidth);
return (
<div ref={containerRef} className={`flex h-full flex-shrink-0 min-w-0 ${editorExpanded ? 'flex-1' : ''}`}>
{!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={`border-l border-gray-200 dark:border-gray-700 h-full overflow-hidden ${useFlexLayout ? 'flex-1 min-w-0' : `flex-shrink-0 min-w-[${MIN_EDITOR_WIDTH}px]`}`}
style={useFlexLayout ? undefined : { width: `${effectiveWidth}px`, minWidth: `${MIN_EDITOR_WIDTH}px` }}
>
<CodeEditor
file={editingFile}
onClose={onCloseEditor}
projectPath={projectPath}
isSidebar
isExpanded={editorExpanded}
onToggleExpand={onToggleEditorExpand}
onPopOut={() => setPoppedOut(true)}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,115 @@
import type { CodeEditorFile } from '../../types/types';
type CodeEditorBinaryFileProps = {
file: CodeEditorFile;
isSidebar: boolean;
isFullscreen: boolean;
onClose: () => void;
onToggleFullscreen: () => void;
title: string;
message: string;
};
export default function CodeEditorBinaryFile({
file,
isSidebar,
isFullscreen,
onClose,
onToggleFullscreen,
title,
message,
}: CodeEditorBinaryFileProps) {
const binaryContent = (
<div className="w-full h-full flex flex-col items-center justify-center bg-background text-muted-foreground p-8">
<div className="flex flex-col items-center gap-4 max-w-md text-center">
<div className="w-16 h-16 rounded-full bg-muted flex items-center justify-center">
<svg className="w-8 h-8 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<div>
<h3 className="text-lg font-medium text-foreground mb-2">{title}</h3>
<p className="text-sm text-muted-foreground">{message}</p>
</div>
<button
onClick={onClose}
className="mt-4 px-4 py-2 text-sm bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors"
>
Close
</button>
</div>
</div>
);
if (isSidebar) {
return (
<div className="w-full h-full flex flex-col bg-background">
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border flex-shrink-0">
<div className="flex items-center gap-2 min-w-0 flex-1">
<h3 className="text-sm font-medium text-gray-900 dark:text-white truncate">{file.name}</h3>
</div>
<button
type="button"
onClick={onClose}
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 flex items-center justify-center"
title="Close"
>
<svg className="w-4 h-4" 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>
{binaryContent}
</div>
);
}
const containerClassName = isFullscreen
? 'fixed inset-0 z-[9999] bg-background flex flex-col'
: 'fixed inset-0 z-[9999] md:bg-black/50 md:flex md:items-center md:justify-center md:p-4';
const innerClassName = isFullscreen
? 'bg-background flex flex-col w-full h-full'
: 'bg-background shadow-2xl flex flex-col w-full h-full md:rounded-lg md:shadow-2xl md:w-full md:max-w-2xl md:h-auto md:max-h-[60vh]';
return (
<div className={containerClassName}>
<div className={innerClassName}>
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border flex-shrink-0">
<div className="flex items-center gap-2 min-w-0 flex-1">
<h3 className="text-sm font-medium text-gray-900 dark:text-white truncate">{file.name}</h3>
</div>
<div className="flex items-center gap-0.5 shrink-0">
<button
type="button"
onClick={onToggleFullscreen}
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 flex items-center justify-center"
title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
>
{isFullscreen ? (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 9V4.5M9 9H4.5M9 9L3.5 3.5M9 15v4.5M9 15H4.5M9 15l-5.5 5.5M15 9h4.5M15 9V4.5M15 9l5.5-5.5M15 15h4.5M15 15v4.5m0-4.5l5.5 5.5" />
</svg>
) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" />
</svg>
)}
</button>
<button
type="button"
onClick={onClose}
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 flex items-center justify-center"
title="Close"
>
<svg className="w-4 h-4" 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>
</div>
{binaryContent}
</div>
</div>
);
}

View File

@@ -0,0 +1,28 @@
type CodeEditorFooterProps = {
content: string;
linesLabel: string;
charactersLabel: string;
shortcutsLabel: string;
};
export default function CodeEditorFooter({
content,
linesLabel,
charactersLabel,
shortcutsLabel,
}: CodeEditorFooterProps) {
return (
<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>
{linesLabel} {content.split('\n').length}
</span>
<span>
{charactersLabel} {content.length}
</span>
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">{shortcutsLabel}</div>
</div>
);
}

View File

@@ -0,0 +1,145 @@
import { Code2, Download, Eye, Maximize2, Minimize2, Save, Settings as SettingsIcon, X } from 'lucide-react';
import type { CodeEditorFile } from '../../types/types';
type CodeEditorHeaderProps = {
file: CodeEditorFile;
isSidebar: boolean;
isFullscreen: boolean;
isMarkdownFile: boolean;
markdownPreview: boolean;
saving: boolean;
saveSuccess: boolean;
onToggleMarkdownPreview: () => void;
onOpenSettings: () => void;
onDownload: () => void;
onSave: () => void;
onToggleFullscreen: () => void;
onClose: () => void;
labels: {
showingChanges: string;
editMarkdown: string;
previewMarkdown: string;
settings: string;
download: string;
save: string;
saving: string;
saved: string;
fullscreen: string;
exitFullscreen: string;
close: string;
};
};
export default function CodeEditorHeader({
file,
isSidebar,
isFullscreen,
isMarkdownFile,
markdownPreview,
saving,
saveSuccess,
onToggleMarkdownPreview,
onOpenSettings,
onDownload,
onSave,
onToggleFullscreen,
onClose,
labels,
}: CodeEditorHeaderProps) {
const saveTitle = saveSuccess ? labels.saved : saving ? labels.saving : labels.save;
return (
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border flex-shrink-0 min-w-0 gap-2">
{/* File info - can shrink */}
<div className="flex items-center gap-2 min-w-0 flex-1 shrink">
<div className="min-w-0 shrink">
<div className="flex items-center gap-2 min-w-0">
<h3 className="text-sm font-medium text-gray-900 dark:text-white truncate">{file.name}</h3>
{file.diffInfo && (
<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 shrink-0">
{labels.showingChanges}
</span>
)}
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">{file.path}</p>
</div>
</div>
{/* Buttons - don't shrink, always visible */}
<div className="flex items-center gap-0.5 shrink-0">
{isMarkdownFile && (
<button
type="button"
onClick={onToggleMarkdownPreview}
className={`p-1.5 rounded-md 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 ? labels.editMarkdown : labels.previewMarkdown}
>
{markdownPreview ? <Code2 className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
)}
<button
type="button"
onClick={onOpenSettings}
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 flex items-center justify-center"
title={labels.settings}
>
<SettingsIcon className="w-4 h-4" />
</button>
<button
type="button"
onClick={onDownload}
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 flex items-center justify-center"
title={labels.download}
>
<Download className="w-4 h-4" />
</button>
<button
type="button"
onClick={onSave}
disabled={saving}
className={`p-1.5 rounded-md disabled:opacity-50 flex items-center justify-center transition-colors ${
saveSuccess
? '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={saveTitle}
>
{saveSuccess ? (
<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-4 h-4" />
)}
</button>
{!isSidebar && (
<button
type="button"
onClick={onToggleFullscreen}
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 flex items-center justify-center"
title={isFullscreen ? labels.exitFullscreen : labels.fullscreen}
>
{isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
</button>
)}
<button
type="button"
onClick={onClose}
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 flex items-center justify-center"
title={labels.close}
>
<X className="w-4 h-4" />
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,36 @@
import { getEditorLoadingStyles } from '../../utils/editorStyles';
type CodeEditorLoadingStateProps = {
isDarkMode: boolean;
isSidebar: boolean;
loadingText: string;
};
export default function CodeEditorLoadingState({
isDarkMode,
isSidebar,
loadingText,
}: CodeEditorLoadingStateProps) {
return (
<>
<style>{getEditorLoadingStyles(isDarkMode)}</style>
{isSidebar ? (
<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" />
<span className="text-gray-900 dark:text-white">{loadingText}</span>
</div>
</div>
) : (
<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" />
<span className="text-gray-900 dark:text-white">{loadingText}</span>
</div>
</div>
</div>
)}
</>
);
}

View File

@@ -0,0 +1,62 @@
import CodeMirror from '@uiw/react-codemirror';
import { oneDark } from '@codemirror/theme-one-dark';
import type { Extension } from '@codemirror/state';
import MarkdownPreview from './markdown/MarkdownPreview';
type CodeEditorSurfaceProps = {
content: string;
onChange: (value: string) => void;
markdownPreview: boolean;
isMarkdownFile: boolean;
isDarkMode: boolean;
fontSize: number;
showLineNumbers: boolean;
extensions: Extension[];
};
export default function CodeEditorSurface({
content,
onChange,
markdownPreview,
isMarkdownFile,
isDarkMode,
fontSize,
showLineNumbers,
extensions,
}: CodeEditorSurfaceProps) {
if (markdownPreview && isMarkdownFile) {
return (
<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>
);
}
return (
<CodeMirror
value={content}
onChange={onChange}
extensions={extensions}
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,
}}
/>
);
}

View File

@@ -0,0 +1,72 @@
import { useState } from 'react';
import type { ComponentProps } from 'react';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { oneDark as prismOneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { copyTextToClipboard } from '../../../../../utils/clipboard';
type MarkdownCodeBlockProps = {
inline?: boolean;
node?: unknown;
} & ComponentProps<'code'>;
export default function MarkdownCodeBlock({
inline,
className,
children,
node: _node,
...props
}: MarkdownCodeBlockProps) {
const [copied, setCopied] = useState(false);
const rawContent = Array.isArray(children) ? children.join('') : String(children ?? '');
const looksMultiline = /[\r\n]/.test(rawContent);
const shouldRenderInline = inline || !looksMultiline;
if (shouldRenderInline) {
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 languageMatch = /language-(\w+)/.exec(className || '');
const language = languageMatch ? languageMatch[1] : 'text';
return (
<div className="relative group my-2">
{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={() =>
copyTextToClipboard(rawContent).then((success) => {
if (success) {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
})}
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 !== 'text' ? '2rem 1rem 1rem 1rem' : '1rem',
}}
>
{rawContent}
</SyntaxHighlighter>
</div>
);
}

View File

@@ -0,0 +1,52 @@
import { useMemo } from 'react';
import type { Components } from 'react-markdown';
import ReactMarkdown from 'react-markdown';
import rehypeKatex from 'rehype-katex';
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
import MarkdownCodeBlock from './MarkdownCodeBlock';
type MarkdownPreviewProps = {
content: string;
};
const markdownPreviewComponents: Components = {
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>
),
};
export default function MarkdownPreview({ content }: MarkdownPreviewProps) {
const remarkPlugins = useMemo(() => [remarkGfm, remarkMath], []);
const rehypePlugins = useMemo(() => [rehypeKatex], []);
return (
<ReactMarkdown
remarkPlugins={remarkPlugins}
rehypePlugins={rehypePlugins}
components={markdownPreviewComponents}
>
{content}
</ReactMarkdown>
);
}

View File

@@ -0,0 +1,18 @@
import type { FileTreeViewMode } from '../types/types';
export const FILE_TREE_VIEW_MODE_STORAGE_KEY = 'file-tree-view-mode';
export const FILE_TREE_DEFAULT_VIEW_MODE: FileTreeViewMode = 'detailed';
export const FILE_TREE_VIEW_MODES: FileTreeViewMode[] = ['simple', 'compact', 'detailed'];
export const IMAGE_FILE_EXTENSIONS = new Set([
'png',
'jpg',
'jpeg',
'gif',
'svg',
'webp',
'ico',
'bmp',
]);

View File

@@ -0,0 +1,224 @@
import {
Archive,
Binary,
Blocks,
BookOpen,
Box,
Braces,
Code2,
Cog,
Coffee,
Cpu,
Database,
File,
FileCheck,
FileCode,
FileCode2,
FileSpreadsheet,
FileText,
FileType,
Flame,
FlaskConical,
Gem,
Globe,
Hash,
Hexagon,
Image,
Lock,
Music2,
NotebookPen,
Palette,
Scroll,
Settings,
Shield,
SquareFunction,
Terminal,
Video,
Workflow,
} from 'lucide-react';
import type { FileIconData, FileIconMap } from '../types/types';
export const ICON_SIZE_CLASS = 'w-4 h-4 flex-shrink-0';
const FILE_ICON_MAP: FileIconMap = {
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' },
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' },
rs: { icon: Cog, color: 'text-orange-600' },
toml: { icon: Settings, color: 'text-gray-500' },
go: { icon: Hexagon, color: 'text-cyan-500' },
rb: { icon: Gem, color: 'text-red-500' },
erb: { icon: Gem, color: 'text-red-400' },
php: { icon: Blocks, color: 'text-violet-500' },
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: { 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' },
cs: { icon: Hexagon, color: 'text-purple-600' },
swift: { icon: Flame, color: 'text-orange-500' },
lua: { icon: SquareFunction, color: 'text-blue-500' },
r: { icon: FlaskConical, color: 'text-blue-600' },
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' },
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' },
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' },
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' },
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' },
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' },
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' },
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' },
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: { icon: Lock, color: 'text-gray-500' },
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' },
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' },
};
const FILENAME_ICON_MAP: FileIconMap = {
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' },
};
// Icon resolution is deterministic: exact filename, then .env prefixes, then extension, then fallback.
export function getFileIconData(filename: string): FileIconData {
if (FILENAME_ICON_MAP[filename]) {
return FILENAME_ICON_MAP[filename];
}
if (filename.startsWith('.env')) {
return { icon: Shield, color: 'text-yellow-600' };
}
const extension = filename.split('.').pop()?.toLowerCase();
if (extension && FILE_ICON_MAP[extension]) {
return FILE_ICON_MAP[extension];
}
return { icon: File, color: 'text-muted-foreground' };
}

View File

@@ -0,0 +1,50 @@
import { useCallback, useState } from 'react';
type UseExpandedDirectoriesResult = {
expandedDirs: Set<string>;
toggleDirectory: (path: string) => void;
expandDirectories: (paths: string[]) => void;
collapseAll: () => void;
};
export function useExpandedDirectories(): UseExpandedDirectoriesResult {
const [expandedDirs, setExpandedDirs] = useState<Set<string>>(() => new Set());
const toggleDirectory = useCallback((path: string) => {
setExpandedDirs((previous) => {
const next = new Set(previous);
if (next.has(path)) {
next.delete(path);
} else {
next.add(path);
}
return next;
});
}, []);
const expandDirectories = useCallback((paths: string[]) => {
if (paths.length === 0) {
return;
}
setExpandedDirs((previous) => {
const next = new Set(previous);
paths.forEach((path) => next.add(path));
return next;
});
}, []);
const collapseAll = useCallback(() => {
setExpandedDirs(new Set());
}, []);
return {
expandedDirs,
toggleDirectory,
expandDirectories,
collapseAll,
};
}

View File

@@ -0,0 +1,89 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { api } from '../../../utils/api';
import type { Project } from '../../../types/app';
import type { FileTreeNode } from '../types/types';
type UseFileTreeDataResult = {
files: FileTreeNode[];
loading: boolean;
refreshFiles: () => void;
};
export function useFileTreeData(selectedProject: Project | null): UseFileTreeDataResult {
const [files, setFiles] = useState<FileTreeNode[]>([]);
const [loading, setLoading] = useState(false);
const [refreshKey, setRefreshKey] = useState(0);
const abortControllerRef = useRef<AbortController | null>(null);
const refreshFiles = useCallback(() => {
setRefreshKey((prev) => prev + 1);
}, []);
useEffect(() => {
const projectName = selectedProject?.name;
if (!projectName) {
setFiles([]);
setLoading(false);
return;
}
// Abort previous request
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
abortControllerRef.current = new AbortController();
// Track mount state so aborted or late responses do not enqueue stale state updates.
let isActive = true;
const fetchFiles = async () => {
if (isActive) {
setLoading(true);
}
try {
const response = await api.getFiles(projectName, { signal: abortControllerRef.current!.signal });
if (!response.ok) {
const errorText = await response.text();
console.error('File fetch failed:', response.status, errorText);
if (isActive) {
setFiles([]);
}
return;
}
const data = (await response.json()) as FileTreeNode[];
if (isActive) {
setFiles(data);
}
} catch (error) {
if ((error as { name?: string }).name === 'AbortError') {
return;
}
console.error('Error fetching files:', error);
if (isActive) {
setFiles([]);
}
} finally {
if (isActive) {
setLoading(false);
}
}
};
void fetchFiles();
return () => {
isActive = false;
abortControllerRef.current?.abort();
};
}, [selectedProject?.name, refreshKey]);
return {
files,
loading,
refreshFiles,
};
}

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