Compare commits

...

144 Commits

Author SHA1 Message Date
viper151
e36d849b5e Merge branch 'main' into refactor/ws-context-latest-message-platform-config 2026-02-03 10:01:52 +01:00
Haileyesus
216932e7f9 fix: correct spelling of "claude code" and update license to GPL-3.0 2026-02-02 20:51:44 +01:00
Haileyesus
a35140bc5e refactor: update WebSocket connection effect to depend on token changes for reconnection 2026-02-02 12:20:51 +03:00
Haileyesus
0b4d048e9a refactor(backend): update environment variable handling and replace VITE_IS_PLATFORM with IS_PLATFORM constant 2026-02-02 11:54:55 +03:00
Haileyesus
312654fdc6 refactor: comment out debug log for render count in AppContent component 2026-01-31 15:59:43 +03:00
Haileyesus
438b9698cc refactor: optimize WebSocket connection handling with useCallback and useMemo 2026-01-31 15:56:34 +03:00
Haileyesus
20d31da4f4 refactor: replace messages with latestMessage in WebSocket context and related components
Why?
Because, messages was only being used to access the latest message in the components it's used in.
2026-01-31 15:43:24 +03:00
Haileyesus
4f87018e61 refactor: update import path for IS_PLATFORM constant to use config file 2026-01-31 15:30:54 +03:00
Haileyesus
b2fdb90203 refactor: move IS_PLATFORM to config file for both frontend and backend
The reason we couldn't place it in shared/modelConstants.js is that the
frontend uses Vite which requires import.meta.env for environment variables,
while the backend uses process.env. Therefore, we created separate config files
for the frontend (src/constants/config.ts) and backend (server/constants/config.js).
2026-01-31 15:01:17 +03:00
Haileyesus
8bea3d83c8 refactor: Use IS_PLATFORM constant for platform detection in authenticatedFetch function (backend) 2026-01-31 14:35:26 +03:00
Haileyesus
cfd766819a refactor: Centralize platform mode detection using IS_PLATFORM constant; use token from Auth context in WebSocket connection 2026-01-31 14:34:01 +03:00
Haileyesus
471892b2bd refactor: Extract WebSocket URL construction into a separate function 2026-01-31 12:03:40 +03:00
Haileyesus
eca96c6973 fix: Prevent WebSocket reconnection attempts after unmount
Right now, when the WebSocketContext component unmounts,
there is still a pending reconnection attempt that tries
to reconnect the WebSocket after 3 seconds.
2026-01-31 11:58:46 +03:00
Haileyesus
5a4813f9bd fix: add type definition for WebSocket URL and remove redundant protocol declaration 2026-01-31 11:54:24 +03:00
Haileyesus
f6970d6ad9 fix: replace WebSocketContext default value with null and add type definitions 2026-01-31 11:45:46 +03:00
Haileyesus
e65a210cb3 fix: use useRef for WebSocketContext
The main issue with using states was, previously the websocket never closed
properly on unmount, so multiple connections could be opened.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

The fix is applied server-side which:
- Reduces data transferred to the client
- Benefits all features using getFileTree()
- Provides consistent filtering across the application

Fixes #290

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 18:43:58 -05:00
viper151
42b2d5e1d9 Merge pull request #289 from siteboon/feat/show-grant-permission-button-in-chat-for-claude
Add inline permission grant for Claude tool errors
2026-01-12 15:12:04 +01:00
viper151
d3c4821258 Merge branch 'main' into feat/show-grant-permission-button-in-chat-for-claude 2026-01-12 12:53:58 +01:00
Haileyesus Dessie
72c4b0749e Merge pull request #277 from whittlelabs/feature/drag-sidebar-handle
feat: add draggable Quick Settings sidebar handle
2026-01-12 12:31:26 +03:00
Haileyesus Dessie
35e140b941 add a clarification comment about crypto.randomUUID() 2026-01-10 15:00:46 +03:00
Haileyesus Dessie
b70728254b fix: move safeJsonParse function to utils.js 2026-01-10 14:36:58 +03:00
Haileyesus Dessie
64ebbaf387 feat: setup canUseTool for claude messages 2026-01-10 14:35:51 +03:00
Haileyesus Dessie
cdaff9d146 Merge branch 'feat/show-grant-permission-button-in-chat-for-claude' of https://github.com/siteboon/claudecodeui into feat/show-grant-permission-button-in-chat-for-claude 2026-01-08 12:21:34 +03:00
Haileyesus Dessie
3f66179e72 fix: remove regex for tool permission extraction 2026-01-08 12:21:28 +03:00
viper151
c654f489af Merge branch 'main' into feat/show-grant-permission-button-in-chat-for-claude 2026-01-07 22:13:48 +01:00
viper151
97ebef016a Merge pull request #288 from siteboon/fix/move-to-correct-scroll-position-in-long-messages-chat
fix: normalize file path handling and improve scroll position restoration
2026-01-07 22:11:55 +01:00
Haileyesus Dessie
ef44942767 feat: add Bash command approval handling in Claude tool permissions 2026-01-07 22:31:17 +03:00
Haileyesus Dessie
7b63a68e7e feat: add grant permission for Claude tools in ChatInterface 2026-01-07 21:49:05 +03:00
Haileyesus Dessie
005033136b fix: normalize file path handling and improve scroll position restoration in ChatInterface 2026-01-07 20:59:41 +03:00
viper151
ee3917b3f9 Merge branch 'main' into main 2026-01-06 12:43:35 +01:00
viper151
8fb43d358c Merge pull request #283 from siteboon/fix/server-crash-when-opening-settings 2026-01-05 18:59:05 +01:00
Haileyesus Dessie
4c40a33255 fix: improve error handling and response structure in MCP CLI routes for codex 2026-01-05 20:54:26 +03:00
viper151
4086fdaa4e Merge pull request #275 from siteboon/fix/navigate-to-correct-session-id-using-codex
fix: navigate to the correct session ID when updating session state
2026-01-05 17:00:23 +01:00
viper151
124c1ac600 Merge branch 'main' into fix/navigate-to-correct-session-id-using-codex 2026-01-05 16:59:24 +01:00
Haileyesus Dessie
9efe433d99 fix: get codex sessions in windows; improve message counting logic; fix session navigation in ChatInterface 2026-01-05 16:35:20 +03:00
Haileyesus Dessie
189a1b174c Merge pull request #244 from ybalbert001/main
[FixBug] The Desktop version's "New Project" button is always hidden
2026-01-01 14:53:28 +03:00
Haileyesus Dessie
04a0ff311e Merge branch 'main' into main 2026-01-01 14:49:30 +03:00
Haileyesus Dessie
efae890e34 Update button title for creating new project 2026-01-01 14:46:09 +03:00
Keith Morris
ea33810a4f fix: add error handling and cleanup for draggable handle
- Add try-catch for localStorage JSON.parse to handle corrupted data
- Remove invalid localStorage key when parsing fails
- Add cleanup effect to reset body styles if component unmounts while dragging
2025-12-31 17:41:52 -05:00
Keith Morris
4fe6cc4272 feat: add draggable Quick Settings sidebar handle
Add ability to reposition the Quick Settings sidebar handle by dragging it vertically on both desktop and mobile devices. The handle combines toggle and drag functionality in a single compact button.

Key features:
- Drag handle vertically to reposition on screen
- Click to toggle sidebar open/closed
- Smart gesture detection (5px threshold distinguishes drag from click)
- Visual feedback with blue grip icon during drag
- Position persists in localStorage across sessions
- Constrained to 10-90% of viewport height
- Full touch support with proper scroll prevention on mobile
- Responsive positioning (top-based on desktop, bottom-based on mobile)
2025-12-31 14:54:43 -05:00
Haileyesus Dessie
ba70ad8e81 fix: navigate to the correct session ID when updating session state 2025-12-31 19:10:33 +03:00
simosmik
b066ec4c01 fix: change codex login for platform mode 2025-12-31 10:47:55 +00:00
simosmik
104e4260a7 Release 1.13.6 2025-12-31 08:00:36 +00:00
simosmik
8af982e706 feat: add update command to CLI for checking and installing the latest version 2025-12-31 07:59:13 +00:00
viper151
81c0773358 Merge branch 'main' into main 2025-12-31 08:54:50 +01:00
viper151
29783f609f Merge branch 'main' into main 2025-12-31 08:53:45 +01:00
simosmik
ea19bd9a00 Release 1.13.5 2025-12-31 07:52:43 +00:00
viper151
6d4e5017d0 Merge pull request #257 from panta82/main
Fix issue: Broken pasted image upload
2025-12-31 08:49:03 +01:00
viper151
9b217ada0d Merge branch 'main' into main 2025-12-31 08:48:30 +01:00
simosmik
04efaa41f6 feat: add custom port and database path options to CLI commands 2025-12-31 07:42:01 +00:00
Ivan Pantic
19bb741af0 Fix issue: Broken pasted image upload 2025-12-12 00:27:09 +01:00
Zhenhong Du
89c9aec5b7 feat: add codeblock highlight in ChatInterface 2025-12-01 12:01:30 +08:00
Zhenhong Du
e74a813093 add packages for code highlight in chatui 2025-12-01 11:57:44 +08:00
Yuanbo Li
73a0b5bebd [FixBug] The Desktop version's "New Project" button is wrapped by the conditional logic projects.length > 0, causing it to not display when there are no projects, preventing users from creating new projects. 2025-11-26 11:45:01 +08:00
68 changed files with 6562 additions and 1149 deletions

View File

@@ -1,11 +1,13 @@
<div align="center">
<img src="public/logo.svg" alt="Claude Code UI" width="64" height="64">
<h1>Claude Code UI</h1>
<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.
[English](./README.md) | [中文](./README.zh-CN.md)
## Screenshots
<div align="center">
@@ -88,32 +90,31 @@ claude-code-ui
**To restart**: Stop with Ctrl+C and run `claude-code-ui` again.
### CLI Commands
**To update**:
```bash
cloudcli update
```
### CLI Usage
After global installation, you have access to both `claude-code-ui` and `cloudcli` commands:
| Command / Option | Short | Description |
|------------------|-------|-------------|
| `cloudcli` or `claude-code-ui` | | Start the server (default) |
| `cloudcli start` | | Start the server explicitly |
| `cloudcli status` | | Show configuration and data locations |
| `cloudcli update` | | Update to the latest version |
| `cloudcli help` | | Show help information |
| `cloudcli version` | | Show version information |
| `--port <port>` | `-p` | Set server port (default: 3001) |
| `--database-path <path>` | | Set custom database location |
**Examples:**
```bash
# Start the server (default command)
claude-code-ui
cloudcli start
# Show configuration and data locations
cloudcli status
# Show help information
cloudcli help
# Show version
cloudcli version
```
**The `cloudcli status` command shows you:**
- Installation directory location
- Database location (where credentials are stored)
- Current configuration (PORT, DATABASE_PATH, etc.)
- Claude projects folder location
- Configuration file location
cloudcli # Start with defaults
cloudcli -p 8080 # Start on custom port
cloudcli status # Show current configuration
```
### Run as Background Service (Recommended for Production)
@@ -134,6 +135,9 @@ pm2 start claude-code-ui --name "claude-code-ui"
# Or using the shorter alias
pm2 start cloudcli --name "claude-code-ui"
# Start on a custom port
pm2 start cloudcli --name "claude-code-ui" -- --port 8080
```

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

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

