Merge branch 'main' into feat/add-highlight-to-file-mentions

This commit is contained in:
viper151
2026-01-25 23:09:16 +01:00
committed by GitHub
43 changed files with 4472 additions and 693 deletions

View File

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

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>

377
package-lock.json generated
View File

@@ -39,6 +39,8 @@
"express": "^4.18.2", "express": "^4.18.2",
"fuse.js": "^7.0.0", "fuse.js": "^7.0.0",
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
"i18next": "^25.7.4",
"i18next-browser-languagedetector": "^8.2.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"katex": "^0.16.25", "katex": "^0.16.25",
"lucide-react": "^0.515.0", "lucide-react": "^0.515.0",
@@ -49,8 +51,10 @@
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-dropzone": "^14.2.3", "react-dropzone": "^14.2.3",
"react-i18next": "^16.5.3",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-router-dom": "^6.8.1", "react-router-dom": "^6.8.1",
"react-syntax-highlighter": "^15.6.1",
"rehype-katex": "^7.0.1", "rehype-katex": "^7.0.1",
"remark-gfm": "^4.0.0", "remark-gfm": "^4.0.0",
"remark-math": "^6.0.0", "remark-math": "^6.0.0",
@@ -359,9 +363,9 @@
} }
}, },
"node_modules/@babel/runtime": { "node_modules/@babel/runtime": {
"version": "7.27.6", "version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz", "resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.28.6.tgz",
"integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==", "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
@@ -5057,6 +5061,19 @@
"reusify": "^1.0.4" "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": { "node_modules/file-selector": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz", "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz",
@@ -5136,6 +5153,14 @@
"url": "https://github.com/sponsors/isaacs" "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": { "node_modules/forwarded": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -5756,6 +5781,30 @@
"url": "https://opencollective.com/unified" "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": { "node_modules/html-url-attributes": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
@@ -5837,6 +5886,46 @@
"ms": "^2.0.0" "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": { "node_modules/iconv-lite": {
"version": "0.4.24", "version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
@@ -6569,6 +6658,20 @@
"loose-envify": "cli.js" "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": { "node_modules/lru-cache": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -8101,9 +8204,9 @@
} }
}, },
"node_modules/node-pty": { "node_modules/node-pty": {
"version": "1.1.0-beta9", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0-beta9.tgz", "resolved": "https://registry.npmmirror.com/node-pty/-/node-pty-1.1.0.tgz",
"integrity": "sha512-/Ue38pvXJdgRZ3+me1FgfglLd301GhJN0NStiotdt61tm43N5htUyR/IXOUzOKuNaFmCwIhy6nwb77Ky41LMbw==", "integrity": "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -8840,6 +8943,15 @@
"node": ">=10" "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": { "node_modules/proc-log": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/proc-log/-/proc-log-4.2.0.tgz", "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-4.2.0.tgz",
@@ -9086,6 +9198,33 @@
"react": ">= 16.8 || 18.0.0" "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": { "node_modules/react-is": {
"version": "16.13.1", "version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
@@ -9161,6 +9300,23 @@
"react-dom": ">=16.8" "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": { "node_modules/read-cache": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@@ -9197,6 +9353,197 @@
"url": "https://paulmillr.com/funding/" "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": { "node_modules/rehype-katex": {
"version": "7.0.1", "version": "7.0.1",
"resolved": "https://registry.npmjs.org/rehype-katex/-/rehype-katex-7.0.1.tgz", "resolved": "https://registry.npmjs.org/rehype-katex/-/rehype-katex-7.0.1.tgz",
@@ -11536,6 +11883,15 @@
"node": "^12.20.0 || ^14.13.1 || >=16.0.0" "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": { "node_modules/util-deprecate": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@@ -11708,6 +12064,15 @@
"url": "https://github.com/sponsors/jonschlinkert" "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": { "node_modules/w3c-keyname": {
"version": "2.2.8", "version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",

View File

@@ -71,6 +71,8 @@
"express": "^4.18.2", "express": "^4.18.2",
"fuse.js": "^7.0.0", "fuse.js": "^7.0.0",
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
"i18next": "^25.7.4",
"i18next-browser-languagedetector": "^8.2.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"katex": "^0.16.25", "katex": "^0.16.25",
"lucide-react": "^0.515.0", "lucide-react": "^0.515.0",
@@ -81,8 +83,10 @@
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-dropzone": "^14.2.3", "react-dropzone": "^14.2.3",
"react-i18next": "^16.5.3",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-router-dom": "^6.8.1", "react-router-dom": "^6.8.1",
"react-syntax-highlighter": "^15.6.1",
"rehype-katex": "^7.0.1", "rehype-katex": "^7.0.1",
"remark-gfm": "^4.0.0", "remark-gfm": "^4.0.0",
"remark-math": "^6.0.0", "remark-math": "^6.0.0",

View File

@@ -80,6 +80,20 @@ import { validateApiKey, authenticateToken, authenticateWebSocket } from './midd
// File system watcher for projects folder // File system watcher for projects folder
let projectsWatcher = null; let projectsWatcher = null;
const connectedClients = new Set(); 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 // Setup file system watcher for Claude projects folder using chokidar
async function setupProjectsWatcher() { async function setupProjectsWatcher() {
@@ -117,13 +131,19 @@ async function setupProjectsWatcher() {
const debouncedUpdate = async (eventType, filePath) => { const debouncedUpdate = async (eventType, filePath) => {
clearTimeout(debounceTimer); clearTimeout(debounceTimer);
debounceTimer = setTimeout(async () => { debounceTimer = setTimeout(async () => {
// Prevent reentrant calls
if (isGetProjectsRunning) {
return;
}
try { try {
isGetProjectsRunning = true;
// Clear project directory cache when files change // Clear project directory cache when files change
clearProjectDirectoryCache(); clearProjectDirectoryCache();
// Get updated projects list // Get updated projects list
const updatedProjects = await getProjects(); const updatedProjects = await getProjects(broadcastProgress);
// Notify all connected clients about the project changes // Notify all connected clients about the project changes
const updateMessage = JSON.stringify({ const updateMessage = JSON.stringify({
@@ -142,6 +162,8 @@ async function setupProjectsWatcher() {
} catch (error) { } catch (error) {
console.error('[ERROR] Error handling project changes:', error); console.error('[ERROR] Error handling project changes:', error);
} finally {
isGetProjectsRunning = false;
} }
}, 300); // 300ms debounce (slightly faster than before) }, 300); // 300ms debounce (slightly faster than before)
}; };
@@ -366,7 +388,7 @@ app.post('/api/system/update', authenticateToken, async (req, res) => {
app.get('/api/projects', authenticateToken, async (req, res) => { app.get('/api/projects', authenticateToken, async (req, res) => {
try { try {
const projects = await getProjects(); const projects = await getProjects(broadcastProgress);
res.json(projects); res.json(projects);
} catch (error) { } catch (error) {
res.status(500).json({ error: error.message }); res.status(500).json({ error: error.message });
@@ -433,11 +455,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) => { app.delete('/api/projects/:projectName', authenticateToken, async (req, res) => {
try { try {
const { projectName } = req.params; const { projectName } = req.params;
await deleteProject(projectName); const force = req.query.force === 'true';
await deleteProject(projectName, force);
res.json({ success: true }); res.json({ success: true });
} catch (error) { } catch (error) {
res.status(500).json({ error: error.message }); res.status(500).json({ error: error.message });
@@ -496,7 +519,13 @@ app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
name: item.name, name: item.name,
type: 'directory' 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 // Add common directories if browsing home directory
const suggestions = []; const suggestions = [];

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 claudeDir = path.join(os.homedir(), '.claude', 'projects');
const config = await loadProjectConfig(); const config = await loadProjectConfig();
const projects = []; const projects = [];
const existingProjects = new Set(); const existingProjects = new Set();
let totalProjects = 0;
let processedProjects = 0;
let directories = [];
try { try {
// Check if the .claude/projects directory exists // Check if the .claude/projects directory exists
await fs.access(claudeDir); await fs.access(claudeDir);
// First, get existing Claude projects from the file system // First, get existing Claude projects from the file system
const entries = await fs.readdir(claudeDir, { withFileTypes: true }); const entries = await fs.readdir(claudeDir, { withFileTypes: true });
directories = entries.filter(e => e.isDirectory());
for (const entry of entries) {
if (entry.isDirectory()) { // Build set of existing project names for later
existingProjects.add(entry.name); 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); const projectPath = path.join(claudeDir, entry.name);
// Extract actual project directory from JSONL sessions // Extract actual project directory from JSONL sessions
@@ -460,20 +484,35 @@ async function getProjects() {
status: 'error' status: 'error'
}; };
} }
projects.push(project); projects.push(project);
}
} }
} catch (error) { } catch (error) {
// If the directory doesn't exist (ENOENT), that's okay - just continue with empty projects // If the directory doesn't exist (ENOENT), that's okay - just continue with empty projects
if (error.code !== 'ENOENT') { if (error.code !== 'ENOENT') {
console.error('Error reading projects directory:', error); 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 // Add manually configured projects that don't exist as folders yet
for (const [projectName, projectConfig] of Object.entries(config)) { for (const [projectName, projectConfig] of Object.entries(config)) {
if (!existingProjects.has(projectName) && projectConfig.manuallyAdded) { 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 // Use the original path if available, otherwise extract from potential sessions
let actualProjectDir = projectConfig.originalPath; let actualProjectDir = projectConfig.originalPath;
@@ -541,7 +580,16 @@ async function getProjects() {
projects.push(project); projects.push(project);
} }
} }
// Emit completion after all projects (including manual) are processed
if (progressCallback) {
progressCallback({
phase: 'complete',
current: totalProjects,
total: totalProjects
});
}
return projects; return projects;
} }
@@ -978,25 +1026,56 @@ async function isProjectEmpty(projectName) {
} }
} }
// Delete an empty project // Delete a project (force=true to delete even with sessions)
async function deleteProject(projectName) { async function deleteProject(projectName, force = false) {
const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName); const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName);
try { try {
// First check if the project is empty
const isEmpty = await isProjectEmpty(projectName); const isEmpty = await isProjectEmpty(projectName);
if (!isEmpty) { if (!isEmpty && !force) {
throw new Error('Cannot delete project with existing sessions'); 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(); 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]; delete config[projectName];
await saveProjectConfig(config); await saveProjectConfig(config);
return true; return true;
} catch (error) { } catch (error) {
console.error(`Error deleting project ${projectName}:`, 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) // Add a project manually to the config (without creating folders)
async function addProjectManually(projectPath, displayName = null) { async function addProjectManually(projectPath, displayName = null) {
const absolutePath = path.resolve(projectPath); const absolutePath = path.resolve(projectPath);
try { try {
// Check if the path exists // Check if the path exists
await fs.access(absolutePath); await fs.access(absolutePath);
} catch (error) { } catch (error) {
throw new Error(`Path does not exist: ${absolutePath}`); throw new Error(`Path does not exist: ${absolutePath}`);
} }
// Generate project name (encode path for use as directory name) // Generate project name (encode path for use as directory name)
const projectName = absolutePath.replace(/\//g, '-'); const projectName = absolutePath.replace(/\//g, '-');
// Check if project already exists in config // Check if project already exists in config
const config = await loadProjectConfig(); const config = await loadProjectConfig();
const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName); 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 // Allow adding projects even if the directory exists - this enables tracking
// existing Claude Code or Cursor projects in the UI // existing Claude Code or Cursor projects in the UI
// Add to config as manually added project // Add to config as manually added project
config[projectName] = { config[projectName] = {
manuallyAdded: true, manuallyAdded: true,
originalPath: absolutePath originalPath: absolutePath
}; };
if (displayName) { if (displayName) {
config[projectName].displayName = displayName; config[projectName].displayName = displayName;
} }
@@ -1166,7 +1245,8 @@ async function getCursorSessions(projectPath) {
// Fetch Codex sessions for a given project path // Fetch Codex sessions for a given project path
async function getCodexSessions(projectPath) { async function getCodexSessions(projectPath, options = {}) {
const { limit = 5 } = options;
try { try {
const codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions'); const codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions');
const sessions = []; const sessions = [];
@@ -1231,8 +1311,8 @@ async function getCodexSessions(projectPath) {
// Sort sessions by last activity (newest first) // Sort sessions by last activity (newest first)
sessions.sort((a, b) => new Date(b.lastActivity) - new Date(a.lastActivity)); sessions.sort((a, b) => new Date(b.lastActivity) - new Date(a.lastActivity));
// Return only the first 5 sessions for performance // Return limited sessions for performance (0 = unlimited for deletion)
return sessions.slice(0, 5); return limit > 0 ? sessions.slice(0, limit) : sessions;
} catch (error) { } catch (error) {
console.error('Error fetching Codex sessions:', error); console.error('Error fetching Codex sessions:', error);

View File

@@ -18,7 +18,7 @@
* Handles both existing sessions (with real IDs) and new sessions (with temporary IDs). * 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 { BrowserRouter as Router, Routes, Route, useNavigate, useParams } from 'react-router-dom';
import { Settings as SettingsIcon, Sparkles } from 'lucide-react'; import { Settings as SettingsIcon, Sparkles } from 'lucide-react';
import Sidebar from './components/Sidebar'; import Sidebar from './components/Sidebar';
@@ -36,12 +36,15 @@ import ProtectedRoute from './components/ProtectedRoute';
import { useVersionCheck } from './hooks/useVersionCheck'; import { useVersionCheck } from './hooks/useVersionCheck';
import useLocalStorage from './hooks/useLocalStorage'; import useLocalStorage from './hooks/useLocalStorage';
import { api, authenticatedFetch } from './utils/api'; import { api, authenticatedFetch } from './utils/api';
import { I18nextProvider, useTranslation } from 'react-i18next';
import i18n from './i18n/config.js';
// Main App component with routing // Main App component with routing
function AppContent() { function AppContent() {
const navigate = useNavigate(); const navigate = useNavigate();
const { sessionId } = useParams(); const { sessionId } = useParams();
const { t } = useTranslation('common');
const { updateAvailable, latestVersion, currentVersion, releaseInfo } = useVersionCheck('siteboon', 'claudecodeui'); const { updateAvailable, latestVersion, currentVersion, releaseInfo } = useVersionCheck('siteboon', 'claudecodeui');
const [showVersionModal, setShowVersionModal] = useState(false); const [showVersionModal, setShowVersionModal] = useState(false);
@@ -53,6 +56,7 @@ function AppContent() {
const [isMobile, setIsMobile] = useState(false); const [isMobile, setIsMobile] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false);
const [isLoadingProjects, setIsLoadingProjects] = useState(true); const [isLoadingProjects, setIsLoadingProjects] = useState(true);
const [loadingProgress, setLoadingProgress] = useState(null); // { phase, current, total, currentProject }
const [isInputFocused, setIsInputFocused] = useState(false); const [isInputFocused, setIsInputFocused] = useState(false);
const [showSettings, setShowSettings] = useState(false); const [showSettings, setShowSettings] = useState(false);
const [settingsInitialTab, setSettingsInitialTab] = useState('agents'); const [settingsInitialTab, setSettingsInitialTab] = useState('agents');
@@ -78,7 +82,10 @@ function AppContent() {
const [externalMessageUpdate, setExternalMessageUpdate] = useState(0); const [externalMessageUpdate, setExternalMessageUpdate] = useState(0);
const { ws, sendMessage, messages } = useWebSocketContext(); const { ws, sendMessage, messages } = useWebSocketContext();
// Ref to track loading progress timeout for cleanup
const loadingProgressTimeoutRef = useRef(null);
// Detect if running as PWA // Detect if running as PWA
const [isPWA, setIsPWA] = useState(false); const [isPWA, setIsPWA] = useState(false);
@@ -170,7 +177,23 @@ function AppContent() {
useEffect(() => { useEffect(() => {
if (messages.length > 0) { if (messages.length > 0) {
const latestMessage = messages[messages.length - 1]; const latestMessage = messages[messages.length - 1];
// 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') { if (latestMessage.type === 'projects_updated') {
// External Session Update Detection: Check if the changed file is the current session's JSONL // External Session Update Detection: Check if the changed file is the current session's JSONL
@@ -247,6 +270,13 @@ function AppContent() {
} }
} }
} }
return () => {
if (loadingProgressTimeoutRef.current) {
clearTimeout(loadingProgressTimeoutRef.current);
loadingProgressTimeoutRef.current = null;
}
};
}, [messages, selectedProject, selectedSession, activeSessions]); }, [messages, selectedProject, selectedSession, activeSessions]);
const fetchProjects = async () => { const fetchProjects = async () => {
@@ -550,6 +580,7 @@ function AppContent() {
// Version Upgrade Modal Component // Version Upgrade Modal Component
const VersionUpgradeModal = () => { const VersionUpgradeModal = () => {
const { t } = useTranslation('common');
const [isUpdating, setIsUpdating] = useState(false); const [isUpdating, setIsUpdating] = useState(false);
const [updateOutput, setUpdateOutput] = useState(''); const [updateOutput, setUpdateOutput] = useState('');
const [updateError, setUpdateError] = useState(''); const [updateError, setUpdateError] = useState('');
@@ -610,7 +641,7 @@ function AppContent() {
<button <button
className="fixed inset-0 bg-black/50 backdrop-blur-sm" className="fixed inset-0 bg-black/50 backdrop-blur-sm"
onClick={() => setShowVersionModal(false)} onClick={() => setShowVersionModal(false)}
aria-label="Close version upgrade modal" aria-label={t('versionUpdate.ariaLabels.closeModal')}
/> />
{/* Modal */} {/* Modal */}
@@ -624,9 +655,9 @@ function AppContent() {
</svg> </svg>
</div> </div>
<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"> <p className="text-sm text-gray-500 dark:text-gray-400">
{releaseInfo?.title || 'A new version is ready'} {releaseInfo?.title || t('versionUpdate.newVersionReady')}
</p> </p>
</div> </div>
</div> </div>
@@ -643,11 +674,11 @@ function AppContent() {
{/* Version Info */} {/* Version Info */}
<div className="space-y-3"> <div className="space-y-3">
<div className="flex justify-between items-center p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg"> <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> <span className="text-sm text-gray-900 dark:text-white font-mono">{currentVersion}</span>
</div> </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"> <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> <span className="text-sm text-blue-900 dark:text-blue-100 font-mono">{latestVersion}</span>
</div> </div>
</div> </div>
@@ -656,7 +687,7 @@ function AppContent() {
{releaseInfo?.body && ( {releaseInfo?.body && (
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center justify-between"> <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 && ( {releaseInfo?.htmlUrl && (
<a <a
href={releaseInfo.htmlUrl} href={releaseInfo.htmlUrl}
@@ -664,7 +695,7 @@ function AppContent() {
rel="noopener noreferrer" 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" 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"> <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" /> <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> </svg>
@@ -682,7 +713,7 @@ function AppContent() {
{/* Update Output */} {/* Update Output */}
{updateOutput && ( {updateOutput && (
<div className="space-y-2"> <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"> <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> <pre className="text-xs text-green-400 font-mono whitespace-pre-wrap">{updateOutput}</pre>
</div> </div>
@@ -692,14 +723,14 @@ function AppContent() {
{/* Upgrade Instructions */} {/* Upgrade Instructions */}
{!isUpdating && !updateOutput && ( {!isUpdating && !updateOutput && (
<div className="space-y-3"> <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"> <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"> <code className="text-sm text-gray-800 dark:text-gray-200 font-mono">
git checkout main && git pull && npm install git checkout main && git pull && npm install
</code> </code>
</div> </div>
<p className="text-xs text-gray-600 dark:text-gray-400"> <p className="text-xs text-gray-600 dark:text-gray-400">
Or click "Update Now" to run the update automatically. {t('versionUpdate.manualUpgradeHint')}
</p> </p>
</div> </div>
)} )}
@@ -710,7 +741,7 @@ function AppContent() {
onClick={() => setShowVersionModal(false)} 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" 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> </button>
{!updateOutput && ( {!updateOutput && (
<> <>
@@ -720,7 +751,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" 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>
<button <button
onClick={handleUpdateNow} onClick={handleUpdateNow}
@@ -730,10 +761,10 @@ function AppContent() {
{isUpdating ? ( {isUpdating ? (
<> <>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" /> <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> </button>
</> </>
@@ -765,6 +796,7 @@ function AppContent() {
onSessionDelete={handleSessionDelete} onSessionDelete={handleSessionDelete}
onProjectDelete={handleProjectDelete} onProjectDelete={handleProjectDelete}
isLoading={isLoadingProjects} isLoading={isLoadingProjects}
loadingProgress={loadingProgress}
onRefresh={handleSidebarRefresh} onRefresh={handleSidebarRefresh}
onShowSettings={() => setShowSettings(true)} onShowSettings={() => setShowSettings(true)}
updateAvailable={updateAvailable} updateAvailable={updateAvailable}
@@ -783,8 +815,8 @@ function AppContent() {
<button <button
onClick={() => setSidebarVisible(true)} onClick={() => setSidebarVisible(true)}
className="p-2 hover:bg-accent rounded-md transition-colors duration-200 group" className="p-2 hover:bg-accent rounded-md transition-colors duration-200 group"
aria-label="Show sidebar" aria-label={t('versionUpdate.ariaLabels.showSidebar')}
title="Show sidebar" title={t('versionUpdate.ariaLabels.showSidebar')}
> >
<svg <svg
className="w-5 h-5 text-foreground group-hover:scale-110 transition-transform" className="w-5 h-5 text-foreground group-hover:scale-110 transition-transform"
@@ -800,8 +832,8 @@ function AppContent() {
<button <button
onClick={() => setShowSettings(true)} onClick={() => setShowSettings(true)}
className="p-2 hover:bg-accent rounded-md transition-colors duration-200" className="p-2 hover:bg-accent rounded-md transition-colors duration-200"
aria-label="Settings" aria-label={t('versionUpdate.ariaLabels.settings')}
title="Settings" title={t('versionUpdate.ariaLabels.settings')}
> >
<SettingsIcon className="w-5 h-5 text-muted-foreground hover:text-foreground transition-colors" /> <SettingsIcon className="w-5 h-5 text-muted-foreground hover:text-foreground transition-colors" />
</button> </button>
@@ -811,8 +843,8 @@ function AppContent() {
<button <button
onClick={() => setShowVersionModal(true)} onClick={() => setShowVersionModal(true)}
className="relative p-2 hover:bg-accent rounded-md transition-colors duration-200" className="relative p-2 hover:bg-accent rounded-md transition-colors duration-200"
aria-label="Update available" aria-label={t('versionUpdate.ariaLabels.updateAvailable')}
title="Update available" title={t('versionUpdate.ariaLabels.updateAvailable')}
> >
<Sparkles className="w-5 h-5 text-blue-500" /> <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" /> <span className="absolute top-1 right-1 w-2 h-2 bg-blue-500 rounded-full animate-pulse" />
@@ -840,7 +872,7 @@ function AppContent() {
e.stopPropagation(); e.stopPropagation();
setSidebarOpen(false); setSidebarOpen(false);
}} }}
aria-label="Close sidebar" aria-label={t('versionUpdate.ariaLabels.closeSidebar')}
/> />
<div <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 ${ 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 ${
@@ -859,6 +891,7 @@ function AppContent() {
onSessionDelete={handleSessionDelete} onSessionDelete={handleSessionDelete}
onProjectDelete={handleProjectDelete} onProjectDelete={handleProjectDelete}
isLoading={isLoadingProjects} isLoading={isLoadingProjects}
loadingProgress={loadingProgress}
onRefresh={handleSidebarRefresh} onRefresh={handleSidebarRefresh}
onShowSettings={() => setShowSettings(true)} onShowSettings={() => setShowSettings(true)}
updateAvailable={updateAvailable} updateAvailable={updateAvailable}
@@ -950,24 +983,26 @@ function AppContent() {
// Root App component with router // Root App component with router
function App() { function App() {
return ( return (
<ThemeProvider> <I18nextProvider i18n={i18n}>
<AuthProvider> <ThemeProvider>
<WebSocketProvider> <AuthProvider>
<TasksSettingsProvider> <WebSocketProvider>
<TaskMasterProvider> <TasksSettingsProvider>
<ProtectedRoute> <TaskMasterProvider>
<Router> <ProtectedRoute>
<Routes> <Router>
<Route path="/" element={<AppContent />} /> <Routes>
<Route path="/session/:sessionId" element={<AppContent />} /> <Route path="/" element={<AppContent />} />
</Routes> <Route path="/session/:sessionId" element={<AppContent />} />
</Router> </Routes>
</ProtectedRoute> </Router>
</TaskMasterProvider> </ProtectedRoute>
</TasksSettingsProvider> </TaskMasterProvider>
</WebSocketProvider> </TasksSettingsProvider>
</AuthProvider> </WebSocketProvider>
</ThemeProvider> </AuthProvider>
</ThemeProvider>
</I18nextProvider>
); );
} }

View File

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

View File

@@ -21,6 +21,8 @@ import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math'; import remarkMath from 'remark-math';
import rehypeKatex from 'rehype-katex'; import rehypeKatex from 'rehype-katex';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { useDropzone } from 'react-dropzone'; import { useDropzone } from 'react-dropzone';
import TodoList from './TodoList'; import TodoList from './TodoList';
import ClaudeLogo from './ClaudeLogo.jsx'; import ClaudeLogo from './ClaudeLogo.jsx';
@@ -28,11 +30,13 @@ import CursorLogo from './CursorLogo.jsx';
import CodexLogo from './CodexLogo.jsx'; import CodexLogo from './CodexLogo.jsx';
import NextTaskBanner from './NextTaskBanner.jsx'; import NextTaskBanner from './NextTaskBanner.jsx';
import { useTasksSettings } from '../contexts/TasksSettingsContext'; import { useTasksSettings } from '../contexts/TasksSettingsContext';
import { useTranslation } from 'react-i18next';
import ClaudeStatus from './ClaudeStatus'; import ClaudeStatus from './ClaudeStatus';
import TokenUsagePie from './TokenUsagePie'; import TokenUsagePie from './TokenUsagePie';
import { MicButton } from './MicButton.jsx'; import { MicButton } from './MicButton.jsx';
import { api, authenticatedFetch } from '../utils/api'; import { api, authenticatedFetch } from '../utils/api';
import ThinkingModeSelector, { thinkingModes } from './ThinkingModeSelector.jsx';
import Fuse from 'fuse.js'; import Fuse from 'fuse.js';
import CommandMenu from './CommandMenu'; import CommandMenu from './CommandMenu';
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../shared/modelConstants'; import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../shared/modelConstants';
@@ -338,25 +342,31 @@ function grantClaudeToolPermission(entry) {
} }
// Common markdown components to ensure consistent rendering (tables, inline code, links, etc.) // Common markdown components to ensure consistent rendering (tables, inline code, links, etc.)
const markdownComponents = { const CodeBlock = ({ node, inline, className, children, ...props }) => {
code: ({ node, inline, className, children, ...props }) => { const { t } = useTranslation('chat');
const [copied, setCopied] = React.useState(false); const [copied, setCopied] = React.useState(false);
const raw = Array.isArray(children) ? children.join('') : String(children ?? ''); const raw = Array.isArray(children) ? children.join('') : String(children ?? '');
const looksMultiline = /[\r\n]/.test(raw); const looksMultiline = /[\r\n]/.test(raw);
const inlineDetected = inline || (node && node.type === 'inlineCode'); const inlineDetected = inline || (node && node.type === 'inlineCode');
const shouldInline = inlineDetected || !looksMultiline; // fallback to inline if single-line const shouldInline = inlineDetected || !looksMultiline; // fallback to inline if single-line
if (shouldInline) {
return ( // Inline code rendering
<code if (shouldInline) {
className={`font-mono text-[0.9em] px-1.5 py-0.5 rounded-md bg-gray-100 text-gray-900 border border-gray-200 dark:bg-gray-800/60 dark:text-gray-100 dark:border-gray-700 whitespace-pre-wrap break-words ${ return (
className || '' <code
}`} className={`font-mono text-[0.9em] px-1.5 py-0.5 rounded-md bg-gray-100 text-gray-900 border border-gray-200 dark:bg-gray-800/60 dark:text-gray-100 dark:border-gray-700 whitespace-pre-wrap break-words ${
{...props} className || ''
> }`}
{children} {...props}
</code> >
); {children}
} </code>
);
}
// Extract language from className (format: language-xxx)
const match = /language-(\w+)/.exec(className || '');
const language = match ? match[1] : 'text';
const textToCopy = raw; const textToCopy = raw;
const handleCopy = () => { const handleCopy = () => {
@@ -392,21 +402,30 @@ const markdownComponents = {
} catch {} } catch {}
}; };
// Code block with syntax highlighting
return ( return (
<div className="relative group my-2"> <div className="relative group my-2">
{/* Language label */}
{language && language !== 'text' && (
<div className="absolute top-2 left-3 z-10 text-xs text-gray-400 font-medium uppercase">
{language}
</div>
)}
{/* Copy button */}
<button <button
type="button" type="button"
onClick={handleCopy} onClick={handleCopy}
className="absolute top-2 right-2 z-10 opacity-0 group-hover:opacity-100 focus:opacity-100 active:opacity-100 transition-opacity text-xs px-2 py-1 rounded-md bg-gray-700/80 hover:bg-gray-700 text-white border border-gray-600" className="absolute top-2 right-2 z-10 opacity-0 group-hover:opacity-100 focus:opacity-100 active:opacity-100 transition-opacity text-xs px-2 py-1 rounded-md bg-gray-700/80 hover:bg-gray-700 text-white border border-gray-600"
title={copied ? 'Copied' : 'Copy code'} title={copied ? t('codeBlock.copied') : t('codeBlock.copyCode')}
aria-label={copied ? 'Copied' : 'Copy code'} aria-label={copied ? t('codeBlock.copied') : t('codeBlock.copyCode')}
> >
{copied ? ( {copied ? (
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<svg className="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor"> <svg className="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" /> <path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg> </svg>
Copied {t('codeBlock.copied')}
</span> </span>
) : ( ) : (
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
@@ -414,18 +433,36 @@ const markdownComponents = {
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect> <rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"></path> <path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"></path>
</svg> </svg>
Copy {t('codeBlock.copy')}
</span> </span>
)} )}
</button> </button>
<pre className="bg-gray-900 dark:bg-gray-900 border border-gray-700/40 rounded-lg p-3 overflow-x-auto">
<code className={`text-gray-100 dark:text-gray-100 text-sm font-mono ${className || ''}`} {...props}> {/* Syntax highlighted code */}
{children} <SyntaxHighlighter
</code> language={language}
</pre> style={oneDark}
customStyle={{
margin: 0,
borderRadius: '0.5rem',
fontSize: '0.875rem',
padding: language && language !== 'text' ? '2rem 1rem 1rem 1rem' : '1rem',
}}
codeTagProps={{
style: {
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
}
}}
>
{raw}
</SyntaxHighlighter>
</div> </div>
); );
}, };
// Common markdown components to ensure consistent rendering (tables, inline code, links, etc.)
const markdownComponents = {
code: CodeBlock,
blockquote: ({ children }) => ( blockquote: ({ children }) => (
<blockquote className="border-l-4 border-gray-300 dark:border-gray-600 pl-4 italic text-gray-600 dark:text-gray-400 my-2"> <blockquote className="border-l-4 border-gray-300 dark:border-gray-600 pl-4 italic text-gray-600 dark:text-gray-400 my-2">
{children} {children}
@@ -458,6 +495,7 @@ const markdownComponents = {
// Memoized message component to prevent unnecessary re-renders // Memoized message component to prevent unnecessary re-renders
const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFileOpen, onShowSettings, onGrantToolPermission, autoExpandTools, showRawParameters, showThinking, selectedProject, provider }) => { const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFileOpen, onShowSettings, onGrantToolPermission, autoExpandTools, showRawParameters, showThinking, selectedProject, provider }) => {
const { t } = useTranslation('chat');
const isGrouped = prevMessage && prevMessage.type === message.type && const isGrouped = prevMessage && prevMessage.type === message.type &&
((prevMessage.type === 'assistant') || ((prevMessage.type === 'assistant') ||
(prevMessage.type === 'user') || (prevMessage.type === 'user') ||
@@ -560,7 +598,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
</div> </div>
)} )}
<div className="text-sm font-medium text-gray-900 dark:text-white"> <div className="text-sm font-medium text-gray-900 dark:text-white">
{message.type === 'error' ? 'Error' : message.type === 'tool' ? 'Tool' : ((localStorage.getItem('selected-provider') || 'claude') === 'cursor' ? 'Cursor' : (localStorage.getItem('selected-provider') || 'claude') === 'codex' ? 'Codex' : 'Claude')} {message.type === 'error' ? t('messageTypes.error') : message.type === 'tool' ? t('messageTypes.tool') : ((localStorage.getItem('selected-provider') || 'claude') === 'cursor' ? t('messageTypes.cursor') : (localStorage.getItem('selected-provider') || 'claude') === 'codex' ? t('messageTypes.codex') : t('messageTypes.claude'))}
</div> </div>
</div> </div>
)} )}
@@ -588,8 +626,8 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
const input = JSON.parse(message.toolInput); const input = JSON.parse(message.toolInput);
return ( return (
<span className="font-mono truncate flex-1 min-w-0"> <span className="font-mono truncate flex-1 min-w-0">
{input.pattern && <span>pattern: <span className="text-blue-600 dark:text-blue-400">{input.pattern}</span></span>} {input.pattern && <span>{t('search.pattern')} <span className="text-blue-600 dark:text-blue-400">{input.pattern}</span></span>}
{input.path && <span className="ml-2">in: {input.path}</span>} {input.path && <span className="ml-2">{t('search.in')} {input.path}</span>}
</span> </span>
); );
} catch (e) { } catch (e) {
@@ -602,7 +640,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
href={`#tool-result-${message.toolId}`} href={`#tool-result-${message.toolId}`}
className="flex-shrink-0 text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 font-medium transition-colors flex items-center gap-1" className="flex-shrink-0 text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 font-medium transition-colors flex items-center gap-1"
> >
<span>results</span> <span>{t('tools.searchResults')}</span>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg> </svg>
@@ -646,7 +684,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
onShowSettings(); onShowSettings();
}} }}
className="p-2 rounded-lg hover:bg-white/60 dark:hover:bg-gray-800/60 transition-all duration-200 group/btn backdrop-blur-sm" className="p-2 rounded-lg hover:bg-white/60 dark:hover:bg-gray-800/60 transition-all duration-200 group/btn backdrop-blur-sm"
title="Tool Settings" title={t('tools.settings')}
> >
<svg className="w-4 h-4 text-gray-600 dark:text-gray-400 group-hover/btn:text-blue-600 dark:group-hover/btn:text-blue-400 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-4 h-4 text-gray-600 dark:text-gray-400 group-hover/btn:text-blue-600 dark:group-hover/btn:text-blue-400 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={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 strokeLinecap="round" strokeLinejoin="round" strokeWidth={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" />
@@ -1824,6 +1862,7 @@ const ImageAttachment = ({ file, onRemove, uploadProgress, error }) => {
// This ensures uninterrupted chat experience by pausing sidebar refreshes during conversations. // This ensures uninterrupted chat experience by pausing sidebar refreshes during conversations.
function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, messages, onFileOpen, onInputFocusChange, onSessionActive, onSessionInactive, onSessionProcessing, onSessionNotProcessing, processingSessions, onReplaceTemporarySession, onNavigateToSession, onShowSettings, autoExpandTools, showRawParameters, showThinking, autoScrollToBottom, sendByCtrlEnter, externalMessageUpdate, onTaskClick, onShowAllTasks }) { function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, messages, onFileOpen, onInputFocusChange, onSessionActive, onSessionInactive, onSessionProcessing, onSessionNotProcessing, processingSessions, onReplaceTemporarySession, onNavigateToSession, onShowSettings, autoExpandTools, showRawParameters, showThinking, autoScrollToBottom, sendByCtrlEnter, externalMessageUpdate, onTaskClick, onShowAllTasks }) {
const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings(); const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings();
const { t } = useTranslation('chat');
const [input, setInput] = useState(() => { const [input, setInput] = useState(() => {
if (typeof window !== 'undefined' && selectedProject) { if (typeof window !== 'undefined' && selectedProject) {
return safeLocalStorage.getItem(`draft_input_${selectedProject.name}`) || ''; return safeLocalStorage.getItem(`draft_input_${selectedProject.name}`) || '';
@@ -1890,6 +1929,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
const [slashPosition, setSlashPosition] = useState(-1); const [slashPosition, setSlashPosition] = useState(-1);
const [visibleMessageCount, setVisibleMessageCount] = useState(100); const [visibleMessageCount, setVisibleMessageCount] = useState(100);
const [claudeStatus, setClaudeStatus] = useState(null); const [claudeStatus, setClaudeStatus] = useState(null);
const [thinkingMode, setThinkingMode] = useState('none');
const [provider, setProvider] = useState(() => { const [provider, setProvider] = useState(() => {
return localStorage.getItem('selected-provider') || 'claude'; return localStorage.getItem('selected-provider') || 'claude';
}); });
@@ -4270,6 +4310,13 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
e.preventDefault(); e.preventDefault();
if (!input.trim() || isLoading || !selectedProject) return; if (!input.trim() || isLoading || !selectedProject) return;
// Apply thinking mode prefix if selected
let messageContent = input;
const selectedThinkingMode = thinkingModes.find(mode => mode.id === thinkingMode);
if (selectedThinkingMode && selectedThinkingMode.prefix) {
messageContent = `${selectedThinkingMode.prefix}: ${input}`;
}
// Upload images first if any // Upload images first if any
let uploadedImages = []; let uploadedImages = [];
if (attachedImages.length > 0) { if (attachedImages.length > 0) {
@@ -4358,7 +4405,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
// Send Cursor command (always use cursor-command; include resume/sessionId when replying) // Send Cursor command (always use cursor-command; include resume/sessionId when replying)
sendMessage({ sendMessage({
type: 'cursor-command', type: 'cursor-command',
command: input, command: messageContent,
sessionId: effectiveSessionId, sessionId: effectiveSessionId,
options: { options: {
// Prefer fullPath (actual cwd for project), fallback to path // Prefer fullPath (actual cwd for project), fallback to path
@@ -4375,7 +4422,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
// Send Codex command // Send Codex command
sendMessage({ sendMessage({
type: 'codex-command', type: 'codex-command',
command: input, command: messageContent,
sessionId: effectiveSessionId, sessionId: effectiveSessionId,
options: { options: {
cwd: selectedProject.fullPath || selectedProject.path, cwd: selectedProject.fullPath || selectedProject.path,
@@ -4390,7 +4437,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
// Send Claude command (existing code) // Send Claude command (existing code)
sendMessage({ sendMessage({
type: 'claude-command', type: 'claude-command',
command: input, command: messageContent,
options: { options: {
projectPath: selectedProject.path, projectPath: selectedProject.path,
cwd: selectedProject.fullPath, cwd: selectedProject.fullPath,
@@ -4409,6 +4456,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
setUploadingImages(new Map()); setUploadingImages(new Map());
setImageErrors(new Map()); setImageErrors(new Map());
setIsTextareaExpanded(false); setIsTextareaExpanded(false);
setThinkingMode('none'); // Reset thinking mode after sending
// Reset textarea height // Reset textarea height
if (textareaRef.current) { if (textareaRef.current) {
@@ -4419,7 +4467,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
if (selectedProject) { if (selectedProject) {
safeLocalStorage.removeItem(`draft_input_${selectedProject.name}`); safeLocalStorage.removeItem(`draft_input_${selectedProject.name}`);
} }
}, [input, isLoading, selectedProject, attachedImages, currentSessionId, selectedSession, provider, permissionMode, onSessionActive, cursorModel, claudeModel, codexModel, sendMessage, setInput, setAttachedImages, setUploadingImages, setImageErrors, setIsTextareaExpanded, textareaRef, setChatMessages, setIsLoading, setCanAbortSession, setClaudeStatus, setIsUserScrolledUp, scrollToBottom]); }, [input, isLoading, selectedProject, attachedImages, currentSessionId, selectedSession, provider, permissionMode, onSessionActive, cursorModel, claudeModel, codexModel, sendMessage, setInput, setAttachedImages, setUploadingImages, setImageErrors, setIsTextareaExpanded, textareaRef, setChatMessages, setIsLoading, setCanAbortSession, setClaudeStatus, setIsUserScrolledUp, scrollToBottom, thinkingMode]);
const handleGrantToolPermission = useCallback((suggestion) => { const handleGrantToolPermission = useCallback((suggestion) => {
if (!suggestion || provider !== 'claude') { if (!suggestion || provider !== 'claude') {
@@ -4792,16 +4840,16 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
<div className="text-center text-gray-500 dark:text-gray-400 mt-8"> <div className="text-center text-gray-500 dark:text-gray-400 mt-8">
<div className="flex items-center justify-center space-x-2"> <div className="flex items-center justify-center space-x-2">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-gray-400"></div> <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-gray-400"></div>
<p>Loading session messages...</p> <p>{t('session.loading.sessionMessages')}</p>
</div> </div>
</div> </div>
) : chatMessages.length === 0 ? ( ) : chatMessages.length === 0 ? (
<div className="flex items-center justify-center h-full"> <div className="flex items-center justify-center h-full">
{!selectedSession && !currentSessionId && ( {!selectedSession && !currentSessionId && (
<div className="text-center px-6 sm:px-4 py-8"> <div className="text-center px-6 sm:px-4 py-8">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-3">Choose Your AI Assistant</h2> <h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-3">{t('providerSelection.title')}</h2>
<p className="text-gray-600 dark:text-gray-400 mb-8"> <p className="text-gray-600 dark:text-gray-400 mb-8">
Select a provider to start a new conversation {t('providerSelection.description')}
</p> </p>
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center mb-8"> <div className="flex flex-col sm:flex-row gap-4 justify-center items-center mb-8">
@@ -4823,7 +4871,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
<ClaudeLogo className="w-10 h-10" /> <ClaudeLogo className="w-10 h-10" />
<div> <div>
<p className="font-semibold text-gray-900 dark:text-white">Claude</p> <p className="font-semibold text-gray-900 dark:text-white">Claude</p>
<p className="text-xs text-gray-500 dark:text-gray-400">by Anthropic</p> <p className="text-xs text-gray-500 dark:text-gray-400">{t('providerSelection.providerInfo.anthropic')}</p>
</div> </div>
</div> </div>
{provider === 'claude' && ( {provider === 'claude' && (
@@ -4855,7 +4903,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
<CursorLogo className="w-10 h-10" /> <CursorLogo className="w-10 h-10" />
<div> <div>
<p className="font-semibold text-gray-900 dark:text-white">Cursor</p> <p className="font-semibold text-gray-900 dark:text-white">Cursor</p>
<p className="text-xs text-gray-500 dark:text-gray-400">AI Code Editor</p> <p className="text-xs text-gray-500 dark:text-gray-400">{t('providerSelection.providerInfo.cursorEditor')}</p>
</div> </div>
</div> </div>
{provider === 'cursor' && ( {provider === 'cursor' && (
@@ -4887,7 +4935,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
<CodexLogo className="w-10 h-10" /> <CodexLogo className="w-10 h-10" />
<div> <div>
<p className="font-semibold text-gray-900 dark:text-white">Codex</p> <p className="font-semibold text-gray-900 dark:text-white">Codex</p>
<p className="text-xs text-gray-500 dark:text-gray-400">by OpenAI</p> <p className="text-xs text-gray-500 dark:text-gray-400">{t('providerSelection.providerInfo.openai')}</p>
</div> </div>
</div> </div>
{provider === 'codex' && ( {provider === 'codex' && (
@@ -4905,7 +4953,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
{/* Model Selection - Always reserve space to prevent jumping */} {/* Model Selection - Always reserve space to prevent jumping */}
<div className={`mb-6 transition-opacity duration-200 ${provider ? 'opacity-100' : 'opacity-0 pointer-events-none'}`}> <div className={`mb-6 transition-opacity duration-200 ${provider ? 'opacity-100' : 'opacity-0 pointer-events-none'}`}>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Select Model {t('providerSelection.selectModel')}
</label> </label>
{provider === 'claude' ? ( {provider === 'claude' ? (
<select <select
@@ -4955,12 +5003,12 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
<p className="text-sm text-gray-500 dark:text-gray-400"> <p className="text-sm text-gray-500 dark:text-gray-400">
{provider === 'claude' {provider === 'claude'
? `Ready to use Claude with ${claudeModel}. Start typing your message below.` ? t('providerSelection.readyPrompt.claude', { model: claudeModel })
: provider === 'cursor' : provider === 'cursor'
? `Ready to use Cursor with ${cursorModel}. Start typing your message below.` ? t('providerSelection.readyPrompt.cursor', { model: cursorModel })
: provider === 'codex' : provider === 'codex'
? `Ready to use Codex with ${codexModel}. Start typing your message below.` ? t('providerSelection.readyPrompt.codex', { model: codexModel })
: 'Select a provider above to begin' : t('providerSelection.readyPrompt.default')
} }
</p> </p>
@@ -4977,9 +5025,9 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
)} )}
{selectedSession && ( {selectedSession && (
<div className="text-center text-gray-500 dark:text-gray-400 px-6 sm:px-4"> <div className="text-center text-gray-500 dark:text-gray-400 px-6 sm:px-4">
<p className="font-bold text-lg sm:text-xl mb-3">Continue your conversation</p> <p className="font-bold text-lg sm:text-xl mb-3">{t('session.continue.title')}</p>
<p className="text-sm sm:text-base leading-relaxed"> <p className="text-sm sm:text-base leading-relaxed">
Ask questions about your code, request changes, or get help with development tasks {t('session.continue.description')}
</p> </p>
{/* Show NextTaskBanner for existing sessions too, only if TaskMaster is installed */} {/* Show NextTaskBanner for existing sessions too, only if TaskMaster is installed */}
@@ -5001,7 +5049,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
<div className="text-center text-gray-500 dark:text-gray-400 py-3"> <div className="text-center text-gray-500 dark:text-gray-400 py-3">
<div className="flex items-center justify-center space-x-2"> <div className="flex items-center justify-center space-x-2">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-gray-400"></div> <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-gray-400"></div>
<p className="text-sm">Loading older messages...</p> <p className="text-sm">{t('session.loading.olderMessages')}</p>
</div> </div>
</div> </div>
)} )}
@@ -5011,8 +5059,8 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
<div className="text-center text-gray-500 dark:text-gray-400 text-sm py-2 border-b border-gray-200 dark:border-gray-700"> <div className="text-center text-gray-500 dark:text-gray-400 text-sm py-2 border-b border-gray-200 dark:border-gray-700">
{totalMessages > 0 && ( {totalMessages > 0 && (
<span> <span>
Showing {sessionMessages.length} of {totalMessages} messages {t('session.messages.showingOf', { shown: sessionMessages.length, total: totalMessages })}
<span className="text-xs">Scroll up to load more</span> <span className="text-xs">{t('session.messages.scrollToLoad')}</span>
</span> </span>
)} )}
</div> </div>
@@ -5021,12 +5069,12 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
{/* Legacy message count indicator (for non-paginated view) */} {/* Legacy message count indicator (for non-paginated view) */}
{!hasMoreMessages && chatMessages.length > visibleMessageCount && ( {!hasMoreMessages && chatMessages.length > visibleMessageCount && (
<div className="text-center text-gray-500 dark:text-gray-400 text-sm py-2 border-b border-gray-200 dark:border-gray-700"> <div className="text-center text-gray-500 dark:text-gray-400 text-sm py-2 border-b border-gray-200 dark:border-gray-700">
Showing last {visibleMessageCount} messages ({chatMessages.length} total) {t('session.messages.showingLast', { count: visibleMessageCount, total: chatMessages.length })}
<button <button
className="ml-1 text-blue-600 hover:text-blue-700 underline" className="ml-1 text-blue-600 hover:text-blue-700 underline"
onClick={loadEarlierMessages} onClick={loadEarlierMessages}
> >
Load earlier messages {t('session.messages.loadEarlier')}
</button> </button>
</div> </div>
)} )}
@@ -5211,7 +5259,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
? 'bg-orange-50 dark:bg-orange-900/20 text-orange-700 dark:text-orange-300 border-orange-300 dark:border-orange-600 hover:bg-orange-100 dark:hover:bg-orange-900/30' ? 'bg-orange-50 dark:bg-orange-900/20 text-orange-700 dark:text-orange-300 border-orange-300 dark:border-orange-600 hover:bg-orange-100 dark:hover:bg-orange-900/30'
: 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border-blue-300 dark:border-blue-600 hover:bg-blue-100 dark:hover:bg-blue-900/30' : 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border-blue-300 dark:border-blue-600 hover:bg-blue-100 dark:hover:bg-blue-900/30'
}`} }`}
title="Click to change permission mode (or press Tab in input)" title={t('input.clickToChangeMode')}
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${ <div className={`w-2 h-2 rounded-full ${
@@ -5224,13 +5272,24 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
: 'bg-blue-500' : 'bg-blue-500'
}`} /> }`} />
<span> <span>
{permissionMode === 'default' && 'Default Mode'} {permissionMode === 'default' && t('codex.modes.default')}
{permissionMode === 'acceptEdits' && 'Accept Edits'} {permissionMode === 'acceptEdits' && t('codex.modes.acceptEdits')}
{permissionMode === 'bypassPermissions' && 'Bypass Permissions'} {permissionMode === 'bypassPermissions' && t('codex.modes.bypassPermissions')}
{permissionMode === 'plan' && 'Plan Mode'} {permissionMode === 'plan' && t('codex.modes.plan')}
</span> </span>
</div> </div>
</button> </button>
{/* Thinking Mode Selector */}
{
provider === 'claude' && (
<ThinkingModeSelector
selectedMode={thinkingMode}
onModeChange={setThinkingMode}
className=""
/>
)}
{/* Token usage pie chart - positioned next to mode indicator */} {/* Token usage pie chart - positioned next to mode indicator */}
<TokenUsagePie <TokenUsagePie
used={tokenBudget?.used || 0} used={tokenBudget?.used || 0}
@@ -5256,7 +5315,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
} }
}} }}
className="relative w-8 h-8 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 rounded-full flex items-center justify-center transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:ring-offset-gray-800" className="relative w-8 h-8 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 rounded-full flex items-center justify-center transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:ring-offset-gray-800"
title="Show all commands" title={t('input.showAllCommands')}
> >
<svg <svg
className="w-5 h-5" className="w-5 h-5"
@@ -5453,7 +5512,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
const isExpanded = e.target.scrollHeight > lineHeight * 2; const isExpanded = e.target.scrollHeight > lineHeight * 2;
setIsTextareaExpanded(isExpanded); setIsTextareaExpanded(isExpanded);
}} }}
placeholder={`Type / for commands, @ for files, or ask ${provider === 'cursor' ? 'Cursor' : 'Claude'} anything...`} placeholder={t('input.placeholder', { provider: provider === 'cursor' ? t('messageTypes.cursor') : provider === 'codex' ? t('messageTypes.codex') : t('messageTypes.claude') })}
disabled={isLoading} disabled={isLoading}
className="chat-input-placeholder block w-full pl-12 pr-20 sm:pr-40 py-1.5 sm:py-4 bg-transparent rounded-2xl focus:outline-none text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 disabled:opacity-50 resize-none min-h-[50px] sm:min-h-[80px] max-h-[40vh] sm:max-h-[300px] overflow-y-auto text-sm sm:text-base leading-[21px] sm:leading-6 transition-all duration-200" className="chat-input-placeholder block w-full pl-12 pr-20 sm:pr-40 py-1.5 sm:py-4 bg-transparent rounded-2xl focus:outline-none text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 disabled:opacity-50 resize-none min-h-[50px] sm:min-h-[80px] max-h-[40vh] sm:max-h-[300px] overflow-y-auto text-sm sm:text-base leading-[21px] sm:leading-6 transition-all duration-200"
style={{ height: '50px' }} style={{ height: '50px' }}
@@ -5463,7 +5522,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
type="button" type="button"
onClick={open} onClick={open}
className="absolute left-2 top-1/2 transform -translate-y-1/2 p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors" className="absolute left-2 top-1/2 transform -translate-y-1/2 p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
title="Attach images" title={t('input.attachImages')}
> >
<svg className="w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
@@ -5512,8 +5571,8 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
input.trim() ? 'opacity-0' : 'opacity-100' input.trim() ? 'opacity-0' : 'opacity-100'
}`}> }`}>
{sendByCtrlEnter {sendByCtrlEnter
? "Ctrl+Enter to send • Shift+Enter for new line • Tab to change modes • / for slash commands" ? t('input.hintText.ctrlEnter')
: "Enter to send • Shift+Enter for new line • Tab to change modes • / for slash commands"} : t('input.hintText.enter')}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -12,8 +12,10 @@ import { unifiedMergeView, getChunks } from '@codemirror/merge';
import { showMinimap } from '@replit/codemirror-minimap'; import { showMinimap } from '@replit/codemirror-minimap';
import { X, Save, Download, Maximize2, Minimize2 } from 'lucide-react'; import { X, Save, Download, Maximize2, Minimize2 } from 'lucide-react';
import { api } from '../utils/api'; import { api } from '../utils/api';
import { useTranslation } from 'react-i18next';
function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded = false, onToggleExpand = null }) { function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded = false, onToggleExpand = null }) {
const { t } = useTranslation('codeEditor');
const [content, setContent] = useState(''); const [content, setContent] = useState('');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); 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;">'; toolbarHTML += '<div style="display: flex; align-items: center; gap: 8px;">';
if (hasDiff) { if (hasDiff) {
toolbarHTML += ` toolbarHTML += `
<span style="font-weight: 500;">${chunkCount > 0 ? `${currentIndex + 1}/${chunkCount}` : '0'} changes</span> <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="Previous change" ${chunkCount === 0 ? 'disabled' : ''}> <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"> <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" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
</svg> </svg>
</button> </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"> <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" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg> </svg>
@@ -146,7 +148,7 @@ function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded
// Show/hide diff button (only if there's diff info) // Show/hide diff button (only if there's diff info)
if (file.diffInfo) { if (file.diffInfo) {
toolbarHTML += ` 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"> <svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
${showDiff ? ${showDiff ?
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />' : '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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 // Settings button
toolbarHTML += ` 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"> <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" /> <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> </svg>
@@ -169,7 +171,7 @@ function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded
// Expand button (only in sidebar mode) // Expand button (only in sidebar mode)
if (isSidebar && onToggleExpand) { if (isSidebar && onToggleExpand) {
toolbarHTML += ` 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"> <svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
${isExpanded ? ${isExpanded ?
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 9V4.5M9 9H4.5M9 9L3.75 3.75M9 15v4.5M9 15H4.5M9 15l-5.25 5.25M15 9h4.5M15 9V4.5M15 9l5.25-5.25M15 15h4.5M15 15v4.5m0-4.5l5.25 5.25" />' : '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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="w-full h-full flex items-center justify-center bg-background">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div> <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> </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="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="flex items-center gap-3">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div> <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> </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> <h3 className="font-medium text-gray-900 dark:text-white truncate">{file.name}</h3>
{file.diffInfo && ( {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"> <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> </span>
)} )}
</div> </div>
@@ -586,7 +588,7 @@ function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded
<button <button
onClick={handleDownload} 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" 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" /> <Download className="w-5 h-5 md:w-4 md:h-4" />
</button> </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"> <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" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg> </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" /> <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> </button>
@@ -619,7 +621,7 @@ function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded
<button <button
onClick={toggleFullscreen} 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" 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" />} {isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
</button> </button>
@@ -628,7 +630,7 @@ function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded
<button <button
onClick={onClose} 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" 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" /> <X className="w-6 h-6 md:w-4 md:h-4" />
</button> </button>
@@ -686,12 +688,12 @@ function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded
{/* Footer */} {/* Footer */}
<div className="flex items-center justify-between p-3 border-t border-border bg-muted flex-shrink-0"> <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"> <div className="flex items-center gap-4 text-sm text-gray-600 dark:text-gray-400">
<span>Lines: {content.split('\n').length}</span> <span>{t('footer.lines')} {content.split('\n').length}</span>
<span>Characters: {content.length}</span> <span>{t('footer.characters')} {content.length}</span>
</div> </div>
<div className="text-sm text-gray-500 dark:text-gray-400"> <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> </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 { useVersionCheck } from '../hooks/useVersionCheck';
import { version } from '../../package.json'; import { version } from '../../package.json';
import { authenticatedFetch } from '../utils/api'; import { authenticatedFetch } from '../utils/api';
import { useTranslation } from 'react-i18next';
function CredentialsSettings() { function CredentialsSettings() {
const { t } = useTranslation('settings');
const [apiKeys, setApiKeys] = useState([]); const [apiKeys, setApiKeys] = useState([]);
const [githubCredentials, setGithubCredentials] = useState([]); const [githubCredentials, setGithubCredentials] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -69,7 +71,7 @@ function CredentialsSettings() {
}; };
const deleteApiKey = async (keyId) => { const deleteApiKey = async (keyId) => {
if (!confirm('Are you sure you want to delete this API key?')) return; if (!confirm(t('apiKeys.confirmDelete'))) return;
try { try {
await authenticatedFetch(`/api/settings/api-keys/${keyId}`, { await authenticatedFetch(`/api/settings/api-keys/${keyId}`, {
@@ -121,7 +123,7 @@ function CredentialsSettings() {
}; };
const deleteGithubCredential = async (credentialId) => { 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 { try {
await authenticatedFetch(`/api/settings/credentials/${credentialId}`, { await authenticatedFetch(`/api/settings/credentials/${credentialId}`, {
@@ -152,7 +154,7 @@ function CredentialsSettings() {
}; };
if (loading) { if (loading) {
return <div className="text-muted-foreground">Loading...</div>; return <div className="text-muted-foreground">{t('apiKeys.loading')}</div>;
} }
return ( return (
@@ -160,9 +162,9 @@ function CredentialsSettings() {
{/* New API Key Alert */} {/* New API Key Alert */}
{newlyCreatedKey && ( {newlyCreatedKey && (
<div className="p-4 bg-yellow-500/10 border border-yellow-500/20 rounded-lg"> <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"> <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> </p>
<div className="flex items-center gap-2"> <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"> <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" className="mt-3"
onClick={() => setNewlyCreatedKey(null)} onClick={() => setNewlyCreatedKey(null)}
> >
I've saved it {t('apiKeys.newKey.iveSavedIt')}
</Button> </Button>
</div> </div>
)} )}
@@ -192,20 +194,20 @@ function CredentialsSettings() {
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Key className="h-5 w-5" /> <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> </div>
<Button <Button
size="sm" size="sm"
onClick={() => setShowNewKeyForm(!showNewKeyForm)} onClick={() => setShowNewKeyForm(!showNewKeyForm)}
> >
<Plus className="h-4 w-4 mr-1" /> <Plus className="h-4 w-4 mr-1" />
New API Key {t('apiKeys.newButton')}
</Button> </Button>
</div> </div>
<div className="mb-4"> <div className="mb-4">
<p className="text-sm text-muted-foreground mb-2"> <p className="text-sm text-muted-foreground mb-2">
Generate API keys to access the external API from other applications. {t('apiKeys.description')}
</p> </p>
<a <a
href="/api-docs.html" href="/api-docs.html"
@@ -213,7 +215,7 @@ function CredentialsSettings() {
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-sm text-primary hover:underline inline-flex items-center gap-1" className="text-sm text-primary hover:underline inline-flex items-center gap-1"
> >
API Documentation {t('apiKeys.apiDocsLink')}
<ExternalLink className="h-3 w-3" /> <ExternalLink className="h-3 w-3" />
</a> </a>
</div> </div>
@@ -221,15 +223,15 @@ function CredentialsSettings() {
{showNewKeyForm && ( {showNewKeyForm && (
<div className="mb-4 p-4 border rounded-lg bg-card"> <div className="mb-4 p-4 border rounded-lg bg-card">
<Input <Input
placeholder="API Key Name (e.g., Production Server)" placeholder={t('apiKeys.form.placeholder')}
value={newKeyName} value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)} onChange={(e) => setNewKeyName(e.target.value)}
className="mb-2" className="mb-2"
/> />
<div className="flex gap-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)}> <Button variant="outline" onClick={() => setShowNewKeyForm(false)}>
Cancel {t('apiKeys.form.cancelButton')}
</Button> </Button>
</div> </div>
</div> </div>
@@ -237,7 +239,7 @@ function CredentialsSettings() {
<div className="space-y-2"> <div className="space-y-2">
{apiKeys.length === 0 ? ( {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) => ( apiKeys.map((key) => (
<div <div
@@ -248,8 +250,8 @@ function CredentialsSettings() {
<div className="font-medium">{key.key_name}</div> <div className="font-medium">{key.key_name}</div>
<code className="text-xs text-muted-foreground">{key.api_key}</code> <code className="text-xs text-muted-foreground">{key.api_key}</code>
<div className="text-xs text-muted-foreground mt-1"> <div className="text-xs text-muted-foreground mt-1">
Created: {new Date(key.created_at).toLocaleDateString()} {t('apiKeys.list.created')} {new Date(key.created_at).toLocaleDateString()}
{key.last_used && `Last used: ${new Date(key.last_used).toLocaleDateString()}`} {key.last_used && `${t('apiKeys.list.lastUsed')} ${new Date(key.last_used).toLocaleDateString()}`}
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -258,7 +260,7 @@ function CredentialsSettings() {
variant={key.is_active ? 'outline' : 'secondary'} variant={key.is_active ? 'outline' : 'secondary'}
onClick={() => toggleApiKey(key.id, key.is_active)} 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>
<Button <Button
size="sm" size="sm"
@@ -279,25 +281,25 @@ function CredentialsSettings() {
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Github className="h-5 w-5" /> <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> </div>
<Button <Button
size="sm" size="sm"
onClick={() => setShowNewGithubForm(!showNewGithubForm)} onClick={() => setShowNewGithubForm(!showNewGithubForm)}
> >
<Plus className="h-4 w-4 mr-1" /> <Plus className="h-4 w-4 mr-1" />
Add Token {t('apiKeys.github.addButton')}
</Button> </Button>
</div> </div>
<p className="text-sm text-muted-foreground mb-4"> <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> </p>
{showNewGithubForm && ( {showNewGithubForm && (
<div className="mb-4 p-4 border rounded-lg bg-card space-y-3"> <div className="mb-4 p-4 border rounded-lg bg-card space-y-3">
<Input <Input
placeholder="Token Name (e.g., Personal Repos)" placeholder={t('apiKeys.github.form.namePlaceholder')}
value={newGithubName} value={newGithubName}
onChange={(e) => setNewGithubName(e.target.value)} onChange={(e) => setNewGithubName(e.target.value)}
/> />
@@ -305,7 +307,7 @@ function CredentialsSettings() {
<div className="relative"> <div className="relative">
<Input <Input
type={showToken['new'] ? 'text' : 'password'} type={showToken['new'] ? 'text' : 'password'}
placeholder="GitHub Personal Access Token (ghp_...)" placeholder={t('apiKeys.github.form.tokenPlaceholder')}
value={newGithubToken} value={newGithubToken}
onChange={(e) => setNewGithubToken(e.target.value)} onChange={(e) => setNewGithubToken(e.target.value)}
className="pr-10" className="pr-10"
@@ -320,20 +322,20 @@ function CredentialsSettings() {
</div> </div>
<Input <Input
placeholder="Description (optional)" placeholder={t('apiKeys.github.form.descriptionPlaceholder')}
value={newGithubDescription} value={newGithubDescription}
onChange={(e) => setNewGithubDescription(e.target.value)} onChange={(e) => setNewGithubDescription(e.target.value)}
/> />
<div className="flex gap-2"> <div className="flex gap-2">
<Button onClick={createGithubCredential}>Add Token</Button> <Button onClick={createGithubCredential}>{t('apiKeys.github.form.addButton')}</Button>
<Button variant="outline" onClick={() => { <Button variant="outline" onClick={() => {
setShowNewGithubForm(false); setShowNewGithubForm(false);
setNewGithubName(''); setNewGithubName('');
setNewGithubToken(''); setNewGithubToken('');
setNewGithubDescription(''); setNewGithubDescription('');
}}> }}>
Cancel {t('apiKeys.github.form.cancelButton')}
</Button> </Button>
</div> </div>
@@ -343,14 +345,14 @@ function CredentialsSettings() {
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-xs text-primary hover:underline block" className="text-xs text-primary hover:underline block"
> >
How to create a GitHub Personal Access Token {t('apiKeys.github.form.howToCreate')}
</a> </a>
</div> </div>
)} )}
<div className="space-y-2"> <div className="space-y-2">
{githubCredentials.length === 0 ? ( {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) => ( githubCredentials.map((credential) => (
<div <div
@@ -363,7 +365,7 @@ function CredentialsSettings() {
<div className="text-xs text-muted-foreground">{credential.description}</div> <div className="text-xs text-muted-foreground">{credential.description}</div>
)} )}
<div className="text-xs text-muted-foreground mt-1"> <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> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -372,7 +374,7 @@ function CredentialsSettings() {
variant={credential.is_active ? 'outline' : 'secondary'} variant={credential.is_active ? 'outline' : 'secondary'}
onClick={() => toggleGithubCredential(credential.id, credential.is_active)} 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>
<Button <Button
size="sm" size="sm"
@@ -406,7 +408,7 @@ function CredentialsSettings() {
rel="noopener noreferrer" 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" 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" /> <ExternalLink className="h-2.5 w-2.5" />
</a> </a>
)} )}

View File

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

View File

@@ -3,8 +3,10 @@ import { Button } from './ui/button';
import { Input } from './ui/input'; import { Input } from './ui/input';
import { GitBranch, Check } from 'lucide-react'; import { GitBranch, Check } from 'lucide-react';
import { authenticatedFetch } from '../utils/api'; import { authenticatedFetch } from '../utils/api';
import { useTranslation } from 'react-i18next';
function GitSettings() { function GitSettings() {
const { t } = useTranslation('settings');
const [gitName, setGitName] = useState(''); const [gitName, setGitName] = useState('');
const [gitEmail, setGitEmail] = useState(''); const [gitEmail, setGitEmail] = useState('');
const [gitConfigLoading, setGitConfigLoading] = useState(false); const [gitConfigLoading, setGitConfigLoading] = useState(false);
@@ -61,17 +63,17 @@ function GitSettings() {
<div> <div>
<div className="flex items-center gap-2 mb-4"> <div className="flex items-center gap-2 mb-4">
<GitBranch className="h-5 w-5" /> <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> </div>
<p className="text-sm text-muted-foreground mb-4"> <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> </p>
<div className="p-4 border rounded-lg bg-card space-y-3"> <div className="p-4 border rounded-lg bg-card space-y-3">
<div> <div>
<label htmlFor="settings-git-name" className="block text-sm font-medium text-foreground mb-2"> <label htmlFor="settings-git-name" className="block text-sm font-medium text-foreground mb-2">
Git Name {t('git.name.label')}
</label> </label>
<Input <Input
id="settings-git-name" id="settings-git-name"
@@ -83,13 +85,13 @@ function GitSettings() {
className="w-full" className="w-full"
/> />
<p className="mt-1 text-xs text-muted-foreground"> <p className="mt-1 text-xs text-muted-foreground">
Your name for git commits {t('git.name.help')}
</p> </p>
</div> </div>
<div> <div>
<label htmlFor="settings-git-email" className="block text-sm font-medium text-foreground mb-2"> <label htmlFor="settings-git-email" className="block text-sm font-medium text-foreground mb-2">
Git Email {t('git.email.label')}
</label> </label>
<Input <Input
id="settings-git-email" id="settings-git-email"
@@ -101,7 +103,7 @@ function GitSettings() {
className="w-full" className="w-full"
/> />
<p className="mt-1 text-xs text-muted-foreground"> <p className="mt-1 text-xs text-muted-foreground">
Your email for git commits {t('git.email.help')}
</p> </p>
</div> </div>
@@ -110,13 +112,13 @@ function GitSettings() {
onClick={saveGitConfig} onClick={saveGitConfig}
disabled={gitConfigSaving || !gitName || !gitEmail} disabled={gitConfigSaving || !gitName || !gitEmail}
> >
{gitConfigSaving ? 'Saving...' : 'Save Configuration'} {gitConfigSaving ? t('git.actions.saving') : t('git.actions.save')}
</Button> </Button>
{saveStatus === 'success' && ( {saveStatus === 'success' && (
<div className="text-sm text-green-600 dark:text-green-400 flex items-center gap-2"> <div className="text-sm text-green-600 dark:text-green-400 flex items-center gap-2">
<Check className="w-4 h-4" /> <Check className="w-4 h-4" />
Saved successfully {t('git.status.success')}
</div> </div>
)} )}
</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 React, { useState } from 'react';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import { MessageSquare } from 'lucide-react'; import { MessageSquare } from 'lucide-react';
import { useTranslation } from 'react-i18next';
const LoginForm = () => { const LoginForm = () => {
const { t } = useTranslation('auth');
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const { login } = useAuth(); const { login } = useAuth();
const handleSubmit = async (e) => { const handleSubmit = async (e) => {
e.preventDefault(); e.preventDefault();
setError(''); setError('');
if (!username || !password) { if (!username || !password) {
setError('Please enter both username and password'); setError(t('errors.requiredFields'));
return; return;
} }
setIsLoading(true); setIsLoading(true);
const result = await login(username, password); const result = await login(username, password);
if (!result.success) { if (!result.success) {
setError(result.error); setError(result.error);
} }
setIsLoading(false); setIsLoading(false);
}; };
@@ -41,9 +43,9 @@ const LoginForm = () => {
<MessageSquare className="w-8 h-8 text-primary-foreground" /> <MessageSquare className="w-8 h-8 text-primary-foreground" />
</div> </div>
</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"> <p className="text-muted-foreground mt-2">
Sign in to your Claude Code UI account {t('login.description')}
</p> </p>
</div> </div>
@@ -51,7 +53,7 @@ const LoginForm = () => {
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <div>
<label htmlFor="username" className="block text-sm font-medium text-foreground mb-1"> <label htmlFor="username" className="block text-sm font-medium text-foreground mb-1">
Username {t('login.username')}
</label> </label>
<input <input
type="text" type="text"
@@ -59,7 +61,7 @@ const LoginForm = () => {
value={username} value={username}
onChange={(e) => setUsername(e.target.value)} 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" 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 required
disabled={isLoading} disabled={isLoading}
/> />
@@ -67,7 +69,7 @@ const LoginForm = () => {
<div> <div>
<label htmlFor="password" className="block text-sm font-medium text-foreground mb-1"> <label htmlFor="password" className="block text-sm font-medium text-foreground mb-1">
Password {t('login.password')}
</label> </label>
<input <input
type="password" type="password"
@@ -75,7 +77,7 @@ const LoginForm = () => {
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} 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" 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 required
disabled={isLoading} disabled={isLoading}
/> />
@@ -92,7 +94,7 @@ const LoginForm = () => {
disabled={isLoading} 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" 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> </button>
</form> </form>

View File

@@ -12,6 +12,7 @@
*/ */
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import ChatInterface from './ChatInterface'; import ChatInterface from './ChatInterface';
import FileTree from './FileTree'; import FileTree from './FileTree';
import CodeEditor from './CodeEditor'; import CodeEditor from './CodeEditor';
@@ -58,6 +59,7 @@ function MainContent({
sendByCtrlEnter, // Send by Ctrl+Enter mode for East Asian language input sendByCtrlEnter, // Send by Ctrl+Enter mode for East Asian language input
externalMessageUpdate // Trigger for external CLI updates to current session externalMessageUpdate // Trigger for external CLI updates to current session
}) { }) {
const { t } = useTranslation();
const [editingFile, setEditingFile] = useState(null); const [editingFile, setEditingFile] = useState(null);
const [selectedTask, setSelectedTask] = useState(null); const [selectedTask, setSelectedTask] = useState(null);
const [showTaskDetail, setShowTaskDetail] = useState(false); const [showTaskDetail, setShowTaskDetail] = useState(false);
@@ -238,8 +240,8 @@ function MainContent({
}} }}
/> />
</div> </div>
<h2 className="text-xl font-semibold mb-2">Loading Claude Code UI</h2> <h2 className="text-xl font-semibold mb-2">{t('mainContent.loading')}</h2>
<p>Setting up your workspace...</p> <p>{t('mainContent.settingUpWorkspace')}</p>
</div> </div>
</div> </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" /> <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> </svg>
</div> </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"> <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> </p>
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4 border border-blue-200 dark:border-blue-800"> <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"> <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> </p>
</div> </div>
</div> </div>
@@ -331,7 +333,7 @@ function MainContent({
) : activeTab === 'chat' && !selectedSession ? ( ) : activeTab === 'chat' && !selectedSession ? (
<div className="min-w-0"> <div className="min-w-0">
<h2 className="text-sm sm:text-base font-semibold text-gray-900 dark:text-white"> <h2 className="text-sm sm:text-base font-semibold text-gray-900 dark:text-white">
New Session {t('mainContent.newSession')}
</h2> </h2>
<div className="text-xs text-gray-500 dark:text-gray-400 truncate"> <div className="text-xs text-gray-500 dark:text-gray-400 truncate">
{selectedProject.displayName} {selectedProject.displayName}
@@ -340,8 +342,8 @@ function MainContent({
) : ( ) : (
<div className="min-w-0"> <div className="min-w-0">
<h2 className="text-sm sm:text-base font-semibold text-gray-900 dark:text-white"> <h2 className="text-sm sm:text-base font-semibold text-gray-900 dark:text-white">
{activeTab === 'files' ? 'Project Files' : {activeTab === 'files' ? t('mainContent.projectFiles') :
activeTab === 'git' ? 'Source Control' : activeTab === 'git' ? t('tabs.git') :
(activeTab === 'tasks' && shouldShowTasksTab) ? 'TaskMaster' : (activeTab === 'tasks' && shouldShowTasksTab) ? 'TaskMaster' :
'Project'} 'Project'}
</h2> </h2>
@@ -357,7 +359,7 @@ function MainContent({
{/* Modern Tab Navigation - Right Side */} {/* Modern Tab Navigation - Right Side */}
<div className="flex-shrink-0 hidden sm:block"> <div className="flex-shrink-0 hidden sm:block">
<div className="relative flex bg-gray-100 dark:bg-gray-800 rounded-lg p-1"> <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 <button
onClick={() => setActiveTab('chat')} onClick={() => setActiveTab('chat')}
className={`relative px-2 sm:px-3 py-1.5 text-xs sm:text-sm font-medium rounded-md ${ 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"> <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" /> <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> </svg>
<span className="hidden md:hidden lg:inline">Chat</span> <span className="hidden md:hidden lg:inline">{t('tabs.chat')}</span>
</span> </span>
</button> </button>
</Tooltip> </Tooltip>
<Tooltip content="Shell" position="bottom"> <Tooltip content={t('tabs.shell')} position="bottom">
<button <button
onClick={() => setActiveTab('shell')} 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 ${ 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"> <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" /> <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> </svg>
<span className="hidden md:hidden lg:inline">Shell</span> <span className="hidden md:hidden lg:inline">{t('tabs.shell')}</span>
</span> </span>
</button> </button>
</Tooltip> </Tooltip>
<Tooltip content="Files" position="bottom"> <Tooltip content={t('tabs.files')} position="bottom">
<button <button
onClick={() => setActiveTab('files')} 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 ${ 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"> <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" /> <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> </svg>
<span className="hidden md:hidden lg:inline">Files</span> <span className="hidden md:hidden lg:inline">{t('tabs.files')}</span>
</span> </span>
</button> </button>
</Tooltip> </Tooltip>
<Tooltip content="Source Control" position="bottom"> <Tooltip content={t('tabs.git')} position="bottom">
<button <button
onClick={() => setActiveTab('git')} 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 ${ 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"> <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" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg> </svg>
<span className="hidden md:hidden lg:inline">Source Control</span> <span className="hidden md:hidden lg:inline">{t('tabs.git')}</span>
</span> </span>
</button> </button>
</Tooltip> </Tooltip>
{shouldShowTasksTab && ( {shouldShowTasksTab && (
<Tooltip content="Tasks" position="bottom"> <Tooltip content={t('tabs.tasks')} position="bottom">
<button <button
onClick={() => setActiveTab('tasks')} 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 ${ 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"> <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" /> <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> </svg>
<span className="hidden md:hidden lg:inline">Tasks</span> <span className="hidden md:hidden lg:inline">{t('tabs.tasks')}</span>
</span> </span>
</button> </button>
</Tooltip> </Tooltip>

View File

@@ -1,13 +1,15 @@
import React, { useState, useEffect } from 'react'; 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 } from 'lucide-react';
import { Button } from './ui/button'; import { Button } from './ui/button';
import { Input } from './ui/input'; import { Input } from './ui/input';
import { api } from '../utils/api'; import { api } from '../utils/api';
import { useTranslation } from 'react-i18next';
const ProjectCreationWizard = ({ onClose, onProjectCreated }) => { const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
const { t } = useTranslation();
// Wizard state // Wizard state
const [step, setStep] = useState(1); // 1: Choose type, 2: Configure, 3: Confirm 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 // Form state
const [workspacePath, setWorkspacePath] = useState(''); const [workspacePath, setWorkspacePath] = useState('');
@@ -23,6 +25,11 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
const [loadingTokens, setLoadingTokens] = useState(false); const [loadingTokens, setLoadingTokens] = useState(false);
const [pathSuggestions, setPathSuggestions] = useState([]); const [pathSuggestions, setPathSuggestions] = useState([]);
const [showPathDropdown, setShowPathDropdown] = useState(false); 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);
// Load available GitHub tokens when needed // Load available GitHub tokens when needed
useEffect(() => { useEffect(() => {
@@ -88,13 +95,13 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
if (step === 1) { if (step === 1) {
if (!workspaceType) { if (!workspaceType) {
setError('Please select whether you have an existing workspace or want to create a new one'); setError(t('projectWizard.errors.selectType'));
return; return;
} }
setStep(2); setStep(2);
} else if (step === 2) { } else if (step === 2) {
if (!workspacePath.trim()) { if (!workspacePath.trim()) {
setError('Please provide a workspace path'); setError(t('projectWizard.errors.providePath'));
return; return;
} }
@@ -133,7 +140,7 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
const data = await response.json(); const data = await response.json();
if (!response.ok) { if (!response.ok) {
throw new Error(data.error || 'Failed to create workspace'); throw new Error(data.error || t('projectWizard.errors.failedToCreate'));
} }
// Success! // Success!
@@ -144,7 +151,7 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
onClose(); onClose();
} catch (error) { } catch (error) {
console.error('Error creating workspace:', error); console.error('Error creating workspace:', error);
setError(error.message || 'Failed to create workspace'); setError(error.message || t('projectWizard.errors.failedToCreate'));
} finally { } finally {
setIsCreating(false); setIsCreating(false);
} }
@@ -155,6 +162,37 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
setShowPathDropdown(false); setShowPathDropdown(false);
}; };
const openFolderBrowser = async () => {
setShowFolderBrowser(true);
await loadBrowserFolders('~');
};
const loadBrowserFolders = async (path) => {
try {
setLoadingFolders(true);
setBrowserCurrentPath(path);
const response = await api.browseFilesystem(path);
const data = await response.json();
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);
};
return ( 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="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"> <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 +203,7 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
<FolderPlus className="w-4 h-4 text-blue-600 dark:text-blue-400" /> <FolderPlus className="w-4 h-4 text-blue-600 dark:text-blue-400" />
</div> </div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white"> <h3 className="text-lg font-semibold text-gray-900 dark:text-white">
Create New Project {t('projectWizard.title')}
</h3> </h3>
</div> </div>
<button <button
@@ -195,7 +233,7 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
{s < step ? <Check className="w-4 h-4" /> : s} {s < step ? <Check className="w-4 h-4" /> : s}
</div> </div>
<span className="text-sm font-medium text-gray-700 dark:text-gray-300 hidden sm:inline"> <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> </span>
</div> </div>
{s < 3 && ( {s < 3 && (
@@ -227,7 +265,7 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3"> <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> </h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Existing Workspace */} {/* Existing Workspace */}
@@ -245,10 +283,10 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
</div> </div>
<div className="flex-1"> <div className="flex-1">
<h5 className="font-semibold text-gray-900 dark:text-white mb-1"> <h5 className="font-semibold text-gray-900 dark:text-white mb-1">
Existing Workspace {t('projectWizard.step1.existing.title')}
</h5> </h5>
<p className="text-sm text-gray-600 dark:text-gray-400"> <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> </p>
</div> </div>
</div> </div>
@@ -269,10 +307,10 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
</div> </div>
<div className="flex-1"> <div className="flex-1">
<h5 className="font-semibold text-gray-900 dark:text-white mb-1"> <h5 className="font-semibold text-gray-900 dark:text-white mb-1">
New Workspace {t('projectWizard.step1.new.title')}
</h5> </h5>
<p className="text-sm text-gray-600 dark:text-gray-400"> <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> </p>
</div> </div>
</div> </div>
@@ -288,35 +326,46 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
{/* Workspace Path */} {/* Workspace Path */}
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> <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> </label>
<div className="relative"> <div className="relative flex gap-2">
<Input <div className="flex-1 relative">
type="text" <Input
value={workspacePath} type="text"
onChange={(e) => setWorkspacePath(e.target.value)} value={workspacePath}
placeholder={workspaceType === 'existing' ? '/path/to/existing/workspace' : '/path/to/new/workspace'} onChange={(e) => setWorkspacePath(e.target.value)}
className="w-full" 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"> {showPathDropdown && pathSuggestions.length > 0 && (
{pathSuggestions.map((suggestion, index) => ( <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">
<button {pathSuggestions.map((suggestion, index) => (
key={index} <button
onClick={() => selectPathSuggestion(suggestion)} key={index}
className="w-full px-4 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-700 text-sm" 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> <div className="font-medium text-gray-900 dark:text-white">{suggestion.name}</div>
</button> <div className="text-xs text-gray-500 dark:text-gray-400">{suggestion.path}</div>
))} </button>
</div> ))}
)} </div>
)}
</div>
<Button
type="button"
variant="outline"
onClick={openFolderBrowser}
className="px-3"
title="Browse folders"
>
<FolderOpen className="w-4 h-4" />
</Button>
</div> </div>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400"> <p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{workspaceType === 'existing' {workspaceType === 'existing'
? 'Full path to your existing workspace directory' ? t('projectWizard.step2.existingHelp')
: 'Full path where the new workspace will be created'} : t('projectWizard.step2.newHelp')}
</p> </p>
</div> </div>
@@ -325,7 +374,7 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
<> <>
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
GitHub URL (Optional) {t('projectWizard.step2.githubUrl')}
</label> </label>
<Input <Input
type="text" type="text"
@@ -335,7 +384,7 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
className="w-full" className="w-full"
/> />
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400"> <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> </p>
</div> </div>
@@ -346,10 +395,10 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
<Key className="w-5 h-5 text-gray-600 dark:text-gray-400 flex-shrink-0 mt-0.5" /> <Key className="w-5 h-5 text-gray-600 dark:text-gray-400 flex-shrink-0 mt-0.5" />
<div className="flex-1"> <div className="flex-1">
<h5 className="font-medium text-gray-900 dark:text-white mb-1"> <h5 className="font-medium text-gray-900 dark:text-white mb-1">
GitHub Authentication (Optional) {t('projectWizard.step2.githubAuth')}
</h5> </h5>
<p className="text-sm text-gray-600 dark:text-gray-400"> <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> </p>
</div> </div>
</div> </div>
@@ -357,7 +406,7 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
{loadingTokens ? ( {loadingTokens ? (
<div className="flex items-center gap-2 text-sm text-gray-500"> <div className="flex items-center gap-2 text-sm text-gray-500">
<Loader2 className="w-4 h-4 animate-spin" /> <Loader2 className="w-4 h-4 animate-spin" />
Loading stored tokens... {t('projectWizard.step2.loadingTokens')}
</div> </div>
) : availableTokens.length > 0 ? ( ) : availableTokens.length > 0 ? (
<> <>
@@ -371,7 +420,7 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300' : 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
}`} }`}
> >
Stored Token {t('projectWizard.step2.storedToken')}
</button> </button>
<button <button
onClick={() => setTokenMode('new')} onClick={() => setTokenMode('new')}
@@ -381,7 +430,7 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300' : 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
}`} }`}
> >
New Token {t('projectWizard.step2.newToken')}
</button> </button>
<button <button
onClick={() => { onClick={() => {
@@ -395,21 +444,21 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300' : 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
}`} }`}
> >
None (Public) {t('projectWizard.step2.nonePublic')}
</button> </button>
</div> </div>
{tokenMode === 'stored' ? ( {tokenMode === 'stored' ? (
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Select Token {t('projectWizard.step2.selectToken')}
</label> </label>
<select <select
value={selectedGithubToken} value={selectedGithubToken}
onChange={(e) => setSelectedGithubToken(e.target.value)} 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" 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) => ( {availableTokens.map((token) => (
<option key={token.id} value={token.id}> <option key={token.id} value={token.id}>
{token.credential_name} {token.credential_name}
@@ -420,7 +469,7 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
) : tokenMode === 'new' ? ( ) : tokenMode === 'new' ? (
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
GitHub Token {t('projectWizard.step2.newToken')}
</label> </label>
<Input <Input
type="password" type="password"
@@ -430,7 +479,7 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
className="w-full" className="w-full"
/> />
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400"> <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> </p>
</div> </div>
) : null} ) : null}
@@ -439,23 +488,23 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
<div className="space-y-4"> <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"> <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"> <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> </p>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> <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> </label>
<Input <Input
type="password" type="password"
value={newGithubToken} value={newGithubToken}
onChange={(e) => setNewGithubToken(e.target.value)} onChange={(e) => setNewGithubToken(e.target.value)}
placeholder="ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx (leave empty for public repos)" placeholder={t('projectWizard.step2.tokenPublicPlaceholder')}
className="w-full" className="w-full"
/> />
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400"> <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> </p>
</div> </div>
</div> </div>
@@ -472,17 +521,17 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
<div className="space-y-4"> <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"> <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"> <h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-3">
Review Your Configuration {t('projectWizard.step3.reviewConfig')}
</h4> </h4>
<div className="space-y-2"> <div className="space-y-2">
<div className="flex justify-between text-sm"> <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"> <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> </span>
</div> </div>
<div className="flex justify-between text-sm"> <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"> <span className="font-mono text-xs text-gray-900 dark:text-white break-all">
{workspacePath} {workspacePath}
</span> </span>
@@ -490,19 +539,19 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
{workspaceType === 'new' && githubUrl && ( {workspaceType === 'new' && githubUrl && (
<> <>
<div className="flex justify-between text-sm"> <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"> <span className="font-mono text-xs text-gray-900 dark:text-white break-all">
{githubUrl} {githubUrl}
</span> </span>
</div> </div>
<div className="flex justify-between text-sm"> <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"> <span className="text-xs text-gray-900 dark:text-white">
{tokenMode === 'stored' && selectedGithubToken {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 : tokenMode === 'new' && newGithubToken
? 'Using provided token' ? t('projectWizard.step3.usingProvidedToken')
: 'No authentication'} : t('projectWizard.step3.noAuthentication')}
</span> </span>
</div> </div>
</> </>
@@ -513,10 +562,10 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4 border border-blue-200 dark:border-blue-800"> <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"> <p className="text-sm text-blue-800 dark:text-blue-200">
{workspaceType === 'existing' {workspaceType === 'existing'
? 'The workspace will be added to your project list and will be available for Claude/Cursor sessions.' ? t('projectWizard.step3.existingInfo')
: githubUrl : githubUrl
? 'A new workspace will be created and the repository will be cloned from GitHub.' ? t('projectWizard.step3.newWithClone')
: 'An empty workspace directory will be created at the specified path.'} : t('projectWizard.step3.newEmpty')}
</p> </p>
</div> </div>
</div> </div>
@@ -531,11 +580,11 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
disabled={isCreating} disabled={isCreating}
> >
{step === 1 ? ( {step === 1 ? (
'Cancel' t('projectWizard.buttons.cancel')
) : ( ) : (
<> <>
<ChevronLeft className="w-4 h-4 mr-1" /> <ChevronLeft className="w-4 h-4 mr-1" />
Back {t('projectWizard.buttons.back')}
</> </>
)} )}
</Button> </Button>
@@ -547,22 +596,137 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
{isCreating ? ( {isCreating ? (
<> <>
<Loader2 className="w-4 h-4 mr-2 animate-spin" /> <Loader2 className="w-4 h-4 mr-2 animate-spin" />
Creating... {t('projectWizard.buttons.creating')}
</> </>
) : step === 3 ? ( ) : step === 3 ? (
<> <>
<Check className="w-4 h-4 mr-1" /> <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" /> <ChevronRight className="w-4 h-4 ml-1" />
</> </>
)} )}
</Button> </Button>
</div> </div>
</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={() => 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>
{/* 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>
) : browserFolders.length === 0 ? (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
No folders found
</div>
) : (
<div className="space-y-1">
{/* Parent Directory */}
{browserCurrentPath !== '~' && browserCurrentPath !== '/' && (
<button
onClick={() => {
const parentPath = browserCurrentPath.substring(0, browserCurrentPath.lastIndexOf('/')) || '/';
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
.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, true)}
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)}
>
Cancel
</Button>
<Button
variant="outline"
onClick={() => selectFolder(browserCurrentPath, true)}
>
Use this folder
</Button>
</div>
</div>
</div>
</div>
)}
</div> </div>
); );
}; };

View File

@@ -15,8 +15,10 @@ import {
Languages, Languages,
GripVertical GripVertical
} from 'lucide-react'; } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import DarkModeToggle from './DarkModeToggle'; import DarkModeToggle from './DarkModeToggle';
import { useTheme } from '../contexts/ThemeContext'; import { useTheme } from '../contexts/ThemeContext';
import LanguageSelector from './LanguageSelector';
const QuickSettingsPanel = ({ const QuickSettingsPanel = ({
isOpen, isOpen,
@@ -33,6 +35,7 @@ const QuickSettingsPanel = ({
onSendByCtrlEnterChange, onSendByCtrlEnterChange,
isMobile isMobile
}) => { }) => {
const { t } = useTranslation('settings');
const [localIsOpen, setLocalIsOpen] = useState(isOpen); const [localIsOpen, setLocalIsOpen] = useState(isOpen);
const [whisperMode, setWhisperMode] = useState(() => { const [whisperMode, setWhisperMode] = useState(() => {
return localStorage.getItem('whisperMode') || 'default'; return localStorage.getItem('whisperMode') || 'default';
@@ -230,8 +233,8 @@ const QuickSettingsPanel = ({
isDragging ? 'cursor-grabbing' : 'cursor-pointer' isDragging ? 'cursor-grabbing' : 'cursor-pointer'
} touch-none`} } touch-none`}
style={{ ...getPositionStyle(), touchAction: 'none', WebkitTouchCallout: 'none', WebkitUserSelect: 'none' }} style={{ ...getPositionStyle(), touchAction: 'none', WebkitTouchCallout: 'none', WebkitUserSelect: 'none' }}
aria-label={isDragging ? 'Dragging handle' : localIsOpen ? 'Close settings panel' : 'Open settings panel'} aria-label={isDragging ? t('quickSettings.dragHandle.dragging') : localIsOpen ? t('quickSettings.dragHandle.closePanel') : t('quickSettings.dragHandle.openPanel')}
title={isDragging ? 'Dragging...' : 'Click to toggle, drag to move'} title={isDragging ? t('quickSettings.dragHandle.draggingStatus') : t('quickSettings.dragHandle.toggleAndMove')}
> >
{isDragging ? ( {isDragging ? (
<GripVertical className="h-5 w-5 text-blue-500 dark:text-blue-400" /> <GripVertical className="h-5 w-5 text-blue-500 dark:text-blue-400" />
@@ -253,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"> <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"> <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" /> <Settings2 className="h-5 w-5 text-gray-600 dark:text-gray-400" />
Quick Settings {t('quickSettings.title')}
</h3> </h3>
</div> </div>
@@ -261,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' : ''}`}> <div className={`flex-1 overflow-y-auto overflow-x-hidden p-4 space-y-6 bg-background ${isMobile ? 'pb-mobile-nav' : ''}`}>
{/* Appearance Settings */} {/* Appearance Settings */}
<div className="space-y-2"> <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"> <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"> <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" />} {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> </span>
<DarkModeToggle /> <DarkModeToggle />
</div> </div>
{/* Language Selector */}
<div>
<LanguageSelector compact={true} />
</div>
</div> </div>
{/* Tool Display Settings */} {/* Tool Display Settings */}
<div className="space-y-2"> <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"> <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"> <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" /> <Maximize2 className="h-4 w-4 text-gray-600 dark:text-gray-400" />
Auto-expand tools {t('quickSettings.autoExpandTools')}
</span> </span>
<input <input
type="checkbox" type="checkbox"
@@ -292,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"> <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"> <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" /> <Eye className="h-4 w-4 text-gray-600 dark:text-gray-400" />
Show raw parameters {t('quickSettings.showRawParameters')}
</span> </span>
<input <input
type="checkbox" type="checkbox"
@@ -305,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"> <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"> <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" /> <Brain className="h-4 w-4 text-gray-600 dark:text-gray-400" />
Show thinking {t('quickSettings.showThinking')}
</span> </span>
<input <input
type="checkbox" type="checkbox"
@@ -317,12 +325,12 @@ const QuickSettingsPanel = ({
</div> </div>
{/* View Options */} {/* View Options */}
<div className="space-y-2"> <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"> <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"> <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" /> <ArrowDown className="h-4 w-4 text-gray-600 dark:text-gray-400" />
Auto-scroll to bottom {t('quickSettings.autoScrollToBottom')}
</span> </span>
<input <input
type="checkbox" type="checkbox"
@@ -335,12 +343,12 @@ const QuickSettingsPanel = ({
{/* Input Settings */} {/* Input Settings */}
<div className="space-y-2"> <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"> <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"> <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" /> <Languages className="h-4 w-4 text-gray-600 dark:text-gray-400" />
Send by Ctrl+Enter {t('quickSettings.sendByCtrlEnter')}
</span> </span>
<input <input
type="checkbox" type="checkbox"
@@ -350,13 +358,13 @@ const QuickSettingsPanel = ({
/> />
</label> </label>
<p className="text-xs text-gray-500 dark:text-gray-400 ml-3"> <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> </p>
</div> </div>
{/* Whisper Dictation Settings - HIDDEN */} {/* Whisper Dictation Settings - HIDDEN */}
<div className="space-y-2" style={{ display: 'none' }}> <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"> <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"> <label className="flex items-start p-3 rounded-lg bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer transition-colors border border-transparent hover:border-gray-300 dark:hover:border-gray-600">
@@ -375,10 +383,10 @@ const QuickSettingsPanel = ({
<div className="ml-3 flex-1"> <div className="ml-3 flex-1">
<span className="flex items-center gap-2 text-sm font-medium text-gray-900 dark:text-white"> <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" /> <Mic className="h-4 w-4 text-gray-600 dark:text-gray-400" />
Default Mode {t('quickSettings.whisper.modes.default')}
</span> </span>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1"> <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Direct transcription of your speech {t('quickSettings.whisper.modes.defaultDescription')}
</p> </p>
</div> </div>
</label> </label>
@@ -399,10 +407,10 @@ const QuickSettingsPanel = ({
<div className="ml-3 flex-1"> <div className="ml-3 flex-1">
<span className="flex items-center gap-2 text-sm font-medium text-gray-900 dark:text-white"> <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" /> <Sparkles className="h-4 w-4 text-gray-600 dark:text-gray-400" />
Prompt Enhancement {t('quickSettings.whisper.modes.prompt')}
</span> </span>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1"> <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> </p>
</div> </div>
</label> </label>
@@ -423,10 +431,10 @@ const QuickSettingsPanel = ({
<div className="ml-3 flex-1"> <div className="ml-3 flex-1">
<span className="flex items-center gap-2 text-sm font-medium text-gray-900 dark:text-white"> <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" /> <FileText className="h-4 w-4 text-gray-600 dark:text-gray-400" />
Vibe Mode {t('quickSettings.whisper.modes.vibe')}
</span> </span>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1"> <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> </p>
</div> </div>
</label> </label>

View File

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

View File

@@ -4,6 +4,7 @@ import { FitAddon } from '@xterm/addon-fit';
import { WebglAddon } from '@xterm/addon-webgl'; import { WebglAddon } from '@xterm/addon-webgl';
import { WebLinksAddon } from '@xterm/addon-web-links'; import { WebLinksAddon } from '@xterm/addon-web-links';
import '@xterm/xterm/css/xterm.css'; import '@xterm/xterm/css/xterm.css';
import { useTranslation } from 'react-i18next';
const xtermStyles = ` const xtermStyles = `
.xterm .xterm-screen { .xterm .xterm-screen {
@@ -25,6 +26,7 @@ if (typeof document !== 'undefined') {
} }
function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell = false, onProcessComplete, minimal = false, autoConnect = false }) { function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell = false, onProcessComplete, minimal = false, autoConnect = false }) {
const { t } = useTranslation('chat');
const terminalRef = useRef(null); const terminalRef = useRef(null);
const terminal = useRef(null); const terminal = useRef(null);
const fitAddon = useRef(null); const fitAddon = useRef(null);
@@ -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" /> <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> </svg>
</div> </div>
<h3 className="text-lg font-semibold mb-2">Select a Project</h3> <h3 className="text-lg font-semibold mb-2">{t('shell.selectProject.title')}</h3>
<p>Choose a project to open an interactive shell in that directory</p> <p>{t('shell.selectProject.description')}</p>
</div> </div>
</div> </div>
); );
@@ -400,13 +402,13 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
</span> </span>
)} )}
{!selectedSession && ( {!selectedSession && (
<span className="text-xs text-gray-400">(New Session)</span> <span className="text-xs text-gray-400">{t('shell.status.newSession')}</span>
)} )}
{!isInitialized && ( {!isInitialized && (
<span className="text-xs text-yellow-400">(Initializing...)</span> <span className="text-xs text-yellow-400">{t('shell.status.initializing')}</span>
)} )}
{isRestarting && ( {isRestarting && (
<span className="text-xs text-blue-400">(Restarting...)</span> <span className="text-xs text-blue-400">{t('shell.status.restarting')}</span>
)} )}
</div> </div>
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
@@ -414,12 +416,12 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
<button <button
onClick={disconnectFromShell} 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" 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"> <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" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
<span>Disconnect</span> <span>{t('shell.actions.disconnect')}</span>
</button> </button>
)} )}
@@ -427,12 +429,12 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
onClick={restartShell} onClick={restartShell}
disabled={isRestarting || isConnected} 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" 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"> <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" /> <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> </svg>
<span>Restart</span> <span>{t('shell.actions.restart')}</span>
</button> </button>
</div> </div>
</div> </div>
@@ -443,7 +445,7 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
{!isInitialized && ( {!isInitialized && (
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-90"> <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> </div>
)} )}
@@ -453,19 +455,19 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
<button <button
onClick={connectToShell} 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" 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"> <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" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg> </svg>
<span>Continue in Shell</span> <span>{t('shell.actions.connect')}</span>
</button> </button>
<p className="text-gray-400 text-sm mt-3 px-2"> <p className="text-gray-400 text-sm mt-3 px-2">
{isPlainShell ? {isPlainShell ?
`Run ${initialCommand || 'command'} in ${selectedProject.displayName}` : t('shell.runCommand', { command: initialCommand || t('shell.defaultCommand'), projectName: selectedProject.displayName }) :
selectedSession ? selectedSession ?
`Resume session: ${sessionDisplayNameLong}...` : t('shell.resumeSession', { displayName: sessionDisplayNameLong }) :
'Start a new Claude session' t('shell.startSession')
} }
</p> </p>
</div> </div>
@@ -477,12 +479,12 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
<div className="text-center max-w-sm w-full"> <div className="text-center max-w-sm w-full">
<div className="flex items-center justify-center space-x-3 text-yellow-400"> <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> <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> </div>
<p className="text-gray-400 text-sm mt-3 px-2"> <p className="text-gray-400 text-sm mt-3 px-2">
{isPlainShell ? {isPlainShell ?
`Running ${initialCommand || 'command'} in ${selectedProject.displayName}` : t('shell.runCommand', { command: initialCommand || t('shell.defaultCommand'), projectName: selectedProject.displayName }) :
`Starting Claude CLI in ${selectedProject.displayName}` t('shell.startCli', { projectName: selectedProject.displayName })
} }
</p> </p>
</div> </div>

View File

@@ -4,8 +4,9 @@ import { ScrollArea } from './ui/scroll-area';
import { Button } from './ui/button'; import { Button } from './ui/button';
import { Badge } from './ui/badge'; import { Badge } from './ui/badge';
import { Input } from './ui/input'; 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 { cn } from '../lib/utils';
import ClaudeLogo from './ClaudeLogo'; import ClaudeLogo from './ClaudeLogo';
import CursorLogo from './CursorLogo.jsx'; import CursorLogo from './CursorLogo.jsx';
@@ -17,28 +18,28 @@ import { useTaskMaster } from '../contexts/TaskMasterContext';
import { useTasksSettings } from '../contexts/TasksSettingsContext'; import { useTasksSettings } from '../contexts/TasksSettingsContext';
// Move formatTimeAgo outside component to avoid recreation on every render // 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 date = new Date(dateString);
const now = currentTime; const now = currentTime;
// Check if date is valid // Check if date is valid
if (isNaN(date.getTime())) { if (isNaN(date.getTime())) {
return 'Unknown'; return t ? t('status.unknown') : 'Unknown';
} }
const diffInMs = now - date; const diffInMs = now - date;
const diffInSeconds = Math.floor(diffInMs / 1000); const diffInSeconds = Math.floor(diffInMs / 1000);
const diffInMinutes = Math.floor(diffInMs / (1000 * 60)); const diffInMinutes = Math.floor(diffInMs / (1000 * 60));
const diffInHours = Math.floor(diffInMs / (1000 * 60 * 60)); const diffInHours = Math.floor(diffInMs / (1000 * 60 * 60));
const diffInDays = Math.floor(diffInMs / (1000 * 60 * 60 * 24)); const diffInDays = Math.floor(diffInMs / (1000 * 60 * 60 * 24));
if (diffInSeconds < 60) return 'Just now'; if (diffInSeconds < 60) return t ? t('time.justNow') : 'Just now';
if (diffInMinutes === 1) return '1 min ago'; if (diffInMinutes === 1) return t ? t('time.oneMinuteAgo') : '1 min ago';
if (diffInMinutes < 60) return `${diffInMinutes} mins ago`; if (diffInMinutes < 60) return t ? t('time.minutesAgo', { count: diffInMinutes }) : `${diffInMinutes} mins ago`;
if (diffInHours === 1) return '1 hour ago'; if (diffInHours === 1) return t ? t('time.oneHourAgo') : '1 hour ago';
if (diffInHours < 24) return `${diffInHours} hours ago`; if (diffInHours < 24) return t ? t('time.hoursAgo', { count: diffInHours }) : `${diffInHours} hours ago`;
if (diffInDays === 1) return '1 day ago'; if (diffInDays === 1) return t ? t('time.oneDayAgo') : '1 day ago';
if (diffInDays < 7) return `${diffInDays} days ago`; if (diffInDays < 7) return t ? t('time.daysAgo', { count: diffInDays }) : `${diffInDays} days ago`;
return date.toLocaleDateString(); return date.toLocaleDateString();
}; };
@@ -52,6 +53,7 @@ function Sidebar({
onSessionDelete, onSessionDelete,
onProjectDelete, onProjectDelete,
isLoading, isLoading,
loadingProgress,
onRefresh, onRefresh,
onShowSettings, onShowSettings,
updateAvailable, updateAvailable,
@@ -63,6 +65,7 @@ function Sidebar({
isMobile, isMobile,
onToggleSidebar onToggleSidebar
}) { }) {
const { t } = useTranslation('sidebar');
const [expandedProjects, setExpandedProjects] = useState(new Set()); const [expandedProjects, setExpandedProjects] = useState(new Set());
const [editingProject, setEditingProject] = useState(null); const [editingProject, setEditingProject] = useState(null);
const [showNewProject, setShowNewProject] = useState(false); const [showNewProject, setShowNewProject] = useState(false);
@@ -77,6 +80,9 @@ function Sidebar({
const [editingSessionName, setEditingSessionName] = useState(''); const [editingSessionName, setEditingSessionName] = useState('');
const [generatingSummary, setGeneratingSummary] = useState({}); const [generatingSummary, setGeneratingSummary] = useState({});
const [searchFilter, setSearchFilter] = 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 // TaskMaster context
const { setCurrentProject, mcpServerStatus } = useTaskMaster(); const { setCurrentProject, mcpServerStatus } = useTaskMaster();
@@ -303,10 +309,15 @@ function Sidebar({
setEditingName(''); setEditingName('');
}; };
const deleteSession = async (projectName, sessionId, provider = 'claude') => { const showDeleteSessionConfirmation = (projectName, sessionId, sessionTitle, provider = 'claude') => {
if (!confirm('Are you sure you want to delete this session? This action cannot be undone.')) { setSessionDeleteConfirmation({ projectName, sessionId, sessionTitle, provider });
return; };
}
const confirmDeleteSession = async () => {
if (!sessionDeleteConfirmation) return;
const { projectName, sessionId, provider } = sessionDeleteConfirmation;
setSessionDeleteConfirmation(null);
try { try {
console.log('[Sidebar] Deleting session:', { projectName, sessionId, provider }); console.log('[Sidebar] Deleting session:', { projectName, sessionId, provider });
@@ -332,58 +343,72 @@ function Sidebar({
} else { } else {
const errorText = await response.text(); const errorText = await response.text();
console.error('[Sidebar] Failed to delete session:', { status: response.status, error: errorText }); 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) { } catch (error) {
console.error('[Sidebar] Error deleting session:', error); console.error('[Sidebar] Error deleting session:', error);
alert('Error deleting session. Please try again.'); alert(t('messages.deleteSessionError'));
} }
}; };
const deleteProject = async (projectName) => { const deleteProject = (project) => {
if (!confirm('Are you sure you want to delete this empty project? This action cannot be undone.')) { const sessionCount = getAllSessions(project).length;
return; 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 { try {
const response = await api.deleteProject(projectName); const response = await api.deleteProject(project.name, !isEmpty);
if (response.ok) { if (response.ok) {
// Call parent callback if provided
if (onProjectDelete) { if (onProjectDelete) {
onProjectDelete(projectName); onProjectDelete(project.name);
} }
} else { } else {
const error = await response.json(); const error = await response.json();
console.error('Failed to delete project'); console.error('Failed to delete project');
alert(error.error || 'Failed to delete project. Please try again.'); alert(error.error || t('messages.deleteProjectFailed'));
} }
} catch (error) { } catch (error) {
console.error('Error deleting project:', 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 () => { const createNewProject = async () => {
if (!newProjectPath.trim()) { if (!newProjectPath.trim()) {
alert('Please enter a project path'); alert(t('messages.enterProjectPath'));
return; return;
} }
setCreatingProject(true); setCreatingProject(true);
try { try {
const response = await api.createProject(newProjectPath.trim()); const response = await api.createProject(newProjectPath.trim());
if (response.ok) { if (response.ok) {
const result = await response.json(); const result = await response.json();
// Save the path to recent paths before clearing // Save the path to recent paths before clearing
saveToRecentPaths(newProjectPath.trim()); saveToRecentPaths(newProjectPath.trim());
setShowNewProject(false); setShowNewProject(false);
setNewProjectPath(''); setNewProjectPath('');
// Refresh projects to show the new one // Refresh projects to show the new one
if (window.refreshProjects) { if (window.refreshProjects) {
window.refreshProjects(); window.refreshProjects();
@@ -392,11 +417,11 @@ function Sidebar({
} }
} else { } else {
const error = await response.json(); const error = await response.json();
alert(error.error || 'Failed to create project. Please try again.'); alert(error.error || t('messages.createProjectFailed'));
} }
} catch (error) { } catch (error) {
console.error('Error creating project:', error); console.error('Error creating project:', error);
alert('Error creating project. Please try again.'); alert(t('messages.createProjectError'));
} finally { } finally {
setCreatingProject(false); setCreatingProject(false);
} }
@@ -485,6 +510,110 @@ function Sidebar({
document.body 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 <div
className="h-full flex flex-col bg-card md:select-none" className="h-full flex flex-col bg-card md:select-none"
style={isPWA && isMobile ? { paddingTop: '44px' } : {}} style={isPWA && isMobile ? { paddingTop: '44px' } : {}}
@@ -497,14 +626,14 @@ function Sidebar({
<a <a
href="https://cloudcli.ai/dashboard" href="https://cloudcli.ai/dashboard"
className="flex items-center gap-3 hover:opacity-80 transition-opacity group" 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"> <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" /> <MessageSquare className="w-4 h-4 text-primary-foreground" />
</div> </div>
<div> <div>
<h1 className="text-lg font-bold text-foreground">Claude Code UI</h1> <h1 className="text-lg font-bold text-foreground">{t('app.title')}</h1>
<p className="text-sm text-muted-foreground">AI coding assistant interface</p> <p className="text-sm text-muted-foreground">{t('app.subtitle')}</p>
</div> </div>
</a> </a>
) : ( ) : (
@@ -513,8 +642,8 @@ function Sidebar({
<MessageSquare className="w-4 h-4 text-primary-foreground" /> <MessageSquare className="w-4 h-4 text-primary-foreground" />
</div> </div>
<div> <div>
<h1 className="text-lg font-bold text-foreground">Claude Code UI</h1> <h1 className="text-lg font-bold text-foreground">{t('app.title')}</h1>
<p className="text-sm text-muted-foreground">AI coding assistant interface</p> <p className="text-sm text-muted-foreground">{t('app.subtitle')}</p>
</div> </div>
</div> </div>
)} )}
@@ -524,7 +653,7 @@ function Sidebar({
size="sm" size="sm"
className="h-8 w-8 px-0 hover:bg-accent transition-colors duration-200" className="h-8 w-8 px-0 hover:bg-accent transition-colors duration-200"
onClick={onToggleSidebar} onClick={onToggleSidebar}
title="Hide sidebar" title={t('tooltips.hideSidebar')}
> >
<svg <svg
className="w-4 h-4" className="w-4 h-4"
@@ -548,14 +677,14 @@ function Sidebar({
<a <a
href="https://cloudcli.ai/dashboard" href="https://cloudcli.ai/dashboard"
className="flex items-center gap-3 active:opacity-70 transition-opacity" 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"> <div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center">
<MessageSquare className="w-4 h-4 text-primary-foreground" /> <MessageSquare className="w-4 h-4 text-primary-foreground" />
</div> </div>
<div> <div>
<h1 className="text-lg font-semibold text-foreground">Claude Code UI</h1> <h1 className="text-lg font-semibold text-foreground">{t('app.title')}</h1>
<p className="text-sm text-muted-foreground">Projects</p> <p className="text-sm text-muted-foreground">{t('projects.title')}</p>
</div> </div>
</a> </a>
) : ( ) : (
@@ -564,8 +693,8 @@ function Sidebar({
<MessageSquare className="w-4 h-4 text-primary-foreground" /> <MessageSquare className="w-4 h-4 text-primary-foreground" />
</div> </div>
<div> <div>
<h1 className="text-lg font-semibold text-foreground">Claude Code UI</h1> <h1 className="text-lg font-semibold text-foreground">{t('app.title')}</h1>
<p className="text-sm text-muted-foreground">Projects</p> <p className="text-sm text-muted-foreground">{t('projects.title')}</p>
</div> </div>
</div> </div>
)} )}
@@ -604,10 +733,10 @@ function Sidebar({
size="sm" size="sm"
className="flex-1 h-8 text-xs bg-primary hover:bg-primary/90 transition-all duration-200" className="flex-1 h-8 text-xs bg-primary hover:bg-primary/90 transition-all duration-200"
onClick={() => setShowNewProject(true)} onClick={() => setShowNewProject(true)}
title="Create new project" title={t('tooltips.createProject')}
> >
<FolderPlus className="w-3.5 h-3.5 mr-1.5" /> <FolderPlus className="w-3.5 h-3.5 mr-1.5" />
New Project {t('projects.newProject')}
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
@@ -622,7 +751,7 @@ function Sidebar({
} }
}} }}
disabled={isRefreshing} disabled={isRefreshing}
title="Refresh projects and sessions (Ctrl+R)" title={t('tooltips.refresh')}
> >
<RefreshCw className={`w-3.5 h-3.5 ${isRefreshing ? 'animate-spin' : ''} group-hover:rotate-180 transition-transform duration-300`} /> <RefreshCw className={`w-3.5 h-3.5 ${isRefreshing ? 'animate-spin' : ''} group-hover:rotate-180 transition-transform duration-300`} />
</Button> </Button>
@@ -637,7 +766,7 @@ function Sidebar({
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" /> <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input <Input
type="text" type="text"
placeholder="Search projects..." placeholder={t('projects.searchPlaceholder')}
value={searchFilter} value={searchFilter}
onChange={(e) => setSearchFilter(e.target.value)} 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" className="pl-9 h-9 text-sm bg-muted/50 border-0 focus:bg-background focus:ring-1 focus:ring-primary/20"
@@ -662,19 +791,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-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 className="w-6 h-6 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
</div> </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"> <p className="text-sm text-muted-foreground">
Fetching your Claude projects and sessions {t('projects.fetchingProjects')}
</p> </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> </div>
) : projects.length === 0 ? ( ) : projects.length === 0 ? (
<div className="text-center py-12 md:py-8 px-4"> <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"> <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" /> <Folder className="w-6 h-6 text-muted-foreground" />
</div> </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"> <p className="text-sm text-muted-foreground">
Run Claude CLI in a project directory to get started {t('projects.runClaudeCli')}
</p> </p>
</div> </div>
) : filteredProjects.length === 0 ? ( ) : filteredProjects.length === 0 ? (
@@ -682,9 +834,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"> <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" /> <Search className="w-6 h-6 text-muted-foreground" />
</div> </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"> <p className="text-sm text-muted-foreground">
Try adjusting your search term {t('projects.tryDifferentSearch')}
</p> </p>
</div> </div>
) : ( ) : (
@@ -692,9 +844,10 @@ function Sidebar({
const isExpanded = expandedProjects.has(project.name); const isExpanded = expandedProjects.has(project.name);
const isSelected = selectedProject?.name === project.name; const isSelected = selectedProject?.name === project.name;
const isStarred = isProjectStarred(project.name); const isStarred = isProjectStarred(project.name);
const isDeleting = deletingProjects.has(project.name);
return ( 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 */} {/* Project Header */}
<div className="group md:group"> <div className="group md:group">
{/* Mobile Project Item */} {/* Mobile Project Item */}
@@ -730,7 +883,7 @@ function Sidebar({
value={editingName} value={editingName}
onChange={(e) => setEditingName(e.target.value)} 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" 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 autoFocus
autoComplete="off" autoComplete="off"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
@@ -814,7 +967,7 @@ function Sidebar({
toggleStarProject(project.name); toggleStarProject(project.name);
}} }}
onTouchEnd={handleTouchClick(() => 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( <Star className={cn(
"w-4 h-4 transition-colors", "w-4 h-4 transition-colors",
@@ -823,18 +976,16 @@ function Sidebar({
: "text-gray-600 dark:text-gray-400" : "text-gray-600 dark:text-gray-400"
)} /> )} />
</button> </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" 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) => { onClick={(e) => {
e.stopPropagation(); 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" /> <Trash2 className="w-4 h-4 text-red-600 dark:text-red-400" />
</button> </button>
)}
<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" 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) => { onClick={(e) => {
@@ -895,7 +1046,7 @@ function Sidebar({
value={editingName} value={editingName}
onChange={(e) => setEditingName(e.target.value)} 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" 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 autoFocus
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter') saveProjectName(project.name); if (e.key === 'Enter') saveProjectName(project.name);
@@ -964,7 +1115,7 @@ function Sidebar({
e.stopPropagation(); e.stopPropagation();
toggleStarProject(project.name); toggleStarProject(project.name);
}} }}
title={isStarred ? "Remove from favorites" : "Add to favorites"} title={isStarred ? t('tooltips.removeFromFavorites') : t('tooltips.addToFavorites')}
> >
<Star className={cn( <Star className={cn(
"w-3 h-3 transition-colors", "w-3 h-3 transition-colors",
@@ -979,22 +1130,20 @@ function Sidebar({
e.stopPropagation(); e.stopPropagation();
startEditing(project); startEditing(project);
}} }}
title="Rename project (F2)" title={t('tooltips.renameProject')}
> >
<Edit3 className="w-3 h-3" /> <Edit3 className="w-3 h-3" />
</div> </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" 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) => { onClick={(e) => {
e.stopPropagation(); 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" /> <Trash2 className="w-3 h-3 text-red-600 dark:text-red-400" />
</div> </div>
)}
{isExpanded ? ( {isExpanded ? (
<ChevronDown className="w-4 h-4 text-muted-foreground group-hover:text-foreground transition-colors" /> <ChevronDown className="w-4 h-4 text-muted-foreground group-hover:text-foreground transition-colors" />
) : ( ) : (
@@ -1024,7 +1173,7 @@ function Sidebar({
)) ))
) : getAllSessions(project).length === 0 && !loadingSessions[project.name] ? ( ) : getAllSessions(project).length === 0 && !loadingSessions[project.name] ? (
<div className="py-2 px-3 text-left"> <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> </div>
) : ( ) : (
getAllSessions(project).map((session) => { getAllSessions(project).map((session) => {
@@ -1044,9 +1193,9 @@ function Sidebar({
// Get session display values // Get session display values
const getSessionName = () => { const getSessionName = () => {
if (isCursorSession) return session.name || 'Untitled Session'; if (isCursorSession) return session.name || t('projects.untitledSession');
if (isCodexSession) return session.summary || session.name || 'Codex Session'; if (isCodexSession) return session.summary || session.name || t('projects.codexSession');
return session.summary || 'New Session'; return session.summary || t('projects.newSession');
}; };
const sessionName = getSessionName(); const sessionName = getSessionName();
const getSessionTime = () => { const getSessionTime = () => {
@@ -1102,7 +1251,7 @@ function Sidebar({
<div className="flex items-center gap-1 mt-0.5"> <div className="flex items-center gap-1 mt-0.5">
<Clock className="w-2.5 h-2.5 text-muted-foreground" /> <Clock className="w-2.5 h-2.5 text-muted-foreground" />
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{formatTimeAgo(sessionTime, currentTime)} {formatTimeAgo(sessionTime, currentTime, t)}
</span> </span>
{messageCount > 0 && ( {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">
@@ -1126,9 +1275,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" 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) => { onClick={(e) => {
e.stopPropagation(); 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" /> <Trash2 className="w-2.5 h-2.5 text-red-600 dark:text-red-400" />
</button> </button>
@@ -1163,14 +1312,14 @@ function Sidebar({
<div className="flex items-center gap-1 mt-0.5"> <div className="flex items-center gap-1 mt-0.5">
<Clock className="w-2.5 h-2.5 text-muted-foreground" /> <Clock className="w-2.5 h-2.5 text-muted-foreground" />
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{formatTimeAgo(sessionTime, currentTime)} {formatTimeAgo(sessionTime, currentTime, t)}
</span> </span>
{messageCount > 0 && ( {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} {messageCount}
</Badge> </Badge>
)} )}
<span className="ml-1 opacity-70"> <span className="ml-1 opacity-70 group-hover:opacity-0 transition-opacity">
{isCursorSession ? ( {isCursorSession ? (
<CursorLogo className="w-3 h-3" /> <CursorLogo className="w-3 h-3" />
) : isCodexSession ? ( ) : isCodexSession ? (
@@ -1210,7 +1359,7 @@ function Sidebar({
e.stopPropagation(); e.stopPropagation();
updateSessionSummary(project.name, session.id, editingSessionName); 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" /> <Check className="w-3 h-3 text-green-600 dark:text-green-400" />
</button> </button>
@@ -1221,7 +1370,7 @@ function Sidebar({
setEditingSession(null); setEditingSession(null);
setEditingSessionName(''); setEditingSessionName('');
}} }}
title="Cancel" title={t('tooltips.cancel')}
> >
<X className="w-3 h-3 text-gray-600 dark:text-gray-400" /> <X className="w-3 h-3 text-gray-600 dark:text-gray-400" />
</button> </button>
@@ -1234,9 +1383,9 @@ function Sidebar({
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setEditingSession(session.id); 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" /> <Edit2 className="w-3 h-3 text-gray-600 dark:text-gray-400" />
</button> </button>
@@ -1245,9 +1394,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" 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) => { onClick={(e) => {
e.stopPropagation(); 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" /> <Trash2 className="w-3 h-3 text-red-600 dark:text-red-400" />
</button> </button>
@@ -1273,18 +1422,18 @@ function Sidebar({
{loadingSessions[project.name] ? ( {loadingSessions[project.name] ? (
<> <>
<div className="w-3 h-3 animate-spin rounded-full border border-muted-foreground border-t-transparent" /> <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" /> <ChevronDown className="w-3 h-3" />
Show more sessions {t('sessions.showMore')}
</> </>
)} )}
</Button> </Button>
)} )}
{/* New Session Button */} {/* Sessions - New Session Button */}
<div className="md:hidden px-3 pb-2"> <div className="md:hidden px-3 pb-2">
<button <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" 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"
@@ -1294,7 +1443,7 @@ function Sidebar({
}} }}
> >
<Plus className="w-3 h-3" /> <Plus className="w-3 h-3" />
New Session {t('sessions.newSession')}
</button> </button>
</div> </div>
@@ -1305,7 +1454,7 @@ function Sidebar({
onClick={() => onNewSession(project)} onClick={() => onNewSession(project)}
> >
<Plus className="w-3 h-3" /> <Plus className="w-3 h-3" />
New Session {t('sessions.newSession')}
</Button> </Button>
</div> </div>
)} )}
@@ -1336,7 +1485,7 @@ function Sidebar({
<div className="text-sm font-medium text-blue-700 dark:text-blue-300"> <div className="text-sm font-medium text-blue-700 dark:text-blue-300">
{releaseInfo?.title || `Version ${latestVersion}`} {releaseInfo?.title || `Version ${latestVersion}`}
</div> </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> </div>
</Button> </Button>
</div> </div>
@@ -1357,7 +1506,7 @@ function Sidebar({
<div className="text-sm font-medium text-blue-700 dark:text-blue-300"> <div className="text-sm font-medium text-blue-700 dark:text-blue-300">
{releaseInfo?.title || `Version ${latestVersion}`} {releaseInfo?.title || `Version ${latestVersion}`}
</div> </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> </div>
</button> </button>
</div> </div>
@@ -1375,7 +1524,7 @@ function Sidebar({
<div className="w-10 h-10 rounded-2xl bg-background/80 flex items-center justify-center"> <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" /> <Settings className="w-5 h-5 text-muted-foreground" />
</div> </div>
<span className="text-lg font-medium text-foreground">Settings</span> <span className="text-lg font-medium text-foreground">{t('actions.settings')}</span>
</button> </button>
</div> </div>
@@ -1386,7 +1535,7 @@ function Sidebar({
onClick={onShowSettings} onClick={onShowSettings}
> >
<Settings className="w-3 h-3" /> <Settings className="w-3 h-3" />
<span className="text-xs">Settings</span> <span className="text-xs">{t('actions.settings')}</span>
</Button> </Button>
</div> </div>
</div> </div>

View File

@@ -1,7 +1,9 @@
import { Zap } from 'lucide-react'; import { Zap } from 'lucide-react';
import { useTasksSettings } from '../contexts/TasksSettingsContext'; import { useTasksSettings } from '../contexts/TasksSettingsContext';
import { useTranslation } from 'react-i18next';
function TasksSettings() { function TasksSettings() {
const { t } = useTranslation('settings');
const { const {
tasksEnabled, tasksEnabled,
setTasksEnabled, 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="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="flex items-center gap-3">
<div className="animate-spin w-5 h-5 border-2 border-blue-600 border-t-transparent rounded-full"></div> <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>
</div> </div>
) : ( ) : (
@@ -32,13 +34,13 @@ function TasksSettings() {
</div> </div>
<div className="flex-1"> <div className="flex-1">
<div className="font-medium text-orange-900 dark:text-orange-100 mb-2"> <div className="font-medium text-orange-900 dark:text-orange-100 mb-2">
TaskMaster AI CLI Not Installed {t('tasks.notInstalled.title')}
</div> </div>
<div className="text-sm text-orange-800 dark:text-orange-200 space-y-3"> <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"> <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>
<div> <div>
@@ -51,7 +53,7 @@ function TasksSettings() {
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"> <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" /> <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> </svg>
View on GitHub {t('tasks.notInstalled.viewOnGitHub')}
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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" /> <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> </svg>
@@ -59,11 +61,11 @@ function TasksSettings() {
</div> </div>
<div className="space-y-2"> <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"> <ol className="list-decimal list-inside space-y-1 text-xs">
<li>Restart this application</li> <li>{t('tasks.notInstalled.steps.restart')}</li>
<li>TaskMaster features will automatically become available</li> <li>{t('tasks.notInstalled.steps.autoAvailable')}</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.initCommand')}</li>
</ol> </ol>
</div> </div>
</div> </div>
@@ -79,10 +81,10 @@ function TasksSettings() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<div className="font-medium text-foreground"> <div className="font-medium text-foreground">
Enable TaskMaster Integration {t('tasks.settings.enableLabel')}
</div> </div>
<div className="text-sm text-muted-foreground mt-1"> <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>
</div> </div>
<label className="relative inline-flex items-center cursor-pointer"> <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 ClaudeLogo from '../ClaudeLogo';
import CursorLogo from '../CursorLogo'; import CursorLogo from '../CursorLogo';
import CodexLogo from '../CodexLogo'; import CodexLogo from '../CodexLogo';
import { useTranslation } from 'react-i18next';
const agentConfig = { const agentConfig = {
claude: { claude: {
@@ -39,6 +40,7 @@ const agentConfig = {
}; };
export default function AccountContent({ agent, authStatus, onLogin }) { export default function AccountContent({ agent, authStatus, onLogin }) {
const { t } = useTranslation('settings');
const config = agentConfig[agent]; const config = agentConfig[agent];
const { Logo } = config; const { Logo } = config;
@@ -47,8 +49,8 @@ export default function AccountContent({ agent, authStatus, onLogin }) {
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
<Logo className="w-6 h-6" /> <Logo className="w-6 h-6" />
<div> <div>
<h3 className="text-lg font-medium text-foreground">{config.name} Account</h3> <h3 className="text-lg font-medium text-foreground">{config.name}</h3>
<p className="text-sm text-muted-foreground">{config.description}</p> <p className="text-sm text-muted-foreground">{t(`agents.account.${agent}.description`)}</p>
</div> </div>
</div> </div>
@@ -58,30 +60,30 @@ export default function AccountContent({ agent, authStatus, onLogin }) {
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex-1"> <div className="flex-1">
<div className={`font-medium ${config.textClass}`}> <div className={`font-medium ${config.textClass}`}>
Connection Status {t('agents.connectionStatus')}
</div> </div>
<div className={`text-sm ${config.subtextClass}`}> <div className={`text-sm ${config.subtextClass}`}>
{authStatus?.loading ? ( {authStatus?.loading ? (
'Checking authentication status...' t('agents.authStatus.checkingAuth')
) : authStatus?.authenticated ? ( ) : 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> </div>
<div> <div>
{authStatus?.loading ? ( {authStatus?.loading ? (
<Badge variant="secondary" className="bg-gray-100 dark:bg-gray-800"> <Badge variant="secondary" className="bg-gray-100 dark:bg-gray-800">
Checking... {t('agents.authStatus.checking')}
</Badge> </Badge>
) : authStatus?.authenticated ? ( ) : authStatus?.authenticated ? (
<Badge variant="success" className="bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300"> <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>
) : ( ) : (
<Badge variant="secondary" className="bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300"> <Badge variant="secondary" className="bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300">
Disconnected {t('agents.authStatus.disconnected')}
</Badge> </Badge>
)} )}
</div> </div>
@@ -91,12 +93,12 @@ export default function AccountContent({ agent, authStatus, onLogin }) {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<div className={`font-medium ${config.textClass}`}> <div className={`font-medium ${config.textClass}`}>
{authStatus?.authenticated ? 'Re-authenticate' : 'Login'} {authStatus?.authenticated ? t('agents.login.reAuthenticate') : t('agents.login.title')}
</div> </div>
<div className={`text-sm ${config.subtextClass}`}> <div className={`text-sm ${config.subtextClass}`}>
{authStatus?.authenticated {authStatus?.authenticated
? 'Sign in with a different account or refresh credentials' ? t('agents.login.reAuthDescription')
: `Sign in to your ${config.name} account to enable AI features`} : t('agents.login.description', { agent: config.name })}
</div> </div>
</div> </div>
<Button <Button
@@ -105,7 +107,7 @@ export default function AccountContent({ agent, authStatus, onLogin }) {
size="sm" size="sm"
> >
<LogIn className="w-4 h-4 mr-2" /> <LogIn className="w-4 h-4 mr-2" />
{authStatus?.authenticated ? 'Re-login' : 'Login'} {authStatus?.authenticated ? t('agents.login.reLoginButton') : t('agents.login.button')}
</Button> </Button>
</div> </div>
</div> </div>
@@ -113,7 +115,7 @@ export default function AccountContent({ agent, authStatus, onLogin }) {
{authStatus?.error && ( {authStatus?.error && (
<div className="border-t border-gray-200 dark:border-gray-700 pt-4"> <div className="border-t border-gray-200 dark:border-gray-700 pt-4">
<div className="text-sm text-red-600 dark:text-red-400"> <div className="text-sm text-red-600 dark:text-red-400">
Error: {authStatus.error} {t('agents.error', { error: authStatus.error })}
</div> </div>
</div> </div>
)} )}

View File

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

View File

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

View File

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

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,218 @@
{
"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": "Where should the workspace be created?",
"existingPlaceholder": "/path/to/existing/workspace",
"newPlaceholder": "/path/to/new/workspace",
"existingHelp": "Full path to your existing workspace directory",
"newHelp": "Full path where the new workspace will be created",
"githubUrl": "GitHub URL (Optional)",
"githubPlaceholder": "https://github.com/username/repository",
"githubHelp": "Leave empty to create an empty workspace, or provide a GitHub URL to clone",
"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",
"existingInfo": "The workspace will be added to your project list and will be available for Claude/Cursor sessions.",
"newWithClone": "A new workspace will be created and the repository will be cloned from GitHub.",
"newEmpty": "An empty workspace directory will be created at the specified path."
},
"buttons": {
"cancel": "Cancel",
"back": "Back",
"next": "Next",
"createProject": "Create Project",
"creating": "Creating..."
},
"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"
}
},
"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,218 @@
{
"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": "无身份验证",
"existingInfo": "工作区将被添加到您的项目列表中,并可用于 Claude/Cursor 会话。",
"newWithClone": "将创建新工作区,并从 GitHub 克隆仓库。",
"newEmpty": "将在指定路径创建一个空的工作区目录。"
},
"buttons": {
"cancel": "取消",
"back": "返回",
"next": "下一步",
"createProject": "创建项目",
"creating": "创建中..."
},
"errors": {
"selectType": "请选择您已有现有工作区还是想创建新工作区",
"providePath": "请提供工作区路径",
"failedToCreate": "创建工作区失败"
}
},
"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

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

View File

@@ -79,8 +79,8 @@ export const api = {
authenticatedFetch(`/api/codex/sessions/${sessionId}`, { authenticatedFetch(`/api/codex/sessions/${sessionId}`, {
method: 'DELETE', method: 'DELETE',
}), }),
deleteProject: (projectName) => deleteProject: (projectName, force = false) =>
authenticatedFetch(`/api/projects/${projectName}`, { authenticatedFetch(`/api/projects/${projectName}${force ? '?force=true' : ''}`, {
method: 'DELETE', method: 'DELETE',
}), }),
createProject: (path) => createProject: (path) =>