414
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@siteboon/claude-code-ui",
"version": "1.13.3",
"version": "1.16.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@siteboon/claude-code-ui",
"version": "1.13.3",
"version": "1.16.3",
"license": "MIT",
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.1.29",
@@ -39,6 +39,8 @@
"express": "^4.18.2",
"fuse.js": "^7.0.0",
"gray-matter": "^4.0.3",
"i18next": "^25.7.4",
"i18next-browser-languagedetector": "^8.2.0",
"jsonwebtoken": "^9.0.2",
"katex": "^0.16.25",
"lucide-react": "^0.515.0",
@@ -49,8 +51,10 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-dropzone": "^14.2.3",
"react-i18next": "^16.5.3",
"react-markdown": "^10.1.0",
"react-router-dom": "^6.8.1",
"react-syntax-highlighter": "^15.6.1",
"rehype-katex": "^7.0.1",
"remark-gfm": "^4.0.0",
"remark-math": "^6.0.0",
@@ -64,6 +68,7 @@
"cloudcli": "server/cli.js"
},
"devDependencies": {
"@types/node": "^22.19.7",
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@vitejs/plugin-react": "^4.6.0",
@@ -75,6 +80,7 @@
"release-it": "^19.0.5",
"sharp": "^0.34.2",
"tailwindcss": "^3.4.0",
"typescript": "^5.9.3",
"vite": "^7.0.4"
}
},
@@ -359,9 +365,9 @@
}
},
"node_modules/@babel/runtime": {
"version": "7.27.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz",
"integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==",
"version": "7.28.6",
"resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.28.6.tgz",
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -2902,6 +2908,16 @@
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.19.7",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz",
"integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@types/parse-path": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/@types/parse-path/-/parse-path-7.0.3.tgz",
@@ -5057,6 +5073,19 @@
"reusify": "^1.0.4"
}
},
"node_modules/fault": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/fault/-/fault-1.0.4.tgz",
"integrity": "sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA==",
"license": "MIT",
"dependencies": {
"format": "^0.2.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/file-selector": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz",
@@ -5136,6 +5165,14 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/format": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz",
"integrity": "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==",
"engines": {
"node": ">=0.4.x"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -5756,6 +5793,30 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/highlight.js": {
"version": "10.7.3",
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz",
"integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==",
"license": "BSD-3-Clause",
"engines": {
"node": "*"
}
},
"node_modules/highlightjs-vue": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/highlightjs-vue/-/highlightjs-vue-1.0.0.tgz",
"integrity": "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==",
"license": "CC0-1.0"
},
"node_modules/html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://registry.npmmirror.com/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
"license": "MIT",
"dependencies": {
"void-elements": "3.1.0"
}
},
"node_modules/html-url-attributes": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
@@ -5837,6 +5898,46 @@
"ms": "^2.0.0"
}
},
"node_modules/i18next": {
"version": "25.7.4",
"resolved": "https://registry.npmmirror.com/i18next/-/i18next-25.7.4.tgz",
"integrity": "sha512-hRkpEblXXcXSNbw8mBNq9042OEetgyB/ahc/X17uV/khPwzV+uB8RHceHh3qavyrkPJvmXFKXME2Sy1E0KjAfw==",
"funding": [
{
"type": "individual",
"url": "https://locize.com"
},
{
"type": "individual",
"url": "https://locize.com/i18next.html"
},
{
"type": "individual",
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
}
],
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.4"
},
"peerDependencies": {
"typescript": "^5"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/i18next-browser-languagedetector": {
"version": "8.2.0",
"resolved": "https://registry.npmmirror.com/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.0.tgz",
"integrity": "sha512-P+3zEKLnOF0qmiesW383vsLdtQVyKtCNA9cjSoKCppTKPQVfKd2W8hbVo5ZhNJKDqeM7BOcvNoKJOjpHh4Js9g==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.23.2"
}
},
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
@@ -6569,6 +6670,20 @@
"loose-envify": "cli.js"
}
},
"node_modules/lowlight": {
"version": "1.20.0",
"resolved": "https://registry.npmjs.org/lowlight/-/lowlight-1.20.0.tgz",
"integrity": "sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw==",
"license": "MIT",
"dependencies": {
"fault": "^1.0.0",
"highlight.js": "~10.7.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -8101,9 +8216,9 @@
}
},
"node_modules/node-pty": {
"version": "1.1.0-beta9",
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0-beta9.tgz",
"integrity": "sha512-/Ue38pvXJdgRZ3+me1FgfglLd301GhJN0NStiotdt61tm43N5htUyR/IXOUzOKuNaFmCwIhy6nwb77Ky41LMbw==",
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/node-pty/-/node-pty-1.1.0.tgz",
"integrity": "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@@ -8840,6 +8955,15 @@
"node": ">=10"
}
},
"node_modules/prismjs": {
"version": "1.30.0",
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz",
"integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/proc-log": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/proc-log/-/proc-log-4.2.0.tgz",
@@ -9086,6 +9210,33 @@
"react": ">= 16.8 || 18.0.0"
}
},
"node_modules/react-i18next": {
"version": "16.5.3",
"resolved": "https://registry.npmmirror.com/react-i18next/-/react-i18next-16.5.3.tgz",
"integrity": "sha512-fo+/NNch37zqxOzlBYrWMx0uy/yInPkRfjSuy4lqKdaecR17nvCHnEUt3QyzA8XjQ2B/0iW/5BhaHR3ZmukpGw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.4",
"html-parse-stringify": "^3.0.1",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
"i18next": ">= 25.6.2",
"react": ">= 16.8.0",
"typescript": "^5"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
@@ -9161,6 +9312,23 @@
"react-dom": ">=16.8"
}
},
"node_modules/react-syntax-highlighter": {
"version": "15.6.6",
"resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-15.6.6.tgz",
"integrity": "sha512-DgXrc+AZF47+HvAPEmn7Ua/1p10jNoVZVI/LoPiYdtY+OM+/nG5yefLHKJwdKqY1adMuHFbeyBaG9j64ML7vTw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.3.1",
"highlight.js": "^10.4.1",
"highlightjs-vue": "^1.0.0",
"lowlight": "^1.17.0",
"prismjs": "^1.30.0",
"refractor": "^3.6.0"
},
"peerDependencies": {
"react": ">= 0.14.0"
}
},
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@@ -9197,6 +9365,197 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/refractor": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/refractor/-/refractor-3.6.0.tgz",
"integrity": "sha512-MY9W41IOWxxk31o+YvFCNyNzdkc9M20NoZK5vq6jkv4I/uh2zkWcfudj0Q1fovjUQJrNewS9NMzeTtqPf+n5EA==",
"license": "MIT",
"dependencies": {
"hastscript": "^6.0.0",
"parse-entities": "^2.0.0",
"prismjs": "~1.27.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/refractor/node_modules/@types/hast": {
"version": "2.3.10",
"resolved": "https://registry.npmjs.org/@types/hast/-/hast-2.3.10.tgz",
"integrity": "sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw==",
"license": "MIT",
"dependencies": {
"@types/unist": "^2"
}
},
"node_modules/refractor/node_modules/@types/unist": {
"version": "2.0.11",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz",
"integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
"license": "MIT"
},
"node_modules/refractor/node_modules/character-entities": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/character-entities/-/character-entities-1.2.4.tgz",
"integrity": "sha512-iBMyeEHxfVnIakwOuDXpVkc54HijNgCyQB2w0VfGQThle6NXn50zU6V/u+LDhxHcDUPojn6Kpga3PTAD8W1bQw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/refractor/node_modules/character-entities-legacy": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-1.1.4.tgz",
"integrity": "sha512-3Xnr+7ZFS1uxeiUDvV02wQ+QDbc55o97tIV5zHScSPJpcLm/r0DFPcoY3tYRp+VZukxuMeKgXYmsXQHO05zQeA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/refractor/node_modules/character-reference-invalid": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-1.1.4.tgz",
"integrity": "sha512-mKKUkUbhPpQlCOfIuZkvSEgktjPFIsZKRRbC6KWVEMvlzblj3i3asQv5ODsrwt0N3pHAEvjP8KTQPHkp0+6jOg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/refractor/node_modules/comma-separated-tokens": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-1.0.8.tgz",
"integrity": "sha512-GHuDRO12Sypu2cV70d1dkA2EUmXHgntrzbpvOB+Qy+49ypNfGgFQIC2fhhXbnyrJRynDCAARsT7Ou0M6hirpfw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/refractor/node_modules/hast-util-parse-selector": {
"version": "2.2.5",
"resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-2.2.5.tgz",
"integrity": "sha512-7j6mrk/qqkSehsM92wQjdIgWM2/BW61u/53G6xmC8i1OmEdKLHbk419QKQUjz6LglWsfqoiHmyMRkP1BGjecNQ==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/refractor/node_modules/hastscript": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/hastscript/-/hastscript-6.0.0.tgz",
"integrity": "sha512-nDM6bvd7lIqDUiYEiu5Sl/+6ReP0BMk/2f4U/Rooccxkj0P5nm+acM5PrGJ/t5I8qPGiqZSE6hVAwZEdZIvP4w==",
"license": "MIT",
"dependencies": {
"@types/hast": "^2.0.0",
"comma-separated-tokens": "^1.0.0",
"hast-util-parse-selector": "^2.0.0",
"property-information": "^5.0.0",
"space-separated-tokens": "^1.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/refractor/node_modules/is-alphabetical": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-1.0.4.tgz",
"integrity": "sha512-DwzsA04LQ10FHTZuL0/grVDk4rFoVH1pjAToYwBrHSxcrBIGQuXrQMtD5U1b0U2XVgKZCTLLP8u2Qxqhy3l2Vg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/refractor/node_modules/is-alphanumerical": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-1.0.4.tgz",
"integrity": "sha512-UzoZUr+XfVz3t3v4KyGEniVL9BDRoQtY7tOyrRybkVNjDFWyo1yhXNGrrBTQxp3ib9BLAWs7k2YKBQsFRkZG9A==",
"license": "MIT",
"dependencies": {
"is-alphabetical": "^1.0.0",
"is-decimal": "^1.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/refractor/node_modules/is-decimal": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-1.0.4.tgz",
"integrity": "sha512-RGdriMmQQvZ2aqaQq3awNA6dCGtKpiDFcOzrTWrDAT2MiWrKQVPmxLGHl7Y2nNu6led0kEyoX0enY0qXYsv9zw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/refractor/node_modules/is-hexadecimal": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-1.0.4.tgz",
"integrity": "sha512-gyPJuv83bHMpocVYoqof5VDiZveEoGoFL8m3BXNb2VW8Xs+rz9kqO8LOQ5DH6EsuvilT1ApazU0pyl+ytbPtlw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/refractor/node_modules/parse-entities": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-2.0.0.tgz",
"integrity": "sha512-kkywGpCcRYhqQIchaWqZ875wzpS/bMKhz5HnN3p7wveJTkTtyAB/AlnS0f8DFSqYW1T82t6yEAkEcB+A1I3MbQ==",
"license": "MIT",
"dependencies": {
"character-entities": "^1.0.0",
"character-entities-legacy": "^1.0.0",
"character-reference-invalid": "^1.0.0",
"is-alphanumerical": "^1.0.0",
"is-decimal": "^1.0.0",
"is-hexadecimal": "^1.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/refractor/node_modules/prismjs": {
"version": "1.27.0",
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.27.0.tgz",
"integrity": "sha512-t13BGPUlFDR7wRB5kQDG4jjl7XeuH6jbJGt11JHPL96qwsEHNX2+68tFXqc1/k+/jALsbSWJKUOT/hcYAZ5LkA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/refractor/node_modules/property-information": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/property-information/-/property-information-5.6.0.tgz",
"integrity": "sha512-YUHSPk+A30YPv+0Qf8i9Mbfe/C0hdPXk1s1jPVToV8pk8BQtpw10ct89Eo7OWkutrwqvT0eicAxlOg3dOAu8JA==",
"license": "MIT",
"dependencies": {
"xtend": "^4.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/refractor/node_modules/space-separated-tokens": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-1.1.5.tgz",
"integrity": "sha512-q/JSVd1Lptzhf5bkYm4ob4iWPjx0KiRe3sRFBNrVqbJkFaBm5vbbowy1mymoPNLRa52+oadOhJ+K49wsSeSjTA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/rehype-katex": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/rehype-katex/-/rehype-katex-7.0.1.tgz",
@@ -11315,6 +11674,20 @@
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"license": "MIT"
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/uglify-js": {
"version": "3.19.3",
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz",
@@ -11339,6 +11712,13 @@
"node": ">=18.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/unified": {
"version": "11.0.5",
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
@@ -11536,6 +11916,15 @@
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmmirror.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@@ -11708,6 +12097,15 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmmirror.com/void-elements/-/void-elements-3.1.0.tgz",
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",

View File

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

View File

@@ -13,6 +13,9 @@
*/
import { query } from '@anthropic-ai/claude-agent-sdk';
// Used to mint unique approval request IDs when randomUUID is not available.
// This keeps parallel tool approvals from colliding; it does not add any crypto/security guarantees.
import crypto from 'crypto';
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
@@ -20,6 +23,124 @@ import { CLAUDE_MODELS } from '../shared/modelConstants.js';
// Session tracking: Map of session IDs to active query instances
const activeSessions = new Map();
// In-memory registry of pending tool approvals keyed by requestId.
// This does not persist approvals or share across processes; it exists so the
// SDK can pause tool execution while the UI decides what to do.
const pendingToolApprovals = new Map();
// Default approval timeout kept under the SDK's 60s control timeout.
// This does not change SDK limits; it only defines how long we wait for the UI,
// introduced to avoid hanging the run when no decision arrives.
const TOOL_APPROVAL_TIMEOUT_MS = parseInt(process.env.CLAUDE_TOOL_APPROVAL_TIMEOUT_MS, 10) || 55000;
// Generate a stable request ID for UI approval flows.
// This does not encode tool details or get shown to users; it exists so the UI
// can respond to the correct pending request without collisions.
function createRequestId() {
// if clause is used because randomUUID is not available in older Node.js versions
if (typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return crypto.randomBytes(16).toString('hex');
}
// Wait for a UI approval decision, honoring SDK cancellation.
// This does not auto-approve or auto-deny; it only resolves with UI input,
// and it cleans up the pending map to avoid leaks, introduced to prevent
// replying after the SDK cancels the control request.
function waitForToolApproval(requestId, options = {}) {
const { timeoutMs = TOOL_APPROVAL_TIMEOUT_MS, signal, onCancel } = options;
return new Promise(resolve => {
let settled = false;
const finalize = (decision) => {
if (settled) return;
settled = true;
cleanup();
resolve(decision);
};
const cleanup = () => {
pendingToolApprovals.delete(requestId);
clearTimeout(timeout);
if (signal && abortHandler) {
signal.removeEventListener('abort', abortHandler);
}
};
// Timeout is local to this process; it does not override SDK timing.
// It exists to prevent the UI prompt from lingering indefinitely.
const timeout = setTimeout(() => {
onCancel?.('timeout');
finalize(null);
}, timeoutMs);
const abortHandler = () => {
// If the SDK cancels the control request, stop waiting to avoid
// replying after the process is no longer ready for writes.
onCancel?.('cancelled');
finalize({ cancelled: true });
};
if (signal) {
if (signal.aborted) {
onCancel?.('cancelled');
finalize({ cancelled: true });
return;
}
signal.addEventListener('abort', abortHandler, { once: true });
}
pendingToolApprovals.set(requestId, (decision) => {
finalize(decision);
});
});
}
// Resolve a pending approval. This does not validate the decision payload;
// validation and tool matching remain in canUseTool, which keeps this as a
// lightweight WebSocket -> SDK relay.
function resolveToolApproval(requestId, decision) {
const resolver = pendingToolApprovals.get(requestId);
if (resolver) {
resolver(decision);
}
}
// Match stored permission entries against a tool + input combo.
// This only supports exact tool names and the Bash(command:*) shorthand
// used by the UI; it intentionally does not implement full glob semantics,
// introduced to stay consistent with the UI's "Allow rule" format.
function matchesToolPermission(entry, toolName, input) {
if (!entry || !toolName) {
return false;
}
if (entry === toolName) {
return true;
}
const bashMatch = entry.match(/^Bash\((.+):\*\)$/);
if (toolName === 'Bash' && bashMatch) {
const allowedPrefix = bashMatch[1];
let command = '';
if (typeof input === 'string') {
command = input.trim();
} else if (input && typeof input === 'object' && typeof input.command === 'string') {
command = input.command.trim();
}
if (!command) {
return false;
}
return command.startsWith(allowedPrefix);
}
return false;
}
/**
* Maps CLI options to SDK-compatible options format
@@ -52,30 +173,29 @@ function mapCliOptionsToSDK(options = {}) {
if (settings.skipPermissions && permissionMode !== 'plan') {
// When skipping permissions, use bypassPermissions mode
sdkOptions.permissionMode = 'bypassPermissions';
} else {
// Map allowed tools
let allowedTools = [...(settings.allowedTools || [])];
}
// Add plan mode default tools
if (permissionMode === 'plan') {
const planModeTools = ['Read', 'Task', 'exit_plan_mode', 'TodoRead', 'TodoWrite', 'WebFetch', 'WebSearch'];
for (const tool of planModeTools) {
if (!allowedTools.includes(tool)) {
allowedTools.push(tool);
}
// Map allowed tools (always set to avoid implicit "allow all" defaults).
// This does not grant permissions by itself; it just configures the SDK,
// introduced because leaving it undefined made the SDK treat it as "all tools allowed."
let allowedTools = [...(settings.allowedTools || [])];
// Add plan mode default tools
if (permissionMode === 'plan') {
const planModeTools = ['Read', 'Task', 'exit_plan_mode', 'TodoRead', 'TodoWrite', 'WebFetch', 'WebSearch'];
for (const tool of planModeTools) {
if (!allowedTools.includes(tool)) {
allowedTools.push(tool);
}
}
if (allowedTools.length > 0) {
sdkOptions.allowedTools = allowedTools;
}
// Map disallowed tools
if (settings.disallowedTools && settings.disallowedTools.length > 0) {
sdkOptions.disallowedTools = settings.disallowedTools;
}
}
sdkOptions.allowedTools = allowedTools;
// Map disallowed tools (always set so the SDK doesn't treat "undefined" as permissive).
// This does not override allowlists; it only feeds the canUseTool gate.
sdkOptions.disallowedTools = settings.disallowedTools || [];
// Map model (default to sonnet)
// Valid models: sonnet, opus, haiku, opusplan, sonnet[1m]
sdkOptions.model = options.model || CLAUDE_MODELS.DEFAULT;
@@ -370,6 +490,76 @@ async function queryClaudeSDK(command, options = {}, ws) {
tempImagePaths = imageResult.tempImagePaths;
tempDir = imageResult.tempDir;
// Gate tool usage with explicit UI approval when not auto-approved.
// This does not render UI or persist permissions; it only bridges to the UI
// via WebSocket and waits for the response, introduced so tool calls pause
// instead of auto-running when the allowlist is empty.
sdkOptions.canUseTool = async (toolName, input, context) => {
if (sdkOptions.permissionMode === 'bypassPermissions') {
return { behavior: 'allow', updatedInput: input };
}
const isDisallowed = (sdkOptions.disallowedTools || []).some(entry =>
matchesToolPermission(entry, toolName, input)
);
if (isDisallowed) {
return { behavior: 'deny', message: 'Tool disallowed by settings' };
}
const isAllowed = (sdkOptions.allowedTools || []).some(entry =>
matchesToolPermission(entry, toolName, input)
);
if (isAllowed) {
return { behavior: 'allow', updatedInput: input };
}
const requestId = createRequestId();
ws.send({
type: 'claude-permission-request',
requestId,
toolName,
input,
sessionId: capturedSessionId || sessionId || null
});
// Wait for the UI; if the SDK cancels, notify the UI so it can dismiss the banner.
// This does not retry or resurface the prompt; it just reflects the cancellation.
const decision = await waitForToolApproval(requestId, {
signal: context?.signal,
onCancel: (reason) => {
ws.send({
type: 'claude-permission-cancelled',
requestId,
reason,
sessionId: capturedSessionId || sessionId || null
});
}
});
if (!decision) {
return { behavior: 'deny', message: 'Permission request timed out' };
}
if (decision.cancelled) {
return { behavior: 'deny', message: 'Permission request cancelled' };
}
if (decision.allow) {
// rememberEntry only updates this run's in-memory allowlist to prevent
// repeated prompts in the same session; persistence is handled by the UI.
if (decision.rememberEntry && typeof decision.rememberEntry === 'string') {
if (!sdkOptions.allowedTools.includes(decision.rememberEntry)) {
sdkOptions.allowedTools.push(decision.rememberEntry);
}
if (Array.isArray(sdkOptions.disallowedTools)) {
sdkOptions.disallowedTools = sdkOptions.disallowedTools.filter(entry => entry !== decision.rememberEntry);
}
}
return { behavior: 'allow', updatedInput: decision.updatedInput ?? input };
}
return { behavior: 'deny', message: decision.message ?? 'User denied tool use' };
};
// Create SDK query instance
const queryInstance = query({
prompt: finalCommand,
@@ -413,7 +603,8 @@ async function queryClaudeSDK(command, options = {}, ws) {
const transformedMessage = transformMessage(message);
ws.send({
type: 'claude-response',
data: transformedMessage
data: transformedMessage,
sessionId: capturedSessionId || sessionId || null
});
// Extract and send token budget updates from result messages
@@ -423,7 +614,8 @@ async function queryClaudeSDK(command, options = {}, ws) {
console.log('Token budget from modelUsage:', tokenBudget);
ws.send({
type: 'token-budget',
data: tokenBudget
data: tokenBudget,
sessionId: capturedSessionId || sessionId || null
});
}
}
@@ -461,7 +653,8 @@ async function queryClaudeSDK(command, options = {}, ws) {
// Send error to WebSocket
ws.send({
type: 'claude-error',
error: error.message
error: error.message,
sessionId: capturedSessionId || sessionId || null
});
throw error;
@@ -526,5 +719,6 @@ export {
queryClaudeSDK,
abortClaudeSDKSession,
isClaudeSDKSessionActive,
getActiveClaudeSDKSessions
getActiveClaudeSDKSessions,
resolveToolApproval
};

View File

@@ -131,10 +131,10 @@ function showStatus() {
console.log('\n' + c.dim('═'.repeat(60)));
console.log(`\n${c.tip('[TIP]')} Hints:`);
console.log(` ${c.dim('>')} Set DATABASE_PATH env variable to use a custom database location`);
console.log(` ${c.dim('>')} Create .env file in installation directory for persistent config`);
console.log(` ${c.dim('>')} Run "claude-code-ui" or "cloudcli start" to start the server`);
console.log(` ${c.dim('>')} Access the UI at http://localhost:3001 (or custom PORT)\n`);
console.log(` ${c.dim('>')} Use ${c.bright('cloudcli --port 8080')} to run on a custom port`);
console.log(` ${c.dim('>')} Use ${c.bright('cloudcli --database-path /path/to/db')} for custom database`);
console.log(` ${c.dim('>')} Run ${c.bright('cloudcli help')} for all options`);
console.log(` ${c.dim('>')} Access the UI at http://localhost:${process.env.PORT || '3001'}\n`);
}
// Show help
@@ -145,19 +145,28 @@ function showHelp() {
╚═══════════════════════════════════════════════════════════════╝
Usage:
claude-code-ui [command]
cloudcli [command]
claude-code-ui [command] [options]
cloudcli [command] [options]
Commands:
start Start the Claude Code UI server (default)
status Show configuration and data locations
update Update to the latest version
help Show this help information
version Show version information
Options:
-p, --port <port> Set server port (default: 3001)
--database-path <path> Set custom database location
-h, --help Show this help information
-v, --version Show version information
Examples:
$ claude-code-ui # Start the server
$ cloudcli status # Show configuration
$ cloudcli help # Show help
$ cloudcli # Start with defaults
$ cloudcli --port 8080 # Start on port 8080
$ cloudcli -p 3000 # Short form for port
$ cloudcli start --port 4000 # Explicit start command
$ cloudcli status # Show configuration
Environment Variables:
PORT Set server port (default: 3001)
@@ -165,11 +174,6 @@ Environment Variables:
CLAUDE_CLI_PATH Set custom Claude CLI path
CONTEXT_WINDOW Set context window size (default: 160000)
Configuration:
Create a .env file in the installation directory to set
persistent environment variables. Use 'cloudcli status' to
see the installation directory path.
Documentation:
${packageJson.homepage || 'https://github.com/siteboon/claudecodeui'}
@@ -183,16 +187,110 @@ function showVersion() {
console.log(`${packageJson.version}`);
}
// Compare semver versions, returns true if v1 > v2
function isNewerVersion(v1, v2) {
const parts1 = v1.split('.').map(Number);
const parts2 = v2.split('.').map(Number);
for (let i = 0; i < 3; i++) {
if (parts1[i] > parts2[i]) return true;
if (parts1[i] < parts2[i]) return false;
}
return false;
}
// Check for updates
async function checkForUpdates(silent = false) {
try {
const { execSync } = await import('child_process');
const latestVersion = execSync('npm show @siteboon/claude-code-ui version', { encoding: 'utf8' }).trim();
const currentVersion = packageJson.version;
if (isNewerVersion(latestVersion, currentVersion)) {
console.log(`\n${c.warn('[UPDATE]')} New version available: ${c.bright(latestVersion)} (current: ${currentVersion})`);
console.log(` Run ${c.bright('cloudcli update')} to update\n`);
return { hasUpdate: true, latestVersion, currentVersion };
} else if (!silent) {
console.log(`${c.ok('[OK]')} You are on the latest version (${currentVersion})`);
}
return { hasUpdate: false, latestVersion, currentVersion };
} catch (e) {
if (!silent) {
console.log(`${c.warn('[WARN]')} Could not check for updates`);
}
return { hasUpdate: false, error: e.message };
}
}
// Update the package
async function updatePackage() {
try {
const { execSync } = await import('child_process');
console.log(`${c.info('[INFO]')} Checking for updates...`);
const { hasUpdate, latestVersion, currentVersion } = await checkForUpdates(true);
if (!hasUpdate) {
console.log(`${c.ok('[OK]')} Already on the latest version (${currentVersion})`);
return;
}
console.log(`${c.info('[INFO]')} Updating from ${currentVersion} to ${latestVersion}...`);
execSync('npm update -g @siteboon/claude-code-ui', { stdio: 'inherit' });
console.log(`${c.ok('[OK]')} Update complete! Restart cloudcli to use the new version.`);
} catch (e) {
console.error(`${c.error('[ERROR]')} Update failed: ${e.message}`);
console.log(`${c.tip('[TIP]')} Try running manually: npm update -g @siteboon/claude-code-ui`);
}
}
// Start the server
async function startServer() {
// Check for updates silently on startup
checkForUpdates(true);
// Import and run the server
await import('./index.js');
}
// Parse CLI arguments
function parseArgs(args) {
const parsed = { command: 'start', options: {} };
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === '--port' || arg === '-p') {
parsed.options.port = args[++i];
} else if (arg.startsWith('--port=')) {
parsed.options.port = arg.split('=')[1];
} else if (arg === '--database-path') {
parsed.options.databasePath = args[++i];
} else if (arg.startsWith('--database-path=')) {
parsed.options.databasePath = arg.split('=')[1];
} else if (arg === '--help' || arg === '-h') {
parsed.command = 'help';
} else if (arg === '--version' || arg === '-v') {
parsed.command = 'version';
} else if (!arg.startsWith('-')) {
parsed.command = arg;
}
}
return parsed;
}
// Main CLI handler
async function main() {
const args = process.argv.slice(2);
const command = args[0] || 'start';
const { command, options } = parseArgs(args);
// Apply CLI options to environment variables
if (options.port) {
process.env.PORT = options.port;
}
if (options.databasePath) {
process.env.DATABASE_PATH = options.databasePath;
}
switch (command) {
case 'start':
@@ -212,6 +310,9 @@ async function main() {
case '--version':
showVersion();
break;
case 'update':
await updatePackage();
break;
default:
console.error(`\n❌ Unknown command: ${command}`);
console.log(' Run "cloudcli help" for usage information.\n');

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
#!/usr/bin/env node
// Load environment variables from .env file
// Load environment variables before other imports execute
import './load-env.js';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
@@ -28,22 +29,6 @@ const c = {
dim: (text) => `${colors.dim}${text}${colors.reset}`,
};
try {
const envPath = path.join(__dirname, '../.env');
const envFile = fs.readFileSync(envPath, 'utf8');
envFile.split('\n').forEach(line => {
const trimmedLine = line.trim();
if (trimmedLine && !trimmedLine.startsWith('#')) {
const [key, ...valueParts] = trimmedLine.split('=');
if (key && valueParts.length > 0 && !process.env[key]) {
process.env[key] = valueParts.join('=').trim();
}
}
});
} catch (e) {
console.log('No .env file found or error reading it:', e.message);
}
console.log('PORT from env:', process.env.PORT);
import express from 'express';
@@ -58,7 +43,7 @@ import fetch from 'node-fetch';
import mime from 'mime-types';
import { getProjects, getSessions, getSessionMessages, renameProject, deleteSession, deleteProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache } from './projects.js';
import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getActiveClaudeSDKSessions } from './claude-sdk.js';
import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getActiveClaudeSDKSessions, resolveToolApproval } from './claude-sdk.js';
import { spawnCursor, abortCursorSession, isCursorSessionActive, getActiveCursorSessions } from './cursor-cli.js';
import { queryCodex, abortCodexSession, isCodexSessionActive, getActiveCodexSessions } from './openai-codex.js';
import gitRoutes from './routes/git.js';
@@ -70,16 +55,31 @@ import mcpUtilsRoutes from './routes/mcp-utils.js';
import commandsRoutes from './routes/commands.js';
import settingsRoutes from './routes/settings.js';
import agentRoutes from './routes/agent.js';
import projectsRoutes from './routes/projects.js';
import projectsRoutes, { WORKSPACES_ROOT, validateWorkspacePath } from './routes/projects.js';
import cliAuthRoutes from './routes/cli-auth.js';
import userRoutes from './routes/user.js';
import codexRoutes from './routes/codex.js';
import { initializeDatabase } from './database/db.js';
import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
import { IS_PLATFORM } from './constants/config.js';
// File system watcher for projects folder
let projectsWatcher = null;
const connectedClients = new Set();
let isGetProjectsRunning = false; // Flag to prevent reentrant calls
// Broadcast progress to all connected WebSocket clients
function broadcastProgress(progress) {
const message = JSON.stringify({
type: 'loading_progress',
...progress
});
connectedClients.forEach(client => {
if (client.readyState === WebSocket.OPEN) {
client.send(message);
}
});
}
// Setup file system watcher for Claude projects folder using chokidar
async function setupProjectsWatcher() {
@@ -117,13 +117,19 @@ async function setupProjectsWatcher() {
const debouncedUpdate = async (eventType, filePath) => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(async () => {
// Prevent reentrant calls
if (isGetProjectsRunning) {
return;
}
try {
isGetProjectsRunning = true;
// Clear project directory cache when files change
clearProjectDirectoryCache();
// Get updated projects list
const updatedProjects = await getProjects();
const updatedProjects = await getProjects(broadcastProgress);
// Notify all connected clients about the project changes
const updateMessage = JSON.stringify({
@@ -142,6 +148,8 @@ async function setupProjectsWatcher() {
} catch (error) {
console.error('[ERROR] Error handling project changes:', error);
} finally {
isGetProjectsRunning = false;
}
}, 300); // 300ms debounce (slightly faster than before)
};
@@ -178,7 +186,7 @@ const wss = new WebSocketServer({
console.log('WebSocket connection attempt to:', info.req.url);
// Platform mode: always allow connection
if (process.env.VITE_IS_PLATFORM === 'true') {
if (IS_PLATFORM) {
const user = authenticateWebSocket(null); // Will return first user
if (!user) {
console.log('[WARN] Platform mode: No user found in database');
@@ -366,7 +374,7 @@ app.post('/api/system/update', authenticateToken, async (req, res) => {
app.get('/api/projects', authenticateToken, async (req, res) => {
try {
const projects = await getProjects();
const projects = await getProjects(broadcastProgress);
res.json(projects);
} catch (error) {
res.status(500).json({ error: error.message });
@@ -433,11 +441,12 @@ app.delete('/api/projects/:projectName/sessions/:sessionId', authenticateToken,
}
});
// Delete project endpoint (only if empty)
// Delete project endpoint (force=true to delete with sessions)
app.delete('/api/projects/:projectName', authenticateToken, async (req, res) => {
try {
const { projectName } = req.params;
await deleteProject(projectName);
const force = req.query.force === 'true';
await deleteProject(projectName, force);
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: error.message });
@@ -461,22 +470,42 @@ app.post('/api/projects/create', authenticateToken, async (req, res) => {
}
});
const expandWorkspacePath = (inputPath) => {
if (!inputPath) return inputPath;
if (inputPath === '~') {
return WORKSPACES_ROOT;
}
if (inputPath.startsWith('~/') || inputPath.startsWith('~\\')) {
return path.join(WORKSPACES_ROOT, inputPath.slice(2));
}
return inputPath;
};
// Browse filesystem endpoint for project suggestions - uses existing getFileTree
app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
try {
const { path: dirPath } = req.query;
console.log('[API] Browse filesystem request for path:', dirPath);
console.log('[API] WORKSPACES_ROOT is:', WORKSPACES_ROOT);
// Default to home directory if no path provided
const homeDir = os.homedir();
let targetPath = dirPath ? dirPath.replace('~', homeDir) : homeDir;
const defaultRoot = WORKSPACES_ROOT;
let targetPath = dirPath ? expandWorkspacePath(dirPath) : defaultRoot;
// Resolve and normalize the path
targetPath = path.resolve(targetPath);
// Security check - ensure path is within allowed workspace root
const validation = await validateWorkspacePath(targetPath);
if (!validation.valid) {
return res.status(403).json({ error: validation.error });
}
const resolvedPath = validation.resolvedPath || targetPath;
// Security check - ensure path is accessible
try {
await fs.promises.access(targetPath);
const stats = await fs.promises.stat(targetPath);
await fs.promises.access(resolvedPath);
const stats = await fs.promises.stat(resolvedPath);
if (!stats.isDirectory()) {
return res.status(400).json({ error: 'Path is not a directory' });
@@ -486,7 +515,7 @@ app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
}
// Use existing getFileTree function with shallow depth (only direct children)
const fileTree = await getFileTree(targetPath, 1, 0, false); // maxDepth=1, showHidden=false
const fileTree = await getFileTree(resolvedPath, 1, 0, false); // maxDepth=1, showHidden=false
// Filter only directories and format for suggestions
const directories = fileTree
@@ -496,11 +525,23 @@ app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
name: item.name,
type: 'directory'
}))
.slice(0, 20); // Limit results
.sort((a, b) => {
const aHidden = a.name.startsWith('.');
const bHidden = b.name.startsWith('.');
if (aHidden && !bHidden) return 1;
if (!aHidden && bHidden) return -1;
return a.name.localeCompare(b.name);
});
// Add common directories if browsing home directory
const suggestions = [];
if (targetPath === homeDir) {
let resolvedWorkspaceRoot = defaultRoot;
try {
resolvedWorkspaceRoot = await fsPromises.realpath(defaultRoot);
} catch (error) {
// Use default root as-is if realpath fails
}
if (resolvedPath === resolvedWorkspaceRoot) {
const commonDirs = ['Desktop', 'Documents', 'Projects', 'Development', 'Dev', 'Code', 'workspace'];
const existingCommon = directories.filter(dir => commonDirs.includes(dir.name));
const otherDirs = directories.filter(dir => !commonDirs.includes(dir.name));
@@ -510,9 +551,9 @@ app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
suggestions.push(...directories);
}
res.json({
path: targetPath,
suggestions: suggestions
res.json({
path: resolvedPath,
suggestions: suggestions
});
} catch (error) {
@@ -521,6 +562,46 @@ app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
}
});
app.post('/api/create-folder', authenticateToken, async (req, res) => {
try {
const { path: folderPath } = req.body;
if (!folderPath) {
return res.status(400).json({ error: 'Path is required' });
}
const expandedPath = expandWorkspacePath(folderPath);
const resolvedInput = path.resolve(expandedPath);
const validation = await validateWorkspacePath(resolvedInput);
if (!validation.valid) {
return res.status(403).json({ error: validation.error });
}
const targetPath = validation.resolvedPath || resolvedInput;
const parentDir = path.dirname(targetPath);
try {
await fs.promises.access(parentDir);
} catch (err) {
return res.status(404).json({ error: 'Parent directory does not exist' });
}
try {
await fs.promises.access(targetPath);
return res.status(409).json({ error: 'Folder already exists' });
} catch (err) {
// Folder doesn't exist, which is what we want
}
try {
await fs.promises.mkdir(targetPath, { recursive: false });
res.json({ success: true, path: targetPath });
} catch (mkdirError) {
if (mkdirError.code === 'EEXIST') {
return res.status(409).json({ error: 'Folder already exists' });
}
throw mkdirError;
}
} catch (error) {
console.error('Error creating folder:', error);
res.status(500).json({ error: 'Failed to create folder' });
}
});
// Read file content endpoint
app.get('/api/projects/:projectName/file', authenticateToken, async (req, res) => {
try {
@@ -804,6 +885,18 @@ function handleChatConnection(ws) {
provider,
success
});
} else if (data.type === 'claude-permission-response') {
// Relay UI approval decisions back into the SDK control flow.
// This does not persist permissions; it only resolves the in-flight request,
// introduced so the SDK can resume once the user clicks Allow/Deny.
if (data.requestId) {
resolveToolApproval(data.requestId, {
allow: Boolean(data.allow),
updatedInput: data.updatedInput,
message: data.message,
rememberEntry: data.rememberEntry
});
}
} else if (data.type === 'cursor-abort') {
console.log('[DEBUG] Abort Cursor session:', data.sessionId);
const success = abortCursorSession(data.sessionId);
@@ -1625,10 +1718,13 @@ async function getFileTree(dirPath, maxDepth = 3, currentDepth = 0, showHidden =
// Debug: log all entries including hidden files
// Skip only heavy build directories
// Skip heavy build directories and VCS directories
if (entry.name === 'node_modules' ||
entry.name === 'dist' ||
entry.name === 'build') continue;
entry.name === 'build' ||
entry.name === '.git' ||
entry.name === '.svn' ||
entry.name === '.hg') continue;
const itemPath = path.join(dirPath, entry.name);
const item = {

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

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

View File

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

View File

@@ -272,7 +272,8 @@ export async function queryCodex(command, options = {}, ws) {
data: {
used: totalTokens,
total: 200000 // Default context window for Codex models
}
},
sessionId: currentSessionId
});
}
}
@@ -280,7 +281,8 @@ export async function queryCodex(command, options = {}, ws) {
// Send completion event
sendMessage(ws, {
type: 'codex-complete',
sessionId: currentSessionId
sessionId: currentSessionId,
actualSessionId: thread.id
});
} catch (error) {

View File

@@ -379,22 +379,46 @@ async function extractProjectDirectory(projectName) {
}
}
async function getProjects() {
async function getProjects(progressCallback = null) {
const claudeDir = path.join(os.homedir(), '.claude', 'projects');
const config = await loadProjectConfig();
const projects = [];
const existingProjects = new Set();
let totalProjects = 0;
let processedProjects = 0;
let directories = [];
try {
// Check if the .claude/projects directory exists
await fs.access(claudeDir);
// First, get existing Claude projects from the file system
const entries = await fs.readdir(claudeDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
existingProjects.add(entry.name);
directories = entries.filter(e => e.isDirectory());
// Build set of existing project names for later
directories.forEach(e => existingProjects.add(e.name));
// Count manual projects not already in directories
const manualProjectsCount = Object.entries(config)
.filter(([name, cfg]) => cfg.manuallyAdded && !existingProjects.has(name))
.length;
totalProjects = directories.length + manualProjectsCount;
for (const entry of directories) {
processedProjects++;
// Emit progress
if (progressCallback) {
progressCallback({
phase: 'loading',
current: processedProjects,
total: totalProjects,
currentProject: entry.name
});
}
const projectPath = path.join(claudeDir, entry.name);
// Extract actual project directory from JSONL sessions
@@ -460,20 +484,35 @@ async function getProjects() {
status: 'error'
};
}
projects.push(project);
}
projects.push(project);
}
} catch (error) {
// If the directory doesn't exist (ENOENT), that's okay - just continue with empty projects
if (error.code !== 'ENOENT') {
console.error('Error reading projects directory:', error);
}
// Calculate total for manual projects only (no directories exist)
totalProjects = Object.entries(config)
.filter(([name, cfg]) => cfg.manuallyAdded)
.length;
}
// Add manually configured projects that don't exist as folders yet
for (const [projectName, projectConfig] of Object.entries(config)) {
if (!existingProjects.has(projectName) && projectConfig.manuallyAdded) {
processedProjects++;
// Emit progress for manual projects
if (progressCallback) {
progressCallback({
phase: 'loading',
current: processedProjects,
total: totalProjects,
currentProject: projectName
});
}
// Use the original path if available, otherwise extract from potential sessions
let actualProjectDir = projectConfig.originalPath;
@@ -541,7 +580,16 @@ async function getProjects() {
projects.push(project);
}
}
// Emit completion after all projects (including manual) are processed
if (progressCallback) {
progressCallback({
phase: 'complete',
current: totalProjects,
total: totalProjects
});
}
return projects;
}
@@ -978,25 +1026,56 @@ async function isProjectEmpty(projectName) {
}
}
// Delete an empty project
async function deleteProject(projectName) {
// Delete a project (force=true to delete even with sessions)
async function deleteProject(projectName, force = false) {
const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName);
try {
// First check if the project is empty
const isEmpty = await isProjectEmpty(projectName);
if (!isEmpty) {
if (!isEmpty && !force) {
throw new Error('Cannot delete project with existing sessions');
}
// Remove the project directory
await fs.rm(projectDir, { recursive: true, force: true });
// Remove from project config
const config = await loadProjectConfig();
let projectPath = config[projectName]?.path || config[projectName]?.originalPath;
// Fallback to extractProjectDirectory if projectPath is not in config
if (!projectPath) {
projectPath = await extractProjectDirectory(projectName);
}
// Remove the project directory (includes all Claude sessions)
await fs.rm(projectDir, { recursive: true, force: true });
// Delete all Codex sessions associated with this project
if (projectPath) {
try {
const codexSessions = await getCodexSessions(projectPath, { limit: 0 });
for (const session of codexSessions) {
try {
await deleteCodexSession(session.id);
} catch (err) {
console.warn(`Failed to delete Codex session ${session.id}:`, err.message);
}
}
} catch (err) {
console.warn('Failed to delete Codex sessions:', err.message);
}
// Delete Cursor sessions directory if it exists
try {
const hash = crypto.createHash('md5').update(projectPath).digest('hex');
const cursorProjectDir = path.join(os.homedir(), '.cursor', 'chats', hash);
await fs.rm(cursorProjectDir, { recursive: true, force: true });
} catch (err) {
// Cursor dir may not exist, ignore
}
}
// Remove from project config
delete config[projectName];
await saveProjectConfig(config);
return true;
} catch (error) {
console.error(`Error deleting project ${projectName}:`, error);
@@ -1007,17 +1086,17 @@ async function deleteProject(projectName) {
// Add a project manually to the config (without creating folders)
async function addProjectManually(projectPath, displayName = null) {
const absolutePath = path.resolve(projectPath);
try {
// Check if the path exists
await fs.access(absolutePath);
} catch (error) {
throw new Error(`Path does not exist: ${absolutePath}`);
}
// Generate project name (encode path for use as directory name)
const projectName = absolutePath.replace(/\//g, '-');
const projectName = absolutePath.replace(/[\\/:\s~_]/g, '-');
// Check if project already exists in config
const config = await loadProjectConfig();
const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName);
@@ -1028,13 +1107,13 @@ async function addProjectManually(projectPath, displayName = null) {
// Allow adding projects even if the directory exists - this enables tracking
// existing Claude Code or Cursor projects in the UI
// Add to config as manually added project
config[projectName] = {
manuallyAdded: true,
originalPath: absolutePath
};
if (displayName) {
config[projectName].displayName = displayName;
}
@@ -1166,7 +1245,8 @@ async function getCursorSessions(projectPath) {
// Fetch Codex sessions for a given project path
async function getCodexSessions(projectPath) {
async function getCodexSessions(projectPath, options = {}) {
const { limit = 5 } = options;
try {
const codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions');
const sessions = [];
@@ -1206,7 +1286,12 @@ async function getCodexSessions(projectPath) {
const sessionData = await parseCodexSessionFile(filePath);
// Check if this session matches the project path
if (sessionData && sessionData.cwd === projectPath) {
// Handle Windows long paths with \\?\ prefix
const sessionCwd = sessionData?.cwd || '';
const cleanSessionCwd = sessionCwd.startsWith('\\\\?\\') ? sessionCwd.slice(4) : sessionCwd;
const cleanProjectPath = projectPath.startsWith('\\\\?\\') ? projectPath.slice(4) : projectPath;
if (sessionData && (sessionData.cwd === projectPath || cleanSessionCwd === cleanProjectPath || path.relative(cleanSessionCwd, cleanProjectPath) === '')) {
sessions.push({
id: sessionData.id,
summary: sessionData.summary || 'Codex Session',
@@ -1226,8 +1311,8 @@ async function getCodexSessions(projectPath) {
// Sort sessions by last activity (newest first)
sessions.sort((a, b) => new Date(b.lastActivity) - new Date(a.lastActivity));
// Return only the first 5 sessions for performance
return sessions.slice(0, 5);
// Return limited sessions for performance (0 = unlimited for deletion)
return limit > 0 ? sessions.slice(0, limit) : sessions;
} catch (error) {
console.error('Error fetching Codex sessions:', error);
@@ -1273,12 +1358,12 @@ async function parseCodexSessionFile(filePath) {
// Count messages and extract user messages for summary
if (entry.type === 'event_msg' && entry.payload?.type === 'user_message') {
messageCount++;
if (entry.payload.text) {
lastUserMessage = entry.payload.text;
if (entry.payload.message) {
lastUserMessage = entry.payload.message;
}
}
if (entry.type === 'response_item' && entry.payload?.type === 'message') {
if (entry.type === 'response_item' && entry.payload?.type === 'message' && entry.payload.role === 'assistant') {
messageCount++;
}

View File

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

View File

@@ -8,6 +8,17 @@ import { getCodexSessions, getCodexSessionMessages, deleteCodexSession } from '.
const router = express.Router();
function createCliResponder(res) {
let responded = false;
return (status, payload) => {
if (responded || res.headersSent) {
return;
}
responded = true;
res.status(status).json(payload);
};
}
router.get('/config', async (req, res) => {
try {
const configPath = path.join(os.homedir(), '.codex', 'config.toml');
@@ -88,24 +99,30 @@ router.delete('/sessions/:sessionId', async (req, res) => {
router.get('/mcp/cli/list', async (req, res) => {
try {
const respond = createCliResponder(res);
const proc = spawn('codex', ['mcp', 'list'], { stdio: ['pipe', 'pipe', 'pipe'] });
let stdout = '';
let stderr = '';
proc.stdout.on('data', (data) => { stdout += data.toString(); });
proc.stderr.on('data', (data) => { stderr += data.toString(); });
proc.stdout?.on('data', (data) => { stdout += data.toString(); });
proc.stderr?.on('data', (data) => { stderr += data.toString(); });
proc.on('close', (code) => {
if (code === 0) {
res.json({ success: true, output: stdout, servers: parseCodexListOutput(stdout) });
respond(200, { success: true, output: stdout, servers: parseCodexListOutput(stdout) });
} else {
res.status(500).json({ error: 'Codex CLI command failed', details: stderr });
respond(500, { error: 'Codex CLI command failed', details: stderr || `Exited with code ${code}` });
}
});
proc.on('error', (error) => {
res.status(500).json({ error: 'Failed to run Codex CLI', details: error.message });
const isMissing = error?.code === 'ENOENT';
respond(isMissing ? 503 : 500, {
error: isMissing ? 'Codex CLI not installed' : 'Failed to run Codex CLI',
details: error.message,
code: error.code
});
});
} catch (error) {
res.status(500).json({ error: 'Failed to list MCP servers', details: error.message });
@@ -133,24 +150,30 @@ router.post('/mcp/cli/add', async (req, res) => {
cliArgs.push(...args);
}
const respond = createCliResponder(res);
const proc = spawn('codex', cliArgs, { stdio: ['pipe', 'pipe', 'pipe'] });
let stdout = '';
let stderr = '';
proc.stdout.on('data', (data) => { stdout += data.toString(); });
proc.stderr.on('data', (data) => { stderr += data.toString(); });
proc.stdout?.on('data', (data) => { stdout += data.toString(); });
proc.stderr?.on('data', (data) => { stderr += data.toString(); });
proc.on('close', (code) => {
if (code === 0) {
res.json({ success: true, output: stdout, message: `MCP server "${name}" added successfully` });
respond(200, { success: true, output: stdout, message: `MCP server "${name}" added successfully` });
} else {
res.status(400).json({ error: 'Codex CLI command failed', details: stderr });
respond(400, { error: 'Codex CLI command failed', details: stderr || `Exited with code ${code}` });
}
});
proc.on('error', (error) => {
res.status(500).json({ error: 'Failed to run Codex CLI', details: error.message });
const isMissing = error?.code === 'ENOENT';
respond(isMissing ? 503 : 500, {
error: isMissing ? 'Codex CLI not installed' : 'Failed to run Codex CLI',
details: error.message,
code: error.code
});
});
} catch (error) {
res.status(500).json({ error: 'Failed to add MCP server', details: error.message });
@@ -161,24 +184,30 @@ router.delete('/mcp/cli/remove/:name', async (req, res) => {
try {
const { name } = req.params;
const respond = createCliResponder(res);
const proc = spawn('codex', ['mcp', 'remove', name], { stdio: ['pipe', 'pipe', 'pipe'] });
let stdout = '';
let stderr = '';
proc.stdout.on('data', (data) => { stdout += data.toString(); });
proc.stderr.on('data', (data) => { stderr += data.toString(); });
proc.stdout?.on('data', (data) => { stdout += data.toString(); });
proc.stderr?.on('data', (data) => { stderr += data.toString(); });
proc.on('close', (code) => {
if (code === 0) {
res.json({ success: true, output: stdout, message: `MCP server "${name}" removed successfully` });
respond(200, { success: true, output: stdout, message: `MCP server "${name}" removed successfully` });
} else {
res.status(400).json({ error: 'Codex CLI command failed', details: stderr });
respond(400, { error: 'Codex CLI command failed', details: stderr || `Exited with code ${code}` });
}
});
proc.on('error', (error) => {
res.status(500).json({ error: 'Failed to run Codex CLI', details: error.message });
const isMissing = error?.code === 'ENOENT';
respond(isMissing ? 503 : 500, {
error: isMissing ? 'Codex CLI not installed' : 'Failed to run Codex CLI',
details: error.message,
code: error.code
});
});
} catch (error) {
res.status(500).json({ error: 'Failed to remove MCP server', details: error.message });
@@ -189,24 +218,30 @@ router.get('/mcp/cli/get/:name', async (req, res) => {
try {
const { name } = req.params;
const respond = createCliResponder(res);
const proc = spawn('codex', ['mcp', 'get', name], { stdio: ['pipe', 'pipe', 'pipe'] });
let stdout = '';
let stderr = '';
proc.stdout.on('data', (data) => { stdout += data.toString(); });
proc.stderr.on('data', (data) => { stderr += data.toString(); });
proc.stdout?.on('data', (data) => { stdout += data.toString(); });
proc.stderr?.on('data', (data) => { stderr += data.toString(); });
proc.on('close', (code) => {
if (code === 0) {
res.json({ success: true, output: stdout, server: parseCodexGetOutput(stdout) });
respond(200, { success: true, output: stdout, server: parseCodexGetOutput(stdout) });
} else {
res.status(404).json({ error: 'Codex CLI command failed', details: stderr });
respond(404, { error: 'Codex CLI command failed', details: stderr || `Exited with code ${code}` });
}
});
proc.on('error', (error) => {
res.status(500).json({ error: 'Failed to run Codex CLI', details: error.message });
const isMissing = error?.code === 'ENOENT';
respond(isMissing ? 503 : 500, {
error: isMissing ? 'Codex CLI not installed' : 'Failed to run Codex CLI',
details: error.message,
code: error.code
});
});
} catch (error) {
res.status(500).json({ error: 'Failed to get MCP server details', details: error.message });
@@ -227,8 +262,7 @@ router.get('/mcp/config/read', async (req, res) => {
}
if (!configData) {
return res.json({ success: false, message: 'No Codex configuration file found', servers: [] });
}
return res.json({ success: true, configPath, servers: [] }); }
const servers = [];

View File

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

View File

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

View File

@@ -18,7 +18,7 @@
* Handles both existing sessions (with real IDs) and new sessions (with temporary IDs).
*/
import React, { useState, useEffect, useCallback } from 'react';
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { BrowserRouter as Router, Routes, Route, useNavigate, useParams } from 'react-router-dom';
import { Settings as SettingsIcon, Sparkles } from 'lucide-react';
import Sidebar from './components/Sidebar';
@@ -31,17 +31,24 @@ import { ThemeProvider } from './contexts/ThemeContext';
import { AuthProvider } from './contexts/AuthContext';
import { TaskMasterProvider } from './contexts/TaskMasterContext';
import { TasksSettingsProvider } from './contexts/TasksSettingsContext';
import { WebSocketProvider, useWebSocketContext } from './contexts/WebSocketContext';
import { WebSocketProvider, useWebSocket } from './contexts/WebSocketContext';
import ProtectedRoute from './components/ProtectedRoute';
import { useVersionCheck } from './hooks/useVersionCheck';
import useLocalStorage from './hooks/useLocalStorage';
import { api, authenticatedFetch } from './utils/api';
import { I18nextProvider, useTranslation } from 'react-i18next';
import i18n from './i18n/config.js';
// ! Move to a separate file called AppContent.ts
// Main App component with routing
function AppContent() {
const navigate = useNavigate();
const { sessionId } = useParams();
const { t } = useTranslation('common');
// * This is a tracker for avoiding excessive re-renders during development
const renderCountRef = useRef(0);
// console.log(`AppContent render count: ${renderCountRef.current++}`);
const { updateAvailable, latestVersion, currentVersion, releaseInfo } = useVersionCheck('siteboon', 'claudecodeui');
const [showVersionModal, setShowVersionModal] = useState(false);
@@ -53,6 +60,7 @@ function AppContent() {
const [isMobile, setIsMobile] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(false);
const [isLoadingProjects, setIsLoadingProjects] = useState(true);
const [loadingProgress, setLoadingProgress] = useState(null); // { phase, current, total, currentProject }
const [isInputFocused, setIsInputFocused] = useState(false);
const [showSettings, setShowSettings] = useState(false);
const [settingsInitialTab, setSettingsInitialTab] = useState('agents');
@@ -77,8 +85,11 @@ function AppContent() {
// Triggers ChatInterface to reload messages without switching sessions
const [externalMessageUpdate, setExternalMessageUpdate] = useState(0);
const { ws, sendMessage, messages } = useWebSocketContext();
const { ws, sendMessage, latestMessage } = useWebSocket();
// Ref to track loading progress timeout for cleanup
const loadingProgressTimeoutRef = useRef(null);
// Detect if running as PWA
const [isPWA, setIsPWA] = useState(false);
@@ -168,16 +179,32 @@ function AppContent() {
// Handle WebSocket messages for real-time project updates
useEffect(() => {
if (messages.length > 0) {
const latestMessage = messages[messages.length - 1];
if (latestMessage) {
// Handle loading progress updates
if (latestMessage.type === 'loading_progress') {
if (loadingProgressTimeoutRef.current) {
clearTimeout(loadingProgressTimeoutRef.current);
loadingProgressTimeoutRef.current = null;
}
setLoadingProgress(latestMessage);
if (latestMessage.phase === 'complete') {
loadingProgressTimeoutRef.current = setTimeout(() => {
setLoadingProgress(null);
loadingProgressTimeoutRef.current = null;
}, 500);
}
return;
}
if (latestMessage.type === 'projects_updated') {
// External Session Update Detection: Check if the changed file is the current session's JSONL
// If so, and the session is not active, trigger a message reload in ChatInterface
if (latestMessage.changedFile && selectedSession && selectedProject) {
// Extract session ID from changedFile (format: "project-name/session-id.jsonl")
const changedFileParts = latestMessage.changedFile.split('/');
const normalized = latestMessage.changedFile.replace(/\\/g, '/');
const changedFileParts = normalized.split('/');
if (changedFileParts.length >= 2) {
const filename = changedFileParts[changedFileParts.length - 1];
const changedSessionId = filename.replace('.jsonl', '');
@@ -245,7 +272,14 @@ function AppContent() {
}
}
}
}, [messages, selectedProject, selectedSession, activeSessions]);
return () => {
if (loadingProgressTimeoutRef.current) {
clearTimeout(loadingProgressTimeoutRef.current);
loadingProgressTimeoutRef.current = null;
}
};
}, [latestMessage, selectedProject, selectedSession, activeSessions]);
const fetchProjects = async () => {
try {
@@ -548,6 +582,7 @@ function AppContent() {
// Version Upgrade Modal Component
const VersionUpgradeModal = () => {
const { t } = useTranslation('common');
const [isUpdating, setIsUpdating] = useState(false);
const [updateOutput, setUpdateOutput] = useState('');
const [updateError, setUpdateError] = useState('');
@@ -608,7 +643,7 @@ function AppContent() {
<button
className="fixed inset-0 bg-black/50 backdrop-blur-sm"
onClick={() => setShowVersionModal(false)}
aria-label="Close version upgrade modal"
aria-label={t('versionUpdate.ariaLabels.closeModal')}
/>
{/* Modal */}
@@ -622,9 +657,9 @@ function AppContent() {
</svg>
</div>
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Update Available</h2>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">{t('versionUpdate.title')}</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">
{releaseInfo?.title || 'A new version is ready'}
{releaseInfo?.title || t('versionUpdate.newVersionReady')}
</p>
</div>
</div>
@@ -641,11 +676,11 @@ function AppContent() {
{/* Version Info */}
<div className="space-y-3">
<div className="flex justify-between items-center p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Current Version</span>
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">{t('versionUpdate.currentVersion')}</span>
<span className="text-sm text-gray-900 dark:text-white font-mono">{currentVersion}</span>
</div>
<div className="flex justify-between items-center p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-700">
<span className="text-sm font-medium text-blue-700 dark:text-blue-300">Latest Version</span>
<span className="text-sm font-medium text-blue-700 dark:text-blue-300">{t('versionUpdate.latestVersion')}</span>
<span className="text-sm text-blue-900 dark:text-blue-100 font-mono">{latestVersion}</span>
</div>
</div>
@@ -654,7 +689,7 @@ function AppContent() {
{releaseInfo?.body && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-gray-900 dark:text-white">What's New:</h3>
<h3 className="text-sm font-medium text-gray-900 dark:text-white">{t('versionUpdate.whatsNew')}</h3>
{releaseInfo?.htmlUrl && (
<a
href={releaseInfo.htmlUrl}
@@ -662,7 +697,7 @@ function AppContent() {
rel="noopener noreferrer"
className="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 hover:underline flex items-center gap-1"
>
View full release
{t('versionUpdate.viewFullRelease')}
<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>
@@ -680,7 +715,7 @@ function AppContent() {
{/* Update Output */}
{updateOutput && (
<div className="space-y-2">
<h3 className="text-sm font-medium text-gray-900 dark:text-white">Update Progress:</h3>
<h3 className="text-sm font-medium text-gray-900 dark:text-white">{t('versionUpdate.updateProgress')}</h3>
<div className="bg-gray-900 dark:bg-gray-950 rounded-lg p-4 border border-gray-700 max-h-48 overflow-y-auto">
<pre className="text-xs text-green-400 font-mono whitespace-pre-wrap">{updateOutput}</pre>
</div>
@@ -690,14 +725,14 @@ function AppContent() {
{/* Upgrade Instructions */}
{!isUpdating && !updateOutput && (
<div className="space-y-3">
<h3 className="text-sm font-medium text-gray-900 dark:text-white">Manual upgrade:</h3>
<h3 className="text-sm font-medium text-gray-900 dark:text-white">{t('versionUpdate.manualUpgrade')}</h3>
<div className="bg-gray-100 dark:bg-gray-800 rounded-lg p-3 border">
<code className="text-sm text-gray-800 dark:text-gray-200 font-mono">
git checkout main && git pull && npm install
</code>
</div>
<p className="text-xs text-gray-600 dark:text-gray-400">
Or click "Update Now" to run the update automatically.
{t('versionUpdate.manualUpgradeHint')}
</p>
</div>
)}
@@ -708,7 +743,7 @@ function AppContent() {
onClick={() => setShowVersionModal(false)}
className="flex-1 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-md transition-colors"
>
{updateOutput ? 'Close' : 'Later'}
{updateOutput ? t('versionUpdate.buttons.close') : t('versionUpdate.buttons.later')}
</button>
{!updateOutput && (
<>
@@ -718,7 +753,7 @@ function AppContent() {
}}
className="flex-1 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-md transition-colors"
>
Copy Command
{t('versionUpdate.buttons.copyCommand')}
</button>
<button
onClick={handleUpdateNow}
@@ -728,10 +763,10 @@ function AppContent() {
{isUpdating ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
Updating...
{t('versionUpdate.buttons.updating')}
</>
) : (
'Update Now'
t('versionUpdate.buttons.updateNow')
)}
</button>
</>
@@ -763,6 +798,7 @@ function AppContent() {
onSessionDelete={handleSessionDelete}
onProjectDelete={handleProjectDelete}
isLoading={isLoadingProjects}
loadingProgress={loadingProgress}
onRefresh={handleSidebarRefresh}
onShowSettings={() => setShowSettings(true)}
updateAvailable={updateAvailable}
@@ -781,8 +817,8 @@ function AppContent() {
<button
onClick={() => setSidebarVisible(true)}
className="p-2 hover:bg-accent rounded-md transition-colors duration-200 group"
aria-label="Show sidebar"
title="Show sidebar"
aria-label={t('versionUpdate.ariaLabels.showSidebar')}
title={t('versionUpdate.ariaLabels.showSidebar')}
>
<svg
className="w-5 h-5 text-foreground group-hover:scale-110 transition-transform"
@@ -798,8 +834,8 @@ function AppContent() {
<button
onClick={() => setShowSettings(true)}
className="p-2 hover:bg-accent rounded-md transition-colors duration-200"
aria-label="Settings"
title="Settings"
aria-label={t('versionUpdate.ariaLabels.settings')}
title={t('versionUpdate.ariaLabels.settings')}
>
<SettingsIcon className="w-5 h-5 text-muted-foreground hover:text-foreground transition-colors" />
</button>
@@ -809,8 +845,8 @@ function AppContent() {
<button
onClick={() => setShowVersionModal(true)}
className="relative p-2 hover:bg-accent rounded-md transition-colors duration-200"
aria-label="Update available"
title="Update available"
aria-label={t('versionUpdate.ariaLabels.updateAvailable')}
title={t('versionUpdate.ariaLabels.updateAvailable')}
>
<Sparkles className="w-5 h-5 text-blue-500" />
<span className="absolute top-1 right-1 w-2 h-2 bg-blue-500 rounded-full animate-pulse" />
@@ -838,7 +874,7 @@ function AppContent() {
e.stopPropagation();
setSidebarOpen(false);
}}
aria-label="Close sidebar"
aria-label={t('versionUpdate.ariaLabels.closeSidebar')}
/>
<div
className={`relative w-[85vw] max-w-sm sm:w-80 h-full bg-card border-r border-border transform transition-transform duration-150 ease-out ${
@@ -857,6 +893,7 @@ function AppContent() {
onSessionDelete={handleSessionDelete}
onProjectDelete={handleProjectDelete}
isLoading={isLoadingProjects}
loadingProgress={loadingProgress}
onRefresh={handleSidebarRefresh}
onShowSettings={() => setShowSettings(true)}
updateAvailable={updateAvailable}
@@ -881,7 +918,7 @@ function AppContent() {
setActiveTab={setActiveTab}
ws={ws}
sendMessage={sendMessage}
messages={messages}
latestMessage={latestMessage}
isMobile={isMobile}
isPWA={isPWA}
onMenuClick={() => setSidebarOpen(true)}
@@ -948,24 +985,26 @@ function AppContent() {
// Root App component with router
function App() {
return (
<ThemeProvider>
<AuthProvider>
<WebSocketProvider>
<TasksSettingsProvider>
<TaskMasterProvider>
<ProtectedRoute>
<Router>
<Routes>
<Route path="/" element={<AppContent />} />
<Route path="/session/:sessionId" element={<AppContent />} />
</Routes>
</Router>
</ProtectedRoute>
</TaskMasterProvider>
</TasksSettingsProvider>
</WebSocketProvider>
</AuthProvider>
</ThemeProvider>
<I18nextProvider i18n={i18n}>
<ThemeProvider>
<AuthProvider>
<WebSocketProvider>
<TasksSettingsProvider>
<TaskMasterProvider>
<ProtectedRoute>
<Router basename={window.__ROUTER_BASENAME__ || ''}>
<Routes>
<Route path="/" element={<AppContent />} />
<Route path="/session/:sessionId" element={<AppContent />} />
</Routes>
</Router>
</ProtectedRoute>
</TaskMasterProvider>
</TasksSettingsProvider>
</WebSocketProvider>
</AuthProvider>
</ThemeProvider>
</I18nextProvider>
);
}

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -12,8 +12,10 @@ import { unifiedMergeView, getChunks } from '@codemirror/merge';
import { showMinimap } from '@replit/codemirror-minimap';
import { X, Save, Download, Maximize2, Minimize2 } from 'lucide-react';
import { api } from '../utils/api';
import { useTranslation } from 'react-i18next';
function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded = false, onToggleExpand = null }) {
const { t } = useTranslation('codeEditor');
const [content, setContent] = useState('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
@@ -125,13 +127,13 @@ function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded
toolbarHTML += '<div style="display: flex; align-items: center; gap: 8px;">';
if (hasDiff) {
toolbarHTML += `
<span style="font-weight: 500;">${chunkCount > 0 ? `${currentIndex + 1}/${chunkCount}` : '0'} changes</span>
<button class="cm-diff-nav-btn cm-diff-nav-prev" title="Previous change" ${chunkCount === 0 ? 'disabled' : ''}>
<span style="font-weight: 500;">${chunkCount > 0 ? `${currentIndex + 1}/${chunkCount}` : '0'} ${t('toolbar.changes')}</span>
<button class="cm-diff-nav-btn cm-diff-nav-prev" title="${t('toolbar.previousChange')}" ${chunkCount === 0 ? 'disabled' : ''}>
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
</svg>
</button>
<button class="cm-diff-nav-btn cm-diff-nav-next" title="Next change" ${chunkCount === 0 ? 'disabled' : ''}>
<button class="cm-diff-nav-btn cm-diff-nav-next" title="${t('toolbar.nextChange')}" ${chunkCount === 0 ? 'disabled' : ''}>
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
@@ -146,7 +148,7 @@ function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded
// Show/hide diff button (only if there's diff info)
if (file.diffInfo) {
toolbarHTML += `
<button class="cm-toolbar-btn cm-toggle-diff-btn" title="${showDiff ? 'Hide diff highlighting' : 'Show diff highlighting'}">
<button class="cm-toolbar-btn cm-toggle-diff-btn" title="${showDiff ? t('toolbar.hideDiff') : t('toolbar.showDiff')}">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
${showDiff ?
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />' :
@@ -159,7 +161,7 @@ function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded
// Settings button
toolbarHTML += `
<button class="cm-toolbar-btn cm-settings-btn" title="Editor Settings">
<button class="cm-toolbar-btn cm-settings-btn" title="${t('toolbar.settings')}">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
@@ -169,7 +171,7 @@ function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded
// Expand button (only in sidebar mode)
if (isSidebar && onToggleExpand) {
toolbarHTML += `
<button class="cm-toolbar-btn cm-expand-btn" title="${isExpanded ? 'Collapse editor' : 'Expand editor to full width'}">
<button class="cm-toolbar-btn cm-expand-btn" title="${isExpanded ? t('toolbar.collapse') : t('toolbar.expand')}">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
${isExpanded ?
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 9V4.5M9 9H4.5M9 9L3.75 3.75M9 15v4.5M9 15H4.5M9 15l-5.25 5.25M15 9h4.5M15 9V4.5M15 9l5.25-5.25M15 15h4.5M15 15v4.5m0-4.5l5.25 5.25" />' :
@@ -463,7 +465,7 @@ function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded
<div className="w-full h-full flex items-center justify-center bg-background">
<div className="flex items-center gap-3">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
<span className="text-gray-900 dark:text-white">Loading {file.name}...</span>
<span className="text-gray-900 dark:text-white">{t('loading', { fileName: file.name })}</span>
</div>
</div>
) : (
@@ -471,7 +473,7 @@ function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded
<div className="code-editor-loading w-full h-full md:rounded-lg md:w-auto md:h-auto p-8 flex items-center justify-center">
<div className="flex items-center gap-3">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
<span className="text-gray-900 dark:text-white">Loading {file.name}...</span>
<span className="text-gray-900 dark:text-white">{t('loading', { fileName: file.name })}</span>
</div>
</div>
</div>
@@ -574,7 +576,7 @@ function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded
<h3 className="font-medium text-gray-900 dark:text-white truncate">{file.name}</h3>
{file.diffInfo && (
<span className="text-xs bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-300 px-2 py-1 rounded whitespace-nowrap">
Showing changes
{t('header.showingChanges')}
</span>
)}
</div>
@@ -586,7 +588,7 @@ function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded
<button
onClick={handleDownload}
className="p-2 md:p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[44px] min-h-[44px] md:min-w-0 md:min-h-0 flex items-center justify-center"
title="Download file"
title={t('actions.download')}
>
<Download className="w-5 h-5 md:w-4 md:h-4" />
</button>
@@ -605,12 +607,12 @@ function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded
<svg className="w-5 h-5 md:w-4 md:h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className="hidden sm:inline">Saved!</span>
<span className="hidden sm:inline">{t('actions.saved')}</span>
</>
) : (
<>
<Save className="w-5 h-5 md:w-4 md:h-4" />
<span className="hidden sm:inline">{saving ? 'Saving...' : 'Save'}</span>
<span className="hidden sm:inline">{saving ? t('actions.saving') : t('actions.save')}</span>
</>
)}
</button>
@@ -619,7 +621,7 @@ function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded
<button
onClick={toggleFullscreen}
className="hidden md:flex p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 items-center justify-center"
title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
title={isFullscreen ? t('actions.exitFullscreen') : t('actions.fullscreen')}
>
{isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
</button>
@@ -628,7 +630,7 @@ function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded
<button
onClick={onClose}
className="p-2 md:p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[44px] min-h-[44px] md:min-w-0 md:min-h-0 flex items-center justify-center"
title="Close"
title={t('actions.close')}
>
<X className="w-6 h-6 md:w-4 md:h-4" />
</button>
@@ -686,12 +688,12 @@ function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded
{/* Footer */}
<div className="flex items-center justify-between p-3 border-t border-border bg-muted flex-shrink-0">
<div className="flex items-center gap-4 text-sm text-gray-600 dark:text-gray-400">
<span>Lines: {content.split('\n').length}</span>
<span>Characters: {content.length}</span>
<span>{t('footer.lines')} {content.split('\n').length}</span>
<span>{t('footer.characters')} {content.length}</span>
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
Press Ctrl+S to save Esc to close
{t('footer.shortcuts')}
</div>
</div>
</div>

View File

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

View File

@@ -1,4 +1,5 @@
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';
@@ -9,6 +10,7 @@ import ImageViewer from './ImageViewer';
import { api } from '../utils/api';
function FileTree({ selectedProject }) {
const { t } = useTranslation();
const [files, setFiles] = useState([]);
const [loading, setLoading] = useState(false);
const [expandedDirs, setExpandedDirs] = useState(new Set());
@@ -130,11 +132,11 @@ function FileTree({ selectedProject }) {
const now = new Date();
const past = new Date(date);
const diffInSeconds = Math.floor((now - past) / 1000);
if (diffInSeconds < 60) return 'just now';
if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)} min ago`;
if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)} hours ago`;
if (diffInSeconds < 2592000) return `${Math.floor(diffInSeconds / 86400)} days ago`;
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();
};
@@ -348,7 +350,7 @@ function FileTree({ selectedProject }) {
return (
<div className="h-full flex items-center justify-center">
<div className="text-gray-500 dark:text-gray-400">
Loading files...
{t('fileTree.loading')}
</div>
</div>
);
@@ -359,14 +361,14 @@ function FileTree({ selectedProject }) {
{/* Header with Search and View Mode Toggle */}
<div className="p-4 border-b border-border space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-foreground">Files</h3>
<h3 className="text-sm font-medium text-foreground">{t('fileTree.files')}</h3>
<div className="flex gap-1">
<Button
variant={viewMode === 'simple' ? 'default' : 'ghost'}
size="sm"
className="h-8 w-8 p-0"
onClick={() => changeViewMode('simple')}
title="Simple view"
title={t('fileTree.simpleView')}
>
<List className="w-4 h-4" />
</Button>
@@ -375,7 +377,7 @@ function FileTree({ selectedProject }) {
size="sm"
className="h-8 w-8 p-0"
onClick={() => changeViewMode('compact')}
title="Compact view"
title={t('fileTree.compactView')}
>
<Eye className="w-4 h-4" />
</Button>
@@ -384,7 +386,7 @@ function FileTree({ selectedProject }) {
size="sm"
className="h-8 w-8 p-0"
onClick={() => changeViewMode('detailed')}
title="Detailed view"
title={t('fileTree.detailedView')}
>
<TableProperties className="w-4 h-4" />
</Button>
@@ -396,7 +398,7 @@ function FileTree({ selectedProject }) {
<Search className="absolute left-2 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
type="text"
placeholder="Search files and folders..."
placeholder={t('fileTree.searchPlaceholder')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-8 pr-8 h-8 text-sm"
@@ -407,7 +409,7 @@ function FileTree({ selectedProject }) {
size="sm"
className="absolute right-1 top-1/2 transform -translate-y-1/2 h-6 w-6 p-0 hover:bg-accent"
onClick={() => setSearchQuery('')}
title="Clear search"
title={t('fileTree.clearSearch')}
>
<X className="w-3 h-3" />
</Button>
@@ -419,23 +421,23 @@ function FileTree({ selectedProject }) {
{viewMode === 'detailed' && filteredFiles.length > 0 && (
<div className="px-4 pt-2 pb-1 border-b border-border">
<div className="grid grid-cols-12 gap-2 px-2 text-xs font-medium text-muted-foreground">
<div className="col-span-5">Name</div>
<div className="col-span-2">Size</div>
<div className="col-span-3">Modified</div>
<div className="col-span-2">Permissions</div>
<div className="col-span-5">{t('fileTree.name')}</div>
<div className="col-span-2">{t('fileTree.size')}</div>
<div className="col-span-3">{t('fileTree.modified')}</div>
<div className="col-span-2">{t('fileTree.permissions')}</div>
</div>
</div>
)}
<ScrollArea className="flex-1 p-4">
{files.length === 0 ? (
<div className="text-center py-8">
<div className="w-12 h-12 bg-muted rounded-lg flex items-center justify-center mx-auto mb-3">
<Folder className="w-6 h-6 text-muted-foreground" />
</div>
<h4 className="font-medium text-foreground mb-1">No files found</h4>
<h4 className="font-medium text-foreground mb-1">{t('fileTree.noFilesFound')}</h4>
<p className="text-sm text-muted-foreground">
Check if the project path is accessible
{t('fileTree.checkProjectPath')}
</p>
</div>
) : filteredFiles.length === 0 && searchQuery ? (
@@ -443,9 +445,9 @@ function FileTree({ selectedProject }) {
<div className="w-12 h-12 bg-muted rounded-lg flex items-center justify-center mx-auto mb-3">
<Search className="w-6 h-6 text-muted-foreground" />
</div>
<h4 className="font-medium text-foreground mb-1">No matches found</h4>
<h4 className="font-medium text-foreground mb-1">{t('fileTree.noMatchesFound')}</h4>
<p className="text-sm text-muted-foreground">
Try a different search term or clear the search
{t('fileTree.tryDifferentSearch')}
</p>
</div>
) : (

View File

@@ -960,6 +960,7 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
{gitStatus.details && (
<p className="text-sm text-center leading-relaxed mb-6 max-w-md">{gitStatus.details}</p>
)}
{/* // ! This can be a custom component that can be reused for " Tip: Create a new project..." as well */}
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800 max-w-md">
<p className="text-sm text-blue-700 dark:text-blue-300 text-center">
<strong>Tip:</strong> Run <code className="bg-blue-100 dark:bg-blue-900 px-2 py-1 rounded font-mono text-xs">git init</code> in your project directory to initialize git source control.

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import { X } from 'lucide-react';
import StandaloneShell from './StandaloneShell';
import { IS_PLATFORM } from '../constants/config';
/**
* Reusable login modal component for Claude, Cursor, and Codex CLI authentication
@@ -11,6 +12,7 @@ import StandaloneShell from './StandaloneShell';
* @param {Object} props.project - Project object containing name and path information
* @param {Function} props.onComplete - Callback when login process completes (receives exitCode)
* @param {string} props.customCommand - Optional custom command to override defaults
* @param {boolean} props.isAuthenticated - Whether user is already authenticated (for re-auth flow)
*/
function LoginModal({
isOpen,
@@ -18,7 +20,8 @@ function LoginModal({
provider = 'claude',
project,
onComplete,
customCommand
customCommand,
isAuthenticated = false
}) {
if (!isOpen) return null;
@@ -27,13 +30,13 @@ function LoginModal({
switch (provider) {
case 'claude':
return 'claude setup-token --dangerously-skip-permissions';
return isAuthenticated ? 'claude setup-token --dangerously-skip-permissions' : 'claude /exit --dangerously-skip-permissions';
case 'cursor':
return 'cursor-agent login';
case 'codex':
return 'codex login';
return IS_PLATFORM ? 'codex login --device-auth' : 'codex login';
default:
return 'claude setup-token --dangerously-skip-permissions';
return isAuthenticated ? 'claude setup-token --dangerously-skip-permissions' : 'claude /exit --dangerously-skip-permissions';
}
};

View File

@@ -12,6 +12,7 @@
*/
import React, { useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import ChatInterface from './ChatInterface';
import FileTree from './FileTree';
import CodeEditor from './CodeEditor';
@@ -35,9 +36,9 @@ function MainContent({
setActiveTab,
ws,
sendMessage,
messages,
latestMessage,
isMobile,
isPWA,
isPWA, // ! Unused
onMenuClick,
isLoading,
onInputFocusChange,
@@ -58,6 +59,7 @@ function MainContent({
sendByCtrlEnter, // Send by Ctrl+Enter mode for East Asian language input
externalMessageUpdate // Trigger for external CLI updates to current session
}) {
const { t } = useTranslation();
const [editingFile, setEditingFile] = useState(null);
const [selectedTask, setSelectedTask] = useState(null);
const [showTaskDetail, setShowTaskDetail] = useState(false);
@@ -238,8 +240,8 @@ function MainContent({
}}
/>
</div>
<h2 className="text-xl font-semibold mb-2">Loading Claude Code UI</h2>
<p>Setting up your workspace...</p>
<h2 className="text-xl font-semibold mb-2">{t('mainContent.loading')}</h2>
<p>{t('mainContent.settingUpWorkspace')}</p>
</div>
</div>
</div>
@@ -271,13 +273,13 @@ function MainContent({
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-5l-2-2H5a2 2 0 00-2 2z" />
</svg>
</div>
<h2 className="text-2xl font-semibold mb-3 text-gray-900 dark:text-white">Choose Your Project</h2>
<h2 className="text-2xl font-semibold mb-3 text-gray-900 dark:text-white">{t('mainContent.chooseProject')}</h2>
<p className="text-gray-600 dark:text-gray-300 mb-6 leading-relaxed">
Select a project from the sidebar to start coding with Claude. Each project contains your chat sessions and file history.
{t('mainContent.selectProjectDescription')}
</p>
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4 border border-blue-200 dark:border-blue-800">
<p className="text-sm text-blue-700 dark:text-blue-300">
💡 <strong>Tip:</strong> {isMobile ? 'Tap the menu button above to access projects' : 'Create a new project by clicking the folder icon in the sidebar'}
💡 <strong>{t('mainContent.tip')}:</strong> {isMobile ? t('mainContent.createProjectMobile') : t('mainContent.createProjectDesktop')}
</p>
</div>
</div>
@@ -331,7 +333,7 @@ function MainContent({
) : activeTab === 'chat' && !selectedSession ? (
<div className="min-w-0">
<h2 className="text-sm sm:text-base font-semibold text-gray-900 dark:text-white">
New Session
{t('mainContent.newSession')}
</h2>
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
{selectedProject.displayName}
@@ -340,8 +342,8 @@ function MainContent({
) : (
<div className="min-w-0">
<h2 className="text-sm sm:text-base font-semibold text-gray-900 dark:text-white">
{activeTab === 'files' ? 'Project Files' :
activeTab === 'git' ? 'Source Control' :
{activeTab === 'files' ? t('mainContent.projectFiles') :
activeTab === 'git' ? t('tabs.git') :
(activeTab === 'tasks' && shouldShowTasksTab) ? 'TaskMaster' :
'Project'}
</h2>
@@ -357,7 +359,7 @@ function MainContent({
{/* Modern Tab Navigation - Right Side */}
<div className="flex-shrink-0 hidden sm:block">
<div className="relative flex bg-gray-100 dark:bg-gray-800 rounded-lg p-1">
<Tooltip content="Chat" position="bottom">
<Tooltip content={t('tabs.chat')} position="bottom">
<button
onClick={() => setActiveTab('chat')}
className={`relative px-2 sm:px-3 py-1.5 text-xs sm:text-sm font-medium rounded-md ${
@@ -370,11 +372,11 @@ function MainContent({
<svg className="w-3 sm:w-3.5 h-3 sm:h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
<span className="hidden md:hidden lg:inline">Chat</span>
<span className="hidden md:hidden lg:inline">{t('tabs.chat')}</span>
</span>
</button>
</Tooltip>
<Tooltip content="Shell" position="bottom">
<Tooltip content={t('tabs.shell')} position="bottom">
<button
onClick={() => setActiveTab('shell')}
className={`relative px-2 sm:px-3 py-1.5 text-xs sm:text-sm font-medium rounded-md transition-all duration-200 ${
@@ -387,11 +389,11 @@ function MainContent({
<svg className="w-3 sm:w-3.5 h-3 sm:h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
<span className="hidden md:hidden lg:inline">Shell</span>
<span className="hidden md:hidden lg:inline">{t('tabs.shell')}</span>
</span>
</button>
</Tooltip>
<Tooltip content="Files" position="bottom">
<Tooltip content={t('tabs.files')} position="bottom">
<button
onClick={() => setActiveTab('files')}
className={`relative px-2 sm:px-3 py-1.5 text-xs sm:text-sm font-medium rounded-md transition-all duration-200 ${
@@ -404,11 +406,11 @@ function MainContent({
<svg className="w-3 sm:w-3.5 h-3 sm:h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-5l-2-2H5a2 2 0 00-2 2z" />
</svg>
<span className="hidden md:hidden lg:inline">Files</span>
<span className="hidden md:hidden lg:inline">{t('tabs.files')}</span>
</span>
</button>
</Tooltip>
<Tooltip content="Source Control" position="bottom">
<Tooltip content={t('tabs.git')} position="bottom">
<button
onClick={() => setActiveTab('git')}
className={`relative px-2 sm:px-3 py-1.5 text-xs sm:text-sm font-medium rounded-md transition-all duration-200 ${
@@ -421,12 +423,12 @@ function MainContent({
<svg className="w-3 sm:w-3.5 h-3 sm:h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<span className="hidden md:hidden lg:inline">Source Control</span>
<span className="hidden md:hidden lg:inline">{t('tabs.git')}</span>
</span>
</button>
</Tooltip>
{shouldShowTasksTab && (
<Tooltip content="Tasks" position="bottom">
<Tooltip content={t('tabs.tasks')} position="bottom">
<button
onClick={() => setActiveTab('tasks')}
className={`relative px-2 sm:px-3 py-1.5 text-xs sm:text-sm font-medium rounded-md transition-all duration-200 ${
@@ -439,7 +441,7 @@ function MainContent({
<svg className="w-3 sm:w-3.5 h-3 sm:h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
</svg>
<span className="hidden md:hidden lg:inline">Tasks</span>
<span className="hidden md:hidden lg:inline">{t('tabs.tasks')}</span>
</span>
</button>
</Tooltip>
@@ -475,7 +477,7 @@ function MainContent({
selectedSession={selectedSession}
ws={ws}
sendMessage={sendMessage}
messages={messages}
latestMessage={latestMessage}
onFileOpen={handleFileOpen}
onInputFocusChange={onInputFocusChange}
onSessionActive={onSessionActive}

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,9 @@
import React, { useState, useEffect } from 'react';
import {
ChevronLeft,
ChevronRight,
Maximize2,
Eye,
import React, { useState, useEffect, useRef, useCallback } from 'react';
import {
ChevronLeft,
ChevronRight,
Maximize2,
Eye,
Settings2,
Moon,
Sun,
@@ -12,10 +12,13 @@ import {
Brain,
Sparkles,
FileText,
Languages
Languages,
GripVertical
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import DarkModeToggle from './DarkModeToggle';
import { useTheme } from '../contexts/ThemeContext';
import LanguageSelector from './LanguageSelector';
const QuickSettingsPanel = ({
isOpen,
@@ -32,17 +35,177 @@ const QuickSettingsPanel = ({
onSendByCtrlEnterChange,
isMobile
}) => {
const { t } = useTranslation('settings');
const [localIsOpen, setLocalIsOpen] = useState(isOpen);
const [whisperMode, setWhisperMode] = useState(() => {
return localStorage.getItem('whisperMode') || 'default';
});
const { isDarkMode } = useTheme();
// Draggable handle state
const [handlePosition, setHandlePosition] = useState(() => {
const saved = localStorage.getItem('quickSettingsHandlePosition');
if (saved) {
try {
const parsed = JSON.parse(saved);
return parsed.y ?? 50;
} catch {
// Remove corrupted data
localStorage.removeItem('quickSettingsHandlePosition');
return 50;
}
}
return 50; // Default to 50% (middle of screen)
});
const [isDragging, setIsDragging] = useState(false);
const [dragStartY, setDragStartY] = useState(0);
const [dragStartPosition, setDragStartPosition] = useState(0);
const [hasMoved, setHasMoved] = useState(false); // Track if user has moved during drag
const handleRef = useRef(null);
const constraintsRef = useRef({ min: 10, max: 90 }); // Percentage constraints
const dragThreshold = 5; // Pixels to move before it's considered a drag
useEffect(() => {
setLocalIsOpen(isOpen);
}, [isOpen]);
const handleToggle = () => {
// Save handle position to localStorage when it changes
useEffect(() => {
localStorage.setItem('quickSettingsHandlePosition', JSON.stringify({ y: handlePosition }));
}, [handlePosition]);
// Calculate position from percentage
const getPositionStyle = useCallback(() => {
if (isMobile) {
// On mobile, convert percentage to pixels from bottom
const bottomPixels = (window.innerHeight * handlePosition) / 100;
return { bottom: `${bottomPixels}px` };
} else {
// On desktop, use top with percentage
return { top: `${handlePosition}%`, transform: 'translateY(-50%)' };
}
}, [handlePosition, isMobile]);
// Handle mouse/touch start
const handleDragStart = useCallback((e) => {
// Don't prevent default yet - we want to allow click if no drag happens
e.stopPropagation();
const clientY = e.type.includes('touch') ? e.touches[0].clientY : e.clientY;
setDragStartY(clientY);
setDragStartPosition(handlePosition);
setHasMoved(false);
setIsDragging(false); // Don't set dragging until threshold is passed
}, [handlePosition]);
// Handle mouse/touch move
const handleDragMove = useCallback((e) => {
if (dragStartY === 0) return; // Not in a potential drag
const clientY = e.type.includes('touch') ? e.touches[0].clientY : e.clientY;
const deltaY = Math.abs(clientY - dragStartY);
// Check if we've moved past threshold
if (!isDragging && deltaY > dragThreshold) {
setIsDragging(true);
setHasMoved(true);
document.body.style.cursor = 'grabbing';
document.body.style.userSelect = 'none';
// Prevent body scroll on mobile during drag
if (e.type.includes('touch')) {
document.body.style.overflow = 'hidden';
document.body.style.position = 'fixed';
document.body.style.width = '100%';
}
}
if (!isDragging) return;
// Prevent scrolling on touch move
if (e.type.includes('touch')) {
e.preventDefault();
}
const actualDeltaY = clientY - dragStartY;
// For top-based positioning (desktop), moving down increases top percentage
// For bottom-based positioning (mobile), we need to invert
let percentageDelta;
if (isMobile) {
// On mobile, moving down should decrease bottom position (increase percentage from top)
percentageDelta = -(actualDeltaY / window.innerHeight) * 100;
} else {
// On desktop, moving down should increase top position
percentageDelta = (actualDeltaY / window.innerHeight) * 100;
}
let newPosition = dragStartPosition + percentageDelta;
// Apply constraints
newPosition = Math.max(constraintsRef.current.min, Math.min(constraintsRef.current.max, newPosition));
setHandlePosition(newPosition);
}, [isDragging, dragStartY, dragStartPosition, isMobile, dragThreshold]);
// Handle mouse/touch end
const handleDragEnd = useCallback(() => {
setIsDragging(false);
setDragStartY(0);
document.body.style.cursor = '';
document.body.style.userSelect = '';
// Restore body scroll on mobile
document.body.style.overflow = '';
document.body.style.position = '';
document.body.style.width = '';
}, []);
// Cleanup body styles on unmount in case component unmounts while dragging
useEffect(() => {
return () => {
document.body.style.cursor = '';
document.body.style.userSelect = '';
document.body.style.overflow = '';
document.body.style.position = '';
document.body.style.width = '';
};
}, []);
// Set up global event listeners for drag
useEffect(() => {
if (dragStartY !== 0) {
// Mouse events
const handleMouseMove = (e) => handleDragMove(e);
const handleMouseUp = () => handleDragEnd();
// Touch events
const handleTouchMove = (e) => handleDragMove(e);
const handleTouchEnd = () => handleDragEnd();
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
document.addEventListener('touchmove', handleTouchMove, { passive: false });
document.addEventListener('touchend', handleTouchEnd);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
document.removeEventListener('touchmove', handleTouchMove);
document.removeEventListener('touchend', handleTouchEnd);
};
}
}, [dragStartY, handleDragMove, handleDragEnd]);
const handleToggle = (e) => {
// Don't toggle if user was dragging
if (hasMoved) {
e.preventDefault();
setHasMoved(false);
return;
}
const newState = !localIsOpen;
setLocalIsOpen(newState);
onToggle(newState);
@@ -50,24 +213,37 @@ const QuickSettingsPanel = ({
return (
<>
{/* Pull Tab */}
<div
className={`fixed ${isMobile ? 'bottom-44' : 'top-1/2 -translate-y-1/2'} ${
{/* Pull Tab - Combined drag handle and toggle button */}
<button
ref={handleRef}
onClick={handleToggle}
onMouseDown={(e) => {
// Start drag on mousedown
handleDragStart(e);
}}
onTouchStart={(e) => {
// Start drag on touchstart
handleDragStart(e);
}}
className={`fixed ${
localIsOpen ? 'right-64' : 'right-0'
} z-50 transition-all duration-150 ease-out`}
} z-50 ${isDragging ? '' : 'transition-all duration-150 ease-out'} bg-white dark:bg-gray-800 border ${
isDragging ? 'border-blue-500 dark:border-blue-400' : 'border-gray-200 dark:border-gray-700'
} rounded-l-md p-2 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors shadow-lg ${
isDragging ? 'cursor-grabbing' : 'cursor-pointer'
} touch-none`}
style={{ ...getPositionStyle(), touchAction: 'none', WebkitTouchCallout: 'none', WebkitUserSelect: 'none' }}
aria-label={isDragging ? t('quickSettings.dragHandle.dragging') : localIsOpen ? t('quickSettings.dragHandle.closePanel') : t('quickSettings.dragHandle.openPanel')}
title={isDragging ? t('quickSettings.dragHandle.draggingStatus') : t('quickSettings.dragHandle.toggleAndMove')}
>
<button
onClick={handleToggle}
className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-l-md p-2 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors shadow-lg"
aria-label={localIsOpen ? 'Close settings panel' : 'Open settings panel'}
>
{localIsOpen ? (
<ChevronRight className="h-5 w-5 text-gray-600 dark:text-gray-400" />
) : (
<ChevronLeft className="h-5 w-5 text-gray-600 dark:text-gray-400" />
)}
</button>
</div>
{isDragging ? (
<GripVertical className="h-5 w-5 text-blue-500 dark:text-blue-400" />
) : localIsOpen ? (
<ChevronRight className="h-5 w-5 text-gray-600 dark:text-gray-400" />
) : (
<ChevronLeft className="h-5 w-5 text-gray-600 dark:text-gray-400" />
)}
</button>
{/* Panel */}
<div
@@ -80,7 +256,7 @@ const QuickSettingsPanel = ({
<div className="p-4 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<Settings2 className="h-5 w-5 text-gray-600 dark:text-gray-400" />
Quick Settings
{t('quickSettings.title')}
</h3>
</div>
@@ -88,25 +264,30 @@ const QuickSettingsPanel = ({
<div className={`flex-1 overflow-y-auto overflow-x-hidden p-4 space-y-6 bg-background ${isMobile ? 'pb-mobile-nav' : ''}`}>
{/* Appearance Settings */}
<div className="space-y-2">
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">Appearance</h4>
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">{t('quickSettings.sections.appearance')}</h4>
<div className="flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors border border-transparent hover:border-gray-300 dark:hover:border-gray-600">
<span className="flex items-center gap-2 text-sm text-gray-900 dark:text-white">
{isDarkMode ? <Moon className="h-4 w-4 text-gray-600 dark:text-gray-400" /> : <Sun className="h-4 w-4 text-gray-600 dark:text-gray-400" />}
Dark Mode
{t('quickSettings.darkMode')}
</span>
<DarkModeToggle />
</div>
{/* Language Selector */}
<div>
<LanguageSelector compact={true} />
</div>
</div>
{/* Tool Display Settings */}
<div className="space-y-2">
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">Tool Display</h4>
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">{t('quickSettings.sections.toolDisplay')}</h4>
<label className="flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer transition-colors border border-transparent hover:border-gray-300 dark:hover:border-gray-600">
<span className="flex items-center gap-2 text-sm text-gray-900 dark:text-white">
<Maximize2 className="h-4 w-4 text-gray-600 dark:text-gray-400" />
Auto-expand tools
{t('quickSettings.autoExpandTools')}
</span>
<input
type="checkbox"
@@ -119,7 +300,7 @@ const QuickSettingsPanel = ({
<label className="flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer transition-colors border border-transparent hover:border-gray-300 dark:hover:border-gray-600">
<span className="flex items-center gap-2 text-sm text-gray-900 dark:text-white">
<Eye className="h-4 w-4 text-gray-600 dark:text-gray-400" />
Show raw parameters
{t('quickSettings.showRawParameters')}
</span>
<input
type="checkbox"
@@ -132,7 +313,7 @@ const QuickSettingsPanel = ({
<label className="flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer transition-colors border border-transparent hover:border-gray-300 dark:hover:border-gray-600">
<span className="flex items-center gap-2 text-sm text-gray-900 dark:text-white">
<Brain className="h-4 w-4 text-gray-600 dark:text-gray-400" />
Show thinking
{t('quickSettings.showThinking')}
</span>
<input
type="checkbox"
@@ -144,12 +325,12 @@ const QuickSettingsPanel = ({
</div>
{/* View Options */}
<div className="space-y-2">
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">View Options</h4>
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">{t('quickSettings.sections.viewOptions')}</h4>
<label className="flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer transition-colors border border-transparent hover:border-gray-300 dark:hover:border-gray-600">
<span className="flex items-center gap-2 text-sm text-gray-900 dark:text-white">
<ArrowDown className="h-4 w-4 text-gray-600 dark:text-gray-400" />
Auto-scroll to bottom
{t('quickSettings.autoScrollToBottom')}
</span>
<input
type="checkbox"
@@ -162,12 +343,12 @@ const QuickSettingsPanel = ({
{/* Input Settings */}
<div className="space-y-2">
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">Input Settings</h4>
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">{t('quickSettings.sections.inputSettings')}</h4>
<label className="flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer transition-colors border border-transparent hover:border-gray-300 dark:hover:border-gray-600">
<span className="flex items-center gap-2 text-sm text-gray-900 dark:text-white">
<Languages className="h-4 w-4 text-gray-600 dark:text-gray-400" />
Send by Ctrl+Enter
{t('quickSettings.sendByCtrlEnter')}
</span>
<input
type="checkbox"
@@ -177,13 +358,13 @@ const QuickSettingsPanel = ({
/>
</label>
<p className="text-xs text-gray-500 dark:text-gray-400 ml-3">
When enabled, pressing Ctrl+Enter will send the message instead of just Enter. This is useful for IME users to avoid accidental sends.
{t('quickSettings.sendByCtrlEnterDescription')}
</p>
</div>
{/* Whisper Dictation Settings - HIDDEN */}
<div className="space-y-2" style={{ display: 'none' }}>
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">Whisper Dictation</h4>
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">{t('quickSettings.sections.whisperDictation')}</h4>
<div className="space-y-2">
<label className="flex items-start p-3 rounded-lg bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer transition-colors border border-transparent hover:border-gray-300 dark:hover:border-gray-600">
@@ -202,10 +383,10 @@ const QuickSettingsPanel = ({
<div className="ml-3 flex-1">
<span className="flex items-center gap-2 text-sm font-medium text-gray-900 dark:text-white">
<Mic className="h-4 w-4 text-gray-600 dark:text-gray-400" />
Default Mode
{t('quickSettings.whisper.modes.default')}
</span>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Direct transcription of your speech
{t('quickSettings.whisper.modes.defaultDescription')}
</p>
</div>
</label>
@@ -226,10 +407,10 @@ const QuickSettingsPanel = ({
<div className="ml-3 flex-1">
<span className="flex items-center gap-2 text-sm font-medium text-gray-900 dark:text-white">
<Sparkles className="h-4 w-4 text-gray-600 dark:text-gray-400" />
Prompt Enhancement
{t('quickSettings.whisper.modes.prompt')}
</span>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Transform rough ideas into clear, detailed AI prompts
{t('quickSettings.whisper.modes.promptDescription')}
</p>
</div>
</label>
@@ -250,10 +431,10 @@ const QuickSettingsPanel = ({
<div className="ml-3 flex-1">
<span className="flex items-center gap-2 text-sm font-medium text-gray-900 dark:text-white">
<FileText className="h-4 w-4 text-gray-600 dark:text-gray-400" />
Vibe Mode
{t('quickSettings.whisper.modes.vibe')}
</span>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Format ideas as clear agent instructions with details
{t('quickSettings.whisper.modes.vibeDescription')}
</p>
</div>
</label>

View File

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

View File

@@ -4,6 +4,8 @@ import { FitAddon } from '@xterm/addon-fit';
import { WebglAddon } from '@xterm/addon-webgl';
import { WebLinksAddon } from '@xterm/addon-web-links';
import '@xterm/xterm/css/xterm.css';
import { useTranslation } from 'react-i18next';
import { IS_PLATFORM } from '../constants/config';
const xtermStyles = `
.xterm .xterm-screen {
@@ -25,6 +27,7 @@ if (typeof document !== 'undefined') {
}
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);
@@ -53,10 +56,9 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
if (isConnecting || isConnected) return;
try {
const isPlatform = import.meta.env.VITE_IS_PLATFORM === 'true';
let wsUrl;
if (isPlatform) {
if (IS_PLATFORM) {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
wsUrl = `${protocol}//${window.location.host}/shell`;
} else {
@@ -232,7 +234,7 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
tabStopWidth: 4,
windowsMode: false,
macOptionIsMeta: true,
macOptionClickForcesSelection: false,
macOptionClickForcesSelection: true,
theme: {
background: '#1e1e1e',
foreground: '#d4d4d4',
@@ -373,8 +375,8 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
</div>
<h3 className="text-lg font-semibold mb-2">Select a Project</h3>
<p>Choose a project to open an interactive shell in that directory</p>
<h3 className="text-lg font-semibold mb-2">{t('shell.selectProject.title')}</h3>
<p>{t('shell.selectProject.description')}</p>
</div>
</div>
);
@@ -400,13 +402,13 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
</span>
)}
{!selectedSession && (
<span className="text-xs text-gray-400">(New Session)</span>
<span className="text-xs text-gray-400">{t('shell.status.newSession')}</span>
)}
{!isInitialized && (
<span className="text-xs text-yellow-400">(Initializing...)</span>
<span className="text-xs text-yellow-400">{t('shell.status.initializing')}</span>
)}
{isRestarting && (
<span className="text-xs text-blue-400">(Restarting...)</span>
<span className="text-xs text-blue-400">{t('shell.status.restarting')}</span>
)}
</div>
<div className="flex items-center space-x-3">
@@ -414,12 +416,12 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
<button
onClick={disconnectFromShell}
className="px-3 py-1 text-xs bg-red-600 text-white rounded hover:bg-red-700 flex items-center space-x-1"
title="Disconnect from shell"
title={t('shell.actions.disconnectTitle')}
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
<span>Disconnect</span>
<span>{t('shell.actions.disconnect')}</span>
</button>
)}
@@ -427,12 +429,12 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
onClick={restartShell}
disabled={isRestarting || isConnected}
className="text-xs text-gray-400 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-1"
title="Restart Shell (disconnect first)"
title={t('shell.actions.restartTitle')}
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<span>Restart</span>
<span>{t('shell.actions.restart')}</span>
</button>
</div>
</div>
@@ -443,7 +445,7 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
{!isInitialized && (
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-90">
<div className="text-white">Loading terminal...</div>
<div className="text-white">{t('shell.loading')}</div>
</div>
)}
@@ -453,19 +455,19 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
<button
onClick={connectToShell}
className="px-6 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors flex items-center justify-center space-x-2 text-base font-medium w-full sm:w-auto"
title="Connect to shell"
title={t('shell.actions.connectTitle')}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<span>Continue in Shell</span>
<span>{t('shell.actions.connect')}</span>
</button>
<p className="text-gray-400 text-sm mt-3 px-2">
{isPlainShell ?
`Run ${initialCommand || 'command'} in ${selectedProject.displayName}` :
t('shell.runCommand', { command: initialCommand || t('shell.defaultCommand'), projectName: selectedProject.displayName }) :
selectedSession ?
`Resume session: ${sessionDisplayNameLong}...` :
'Start a new Claude session'
t('shell.resumeSession', { displayName: sessionDisplayNameLong }) :
t('shell.startSession')
}
</p>
</div>
@@ -477,12 +479,12 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
<div className="text-center max-w-sm w-full">
<div className="flex items-center justify-center space-x-3 text-yellow-400">
<div className="w-6 h-6 animate-spin rounded-full border-2 border-yellow-400 border-t-transparent"></div>
<span className="text-base font-medium">Connecting to shell...</span>
<span className="text-base font-medium">{t('shell.connecting')}</span>
</div>
<p className="text-gray-400 text-sm mt-3 px-2">
{isPlainShell ?
`Running ${initialCommand || 'command'} in ${selectedProject.displayName}` :
`Starting Claude CLI in ${selectedProject.displayName}`
t('shell.runCommand', { command: initialCommand || t('shell.defaultCommand'), projectName: selectedProject.displayName }) :
t('shell.startCli', { projectName: selectedProject.displayName })
}
</p>
</div>

View File

@@ -4,8 +4,9 @@ import { ScrollArea } from './ui/scroll-area';
import { Button } from './ui/button';
import { Badge } from './ui/badge';
import { Input } from './ui/input';
import { useTranslation } from 'react-i18next';
import { FolderOpen, Folder, Plus, MessageSquare, Clock, ChevronDown, ChevronRight, Edit3, Check, X, Trash2, Settings, FolderPlus, RefreshCw, Sparkles, Edit2, Star, Search } from 'lucide-react';
import { FolderOpen, Folder, Plus, MessageSquare, Clock, ChevronDown, ChevronRight, Edit3, Check, X, Trash2, Settings, FolderPlus, RefreshCw, Sparkles, Edit2, Star, Search, AlertTriangle } from 'lucide-react';
import { cn } from '../lib/utils';
import ClaudeLogo from './ClaudeLogo';
import CursorLogo from './CursorLogo.jsx';
@@ -15,30 +16,31 @@ import ProjectCreationWizard from './ProjectCreationWizard';
import { api } from '../utils/api';
import { useTaskMaster } from '../contexts/TaskMasterContext';
import { useTasksSettings } from '../contexts/TasksSettingsContext';
import { IS_PLATFORM } from '../constants/config';
// Move formatTimeAgo outside component to avoid recreation on every render
const formatTimeAgo = (dateString, currentTime) => {
const formatTimeAgo = (dateString, currentTime, t) => {
const date = new Date(dateString);
const now = currentTime;
// Check if date is valid
if (isNaN(date.getTime())) {
return 'Unknown';
return t ? t('status.unknown') : 'Unknown';
}
const diffInMs = now - date;
const diffInSeconds = Math.floor(diffInMs / 1000);
const diffInMinutes = Math.floor(diffInMs / (1000 * 60));
const diffInHours = Math.floor(diffInMs / (1000 * 60 * 60));
const diffInDays = Math.floor(diffInMs / (1000 * 60 * 60 * 24));
if (diffInSeconds < 60) return 'Just now';
if (diffInMinutes === 1) return '1 min ago';
if (diffInMinutes < 60) return `${diffInMinutes} mins ago`;
if (diffInHours === 1) return '1 hour ago';
if (diffInHours < 24) return `${diffInHours} hours ago`;
if (diffInDays === 1) return '1 day ago';
if (diffInDays < 7) return `${diffInDays} days ago`;
if (diffInSeconds < 60) return t ? t('time.justNow') : 'Just now';
if (diffInMinutes === 1) return t ? t('time.oneMinuteAgo') : '1 min ago';
if (diffInMinutes < 60) return t ? t('time.minutesAgo', { count: diffInMinutes }) : `${diffInMinutes} mins ago`;
if (diffInHours === 1) return t ? t('time.oneHourAgo') : '1 hour ago';
if (diffInHours < 24) return t ? t('time.hoursAgo', { count: diffInHours }) : `${diffInHours} hours ago`;
if (diffInDays === 1) return t ? t('time.oneDayAgo') : '1 day ago';
if (diffInDays < 7) return t ? t('time.daysAgo', { count: diffInDays }) : `${diffInDays} days ago`;
return date.toLocaleDateString();
};
@@ -52,6 +54,7 @@ function Sidebar({
onSessionDelete,
onProjectDelete,
isLoading,
loadingProgress,
onRefresh,
onShowSettings,
updateAvailable,
@@ -63,6 +66,7 @@ function Sidebar({
isMobile,
onToggleSidebar
}) {
const { t } = useTranslation('sidebar');
const [expandedProjects, setExpandedProjects] = useState(new Set());
const [editingProject, setEditingProject] = useState(null);
const [showNewProject, setShowNewProject] = useState(false);
@@ -77,6 +81,9 @@ function Sidebar({
const [editingSessionName, setEditingSessionName] = useState('');
const [generatingSummary, setGeneratingSummary] = useState({});
const [searchFilter, setSearchFilter] = useState('');
const [deletingProjects, setDeletingProjects] = useState(new Set());
const [deleteConfirmation, setDeleteConfirmation] = useState(null); // { project, sessionCount }
const [sessionDeleteConfirmation, setSessionDeleteConfirmation] = useState(null); // { projectName, sessionId, sessionTitle, provider }
// TaskMaster context
const { setCurrentProject, mcpServerStatus } = useTaskMaster();
@@ -303,10 +310,15 @@ function Sidebar({
setEditingName('');
};
const deleteSession = async (projectName, sessionId, provider = 'claude') => {
if (!confirm('Are you sure you want to delete this session? This action cannot be undone.')) {
return;
}
const showDeleteSessionConfirmation = (projectName, sessionId, sessionTitle, provider = 'claude') => {
setSessionDeleteConfirmation({ projectName, sessionId, sessionTitle, provider });
};
const confirmDeleteSession = async () => {
if (!sessionDeleteConfirmation) return;
const { projectName, sessionId, provider } = sessionDeleteConfirmation;
setSessionDeleteConfirmation(null);
try {
console.log('[Sidebar] Deleting session:', { projectName, sessionId, provider });
@@ -332,58 +344,72 @@ function Sidebar({
} else {
const errorText = await response.text();
console.error('[Sidebar] Failed to delete session:', { status: response.status, error: errorText });
alert('Failed to delete session. Please try again.');
alert(t('messages.deleteSessionFailed'));
}
} catch (error) {
console.error('[Sidebar] Error deleting session:', error);
alert('Error deleting session. Please try again.');
alert(t('messages.deleteSessionError'));
}
};
const deleteProject = async (projectName) => {
if (!confirm('Are you sure you want to delete this empty project? This action cannot be undone.')) {
return;
}
const deleteProject = (project) => {
const sessionCount = getAllSessions(project).length;
setDeleteConfirmation({ project, sessionCount });
};
const confirmDeleteProject = async () => {
if (!deleteConfirmation) return;
const { project, sessionCount } = deleteConfirmation;
const isEmpty = sessionCount === 0;
setDeleteConfirmation(null);
setDeletingProjects(prev => new Set([...prev, project.name]));
try {
const response = await api.deleteProject(projectName);
const response = await api.deleteProject(project.name, !isEmpty);
if (response.ok) {
// Call parent callback if provided
if (onProjectDelete) {
onProjectDelete(projectName);
onProjectDelete(project.name);
}
} else {
const error = await response.json();
console.error('Failed to delete project');
alert(error.error || 'Failed to delete project. Please try again.');
alert(error.error || t('messages.deleteProjectFailed'));
}
} catch (error) {
console.error('Error deleting project:', error);
alert('Error deleting project. Please try again.');
alert(t('messages.deleteProjectError'));
} finally {
setDeletingProjects(prev => {
const next = new Set(prev);
next.delete(project.name);
return next;
});
}
};
const createNewProject = async () => {
if (!newProjectPath.trim()) {
alert('Please enter a project path');
alert(t('messages.enterProjectPath'));
return;
}
setCreatingProject(true);
try {
const response = await api.createProject(newProjectPath.trim());
if (response.ok) {
const result = await response.json();
// Save the path to recent paths before clearing
saveToRecentPaths(newProjectPath.trim());
setShowNewProject(false);
setNewProjectPath('');
// Refresh projects to show the new one
if (window.refreshProjects) {
window.refreshProjects();
@@ -392,11 +418,11 @@ function Sidebar({
}
} else {
const error = await response.json();
alert(error.error || 'Failed to create project. Please try again.');
alert(error.error || t('messages.createProjectFailed'));
}
} catch (error) {
console.error('Error creating project:', error);
alert('Error creating project. Please try again.');
alert(t('messages.createProjectError'));
} finally {
setCreatingProject(false);
}
@@ -485,6 +511,110 @@ function Sidebar({
document.body
)}
{/* Delete Confirmation Modal */}
{deleteConfirmation && ReactDOM.createPortal(
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="bg-card border border-border rounded-xl shadow-2xl max-w-md w-full overflow-hidden">
<div className="p-6">
<div className="flex items-start gap-4">
<div className="w-12 h-12 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center flex-shrink-0">
<AlertTriangle className="w-6 h-6 text-red-600 dark:text-red-400" />
</div>
<div className="flex-1 min-w-0">
<h3 className="text-lg font-semibold text-foreground mb-2">
{t('deleteConfirmation.deleteProject')}
</h3>
<p className="text-sm text-muted-foreground mb-1">
{t('deleteConfirmation.confirmDelete')}{' '}
<span className="font-medium text-foreground">
{deleteConfirmation.project.displayName || deleteConfirmation.project.name}
</span>?
</p>
{deleteConfirmation.sessionCount > 0 && (
<div className="mt-3 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<p className="text-sm text-red-700 dark:text-red-300 font-medium">
{t('deleteConfirmation.sessionCount', { count: deleteConfirmation.sessionCount })}
</p>
<p className="text-xs text-red-600 dark:text-red-400 mt-1">
{t('deleteConfirmation.allConversationsDeleted')}
</p>
</div>
)}
<p className="text-xs text-muted-foreground mt-3">
{t('deleteConfirmation.cannotUndo')}
</p>
</div>
</div>
</div>
<div className="flex gap-3 p-4 bg-muted/30 border-t border-border">
<Button
variant="outline"
className="flex-1"
onClick={() => setDeleteConfirmation(null)}
>
{t('actions.cancel')}
</Button>
<Button
variant="destructive"
className="flex-1 bg-red-600 hover:bg-red-700 text-white"
onClick={confirmDeleteProject}
>
<Trash2 className="w-4 h-4 mr-2" />
{t('actions.delete')}
</Button>
</div>
</div>
</div>,
document.body
)}
{/* Session Delete Confirmation Modal */}
{sessionDeleteConfirmation && ReactDOM.createPortal(
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="bg-card border border-border rounded-xl shadow-2xl max-w-md w-full overflow-hidden">
<div className="p-6">
<div className="flex items-start gap-4">
<div className="w-12 h-12 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center flex-shrink-0">
<AlertTriangle className="w-6 h-6 text-red-600 dark:text-red-400" />
</div>
<div className="flex-1 min-w-0">
<h3 className="text-lg font-semibold text-foreground mb-2">
{t('deleteConfirmation.deleteSession')}
</h3>
<p className="text-sm text-muted-foreground mb-1">
{t('deleteConfirmation.confirmDelete')}{' '}
<span className="font-medium text-foreground">
{sessionDeleteConfirmation.sessionTitle || t('sessions.unnamed')}
</span>?
</p>
<p className="text-xs text-muted-foreground mt-3">
{t('deleteConfirmation.cannotUndo')}
</p>
</div>
</div>
</div>
<div className="flex gap-3 p-4 bg-muted/30 border-t border-border">
<Button
variant="outline"
className="flex-1"
onClick={() => setSessionDeleteConfirmation(null)}
>
{t('actions.cancel')}
</Button>
<Button
variant="destructive"
className="flex-1 bg-red-600 hover:bg-red-700 text-white"
onClick={confirmDeleteSession}
>
<Trash2 className="w-4 h-4 mr-2" />
{t('actions.delete')}
</Button>
</div>
</div>
</div>,
document.body
)}
<div
className="h-full flex flex-col bg-card md:select-none"
style={isPWA && isMobile ? { paddingTop: '44px' } : {}}
@@ -493,18 +623,18 @@ function Sidebar({
<div className="md:p-4 md:border-b md:border-border">
{/* Desktop Header */}
<div className="hidden md:flex items-center justify-between">
{import.meta.env.VITE_IS_PLATFORM === 'true' ? (
{IS_PLATFORM ? (
<a
href="https://cloudcli.ai/dashboard"
className="flex items-center gap-3 hover:opacity-80 transition-opacity group"
title="View Environments"
title={t('tooltips.viewEnvironments')}
>
<div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center shadow-sm group-hover:shadow-md transition-shadow">
<MessageSquare className="w-4 h-4 text-primary-foreground" />
</div>
<div>
<h1 className="text-lg font-bold text-foreground">Claude Code UI</h1>
<p className="text-sm text-muted-foreground">AI coding assistant interface</p>
<h1 className="text-lg font-bold text-foreground">{t('app.title')}</h1>
<p className="text-sm text-muted-foreground">{t('app.subtitle')}</p>
</div>
</a>
) : (
@@ -513,8 +643,8 @@ function Sidebar({
<MessageSquare className="w-4 h-4 text-primary-foreground" />
</div>
<div>
<h1 className="text-lg font-bold text-foreground">Claude Code UI</h1>
<p className="text-sm text-muted-foreground">AI coding assistant interface</p>
<h1 className="text-lg font-bold text-foreground">{t('app.title')}</h1>
<p className="text-sm text-muted-foreground">{t('app.subtitle')}</p>
</div>
</div>
)}
@@ -524,7 +654,7 @@ function Sidebar({
size="sm"
className="h-8 w-8 px-0 hover:bg-accent transition-colors duration-200"
onClick={onToggleSidebar}
title="Hide sidebar"
title={t('tooltips.hideSidebar')}
>
<svg
className="w-4 h-4"
@@ -544,18 +674,18 @@ function Sidebar({
style={isPWA && isMobile ? { paddingTop: '16px' } : {}}
>
<div className="flex items-center justify-between">
{import.meta.env.VITE_IS_PLATFORM === 'true' ? (
{IS_PLATFORM ? (
<a
href="https://cloudcli.ai/dashboard"
className="flex items-center gap-3 active:opacity-70 transition-opacity"
title="View Environments"
title={t('tooltips.viewEnvironments')}
>
<div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center">
<MessageSquare className="w-4 h-4 text-primary-foreground" />
</div>
<div>
<h1 className="text-lg font-semibold text-foreground">Claude Code UI</h1>
<p className="text-sm text-muted-foreground">Projects</p>
<h1 className="text-lg font-semibold text-foreground">{t('app.title')}</h1>
<p className="text-sm text-muted-foreground">{t('projects.title')}</p>
</div>
</a>
) : (
@@ -564,8 +694,8 @@ function Sidebar({
<MessageSquare className="w-4 h-4 text-primary-foreground" />
</div>
<div>
<h1 className="text-lg font-semibold text-foreground">Claude Code UI</h1>
<p className="text-sm text-muted-foreground">Projects</p>
<h1 className="text-lg font-semibold text-foreground">{t('app.title')}</h1>
<p className="text-sm text-muted-foreground">{t('projects.title')}</p>
</div>
</div>
)}
@@ -595,14 +725,49 @@ function Sidebar({
</div>
</div>
{/* Search Filter and Actions */}
{/* Action Buttons - Desktop only - Always show when not loading */}
{!isLoading && !isMobile && (
<div className="px-3 md:px-4 py-2 border-b border-border">
<div className="flex gap-2">
<Button
variant="default"
size="sm"
className="flex-1 h-8 text-xs bg-primary hover:bg-primary/90 transition-all duration-200"
onClick={() => setShowNewProject(true)}
title={t('tooltips.createProject')}
>
<FolderPlus className="w-3.5 h-3.5 mr-1.5" />
{t('projects.newProject')}
</Button>
<Button
variant="outline"
size="sm"
className="h-8 w-8 px-0 hover:bg-accent transition-colors duration-200 group"
onClick={async () => {
setIsRefreshing(true);
try {
await onRefresh();
} finally {
setIsRefreshing(false);
}
}}
disabled={isRefreshing}
title={t('tooltips.refresh')}
>
<RefreshCw className={`w-3.5 h-3.5 ${isRefreshing ? 'animate-spin' : ''} group-hover:rotate-180 transition-transform duration-300`} />
</Button>
</div>
</div>
)}
{/* Search Filter - Only show when there are projects */}
{projects.length > 0 && !isLoading && (
<div className="px-3 md:px-4 py-2 border-b border-border space-y-2">
<div className="px-3 md:px-4 py-2 border-b border-border">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
type="text"
placeholder="Search projects..."
placeholder={t('projects.searchPlaceholder')}
value={searchFilter}
onChange={(e) => setSearchFilter(e.target.value)}
className="pl-9 h-9 text-sm bg-muted/50 border-0 focus:bg-background focus:ring-1 focus:ring-primary/20"
@@ -616,39 +781,6 @@ function Sidebar({
</button>
)}
</div>
{/* Action Buttons - Desktop only */}
{!isMobile && (
<div className="flex gap-2">
<Button
variant="default"
size="sm"
className="flex-1 h-8 text-xs bg-primary hover:bg-primary/90 transition-all duration-200"
onClick={() => setShowNewProject(true)}
title="Create new project (Ctrl+N)"
>
<FolderPlus className="w-3.5 h-3.5 mr-1.5" />
New Project
</Button>
<Button
variant="outline"
size="sm"
className="h-8 w-8 px-0 hover:bg-accent transition-colors duration-200 group"
onClick={async () => {
setIsRefreshing(true);
try {
await onRefresh();
} finally {
setIsRefreshing(false);
}
}}
disabled={isRefreshing}
title="Refresh projects and sessions (Ctrl+R)"
>
<RefreshCw className={`w-3.5 h-3.5 ${isRefreshing ? 'animate-spin' : ''} group-hover:rotate-180 transition-transform duration-300`} />
</Button>
</div>
)}
</div>
)}
@@ -660,19 +792,42 @@ function Sidebar({
<div className="w-12 h-12 bg-muted rounded-lg flex items-center justify-center mx-auto mb-4 md:mb-3">
<div className="w-6 h-6 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
</div>
<h3 className="text-base font-medium text-foreground mb-2 md:mb-1">Loading projects...</h3>
<h3 className="text-base font-medium text-foreground mb-2 md:mb-1">{t('projects.loadingProjects')}</h3>
<p className="text-sm text-muted-foreground">
Fetching your Claude projects and sessions
{t('projects.fetchingProjects')}
</p>
<h3 className="text-base font-medium text-foreground mb-2 md:mb-1">{t('projects.loadingProjects')}</h3>
{loadingProgress && loadingProgress.total > 0 ? (
<div className="space-y-2">
<div className="w-full bg-muted rounded-full h-2 overflow-hidden">
<div
className="bg-primary h-full transition-all duration-300 ease-out"
style={{ width: `${(loadingProgress.current / loadingProgress.total) * 100}%` }}
/>
</div>
<p className="text-sm text-muted-foreground">
{loadingProgress.current}/{loadingProgress.total} {t('projects.projects')}
</p>
{loadingProgress.currentProject && (
<p className="text-xs text-muted-foreground/70 truncate max-w-[200px] mx-auto" title={loadingProgress.currentProject}>
{loadingProgress.currentProject.split('-').slice(-2).join('/')}
</p>
)}
</div>
) : (
<p className="text-sm text-muted-foreground">
{t('projects.fetchingProjects')}
</p>
)}
</div>
) : projects.length === 0 ? (
<div className="text-center py-12 md:py-8 px-4">
<div className="w-12 h-12 bg-muted rounded-lg flex items-center justify-center mx-auto mb-4 md:mb-3">
<Folder className="w-6 h-6 text-muted-foreground" />
</div>
<h3 className="text-base font-medium text-foreground mb-2 md:mb-1">No projects found</h3>
<h3 className="text-base font-medium text-foreground mb-2 md:mb-1">{t('projects.noProjects')}</h3>
<p className="text-sm text-muted-foreground">
Run Claude CLI in a project directory to get started
{t('projects.runClaudeCli')}
</p>
</div>
) : filteredProjects.length === 0 ? (
@@ -680,9 +835,9 @@ function Sidebar({
<div className="w-12 h-12 bg-muted rounded-lg flex items-center justify-center mx-auto mb-4 md:mb-3">
<Search className="w-6 h-6 text-muted-foreground" />
</div>
<h3 className="text-base font-medium text-foreground mb-2 md:mb-1">No matching projects</h3>
<h3 className="text-base font-medium text-foreground mb-2 md:mb-1">{t('projects.noMatchingProjects')}</h3>
<p className="text-sm text-muted-foreground">
Try adjusting your search term
{t('projects.tryDifferentSearch')}
</p>
</div>
) : (
@@ -690,9 +845,10 @@ function Sidebar({
const isExpanded = expandedProjects.has(project.name);
const isSelected = selectedProject?.name === project.name;
const isStarred = isProjectStarred(project.name);
const isDeleting = deletingProjects.has(project.name);
return (
<div key={project.name} className="md:space-y-1">
<div key={project.name} className={cn("md:space-y-1", isDeleting && "opacity-50 pointer-events-none")}>
{/* Project Header */}
<div className="group md:group">
{/* Mobile Project Item */}
@@ -728,7 +884,7 @@ function Sidebar({
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
className="w-full px-3 py-2 text-sm border-2 border-primary/40 focus:border-primary rounded-lg bg-background text-foreground shadow-sm focus:shadow-md transition-all duration-200 focus:outline-none"
placeholder="Project name"
placeholder={t('projects.projectNamePlaceholder')}
autoFocus
autoComplete="off"
onClick={(e) => e.stopPropagation()}
@@ -812,7 +968,7 @@ function Sidebar({
toggleStarProject(project.name);
}}
onTouchEnd={handleTouchClick(() => toggleStarProject(project.name))}
title={isStarred ? "Remove from favorites" : "Add to favorites"}
title={isStarred ? t('tooltips.removeFromFavorites') : t('tooltips.addToFavorites')}
>
<Star className={cn(
"w-4 h-4 transition-colors",
@@ -821,18 +977,16 @@ function Sidebar({
: "text-gray-600 dark:text-gray-400"
)} />
</button>
{getAllSessions(project).length === 0 && (
<button
<button
className="w-8 h-8 rounded-lg bg-red-500/10 dark:bg-red-900/30 flex items-center justify-center active:scale-90 border border-red-200 dark:border-red-800"
onClick={(e) => {
e.stopPropagation();
deleteProject(project.name);
deleteProject(project);
}}
onTouchEnd={handleTouchClick(() => deleteProject(project.name))}
onTouchEnd={handleTouchClick(() => deleteProject(project))}
>
<Trash2 className="w-4 h-4 text-red-600 dark:text-red-400" />
</button>
)}
<button
className="w-8 h-8 rounded-lg bg-primary/10 dark:bg-primary/20 flex items-center justify-center active:scale-90 border border-primary/20 dark:border-primary/30"
onClick={(e) => {
@@ -893,7 +1047,7 @@ function Sidebar({
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
className="w-full px-2 py-1 text-sm border border-border rounded bg-background text-foreground focus:ring-2 focus:ring-primary/20"
placeholder="Project name"
placeholder={t('projects.projectNamePlaceholder')}
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') saveProjectName(project.name);
@@ -962,7 +1116,7 @@ function Sidebar({
e.stopPropagation();
toggleStarProject(project.name);
}}
title={isStarred ? "Remove from favorites" : "Add to favorites"}
title={isStarred ? t('tooltips.removeFromFavorites') : t('tooltips.addToFavorites')}
>
<Star className={cn(
"w-3 h-3 transition-colors",
@@ -977,22 +1131,20 @@ function Sidebar({
e.stopPropagation();
startEditing(project);
}}
title="Rename project (F2)"
title={t('tooltips.renameProject')}
>
<Edit3 className="w-3 h-3" />
</div>
{getAllSessions(project).length === 0 && (
<div
<div
className="w-6 h-6 opacity-0 group-hover:opacity-100 transition-all duration-200 hover:bg-red-50 dark:hover:bg-red-900/20 flex items-center justify-center rounded cursor-pointer touch:opacity-100"
onClick={(e) => {
e.stopPropagation();
deleteProject(project.name);
deleteProject(project);
}}
title="Delete empty project (Delete)"
title={t('tooltips.deleteProject')}
>
<Trash2 className="w-3 h-3 text-red-600 dark:text-red-400" />
</div>
)}
{isExpanded ? (
<ChevronDown className="w-4 h-4 text-muted-foreground group-hover:text-foreground transition-colors" />
) : (
@@ -1022,7 +1174,7 @@ function Sidebar({
))
) : getAllSessions(project).length === 0 && !loadingSessions[project.name] ? (
<div className="py-2 px-3 text-left">
<p className="text-xs text-muted-foreground">No sessions yet</p>
<p className="text-xs text-muted-foreground">{t('sessions.noSessions')}</p>
</div>
) : (
getAllSessions(project).map((session) => {
@@ -1042,9 +1194,9 @@ function Sidebar({
// Get session display values
const getSessionName = () => {
if (isCursorSession) return session.name || 'Untitled Session';
if (isCodexSession) return session.summary || session.name || 'Codex Session';
return session.summary || 'New Session';
if (isCursorSession) return session.name || t('projects.untitledSession');
if (isCodexSession) return session.summary || session.name || t('projects.codexSession');
return session.summary || t('projects.newSession');
};
const sessionName = getSessionName();
const getSessionTime = () => {
@@ -1100,7 +1252,7 @@ function Sidebar({
<div className="flex items-center gap-1 mt-0.5">
<Clock className="w-2.5 h-2.5 text-muted-foreground" />
<span className="text-xs text-muted-foreground">
{formatTimeAgo(sessionTime, currentTime)}
{formatTimeAgo(sessionTime, currentTime, t)}
</span>
{messageCount > 0 && (
<Badge variant="secondary" className="text-xs px-1 py-0 ml-auto">
@@ -1124,9 +1276,9 @@ function Sidebar({
className="w-5 h-5 rounded-md bg-red-50 dark:bg-red-900/20 flex items-center justify-center active:scale-95 transition-transform opacity-70 ml-1"
onClick={(e) => {
e.stopPropagation();
deleteSession(project.name, session.id, session.__provider);
showDeleteSessionConfirmation(project.name, session.id, sessionName, session.__provider);
}}
onTouchEnd={handleTouchClick(() => deleteSession(project.name, session.id, session.__provider))}
onTouchEnd={handleTouchClick(() => showDeleteSessionConfirmation(project.name, session.id, sessionName, session.__provider))}
>
<Trash2 className="w-2.5 h-2.5 text-red-600 dark:text-red-400" />
</button>
@@ -1161,14 +1313,14 @@ function Sidebar({
<div className="flex items-center gap-1 mt-0.5">
<Clock className="w-2.5 h-2.5 text-muted-foreground" />
<span className="text-xs text-muted-foreground">
{formatTimeAgo(sessionTime, currentTime)}
{formatTimeAgo(sessionTime, currentTime, t)}
</span>
{messageCount > 0 && (
<Badge variant="secondary" className="text-xs px-1 py-0 ml-auto">
<Badge variant="secondary" className="text-xs px-1 py-0 ml-auto group-hover:opacity-0 transition-opacity">
{messageCount}
</Badge>
)}
<span className="ml-1 opacity-70">
<span className="ml-1 opacity-70 group-hover:opacity-0 transition-opacity">
{isCursorSession ? (
<CursorLogo className="w-3 h-3" />
) : isCodexSession ? (
@@ -1208,7 +1360,7 @@ function Sidebar({
e.stopPropagation();
updateSessionSummary(project.name, session.id, editingSessionName);
}}
title="Save"
title={t('tooltips.save')}
>
<Check className="w-3 h-3 text-green-600 dark:text-green-400" />
</button>
@@ -1219,7 +1371,7 @@ function Sidebar({
setEditingSession(null);
setEditingSessionName('');
}}
title="Cancel"
title={t('tooltips.cancel')}
>
<X className="w-3 h-3 text-gray-600 dark:text-gray-400" />
</button>
@@ -1232,9 +1384,9 @@ function Sidebar({
onClick={(e) => {
e.stopPropagation();
setEditingSession(session.id);
setEditingSessionName(session.summary || 'New Session');
setEditingSessionName(session.summary || t('projects.newSession'));
}}
title="Manually edit session name"
title={t('tooltips.editSessionName')}
>
<Edit2 className="w-3 h-3 text-gray-600 dark:text-gray-400" />
</button>
@@ -1243,9 +1395,9 @@ function Sidebar({
className="w-6 h-6 bg-red-50 hover:bg-red-100 dark:bg-red-900/20 dark:hover:bg-red-900/40 rounded flex items-center justify-center"
onClick={(e) => {
e.stopPropagation();
deleteSession(project.name, session.id, session.__provider);
showDeleteSessionConfirmation(project.name, session.id, sessionName, session.__provider);
}}
title="Delete this session permanently"
title={t('tooltips.deleteSession')}
>
<Trash2 className="w-3 h-3 text-red-600 dark:text-red-400" />
</button>
@@ -1271,18 +1423,18 @@ function Sidebar({
{loadingSessions[project.name] ? (
<>
<div className="w-3 h-3 animate-spin rounded-full border border-muted-foreground border-t-transparent" />
Loading...
{t('sessions.loading')}
</>
) : (
<>
<ChevronDown className="w-3 h-3" />
Show more sessions
{t('sessions.showMore')}
</>
)}
</Button>
)}
{/* New Session Button */}
{/* Sessions - New Session Button */}
<div className="md:hidden px-3 pb-2">
<button
className="w-full h-8 bg-primary hover:bg-primary/90 text-primary-foreground rounded-md flex items-center justify-center gap-2 font-medium text-xs active:scale-[0.98] transition-all duration-150"
@@ -1292,7 +1444,7 @@ function Sidebar({
}}
>
<Plus className="w-3 h-3" />
New Session
{t('sessions.newSession')}
</button>
</div>
@@ -1303,7 +1455,7 @@ function Sidebar({
onClick={() => onNewSession(project)}
>
<Plus className="w-3 h-3" />
New Session
{t('sessions.newSession')}
</Button>
</div>
)}
@@ -1334,7 +1486,7 @@ function Sidebar({
<div className="text-sm font-medium text-blue-700 dark:text-blue-300">
{releaseInfo?.title || `Version ${latestVersion}`}
</div>
<div className="text-xs text-blue-600 dark:text-blue-400">Update available</div>
<div className="text-xs text-blue-600 dark:text-blue-400">{t('version.updateAvailable')}</div>
</div>
</Button>
</div>
@@ -1355,7 +1507,7 @@ function Sidebar({
<div className="text-sm font-medium text-blue-700 dark:text-blue-300">
{releaseInfo?.title || `Version ${latestVersion}`}
</div>
<div className="text-xs text-blue-600 dark:text-blue-400">Update available</div>
<div className="text-xs text-blue-600 dark:text-blue-400">{t('version.updateAvailable')}</div>
</div>
</button>
</div>
@@ -1373,7 +1525,7 @@ function Sidebar({
<div className="w-10 h-10 rounded-2xl bg-background/80 flex items-center justify-center">
<Settings className="w-5 h-5 text-muted-foreground" />
</div>
<span className="text-lg font-medium text-foreground">Settings</span>
<span className="text-lg font-medium text-foreground">{t('actions.settings')}</span>
</button>
</div>
@@ -1384,7 +1536,7 @@ function Sidebar({
onClick={onShowSettings}
>
<Settings className="w-3 h-3" />
<span className="text-xs">Settings</span>
<span className="text-xs">{t('actions.settings')}</span>
</Button>
</div>
</div>
@@ -1392,4 +1544,4 @@ function Sidebar({
);
}
export default Sidebar;
export default Sidebar;

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ import { LogIn } from 'lucide-react';
import ClaudeLogo from '../ClaudeLogo';
import CursorLogo from '../CursorLogo';
import CodexLogo from '../CodexLogo';
import { useTranslation } from 'react-i18next';
const agentConfig = {
claude: {
@@ -39,6 +40,7 @@ const agentConfig = {
};
export default function AccountContent({ agent, authStatus, onLogin }) {
const { t } = useTranslation('settings');
const config = agentConfig[agent];
const { Logo } = config;
@@ -47,8 +49,8 @@ export default function AccountContent({ agent, authStatus, onLogin }) {
<div className="flex items-center gap-3 mb-4">
<Logo className="w-6 h-6" />
<div>
<h3 className="text-lg font-medium text-foreground">{config.name} Account</h3>
<p className="text-sm text-muted-foreground">{config.description}</p>
<h3 className="text-lg font-medium text-foreground">{config.name}</h3>
<p className="text-sm text-muted-foreground">{t(`agents.account.${agent}.description`)}</p>
</div>
</div>
@@ -58,30 +60,30 @@ export default function AccountContent({ agent, authStatus, onLogin }) {
<div className="flex items-center gap-3">
<div className="flex-1">
<div className={`font-medium ${config.textClass}`}>
Connection Status
{t('agents.connectionStatus')}
</div>
<div className={`text-sm ${config.subtextClass}`}>
{authStatus?.loading ? (
'Checking authentication status...'
t('agents.authStatus.checkingAuth')
) : authStatus?.authenticated ? (
`Logged in as ${authStatus.email || 'authenticated user'}`
t('agents.authStatus.loggedInAs', { email: authStatus.email || t('agents.authStatus.authenticatedUser') })
) : (
'Not connected'
t('agents.authStatus.notConnected')
)}
</div>
</div>
<div>
{authStatus?.loading ? (
<Badge variant="secondary" className="bg-gray-100 dark:bg-gray-800">
Checking...
{t('agents.authStatus.checking')}
</Badge>
) : authStatus?.authenticated ? (
<Badge variant="success" className="bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">
Connected
{t('agents.authStatus.connected')}
</Badge>
) : (
<Badge variant="secondary" className="bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300">
Disconnected
{t('agents.authStatus.disconnected')}
</Badge>
)}
</div>
@@ -91,12 +93,12 @@ export default function AccountContent({ agent, authStatus, onLogin }) {
<div className="flex items-center justify-between">
<div>
<div className={`font-medium ${config.textClass}`}>
{authStatus?.authenticated ? 'Re-authenticate' : 'Login'}
{authStatus?.authenticated ? t('agents.login.reAuthenticate') : t('agents.login.title')}
</div>
<div className={`text-sm ${config.subtextClass}`}>
{authStatus?.authenticated
? 'Sign in with a different account or refresh credentials'
: `Sign in to your ${config.name} account to enable AI features`}
? t('agents.login.reAuthDescription')
: t('agents.login.description', { agent: config.name })}
</div>
</div>
<Button
@@ -105,7 +107,7 @@ export default function AccountContent({ agent, authStatus, onLogin }) {
size="sm"
>
<LogIn className="w-4 h-4 mr-2" />
{authStatus?.authenticated ? 'Re-login' : 'Login'}
{authStatus?.authenticated ? t('agents.login.reLoginButton') : t('agents.login.button')}
</Button>
</div>
</div>
@@ -113,7 +115,7 @@ export default function AccountContent({ agent, authStatus, onLogin }) {
{authStatus?.error && (
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
<div className="text-sm text-red-600 dark:text-red-400">
Error: {authStatus.error}
{t('agents.error', { error: authStatus.error })}
</div>
</div>
)}

View File

@@ -1,6 +1,7 @@
import ClaudeLogo from '../ClaudeLogo';
import CursorLogo from '../CursorLogo';
import CodexLogo from '../CodexLogo';
import { useTranslation } from 'react-i18next';
const agentConfig = {
claude: {
@@ -42,6 +43,7 @@ const colorClasses = {
};
export default function AgentListItem({ agentId, authStatus, isSelected, onClick, isMobile = false }) {
const { t } = useTranslation('settings');
const config = agentConfig[agentId];
const colors = colorClasses[config.color];
const { Logo } = config;
@@ -84,18 +86,18 @@ export default function AgentListItem({ agentId, authStatus, isSelected, onClick
</div>
<div className="text-xs text-muted-foreground pl-6">
{authStatus?.loading ? (
<span className="text-gray-400">Checking...</span>
<span className="text-gray-400">{t('agents.authStatus.checking')}</span>
) : authStatus?.authenticated ? (
<div className="flex items-center gap-1">
<span className={`w-1.5 h-1.5 rounded-full ${colors.dot}`} />
<span className="truncate max-w-[120px]" title={authStatus.email}>
{authStatus.email || 'Connected'}
{authStatus.email || t('agents.authStatus.connected')}
</span>
</div>
) : (
<div className="flex items-center gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-gray-400" />
<span>Not connected</span>
<span>{t('agents.authStatus.notConnected')}</span>
</div>
)}
</div>

View File

@@ -3,6 +3,7 @@ import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Badge } from '../ui/badge';
import { Server, Plus, Edit3, Trash2, Terminal, Globe, Zap, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
const getTransportIcon = (type) => {
switch (type) {
@@ -25,16 +26,17 @@ function ClaudeMcpServers({
serverTools,
toolsLoading,
}) {
const { t } = useTranslation('settings');
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
<Server className="w-5 h-5 text-purple-500" />
<h3 className="text-lg font-medium text-foreground">
MCP Servers
{t('mcpServers.title')}
</h3>
</div>
<p className="text-sm text-muted-foreground">
Model Context Protocol servers provide additional tools and data sources to Claude
{t('mcpServers.description.claude')}
</p>
<div className="flex justify-between items-center">
@@ -44,7 +46,7 @@ function ClaudeMcpServers({
size="sm"
>
<Plus className="w-4 h-4 mr-2" />
Add MCP Server
{t('mcpServers.addButton')}
</Button>
</div>
@@ -60,19 +62,19 @@ function ClaudeMcpServers({
{server.type}
</Badge>
<Badge variant="outline" className="text-xs">
{server.scope === 'local' ? 'local' : server.scope === 'user' ? 'user' : server.scope}
{server.scope === 'local' ? t('mcpServers.scope.local') : server.scope === 'user' ? t('mcpServers.scope.user') : server.scope}
</Badge>
</div>
<div className="text-sm text-muted-foreground space-y-1">
{server.type === 'stdio' && server.config?.command && (
<div>Command: <code className="bg-gray-100 dark:bg-gray-800 px-1 rounded text-xs">{server.config.command}</code></div>
<div>{t('mcpServers.config.command')}: <code className="bg-gray-100 dark:bg-gray-800 px-1 rounded text-xs">{server.config.command}</code></div>
)}
{(server.type === 'sse' || server.type === 'http') && server.config?.url && (
<div>URL: <code className="bg-gray-100 dark:bg-gray-800 px-1 rounded text-xs">{server.config.url}</code></div>
<div>{t('mcpServers.config.url')}: <code className="bg-gray-100 dark:bg-gray-800 px-1 rounded text-xs">{server.config.url}</code></div>
)}
{server.config?.args && server.config.args.length > 0 && (
<div>Args: <code className="bg-gray-100 dark:bg-gray-800 px-1 rounded text-xs">{server.config.args.join(' ')}</code></div>
<div>{t('mcpServers.config.args')}: <code className="bg-gray-100 dark:bg-gray-800 px-1 rounded text-xs">{server.config.args.join(' ')}</code></div>
)}
</div>
@@ -90,13 +92,13 @@ function ClaudeMcpServers({
{/* Tools Discovery Results */}
{serverTools?.[server.id] && serverTools[server.id].tools?.length > 0 && (
<div className="mt-2 p-2 rounded text-xs bg-blue-50 dark:bg-blue-900/20 text-blue-800 dark:text-blue-200">
<div className="font-medium">Tools ({serverTools[server.id].tools.length}):</div>
<div className="font-medium">{t('mcpServers.tools.title')} {t('mcpServers.tools.count', { count: serverTools[server.id].tools.length })}</div>
<div className="flex flex-wrap gap-1 mt-1">
{serverTools[server.id].tools.slice(0, 5).map((tool, i) => (
<code key={i} className="bg-blue-100 dark:bg-blue-800 px-1 rounded">{tool.name}</code>
))}
{serverTools[server.id].tools.length > 5 && (
<span className="text-xs opacity-75">+{serverTools[server.id].tools.length - 5} more</span>
<span className="text-xs opacity-75">{t('mcpServers.tools.more', { count: serverTools[server.id].tools.length - 5 })}</span>
)}
</div>
</div>
@@ -109,7 +111,7 @@ function ClaudeMcpServers({
variant="ghost"
size="sm"
className="text-gray-600 hover:text-gray-700"
title="Edit server"
title={t('mcpServers.actions.edit')}
>
<Edit3 className="w-4 h-4" />
</Button>
@@ -118,7 +120,7 @@ function ClaudeMcpServers({
variant="ghost"
size="sm"
className="text-red-600 hover:text-red-700"
title="Delete server"
title={t('mcpServers.actions.delete')}
>
<Trash2 className="w-4 h-4" />
</Button>
@@ -128,7 +130,7 @@ function ClaudeMcpServers({
))}
{servers.length === 0 && (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
No MCP servers configured
{t('mcpServers.empty')}
</div>
)}
</div>
@@ -138,16 +140,17 @@ function ClaudeMcpServers({
// Cursor MCP Servers
function CursorMcpServers({ servers, onAdd, onEdit, onDelete }) {
const { t } = useTranslation('settings');
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
<Server className="w-5 h-5 text-purple-500" />
<h3 className="text-lg font-medium text-foreground">
MCP Servers
{t('mcpServers.title')}
</h3>
</div>
<p className="text-sm text-muted-foreground">
Model Context Protocol servers provide additional tools and data sources to Cursor
{t('mcpServers.description.cursor')}
</p>
<div className="flex justify-between items-center">
@@ -157,7 +160,7 @@ function CursorMcpServers({ servers, onAdd, onEdit, onDelete }) {
size="sm"
>
<Plus className="w-4 h-4 mr-2" />
Add MCP Server
{t('mcpServers.addButton')}
</Button>
</div>
@@ -173,7 +176,7 @@ function CursorMcpServers({ servers, onAdd, onEdit, onDelete }) {
</div>
<div className="text-sm text-muted-foreground">
{server.config?.command && (
<div>Command: <code className="bg-gray-100 dark:bg-gray-800 px-1 rounded text-xs">{server.config.command}</code></div>
<div>{t('mcpServers.config.command')}: <code className="bg-gray-100 dark:bg-gray-800 px-1 rounded text-xs">{server.config.command}</code></div>
)}
</div>
</div>
@@ -183,6 +186,7 @@ function CursorMcpServers({ servers, onAdd, onEdit, onDelete }) {
variant="ghost"
size="sm"
className="text-gray-600 hover:text-gray-700"
title={t('mcpServers.actions.edit')}
>
<Edit3 className="w-4 h-4" />
</Button>
@@ -191,6 +195,7 @@ function CursorMcpServers({ servers, onAdd, onEdit, onDelete }) {
variant="ghost"
size="sm"
className="text-red-600 hover:text-red-700"
title={t('mcpServers.actions.delete')}
>
<Trash2 className="w-4 h-4" />
</Button>
@@ -200,7 +205,7 @@ function CursorMcpServers({ servers, onAdd, onEdit, onDelete }) {
))}
{servers.length === 0 && (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
No MCP servers configured
{t('mcpServers.empty')}
</div>
)}
</div>
@@ -210,16 +215,17 @@ function CursorMcpServers({ servers, onAdd, onEdit, onDelete }) {
// Codex MCP Servers
function CodexMcpServers({ servers, onAdd, onEdit, onDelete }) {
const { t } = useTranslation('settings');
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
<Server className="w-5 h-5 text-gray-700 dark:text-gray-300" />
<h3 className="text-lg font-medium text-foreground">
MCP Servers
{t('mcpServers.title')}
</h3>
</div>
<p className="text-sm text-muted-foreground">
Model Context Protocol servers provide additional tools and data sources to Codex
{t('mcpServers.description.codex')}
</p>
<div className="flex justify-between items-center">
@@ -229,7 +235,7 @@ function CodexMcpServers({ servers, onAdd, onEdit, onDelete }) {
size="sm"
>
<Plus className="w-4 h-4 mr-2" />
Add MCP Server
{t('mcpServers.addButton')}
</Button>
</div>
@@ -246,13 +252,13 @@ function CodexMcpServers({ servers, onAdd, onEdit, onDelete }) {
<div className="text-sm text-muted-foreground space-y-1">
{server.config?.command && (
<div>Command: <code className="bg-gray-100 dark:bg-gray-800 px-1 rounded text-xs">{server.config.command}</code></div>
<div>{t('mcpServers.config.command')}: <code className="bg-gray-100 dark:bg-gray-800 px-1 rounded text-xs">{server.config.command}</code></div>
)}
{server.config?.args && server.config.args.length > 0 && (
<div>Args: <code className="bg-gray-100 dark:bg-gray-800 px-1 rounded text-xs">{server.config.args.join(' ')}</code></div>
<div>{t('mcpServers.config.args')}: <code className="bg-gray-100 dark:bg-gray-800 px-1 rounded text-xs">{server.config.args.join(' ')}</code></div>
)}
{server.config?.env && Object.keys(server.config.env).length > 0 && (
<div>Environment: <code className="bg-gray-100 dark:bg-gray-800 px-1 rounded text-xs">{Object.entries(server.config.env).map(([k, v]) => `${k}=${v}`).join(', ')}</code></div>
<div>{t('mcpServers.config.environment')}: <code className="bg-gray-100 dark:bg-gray-800 px-1 rounded text-xs">{Object.entries(server.config.env).map(([k, v]) => `${k}=${v}`).join(', ')}</code></div>
)}
</div>
</div>
@@ -263,7 +269,7 @@ function CodexMcpServers({ servers, onAdd, onEdit, onDelete }) {
variant="ghost"
size="sm"
className="text-gray-600 hover:text-gray-700"
title="Edit server"
title={t('mcpServers.actions.edit')}
>
<Edit3 className="w-4 h-4" />
</Button>
@@ -272,7 +278,7 @@ function CodexMcpServers({ servers, onAdd, onEdit, onDelete }) {
variant="ghost"
size="sm"
className="text-red-600 hover:text-red-700"
title="Delete server"
title={t('mcpServers.actions.delete')}
>
<Trash2 className="w-4 h-4" />
</Button>
@@ -282,17 +288,16 @@ function CodexMcpServers({ servers, onAdd, onEdit, onDelete }) {
))}
{servers.length === 0 && (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
No MCP servers configured
{t('mcpServers.empty')}
</div>
)}
</div>
{/* Help Section */}
<div className="bg-gray-100 dark:bg-gray-800/50 border border-gray-300 dark:border-gray-600 rounded-lg p-4">
<h4 className="font-medium text-gray-900 dark:text-gray-100 mb-2">About Codex MCP</h4>
<h4 className="font-medium text-gray-900 dark:text-gray-100 mb-2">{t('mcpServers.help.title')}</h4>
<p className="text-sm text-gray-700 dark:text-gray-300">
Codex supports stdio-based MCP servers. You can add servers that extend Codex's capabilities
with additional tools and resources.
{t('mcpServers.help.description')}
</p>
</div>
</div>

View File

@@ -1,6 +1,7 @@
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Shield, AlertTriangle, Plus, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
// Common tool patterns for Claude
const commonClaudeTools = [
@@ -49,6 +50,7 @@ function ClaudePermissions({
newDisallowedTool,
setNewDisallowedTool,
}) {
const { t } = useTranslation('settings');
const addAllowedTool = (tool) => {
if (tool && !allowedTools.includes(tool)) {
setAllowedTools([...allowedTools, tool]);
@@ -78,7 +80,7 @@ function ClaudePermissions({
<div className="flex items-center gap-3">
<AlertTriangle className="w-5 h-5 text-orange-500" />
<h3 className="text-lg font-medium text-foreground">
Permission Settings
{t('permissions.title')}
</h3>
</div>
<div className="bg-orange-50 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800 rounded-lg p-4">
@@ -91,10 +93,10 @@ function ClaudePermissions({
/>
<div>
<div className="font-medium text-orange-900 dark:text-orange-100">
Skip permission prompts (use with caution)
{t('permissions.skipPermissions.label')}
</div>
<div className="text-sm text-orange-700 dark:text-orange-300">
Equivalent to --dangerously-skip-permissions flag
{t('permissions.skipPermissions.claudeDescription')}
</div>
</div>
</label>
@@ -106,18 +108,18 @@ function ClaudePermissions({
<div className="flex items-center gap-3">
<Shield className="w-5 h-5 text-green-500" />
<h3 className="text-lg font-medium text-foreground">
Allowed Tools
{t('permissions.allowedTools.title')}
</h3>
</div>
<p className="text-sm text-muted-foreground">
Tools that are automatically allowed without prompting for permission
{t('permissions.allowedTools.description')}
</p>
<div className="flex flex-col sm:flex-row gap-2">
<Input
value={newAllowedTool}
onChange={(e) => setNewAllowedTool(e.target.value)}
placeholder='e.g., "Bash(git log:*)" or "Write"'
placeholder={t('permissions.allowedTools.placeholder')}
onKeyPress={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
@@ -133,14 +135,14 @@ function ClaudePermissions({
className="h-10 px-4"
>
<Plus className="w-4 h-4 mr-2 sm:mr-0" />
<span className="sm:hidden">Add</span>
<span className="sm:hidden">{t('permissions.actions.add')}</span>
</Button>
</div>
{/* Quick add buttons */}
<div className="space-y-2">
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
Quick add common tools:
{t('permissions.allowedTools.quickAdd')}
</p>
<div className="flex flex-wrap gap-2">
{commonClaudeTools.map(tool => (
@@ -176,7 +178,7 @@ function ClaudePermissions({
))}
{allowedTools.length === 0 && (
<div className="text-center py-6 text-gray-500 dark:text-gray-400">
No allowed tools configured
{t('permissions.allowedTools.empty')}
</div>
)}
</div>
@@ -187,18 +189,18 @@ function ClaudePermissions({
<div className="flex items-center gap-3">
<AlertTriangle className="w-5 h-5 text-red-500" />
<h3 className="text-lg font-medium text-foreground">
Blocked Tools
{t('permissions.blockedTools.title')}
</h3>
</div>
<p className="text-sm text-muted-foreground">
Tools that are automatically blocked without prompting for permission
{t('permissions.blockedTools.description')}
</p>
<div className="flex flex-col sm:flex-row gap-2">
<Input
value={newDisallowedTool}
onChange={(e) => setNewDisallowedTool(e.target.value)}
placeholder='e.g., "Bash(rm:*)"'
placeholder={t('permissions.blockedTools.placeholder')}
onKeyPress={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
@@ -214,7 +216,7 @@ function ClaudePermissions({
className="h-10 px-4"
>
<Plus className="w-4 h-4 mr-2 sm:mr-0" />
<span className="sm:hidden">Add</span>
<span className="sm:hidden">{t('permissions.actions.add')}</span>
</Button>
</div>
@@ -236,7 +238,7 @@ function ClaudePermissions({
))}
{disallowedTools.length === 0 && (
<div className="text-center py-6 text-gray-500 dark:text-gray-400">
No blocked tools configured
{t('permissions.blockedTools.empty')}
</div>
)}
</div>
@@ -245,13 +247,13 @@ function ClaudePermissions({
{/* Help Section */}
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<h4 className="font-medium text-blue-900 dark:text-blue-100 mb-2">
Tool Pattern Examples:
{t('permissions.toolExamples.title')}
</h4>
<ul className="text-sm text-blue-800 dark:text-blue-200 space-y-1">
<li><code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">"Bash(git log:*)"</code> - Allow all git log commands</li>
<li><code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">"Bash(git diff:*)"</code> - Allow all git diff commands</li>
<li><code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">"Write"</code> - Allow all Write tool usage</li>
<li><code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">"Bash(rm:*)"</code> - Block all rm commands (dangerous)</li>
<li><code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">"Bash(git log:*)"</code> {t('permissions.toolExamples.bashGitLog')}</li>
<li><code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">"Bash(git diff:*)"</code> {t('permissions.toolExamples.bashGitDiff')}</li>
<li><code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">"Write"</code> {t('permissions.toolExamples.write')}</li>
<li><code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">"Bash(rm:*)"</code> {t('permissions.toolExamples.bashRm')}</li>
</ul>
</div>
</div>
@@ -271,6 +273,7 @@ function CursorPermissions({
newDisallowedCommand,
setNewDisallowedCommand,
}) {
const { t } = useTranslation('settings');
const addAllowedCommand = (cmd) => {
if (cmd && !allowedCommands.includes(cmd)) {
setAllowedCommands([...allowedCommands, cmd]);
@@ -300,7 +303,7 @@ function CursorPermissions({
<div className="flex items-center gap-3">
<AlertTriangle className="w-5 h-5 text-orange-500" />
<h3 className="text-lg font-medium text-foreground">
Permission Settings
{t('permissions.title')}
</h3>
</div>
<div className="bg-orange-50 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800 rounded-lg p-4">
@@ -313,10 +316,10 @@ function CursorPermissions({
/>
<div>
<div className="font-medium text-orange-900 dark:text-orange-100">
Skip permission prompts (use with caution)
{t('permissions.skipPermissions.label')}
</div>
<div className="text-sm text-orange-700 dark:text-orange-300">
Equivalent to -f flag in Cursor CLI
{t('permissions.skipPermissions.cursorDescription')}
</div>
</div>
</label>
@@ -328,18 +331,18 @@ function CursorPermissions({
<div className="flex items-center gap-3">
<Shield className="w-5 h-5 text-green-500" />
<h3 className="text-lg font-medium text-foreground">
Allowed Shell Commands
{t('permissions.allowedCommands.title')}
</h3>
</div>
<p className="text-sm text-muted-foreground">
Shell commands that are automatically allowed without prompting
{t('permissions.allowedCommands.description')}
</p>
<div className="flex flex-col sm:flex-row gap-2">
<Input
value={newAllowedCommand}
onChange={(e) => setNewAllowedCommand(e.target.value)}
placeholder='e.g., "Shell(ls)" or "Shell(git status)"'
placeholder={t('permissions.allowedCommands.placeholder')}
onKeyPress={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
@@ -355,14 +358,14 @@ function CursorPermissions({
className="h-10 px-4"
>
<Plus className="w-4 h-4 mr-2 sm:mr-0" />
<span className="sm:hidden">Add</span>
<span className="sm:hidden">{t('permissions.actions.add')}</span>
</Button>
</div>
{/* Quick add buttons */}
<div className="space-y-2">
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
Quick add common commands:
{t('permissions.allowedCommands.quickAdd')}
</p>
<div className="flex flex-wrap gap-2">
{commonCursorCommands.map(cmd => (
@@ -398,7 +401,7 @@ function CursorPermissions({
))}
{allowedCommands.length === 0 && (
<div className="text-center py-6 text-gray-500 dark:text-gray-400">
No allowed commands configured
{t('permissions.allowedCommands.empty')}
</div>
)}
</div>
@@ -409,18 +412,18 @@ function CursorPermissions({
<div className="flex items-center gap-3">
<AlertTriangle className="w-5 h-5 text-red-500" />
<h3 className="text-lg font-medium text-foreground">
Blocked Shell Commands
{t('permissions.blockedCommands.title')}
</h3>
</div>
<p className="text-sm text-muted-foreground">
Shell commands that are automatically blocked
{t('permissions.blockedCommands.description')}
</p>
<div className="flex flex-col sm:flex-row gap-2">
<Input
value={newDisallowedCommand}
onChange={(e) => setNewDisallowedCommand(e.target.value)}
placeholder='e.g., "Shell(rm -rf)" or "Shell(sudo)"'
placeholder={t('permissions.blockedCommands.placeholder')}
onKeyPress={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
@@ -436,7 +439,7 @@ function CursorPermissions({
className="h-10 px-4"
>
<Plus className="w-4 h-4 mr-2 sm:mr-0" />
<span className="sm:hidden">Add</span>
<span className="sm:hidden">{t('permissions.actions.add')}</span>
</Button>
</div>
@@ -458,7 +461,7 @@ function CursorPermissions({
))}
{disallowedCommands.length === 0 && (
<div className="text-center py-6 text-gray-500 dark:text-gray-400">
No blocked commands configured
{t('permissions.blockedCommands.empty')}
</div>
)}
</div>
@@ -467,13 +470,13 @@ function CursorPermissions({
{/* Help Section */}
<div className="bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-800 rounded-lg p-4">
<h4 className="font-medium text-purple-900 dark:text-purple-100 mb-2">
Shell Command Examples:
{t('permissions.shellExamples.title')}
</h4>
<ul className="text-sm text-purple-800 dark:text-purple-200 space-y-1">
<li><code className="bg-purple-100 dark:bg-purple-800 px-1 rounded">"Shell(ls)"</code> - Allow ls command</li>
<li><code className="bg-purple-100 dark:bg-purple-800 px-1 rounded">"Shell(git status)"</code> - Allow git status</li>
<li><code className="bg-purple-100 dark:bg-purple-800 px-1 rounded">"Shell(npm install)"</code> - Allow npm install</li>
<li><code className="bg-purple-100 dark:bg-purple-800 px-1 rounded">"Shell(rm -rf)"</code> - Block recursive delete</li>
<li><code className="bg-purple-100 dark:bg-purple-800 px-1 rounded">"Shell(ls)"</code> {t('permissions.shellExamples.ls')}</li>
<li><code className="bg-purple-100 dark:bg-purple-800 px-1 rounded">"Shell(git status)"</code> {t('permissions.shellExamples.gitStatus')}</li>
<li><code className="bg-purple-100 dark:bg-purple-800 px-1 rounded">"Shell(npm install)"</code> {t('permissions.shellExamples.npmInstall')}</li>
<li><code className="bg-purple-100 dark:bg-purple-800 px-1 rounded">"Shell(rm -rf)"</code> {t('permissions.shellExamples.rmRf')}</li>
</ul>
</div>
</div>
@@ -482,17 +485,18 @@ function CursorPermissions({
// Codex Permissions
function CodexPermissions({ permissionMode, setPermissionMode }) {
const { t } = useTranslation('settings');
return (
<div className="space-y-6">
<div className="space-y-4">
<div className="flex items-center gap-3">
<Shield className="w-5 h-5 text-green-500" />
<h3 className="text-lg font-medium text-foreground">
Permission Mode
{t('permissions.codex.permissionMode')}
</h3>
</div>
<p className="text-sm text-muted-foreground">
Controls how Codex handles file modifications and command execution
{t('permissions.codex.description')}
</p>
{/* Default Mode */}
@@ -513,10 +517,9 @@ function CodexPermissions({ permissionMode, setPermissionMode }) {
className="mt-1 w-4 h-4 text-green-600"
/>
<div>
<div className="font-medium text-foreground">Default</div>
<div className="font-medium text-foreground">{t('permissions.codex.modes.default.title')}</div>
<div className="text-sm text-muted-foreground">
Only trusted commands (ls, cat, grep, git status, etc.) run automatically.
Other commands are skipped. Can write to workspace.
{t('permissions.codex.modes.default.description')}
</div>
</div>
</label>
@@ -540,10 +543,9 @@ function CodexPermissions({ permissionMode, setPermissionMode }) {
className="mt-1 w-4 h-4 text-green-600"
/>
<div>
<div className="font-medium text-green-900 dark:text-green-100">Accept Edits</div>
<div className="font-medium text-green-900 dark:text-green-100">{t('permissions.codex.modes.acceptEdits.title')}</div>
<div className="text-sm text-green-700 dark:text-green-300">
All commands run automatically within the workspace.
Full auto mode with sandboxed execution.
{t('permissions.codex.modes.acceptEdits.description')}
</div>
</div>
</label>
@@ -568,12 +570,11 @@ function CodexPermissions({ permissionMode, setPermissionMode }) {
/>
<div>
<div className="font-medium text-orange-900 dark:text-orange-100 flex items-center gap-2">
Bypass Permissions
{t('permissions.codex.modes.bypassPermissions.title')}
<AlertTriangle className="w-4 h-4" />
</div>
<div className="text-sm text-orange-700 dark:text-orange-300">
Full system access with no restrictions. All commands run automatically
with full disk and network access. Use with caution.
{t('permissions.codex.modes.bypassPermissions.description')}
</div>
</div>
</label>
@@ -582,13 +583,13 @@ function CodexPermissions({ permissionMode, setPermissionMode }) {
{/* Technical Details */}
<details className="text-sm">
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">
Technical details
{t('permissions.codex.technicalDetails')}
</summary>
<div className="mt-2 p-3 bg-gray-50 dark:bg-gray-900/50 rounded-lg text-xs text-muted-foreground space-y-2">
<p><strong>Default:</strong> sandboxMode=workspace-write, approvalPolicy=untrusted. Trusted commands: cat, cd, grep, head, ls, pwd, tail, git status/log/diff/show, find (without -exec), etc.</p>
<p><strong>Accept Edits:</strong> sandboxMode=workspace-write, approvalPolicy=never. All commands auto-execute within project directory.</p>
<p><strong>Bypass Permissions:</strong> sandboxMode=danger-full-access, approvalPolicy=never. Full system access, use only in trusted environments.</p>
<p className="text-xs opacity-75">You can override this per-session using the mode button in the chat interface.</p>
<p><strong>{t('permissions.codex.modes.default.title')}:</strong> {t('permissions.codex.technicalInfo.default')}</p>
<p><strong>{t('permissions.codex.modes.acceptEdits.title')}:</strong> {t('permissions.codex.technicalInfo.acceptEdits')}</p>
<p><strong>{t('permissions.codex.modes.bypassPermissions.title')}:</strong> {t('permissions.codex.technicalInfo.bypassPermissions')}</p>
<p className="text-xs opacity-75">{t('permissions.codex.technicalInfo.overrideNote')}</p>
</div>
</details>
</div>

5
src/constants/config.ts Normal file
View File

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

View File

@@ -1,5 +1,6 @@
import React, { createContext, useContext, useEffect, useState } from 'react';
import { api } from '../utils/api';
import { IS_PLATFORM } from '../constants/config';
const AuthContext = createContext({
user: null,
@@ -31,7 +32,7 @@ export const AuthProvider = ({ children }) => {
const [error, setError] = useState(null);
useEffect(() => {
if (import.meta.env.VITE_IS_PLATFORM === 'true') {
if (IS_PLATFORM) {
setUser({ username: 'platform-user' });
setNeedsSetup(false);
checkOnboardingStatus();

View File

@@ -1,7 +1,7 @@
import React, { createContext, useContext, useEffect, useState, useCallback } from 'react';
import { api } from '../utils/api';
import { useAuth } from './AuthContext';
import { useWebSocketContext } from './WebSocketContext';
import { useWebSocket } from './WebSocketContext';
const TaskMasterContext = createContext({
// TaskMaster project state
@@ -42,7 +42,7 @@ export const useTaskMaster = () => {
export const TaskMasterProvider = ({ children }) => {
// Get WebSocket messages from shared context to avoid duplicate connections
const { messages } = useWebSocketContext();
const { latestMessage } = useWebSocket();
// Authentication context
const { user, token, isLoading: authLoading } = useAuth();
@@ -238,9 +238,8 @@ export const TaskMasterProvider = ({ children }) => {
}
}, [currentProject?.name, user, token, refreshTasks]);
// Handle WebSocket messages for TaskMaster updates
// Handle WebSocket latestMessage for TaskMaster updates
useEffect(() => {
const latestMessage = messages[messages.length - 1];
if (!latestMessage) return;
@@ -268,7 +267,7 @@ export const TaskMasterProvider = ({ children }) => {
// Ignore non-TaskMaster messages
break;
}
}, [messages, refreshProjects, refreshTasks, refreshMCPStatus, currentProject]);
}, [latestMessage, refreshProjects, refreshTasks, refreshMCPStatus, currentProject]);
// Context value
const contextValue = {

View File

@@ -1,29 +0,0 @@
import React, { createContext, useContext } from 'react';
import { useWebSocket } from '../utils/websocket';
const WebSocketContext = createContext({
ws: null,
sendMessage: () => {},
messages: [],
isConnected: false
});
export const useWebSocketContext = () => {
const context = useContext(WebSocketContext);
if (!context) {
throw new Error('useWebSocketContext must be used within a WebSocketProvider');
}
return context;
};
export const WebSocketProvider = ({ children }) => {
const webSocketData = useWebSocket();
return (
<WebSocketContext.Provider value={webSocketData}>
{children}
</WebSocketContext.Provider>
);
};
export default WebSocketContext;

View File

@@ -0,0 +1,125 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { useAuth } from './AuthContext';
import { IS_PLATFORM } from '../constants/config';
type WebSocketContextType = {
ws: WebSocket | null;
sendMessage: (message: any) => void;
latestMessage: any | null;
isConnected: boolean;
};
const WebSocketContext = createContext<WebSocketContextType | null>(null);
export const useWebSocket = () => {
const context = useContext(WebSocketContext);
if (!context) {
throw new Error('useWebSocket must be used within a WebSocketProvider');
}
return context;
};
const buildWebSocketUrl = (token: string | null) => {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
if (IS_PLATFORM) return `${protocol}//${window.location.host}/ws`; // Platform mode: Use same domain as the page (goes through proxy)
if (!token) return null;
return `${protocol}//${window.location.host}/ws?token=${encodeURIComponent(token)}`; // OSS mode: Use same host:port that served the page
};
const useWebSocketProviderState = (): WebSocketContextType => {
const wsRef = useRef<WebSocket | null>(null);
const unmountedRef = useRef(false); // Track if component is unmounted
const [latestMessage, setLatestMessage] = useState<any>(null);
const [isConnected, setIsConnected] = useState(false);
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const { token } = useAuth();
useEffect(() => {
connect();
return () => {
unmountedRef.current = true;
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
if (wsRef.current) {
wsRef.current.close();
}
};
}, [token]); // everytime token changes, we reconnect
const connect = useCallback(() => {
if (unmountedRef.current) return; // Prevent connection if unmounted
try {
// Construct WebSocket URL
const wsUrl = buildWebSocketUrl(token);
if (!wsUrl) return console.warn('No authentication token found for WebSocket connection');
const websocket = new WebSocket(wsUrl);
websocket.onopen = () => {
setIsConnected(true);
wsRef.current = websocket;
};
websocket.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
setLatestMessage(data);
} catch (error) {
console.error('Error parsing WebSocket message:', error);
}
};
websocket.onclose = () => {
setIsConnected(false);
wsRef.current = null;
// Attempt to reconnect after 3 seconds
reconnectTimeoutRef.current = setTimeout(() => {
if (unmountedRef.current) return; // Prevent reconnection if unmounted
connect();
}, 3000);
};
websocket.onerror = (error) => {
console.error('WebSocket error:', error);
};
} catch (error) {
console.error('Error creating WebSocket connection:', error);
}
}, [token]); // everytime token changes, we reconnect
const sendMessage = useCallback((message: any) => {
const socket = wsRef.current;
if (socket && isConnected) {
socket.send(JSON.stringify(message));
} else {
console.warn('WebSocket not connected');
}
}, [isConnected]);
const value: WebSocketContextType = useMemo(() =>
({
ws: wsRef.current,
sendMessage,
latestMessage,
isConnected
}), [sendMessage, latestMessage, isConnected]);
return value;
};
export const WebSocketProvider = ({ children }: { children: React.ReactNode }) => {
const webSocketData = useWebSocketProviderState();
return (
<WebSocketContext.Provider value={webSocketData}>
{children}
</WebSocketContext.Provider>
);
};
export default WebSocketContext;

129
src/i18n/config.js Normal file
View File

@@ -0,0 +1,129 @@
/**
* i18n Configuration
*
* Configures i18next for internationalization support.
* Features:
* - Lazy-loading of translation namespaces
* - Language detection from localStorage
* - Fallback to English for missing translations
* - Development mode warnings for missing keys
*/
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
// Import translation resources
import enCommon from './locales/en/common.json';
import enSettings from './locales/en/settings.json';
import enAuth from './locales/en/auth.json';
import enSidebar from './locales/en/sidebar.json';
import enChat from './locales/en/chat.json';
import enCodeEditor from './locales/en/codeEditor.json';
import zhCommon from './locales/zh-CN/common.json';
import zhSettings from './locales/zh-CN/settings.json';
import zhAuth from './locales/zh-CN/auth.json';
import zhSidebar from './locales/zh-CN/sidebar.json';
import zhChat from './locales/zh-CN/chat.json';
import zhCodeEditor from './locales/zh-CN/codeEditor.json';
// Import supported languages configuration
import { languages } from './languages.js';
// Get saved language preference from localStorage
const getSavedLanguage = () => {
try {
const saved = localStorage.getItem('userLanguage');
// Validate that the saved language is supported
if (saved && languages.some(lang => lang.value === saved)) {
return saved;
}
return 'en';
} catch {
return 'en';
}
};
// Initialize i18next
i18n
.use(LanguageDetector) // Detect user language
.use(initReactI18next) // Pass i18n instance to react-i18next
.init({
// Resources containing all translations
resources: {
en: {
common: enCommon,
settings: enSettings,
auth: enAuth,
sidebar: enSidebar,
chat: enChat,
codeEditor: enCodeEditor,
},
'zh-CN': {
common: zhCommon,
settings: zhSettings,
auth: zhAuth,
sidebar: zhSidebar,
chat: zhChat,
codeEditor: zhCodeEditor,
},
},
// Default language
lng: getSavedLanguage(),
// Fallback language when a translation is missing
fallbackLng: 'en',
// Enable debug mode in development (logs missing keys to console)
debug: import.meta.env.DEV,
// Namespaces - load only what's needed
ns: ['common', 'settings', 'auth', 'sidebar', 'chat', 'codeEditor'],
defaultNS: 'common',
// Key separator for nested keys (default: '.')
keySeparator: '.',
// Namespace separator (default: ':')
nsSeparator: ':',
// Save missing translations (disabled - requires manual review)
saveMissing: false,
// Interpolation settings
interpolation: {
escapeValue: false, // React already escapes values
},
// React-specific settings
react: {
useSuspense: true, // Use Suspense for lazy-loading
bindI18n: 'languageChanged', // Re-render on language change
bindI18nStore: false, // Don't re-render on resource changes
},
// Detection options
detection: {
// Order of language detection (local storage first)
order: ['localStorage'],
// Keys to look for in localStorage
lookupLocalStorage: 'userLanguage',
// Cache user language
caches: ['localStorage'],
},
});
// Save language preference when it changes
i18n.on('languageChanged', (lng) => {
try {
localStorage.setItem('userLanguage', lng);
} catch (error) {
console.error('Failed to save language preference:', error);
}
});
export default i18n;

48
src/i18n/languages.js Normal file
View File

@@ -0,0 +1,48 @@
/**
* Supported Languages Configuration
*
* This file contains the list of supported languages for the application.
* Each language includes:
* - value: Language code (e.g., 'en', 'zh-CN')
* - label: Display name in English
* - nativeName: Native language name for display
*/
export const languages = [
{
value: 'en',
label: 'English',
nativeName: 'English',
},
{
value: 'zh-CN',
label: 'Simplified Chinese',
nativeName: '简体中文',
},
];
/**
* Get language object by value
* @param {string} value - Language code
* @returns {Object|undefined} Language object or undefined if not found
*/
export const getLanguage = (value) => {
return languages.find(lang => lang.value === value);
};
/**
* Get all language values
* @returns {string[]} Array of language codes
*/
export const getLanguageValues = () => {
return languages.map(lang => lang.value);
};
/**
* Check if a language is supported
* @param {string} value - Language code to check
* @returns {boolean} True if language is supported
*/
export const isLanguageSupported = (value) => {
return languages.some(lang => lang.value === value);
};

View File

@@ -0,0 +1,37 @@
{
"login": {
"title": "Welcome Back",
"description": "Sign in to your Claude Code UI account",
"username": "Username",
"password": "Password",
"submit": "Sign In",
"loading": "Signing in...",
"errors": {
"invalidCredentials": "Invalid username or password",
"requiredFields": "Please fill in all fields",
"networkError": "Network error. Please try again."
},
"placeholders": {
"username": "Enter your username",
"password": "Enter your password"
}
},
"register": {
"title": "Create Account",
"username": "Username",
"password": "Password",
"confirmPassword": "Confirm Password",
"submit": "Create Account",
"loading": "Creating account...",
"errors": {
"passwordMismatch": "Passwords do not match",
"usernameTaken": "Username is already taken",
"weakPassword": "Password is too weak"
}
},
"logout": {
"title": "Sign Out",
"confirm": "Are you sure you want to sign out?",
"button": "Sign Out"
}
}

View File

@@ -0,0 +1,205 @@
{
"codeBlock": {
"copy": "Copy",
"copied": "Copied",
"copyCode": "Copy code"
},
"messageTypes": {
"user": "U",
"error": "Error",
"tool": "Tool",
"claude": "Claude",
"cursor": "Cursor",
"codex": "Codex"
},
"tools": {
"settings": "Tool Settings",
"error": "Tool Error",
"result": "Tool Result",
"viewParams": "View input parameters",
"viewRawParams": "View raw parameters",
"viewDiff": "View edit diff for",
"creatingFile": "Creating new file:",
"updatingTodo": "Updating Todo List",
"read": "Read",
"readFile": "Read file",
"updateTodo": "Update todo list",
"readTodo": "Read todo list",
"searchResults": "results"
},
"search": {
"found": "Found {{count}} {{type}}",
"file": "file",
"files": "files",
"pattern": "pattern:",
"in": "in:"
},
"fileOperations": {
"updated": "File updated successfully",
"created": "File created successfully",
"written": "File written successfully",
"diff": "Diff",
"newFile": "New File",
"viewContent": "View file content",
"viewFullOutput": "View full output ({{count}} chars)",
"contentDisplayed": "The file content is displayed in the diff view above"
},
"interactive": {
"title": "Interactive Prompt",
"waiting": "Waiting for your response in the CLI",
"instruction": "Please select an option in your terminal where Claude is running.",
"selectedOption": "✓ Claude selected option {{number}}",
"instructionDetail": "In the CLI, you would select this option interactively using arrow keys or by typing the number."
},
"thinking": {
"title": "Thinking...",
"emoji": "💭 Thinking..."
},
"json": {
"response": "JSON Response"
},
"permissions": {
"grant": "Grant permission for {{tool}}",
"added": "Permission added",
"addTo": "Adds {{entry}} to Allowed Tools.",
"retry": "Permission saved. Retry the request to use the tool.",
"error": "Unable to update permissions. Please try again.",
"openSettings": "Open settings"
},
"todo": {
"updated": "Todo list has been updated successfully",
"current": "Current Todo List"
},
"plan": {
"viewPlan": "📋 View implementation plan",
"title": "Implementation Plan"
},
"usageLimit": {
"resetAt": "Claude usage limit reached. Your limit will reset at **{{time}} {{timezone}}** - {{date}}"
},
"codex": {
"permissionMode": "Permission Mode",
"modes": {
"default": "Default Mode",
"acceptEdits": "Accept Edits",
"bypassPermissions": "Bypass Permissions",
"plan": "Plan Mode"
},
"descriptions": {
"default": "Only trusted commands (ls, cat, grep, git status, etc.) run automatically. Other commands are skipped. Can write to workspace.",
"acceptEdits": "All commands run automatically within the workspace. Full auto mode with sandboxed execution.",
"bypassPermissions": "Full system access with no restrictions. All commands run automatically with full disk and network access. Use with caution.",
"plan": "Planning mode - no commands are executed"
},
"technicalDetails": "Technical details"
},
"input": {
"placeholder": "Type / for commands, @ for files, or ask {{provider}} anything...",
"placeholderDefault": "Type your message...",
"disabled": "Input disabled",
"attachFiles": "Attach files",
"attachImages": "Attach images",
"send": "Send",
"stop": "Stop",
"hintText": {
"ctrlEnter": "Ctrl+Enter to send • Shift+Enter for new line • Tab to change modes • / for slash commands",
"enter": "Enter to send • Shift+Enter for new line • Tab to change modes • / for slash commands"
},
"clickToChangeMode": "Click to change permission mode (or press Tab in input)",
"showAllCommands": "Show all commands"
},
"thinkingMode": {
"selector": {
"title": "Thinking Mode",
"description": "Extended thinking gives Claude more time to evaluate alternatives",
"active": "Active",
"tip": "Higher thinking modes take more time but provide more thorough analysis"
},
"modes": {
"none": {
"name": "Standard",
"description": "Regular Claude response",
"prefix": ""
},
"think": {
"name": "Think",
"description": "Basic extended thinking",
"prefix": "think"
},
"thinkHard": {
"name": "Think Hard",
"description": "More thorough evaluation",
"prefix": "think hard"
},
"thinkHarder": {
"name": "Think Harder",
"description": "Deep analysis with alternatives",
"prefix": "think harder"
},
"ultrathink": {
"name": "Ultrathink",
"description": "Maximum thinking budget",
"prefix": "ultrathink"
}
},
"buttonTitle": "Thinking mode: {{mode}}"
},
"providerSelection": {
"title": "Choose Your AI Assistant",
"description": "Select a provider to start a new conversation",
"selectModel": "Select Model",
"providerInfo": {
"anthropic": "by Anthropic",
"openai": "by OpenAI",
"cursorEditor": "AI Code Editor"
},
"readyPrompt": {
"claude": "Ready to use Claude with {{model}}. Start typing your message below.",
"cursor": "Ready to use Cursor with {{model}}. Start typing your message below.",
"codex": "Ready to use Codex with {{model}}. Start typing your message below.",
"default": "Select a provider above to begin"
}
},
"session": {
"continue": {
"title": "Continue your conversation",
"description": "Ask questions about your code, request changes, or get help with development tasks"
},
"loading": {
"olderMessages": "Loading older messages...",
"sessionMessages": "Loading session messages..."
},
"messages": {
"showingOf": "Showing {{shown}} of {{total}} messages",
"scrollToLoad": "Scroll up to load more",
"showingLast": "Showing last {{count}} messages ({{total}} total)",
"loadEarlier": "Load earlier messages"
}
},
"shell": {
"selectProject": {
"title": "Select a Project",
"description": "Choose a project to open an interactive shell in that directory"
},
"status": {
"newSession": "New Session",
"initializing": "Initializing...",
"restarting": "Restarting..."
},
"actions": {
"disconnect": "Disconnect",
"disconnectTitle": "Disconnect from shell",
"restart": "Restart",
"restartTitle": "Restart Shell (disconnect first)",
"connect": "Continue in Shell",
"connectTitle": "Connect to shell"
},
"loading": "Loading terminal...",
"connecting": "Connecting to shell...",
"startSession": "Start a new Claude session",
"resumeSession": "Resume session: {{displayName}}...",
"runCommand": "Run {{command}} in {{projectName}}",
"startCli": "Starting Claude CLI in {{projectName}}",
"defaultCommand": "command"
}
}

View File

@@ -0,0 +1,30 @@
{
"toolbar": {
"changes": "changes",
"previousChange": "Previous change",
"nextChange": "Next change",
"hideDiff": "Hide diff highlighting",
"showDiff": "Show diff highlighting",
"settings": "Editor Settings",
"collapse": "Collapse editor",
"expand": "Expand editor to full width"
},
"loading": "Loading {{fileName}}...",
"header": {
"showingChanges": "Showing changes"
},
"actions": {
"download": "Download file",
"save": "Save",
"saving": "Saving...",
"saved": "Saved!",
"exitFullscreen": "Exit fullscreen",
"fullscreen": "Fullscreen",
"close": "Close"
},
"footer": {
"lines": "Lines:",
"characters": "Characters:",
"shortcuts": "Press Ctrl+S to save • Esc to close"
}
}

View File

@@ -0,0 +1,222 @@
{
"buttons": {
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"create": "Create",
"edit": "Edit",
"close": "Close",
"confirm": "Confirm",
"submit": "Submit",
"retry": "Retry",
"refresh": "Refresh",
"search": "Search",
"clear": "Clear",
"copy": "Copy",
"download": "Download",
"upload": "Upload",
"browse": "Browse"
},
"tabs": {
"chat": "Chat",
"shell": "Shell",
"files": "Files",
"git": "Source Control",
"tasks": "Tasks"
},
"status": {
"loading": "Loading...",
"success": "Success",
"error": "Error",
"failed": "Failed",
"pending": "Pending",
"completed": "Completed",
"inProgress": "In Progress"
},
"messages": {
"savedSuccessfully": "Saved successfully",
"deletedSuccessfully": "Deleted successfully",
"updatedSuccessfully": "Updated successfully",
"operationFailed": "Operation failed",
"networkError": "Network error. Please check your connection.",
"unauthorized": "Unauthorized. Please log in.",
"notFound": "Not found",
"invalidInput": "Invalid input",
"requiredField": "This field is required",
"unknownError": "An unknown error occurred"
},
"navigation": {
"settings": "Settings",
"home": "Home",
"back": "Back",
"next": "Next",
"previous": "Previous",
"logout": "Logout"
},
"common": {
"language": "Language",
"theme": "Theme",
"darkMode": "Dark Mode",
"lightMode": "Light Mode",
"name": "Name",
"description": "Description",
"enabled": "Enabled",
"disabled": "Disabled",
"optional": "Optional",
"version": "Version",
"select": "Select",
"selectAll": "Select All",
"deselectAll": "Deselect All"
},
"time": {
"justNow": "Just now",
"minutesAgo": "{{count}} mins ago",
"hoursAgo": "{{count}} hours ago",
"daysAgo": "{{count}} days ago",
"yesterday": "Yesterday"
},
"fileOperations": {
"newFile": "New File",
"newFolder": "New Folder",
"rename": "Rename",
"move": "Move",
"copyPath": "Copy Path",
"openInEditor": "Open in Editor"
},
"mainContent": {
"loading": "Loading Claude Code UI",
"settingUpWorkspace": "Setting up your workspace...",
"chooseProject": "Choose Your Project",
"selectProjectDescription": "Select a project from the sidebar to start coding with Claude. Each project contains your chat sessions and file history.",
"tip": "Tip",
"createProjectMobile": "Tap the menu button above to access projects",
"createProjectDesktop": "Create a new project by clicking the folder icon in the sidebar",
"newSession": "New Session",
"untitledSession": "Untitled Session",
"projectFiles": "Project Files"
},
"fileTree": {
"loading": "Loading files...",
"files": "Files",
"simpleView": "Simple view",
"compactView": "Compact view",
"detailedView": "Detailed view",
"searchPlaceholder": "Search files and folders...",
"clearSearch": "Clear search",
"name": "Name",
"size": "Size",
"modified": "Modified",
"permissions": "Permissions",
"noFilesFound": "No files found",
"checkProjectPath": "Check if the project path is accessible",
"noMatchesFound": "No matches found",
"tryDifferentSearch": "Try a different search term or clear the search",
"justNow": "just now",
"minAgo": "{{count}} min ago",
"hoursAgo": "{{count}} hours ago",
"daysAgo": "{{count}} days ago"
},
"projectWizard": {
"title": "Create New Project",
"steps": {
"type": "Type",
"configure": "Configure",
"confirm": "Confirm"
},
"step1": {
"question": "Do you already have a workspace, or would you like to create a new one?",
"existing": {
"title": "Existing Workspace",
"description": "I already have a workspace on my server and just need to add it to the project list"
},
"new": {
"title": "New Workspace",
"description": "Create a new workspace, optionally clone from a GitHub repository"
}
},
"step2": {
"existingPath": "Workspace Path",
"newPath": "Workspace Path",
"existingPlaceholder": "/path/to/existing/workspace",
"newPlaceholder": "/path/to/new/workspace",
"existingHelp": "Full path to your existing workspace directory",
"newHelp": "Full path to your workspace directory",
"githubUrl": "GitHub URL (Optional)",
"githubPlaceholder": "https://github.com/username/repository",
"githubHelp": "Optional: provide a GitHub URL to clone a repository",
"githubAuth": "GitHub Authentication (Optional)",
"githubAuthHelp": "Only required for private repositories. Public repos can be cloned without authentication.",
"loadingTokens": "Loading stored tokens...",
"storedToken": "Stored Token",
"newToken": "New Token",
"nonePublic": "None (Public)",
"selectToken": "Select Token",
"selectTokenPlaceholder": "-- Select a token --",
"tokenPlaceholder": "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"tokenHelp": "This token will be used only for this operation",
"publicRepoInfo": "Public repositories don't require authentication. You can skip providing a token if cloning a public repo.",
"noTokensHelp": "No stored tokens available. You can add tokens in Settings → API Keys for easier reuse.",
"optionalTokenPublic": "GitHub Token (Optional for Public Repos)",
"tokenPublicPlaceholder": "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx (leave empty for public repos)"
},
"step3": {
"reviewConfig": "Review Your Configuration",
"workspaceType": "Workspace Type:",
"existingWorkspace": "Existing Workspace",
"newWorkspace": "New Workspace",
"path": "Path:",
"cloneFrom": "Clone From:",
"authentication": "Authentication:",
"usingStoredToken": "Using stored token:",
"usingProvidedToken": "Using provided token",
"noAuthentication": "No authentication",
"sshKey": "SSH Key",
"existingInfo": "The workspace will be added to your project list and will be available for Claude/Cursor sessions.",
"newWithClone": "The repository will be cloned from this folder.",
"newEmpty": "The workspace will be added to your project list and will be available for Claude/Cursor sessions.",
"cloningRepository": "Cloning repository..."
},
"buttons": {
"cancel": "Cancel",
"back": "Back",
"next": "Next",
"createProject": "Create Project",
"creating": "Creating...",
"cloning": "Cloning..."
},
"errors": {
"selectType": "Please select whether you have an existing workspace or want to create a new one",
"providePath": "Please provide a workspace path",
"failedToCreate": "Failed to create workspace",
"failedToCreateFolder": "Failed to create folder"
}
},
"versionUpdate": {
"title": "Update Available",
"newVersionReady": "A new version is ready",
"currentVersion": "Current Version",
"latestVersion": "Latest Version",
"whatsNew": "What's New:",
"viewFullRelease": "View full release",
"updateProgress": "Update Progress:",
"manualUpgrade": "Manual upgrade:",
"manualUpgradeHint": "Or click \"Update Now\" to run the update automatically.",
"updateCompleted": "Update completed successfully!",
"restartServer": "Please restart the server to apply changes.",
"updateFailed": "Update failed",
"buttons": {
"close": "Close",
"later": "Later",
"copyCommand": "Copy Command",
"updateNow": "Update Now",
"updating": "Updating..."
},
"ariaLabels": {
"closeModal": "Close version upgrade modal",
"showSidebar": "Show sidebar",
"settings": "Settings",
"updateAvailable": "Update available",
"closeSidebar": "Close sidebar"
}
}
}

View File

@@ -0,0 +1,418 @@
{
"title": "Settings",
"tabs": {
"account": "Account",
"permissions": "Permissions",
"mcpServers": "MCP Servers",
"appearance": "Appearance"
},
"account": {
"title": "Account",
"language": "Language",
"languageLabel": "Display Language",
"languageDescription": "Choose your preferred language for the interface",
"username": "Username",
"email": "Email",
"profile": "Profile",
"changePassword": "Change Password"
},
"mcp": {
"title": "MCP Servers",
"addServer": "Add Server",
"editServer": "Edit Server",
"deleteServer": "Delete Server",
"serverName": "Server Name",
"serverType": "Server Type",
"config": "Configuration",
"testConnection": "Test Connection",
"status": "Status",
"connected": "Connected",
"disconnected": "Disconnected",
"scope": {
"label": "Scope",
"user": "User",
"project": "Project"
}
},
"appearance": {
"title": "Appearance",
"theme": "Theme",
"codeEditor": "Code Editor",
"editorTheme": "Editor Theme",
"wordWrap": "Word Wrap",
"showMinimap": "Show Minimap",
"lineNumbers": "Line Numbers",
"fontSize": "Font Size"
},
"actions": {
"saveChanges": "Save Changes",
"resetToDefaults": "Reset to Defaults",
"cancelChanges": "Cancel Changes"
},
"quickSettings": {
"title": "Quick Settings",
"sections": {
"appearance": "Appearance",
"toolDisplay": "Tool Display",
"viewOptions": "View Options",
"inputSettings": "Input Settings",
"whisperDictation": "Whisper Dictation"
},
"darkMode": "Dark Mode",
"autoExpandTools": "Auto-expand tools",
"showRawParameters": "Show raw parameters",
"showThinking": "Show thinking",
"autoScrollToBottom": "Auto-scroll to bottom",
"sendByCtrlEnter": "Send by Ctrl+Enter",
"sendByCtrlEnterDescription": "When enabled, pressing Ctrl+Enter will send the message instead of just Enter. This is useful for IME users to avoid accidental sends.",
"dragHandle": {
"dragging": "Dragging handle",
"closePanel": "Close settings panel",
"openPanel": "Open settings panel",
"draggingStatus": "Dragging...",
"toggleAndMove": "Click to toggle, drag to move"
},
"whisper": {
"modes": {
"default": "Default Mode",
"defaultDescription": "Direct transcription of your speech",
"prompt": "Prompt Enhancement",
"promptDescription": "Transform rough ideas into clear, detailed AI prompts",
"vibe": "Vibe Mode",
"vibeDescription": "Format ideas as clear agent instructions with details"
}
}
},
"mainTabs": {
"agents": "Agents",
"appearance": "Appearance",
"git": "Git",
"apiTokens": "API & Tokens",
"tasks": "Tasks"
},
"appearanceSettings": {
"darkMode": {
"label": "Dark Mode",
"description": "Toggle between light and dark themes"
},
"projectSorting": {
"label": "Project Sorting",
"description": "How projects are ordered in the sidebar",
"alphabetical": "Alphabetical",
"recentActivity": "Recent Activity"
},
"codeEditor": {
"title": "Code Editor",
"theme": {
"label": "Editor Theme",
"description": "Default theme for the code editor"
},
"wordWrap": {
"label": "Word Wrap",
"description": "Enable word wrapping by default in the editor"
},
"showMinimap": {
"label": "Show Minimap",
"description": "Display a minimap for easier navigation in diff view"
},
"lineNumbers": {
"label": "Show Line Numbers",
"description": "Display line numbers in the editor"
},
"fontSize": {
"label": "Font Size",
"description": "Editor font size in pixels"
}
}
},
"mcpForm": {
"title": {
"add": "Add MCP Server",
"edit": "Edit MCP Server"
},
"importMode": {
"form": "Form Input",
"json": "JSON Import"
},
"scope": {
"label": "Scope",
"userGlobal": "User (Global)",
"projectLocal": "Project (Local)",
"userDescription": "User scope: Available across all projects on your machine",
"projectDescription": "Local scope: Only available in the selected project",
"cannotChange": "Scope cannot be changed when editing an existing server"
},
"fields": {
"serverName": "Server Name",
"transportType": "Transport Type",
"command": "Command",
"arguments": "Arguments (one per line)",
"jsonConfig": "JSON Configuration",
"url": "URL",
"envVars": "Environment Variables (KEY=value, one per line)",
"headers": "Headers (KEY=value, one per line)",
"selectProject": "Select a project..."
},
"placeholders": {
"serverName": "my-server"
},
"validation": {
"missingType": "Missing required field: type",
"stdioRequiresCommand": "stdio type requires a command field",
"httpRequiresUrl": "{{type}} type requires a url field",
"invalidJson": "Invalid JSON format",
"jsonHelp": "Paste your MCP server configuration in JSON format. Example formats:",
"jsonExampleStdio": "• stdio: {\"type\":\"stdio\",\"command\":\"npx\",\"args\":[\"@upstash/context7-mcp\"]}",
"jsonExampleHttp": "• http/sse: {\"type\":\"http\",\"url\":\"https://api.example.com/mcp\"}"
},
"configDetails": "Configuration Details (from {{configFile}})",
"projectPath": "Path: {{path}}",
"actions": {
"cancel": "Cancel",
"saving": "Saving...",
"addServer": "Add Server",
"updateServer": "Update Server"
}
},
"saveStatus": {
"success": "Settings saved successfully!",
"error": "Failed to save settings",
"saving": "Saving..."
},
"footerActions": {
"save": "Save Settings",
"cancel": "Cancel"
},
"git": {
"title": "Git Configuration",
"description": "Configure your git identity for commits. These settings will be applied globally via git config --global",
"name": {
"label": "Git Name",
"help": "Your name for git commits"
},
"email": {
"label": "Git Email",
"help": "Your email for git commits"
},
"actions": {
"save": "Save Configuration",
"saving": "Saving..."
},
"status": {
"success": "Saved successfully"
}
},
"apiKeys": {
"title": "API Keys",
"description": "Generate API keys to access the external API from other applications.",
"newKey": {
"alertTitle": "⚠️ Save Your API Key",
"alertMessage": "This is the only time you'll see this key. Store it securely.",
"iveSavedIt": "I've saved it"
},
"form": {
"placeholder": "API Key Name (e.g., Production Server)",
"createButton": "Create",
"cancelButton": "Cancel"
},
"newButton": "New API Key",
"empty": "No API keys created yet.",
"list": {
"created": "Created:",
"lastUsed": "Last used:"
},
"confirmDelete": "Are you sure you want to delete this API key?",
"status": {
"active": "Active",
"inactive": "Inactive"
},
"github": {
"title": "GitHub Tokens",
"description": "Add GitHub Personal Access Tokens to clone private repositories via the external API.",
"descriptionAlt": "Add GitHub Personal Access Tokens to clone private repositories. You can also pass tokens directly in API requests without storing them.",
"addButton": "Add Token",
"form": {
"namePlaceholder": "Token Name (e.g., Personal Repos)",
"tokenPlaceholder": "GitHub Personal Access Token (ghp_...)",
"descriptionPlaceholder": "Description (optional)",
"addButton": "Add Token",
"cancelButton": "Cancel",
"howToCreate": "How to create a GitHub Personal Access Token →"
},
"empty": "No GitHub tokens added yet.",
"added": "Added:",
"confirmDelete": "Are you sure you want to delete this GitHub token?"
},
"apiDocsLink": "API Documentation",
"documentation": {
"title": "External API Documentation",
"description": "Learn how to use the external API to trigger Claude/Cursor sessions from your applications.",
"viewLink": "View API Documentation →"
},
"loading": "Loading...",
"version": {
"updateAvailable": "Update available: v{{version}}"
}
},
"tasks": {
"checking": "Checking TaskMaster installation...",
"notInstalled": {
"title": "TaskMaster AI CLI Not Installed",
"description": "TaskMaster CLI is required to use task management features. Install it to get started:",
"installCommand": "npm install -g task-master-ai",
"viewOnGitHub": "View on GitHub",
"afterInstallation": "After installation:",
"steps": {
"restart": "Restart this application",
"autoAvailable": "TaskMaster features will automatically become available",
"initCommand": "Use task-master init in your project directory"
}
},
"settings": {
"enableLabel": "Enable TaskMaster Integration",
"enableDescription": "Show TaskMaster tasks, banners, and sidebar indicators across the interface"
}
},
"agents": {
"authStatus": {
"checking": "Checking...",
"connected": "Connected",
"notConnected": "Not connected",
"disconnected": "Disconnected",
"checkingAuth": "Checking authentication status...",
"loggedInAs": "Logged in as {{email}}",
"authenticatedUser": "authenticated user"
},
"account": {
"claude": {
"description": "Anthropic Claude AI assistant"
},
"cursor": {
"description": "Cursor AI-powered code editor"
},
"codex": {
"description": "OpenAI Codex AI assistant"
}
},
"connectionStatus": "Connection Status",
"login": {
"title": "Login",
"reAuthenticate": "Re-authenticate",
"description": "Sign in to your {{agent}} account to enable AI features",
"reAuthDescription": "Sign in with a different account or refresh credentials",
"button": "Login",
"reLoginButton": "Re-login"
},
"error": "Error: {{error}}"
},
"permissions": {
"title": "Permission Settings",
"skipPermissions": {
"label": "Skip permission prompts (use with caution)",
"claudeDescription": "Equivalent to --dangerously-skip-permissions flag",
"cursorDescription": "Equivalent to -f flag in Cursor CLI"
},
"allowedTools": {
"title": "Allowed Tools",
"description": "Tools that are automatically allowed without prompting for permission",
"placeholder": "e.g., \"Bash(git log:*)\" or \"Write\"",
"quickAdd": "Quick add common tools:",
"empty": "No allowed tools configured"
},
"blockedTools": {
"title": "Blocked Tools",
"description": "Tools that are automatically blocked without prompting for permission",
"placeholder": "e.g., \"Bash(rm:*)\"",
"empty": "No blocked tools configured"
},
"allowedCommands": {
"title": "Allowed Shell Commands",
"description": "Shell commands that are automatically allowed without prompting",
"placeholder": "e.g., \"Shell(ls)\" or \"Shell(git status)\"",
"quickAdd": "Quick add common commands:",
"empty": "No allowed commands configured"
},
"blockedCommands": {
"title": "Blocked Shell Commands",
"description": "Shell commands that are automatically blocked",
"placeholder": "e.g., \"Shell(rm -rf)\" or \"Shell(sudo)\"",
"empty": "No blocked commands configured"
},
"toolExamples": {
"title": "Tool Pattern Examples:",
"bashGitLog": "- Allow all git log commands",
"bashGitDiff": "- Allow all git diff commands",
"write": "- Allow all Write tool usage",
"bashRm": "- Block all rm commands (dangerous)"
},
"shellExamples": {
"title": "Shell Command Examples:",
"ls": "- Allow ls command",
"gitStatus": "- Allow git status",
"npmInstall": "- Allow npm install",
"rmRf": "- Block recursive delete"
},
"codex": {
"permissionMode": "Permission Mode",
"description": "Controls how Codex handles file modifications and command execution",
"modes": {
"default": {
"title": "Default",
"description": "Only trusted commands (ls, cat, grep, git status, etc.) run automatically. Other commands are skipped. Can write to workspace."
},
"acceptEdits": {
"title": "Accept Edits",
"description": "All commands run automatically within the workspace. Full auto mode with sandboxed execution."
},
"bypassPermissions": {
"title": "Bypass Permissions",
"description": "Full system access with no restrictions. All commands run automatically with full disk and network access. Use with caution."
}
},
"technicalDetails": "Technical details",
"technicalInfo": {
"default": "sandboxMode=workspace-write, approvalPolicy=untrusted. Trusted commands: cat, cd, grep, head, ls, pwd, tail, git status/log/diff/show, find (without -exec), etc.",
"acceptEdits": "sandboxMode=workspace-write, approvalPolicy=never. All commands auto-execute within project directory.",
"bypassPermissions": "sandboxMode=danger-full-access, approvalPolicy=never. Full system access, use only in trusted environments.",
"overrideNote": "You can override this per-session using the mode button in the chat interface."
}
},
"actions": {
"add": "Add"
}
},
"mcpServers": {
"title": "MCP Servers",
"description": {
"claude": "Model Context Protocol servers provide additional tools and data sources to Claude",
"cursor": "Model Context Protocol servers provide additional tools and data sources to Cursor",
"codex": "Model Context Protocol servers provide additional tools and data sources to Codex"
},
"addButton": "Add MCP Server",
"empty": "No MCP servers configured",
"serverType": "Type",
"scope": {
"local": "local",
"user": "user"
},
"config": {
"command": "Command",
"url": "URL",
"args": "Args",
"environment": "Environment"
},
"tools": {
"title": "Tools",
"count": "({{count}}):",
"more": "+{{count}} more"
},
"actions": {
"edit": "Edit server",
"delete": "Delete server"
},
"help": {
"title": "About Codex MCP",
"description": "Codex supports stdio-based MCP servers. You can add servers that extend Codex's capabilities with additional tools and resources."
}
}
}

View File

@@ -0,0 +1,112 @@
{
"projects": {
"title": "Projects",
"newProject": "New Project",
"deleteProject": "Delete Project",
"renameProject": "Rename Project",
"noProjects": "No projects found",
"loadingProjects": "Loading projects...",
"searchPlaceholder": "Search projects...",
"projectNamePlaceholder": "Project name",
"starred": "Starred",
"all": "All",
"untitledSession": "Untitled Session",
"newSession": "New Session",
"codexSession": "Codex Session",
"fetchingProjects": "Fetching your Claude projects and sessions",
"projects": "projects",
"noMatchingProjects": "No matching projects",
"tryDifferentSearch": "Try adjusting your search term",
"runClaudeCli": "Run Claude CLI in a project directory to get started"
},
"app": {
"title": "Claude Code UI",
"subtitle": "AI coding assistant interface"
},
"sessions": {
"title": "Sessions",
"newSession": "New Session",
"deleteSession": "Delete Session",
"renameSession": "Rename Session",
"noSessions": "No sessions yet",
"loadingSessions": "Loading sessions...",
"unnamed": "Unnamed",
"loading": "Loading...",
"showMore": "Show more sessions"
},
"tooltips": {
"viewEnvironments": "View Environments",
"hideSidebar": "Hide sidebar",
"createProject": "Create new project",
"refresh": "Refresh projects and sessions (Ctrl+R)",
"renameProject": "Rename project (F2)",
"deleteProject": "Delete empty project (Delete)",
"addToFavorites": "Add to favorites",
"removeFromFavorites": "Remove from favorites",
"editSessionName": "Manually edit session name",
"deleteSession": "Delete this session permanently",
"save": "Save",
"cancel": "Cancel"
},
"navigation": {
"chat": "Chat",
"files": "Files",
"git": "Git",
"terminal": "Terminal",
"tasks": "Tasks"
},
"actions": {
"refresh": "Refresh",
"settings": "Settings",
"collapseAll": "Collapse All",
"expandAll": "Expand All",
"cancel": "Cancel",
"save": "Save",
"delete": "Delete",
"rename": "Rename"
},
"status": {
"active": "Active",
"inactive": "Inactive",
"thinking": "Thinking...",
"error": "Error",
"aborted": "Aborted",
"unknown": "Unknown"
},
"time": {
"justNow": "Just now",
"oneMinuteAgo": "1 min ago",
"minutesAgo": "{{count}} mins ago",
"oneHourAgo": "1 hour ago",
"hoursAgo": "{{count}} hours ago",
"oneDayAgo": "1 day ago",
"daysAgo": "{{count}} days ago"
},
"messages": {
"deleteConfirm": "Are you sure you want to delete this?",
"renameSuccess": "Renamed successfully",
"deleteSuccess": "Deleted successfully",
"errorOccurred": "An error occurred",
"deleteSessionConfirm": "Are you sure you want to delete this session? This action cannot be undone.",
"deleteProjectConfirm": "Are you sure you want to delete this empty project? This action cannot be undone.",
"enterProjectPath": "Please enter a project path",
"deleteSessionFailed": "Failed to delete session. Please try again.",
"deleteSessionError": "Error deleting session. Please try again.",
"deleteProjectFailed": "Failed to delete project. Please try again.",
"deleteProjectError": "Error deleting project. Please try again.",
"createProjectFailed": "Failed to create project. Please try again.",
"createProjectError": "Error creating project. Please try again."
},
"version": {
"updateAvailable": "Update available"
},
"deleteConfirmation": {
"deleteProject": "Delete Project",
"deleteSession": "Delete Session",
"confirmDelete": "Are you sure you want to delete",
"sessionCount_one": "This project contains {{count}} conversation.",
"sessionCount_other": "This project contains {{count}} conversations.",
"allConversationsDeleted": "All conversations will be permanently deleted.",
"cannotUndo": "This action cannot be undone."
}
}

View File

@@ -0,0 +1,37 @@
{
"login": {
"title": "欢迎回来",
"description": "登录您的 Claude Code UI 账户",
"username": "用户名",
"password": "密码",
"submit": "登录",
"loading": "登录中...",
"errors": {
"invalidCredentials": "用户名或密码无效",
"requiredFields": "请填写所有字段",
"networkError": "网络错误,请重试。"
},
"placeholders": {
"username": "输入您的用户名",
"password": "输入您的密码"
}
},
"register": {
"title": "创建账户",
"username": "用户名",
"password": "密码",
"confirmPassword": "确认密码",
"submit": "创建账户",
"loading": "创建账户中...",
"errors": {
"passwordMismatch": "密码不匹配",
"usernameTaken": "用户名已被占用",
"weakPassword": "密码强度太弱"
}
},
"logout": {
"title": "退出登录",
"confirm": "确定要退出登录吗?",
"button": "退出登录"
}
}

View File

@@ -0,0 +1,205 @@
{
"codeBlock": {
"copy": "复制",
"copied": "已复制",
"copyCode": "复制代码"
},
"messageTypes": {
"user": "U",
"error": "错误",
"tool": "工具",
"claude": "Claude",
"cursor": "Cursor",
"codex": "Codex"
},
"tools": {
"settings": "工具设置",
"error": "工具错误",
"result": "工具结果",
"viewParams": "查看输入参数",
"viewRawParams": "查看原始参数",
"viewDiff": "查看编辑差异",
"creatingFile": "创建新文件:",
"updatingTodo": "更新待办事项",
"read": "读取",
"readFile": "读取文件",
"updateTodo": "更新待办列表",
"readTodo": "读取待办列表",
"searchResults": "结果"
},
"search": {
"found": "找到 {{count}} 个{{type}}",
"file": "文件",
"files": "文件",
"pattern": "模式:",
"in": "在:"
},
"fileOperations": {
"updated": "文件更新成功",
"created": "文件创建成功",
"written": "文件写入成功",
"diff": "差异",
"newFile": "新文件",
"viewContent": "查看文件内容",
"viewFullOutput": "查看完整输出({{count}} 个字符)",
"contentDisplayed": "文件内容显示在上面的差异视图中"
},
"interactive": {
"title": "交互式提示",
"waiting": "等待您在 CLI 中响应",
"instruction": "请在 Claude 运行的终端中选择一个选项。",
"selectedOption": "✓ Claude 选择了选项 {{number}}",
"instructionDetail": "在 CLI 中,您可以使用方向键或输入数字来交互式地选择此选项。"
},
"thinking": {
"title": "思考中...",
"emoji": "💭 思考中..."
},
"json": {
"response": "JSON 响应"
},
"permissions": {
"grant": "授予 {{tool}} 权限",
"added": "权限已添加",
"addTo": "将 {{entry}} 添加到允许的工具。",
"retry": "权限已保存。重试请求以使用该工具。",
"error": "无法更新权限。请重试。",
"openSettings": "打开设置"
},
"todo": {
"updated": "待办列表已成功更新",
"current": "当前待办列表"
},
"plan": {
"viewPlan": "📋 查看实施计划",
"title": "实施计划"
},
"usageLimit": {
"resetAt": "Claude 使用限制已达到。您的限制将在 **{{time}} {{timezone}}** - {{date}} 重置"
},
"codex": {
"permissionMode": "权限模式",
"modes": {
"default": "默认模式",
"acceptEdits": "编辑模式",
"bypassPermissions": "无限制模式",
"plan": "计划模式"
},
"descriptions": {
"default": "只有受信任的命令ls、cat、grep、git status 等)自动运行。其他命令将被跳过。可以写入工作区。",
"acceptEdits": "工作区内的所有命令自动运行。完全自动模式,具有沙盒执行功能。",
"bypassPermissions": "完全的系统访问,无限制。所有命令自动运行,具有完整的磁盘和网络访问权限。请谨慎使用。",
"plan": "计划模式 - 不执行任何命令"
},
"technicalDetails": "技术细节"
},
"input": {
"placeholder": "输入 / 调用命令,@ 选择文件,或向 {{provider}} 提问...",
"placeholderDefault": "输入您的消息...",
"disabled": "输入已禁用",
"attachFiles": "附加文件",
"attachImages": "附加图片",
"send": "发送",
"stop": "停止",
"hintText": {
"ctrlEnter": "Ctrl+Enter 发送 • Shift+Enter 换行 • Tab 切换模式 • / 斜杠命令",
"enter": "Enter 发送 • Shift+Enter 换行 • Tab 切换模式 • / 斜杠命令"
},
"clickToChangeMode": "点击更改权限模式(或在输入框中按 Tab",
"showAllCommands": "显示所有命令"
},
"thinkingMode": {
"selector": {
"title": "思考模式",
"description": "扩展思考给 Claude 更多时间来评估替代方案",
"active": "激活",
"tip": "更高的思考模式需要更多时间,但提供更彻底的分析"
},
"modes": {
"none": {
"name": "标准",
"description": "常规 Claude 响应",
"prefix": ""
},
"think": {
"name": "思考",
"description": "基本扩展思考",
"prefix": "思考"
},
"thinkHard": {
"name": "深入思考",
"description": "更彻底的评估",
"prefix": "深入思考"
},
"thinkHarder": {
"name": "更深入思考",
"description": "考虑替代方案的深度分析",
"prefix": "更深入思考"
},
"ultrathink": {
"name": "超级思考",
"description": "最大思考预算",
"prefix": "超级思考"
}
},
"buttonTitle": "思考模式:{{mode}}"
},
"providerSelection": {
"title": "选择您的 AI 助手",
"description": "选择一个供应商以开始新对话",
"selectModel": "选择模型",
"providerInfo": {
"anthropic": "Anthropic",
"openai": "OpenAI",
"cursorEditor": "AI 代码编辑器"
},
"readyPrompt": {
"claude": "已准备好使用 Claude {{model}}。在下方输入您的消息。",
"cursor": "已准备好使用 Cursor {{model}}。在下方输入您的消息。",
"codex": "已准备好使用 Codex {{model}}。在下方输入您的消息。",
"default": "请在上方选择一个供应商以开始"
}
},
"session": {
"continue": {
"title": "继续您的对话",
"description": "询问有关代码的问题、请求更改或获取开发任务的帮助"
},
"loading": {
"olderMessages": "正在加载更早的消息...",
"sessionMessages": "正在加载会话消息..."
},
"messages": {
"showingOf": "显示 {{shown}} / {{total}} 条消息",
"scrollToLoad": "向上滚动以加载更多",
"showingLast": "显示最近 {{count}} 条消息(共 {{total}} 条)",
"loadEarlier": "加载更早的消息"
}
},
"shell": {
"selectProject": {
"title": "选择项目",
"description": "选择一个项目以在该目录中打开交互式 Shell"
},
"status": {
"newSession": "新会话",
"initializing": "初始化中...",
"restarting": "重启中..."
},
"actions": {
"disconnect": "断开连接",
"disconnectTitle": "断开 Shell 连接",
"restart": "重启",
"restartTitle": "重启 Shell请先断开连接",
"connect": "在 Shell 中继续",
"connectTitle": "连接到 Shell"
},
"loading": "正在加载终端...",
"connecting": "正在连接到 Shell...",
"startSession": "启动新的 Claude 会话",
"resumeSession": "恢复会话:{{displayName}}...",
"runCommand": "在 {{projectName}} 中运行 {{command}}",
"startCli": "在 {{projectName}} 中启动 Claude CLI",
"defaultCommand": "命令"
}
}

View File

@@ -0,0 +1,30 @@
{
"toolbar": {
"changes": "个更改",
"previousChange": "上一个更改",
"nextChange": "下一个更改",
"hideDiff": "隐藏差异高亮",
"showDiff": "显示差异高亮",
"settings": "编辑器设置",
"collapse": "折叠编辑器",
"expand": "展开编辑器到全宽"
},
"loading": "正在加载 {{fileName}}...",
"header": {
"showingChanges": "显示更改"
},
"actions": {
"download": "下载文件",
"save": "保存",
"saving": "保存中...",
"saved": "已保存!",
"exitFullscreen": "退出全屏",
"fullscreen": "全屏",
"close": "关闭"
},
"footer": {
"lines": "行数:",
"characters": "字符数:",
"shortcuts": "按 Ctrl+S 保存 • Esc 关闭"
}
}

View File

@@ -0,0 +1,222 @@
{
"buttons": {
"save": "保存",
"cancel": "取消",
"delete": "删除",
"create": "创建",
"edit": "编辑",
"close": "关闭",
"confirm": "确认",
"submit": "提交",
"retry": "重试",
"refresh": "刷新",
"search": "搜索",
"clear": "清除",
"copy": "复制",
"download": "下载",
"upload": "上传",
"browse": "浏览"
},
"tabs": {
"chat": "聊天",
"shell": "终端",
"files": "文件",
"git": "源代码管理",
"tasks": "任务"
},
"status": {
"loading": "加载中...",
"success": "成功",
"error": "错误",
"failed": "失败",
"pending": "待处理",
"completed": "已完成",
"inProgress": "进行中"
},
"messages": {
"savedSuccessfully": "保存成功",
"deletedSuccessfully": "删除成功",
"updatedSuccessfully": "更新成功",
"operationFailed": "操作失败",
"networkError": "网络错误,请检查您的连接。",
"unauthorized": "未授权,请登录。",
"notFound": "未找到",
"invalidInput": "输入无效",
"requiredField": "此字段为必填项",
"unknownError": "发生未知错误"
},
"navigation": {
"settings": "设置",
"home": "首页",
"back": "返回",
"next": "下一步",
"previous": "上一步",
"logout": "退出登录"
},
"common": {
"language": "语言",
"theme": "主题",
"darkMode": "深色模式",
"lightMode": "浅色模式",
"name": "名称",
"description": "描述",
"enabled": "已启用",
"disabled": "已禁用",
"optional": "可选",
"version": "版本",
"select": "选择",
"selectAll": "全选",
"deselectAll": "取消全选"
},
"time": {
"justNow": "刚刚",
"minutesAgo": "{{count}} 分钟前",
"hoursAgo": "{{count}} 小时前",
"daysAgo": "{{count}} 天前",
"yesterday": "昨天"
},
"fileOperations": {
"newFile": "新建文件",
"newFolder": "新建文件夹",
"rename": "重命名",
"move": "移动",
"copyPath": "复制路径",
"openInEditor": "在编辑器中打开"
},
"mainContent": {
"loading": "正在加载 Claude Code UI",
"settingUpWorkspace": "正在设置您的工作空间...",
"chooseProject": "选择您的项目",
"selectProjectDescription": "从侧边栏选择一个项目以开始使用 Claude 进行编程。每个项目包含您的聊天会话和文件历史。",
"tip": "提示",
"createProjectMobile": "点击上方的菜单按钮以访问项目",
"createProjectDesktop": "点击侧边栏中的文件夹图标以创建新项目",
"newSession": "新会话",
"untitledSession": "未命名会话",
"projectFiles": "项目文件"
},
"fileTree": {
"loading": "正在加载文件...",
"files": "文件",
"simpleView": "简单视图",
"compactView": "紧凑视图",
"detailedView": "详细视图",
"searchPlaceholder": "搜索文件和文件夹...",
"clearSearch": "清除搜索",
"name": "名称",
"size": "大小",
"modified": "修改时间",
"permissions": "权限",
"noFilesFound": "未找到文件",
"checkProjectPath": "检查项目路径是否可访问",
"noMatchesFound": "未找到匹配项",
"tryDifferentSearch": "尝试不同的搜索词或清除搜索",
"justNow": "刚刚",
"minAgo": "{{count}} 分钟前",
"hoursAgo": "{{count}} 小时前",
"daysAgo": "{{count}} 天前"
},
"projectWizard": {
"title": "创建新项目",
"steps": {
"type": "类型",
"configure": "配置",
"confirm": "确认"
},
"step1": {
"question": "您已经有工作区,还是想创建一个新的工作区?",
"existing": {
"title": "现有工作区",
"description": "我的服务器上已经有工作区,只需要将其添加到项目列表中"
},
"new": {
"title": "新建工作区",
"description": "创建一个新工作区,可选择从 GitHub 仓库克隆"
}
},
"step2": {
"existingPath": "工作区路径",
"newPath": "工作区路径",
"existingPlaceholder": "/path/to/existing/workspace",
"newPlaceholder": "/path/to/new/workspace",
"existingHelp": "您现有工作区目录的完整路径",
"newHelp": "工作区目录的完整路径",
"githubUrl": "GitHub URL可选",
"githubPlaceholder": "https://github.com/username/repository",
"githubHelp": "可选:提供 GitHub URL 以克隆仓库",
"githubAuth": "GitHub 身份验证(可选)",
"githubAuthHelp": "仅私有仓库需要。公共仓库无需身份验证即可克隆。",
"loadingTokens": "正在加载已保存的令牌...",
"storedToken": "已保存的令牌",
"newToken": "新令牌",
"nonePublic": "无(公共)",
"selectToken": "选择令牌",
"selectTokenPlaceholder": "-- 选择令牌 --",
"tokenPlaceholder": "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"tokenHelp": "此令牌仅用于此操作",
"publicRepoInfo": "公共仓库不需要身份验证。如果克隆公共仓库,可以跳过提供令牌。",
"noTokensHelp": "没有可用的已保存令牌。您可以在 设置 → API 密钥 中添加令牌以便重复使用。",
"optionalTokenPublic": "GitHub 令牌(公共仓库可选)",
"tokenPublicPlaceholder": "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx公共仓库可留空"
},
"step3": {
"reviewConfig": "查看您的配置",
"workspaceType": "工作区类型:",
"existingWorkspace": "现有工作区",
"newWorkspace": "新建工作区",
"path": "路径:",
"cloneFrom": "克隆自:",
"authentication": "身份验证:",
"usingStoredToken": "使用已保存的令牌:",
"usingProvidedToken": "使用提供的令牌",
"noAuthentication": "无身份验证",
"sshKey": "SSH 密钥",
"existingInfo": "工作区将被添加到您的项目列表中,并可用于 Claude/Cursor 会话。",
"newWithClone": "仓库将从此文件夹克隆。",
"newEmpty": "工作区将被添加到您的项目列表中,并可用于 Claude/Cursor 会话。",
"cloningRepository": "正在克隆仓库..."
},
"buttons": {
"cancel": "取消",
"back": "返回",
"next": "下一步",
"createProject": "创建项目",
"creating": "创建中...",
"cloning": "正在克隆..."
},
"errors": {
"selectType": "请选择您已有现有工作区还是想创建新工作区",
"providePath": "请提供工作区路径",
"failedToCreate": "创建工作区失败",
"failedToCreateFolder": "创建文件夹失败"
}
},
"versionUpdate": {
"title": "有可用更新",
"newVersionReady": "新版本已准备就绪",
"currentVersion": "当前版本",
"latestVersion": "最新版本",
"whatsNew": "新内容:",
"viewFullRelease": "查看完整发布",
"updateProgress": "更新进度:",
"manualUpgrade": "手动升级:",
"manualUpgradeHint": "或点击'立即更新'以自动运行更新。",
"updateCompleted": "更新成功完成!",
"restartServer": "请重启服务器以应用更改。",
"updateFailed": "更新失败",
"buttons": {
"close": "关闭",
"later": "稍后",
"copyCommand": "复制命令",
"updateNow": "立即更新",
"updating": "更新中..."
},
"ariaLabels": {
"closeModal": "关闭版本升级模态框",
"showSidebar": "显示侧边栏",
"settings": "设置",
"updateAvailable": "有可用更新",
"closeSidebar": "关闭侧边栏"
}
}
}

View File

@@ -0,0 +1,418 @@
{
"title": "设置",
"tabs": {
"account": "账户",
"permissions": "权限",
"mcpServers": "MCP 服务器",
"appearance": "外观"
},
"account": {
"title": "账户",
"language": "语言",
"languageLabel": "显示语言",
"languageDescription": "选择您偏好的界面语言",
"username": "用户名",
"email": "邮箱",
"profile": "个人资料",
"changePassword": "修改密码"
},
"mcp": {
"title": "MCP 服务器",
"addServer": "添加服务器",
"editServer": "编辑服务器",
"deleteServer": "删除服务器",
"serverName": "服务器名称",
"serverType": "服务器类型",
"config": "配置",
"testConnection": "测试连接",
"status": "状态",
"connected": "已连接",
"disconnected": "未连接",
"scope": {
"label": "范围",
"user": "用户",
"project": "项目"
}
},
"appearance": {
"title": "外观",
"theme": "主题",
"codeEditor": "代码编辑器",
"editorTheme": "编辑器主题",
"wordWrap": "自动换行",
"showMinimap": "显示缩略图",
"lineNumbers": "行号",
"fontSize": "字体大小"
},
"actions": {
"saveChanges": "保存更改",
"resetToDefaults": "重置为默认值",
"cancelChanges": "取消更改"
},
"quickSettings": {
"title": "快速设置",
"sections": {
"appearance": "外观",
"toolDisplay": "工具显示",
"viewOptions": "视图选项",
"inputSettings": "输入设置",
"whisperDictation": "Whisper 听写"
},
"darkMode": "深色模式",
"autoExpandTools": "自动展开工具",
"showRawParameters": "显示原始参数",
"showThinking": "显示思考过程",
"autoScrollToBottom": "自动滚动到底部",
"sendByCtrlEnter": "使用 Ctrl+Enter 发送",
"sendByCtrlEnterDescription": "启用后,按 Ctrl+Enter 发送消息,而不是仅按 Enter。这对于使用输入法的用户可以避免意外发送。",
"dragHandle": {
"dragging": "正在拖拽手柄",
"closePanel": "关闭设置面板",
"openPanel": "打开设置面板",
"draggingStatus": "正在拖拽...",
"toggleAndMove": "点击切换,拖拽移动"
},
"whisper": {
"modes": {
"default": "默认模式",
"defaultDescription": "直接转录您的语音",
"prompt": "提示词增强",
"promptDescription": "将粗略的想法转化为清晰、详细的 AI 提示词",
"vibe": "Vibe 模式",
"vibeDescription": "将想法格式化为带有详细说明的清晰智能体指令"
}
}
},
"mainTabs": {
"agents": "智能体",
"appearance": "外观",
"git": "Git",
"apiTokens": "API 和令牌",
"tasks": "任务"
},
"appearanceSettings": {
"darkMode": {
"label": "深色模式",
"description": "切换浅色和深色主题"
},
"projectSorting": {
"label": "项目排序",
"description": "项目在侧边栏中的排列方式",
"alphabetical": "按字母顺序",
"recentActivity": "最近活动"
},
"codeEditor": {
"title": "代码编辑器",
"theme": {
"label": "编辑器主题",
"description": "代码编辑器的默认主题"
},
"wordWrap": {
"label": "自动换行",
"description": "在编辑器中默认启用自动换行"
},
"showMinimap": {
"label": "显示缩略图",
"description": "在差异视图中显示缩略图以便于导航"
},
"lineNumbers": {
"label": "显示行号",
"description": "在编辑器中显示行号"
},
"fontSize": {
"label": "字体大小",
"description": "编辑器字体大小px"
}
}
},
"mcpForm": {
"title": {
"add": "添加 MCP 服务器",
"edit": "编辑 MCP 服务器"
},
"importMode": {
"form": "表单输入",
"json": "JSON 导入"
},
"scope": {
"label": "范围",
"userGlobal": "用户(全局)",
"projectLocal": "项目(本地)",
"userDescription": "用户范围:在您机器上的所有项目中可用",
"projectDescription": "本地范围:仅在选定项目中可用",
"cannotChange": "编辑现有服务器时无法更改范围"
},
"fields": {
"serverName": "服务器名称",
"transportType": "传输类型",
"command": "命令",
"arguments": "参数(每行一个)",
"jsonConfig": "JSON 配置",
"url": "URL",
"envVars": "环境变量KEY=值,每行一个)",
"headers": "请求头KEY=值,每行一个)",
"selectProject": "选择项目..."
},
"placeholders": {
"serverName": "我的服务"
},
"validation": {
"missingType": "缺少必填字段type",
"stdioRequiresCommand": "stdio 类型需要 command 字段",
"httpRequiresUrl": "{{type}} 类型需要 url 字段",
"invalidJson": "无效的 JSON 格式",
"jsonHelp": "粘贴您的 MCP 服务器配置JSON 格式)。示例格式:",
"jsonExampleStdio": "• stdio: {\"type\":\"stdio\",\"command\":\"npx\",\"args\":[\"@upstash/context7-mcp\"]}",
"jsonExampleHttp": "• http/sse: {\"type\":\"http\",\"url\":\"https://api.example.com/mcp\"}"
},
"configDetails": "配置详细信息(来自 {{configFile}}",
"projectPath": "路径:{{path}}",
"actions": {
"cancel": "取消",
"saving": "保存中...",
"addServer": "添加服务器",
"updateServer": "更新服务器"
}
},
"saveStatus": {
"success": "设置保存成功!",
"error": "保存设置失败",
"saving": "保存中..."
},
"footerActions": {
"save": "保存设置",
"cancel": "取消"
},
"git": {
"title": "Git 配置",
"description": "配置您的 git 提交身份。这些设置将通过 git config --global 全局应用",
"name": {
"label": "Git 名称",
"help": "您的 git 提交名称"
},
"email": {
"label": "Git 邮箱",
"help": "您的 git 提交邮箱"
},
"actions": {
"save": "保存配置",
"saving": "保存中..."
},
"status": {
"success": "保存成功"
}
},
"apiKeys": {
"title": "API 密钥",
"description": "生成 API 密钥以从其他应用访问外部 API。",
"newKey": {
"alertTitle": "⚠️ 保存您的 API 密钥",
"alertMessage": "这是您唯一一次看到此密钥。请妥善保存。",
"iveSavedIt": "我已保存"
},
"form": {
"placeholder": "API 密钥名称(例如:生产服务器)",
"createButton": "创建",
"cancelButton": "取消"
},
"newButton": "新建 API 密钥",
"empty": "尚未创建 API 密钥。",
"list": {
"created": "创建时间:",
"lastUsed": "最后使用:"
},
"confirmDelete": "确定要删除此 API 密钥吗?",
"status": {
"active": "激活",
"inactive": "未激活"
},
"github": {
"title": "GitHub 令牌",
"description": "添加 GitHub 个人访问令牌以通过外部 API 克隆私有仓库。",
"descriptionAlt": "添加 GitHub 个人访问令牌以克隆私有仓库。您也可以直接在 API 请求中传递令牌而无需存储。",
"addButton": "添加令牌",
"form": {
"namePlaceholder": "令牌名称(例如:个人仓库)",
"tokenPlaceholder": "GitHub 个人访问令牌ghp_...",
"descriptionPlaceholder": "描述(可选)",
"addButton": "添加令牌",
"cancelButton": "取消",
"howToCreate": "如何创建 GitHub 个人访问令牌 →"
},
"empty": "尚未添加 GitHub 令牌。",
"added": "添加时间:",
"confirmDelete": "确定要删除此 GitHub 令牌吗?"
},
"apiDocsLink": "API 文档",
"documentation": {
"title": "外部 API 文档",
"description": "了解如何使用外部 API 从您的应用程序触发 Claude/Cursor 会话。",
"viewLink": "查看 API 文档 →"
},
"loading": "加载中...",
"version": {
"updateAvailable": "有可用更新v{{version}}"
}
},
"tasks": {
"checking": "正在检查 TaskMaster 安装...",
"notInstalled": {
"title": "未安装 TaskMaster AI CLI",
"description": "需要 TaskMaster CLI 才能使用任务管理功能。安装它以开始使用:",
"installCommand": "npm install -g task-master-ai",
"viewOnGitHub": "在 GitHub 上查看",
"afterInstallation": "安装后:",
"steps": {
"restart": "重启此应用程序",
"autoAvailable": "TaskMaster 功能将自动可用",
"initCommand": "在项目目录中使用 task-master init"
}
},
"settings": {
"enableLabel": "启用 TaskMaster 集成",
"enableDescription": "在整个界面中显示 TaskMaster 任务、横幅和侧边栏指示器"
}
},
"agents": {
"authStatus": {
"checking": "检查中...",
"connected": "已连接",
"notConnected": "未连接",
"disconnected": "已断开",
"checkingAuth": "正在检查认证状态...",
"loggedInAs": "登录为 {{email}}",
"authenticatedUser": "已认证用户"
},
"account": {
"claude": {
"description": "Anthropic Claude AI 助手"
},
"cursor": {
"description": "Cursor AI 驱动的代码编辑器"
},
"codex": {
"description": "OpenAI Codex AI 助手"
}
},
"connectionStatus": "连接状态",
"login": {
"title": "登录",
"reAuthenticate": "重新认证",
"description": "登录您的 {{agent}} 账户以启用 AI 功能",
"reAuthDescription": "使用其他账户登录或刷新凭据",
"button": "登录",
"reLoginButton": "重新登录"
},
"error": "错误:{{error}}"
},
"permissions": {
"title": "权限设置",
"skipPermissions": {
"label": "跳过权限提示(请谨慎使用)",
"claudeDescription": "等同于 --dangerously-skip-permissions 标志",
"cursorDescription": "等同于 Cursor CLI 中的 -f 标志"
},
"allowedTools": {
"title": "允许的工具",
"description": "无需权限提示即可自动使用的工具",
"placeholder": "例如:\"Bash(git log:*)\" 或 \"Write\"",
"quickAdd": "快速添加常用工具:",
"empty": "未配置允许的工具"
},
"blockedTools": {
"title": "禁用的工具",
"description": "无需权限提示即可自动禁用的工具",
"placeholder": "例如:\"Bash(rm:*)\"",
"empty": "未配置禁用的工具"
},
"allowedCommands": {
"title": "允许的 Shell 命令",
"description": "无需权限提示即可自动执行的 Shell 命令",
"placeholder": "例如:\"Shell(ls)\" 或 \"Shell(git status)\"",
"quickAdd": "快速添加常用命令:",
"empty": "未配置允许的命令"
},
"blockedCommands": {
"title": "阻止的 Shell 命令",
"description": "自动阻止的 Shell 命令",
"placeholder": "例如:\"Shell(rm -rf)\" 或 \"Shell(sudo)\"",
"empty": "未配置阻止的命令"
},
"toolExamples": {
"title": "工具模式示例:",
"bashGitLog": "- 允许所有 git log 命令",
"bashGitDiff": "- 允许所有 git diff 命令",
"write": "- 允许所有 Write 工具使用",
"bashRm": "- 阻止所有 rm 命令(危险)"
},
"shellExamples": {
"title": "Shell 命令示例:",
"ls": "- 允许 ls 命令",
"gitStatus": "- 允许 git status",
"npmInstall": "- 允许 npm install",
"rmRf": "- 阻止递归删除"
},
"codex": {
"permissionMode": "权限模式",
"description": "控制 Codex 如何处理文件修改和命令执行",
"modes": {
"default": {
"title": "默认",
"description": "只有受信任的命令ls、cat、grep、git status 等)会自动运行。其他命令将被跳过。可以写入工作区。"
},
"acceptEdits": {
"title": "接受编辑",
"description": "所有命令在工作区内自动运行。具有沙箱执行的全自动模式。"
},
"bypassPermissions": {
"title": "绕过权限",
"description": "完全系统访问,无任何限制。所有命令自动运行,具有完整的磁盘和网络访问权限。请谨慎使用。"
}
},
"technicalDetails": "技术详情",
"technicalInfo": {
"default": "sandboxMode=workspace-write, approvalPolicy=untrusted。受信任的命令cat、cd、grep、head、ls、pwd、tail、git status/log/diff/show、find不带 -exec等。",
"acceptEdits": "sandboxMode=workspace-write, approvalPolicy=never。所有命令在项目目录内自动执行。",
"bypassPermissions": "sandboxMode=danger-full-access, approvalPolicy=never。完全系统访问权限仅在可信环境中使用。",
"overrideNote": "您可以使用聊天界面中的模式按钮按会话覆盖此设置。"
}
},
"actions": {
"add": "添加"
}
},
"mcpServers": {
"title": "MCP 服务器",
"description": {
"claude": "Model Context Protocol 服务器为 Claude 提供额外的工具和数据源",
"cursor": "Model Context Protocol 服务器为 Cursor 提供额外的工具和数据源",
"codex": "Model Context Protocol 服务器为 Codex 提供额外的工具和数据源"
},
"addButton": "添加 MCP 服务器",
"empty": "未配置 MCP 服务器",
"serverType": "类型",
"scope": {
"local": "本地",
"user": "用户"
},
"config": {
"command": "命令",
"url": "URL",
"args": "参数",
"environment": "环境变量"
},
"tools": {
"title": "工具",
"count": "{{count}}",
"more": "还有 {{count}} 个"
},
"actions": {
"edit": "编辑服务器",
"delete": "删除服务器"
},
"help": {
"title": "关于 Codex MCP",
"description": "Codex 支持基于 stdio 的 MCP 服务器。您可以添加服务器,通过额外的工具和资源来扩展 Codex 的功能。"
}
}
}

View File

@@ -0,0 +1,112 @@
{
"projects": {
"title": "项目",
"newProject": "新建项目",
"deleteProject": "删除项目",
"renameProject": "重命名项目",
"noProjects": "未找到项目",
"loadingProjects": "加载项目中...",
"searchPlaceholder": "搜索项目...",
"projectNamePlaceholder": "项目名称",
"starred": "星标",
"all": "全部",
"untitledSession": "未命名会话",
"newSession": "新会话",
"codexSession": "Codex 会话",
"fetchingProjects": "正在获取您的 Claude 项目和会话",
"projects": "项目",
"noMatchingProjects": "未找到匹配的项目",
"tryDifferentSearch": "尝试调整您的搜索词",
"runClaudeCli": "在项目目录中运行 Claude CLI 以开始使用"
},
"app": {
"title": "Claude Code UI",
"subtitle": "AI 编程助手"
},
"sessions": {
"title": "会话",
"newSession": "新建会话",
"deleteSession": "删除会话",
"renameSession": "重命名会话",
"noSessions": "暂无会话",
"loadingSessions": "加载会话中...",
"unnamed": "未命名",
"loading": "加载中...",
"showMore": "显示更多会话"
},
"tooltips": {
"viewEnvironments": "查看环境",
"hideSidebar": "隐藏侧边栏",
"createProject": "创建新项目",
"refresh": "刷新项目和会话 (Ctrl+R)",
"renameProject": "重命名项目 (F2)",
"deleteProject": "删除空项目 (Delete)",
"addToFavorites": "添加到收藏",
"removeFromFavorites": "从收藏移除",
"editSessionName": "手动编辑会话名称",
"deleteSession": "永久删除此会话",
"save": "保存",
"cancel": "取消"
},
"navigation": {
"chat": "聊天",
"files": "文件",
"git": "Git",
"terminal": "终端",
"tasks": "任务"
},
"actions": {
"refresh": "刷新",
"settings": "设置",
"collapseAll": "全部折叠",
"expandAll": "全部展开",
"cancel": "取消",
"save": "保存",
"delete": "删除",
"rename": "重命名"
},
"status": {
"active": "活动",
"inactive": "非活动",
"thinking": "思考中...",
"error": "错误",
"aborted": "已中止",
"unknown": "未知"
},
"time": {
"justNow": "刚刚",
"oneMinuteAgo": "1 分钟前",
"minutesAgo": "{{count}} 分钟前",
"oneHourAgo": "1 小时前",
"hoursAgo": "{{count}} 小时前",
"oneDayAgo": "1 天前",
"daysAgo": "{{count}} 天前"
},
"messages": {
"deleteConfirm": "确定要删除吗?",
"renameSuccess": "重命名成功",
"deleteSuccess": "删除成功",
"errorOccurred": "发生错误",
"deleteSessionConfirm": "确定要删除此会话吗?此操作无法撤销。",
"deleteProjectConfirm": "确定要删除此空项目吗?此操作无法撤销。",
"enterProjectPath": "请输入项目路径",
"deleteSessionFailed": "删除会话失败,请重试。",
"deleteSessionError": "删除会话时出错,请重试。",
"deleteProjectFailed": "删除项目失败,请重试。",
"deleteProjectError": "删除项目时出错,请重试。",
"createProjectFailed": "创建项目失败,请重试。",
"createProjectError": "创建项目时出错,请重试。"
},
"version": {
"updateAvailable": "有可用更新"
},
"deleteConfirmation": {
"deleteProject": "删除项目",
"deleteSession": "删除会话",
"confirmDelete": "您确定要删除",
"sessionCount_one": "此项目包含 {{count}} 个对话。",
"sessionCount_other": "此项目包含 {{count}} 个对话。",
"allConversationsDeleted": "所有对话将被永久删除。",
"cannotUndo": "此操作无法撤销。"
}
}

View File

@@ -3,4 +3,13 @@ import { twMerge } from "tailwind-merge"
export function cn(...inputs) {
return twMerge(clsx(inputs))
}
}
export function safeJsonParse(value) {
if (!value || typeof value !== 'string') return null;
try {
return JSON.parse(value);
} catch {
return null;
}
}

View File

@@ -4,6 +4,9 @@ import App from './App.jsx'
import './index.css'
import 'katex/dist/katex.min.css'
// Initialize i18n
import './i18n/config.js'
// Clean up stale service workers on app load to prevent caching issues after builds
if ('serviceWorker' in navigator) {
navigator.serviceWorker.getRegistrations().then(registrations => {

View File

@@ -1,6 +1,7 @@
import { IS_PLATFORM } from "../constants/config";
// Utility function for authenticated API calls
export const authenticatedFetch = (url, options = {}) => {
const isPlatform = import.meta.env.VITE_IS_PLATFORM === 'true';
const token = localStorage.getItem('auth-token');
const defaultHeaders = {};
@@ -10,7 +11,7 @@ export const authenticatedFetch = (url, options = {}) => {
defaultHeaders['Content-Type'] = 'application/json';
}
if (!isPlatform && token) {
if (!IS_PLATFORM && token) {
defaultHeaders['Authorization'] = `Bearer ${token}`;
}
@@ -79,8 +80,8 @@ export const api = {
authenticatedFetch(`/api/codex/sessions/${sessionId}`, {
method: 'DELETE',
}),
deleteProject: (projectName) =>
authenticatedFetch(`/api/projects/${projectName}`, {
deleteProject: (projectName, force = false) =>
authenticatedFetch(`/api/projects/${projectName}${force ? '?force=true' : ''}`, {
method: 'DELETE',
}),
createProject: (path) =>
@@ -158,6 +159,12 @@ export const api = {
return authenticatedFetch(`/api/browse-filesystem?${params}`);
},
createFolder: (folderPath) =>
authenticatedFetch('/api/create-folder', {
method: 'POST',
body: JSON.stringify({ path: folderPath }),
}),
// User endpoints
user: {
gitConfig: () => authenticatedFetch('/api/user/git-config'),

View File

@@ -1,94 +0,0 @@
import { useState, useEffect, useRef } from 'react';
export function useWebSocket() {
const [ws, setWs] = useState(null);
const [messages, setMessages] = useState([]);
const [isConnected, setIsConnected] = useState(false);
const reconnectTimeoutRef = useRef(null);
useEffect(() => {
connect();
return () => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
if (ws) {
ws.close();
}
};
}, []); // Keep dependency array but add proper cleanup
const connect = async () => {
try {
const isPlatform = import.meta.env.VITE_IS_PLATFORM === 'true';
// Construct WebSocket URL
let wsUrl;
if (isPlatform) {
// Platform mode: Use same domain as the page (goes through proxy)
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
wsUrl = `${protocol}//${window.location.host}/ws`;
} else {
// OSS mode: Connect to same host:port that served the page
const token = localStorage.getItem('auth-token');
if (!token) {
console.warn('No authentication token found for WebSocket connection');
return;
}
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
wsUrl = `${protocol}//${window.location.host}/ws?token=${encodeURIComponent(token)}`;
}
const websocket = new WebSocket(wsUrl);
websocket.onopen = () => {
setIsConnected(true);
setWs(websocket);
};
websocket.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
setMessages(prev => [...prev, data]);
} catch (error) {
console.error('Error parsing WebSocket message:', error);
}
};
websocket.onclose = () => {
setIsConnected(false);
setWs(null);
// Attempt to reconnect after 3 seconds
reconnectTimeoutRef.current = setTimeout(() => {
connect();
}, 3000);
};
websocket.onerror = (error) => {
console.error('WebSocket error:', error);
};
} catch (error) {
console.error('Error creating WebSocket connection:', error);
}
};
const sendMessage = (message) => {
if (ws && isConnected) {
ws.send(JSON.stringify(message));
} else {
console.warn('WebSocket not connected');
}
};
return {
ws,
sendMessage,
messages,
isConnected
};
}

1
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

20
tsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"forceConsistentCasingInFileNames": true,
"allowJs": true,
// "checkJs": true,
"types": ["vite/client"]
},
"include": ["src", "shared", "vite.config.js"]
}

View File

@@ -45,4 +45,4 @@ export default defineConfig(({ command, mode }) => {
}
}
}
})
})