mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-14 02:12:04 +08:00
Compare commits
63 Commits
v1.14.0
...
23801e9cc1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
23801e9cc1 | ||
|
|
4f6ff9260d | ||
|
|
49061bc7a3 | ||
|
|
50e097d4ac | ||
|
|
f986004319 | ||
|
|
f488a346ef | ||
|
|
82efac4704 | ||
|
|
81697d0e73 | ||
|
|
27bf09b0c1 | ||
|
|
7ccbc8d92d | ||
|
|
597e9c54b7 | ||
|
|
cccd915c33 | ||
|
|
0207a1f3a3 | ||
|
|
38a593c97f | ||
|
|
fc369d047e | ||
|
|
9d8e92b5a4 | ||
|
|
07f1d9a4a8 | ||
|
|
e853d29584 | ||
|
|
09af23bcaf | ||
|
|
520e3f2280 | ||
|
|
151e8ee808 | ||
|
|
2cfcae049b | ||
|
|
8723393b66 | ||
|
|
29b80b1905 | ||
|
|
c55e996f3a | ||
|
|
e7800c494f | ||
|
|
374fe35915 | ||
|
|
33b0ea4c4a | ||
|
|
412102c531 | ||
|
|
afe1be7fca | ||
|
|
42f13e151c | ||
|
|
272eb00602 | ||
|
|
f891316ec0 | ||
|
|
1ed3358cbd | ||
|
|
c1e025b665 | ||
|
|
cf3d23ee31 | ||
|
|
e7d6c40452 | ||
|
|
216932e7f9 | ||
|
|
e9719256fc | ||
|
|
55caaf060c | ||
|
|
f9c7321c8c | ||
|
|
88bda6e5c0 | ||
|
|
86b421c790 | ||
|
|
41ef84c283 | ||
|
|
53224e47b6 | ||
|
|
bbb51dbf99 | ||
|
|
2d06cae0ca | ||
|
|
14fb81586c | ||
|
|
4d2b592ec6 | ||
|
|
4957220a05 | ||
|
|
3debc3a249 | ||
|
|
5512e2e15b | ||
|
|
1b42dba902 | ||
|
|
ede56ad81b | ||
|
|
36094fb73f | ||
|
|
57828653bf | ||
|
|
8ef0951901 | ||
|
|
ab50c5c1a8 | ||
|
|
6726e8f44e | ||
|
|
07f89e5240 | ||
|
|
8a675a713b | ||
|
|
5724c11253 | ||
|
|
c7b9976986 |
@@ -1,4 +1,4 @@
|
||||
# Claude Code UI Environment Configuration
|
||||
# CloudCLI UI Environment Configuration
|
||||
# Only includes variables that are actually used in the code
|
||||
#
|
||||
# TIP: Run 'cloudcli status' to see where this file should be located
|
||||
@@ -21,6 +21,10 @@ PORT=3001
|
||||
#Frontend port
|
||||
VITE_PORT=5173
|
||||
|
||||
# Host/IP to bind servers to (default: 0.0.0.0 for all interfaces)
|
||||
# Use 127.0.0.1 to restrict to localhost only
|
||||
HOST=0.0.0.0
|
||||
|
||||
# Uncomment the following line if you have a custom claude cli path other than the default "claude"
|
||||
# CLAUDE_CLI_PATH=claude
|
||||
|
||||
|
||||
@@ -2,20 +2,39 @@
|
||||
"git": {
|
||||
"commitMessage": "Release ${version}",
|
||||
"tagName": "v${version}",
|
||||
"changelog": "git log --pretty=format:\"* %s (%h)\" ${from}...${to}"
|
||||
"requireBranch": "main",
|
||||
"requireCleanWorkingDir": true
|
||||
},
|
||||
"npm": {
|
||||
"publish": true
|
||||
},
|
||||
"github": {
|
||||
"release": true,
|
||||
"releaseName": "Claude Code UI v${version}",
|
||||
"releaseNotes": {
|
||||
"commit": "* ${commit.subject} (${sha}){ - thanks @${author.login}!}",
|
||||
"excludeMatches": ["viper151"]
|
||||
}
|
||||
"releaseName": "CloudCLI UI v${version}"
|
||||
},
|
||||
"hooks": {
|
||||
"before:init": ["npm run build"]
|
||||
},
|
||||
"plugins": {
|
||||
"@release-it/conventional-changelog": {
|
||||
"infile": "CHANGELOG.md",
|
||||
"header": "# Changelog\n\nAll notable changes to CloudCLI UI will be documented in this file.\n",
|
||||
"preset": {
|
||||
"name": "conventionalcommits",
|
||||
"types": [
|
||||
{ "type": "feat", "section": "New Features" },
|
||||
{ "type": "feature", "section": "New Features" },
|
||||
{ "type": "fix", "section": "Bug Fixes" },
|
||||
{ "type": "perf", "section": "Performance" },
|
||||
{ "type": "refactor", "section": "Refactoring" },
|
||||
{ "type": "docs", "section": "Documentation" },
|
||||
{ "type": "style", "section": "Styling" },
|
||||
{ "type": "chore", "section": "Maintenance" },
|
||||
{ "type": "ci", "section": "CI/CD" },
|
||||
{ "type": "test", "section": "Tests" },
|
||||
{ "type": "build", "section": "Build" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
37
CHANGELOG.md
Normal file
37
CHANGELOG.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to CloudCLI UI will be documented in this file.
|
||||
|
||||
|
||||
## [1.20.1](https://github.com/siteboon/claudecodeui/compare/v1.19.1...v1.20.1) (2026-02-23)
|
||||
|
||||
### New Features
|
||||
|
||||
* implement install mode detection and update commands in version upgrade process ([f986004](https://github.com/siteboon/claudecodeui/commit/f986004319207b068431f9f6adf338a8ce8decfc))
|
||||
* migrate legacy database to new location and improve last login update handling ([50e097d](https://github.com/siteboon/claudecodeui/commit/50e097d4ac498aa9f1803ef3564843721833dc19))
|
||||
|
||||
## [1.19.1](https://github.com/siteboon/claudecodeui/compare/v1.19.0...v1.19.1) (2026-02-23)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add prepublishOnly script to build before publishing ([82efac4](https://github.com/siteboon/claudecodeui/commit/82efac4704cab11ed8d1a05fe84f41312140b223))
|
||||
|
||||
## [1.19.0](https://github.com/siteboon/claudecodeui/compare/v1.18.2...v1.19.0) (2026-02-23)
|
||||
|
||||
### New Features
|
||||
|
||||
* add HOST environment variable for configurable bind address ([#360](https://github.com/siteboon/claudecodeui/issues/360)) ([cccd915](https://github.com/siteboon/claudecodeui/commit/cccd915c336192216b6e6f68e2b5f3ece0ccf966))
|
||||
* subagent tool grouping ([#398](https://github.com/siteboon/claudecodeui/issues/398)) ([0207a1f](https://github.com/siteboon/claudecodeui/commit/0207a1f3a3c87f1c6c1aee8213be999b23289386))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **macos:** fix node-pty posix_spawnp error with postinstall script ([#347](https://github.com/siteboon/claudecodeui/issues/347)) ([38a593c](https://github.com/siteboon/claudecodeui/commit/38a593c97fdb2bb7f051e09e8e99c16035448655)), closes [#284](https://github.com/siteboon/claudecodeui/issues/284)
|
||||
* slash commands with arguments bypass command execution ([#392](https://github.com/siteboon/claudecodeui/issues/392)) ([597e9c5](https://github.com/siteboon/claudecodeui/commit/597e9c54b76e7c6cd1947299c668c78d24019cab))
|
||||
|
||||
### Refactoring
|
||||
|
||||
* **releases:** Create a contributing guide and proper release notes using a release-it plugin ([fc369d0](https://github.com/siteboon/claudecodeui/commit/fc369d047e13cba9443fe36c0b6bb2ce3beaf61c))
|
||||
|
||||
### Maintenance
|
||||
|
||||
* update @anthropic-ai/claude-agent-sdk to version 0.1.77 in package-lock.json ([#410](https://github.com/siteboon/claudecodeui/issues/410)) ([7ccbc8d](https://github.com/siteboon/claudecodeui/commit/7ccbc8d92d440e18c157b656c9ea2635044a64f6))
|
||||
156
CONTRIBUTING.md
Normal file
156
CONTRIBUTING.md
Normal file
@@ -0,0 +1,156 @@
|
||||
# Contributing to CloudCLI UI
|
||||
|
||||
Thanks for your interest in contributing to CloudCLI UI! Before you start, please take a moment to read through this guide.
|
||||
|
||||
## Before You Start
|
||||
|
||||
- **Search first.** Check [existing issues](https://github.com/siteboon/claudecodeui/issues) and [pull requests](https://github.com/siteboon/claudecodeui/pulls) to avoid duplicating work.
|
||||
- **Discuss first** for new features. Open an [issue](https://github.com/siteboon/claudecodeui/issues/new) to discuss your idea before investing time in implementation. We may already have plans or opinions on how it should work.
|
||||
- **Bug fixes are always welcome.** If you spot a bug, feel free to open a PR directly.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [Node.js](https://nodejs.org/) 22 or later
|
||||
- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) installed and configured
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. Fork the repository
|
||||
2. Clone your fork:
|
||||
```bash
|
||||
git clone https://github.com/<your-username>/claudecodeui.git
|
||||
cd claudecodeui
|
||||
```
|
||||
3. Install dependencies:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
4. Start the development server:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
5. Create a branch for your changes:
|
||||
```bash
|
||||
git checkout -b feat/your-feature-name
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
claudecodeui/
|
||||
├── src/ # React frontend (Vite + Tailwind)
|
||||
│ ├── components/ # UI components
|
||||
│ ├── contexts/ # React context providers
|
||||
│ ├── hooks/ # Custom React hooks
|
||||
│ ├── i18n/ # Internationalization and translations
|
||||
│ ├── lib/ # Shared frontend libraries
|
||||
│ ├── types/ # TypeScript type definitions
|
||||
│ └── utils/ # Frontend utilities
|
||||
├── server/ # Express backend
|
||||
│ ├── routes/ # API route handlers
|
||||
│ ├── middleware/ # Express middleware
|
||||
│ ├── database/ # SQLite database layer
|
||||
│ └── tools/ # CLI tool integrations
|
||||
├── shared/ # Code shared between client and server
|
||||
└── public/ # Static assets, icons, PWA manifest
|
||||
```
|
||||
|
||||
## Development Workflow
|
||||
|
||||
- `npm run dev` — Start both the frontend and backend in development mode
|
||||
- `npm run build` — Create a production build
|
||||
- `npm run server` — Start only the backend server
|
||||
- `npm run client` — Start only the Vite dev server
|
||||
|
||||
## Making Changes
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Reference the issue number in your PR if one exists
|
||||
- Describe how to reproduce the bug in your PR description
|
||||
- Add a screenshot or recording for visual bugs
|
||||
|
||||
### New Features
|
||||
|
||||
- Keep the scope focused — one feature per PR
|
||||
- Include screenshots or recordings for UI changes
|
||||
|
||||
### Documentation
|
||||
|
||||
- Documentation improvements are always welcome
|
||||
- Keep language clear and concise
|
||||
|
||||
## Commit Convention
|
||||
|
||||
We follow [Conventional Commits](https://conventionalcommits.org/) to generate release notes automatically. Every commit message should follow this format:
|
||||
|
||||
```
|
||||
<type>(optional scope): <description>
|
||||
```
|
||||
|
||||
Use imperative, present tense: "add feature" not "added feature" or "adds feature".
|
||||
|
||||
### Types
|
||||
|
||||
| Type | Description |
|
||||
|------|-------------|
|
||||
| `feat` | A new feature |
|
||||
| `fix` | A bug fix |
|
||||
| `perf` | A performance improvement |
|
||||
| `refactor` | Code change that neither fixes a bug nor adds a feature |
|
||||
| `docs` | Documentation only |
|
||||
| `style` | CSS, formatting, visual changes |
|
||||
| `chore` | Maintenance, dependencies, config |
|
||||
| `ci` | CI/CD pipeline changes |
|
||||
| `test` | Adding or updating tests |
|
||||
| `build` | Build system changes |
|
||||
|
||||
### Examples
|
||||
|
||||
```bash
|
||||
feat: add conversation search
|
||||
feat(i18n): add Japanese language support
|
||||
fix: redirect unauthenticated users to login
|
||||
fix(editor): syntax highlighting for .env files
|
||||
perf: lazy load code editor component
|
||||
refactor(chat): extract message list component
|
||||
docs: update API configuration guide
|
||||
```
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
Add `!` after the type or include `BREAKING CHANGE:` in the commit footer:
|
||||
|
||||
```bash
|
||||
feat!: redesign settings page layout
|
||||
```
|
||||
|
||||
## Pull Requests
|
||||
|
||||
- Give your PR a clear, descriptive title following the commit convention above
|
||||
- Fill in the PR description with what changed and why
|
||||
- Link any related issues
|
||||
- Include screenshots for UI changes
|
||||
- Make sure the build passes (`npm run build`)
|
||||
- Keep PRs focused — avoid unrelated changes
|
||||
|
||||
## Releases
|
||||
|
||||
Releases are managed by maintainers using [release-it](https://github.com/release-it/release-it) with the [conventional changelog plugin](https://github.com/release-it/conventional-changelog).
|
||||
|
||||
```bash
|
||||
npm run release # interactive (prompts for version bump)
|
||||
npm run release -- patch # patch release
|
||||
npm run release -- minor # minor release
|
||||
```
|
||||
|
||||
This automatically:
|
||||
- Bumps the version based on commit types (`feat` = minor, `fix` = patch)
|
||||
- Generates categorized release notes
|
||||
- Updates `CHANGELOG.md`
|
||||
- Creates a git tag and GitHub Release
|
||||
- Publishes to npm
|
||||
|
||||
## License
|
||||
|
||||
By contributing, you agree that your contributions will be licensed under the [GPL-3.0 License](LICENSE).
|
||||
346
README.ja.md
Normal file
346
README.ja.md
Normal file
@@ -0,0 +1,346 @@
|
||||
<div align="center">
|
||||
<img src="public/logo.svg" alt="Claude Code UI" width="64" height="64">
|
||||
<h1>Cloud CLI (別名 Claude Code UI)</h1>
|
||||
</div>
|
||||
|
||||
|
||||
[Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[Cursor CLI](https://docs.cursor.com/en/cli/overview)、[Codex](https://developers.openai.com/codex) 向けのデスクトップ・モバイル UI です。ローカルまたはリモートで使用して、Claude Code、Cursor、Codex のアクティブなプロジェクトやセッションを確認し、どこからでも(モバイルやデスクトップから)変更を加えることができます。どこでも使える適切なインターフェースを提供します。
|
||||
|
||||
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">中文</a></i></div>
|
||||
|
||||
## スクリーンショット
|
||||
|
||||
<div align="center">
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<h3>デスクトップビュー</h3>
|
||||
<img src="public/screenshots/desktop-main.png" alt="Desktop Interface" width="400">
|
||||
<br>
|
||||
<em>プロジェクト概要とチャットを表示するメインインターフェース</em>
|
||||
</td>
|
||||
<td align="center">
|
||||
<h3>モバイル体験</h3>
|
||||
<img src="public/screenshots/mobile-chat.png" alt="Mobile Interface" width="250">
|
||||
<br>
|
||||
<em>タッチナビゲーション対応のレスポンシブモバイルデザイン</em>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" colspan="2">
|
||||
<h3>CLI 選択</h3>
|
||||
<img src="public/screenshots/cli-selection.png" alt="CLI Selection" width="400">
|
||||
<br>
|
||||
<em>Claude Code、Cursor CLI、Codex から選択</em>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
## 機能
|
||||
|
||||
- **レスポンシブデザイン** - デスクトップ、タブレット、モバイルでシームレスに動作し、モバイルからも Claude Code、Cursor、Codex を使用可能
|
||||
- **インタラクティブチャットインターフェース** - Claude Code、Cursor、Codex とシームレスに通信する組み込みチャットインターフェース
|
||||
- **統合シェルターミナル** - 組み込みシェル機能による Claude Code、Cursor CLI、Codex への直接アクセス
|
||||
- **ファイルエクスプローラー** - シンタックスハイライトとライブ編集対応のインタラクティブファイルツリー
|
||||
- **Git エクスプローラー** - 変更の確認、ステージング、コミット。ブランチの切り替えも可能
|
||||
- **セッション管理** - 会話の再開、複数セッションの管理、履歴の追跡
|
||||
- **TaskMaster AI 統合** *(オプション)* - AI 駆動のタスク計画、PRD 解析、ワークフロー自動化による高度なプロジェクト管理
|
||||
- **モデル互換性** - Claude Sonnet 4.5、Opus 4.5、GPT-5.2 に対応
|
||||
|
||||
|
||||
## クイックスタート
|
||||
|
||||
### 前提条件
|
||||
|
||||
- [Node.js](https://nodejs.org/) v22 以上
|
||||
- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) のインストールと設定、および/または
|
||||
- [Cursor CLI](https://docs.cursor.com/en/cli/overview) のインストールと設定、および/または
|
||||
- [Codex](https://developers.openai.com/codex) のインストールと設定
|
||||
|
||||
### ワンクリック実行(推奨)
|
||||
|
||||
インストール不要、直接実行:
|
||||
|
||||
```bash
|
||||
npx @siteboon/claude-code-ui
|
||||
```
|
||||
|
||||
サーバーが起動し、`http://localhost:3001`(または設定した PORT)でアクセスできます。
|
||||
|
||||
**再起動**: サーバーを停止した後、同じ `npx` コマンドを再度実行するだけです
|
||||
### グローバルインストール(定期的に使用する場合)
|
||||
|
||||
頻繁に使用する場合は、一度だけグローバルインストール:
|
||||
|
||||
```bash
|
||||
npm install -g @siteboon/claude-code-ui
|
||||
```
|
||||
|
||||
シンプルなコマンドで起動:
|
||||
|
||||
```bash
|
||||
claude-code-ui
|
||||
```
|
||||
|
||||
|
||||
**再起動**: Ctrl+C で停止し、`claude-code-ui` を再度実行します。
|
||||
|
||||
**アップデート**:
|
||||
```bash
|
||||
cloudcli update
|
||||
```
|
||||
|
||||
### CLI の使い方
|
||||
|
||||
グローバルインストール後、`claude-code-ui` と `cloudcli` コマンドが使用できます:
|
||||
|
||||
| コマンド / オプション | 短縮形 | 説明 |
|
||||
|------------------|-------|-------------|
|
||||
| `cloudcli` または `claude-code-ui` | | サーバーを起動(デフォルト) |
|
||||
| `cloudcli start` | | サーバーを明示的に起動 |
|
||||
| `cloudcli status` | | 設定とデータの場所を表示 |
|
||||
| `cloudcli update` | | 最新バージョンに更新 |
|
||||
| `cloudcli help` | | ヘルプ情報を表示 |
|
||||
| `cloudcli version` | | バージョン情報を表示 |
|
||||
| `--port <port>` | `-p` | サーバーポートを設定(デフォルト: 3001) |
|
||||
| `--database-path <path>` | | カスタムデータベースの場所を設定 |
|
||||
|
||||
**例:**
|
||||
```bash
|
||||
cloudcli # デフォルト設定で起動
|
||||
cloudcli -p 8080 # カスタムポートで起動
|
||||
cloudcli status # 現在の設定を表示
|
||||
```
|
||||
|
||||
### バックグラウンドサービスとして実行(本番環境推奨)
|
||||
|
||||
本番環境では、PM2(Process Manager 2)を使用して Claude Code UI をバックグラウンドサービスとして実行します:
|
||||
|
||||
#### PM2 のインストール
|
||||
|
||||
```bash
|
||||
npm install -g pm2
|
||||
```
|
||||
|
||||
#### バックグラウンドサービスとして起動
|
||||
|
||||
```bash
|
||||
# バックグラウンドでサーバーを起動
|
||||
pm2 start claude-code-ui --name "claude-code-ui"
|
||||
|
||||
# または短いエイリアスを使用
|
||||
pm2 start cloudcli --name "claude-code-ui"
|
||||
|
||||
# カスタムポートで起動
|
||||
pm2 start cloudcli --name "claude-code-ui" -- --port 8080
|
||||
```
|
||||
|
||||
|
||||
#### システム起動時の自動起動
|
||||
|
||||
システム起動時に Claude Code UI を自動的に起動するには:
|
||||
|
||||
```bash
|
||||
# プラットフォーム用の起動スクリプトを生成
|
||||
pm2 startup
|
||||
|
||||
# 現在のプロセスリストを保存
|
||||
pm2 save
|
||||
```
|
||||
|
||||
|
||||
### ローカル開発インストール
|
||||
|
||||
1. **リポジトリをクローン:**
|
||||
```bash
|
||||
git clone https://github.com/siteboon/claudecodeui.git
|
||||
cd claudecodeui
|
||||
```
|
||||
|
||||
2. **依存関係をインストール:**
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. **環境を設定:**
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# お好みの設定で .env を編集
|
||||
```
|
||||
|
||||
4. **アプリケーションを起動:**
|
||||
```bash
|
||||
# 開発モード(ホットリロード付き)
|
||||
npm run dev
|
||||
|
||||
```
|
||||
アプリケーションは .env で指定したポートで起動します
|
||||
|
||||
5. **ブラウザを開く:**
|
||||
- 開発: `http://localhost:3001`
|
||||
|
||||
## セキュリティとツール設定
|
||||
|
||||
**重要なお知らせ**: すべての Claude Code ツールは**デフォルトで無効**になっています。これにより、潜在的に有害な操作が自動的に実行されることを防ぎます。
|
||||
|
||||
### ツールの有効化
|
||||
|
||||
Claude Code の全機能を使用するには、手動でツールを有効にする必要があります:
|
||||
|
||||
1. **ツール設定を開く** - サイドバーの歯車アイコンをクリック
|
||||
3. **選択的に有効化** - 必要なツールのみを有効にする
|
||||
4. **設定を適用** - 環境設定はローカルに保存されます
|
||||
|
||||
<div align="center">
|
||||
|
||||

|
||||
*ツール設定インターフェース - 必要なものだけを有効にしましょう*
|
||||
|
||||
</div>
|
||||
|
||||
**推奨アプローチ**: 基本的なツールから有効にし、必要に応じて追加してください。これらの設定はいつでも調整できます。
|
||||
|
||||
## TaskMaster AI 統合 *(オプション)*
|
||||
|
||||
Claude Code UI は、高度なプロジェクト管理と AI 駆動のタスク計画のための **[TaskMaster AI](https://github.com/eyaltoledano/claude-task-master)**(別名 claude-task-master)統合をサポートしています。
|
||||
|
||||
提供機能
|
||||
- PRD(製品要件ドキュメント)からの AI 駆動タスク生成
|
||||
- スマートなタスク分解と依存関係管理
|
||||
- ビジュアルタスクボードと進捗追跡
|
||||
|
||||
**セットアップとドキュメント**: インストール手順、設定ガイド、使用例は [TaskMaster AI GitHub リポジトリ](https://github.com/eyaltoledano/claude-task-master)をご覧ください。
|
||||
インストール後、設定から有効にできます
|
||||
|
||||
|
||||
## 使用ガイド
|
||||
|
||||
### 主要機能
|
||||
|
||||
#### プロジェクト管理
|
||||
Claude Code、Cursor、Codex のセッションが利用可能な場合、自動的に検出しプロジェクトとしてグループ化します
|
||||
- **プロジェクト操作** - プロジェクトの名前変更、削除、整理
|
||||
- **スマートナビゲーション** - 最近のプロジェクトやセッションへのクイックアクセス
|
||||
- **MCP サポート** - UI から独自の MCP サーバーを追加
|
||||
|
||||
#### チャットインターフェース
|
||||
- **レスポンシブチャットまたは Claude Code/Cursor CLI/Codex CLI を使用** - アダプティブチャットインターフェースを使用するか、シェルボタンで選択した CLI に接続できます
|
||||
- **リアルタイム通信** - WebSocket 接続で選択した CLI(Claude Code/Cursor/Codex)からレスポンスをストリーミング
|
||||
- **セッション管理** - 以前の会話を再開、または新しいセッションを開始
|
||||
- **メッセージ履歴** - タイムスタンプとメタデータ付きの完全な会話履歴
|
||||
- **マルチフォーマット対応** - テキスト、コードブロック、ファイル参照
|
||||
|
||||
#### ファイルエクスプローラーとエディター
|
||||
- **インタラクティブファイルツリー** - 展開/折りたたみナビゲーションでプロジェクト構造を閲覧
|
||||
- **ライブファイル編集** - インターフェースで直接ファイルの読み取り、変更、保存
|
||||
- **シンタックスハイライト** - 複数のプログラミング言語に対応
|
||||
- **ファイル操作** - ファイルやディレクトリの作成、名前変更、削除
|
||||
|
||||
#### Git エクスプローラー
|
||||
|
||||
|
||||
#### TaskMaster AI 統合 *(オプション)*
|
||||
- **ビジュアルタスクボード** - 開発タスク管理のためのカンバンスタイルインターフェース
|
||||
- **PRD パーサー** - 製品要件ドキュメントを作成し、構造化されたタスクに変換
|
||||
- **進捗追跡** - リアルタイムのステータス更新と完了追跡
|
||||
|
||||
#### セッション管理
|
||||
- **セッション永続化** - すべての会話を自動保存
|
||||
- **セッション整理** - プロジェクトとタイムスタンプでセッションをグループ化
|
||||
- **セッション操作** - 会話履歴の名前変更、削除、エクスポート
|
||||
- **クロスデバイス同期** - どのデバイスからでもセッションにアクセス
|
||||
|
||||
### モバイルアプリ
|
||||
- **レスポンシブデザイン** - すべての画面サイズに最適化
|
||||
- **タッチフレンドリーインターフェース** - スワイプジェスチャーとタッチナビゲーション
|
||||
- **モバイルナビゲーション** - 親指で操作しやすいボトムタブバー
|
||||
- **アダプティブレイアウト** - 折りたたみ可能なサイドバーとスマートコンテンツ優先順位
|
||||
- **ホーム画面にショートカットを追加** - ホーム画面にショートカットを追加すると、アプリが PWA のように動作します
|
||||
|
||||
## アーキテクチャ
|
||||
|
||||
### システム概要
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ Frontend │ │ Backend │ │ Agent │
|
||||
│ (React/Vite) │◄──►│ (Express/WS) │◄──►│ Integration │
|
||||
│ │ │ │ │ │
|
||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||
```
|
||||
|
||||
### バックエンド (Node.js + Express)
|
||||
- **Express サーバー** - 静的ファイル配信付きの RESTful API
|
||||
- **WebSocket サーバー** - チャットとプロジェクト更新のための通信
|
||||
- **エージェント統合 (Claude Code / Cursor CLI / Codex)** - プロセスの生成と管理
|
||||
- **ファイルシステム API** - プロジェクト向けファイルブラウザの公開
|
||||
|
||||
### フロントエンド (React + Vite)
|
||||
- **React 18** - hooks を使用したモダンなコンポーネントアーキテクチャ
|
||||
- **CodeMirror** - シンタックスハイライト対応の高度なコードエディター
|
||||
|
||||
|
||||
|
||||
|
||||
### コントリビューション
|
||||
|
||||
コントリビューションを歓迎します!コミット規約、開発ワークフロー、リリースプロセスの詳細は [Contributing Guide](CONTRIBUTING.md) をご覧ください。
|
||||
|
||||
## トラブルシューティング
|
||||
|
||||
### よくある問題と解決方法
|
||||
|
||||
|
||||
#### 「Claude プロジェクトが見つかりません」
|
||||
**問題**: UI にプロジェクトが表示されない、またはプロジェクトリストが空
|
||||
**解決方法**:
|
||||
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) が正しくインストールされていることを確認
|
||||
- 少なくとも1つのプロジェクトディレクトリで `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>
|
||||
345
README.ko.md
Normal file
345
README.ko.md
Normal file
@@ -0,0 +1,345 @@
|
||||
<div align="center">
|
||||
<img src="public/logo.svg" alt="Claude Code UI" width="64" height="64">
|
||||
<h1>Cloud CLI (일명 Claude Code UI)</h1>
|
||||
</div>
|
||||
|
||||
|
||||
[Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Cursor CLI](https://docs.cursor.com/en/cli/overview) 및 [Codex](https://developers.openai.com/codex)를 위한 데스크톱 및 모바일 UI입니다. 로컬 또는 원격으로 사용하여 Claude Code, Cursor 또는 Codex의 활성 프로젝트와 세션을 확인하고, 어디서든(모바일 또는 데스크톱) 변경할 수 있습니다. 어디서든 작동하는 적절한 인터페이스를 제공합니다.
|
||||
|
||||
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.zh-CN.md">中文</a> · <a href="./README.ja.md">日本語</a></i></div>
|
||||
|
||||
## 스크린샷
|
||||
|
||||
<div align="center">
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<h3>데스크톱 뷰</h3>
|
||||
<img src="public/screenshots/desktop-main.png" alt="Desktop Interface" width="400">
|
||||
<br>
|
||||
<em>프로젝트 개요와 채팅을 보여주는 메인 인터페이스</em>
|
||||
</td>
|
||||
<td align="center">
|
||||
<h3>모바일 경험</h3>
|
||||
<img src="public/screenshots/mobile-chat.png" alt="Mobile Interface" width="250">
|
||||
<br>
|
||||
<em>터치 내비게이션이 포함된 반응형 모바일 디자인</em>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" colspan="2">
|
||||
<h3>CLI 선택</h3>
|
||||
<img src="public/screenshots/cli-selection.png" alt="CLI Selection" width="400">
|
||||
<br>
|
||||
<em>Claude Code, Cursor CLI, Codex 중 선택</em>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
## 기능
|
||||
|
||||
- **반응형 디자인** - 데스크톱, 태블릿, 모바일에서 원활하게 작동하여 모바일에서도 Claude Code, Cursor 또는 Codex를 사용할 수 있습니다
|
||||
- **대화형 채팅 인터페이스** - Claude Code, Cursor 또는 Codex와 원활하게 소통하는 내장 채팅 인터페이스
|
||||
- **통합 셸 터미널** - 내장 셸 기능을 통한 Claude Code, Cursor CLI 또는 Codex 직접 접근
|
||||
- **파일 탐색기** - 구문 강조 및 실시간 편집이 가능한 대화형 파일 트리
|
||||
- **Git 탐색기** - 변경사항 보기, 스테이징 및 커밋. 브랜치 전환도 가능
|
||||
- **세션 관리** - 대화 재개, 여러 세션 관리 및 기록 추적
|
||||
- **TaskMaster AI 통합** *(선택사항)* - AI 기반 작업 계획, PRD 분석 및 워크플로우 자동화를 통한 고급 프로젝트 관리
|
||||
- **모델 호환성** - Claude Sonnet 4.5, Opus 4.5 및 GPT-5.2 지원
|
||||
|
||||
|
||||
## 빠른 시작
|
||||
|
||||
### 사전 요구사항
|
||||
|
||||
- [Node.js](https://nodejs.org/) v22 이상
|
||||
- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) 설치 및 구성, 그리고/또는
|
||||
- [Cursor CLI](https://docs.cursor.com/en/cli/overview) 설치 및 구성, 그리고/또는
|
||||
- [Codex](https://developers.openai.com/codex) 설치 및 구성
|
||||
|
||||
### 원클릭 실행 (권장)
|
||||
|
||||
설치 없이 바로 실행:
|
||||
|
||||
```bash
|
||||
npx @siteboon/claude-code-ui
|
||||
```
|
||||
|
||||
서버가 시작되면 `http://localhost:3001` (또는 설정한 PORT)에서 접근할 수 있습니다.
|
||||
|
||||
**재시작**: 서버를 중지한 후 동일한 `npx` 명령을 다시 실행하면 됩니다
|
||||
### 전역 설치 (정기적 사용 시)
|
||||
|
||||
자주 사용하는 경우 한 번만 전역 설치:
|
||||
|
||||
```bash
|
||||
npm install -g @siteboon/claude-code-ui
|
||||
```
|
||||
|
||||
간단한 명령으로 시작:
|
||||
|
||||
```bash
|
||||
claude-code-ui
|
||||
```
|
||||
|
||||
|
||||
**재시작**: Ctrl+C로 중지한 후 `claude-code-ui`를 다시 실행합니다.
|
||||
|
||||
**업데이트**:
|
||||
```bash
|
||||
cloudcli update
|
||||
```
|
||||
|
||||
### CLI 사용법
|
||||
|
||||
전역 설치 후 `claude-code-ui`와 `cloudcli` 명령을 사용할 수 있습니다:
|
||||
|
||||
| 명령 / 옵션 | 약어 | 설명 |
|
||||
|------------------|-------|-------------|
|
||||
| `cloudcli` 또는 `claude-code-ui` | | 서버 시작 (기본값) |
|
||||
| `cloudcli start` | | 서버 명시적 시작 |
|
||||
| `cloudcli status` | | 구성 및 데이터 위치 표시 |
|
||||
| `cloudcli update` | | 최신 버전으로 업데이트 |
|
||||
| `cloudcli help` | | 도움말 정보 표시 |
|
||||
| `cloudcli version` | | 버전 정보 표시 |
|
||||
| `--port <port>` | `-p` | 서버 포트 설정 (기본값: 3001) |
|
||||
| `--database-path <path>` | | 사용자 지정 데이터베이스 위치 설정 |
|
||||
|
||||
**예시:**
|
||||
```bash
|
||||
cloudcli # 기본 설정으로 시작
|
||||
cloudcli -p 8080 # 사용자 지정 포트로 시작
|
||||
cloudcli status # 현재 구성 표시
|
||||
```
|
||||
|
||||
### 백그라운드 서비스로 실행 (프로덕션 권장)
|
||||
|
||||
프로덕션 환경에서는 PM2(Process Manager 2)를 사용하여 Claude Code UI를 백그라운드 서비스로 실행하세요:
|
||||
|
||||
#### PM2 설치
|
||||
|
||||
```bash
|
||||
npm install -g pm2
|
||||
```
|
||||
|
||||
#### 백그라운드 서비스로 시작
|
||||
|
||||
```bash
|
||||
# 백그라운드에서 서버 시작
|
||||
pm2 start claude-code-ui --name "claude-code-ui"
|
||||
|
||||
# 또는 짧은 별칭 사용
|
||||
pm2 start cloudcli --name "claude-code-ui"
|
||||
|
||||
# 사용자 지정 포트로 시작
|
||||
pm2 start cloudcli --name "claude-code-ui" -- --port 8080
|
||||
```
|
||||
|
||||
|
||||
#### 시스템 부팅 시 자동 시작
|
||||
|
||||
시스템 부팅 시 Claude Code UI를 자동으로 시작하려면:
|
||||
|
||||
```bash
|
||||
# 플랫폼에 맞는 시작 스크립트 생성
|
||||
pm2 startup
|
||||
|
||||
# 현재 프로세스 목록 저장
|
||||
pm2 save
|
||||
```
|
||||
|
||||
|
||||
### 로컬 개발 설치
|
||||
|
||||
1. **리포지토리 클론:**
|
||||
```bash
|
||||
git clone https://github.com/siteboon/claudecodeui.git
|
||||
cd claudecodeui
|
||||
```
|
||||
|
||||
2. **의존성 설치:**
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. **환경 구성:**
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# 원하는 설정으로 .env 파일 편집
|
||||
```
|
||||
|
||||
4. **애플리케이션 시작:**
|
||||
```bash
|
||||
# 개발 모드 (핫 리로드 포함)
|
||||
npm run dev
|
||||
|
||||
```
|
||||
애플리케이션은 .env에서 지정한 포트에서 시작됩니다
|
||||
|
||||
5. **브라우저 열기:**
|
||||
- 개발: `http://localhost:3001`
|
||||
|
||||
## 보안 및 도구 설정
|
||||
|
||||
**🔒 중요 공지**: 모든 Claude Code 도구는 **기본적으로 비활성화**되어 있습니다. 이는 잠재적으로 유해한 작업이 자동으로 실행되는 것을 방지합니다.
|
||||
|
||||
### 도구 활성화
|
||||
|
||||
Claude Code의 전체 기능을 사용하려면 수동으로 도구를 활성화해야 합니다:
|
||||
|
||||
1. **도구 설정 열기** - 사이드바의 톱니바퀴 아이콘을 클릭
|
||||
3. **선택적으로 활성화** - 필요한 도구만 활성화
|
||||
4. **설정 적용** - 환경설정은 로컬에 저장됩니다
|
||||
|
||||
<div align="center">
|
||||
|
||||

|
||||
*도구 설정 인터페이스 - 필요한 것만 활성화하세요*
|
||||
|
||||
</div>
|
||||
|
||||
**권장 접근법**: 기본 도구부터 활성화하고 필요에 따라 추가하세요. 언제든지 이 설정을 조정할 수 있습니다.
|
||||
|
||||
## TaskMaster AI 통합 *(선택사항)*
|
||||
|
||||
Claude Code UI는 고급 프로젝트 관리 및 AI 기반 작업 계획을 위한 **[TaskMaster AI](https://github.com/eyaltoledano/claude-task-master)**(일명 claude-task-master) 통합을 지원합니다.
|
||||
|
||||
제공 기능
|
||||
- PRD(제품 요구사항 문서)에서 AI 기반 작업 생성
|
||||
- 스마트 작업 분해 및 의존성 관리
|
||||
- 시각적 작업 보드 및 진행 상황 추적
|
||||
|
||||
**설정 및 문서**: 설치 지침, 구성 가이드 및 사용 예시는 [TaskMaster AI GitHub 리포지토리](https://github.com/eyaltoledano/claude-task-master)를 방문하세요.
|
||||
설치 후 설정에서 활성화할 수 있습니다
|
||||
|
||||
|
||||
## 사용 가이드
|
||||
|
||||
### 핵심 기능
|
||||
|
||||
#### 프로젝트 관리
|
||||
Claude Code, Cursor 또는 Codex 세션을 사용할 수 있을 때 자동으로 발견하고 프로젝트로 그룹화합니다
|
||||
- **프로젝트 작업** - 프로젝트 이름 변경, 삭제 및 정리
|
||||
- **스마트 내비게이션** - 최근 프로젝트 및 세션에 빠르게 접근
|
||||
- **MCP 지원** - UI를 통해 자체 MCP 서버 추가
|
||||
|
||||
#### 채팅 인터페이스
|
||||
- **반응형 채팅 또는 Claude Code/Cursor CLI/Codex CLI 사용** - 적응형 채팅 인터페이스를 사용하거나 셸 버튼을 사용하여 선택한 CLI에 연결할 수 있습니다
|
||||
- **실시간 통신** - WebSocket 연결을 통해 선택한 CLI(Claude Code/Cursor/Codex)에서 응답 스트리밍
|
||||
- **세션 관리** - 이전 대화 재개 또는 새 세션 시작
|
||||
- **메시지 기록** - 타임스탬프 및 메타데이터가 포함된 전체 대화 기록
|
||||
- **다중 형식 지원** - 텍스트, 코드 블록 및 파일 참조
|
||||
|
||||
#### 파일 탐색기 및 편집기
|
||||
- **대화형 파일 트리** - 확장/축소 내비게이션으로 프로젝트 구조 탐색
|
||||
- **실시간 파일 편집** - 인터페이스에서 직접 파일 읽기, 수정 및 저장
|
||||
- **구문 강조** - 다양한 프로그래밍 언어 지원
|
||||
- **파일 작업** - 파일 및 디렉토리 생성, 이름 변경, 삭제
|
||||
|
||||
#### Git 탐색기
|
||||
|
||||
|
||||
#### TaskMaster AI 통합 *(선택사항)*
|
||||
- **시각적 작업 보드** - 개발 작업 관리를 위한 칸반 스타일 인터페이스
|
||||
- **PRD 파서** - 제품 요구사항 문서를 생성하고 구조화된 작업으로 변환
|
||||
- **진행 상황 추적** - 실시간 상태 업데이트 및 완료 추적
|
||||
|
||||
#### 세션 관리
|
||||
- **세션 지속성** - 모든 대화 자동 저장
|
||||
- **세션 정리** - 프로젝트 및 타임스탬프별 세션 그룹화
|
||||
- **세션 작업** - 대화 기록 이름 변경, 삭제 및 내보내기
|
||||
- **크로스 디바이스 동기화** - 모든 기기에서 세션 접근
|
||||
|
||||
### 모바일 앱
|
||||
- **반응형 디자인** - 모든 화면 크기에 최적화
|
||||
- **터치 친화적 인터페이스** - 스와이프 제스처 및 터치 내비게이션
|
||||
- **모바일 내비게이션** - 엄지 내비게이션을 위한 하단 탭 바
|
||||
- **적응형 레이아웃** - 접을 수 있는 사이드바 및 스마트 콘텐츠 우선순위
|
||||
- **홈 화면 바로가기 추가** - 홈 화면에 바로가기를 추가하면 앱이 PWA처럼 작동합니다
|
||||
|
||||
## 아키텍처
|
||||
|
||||
### 시스템 개요
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ Frontend │ │ Backend │ │ Agent │
|
||||
│ (React/Vite) │◄──►│ (Express/WS) │◄──►│ Integration │
|
||||
│ │ │ │ │ │
|
||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||
```
|
||||
|
||||
### 백엔드 (Node.js + Express)
|
||||
- **Express 서버** - 정적 파일 제공이 포함된 RESTful API
|
||||
- **WebSocket 서버** - 채팅 및 프로젝트 새로고침을 위한 통신
|
||||
- **에이전트 통합 (Claude Code / Cursor CLI / Codex)** - 프로세스 생성 및 관리
|
||||
- **파일 시스템 API** - 프로젝트를 위한 파일 브라우저 노출
|
||||
|
||||
### 프론트엔드 (React + Vite)
|
||||
- **React 18** - hooks를 사용한 현대적 컴포넌트 아키텍처
|
||||
- **CodeMirror** - 구문 강조를 지원하는 고급 코드 편집기
|
||||
|
||||
|
||||
|
||||
### 기여하기
|
||||
|
||||
기여를 환영합니다! 커밋 규칙, 개발 워크플로우, 릴리스 프로세스에 대한 자세한 내용은 [Contributing Guide](CONTRIBUTING.md)를 참조해주세요.
|
||||
|
||||
## 문제 해결
|
||||
|
||||
### 일반적인 문제 및 해결 방법
|
||||
|
||||
|
||||
#### "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>
|
||||
30
README.md
30
README.md
@@ -6,7 +6,7 @@
|
||||
|
||||
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)
|
||||
<div align="right"><i><b>English</b> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">中文</a> · <a href="./README.ja.md">日本語</a></i></div>
|
||||
|
||||
## Screenshots
|
||||
|
||||
@@ -57,7 +57,7 @@ A desktop and mobile UI for [Claude Code](https://docs.anthropic.com/en/docs/cla
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Node.js](https://nodejs.org/) v20 or higher
|
||||
- [Node.js](https://nodejs.org/) v22 or higher
|
||||
- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) installed and configured, and/or
|
||||
- [Cursor CLI](https://docs.cursor.com/en/cli/overview) installed and configured, and/or
|
||||
- [Codex](https://developers.openai.com/codex) installed and configured
|
||||
@@ -291,31 +291,7 @@ session counts
|
||||
|
||||
### Contributing
|
||||
|
||||
We welcome contributions! Please follow these guidelines:
|
||||
|
||||
#### Getting Started
|
||||
1. **Fork** the repository
|
||||
2. **Clone** your fork: `git clone <your-fork-url>`
|
||||
3. **Install** dependencies: `npm install`
|
||||
4. **Create** a feature branch: `git checkout -b feature/amazing-feature`
|
||||
|
||||
#### Development Process
|
||||
1. **Make your changes** following the existing code style
|
||||
2. **Test thoroughly** - ensure all features work correctly
|
||||
3. **Run quality checks**: `npm run lint && npm run format`
|
||||
4. **Commit** with descriptive messages following [Conventional Commits](https://conventionalcommits.org/)
|
||||
5. **Push** to your branch: `git push origin feature/amazing-feature`
|
||||
6. **Submit** a Pull Request with:
|
||||
- Clear description of changes
|
||||
- Screenshots for UI changes
|
||||
- Test results if applicable
|
||||
|
||||
#### What to Contribute
|
||||
- **Bug fixes** - Help us improve stability
|
||||
- **New features** - Enhance functionality (discuss in issues first)
|
||||
- **Documentation** - Improve guides and API docs
|
||||
- **UI/UX improvements** - Better user experience
|
||||
- **Performance optimizations** - Make it faster
|
||||
We welcome contributions! Please see our [Contributing Guide](CONTRIBUTING.md) for details on commit conventions, development workflow, and release process.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
[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="right"><i><a href="./README.md">English</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.ja.md">日本語</a></i></div>
|
||||
|
||||
## 截图
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
|
||||
### 前置要求
|
||||
|
||||
- [Node.js](https://nodejs.org/) v20 或更高版本
|
||||
- [Node.js](https://nodejs.org/) v22 或更高版本
|
||||
- 已安装并配置 [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code),和/或
|
||||
- 已安装并配置 [Cursor CLI](https://docs.cursor.com/en/cli/overview),和/或
|
||||
- 已安装并配置 [Codex](https://developers.openai.com/codex)
|
||||
@@ -290,31 +290,7 @@ Claude Code UI 支持 **[TaskMaster AI](https://github.com/eyaltoledano/claude-t
|
||||
|
||||
### 贡献
|
||||
|
||||
我们欢迎贡献!请遵循以下指南:
|
||||
|
||||
#### 入门
|
||||
1. **Fork** 仓库
|
||||
2. **克隆** 您的 fork: `git clone <your-fork-url>`
|
||||
3. **安装** 依赖: `npm install`
|
||||
4. **创建** 特性分支: `git checkout -b feature/amazing-feature`
|
||||
|
||||
#### 开发流程
|
||||
1. **进行更改**,遵循现有代码风格
|
||||
2. **彻底测试** - 确保所有功能正常工作
|
||||
3. **运行质量检查**: `npm run lint && npm run format`
|
||||
4. **提交**,遵循 [Conventional Commits](https://conventionalcommits.org/)的描述性消息
|
||||
5. **推送** 到您的分支: `git push origin feature/amazing-feature`
|
||||
6. **提交** 拉取请求,包括:
|
||||
- 更改的清晰描述
|
||||
- UI 更改的截图
|
||||
- 适用时的测试结果
|
||||
|
||||
#### 贡献内容
|
||||
- **错误修复** - 帮助我们提高稳定性
|
||||
- **新功能** - 增强功能(先在 issue 中讨论)
|
||||
- **文档** - 改进指南和 API 文档
|
||||
- **UI/UX 改进** - 更好的用户体验
|
||||
- **性能优化** - 让它更快
|
||||
我们欢迎贡献!有关提交规范、开发流程和发布流程的详细信息,请参阅 [Contributing Guide](CONTRIBUTING.md)。
|
||||
|
||||
## 故障排除
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||
<title>Claude Code UI</title>
|
||||
<title>CloudCLI UI</title>
|
||||
|
||||
<!-- PWA Manifest -->
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
|
||||
761
package-lock.json
generated
761
package-lock.json
generated
@@ -1,15 +1,16 @@
|
||||
{
|
||||
"name": "@siteboon/claude-code-ui",
|
||||
"version": "1.14.0",
|
||||
"version": "1.20.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@siteboon/claude-code-ui",
|
||||
"version": "1.14.0",
|
||||
"license": "MIT",
|
||||
"version": "1.20.1",
|
||||
"hasInstallScript": true,
|
||||
"license": "GPL-3.0",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.1.29",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.1.71",
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
"@codemirror/lang-html": "^6.4.9",
|
||||
"@codemirror/lang-javascript": "^6.2.4",
|
||||
@@ -20,7 +21,7 @@
|
||||
"@codemirror/theme-one-dark": "^6.1.2",
|
||||
"@iarna/toml": "^2.2.5",
|
||||
"@octokit/rest": "^22.0.0",
|
||||
"@openai/codex-sdk": "^0.75.0",
|
||||
"@openai/codex-sdk": "^0.101.0",
|
||||
"@replit/codemirror-minimap": "^0.5.2",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@uiw/react-codemirror": "^4.23.13",
|
||||
@@ -56,6 +57,7 @@
|
||||
"react-router-dom": "^6.8.1",
|
||||
"react-syntax-highlighter": "^15.6.1",
|
||||
"rehype-katex": "^7.0.1",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"remark-math": "^6.0.0",
|
||||
"sqlite": "^5.1.1",
|
||||
@@ -68,6 +70,8 @@
|
||||
"cloudcli": "server/cli.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@release-it/conventional-changelog": "^10.0.5",
|
||||
"@types/node": "^22.19.7",
|
||||
"@types/react": "^18.2.43",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
@@ -79,6 +83,7 @@
|
||||
"release-it": "^19.0.5",
|
||||
"sharp": "^0.34.2",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.0.4"
|
||||
}
|
||||
},
|
||||
@@ -109,9 +114,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk": {
|
||||
"version": "0.1.29",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.1.29.tgz",
|
||||
"integrity": "sha512-VbR2ybPdJHVKAD3pQdruVw8LdXoPbk5J59xU/bQoMNzAsBckHrD2LhupMJrBxLUWxLaPkIUlNKquGBRbkoK84Q==",
|
||||
"version": "0.1.77",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.1.77.tgz",
|
||||
"integrity": "sha512-ZEjWQtkoB2MEY6K16DWMmF+8OhywAynH0m08V265cerbZ8xPD/2Ng2jPzbbO40mPeFSsMDJboShL+a3aObP0Jg==",
|
||||
"license": "SEE LICENSE IN README.md",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
@@ -122,10 +127,88 @@
|
||||
"@img/sharp-linux-arm": "^0.33.5",
|
||||
"@img/sharp-linux-arm64": "^0.33.5",
|
||||
"@img/sharp-linux-x64": "^0.33.5",
|
||||
"@img/sharp-linuxmusl-arm64": "^0.33.5",
|
||||
"@img/sharp-linuxmusl-x64": "^0.33.5",
|
||||
"@img/sharp-win32-x64": "^0.33.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^3.24.1"
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz",
|
||||
"integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz",
|
||||
"integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@img/sharp-linuxmusl-arm64": {
|
||||
"version": "0.33.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz",
|
||||
"integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@img/sharp-linuxmusl-x64": {
|
||||
"version": "0.33.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz",
|
||||
"integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
@@ -608,6 +691,46 @@
|
||||
"w3c-keyname": "^2.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@conventional-changelog/git-client": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@conventional-changelog/git-client/-/git-client-2.5.1.tgz",
|
||||
"integrity": "sha512-lAw7iA5oTPWOLjiweb7DlGEMDEvzqzLLa6aWOly2FSZ64IwLE8T458rC+o+WvI31Doz6joM7X2DoNog7mX8r4A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@simple-libs/child-process-utils": "^1.0.0",
|
||||
"@simple-libs/stream-utils": "^1.1.0",
|
||||
"semver": "^7.5.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"conventional-commits-filter": "^5.0.0",
|
||||
"conventional-commits-parser": "^6.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"conventional-commits-filter": {
|
||||
"optional": true
|
||||
},
|
||||
"conventional-commits-parser": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@conventional-changelog/git-client/node_modules/semver": {
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.4.5",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.5.tgz",
|
||||
@@ -2429,15 +2552,140 @@
|
||||
"@octokit/openapi-types": "^26.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@openai/codex-sdk": {
|
||||
"version": "0.75.0",
|
||||
"resolved": "https://registry.npmjs.org/@openai/codex-sdk/-/codex-sdk-0.75.0.tgz",
|
||||
"integrity": "sha512-4X5kHPXLu16SmGUdsvSa9xRuVmRC8oQw62iH8dRyIDbyy2MNkh068NNoHWDoJErRLUc4X4Ed2ceiNs6Tbkswnw==",
|
||||
"node_modules/@openai/codex": {
|
||||
"version": "0.101.0",
|
||||
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.101.0.tgz",
|
||||
"integrity": "sha512-H874q5K5I3chrT588BaddMr7GNvRYypc8C1MKWytNUF2PgxWMko2g/2DgKbt5OdajZKMsWdbsPywu34KQGf5Qw==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"codex": "bin/codex.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@openai/codex-darwin-arm64": "npm:@openai/codex@0.101.0-darwin-arm64",
|
||||
"@openai/codex-darwin-x64": "npm:@openai/codex@0.101.0-darwin-x64",
|
||||
"@openai/codex-linux-arm64": "npm:@openai/codex@0.101.0-linux-arm64",
|
||||
"@openai/codex-linux-x64": "npm:@openai/codex@0.101.0-linux-x64",
|
||||
"@openai/codex-win32-arm64": "npm:@openai/codex@0.101.0-win32-arm64",
|
||||
"@openai/codex-win32-x64": "npm:@openai/codex@0.101.0-win32-x64"
|
||||
}
|
||||
},
|
||||
"node_modules/@openai/codex-darwin-arm64": {
|
||||
"name": "@openai/codex",
|
||||
"version": "0.101.0-darwin-arm64",
|
||||
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.101.0-darwin-arm64.tgz",
|
||||
"integrity": "sha512-unk4rTRQQ9o0w2Upu35IsJHpoZHJ+tU/myn6LNhUjcP9FrjLnEcAQJ6WIMtdTYVPja1PGhFSO0DNxV79GMvehw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/@openai/codex-darwin-x64": {
|
||||
"name": "@openai/codex",
|
||||
"version": "0.101.0-darwin-x64",
|
||||
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.101.0-darwin-x64.tgz",
|
||||
"integrity": "sha512-+KFi1IapCQGd3vLQp2lI4xI3hu2QffDZYt7Fhfw6NxEFOKhHnTamRtQ5yI8jYQcYF+pQfYF2fyiuXLM1lITLQw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/@openai/codex-linux-arm64": {
|
||||
"name": "@openai/codex",
|
||||
"version": "0.101.0-linux-arm64",
|
||||
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.101.0-linux-arm64.tgz",
|
||||
"integrity": "sha512-RkDnQeq7M6ZBtD+8i+I5ewjjOf02BcJq6r1kN4RBewfAQBsz6B73Ns3OrI2bHVRsuPtAf8Cf1S4xg/eFZT2Omg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/@openai/codex-linux-x64": {
|
||||
"name": "@openai/codex",
|
||||
"version": "0.101.0-linux-x64",
|
||||
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.101.0-linux-x64.tgz",
|
||||
"integrity": "sha512-SJeEdQ4ReEU3nvtceZ1uY3me6oWoB3djr3GnZmAUCEUuYEWD1kRGprAyJB1N0B+8zhSv0SU2e9sX5t3aCV4AwQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/@openai/codex-sdk": {
|
||||
"version": "0.101.0",
|
||||
"resolved": "https://registry.npmjs.org/@openai/codex-sdk/-/codex-sdk-0.101.0.tgz",
|
||||
"integrity": "sha512-Lrar2pDvGUX64itSbMNKuNBzxh72UwKokY4TPuXJRURwGX0qyDi80n7DiVivC40BwFsQWNs6behSo/9Mr6PoLw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@openai/codex": "0.101.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@openai/codex-win32-arm64": {
|
||||
"name": "@openai/codex",
|
||||
"version": "0.101.0-win32-arm64",
|
||||
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.101.0-win32-arm64.tgz",
|
||||
"integrity": "sha512-WQ8QsychjHyvlr+vCSTMbd2/yrBIZre5tRuM79eZi973BJz0CSEiFsNSGg5fvpnJuiHHawZ/8HWeir7nlatamQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/@openai/codex-win32-x64": {
|
||||
"name": "@openai/codex",
|
||||
"version": "0.101.0-win32-x64",
|
||||
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.101.0-win32-x64.tgz",
|
||||
"integrity": "sha512-H+7h9x0fYrJRUZZHCA62Dzb/CS5Scl1sUw1aamfmHJzzorX+uTFOgGsibzqFpHTd6nRM4q8//fCdSxe5wUpOQQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/@phun-ky/typeof": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@phun-ky/typeof/-/typeof-2.0.3.tgz",
|
||||
@@ -2462,6 +2710,41 @@
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@release-it/conventional-changelog": {
|
||||
"version": "10.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@release-it/conventional-changelog/-/conventional-changelog-10.0.5.tgz",
|
||||
"integrity": "sha512-Dxul3YlUsDLbIg+aR6T0QR/VyKwuJNR3GZM8mKVEwFO8GpH2H5vgnN7kacEvq/Qk5puDadOVbhbUq/KBjraemQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@conventional-changelog/git-client": "^2.5.1",
|
||||
"concat-stream": "^2.0.0",
|
||||
"conventional-changelog": "^7.1.1",
|
||||
"conventional-changelog-angular": "^8.1.0",
|
||||
"conventional-changelog-conventionalcommits": "^9.1.0",
|
||||
"conventional-recommended-bump": "^11.2.0",
|
||||
"semver": "^7.7.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.12.0 || >=22.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"release-it": "^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@release-it/conventional-changelog/node_modules/semver": {
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@remix-run/router": {
|
||||
"version": "1.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz",
|
||||
@@ -2775,6 +3058,39 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@simple-libs/child-process-utils": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@simple-libs/child-process-utils/-/child-process-utils-1.0.1.tgz",
|
||||
"integrity": "sha512-3nWd8irxvDI6v856wpPCHZ+08iQR0oHTZfzAZmnbsLzf+Sf1odraP6uKOHDZToXq3RPRV/LbqGVlSCogm9cJjg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@simple-libs/stream-utils": "^1.1.0",
|
||||
"@types/node": "^22.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://ko-fi.com/dangreen"
|
||||
}
|
||||
},
|
||||
"node_modules/@simple-libs/stream-utils": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@simple-libs/stream-utils/-/stream-utils-1.1.0.tgz",
|
||||
"integrity": "sha512-6rsHTjodIn/t90lv5snQjRPVtOosM7Vp0AKdrObymq45ojlgVwnpAqdc+0OBBrpEiy31zZ6/TKeIVqV1HwvnuQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "^22.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://ko-fi.com/dangreen"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/typography": {
|
||||
"version": "0.5.16",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.16.tgz",
|
||||
@@ -2906,6 +3222,23 @@
|
||||
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.19.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz",
|
||||
"integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/normalize-package-data": {
|
||||
"version": "2.4.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz",
|
||||
"integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/parse-path": {
|
||||
"version": "7.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/parse-path/-/parse-path-7.0.3.tgz",
|
||||
@@ -3246,6 +3579,13 @@
|
||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/array-ify": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/array-ify/-/array-ify-1.0.0.tgz",
|
||||
"integrity": "sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ast-types": {
|
||||
"version": "0.13.4",
|
||||
"resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz",
|
||||
@@ -4158,6 +4498,17 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/compare-func": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/compare-func/-/compare-func-2.0.0.tgz",
|
||||
"integrity": "sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"array-ify": "^1.0.0",
|
||||
"dot-prop": "^5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
@@ -4253,6 +4604,143 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/conventional-changelog": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/conventional-changelog/-/conventional-changelog-7.1.1.tgz",
|
||||
"integrity": "sha512-rlqa8Lgh8YzT3Akruk05DR79j5gN9NCglHtJZwpi6vxVeaoagz+84UAtKQj/sT+RsfGaZkt3cdFCjcN6yjr5sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@conventional-changelog/git-client": "^2.5.1",
|
||||
"@types/normalize-package-data": "^2.4.4",
|
||||
"conventional-changelog-preset-loader": "^5.0.0",
|
||||
"conventional-changelog-writer": "^8.2.0",
|
||||
"conventional-commits-parser": "^6.2.0",
|
||||
"fd-package-json": "^2.0.0",
|
||||
"meow": "^13.0.0",
|
||||
"normalize-package-data": "^7.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"conventional-changelog": "dist/cli/index.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/conventional-changelog-angular": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-8.1.0.tgz",
|
||||
"integrity": "sha512-GGf2Nipn1RUCAktxuVauVr1e3r8QrLP/B0lEUsFktmGqc3ddbQkhoJZHJctVU829U1c6mTSWftrVOCHaL85Q3w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"compare-func": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/conventional-changelog-conventionalcommits": {
|
||||
"version": "9.1.0",
|
||||
"resolved": "https://registry.npmjs.org/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-9.1.0.tgz",
|
||||
"integrity": "sha512-MnbEysR8wWa8dAEvbj5xcBgJKQlX/m0lhS8DsyAAWDHdfs2faDJxTgzRYlRYpXSe7UiKrIIlB4TrBKU9q9DgkA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"compare-func": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/conventional-changelog-preset-loader": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/conventional-changelog-preset-loader/-/conventional-changelog-preset-loader-5.0.0.tgz",
|
||||
"integrity": "sha512-SetDSntXLk8Jh1NOAl1Gu5uLiCNSYenB5tm0YVeZKePRIgDW9lQImromTwLa3c/Gae298tsgOM+/CYT9XAl0NA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/conventional-changelog-writer": {
|
||||
"version": "8.2.0",
|
||||
"resolved": "https://registry.npmjs.org/conventional-changelog-writer/-/conventional-changelog-writer-8.2.0.tgz",
|
||||
"integrity": "sha512-Y2aW4596l9AEvFJRwFGJGiQjt2sBYTjPD18DdvxX9Vpz0Z7HQ+g1Z+6iYDAm1vR3QOJrDBkRHixHK/+FhkR6Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"conventional-commits-filter": "^5.0.0",
|
||||
"handlebars": "^4.7.7",
|
||||
"meow": "^13.0.0",
|
||||
"semver": "^7.5.2"
|
||||
},
|
||||
"bin": {
|
||||
"conventional-changelog-writer": "dist/cli/index.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/conventional-changelog-writer/node_modules/semver": {
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/conventional-commits-filter": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/conventional-commits-filter/-/conventional-commits-filter-5.0.0.tgz",
|
||||
"integrity": "sha512-tQMagCOC59EVgNZcC5zl7XqO30Wki9i9J3acbUvkaosCT6JX3EeFwJD7Qqp4MCikRnzS18WXV3BLIQ66ytu6+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/conventional-commits-parser": {
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-6.2.1.tgz",
|
||||
"integrity": "sha512-20pyHgnO40rvfI0NGF/xiEoFMkXDtkF8FwHvk5BokoFoCuTQRI8vrNCNFWUOfuolKJMm1tPCHc8GgYEtr1XRNA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"meow": "^13.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"conventional-commits-parser": "dist/cli/index.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/conventional-recommended-bump": {
|
||||
"version": "11.2.0",
|
||||
"resolved": "https://registry.npmjs.org/conventional-recommended-bump/-/conventional-recommended-bump-11.2.0.tgz",
|
||||
"integrity": "sha512-lqIdmw330QdMBgfL0e6+6q5OMKyIpy4OZNmepit6FS3GldhkG+70drZjuZ0A5NFpze5j85dlYs3GabQXl6sMHw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@conventional-changelog/git-client": "^2.5.1",
|
||||
"conventional-changelog-preset-loader": "^5.0.0",
|
||||
"conventional-commits-filter": "^5.0.0",
|
||||
"conventional-commits-parser": "^6.1.0",
|
||||
"meow": "^13.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"conventional-recommended-bump": "dist/cli/index.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/convert-source-map": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
@@ -4548,6 +5036,19 @@
|
||||
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dot-prop": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz",
|
||||
"integrity": "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-obj": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "17.2.2",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.2.tgz",
|
||||
@@ -5074,6 +5575,16 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/fd-package-json": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fd-package-json/-/fd-package-json-2.0.0.tgz",
|
||||
"integrity": "sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"walk-up-path": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/file-selector": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz",
|
||||
@@ -5708,6 +6219,31 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/hast-util-raw": {
|
||||
"version": "9.1.0",
|
||||
"resolved": "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-9.1.0.tgz",
|
||||
"integrity": "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/hast": "^3.0.0",
|
||||
"@types/unist": "^3.0.0",
|
||||
"@ungap/structured-clone": "^1.0.0",
|
||||
"hast-util-from-parse5": "^8.0.0",
|
||||
"hast-util-to-parse5": "^8.0.0",
|
||||
"html-void-elements": "^3.0.0",
|
||||
"mdast-util-to-hast": "^13.0.0",
|
||||
"parse5": "^7.0.0",
|
||||
"unist-util-position": "^5.0.0",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"vfile": "^6.0.0",
|
||||
"web-namespaces": "^2.0.0",
|
||||
"zwitch": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/hast-util-to-jsx-runtime": {
|
||||
"version": "2.3.6",
|
||||
"resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz",
|
||||
@@ -5735,6 +6271,25 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/hast-util-to-parse5": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.1.tgz",
|
||||
"integrity": "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/hast": "^3.0.0",
|
||||
"comma-separated-tokens": "^2.0.0",
|
||||
"devlop": "^1.0.0",
|
||||
"property-information": "^7.0.0",
|
||||
"space-separated-tokens": "^2.0.0",
|
||||
"web-namespaces": "^2.0.0",
|
||||
"zwitch": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/hast-util-to-text": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz",
|
||||
@@ -5796,6 +6351,26 @@
|
||||
"integrity": "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==",
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
"node_modules/hosted-git-info": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-8.1.0.tgz",
|
||||
"integrity": "sha512-Rw/B2DNQaPBICNXEm8balFz9a6WpZrkCGpcWFpy7nCj+NyhSdqXipmfvtmWt9xGfp0wZnBxB+iVpLmQMYt47Tw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"lru-cache": "^10.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.17.0 || >=20.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/hosted-git-info/node_modules/lru-cache": {
|
||||
"version": "10.4.3",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
|
||||
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/html-parse-stringify": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
|
||||
@@ -5815,6 +6390,16 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/html-void-elements": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz",
|
||||
"integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/http-cache-semantics": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
|
||||
@@ -6268,6 +6853,16 @@
|
||||
"node": ">=0.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-obj": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz",
|
||||
"integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-plain-obj": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz",
|
||||
@@ -7045,6 +7640,19 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/meow": {
|
||||
"version": "13.2.0",
|
||||
"resolved": "https://registry.npmjs.org/meow/-/meow-13.2.0.tgz",
|
||||
"integrity": "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/merge-descriptors": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
|
||||
@@ -8242,6 +8850,34 @@
|
||||
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/normalize-package-data": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-7.0.1.tgz",
|
||||
"integrity": "sha512-linxNAT6M0ebEYZOx2tO6vBEFsVgnPpv+AVjk0wJHfaUIbq31Jm3T6vvZaarnOeWDh8ShnwXuaAyM7WT3RzErA==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"hosted-git-info": "^8.0.0",
|
||||
"semver": "^7.3.5",
|
||||
"validate-npm-package-license": "^3.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.17.0 || >=20.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/normalize-package-data/node_modules/semver": {
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/normalize-path": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||
@@ -9563,6 +10199,21 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/rehype-raw": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz",
|
||||
"integrity": "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/hast": "^3.0.0",
|
||||
"hast-util-raw": "^9.0.0",
|
||||
"vfile": "^6.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/release-it": {
|
||||
"version": "19.0.5",
|
||||
"resolved": "https://registry.npmjs.org/release-it/-/release-it-19.0.5.tgz",
|
||||
@@ -10601,6 +11252,42 @@
|
||||
"integrity": "sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/spdx-correct": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz",
|
||||
"integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"spdx-expression-parse": "^3.0.0",
|
||||
"spdx-license-ids": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/spdx-exceptions": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz",
|
||||
"integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==",
|
||||
"dev": true,
|
||||
"license": "CC-BY-3.0"
|
||||
},
|
||||
"node_modules/spdx-expression-parse": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz",
|
||||
"integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"spdx-exceptions": "^2.1.0",
|
||||
"spdx-license-ids": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/spdx-license-ids": {
|
||||
"version": "3.0.22",
|
||||
"resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.22.tgz",
|
||||
"integrity": "sha512-4PRT4nh1EImPbt2jASOKHX7PB7I+e4IWNLvkKFDxNhJlfjbYlleYQh285Z/3mPTHSAK/AvdMmw5BNNuYH8ShgQ==",
|
||||
"dev": true,
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
"node_modules/sprintf-js": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
|
||||
@@ -11662,6 +12349,20 @@
|
||||
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/uglify-js": {
|
||||
"version": "3.19.3",
|
||||
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz",
|
||||
@@ -11686,6 +12387,13 @@
|
||||
"node": ">=18.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unified": {
|
||||
"version": "11.0.5",
|
||||
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
|
||||
@@ -11907,6 +12615,17 @@
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/validate-npm-package-license": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz",
|
||||
"integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"spdx-correct": "^3.0.0",
|
||||
"spdx-expression-parse": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vary": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||
@@ -12079,6 +12798,16 @@
|
||||
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/walk-up-path": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/walk-up-path/-/walk-up-path-4.0.0.tgz",
|
||||
"integrity": "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/web-namespaces": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz",
|
||||
@@ -12475,9 +13204,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "3.25.76",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"version": "4.3.6",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
|
||||
24
package.json
24
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@siteboon/claude-code-ui",
|
||||
"version": "1.14.0",
|
||||
"version": "1.20.1",
|
||||
"description": "A web-based UI for Claude Code CLI",
|
||||
"type": "module",
|
||||
"main": "server/index.js",
|
||||
@@ -12,9 +12,10 @@
|
||||
"server/",
|
||||
"shared/",
|
||||
"dist/",
|
||||
"scripts/",
|
||||
"README.md"
|
||||
],
|
||||
"homepage": "https://claudecodeui.siteboon.ai",
|
||||
"homepage": "https://cloudcli.ai",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/siteboon/claudecodeui.git"
|
||||
@@ -28,20 +29,23 @@
|
||||
"client": "vite --host",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||
"start": "npm run build && npm run server",
|
||||
"release": "./release.sh"
|
||||
"release": "./release.sh",
|
||||
"prepublishOnly": "npm run build",
|
||||
"postinstall": "node scripts/fix-node-pty.js"
|
||||
},
|
||||
"keywords": [
|
||||
"claude coode",
|
||||
"claude code",
|
||||
"ai",
|
||||
"anthropic",
|
||||
"ui",
|
||||
"mobile"
|
||||
],
|
||||
"author": "Claude Code UI Contributors",
|
||||
"license": "MIT",
|
||||
"author": "CloudCLI UI Contributors",
|
||||
"license": "GPL-3.0",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.1.29",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.1.71",
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
"@codemirror/lang-html": "^6.4.9",
|
||||
"@codemirror/lang-javascript": "^6.2.4",
|
||||
@@ -52,7 +56,7 @@
|
||||
"@codemirror/theme-one-dark": "^6.1.2",
|
||||
"@iarna/toml": "^2.2.5",
|
||||
"@octokit/rest": "^22.0.0",
|
||||
"@openai/codex-sdk": "^0.75.0",
|
||||
"@openai/codex-sdk": "^0.101.0",
|
||||
"@replit/codemirror-minimap": "^0.5.2",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@uiw/react-codemirror": "^4.23.13",
|
||||
@@ -88,6 +92,7 @@
|
||||
"react-router-dom": "^6.8.1",
|
||||
"react-syntax-highlighter": "^15.6.1",
|
||||
"rehype-katex": "^7.0.1",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"remark-math": "^6.0.0",
|
||||
"sqlite": "^5.1.1",
|
||||
@@ -96,6 +101,8 @@
|
||||
"ws": "^8.14.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@release-it/conventional-changelog": "^10.0.5",
|
||||
"@types/node": "^22.19.7",
|
||||
"@types/react": "^18.2.43",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
@@ -107,6 +114,7 @@
|
||||
"release-it": "^19.0.5",
|
||||
"sharp": "^0.34.2",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.0.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
# PWA Icons Required
|
||||
|
||||
Create the following icon files in this directory:
|
||||
|
||||
- icon-72x72.png
|
||||
- icon-96x96.png
|
||||
- icon-128x128.png
|
||||
- icon-144x144.png
|
||||
- icon-152x152.png
|
||||
- icon-192x192.png
|
||||
- icon-384x384.png
|
||||
- icon-512x512.png
|
||||
|
||||
You can use any icon generator tool or create them manually. The icons should be square and represent your Claude Code UI application.
|
||||
|
||||
For a quick solution, you can:
|
||||
1. Create a simple square PNG icon (512x512)
|
||||
2. Use online tools like realfavicongenerator.net to generate all sizes
|
||||
3. Or use ImageMagick: `convert icon-512x512.png -resize 192x192 icon-192x192.png`
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "Claude Code UI",
|
||||
"short_name": "Claude UI",
|
||||
"description": "Claude Code UI web application",
|
||||
"name": "CloudCLI UI",
|
||||
"short_name": "CloudCLI UI",
|
||||
"description": "CloudCLI UI web application",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff",
|
||||
|
||||
67
scripts/fix-node-pty.js
Normal file
67
scripts/fix-node-pty.js
Normal file
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Fix node-pty spawn-helper permissions on macOS
|
||||
*
|
||||
* This script fixes a known issue with node-pty where the spawn-helper
|
||||
* binary is shipped without execute permissions, causing "posix_spawnp failed" errors.
|
||||
*
|
||||
* @see https://github.com/microsoft/node-pty/issues/850
|
||||
* @module scripts/fix-node-pty
|
||||
*/
|
||||
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
/**
|
||||
* Fixes the spawn-helper binary permissions for node-pty on macOS.
|
||||
*
|
||||
* The node-pty package ships the spawn-helper binary without execute permissions
|
||||
* (644 instead of 755), which causes "posix_spawnp failed" errors when trying
|
||||
* to spawn terminal processes.
|
||||
*
|
||||
* This function:
|
||||
* 1. Checks if running on macOS (darwin)
|
||||
* 2. Locates spawn-helper binaries for both arm64 and x64 architectures
|
||||
* 3. Sets execute permissions (755) on each binary found
|
||||
*
|
||||
* @async
|
||||
* @function fixSpawnHelper
|
||||
* @returns {Promise<void>} Resolves when permissions are fixed or skipped
|
||||
* @example
|
||||
* // Run as postinstall script
|
||||
* await fixSpawnHelper();
|
||||
*/
|
||||
async function fixSpawnHelper() {
|
||||
const nodeModulesPath = path.join(__dirname, '..', 'node_modules', 'node-pty', 'prebuilds');
|
||||
|
||||
// Only run on macOS
|
||||
if (process.platform !== 'darwin') {
|
||||
return;
|
||||
}
|
||||
|
||||
const darwinDirs = ['darwin-arm64', 'darwin-x64'];
|
||||
|
||||
for (const dir of darwinDirs) {
|
||||
const spawnHelperPath = path.join(nodeModulesPath, dir, 'spawn-helper');
|
||||
|
||||
try {
|
||||
// Check if file exists
|
||||
await fs.access(spawnHelperPath);
|
||||
|
||||
// Make it executable (755)
|
||||
await fs.chmod(spawnHelperPath, 0o755);
|
||||
console.log(`[postinstall] Fixed permissions for ${spawnHelperPath}`);
|
||||
} catch (err) {
|
||||
// File doesn't exist or other error - ignore
|
||||
if (err.code !== 'ENOENT') {
|
||||
console.warn(`[postinstall] Warning: Could not fix ${spawnHelperPath}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fixSpawnHelper().catch(console.error);
|
||||
@@ -13,41 +13,26 @@
|
||||
*/
|
||||
|
||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||
// Used to mint unique approval request IDs when randomUUID is not available.
|
||||
// This keeps parallel tool approvals from colliding; it does not add any crypto/security guarantees.
|
||||
import crypto from 'crypto';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { CLAUDE_MODELS } from '../shared/modelConstants.js';
|
||||
|
||||
// Session tracking: Map of session IDs to active query instances
|
||||
const activeSessions = new Map();
|
||||
// In-memory registry of pending tool approvals keyed by requestId.
|
||||
// This does not persist approvals or share across processes; it exists so the
|
||||
// SDK can pause tool execution while the UI decides what to do.
|
||||
const pendingToolApprovals = new Map();
|
||||
|
||||
// Default approval timeout kept under the SDK's 60s control timeout.
|
||||
// This does not change SDK limits; it only defines how long we wait for the UI,
|
||||
// introduced to avoid hanging the run when no decision arrives.
|
||||
const TOOL_APPROVAL_TIMEOUT_MS = parseInt(process.env.CLAUDE_TOOL_APPROVAL_TIMEOUT_MS, 10) || 55000;
|
||||
|
||||
// Generate a stable request ID for UI approval flows.
|
||||
// This does not encode tool details or get shown to users; it exists so the UI
|
||||
// can respond to the correct pending request without collisions.
|
||||
const TOOLS_REQUIRING_INTERACTION = new Set(['AskUserQuestion']);
|
||||
|
||||
function createRequestId() {
|
||||
// if clause is used because randomUUID is not available in older Node.js versions
|
||||
if (typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return crypto.randomBytes(16).toString('hex');
|
||||
}
|
||||
|
||||
// Wait for a UI approval decision, honoring SDK cancellation.
|
||||
// This does not auto-approve or auto-deny; it only resolves with UI input,
|
||||
// and it cleans up the pending map to avoid leaks, introduced to prevent
|
||||
// replying after the SDK cancels the control request.
|
||||
function waitForToolApproval(requestId, options = {}) {
|
||||
const { timeoutMs = TOOL_APPROVAL_TIMEOUT_MS, signal, onCancel } = options;
|
||||
|
||||
@@ -61,24 +46,25 @@ function waitForToolApproval(requestId, options = {}) {
|
||||
resolve(decision);
|
||||
};
|
||||
|
||||
let timeout;
|
||||
|
||||
const cleanup = () => {
|
||||
pendingToolApprovals.delete(requestId);
|
||||
clearTimeout(timeout);
|
||||
if (timeout) clearTimeout(timeout);
|
||||
if (signal && abortHandler) {
|
||||
signal.removeEventListener('abort', abortHandler);
|
||||
}
|
||||
};
|
||||
|
||||
// Timeout is local to this process; it does not override SDK timing.
|
||||
// It exists to prevent the UI prompt from lingering indefinitely.
|
||||
const timeout = setTimeout(() => {
|
||||
onCancel?.('timeout');
|
||||
finalize(null);
|
||||
}, timeoutMs);
|
||||
// timeoutMs 0 = wait indefinitely (interactive tools)
|
||||
if (timeoutMs > 0) {
|
||||
timeout = setTimeout(() => {
|
||||
onCancel?.('timeout');
|
||||
finalize(null);
|
||||
}, timeoutMs);
|
||||
}
|
||||
|
||||
const abortHandler = () => {
|
||||
// If the SDK cancels the control request, stop waiting to avoid
|
||||
// replying after the process is no longer ready for writes.
|
||||
onCancel?.('cancelled');
|
||||
finalize({ cancelled: true });
|
||||
};
|
||||
@@ -98,9 +84,6 @@ function waitForToolApproval(requestId, options = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
// Resolve a pending approval. This does not validate the decision payload;
|
||||
// validation and tool matching remain in canUseTool, which keeps this as a
|
||||
// lightweight WebSocket -> SDK relay.
|
||||
function resolveToolApproval(requestId, decision) {
|
||||
const resolver = pendingToolApprovals.get(requestId);
|
||||
if (resolver) {
|
||||
@@ -175,9 +158,6 @@ function mapCliOptionsToSDK(options = {}) {
|
||||
sdkOptions.permissionMode = 'bypassPermissions';
|
||||
}
|
||||
|
||||
// Map allowed tools (always set to avoid implicit "allow all" defaults).
|
||||
// This does not grant permissions by itself; it just configures the SDK,
|
||||
// introduced because leaving it undefined made the SDK treat it as "all tools allowed."
|
||||
let allowedTools = [...(settings.allowedTools || [])];
|
||||
|
||||
// Add plan mode default tools
|
||||
@@ -192,8 +172,11 @@ function mapCliOptionsToSDK(options = {}) {
|
||||
|
||||
sdkOptions.allowedTools = allowedTools;
|
||||
|
||||
// Map disallowed tools (always set so the SDK doesn't treat "undefined" as permissive).
|
||||
// This does not override allowlists; it only feeds the canUseTool gate.
|
||||
// Use the tools preset to make all default built-in tools available (including AskUserQuestion).
|
||||
// This was introduced in SDK 0.1.57. Omitting this preserves existing behavior (all tools available),
|
||||
// but being explicit ensures forward compatibility and clarity.
|
||||
sdkOptions.tools = { type: 'preset', preset: 'claude_code' };
|
||||
|
||||
sdkOptions.disallowedTools = settings.disallowedTools || [];
|
||||
|
||||
// Map model (default to sonnet)
|
||||
@@ -267,9 +250,13 @@ function getAllSessions() {
|
||||
* @returns {Object} Transformed message ready for WebSocket
|
||||
*/
|
||||
function transformMessage(sdkMessage) {
|
||||
// SDK messages are already in a format compatible with the frontend
|
||||
// The CLI sends them wrapped in {type: 'claude-response', data: message}
|
||||
// We'll do the same here to maintain compatibility
|
||||
// Extract parent_tool_use_id for subagent tool grouping
|
||||
if (sdkMessage.parent_tool_use_id) {
|
||||
return {
|
||||
...sdkMessage,
|
||||
parentToolUseId: sdkMessage.parent_tool_use_id
|
||||
};
|
||||
}
|
||||
return sdkMessage;
|
||||
}
|
||||
|
||||
@@ -490,27 +477,27 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
||||
tempImagePaths = imageResult.tempImagePaths;
|
||||
tempDir = imageResult.tempDir;
|
||||
|
||||
// Gate tool usage with explicit UI approval when not auto-approved.
|
||||
// This does not render UI or persist permissions; it only bridges to the UI
|
||||
// via WebSocket and waits for the response, introduced so tool calls pause
|
||||
// instead of auto-running when the allowlist is empty.
|
||||
sdkOptions.canUseTool = async (toolName, input, context) => {
|
||||
if (sdkOptions.permissionMode === 'bypassPermissions') {
|
||||
return { behavior: 'allow', updatedInput: input };
|
||||
}
|
||||
const requiresInteraction = TOOLS_REQUIRING_INTERACTION.has(toolName);
|
||||
|
||||
const isDisallowed = (sdkOptions.disallowedTools || []).some(entry =>
|
||||
matchesToolPermission(entry, toolName, input)
|
||||
);
|
||||
if (isDisallowed) {
|
||||
return { behavior: 'deny', message: 'Tool disallowed by settings' };
|
||||
}
|
||||
if (!requiresInteraction) {
|
||||
if (sdkOptions.permissionMode === 'bypassPermissions') {
|
||||
return { behavior: 'allow', updatedInput: input };
|
||||
}
|
||||
|
||||
const isAllowed = (sdkOptions.allowedTools || []).some(entry =>
|
||||
matchesToolPermission(entry, toolName, input)
|
||||
);
|
||||
if (isAllowed) {
|
||||
return { behavior: 'allow', updatedInput: input };
|
||||
const isDisallowed = (sdkOptions.disallowedTools || []).some(entry =>
|
||||
matchesToolPermission(entry, toolName, input)
|
||||
);
|
||||
if (isDisallowed) {
|
||||
return { behavior: 'deny', message: 'Tool disallowed by settings' };
|
||||
}
|
||||
|
||||
const isAllowed = (sdkOptions.allowedTools || []).some(entry =>
|
||||
matchesToolPermission(entry, toolName, input)
|
||||
);
|
||||
if (isAllowed) {
|
||||
return { behavior: 'allow', updatedInput: input };
|
||||
}
|
||||
}
|
||||
|
||||
const requestId = createRequestId();
|
||||
@@ -522,9 +509,8 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
||||
sessionId: capturedSessionId || sessionId || null
|
||||
});
|
||||
|
||||
// Wait for the UI; if the SDK cancels, notify the UI so it can dismiss the banner.
|
||||
// This does not retry or resurface the prompt; it just reflects the cancellation.
|
||||
const decision = await waitForToolApproval(requestId, {
|
||||
timeoutMs: requiresInteraction ? 0 : undefined,
|
||||
signal: context?.signal,
|
||||
onCancel: (reason) => {
|
||||
ws.send({
|
||||
@@ -544,8 +530,6 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
||||
}
|
||||
|
||||
if (decision.allow) {
|
||||
// rememberEntry only updates this run's in-memory allowlist to prevent
|
||||
// repeated prompts in the same session; persistence is handled by the UI.
|
||||
if (decision.rememberEntry && typeof decision.rememberEntry === 'string') {
|
||||
if (!sdkOptions.allowedTools.includes(decision.rememberEntry)) {
|
||||
sdkOptions.allowedTools.push(decision.rememberEntry);
|
||||
@@ -560,12 +544,22 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
||||
return { behavior: 'deny', message: decision.message ?? 'User denied tool use' };
|
||||
};
|
||||
|
||||
// Create SDK query instance
|
||||
// Set stream-close timeout for interactive tools (Query constructor reads it synchronously). Claude Agent SDK has a default of 5s and this overrides it
|
||||
const prevStreamTimeout = process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT;
|
||||
process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT = '300000';
|
||||
|
||||
const queryInstance = query({
|
||||
prompt: finalCommand,
|
||||
options: sdkOptions
|
||||
});
|
||||
|
||||
// Restore immediately — Query constructor already captured the value
|
||||
if (prevStreamTimeout !== undefined) {
|
||||
process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT = prevStreamTimeout;
|
||||
} else {
|
||||
delete process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT;
|
||||
}
|
||||
|
||||
// Track the query instance for abort capability
|
||||
if (capturedSessionId) {
|
||||
addSession(capturedSessionId, queryInstance, tempImagePaths, tempDir);
|
||||
|
||||
5
server/constants/config.js
Normal file
5
server/constants/config.js
Normal file
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Environment Flag: Is Platform
|
||||
* Indicates if the app is running in Platform mode (hosted) or OSS mode (self-hosted)
|
||||
*/
|
||||
export const IS_PLATFORM = process.env.VITE_IS_PLATFORM === 'true';
|
||||
@@ -40,6 +40,22 @@ if (process.env.DATABASE_PATH) {
|
||||
}
|
||||
}
|
||||
|
||||
// As part of 1.19.2 we are introducing a new location for auth.db. The below handles exisitng moving legacy database from install directory to new location
|
||||
const LEGACY_DB_PATH = path.join(__dirname, 'auth.db');
|
||||
if (DB_PATH !== LEGACY_DB_PATH && !fs.existsSync(DB_PATH) && fs.existsSync(LEGACY_DB_PATH)) {
|
||||
try {
|
||||
fs.copyFileSync(LEGACY_DB_PATH, DB_PATH);
|
||||
console.log(`[MIGRATION] Copied database from ${LEGACY_DB_PATH} to ${DB_PATH}`);
|
||||
for (const suffix of ['-wal', '-shm']) {
|
||||
if (fs.existsSync(LEGACY_DB_PATH + suffix)) {
|
||||
fs.copyFileSync(LEGACY_DB_PATH + suffix, DB_PATH + suffix);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[MIGRATION] Could not copy legacy database: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Create database connection
|
||||
const db = new Database(DB_PATH);
|
||||
|
||||
@@ -128,12 +144,12 @@ const userDb = {
|
||||
}
|
||||
},
|
||||
|
||||
// Update last login time
|
||||
// Update last login time (non-fatal — logged but not thrown)
|
||||
updateLastLogin: (userId) => {
|
||||
try {
|
||||
db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?').run(userId);
|
||||
} catch (err) {
|
||||
throw err;
|
||||
console.warn('Failed to update last login:', err.message);
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
471
server/index.js
471
server/index.js
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
// Load environment variables from .env file
|
||||
// Load environment variables before other imports execute
|
||||
import './load-env.js';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
@@ -8,6 +9,8 @@ import { dirname } from 'path';
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const installMode = fs.existsSync(path.join(__dirname, '..', '.git')) ? 'git' : 'npm';
|
||||
|
||||
// ANSI color codes for terminal output
|
||||
const colors = {
|
||||
reset: '\x1b[0m',
|
||||
@@ -28,22 +31,6 @@ const c = {
|
||||
dim: (text) => `${colors.dim}${text}${colors.reset}`,
|
||||
};
|
||||
|
||||
try {
|
||||
const envPath = path.join(__dirname, '../.env');
|
||||
const envFile = fs.readFileSync(envPath, 'utf8');
|
||||
envFile.split('\n').forEach(line => {
|
||||
const trimmedLine = line.trim();
|
||||
if (trimmedLine && !trimmedLine.startsWith('#')) {
|
||||
const [key, ...valueParts] = trimmedLine.split('=');
|
||||
if (key && valueParts.length > 0 && !process.env[key]) {
|
||||
process.env[key] = valueParts.join('=').trim();
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.log('No .env file found or error reading it:', e.message);
|
||||
}
|
||||
|
||||
console.log('PORT from env:', process.env.PORT);
|
||||
|
||||
import express from 'express';
|
||||
@@ -70,15 +57,32 @@ import mcpUtilsRoutes from './routes/mcp-utils.js';
|
||||
import commandsRoutes from './routes/commands.js';
|
||||
import settingsRoutes from './routes/settings.js';
|
||||
import agentRoutes from './routes/agent.js';
|
||||
import projectsRoutes from './routes/projects.js';
|
||||
import projectsRoutes, { WORKSPACES_ROOT, validateWorkspacePath } from './routes/projects.js';
|
||||
import cliAuthRoutes from './routes/cli-auth.js';
|
||||
import userRoutes from './routes/user.js';
|
||||
import codexRoutes from './routes/codex.js';
|
||||
import { initializeDatabase } from './database/db.js';
|
||||
import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
|
||||
import { IS_PLATFORM } from './constants/config.js';
|
||||
|
||||
// File system watcher for projects folder
|
||||
let projectsWatcher = null;
|
||||
// File system watchers for provider project/session folders
|
||||
const PROVIDER_WATCH_PATHS = [
|
||||
{ provider: 'claude', rootPath: path.join(os.homedir(), '.claude', 'projects') },
|
||||
{ provider: 'cursor', rootPath: path.join(os.homedir(), '.cursor', 'chats') },
|
||||
{ provider: 'codex', rootPath: path.join(os.homedir(), '.codex', 'sessions') }
|
||||
];
|
||||
const WATCHER_IGNORED_PATTERNS = [
|
||||
'**/node_modules/**',
|
||||
'**/.git/**',
|
||||
'**/dist/**',
|
||||
'**/build/**',
|
||||
'**/*.tmp',
|
||||
'**/*.swp',
|
||||
'**/.DS_Store'
|
||||
];
|
||||
const WATCHER_DEBOUNCE_MS = 300;
|
||||
let projectsWatchers = [];
|
||||
let projectsWatcherDebounceTimer = null;
|
||||
const connectedClients = new Set();
|
||||
let isGetProjectsRunning = false; // Flag to prevent reentrant calls
|
||||
|
||||
@@ -95,94 +99,110 @@ function broadcastProgress(progress) {
|
||||
});
|
||||
}
|
||||
|
||||
// Setup file system watcher for Claude projects folder using chokidar
|
||||
// Setup file system watchers for Claude, Cursor, and Codex project/session folders
|
||||
async function setupProjectsWatcher() {
|
||||
const chokidar = (await import('chokidar')).default;
|
||||
const claudeProjectsPath = path.join(os.homedir(), '.claude', 'projects');
|
||||
|
||||
if (projectsWatcher) {
|
||||
projectsWatcher.close();
|
||||
if (projectsWatcherDebounceTimer) {
|
||||
clearTimeout(projectsWatcherDebounceTimer);
|
||||
projectsWatcherDebounceTimer = null;
|
||||
}
|
||||
|
||||
try {
|
||||
// Initialize chokidar watcher with optimized settings
|
||||
projectsWatcher = chokidar.watch(claudeProjectsPath, {
|
||||
ignored: [
|
||||
'**/node_modules/**',
|
||||
'**/.git/**',
|
||||
'**/dist/**',
|
||||
'**/build/**',
|
||||
'**/*.tmp',
|
||||
'**/*.swp',
|
||||
'**/.DS_Store'
|
||||
],
|
||||
persistent: true,
|
||||
ignoreInitial: true, // Don't fire events for existing files on startup
|
||||
followSymlinks: false,
|
||||
depth: 10, // Reasonable depth limit
|
||||
awaitWriteFinish: {
|
||||
stabilityThreshold: 100, // Wait 100ms for file to stabilize
|
||||
pollInterval: 50
|
||||
await Promise.all(
|
||||
projectsWatchers.map(async (watcher) => {
|
||||
try {
|
||||
await watcher.close();
|
||||
} catch (error) {
|
||||
console.error('[WARN] Failed to close watcher:', error);
|
||||
}
|
||||
});
|
||||
})
|
||||
);
|
||||
projectsWatchers = [];
|
||||
|
||||
// Debounce function to prevent excessive notifications
|
||||
let debounceTimer;
|
||||
const debouncedUpdate = async (eventType, filePath) => {
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(async () => {
|
||||
// Prevent reentrant calls
|
||||
if (isGetProjectsRunning) {
|
||||
return;
|
||||
const debouncedUpdate = (eventType, filePath, provider, rootPath) => {
|
||||
if (projectsWatcherDebounceTimer) {
|
||||
clearTimeout(projectsWatcherDebounceTimer);
|
||||
}
|
||||
|
||||
projectsWatcherDebounceTimer = setTimeout(async () => {
|
||||
// Prevent reentrant calls
|
||||
if (isGetProjectsRunning) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
isGetProjectsRunning = true;
|
||||
|
||||
// Clear project directory cache when files change
|
||||
clearProjectDirectoryCache();
|
||||
|
||||
// Get updated projects list
|
||||
const updatedProjects = await getProjects(broadcastProgress);
|
||||
|
||||
// Notify all connected clients about the project changes
|
||||
const updateMessage = JSON.stringify({
|
||||
type: 'projects_updated',
|
||||
projects: updatedProjects,
|
||||
timestamp: new Date().toISOString(),
|
||||
changeType: eventType,
|
||||
changedFile: path.relative(rootPath, filePath),
|
||||
watchProvider: provider
|
||||
});
|
||||
|
||||
connectedClients.forEach(client => {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.send(updateMessage);
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('[ERROR] Error handling project changes:', error);
|
||||
} finally {
|
||||
isGetProjectsRunning = false;
|
||||
}
|
||||
}, WATCHER_DEBOUNCE_MS);
|
||||
};
|
||||
|
||||
for (const { provider, rootPath } of PROVIDER_WATCH_PATHS) {
|
||||
try {
|
||||
// chokidar v4 emits ENOENT via the "error" event for missing roots and will not auto-recover.
|
||||
// Ensure provider folders exist before creating the watcher so watching stays active.
|
||||
await fsPromises.mkdir(rootPath, { recursive: true });
|
||||
|
||||
// Initialize chokidar watcher with optimized settings
|
||||
const watcher = chokidar.watch(rootPath, {
|
||||
ignored: WATCHER_IGNORED_PATTERNS,
|
||||
persistent: true,
|
||||
ignoreInitial: true, // Don't fire events for existing files on startup
|
||||
followSymlinks: false,
|
||||
depth: 10, // Reasonable depth limit
|
||||
awaitWriteFinish: {
|
||||
stabilityThreshold: 100, // Wait 100ms for file to stabilize
|
||||
pollInterval: 50
|
||||
}
|
||||
|
||||
try {
|
||||
isGetProjectsRunning = true;
|
||||
|
||||
// Clear project directory cache when files change
|
||||
clearProjectDirectoryCache();
|
||||
|
||||
// Get updated projects list
|
||||
const updatedProjects = await getProjects(broadcastProgress);
|
||||
|
||||
// Notify all connected clients about the project changes
|
||||
const updateMessage = JSON.stringify({
|
||||
type: 'projects_updated',
|
||||
projects: updatedProjects,
|
||||
timestamp: new Date().toISOString(),
|
||||
changeType: eventType,
|
||||
changedFile: path.relative(claudeProjectsPath, filePath)
|
||||
});
|
||||
|
||||
connectedClients.forEach(client => {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.send(updateMessage);
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('[ERROR] Error handling project changes:', error);
|
||||
} finally {
|
||||
isGetProjectsRunning = false;
|
||||
}
|
||||
}, 300); // 300ms debounce (slightly faster than before)
|
||||
};
|
||||
|
||||
// Set up event listeners
|
||||
projectsWatcher
|
||||
.on('add', (filePath) => debouncedUpdate('add', filePath))
|
||||
.on('change', (filePath) => debouncedUpdate('change', filePath))
|
||||
.on('unlink', (filePath) => debouncedUpdate('unlink', filePath))
|
||||
.on('addDir', (dirPath) => debouncedUpdate('addDir', dirPath))
|
||||
.on('unlinkDir', (dirPath) => debouncedUpdate('unlinkDir', dirPath))
|
||||
.on('error', (error) => {
|
||||
console.error('[ERROR] Chokidar watcher error:', error);
|
||||
})
|
||||
.on('ready', () => {
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('[ERROR] Failed to setup projects watcher:', error);
|
||||
// Set up event listeners
|
||||
watcher
|
||||
.on('add', (filePath) => debouncedUpdate('add', filePath, provider, rootPath))
|
||||
.on('change', (filePath) => debouncedUpdate('change', filePath, provider, rootPath))
|
||||
.on('unlink', (filePath) => debouncedUpdate('unlink', filePath, provider, rootPath))
|
||||
.on('addDir', (dirPath) => debouncedUpdate('addDir', dirPath, provider, rootPath))
|
||||
.on('unlinkDir', (dirPath) => debouncedUpdate('unlinkDir', dirPath, provider, rootPath))
|
||||
.on('error', (error) => {
|
||||
console.error(`[ERROR] ${provider} watcher error:`, error);
|
||||
})
|
||||
.on('ready', () => {
|
||||
});
|
||||
|
||||
projectsWatchers.push(watcher);
|
||||
} catch (error) {
|
||||
console.error(`[ERROR] Failed to setup ${provider} watcher for ${rootPath}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
if (projectsWatchers.length === 0) {
|
||||
console.error('[ERROR] Failed to setup any provider watchers');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,6 +212,69 @@ const server = http.createServer(app);
|
||||
|
||||
const ptySessionsMap = new Map();
|
||||
const PTY_SESSION_TIMEOUT = 30 * 60 * 1000;
|
||||
const SHELL_URL_PARSE_BUFFER_LIMIT = 32768;
|
||||
const ANSI_ESCAPE_SEQUENCE_REGEX = /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1B\\))/g;
|
||||
const TRAILING_URL_PUNCTUATION_REGEX = /[)\]}>.,;:!?]+$/;
|
||||
|
||||
function stripAnsiSequences(value = '') {
|
||||
return value.replace(ANSI_ESCAPE_SEQUENCE_REGEX, '');
|
||||
}
|
||||
|
||||
function normalizeDetectedUrl(url) {
|
||||
if (!url || typeof url !== 'string') return null;
|
||||
|
||||
const cleaned = url.trim().replace(TRAILING_URL_PUNCTUATION_REGEX, '');
|
||||
if (!cleaned) return null;
|
||||
|
||||
try {
|
||||
const parsed = new URL(cleaned);
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
return null;
|
||||
}
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function extractUrlsFromText(value = '') {
|
||||
const directMatches = value.match(/https?:\/\/[^\s<>"'`\\\x1b\x07]+/gi) || [];
|
||||
|
||||
// Handle wrapped terminal URLs split across lines by terminal width.
|
||||
const wrappedMatches = [];
|
||||
const continuationRegex = /^[A-Za-z0-9\-._~:/?#\[\]@!$&'()*+,;=%]+$/;
|
||||
const lines = value.split(/\r?\n/);
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
const startMatch = line.match(/https?:\/\/[^\s<>"'`\\\x1b\x07]+/i);
|
||||
if (!startMatch) continue;
|
||||
|
||||
let combined = startMatch[0];
|
||||
let j = i + 1;
|
||||
while (j < lines.length) {
|
||||
const continuation = lines[j].trim();
|
||||
if (!continuation) break;
|
||||
if (!continuationRegex.test(continuation)) break;
|
||||
combined += continuation;
|
||||
j++;
|
||||
}
|
||||
|
||||
wrappedMatches.push(combined.replace(/\r?\n\s*/g, ''));
|
||||
}
|
||||
|
||||
return Array.from(new Set([...directMatches, ...wrappedMatches]));
|
||||
}
|
||||
|
||||
function shouldAutoOpenUrlFromOutput(value = '') {
|
||||
const normalized = value.toLowerCase();
|
||||
return (
|
||||
normalized.includes('browser didn\'t open') ||
|
||||
normalized.includes('open this url') ||
|
||||
normalized.includes('continue in your browser') ||
|
||||
normalized.includes('press enter to open') ||
|
||||
normalized.includes('open_url:')
|
||||
);
|
||||
}
|
||||
|
||||
// Single WebSocket server that handles both paths
|
||||
const wss = new WebSocketServer({
|
||||
@@ -200,7 +283,7 @@ const wss = new WebSocketServer({
|
||||
console.log('WebSocket connection attempt to:', info.req.url);
|
||||
|
||||
// Platform mode: always allow connection
|
||||
if (process.env.VITE_IS_PLATFORM === 'true') {
|
||||
if (IS_PLATFORM) {
|
||||
const user = authenticateWebSocket(null); // Will return first user
|
||||
if (!user) {
|
||||
console.log('[WARN] Platform mode: No user found in database');
|
||||
@@ -252,7 +335,8 @@ app.use(express.urlencoded({ limit: '50mb', extended: true }));
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString()
|
||||
timestamp: new Date().toISOString(),
|
||||
installMode
|
||||
});
|
||||
});
|
||||
|
||||
@@ -329,11 +413,13 @@ app.post('/api/system/update', authenticateToken, async (req, res) => {
|
||||
|
||||
console.log('Starting system update from directory:', projectRoot);
|
||||
|
||||
// Run the update command
|
||||
const updateCommand = 'git checkout main && git pull && npm install';
|
||||
// Run the update command based on install mode
|
||||
const updateCommand = installMode === 'git'
|
||||
? 'git checkout main && git pull && npm install'
|
||||
: 'npm install -g @siteboon/claude-code-ui@latest';
|
||||
|
||||
const child = spawn('sh', ['-c', updateCommand], {
|
||||
cwd: projectRoot,
|
||||
cwd: installMode === 'git' ? projectRoot : os.homedir(),
|
||||
env: process.env
|
||||
});
|
||||
|
||||
@@ -484,22 +570,42 @@ app.post('/api/projects/create', authenticateToken, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
const expandWorkspacePath = (inputPath) => {
|
||||
if (!inputPath) return inputPath;
|
||||
if (inputPath === '~') {
|
||||
return WORKSPACES_ROOT;
|
||||
}
|
||||
if (inputPath.startsWith('~/') || inputPath.startsWith('~\\')) {
|
||||
return path.join(WORKSPACES_ROOT, inputPath.slice(2));
|
||||
}
|
||||
return inputPath;
|
||||
};
|
||||
|
||||
// Browse filesystem endpoint for project suggestions - uses existing getFileTree
|
||||
app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { path: dirPath } = req.query;
|
||||
|
||||
console.log('[API] Browse filesystem request for path:', dirPath);
|
||||
console.log('[API] WORKSPACES_ROOT is:', WORKSPACES_ROOT);
|
||||
// Default to home directory if no path provided
|
||||
const homeDir = os.homedir();
|
||||
let targetPath = dirPath ? dirPath.replace('~', homeDir) : homeDir;
|
||||
const defaultRoot = WORKSPACES_ROOT;
|
||||
let targetPath = dirPath ? expandWorkspacePath(dirPath) : defaultRoot;
|
||||
|
||||
// Resolve and normalize the path
|
||||
targetPath = path.resolve(targetPath);
|
||||
|
||||
// Security check - ensure path is within allowed workspace root
|
||||
const validation = await validateWorkspacePath(targetPath);
|
||||
if (!validation.valid) {
|
||||
return res.status(403).json({ error: validation.error });
|
||||
}
|
||||
const resolvedPath = validation.resolvedPath || targetPath;
|
||||
|
||||
// Security check - ensure path is accessible
|
||||
try {
|
||||
await fs.promises.access(targetPath);
|
||||
const stats = await fs.promises.stat(targetPath);
|
||||
await fs.promises.access(resolvedPath);
|
||||
const stats = await fs.promises.stat(resolvedPath);
|
||||
|
||||
if (!stats.isDirectory()) {
|
||||
return res.status(400).json({ error: 'Path is not a directory' });
|
||||
@@ -509,7 +615,7 @@ app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
|
||||
}
|
||||
|
||||
// Use existing getFileTree function with shallow depth (only direct children)
|
||||
const fileTree = await getFileTree(targetPath, 1, 0, false); // maxDepth=1, showHidden=false
|
||||
const fileTree = await getFileTree(resolvedPath, 1, 0, false); // maxDepth=1, showHidden=false
|
||||
|
||||
// Filter only directories and format for suggestions
|
||||
const directories = fileTree
|
||||
@@ -529,7 +635,13 @@ app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
|
||||
|
||||
// Add common directories if browsing home directory
|
||||
const suggestions = [];
|
||||
if (targetPath === homeDir) {
|
||||
let resolvedWorkspaceRoot = defaultRoot;
|
||||
try {
|
||||
resolvedWorkspaceRoot = await fsPromises.realpath(defaultRoot);
|
||||
} catch (error) {
|
||||
// Use default root as-is if realpath fails
|
||||
}
|
||||
if (resolvedPath === resolvedWorkspaceRoot) {
|
||||
const commonDirs = ['Desktop', 'Documents', 'Projects', 'Development', 'Dev', 'Code', 'workspace'];
|
||||
const existingCommon = directories.filter(dir => commonDirs.includes(dir.name));
|
||||
const otherDirs = directories.filter(dir => !commonDirs.includes(dir.name));
|
||||
@@ -540,7 +652,7 @@ app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
|
||||
}
|
||||
|
||||
res.json({
|
||||
path: targetPath,
|
||||
path: resolvedPath,
|
||||
suggestions: suggestions
|
||||
});
|
||||
|
||||
@@ -550,13 +662,52 @@ app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/create-folder', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { path: folderPath } = req.body;
|
||||
if (!folderPath) {
|
||||
return res.status(400).json({ error: 'Path is required' });
|
||||
}
|
||||
const expandedPath = expandWorkspacePath(folderPath);
|
||||
const resolvedInput = path.resolve(expandedPath);
|
||||
const validation = await validateWorkspacePath(resolvedInput);
|
||||
if (!validation.valid) {
|
||||
return res.status(403).json({ error: validation.error });
|
||||
}
|
||||
const targetPath = validation.resolvedPath || resolvedInput;
|
||||
const parentDir = path.dirname(targetPath);
|
||||
try {
|
||||
await fs.promises.access(parentDir);
|
||||
} catch (err) {
|
||||
return res.status(404).json({ error: 'Parent directory does not exist' });
|
||||
}
|
||||
try {
|
||||
await fs.promises.access(targetPath);
|
||||
return res.status(409).json({ error: 'Folder already exists' });
|
||||
} catch (err) {
|
||||
// Folder doesn't exist, which is what we want
|
||||
}
|
||||
try {
|
||||
await fs.promises.mkdir(targetPath, { recursive: false });
|
||||
res.json({ success: true, path: targetPath });
|
||||
} catch (mkdirError) {
|
||||
if (mkdirError.code === 'EEXIST') {
|
||||
return res.status(409).json({ error: 'Folder already exists' });
|
||||
}
|
||||
throw mkdirError;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating folder:', error);
|
||||
res.status(500).json({ error: 'Failed to create folder' });
|
||||
}
|
||||
});
|
||||
|
||||
// Read file content endpoint
|
||||
app.get('/api/projects/:projectName/file', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { projectName } = req.params;
|
||||
const { filePath } = req.query;
|
||||
|
||||
console.log('[DEBUG] File read request:', projectName, filePath);
|
||||
|
||||
// Security: ensure the requested path is inside the project root
|
||||
if (!filePath) {
|
||||
@@ -597,7 +748,6 @@ app.get('/api/projects/:projectName/files/content', authenticateToken, async (re
|
||||
const { projectName } = req.params;
|
||||
const { path: filePath } = req.query;
|
||||
|
||||
console.log('[DEBUG] Binary file serve request:', projectName, filePath);
|
||||
|
||||
// Security: ensure the requested path is inside the project root
|
||||
if (!filePath) {
|
||||
@@ -651,7 +801,6 @@ app.put('/api/projects/:projectName/file', authenticateToken, async (req, res) =
|
||||
const { projectName } = req.params;
|
||||
const { filePath, content } = req.body;
|
||||
|
||||
console.log('[DEBUG] File save request:', projectName, filePath);
|
||||
|
||||
// Security: ensure the requested path is inside the project root
|
||||
if (!filePath) {
|
||||
@@ -908,7 +1057,8 @@ function handleShellConnection(ws) {
|
||||
console.log('🐚 Shell client connected');
|
||||
let shellProcess = null;
|
||||
let ptySessionKey = null;
|
||||
let outputBuffer = [];
|
||||
let urlDetectionBuffer = '';
|
||||
const announcedAuthUrls = new Set();
|
||||
|
||||
ws.on('message', async (message) => {
|
||||
try {
|
||||
@@ -922,6 +1072,8 @@ function handleShellConnection(ws) {
|
||||
const provider = data.provider || 'claude';
|
||||
const initialCommand = data.initialCommand;
|
||||
const isPlainShell = data.isPlainShell || (!!initialCommand && !hasSession) || provider === 'plain-shell';
|
||||
urlDetectionBuffer = '';
|
||||
announcedAuthUrls.clear();
|
||||
|
||||
// Login commands (Claude/Cursor auth) should never reuse cached sessions
|
||||
const isLoginCommand = initialCommand && (
|
||||
@@ -986,7 +1138,7 @@ function handleShellConnection(ws) {
|
||||
if (isPlainShell) {
|
||||
welcomeMsg = `\x1b[36mStarting terminal in: ${projectPath}\x1b[0m\r\n`;
|
||||
} else {
|
||||
const providerName = provider === 'cursor' ? 'Cursor' : 'Claude';
|
||||
const providerName = provider === 'cursor' ? 'Cursor' : provider === 'codex' ? 'Codex' : 'Claude';
|
||||
welcomeMsg = hasSession ?
|
||||
`\x1b[36mResuming ${providerName} session ${sessionId} in: ${projectPath}\x1b[0m\r\n` :
|
||||
`\x1b[36mStarting new ${providerName} session in: ${projectPath}\x1b[0m\r\n`;
|
||||
@@ -1022,6 +1174,23 @@ function handleShellConnection(ws) {
|
||||
shellCommand = `cd "${projectPath}" && cursor-agent`;
|
||||
}
|
||||
}
|
||||
} else if (provider === 'codex') {
|
||||
// Use codex command
|
||||
if (os.platform() === 'win32') {
|
||||
if (hasSession && sessionId) {
|
||||
// Try to resume session, but with fallback to a new session if it fails
|
||||
shellCommand = `Set-Location -Path "${projectPath}"; codex resume "${sessionId}"; if ($LASTEXITCODE -ne 0) { codex }`;
|
||||
} else {
|
||||
shellCommand = `Set-Location -Path "${projectPath}"; codex`;
|
||||
}
|
||||
} else {
|
||||
if (hasSession && sessionId) {
|
||||
// Try to resume session, but with fallback to a new session if it fails
|
||||
shellCommand = `cd "${projectPath}" && codex resume "${sessionId}" || codex`;
|
||||
} else {
|
||||
shellCommand = `cd "${projectPath}" && codex`;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Use claude command (default) or initialCommand if provided
|
||||
const command = initialCommand || 'claude';
|
||||
@@ -1061,9 +1230,7 @@ function handleShellConnection(ws) {
|
||||
...process.env,
|
||||
TERM: 'xterm-256color',
|
||||
COLORTERM: 'truecolor',
|
||||
FORCE_COLOR: '3',
|
||||
// Override browser opening commands to echo URL for detection
|
||||
BROWSER: os.platform() === 'win32' ? 'echo "OPEN_URL:"' : 'echo "OPEN_URL:"'
|
||||
FORCE_COLOR: '3'
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1093,38 +1260,47 @@ function handleShellConnection(ws) {
|
||||
if (session.ws && session.ws.readyState === WebSocket.OPEN) {
|
||||
let outputData = data;
|
||||
|
||||
// Check for various URL opening patterns
|
||||
const patterns = [
|
||||
// Direct browser opening commands
|
||||
/(?:xdg-open|open|start)\s+(https?:\/\/[^\s\x1b\x07]+)/g,
|
||||
// BROWSER environment variable override
|
||||
const cleanChunk = stripAnsiSequences(data);
|
||||
urlDetectionBuffer = `${urlDetectionBuffer}${cleanChunk}`.slice(-SHELL_URL_PARSE_BUFFER_LIMIT);
|
||||
|
||||
outputData = outputData.replace(
|
||||
/OPEN_URL:\s*(https?:\/\/[^\s\x1b\x07]+)/g,
|
||||
// Git and other tools opening URLs
|
||||
/Opening\s+(https?:\/\/[^\s\x1b\x07]+)/gi,
|
||||
// General URL patterns that might be opened
|
||||
/Visit:\s*(https?:\/\/[^\s\x1b\x07]+)/gi,
|
||||
/View at:\s*(https?:\/\/[^\s\x1b\x07]+)/gi,
|
||||
/Browse to:\s*(https?:\/\/[^\s\x1b\x07]+)/gi
|
||||
];
|
||||
'[INFO] Opening in browser: $1'
|
||||
);
|
||||
|
||||
patterns.forEach(pattern => {
|
||||
let match;
|
||||
while ((match = pattern.exec(data)) !== null) {
|
||||
const url = match[1];
|
||||
console.log('[DEBUG] Detected URL for opening:', url);
|
||||
const emitAuthUrl = (detectedUrl, autoOpen = false) => {
|
||||
const normalizedUrl = normalizeDetectedUrl(detectedUrl);
|
||||
if (!normalizedUrl) return;
|
||||
|
||||
// Send URL opening message to client
|
||||
const isNewUrl = !announcedAuthUrls.has(normalizedUrl);
|
||||
if (isNewUrl) {
|
||||
announcedAuthUrls.add(normalizedUrl);
|
||||
session.ws.send(JSON.stringify({
|
||||
type: 'url_open',
|
||||
url: url
|
||||
type: 'auth_url',
|
||||
url: normalizedUrl,
|
||||
autoOpen
|
||||
}));
|
||||
|
||||
// Replace the OPEN_URL pattern with a user-friendly message
|
||||
if (pattern.source.includes('OPEN_URL')) {
|
||||
outputData = outputData.replace(match[0], `[INFO] Opening in browser: ${url}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
};
|
||||
|
||||
const normalizedDetectedUrls = extractUrlsFromText(urlDetectionBuffer)
|
||||
.map((url) => normalizeDetectedUrl(url))
|
||||
.filter(Boolean);
|
||||
|
||||
// Prefer the most complete URL if shorter prefix variants are also present.
|
||||
const dedupedDetectedUrls = Array.from(new Set(normalizedDetectedUrls)).filter((url, _, urls) =>
|
||||
!urls.some((otherUrl) => otherUrl !== url && otherUrl.startsWith(url))
|
||||
);
|
||||
|
||||
dedupedDetectedUrls.forEach((url) => emitAuthUrl(url, false));
|
||||
|
||||
if (shouldAutoOpenUrlFromOutput(cleanChunk) && dedupedDetectedUrls.length > 0) {
|
||||
const bestUrl = dedupedDetectedUrls.reduce((longest, current) =>
|
||||
current.length > longest.length ? current : longest
|
||||
);
|
||||
emitAuthUrl(bestUrl, true);
|
||||
}
|
||||
|
||||
// Send regular output
|
||||
session.ws.send(JSON.stringify({
|
||||
@@ -1732,6 +1908,9 @@ async function getFileTree(dirPath, maxDepth = 3, currentDepth = 0, showHidden =
|
||||
}
|
||||
|
||||
const PORT = process.env.PORT || 3001;
|
||||
const HOST = process.env.HOST || '0.0.0.0';
|
||||
// Show localhost in URL when binding to all interfaces (0.0.0.0 isn't a connectable address)
|
||||
const DISPLAY_HOST = HOST === '0.0.0.0' ? 'localhost' : HOST;
|
||||
|
||||
// Initialize database and start server
|
||||
async function startServer() {
|
||||
@@ -1751,7 +1930,7 @@ async function startServer() {
|
||||
console.log(`${c.warn('[WARN]')} Note: Requests will be proxied to Vite dev server at ${c.dim('http://localhost:' + (process.env.VITE_PORT || 5173))}`);
|
||||
}
|
||||
|
||||
server.listen(PORT, '0.0.0.0', async () => {
|
||||
server.listen(PORT, HOST, async () => {
|
||||
const appInstallPath = path.join(__dirname, '..');
|
||||
|
||||
console.log('');
|
||||
@@ -1759,7 +1938,7 @@ async function startServer() {
|
||||
console.log(` ${c.bright('Claude Code UI Server - Ready')}`);
|
||||
console.log(c.dim('═'.repeat(63)));
|
||||
console.log('');
|
||||
console.log(`${c.info('[INFO]')} Server URL: ${c.bright('http://0.0.0.0:' + PORT)}`);
|
||||
console.log(`${c.info('[INFO]')} Server URL: ${c.bright('http://' + DISPLAY_HOST + ':' + PORT)}`);
|
||||
console.log(`${c.info('[INFO]')} Installed at: ${c.dim(appInstallPath)}`);
|
||||
console.log(`${c.tip('[TIP]')} Run "cloudcli status" for full configuration details`);
|
||||
console.log('');
|
||||
|
||||
29
server/load-env.js
Normal file
29
server/load-env.js
Normal file
@@ -0,0 +1,29 @@
|
||||
// Load environment variables from .env before other imports execute.
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname } from 'path';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
try {
|
||||
const envPath = path.join(__dirname, '../.env');
|
||||
const envFile = fs.readFileSync(envPath, 'utf8');
|
||||
envFile.split('\n').forEach(line => {
|
||||
const trimmedLine = line.trim();
|
||||
if (trimmedLine && !trimmedLine.startsWith('#')) {
|
||||
const [key, ...valueParts] = trimmedLine.split('=');
|
||||
if (key && valueParts.length > 0 && !process.env[key]) {
|
||||
process.env[key] = valueParts.join('=').trim();
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.log('No .env file found or error reading it:', e.message);
|
||||
}
|
||||
|
||||
if (!process.env.DATABASE_PATH) {
|
||||
process.env.DATABASE_PATH = path.join(os.homedir(), '.cloudcli', 'auth.db');
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { userDb } from '../database/db.js';
|
||||
import { IS_PLATFORM } from '../constants/config.js';
|
||||
|
||||
// Get JWT secret from environment or use default (for development)
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'claude-ui-dev-secret-change-in-production';
|
||||
@@ -21,7 +22,7 @@ const validateApiKey = (req, res, next) => {
|
||||
// JWT authentication middleware
|
||||
const authenticateToken = async (req, res, next) => {
|
||||
// Platform mode: use single database user
|
||||
if (process.env.VITE_IS_PLATFORM === 'true') {
|
||||
if (IS_PLATFORM) {
|
||||
try {
|
||||
const user = userDb.getFirstUser();
|
||||
if (!user) {
|
||||
@@ -37,7 +38,12 @@ const authenticateToken = async (req, res, next) => {
|
||||
|
||||
// Normal OSS JWT validation
|
||||
const authHeader = req.headers['authorization'];
|
||||
const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN
|
||||
let token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN
|
||||
|
||||
// Also check query param for SSE endpoints (EventSource can't set headers)
|
||||
if (!token && req.query.token) {
|
||||
token = req.query.token;
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: 'Access denied. No token provided.' });
|
||||
@@ -75,7 +81,7 @@ const generateToken = (user) => {
|
||||
// WebSocket authentication function
|
||||
const authenticateWebSocket = (token) => {
|
||||
// Platform mode: bypass token validation, return first user
|
||||
if (process.env.VITE_IS_PLATFORM === 'true') {
|
||||
if (IS_PLATFORM) {
|
||||
try {
|
||||
const user = userDb.getFirstUser();
|
||||
if (user) {
|
||||
|
||||
@@ -203,6 +203,7 @@ export async function queryCodex(command, options = {}, ws) {
|
||||
let codex;
|
||||
let thread;
|
||||
let currentSessionId = sessionId;
|
||||
const abortController = new AbortController();
|
||||
|
||||
try {
|
||||
// Initialize Codex SDK
|
||||
@@ -232,6 +233,7 @@ export async function queryCodex(command, options = {}, ws) {
|
||||
thread,
|
||||
codex,
|
||||
status: 'running',
|
||||
abortController,
|
||||
startedAt: new Date().toISOString()
|
||||
});
|
||||
|
||||
@@ -243,7 +245,9 @@ export async function queryCodex(command, options = {}, ws) {
|
||||
});
|
||||
|
||||
// Execute with streaming
|
||||
const streamedTurn = await thread.runStreamed(command);
|
||||
const streamedTurn = await thread.runStreamed(command, {
|
||||
signal: abortController.signal
|
||||
});
|
||||
|
||||
for await (const event of streamedTurn.events) {
|
||||
// Check if session was aborted
|
||||
@@ -286,20 +290,27 @@ export async function queryCodex(command, options = {}, ws) {
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('[Codex] Error:', error);
|
||||
const session = currentSessionId ? activeCodexSessions.get(currentSessionId) : null;
|
||||
const wasAborted =
|
||||
session?.status === 'aborted' ||
|
||||
error?.name === 'AbortError' ||
|
||||
String(error?.message || '').toLowerCase().includes('aborted');
|
||||
|
||||
sendMessage(ws, {
|
||||
type: 'codex-error',
|
||||
error: error.message,
|
||||
sessionId: currentSessionId
|
||||
});
|
||||
if (!wasAborted) {
|
||||
console.error('[Codex] Error:', error);
|
||||
sendMessage(ws, {
|
||||
type: 'codex-error',
|
||||
error: error.message,
|
||||
sessionId: currentSessionId
|
||||
});
|
||||
}
|
||||
|
||||
} finally {
|
||||
// Update session status
|
||||
if (currentSessionId) {
|
||||
const session = activeCodexSessions.get(currentSessionId);
|
||||
if (session) {
|
||||
session.status = 'completed';
|
||||
session.status = session.status === 'aborted' ? 'aborted' : 'completed';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -318,9 +329,11 @@ export function abortCodexSession(sessionId) {
|
||||
}
|
||||
|
||||
session.status = 'aborted';
|
||||
|
||||
// The SDK doesn't have a direct abort method, but marking status
|
||||
// will cause the streaming loop to exit
|
||||
try {
|
||||
session.abortController?.abort();
|
||||
} catch (error) {
|
||||
console.warn(`[Codex] Failed to abort session ${sessionId}:`, error);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -384,6 +384,7 @@ async function getProjects(progressCallback = null) {
|
||||
const config = await loadProjectConfig();
|
||||
const projects = [];
|
||||
const existingProjects = new Set();
|
||||
const codexSessionsIndexRef = { sessionsByProject: null };
|
||||
let totalProjects = 0;
|
||||
let processedProjects = 0;
|
||||
let directories = [];
|
||||
@@ -419,8 +420,6 @@ async function getProjects(progressCallback = null) {
|
||||
});
|
||||
}
|
||||
|
||||
const projectPath = path.join(claudeDir, entry.name);
|
||||
|
||||
// Extract actual project directory from JSONL sessions
|
||||
const actualProjectDir = await extractProjectDirectory(entry.name);
|
||||
|
||||
@@ -435,7 +434,11 @@ async function getProjects(progressCallback = null) {
|
||||
displayName: customName || autoDisplayName,
|
||||
fullPath: fullPath,
|
||||
isCustomName: !!customName,
|
||||
sessions: []
|
||||
sessions: [],
|
||||
sessionMeta: {
|
||||
hasMore: false,
|
||||
total: 0
|
||||
}
|
||||
};
|
||||
|
||||
// Try to get sessions for this project (just first 5 for performance)
|
||||
@@ -448,6 +451,10 @@ async function getProjects(progressCallback = null) {
|
||||
};
|
||||
} catch (e) {
|
||||
console.warn(`Could not load sessions for project ${entry.name}:`, e.message);
|
||||
project.sessionMeta = {
|
||||
hasMore: false,
|
||||
total: 0
|
||||
};
|
||||
}
|
||||
|
||||
// Also fetch Cursor sessions for this project
|
||||
@@ -460,7 +467,9 @@ async function getProjects(progressCallback = null) {
|
||||
|
||||
// Also fetch Codex sessions for this project
|
||||
try {
|
||||
project.codexSessions = await getCodexSessions(actualProjectDir);
|
||||
project.codexSessions = await getCodexSessions(actualProjectDir, {
|
||||
indexRef: codexSessionsIndexRef,
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn(`Could not load Codex sessions for project ${entry.name}:`, e.message);
|
||||
project.codexSessions = [];
|
||||
@@ -525,7 +534,7 @@ async function getProjects(progressCallback = null) {
|
||||
}
|
||||
}
|
||||
|
||||
const project = {
|
||||
const project = {
|
||||
name: projectName,
|
||||
path: actualProjectDir,
|
||||
displayName: projectConfig.displayName || await generateDisplayName(projectName, actualProjectDir),
|
||||
@@ -533,9 +542,13 @@ async function getProjects(progressCallback = null) {
|
||||
isCustomName: !!projectConfig.displayName,
|
||||
isManuallyAdded: true,
|
||||
sessions: [],
|
||||
sessionMeta: {
|
||||
hasMore: false,
|
||||
total: 0
|
||||
},
|
||||
cursorSessions: [],
|
||||
codexSessions: []
|
||||
};
|
||||
};
|
||||
|
||||
// Try to fetch Cursor sessions for manual projects too
|
||||
try {
|
||||
@@ -546,7 +559,9 @@ async function getProjects(progressCallback = null) {
|
||||
|
||||
// Try to fetch Codex sessions for manual projects too
|
||||
try {
|
||||
project.codexSessions = await getCodexSessions(actualProjectDir);
|
||||
project.codexSessions = await getCodexSessions(actualProjectDir, {
|
||||
indexRef: codexSessionsIndexRef,
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn(`Could not load Codex sessions for manual project ${projectName}:`, e.message);
|
||||
}
|
||||
@@ -874,22 +889,81 @@ async function parseJsonlSessions(filePath) {
|
||||
}
|
||||
}
|
||||
|
||||
// Parse an agent JSONL file and extract tool uses
|
||||
async function parseAgentTools(filePath) {
|
||||
const tools = [];
|
||||
|
||||
try {
|
||||
const fileStream = fsSync.createReadStream(filePath);
|
||||
const rl = readline.createInterface({
|
||||
input: fileStream,
|
||||
crlfDelay: Infinity
|
||||
});
|
||||
|
||||
for await (const line of rl) {
|
||||
if (line.trim()) {
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
// Look for assistant messages with tool_use
|
||||
if (entry.message?.role === 'assistant' && Array.isArray(entry.message?.content)) {
|
||||
for (const part of entry.message.content) {
|
||||
if (part.type === 'tool_use') {
|
||||
tools.push({
|
||||
toolId: part.id,
|
||||
toolName: part.name,
|
||||
toolInput: part.input,
|
||||
timestamp: entry.timestamp
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
// Look for tool results
|
||||
if (entry.message?.role === 'user' && Array.isArray(entry.message?.content)) {
|
||||
for (const part of entry.message.content) {
|
||||
if (part.type === 'tool_result') {
|
||||
// Find the matching tool and add result
|
||||
const tool = tools.find(t => t.toolId === part.tool_use_id);
|
||||
if (tool) {
|
||||
tool.toolResult = {
|
||||
content: typeof part.content === 'string' ? part.content :
|
||||
Array.isArray(part.content) ? part.content.map(c => c.text || '').join('\n') :
|
||||
JSON.stringify(part.content),
|
||||
isError: Boolean(part.is_error)
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (parseError) {
|
||||
// Skip malformed lines
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Error parsing agent file ${filePath}:`, error.message);
|
||||
}
|
||||
|
||||
return tools;
|
||||
}
|
||||
|
||||
// Get messages for a specific session with pagination support
|
||||
async function getSessionMessages(projectName, sessionId, limit = null, offset = 0) {
|
||||
const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName);
|
||||
|
||||
try {
|
||||
const files = await fs.readdir(projectDir);
|
||||
// agent-*.jsonl files contain session start data at this point. This needs to be revisited
|
||||
// periodically to make sure only accurate data is there and no new functionality is added there
|
||||
// agent-*.jsonl files contain subagent tool history - we'll process them separately
|
||||
const jsonlFiles = files.filter(file => file.endsWith('.jsonl') && !file.startsWith('agent-'));
|
||||
|
||||
const agentFiles = files.filter(file => file.endsWith('.jsonl') && file.startsWith('agent-'));
|
||||
|
||||
if (jsonlFiles.length === 0) {
|
||||
return { messages: [], total: 0, hasMore: false };
|
||||
}
|
||||
|
||||
|
||||
const messages = [];
|
||||
|
||||
// Map of agentId -> tools for subagent tool grouping
|
||||
const agentToolsCache = new Map();
|
||||
|
||||
// Process all JSONL files to find messages for this session
|
||||
for (const file of jsonlFiles) {
|
||||
const jsonlFile = path.join(projectDir, file);
|
||||
@@ -898,7 +972,7 @@ async function getSessionMessages(projectName, sessionId, limit = null, offset =
|
||||
input: fileStream,
|
||||
crlfDelay: Infinity
|
||||
});
|
||||
|
||||
|
||||
for await (const line of rl) {
|
||||
if (line.trim()) {
|
||||
try {
|
||||
@@ -912,26 +986,55 @@ async function getSessionMessages(projectName, sessionId, limit = null, offset =
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Collect agentIds from Task tool results
|
||||
const agentIds = new Set();
|
||||
for (const message of messages) {
|
||||
if (message.toolUseResult?.agentId) {
|
||||
agentIds.add(message.toolUseResult.agentId);
|
||||
}
|
||||
}
|
||||
|
||||
// Load agent tools for each agentId found
|
||||
for (const agentId of agentIds) {
|
||||
const agentFileName = `agent-${agentId}.jsonl`;
|
||||
if (agentFiles.includes(agentFileName)) {
|
||||
const agentFilePath = path.join(projectDir, agentFileName);
|
||||
const tools = await parseAgentTools(agentFilePath);
|
||||
agentToolsCache.set(agentId, tools);
|
||||
}
|
||||
}
|
||||
|
||||
// Attach agent tools to their parent Task messages
|
||||
for (const message of messages) {
|
||||
if (message.toolUseResult?.agentId) {
|
||||
const agentId = message.toolUseResult.agentId;
|
||||
const agentTools = agentToolsCache.get(agentId);
|
||||
if (agentTools && agentTools.length > 0) {
|
||||
message.subagentTools = agentTools;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort messages by timestamp
|
||||
const sortedMessages = messages.sort((a, b) =>
|
||||
const sortedMessages = messages.sort((a, b) =>
|
||||
new Date(a.timestamp || 0) - new Date(b.timestamp || 0)
|
||||
);
|
||||
|
||||
|
||||
const total = sortedMessages.length;
|
||||
|
||||
|
||||
// If no limit is specified, return all messages (backward compatibility)
|
||||
if (limit === null) {
|
||||
return sortedMessages;
|
||||
}
|
||||
|
||||
|
||||
// Apply pagination - for recent messages, we need to slice from the end
|
||||
// offset 0 should give us the most recent messages
|
||||
const startIndex = Math.max(0, total - offset - limit);
|
||||
const endIndex = total - offset;
|
||||
const paginatedMessages = sortedMessages.slice(startIndex, endIndex);
|
||||
const hasMore = startIndex > 0;
|
||||
|
||||
|
||||
return {
|
||||
messages: paginatedMessages,
|
||||
total,
|
||||
@@ -1095,7 +1198,7 @@ async function addProjectManually(projectPath, displayName = null) {
|
||||
}
|
||||
|
||||
// Generate project name (encode path for use as directory name)
|
||||
const projectName = absolutePath.replace(/\//g, '-');
|
||||
const projectName = absolutePath.replace(/[\\/:\s~_]/g, '-');
|
||||
|
||||
// Check if project already exists in config
|
||||
const config = await loadProjectConfig();
|
||||
@@ -1244,75 +1347,114 @@ async function getCursorSessions(projectPath) {
|
||||
}
|
||||
|
||||
|
||||
function normalizeComparablePath(inputPath) {
|
||||
if (!inputPath || typeof inputPath !== 'string') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const withoutLongPathPrefix = inputPath.startsWith('\\\\?\\')
|
||||
? inputPath.slice(4)
|
||||
: inputPath;
|
||||
const normalized = path.normalize(withoutLongPathPrefix.trim());
|
||||
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const resolved = path.resolve(normalized);
|
||||
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
||||
}
|
||||
|
||||
async function findCodexJsonlFiles(dir) {
|
||||
const files = [];
|
||||
|
||||
try {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...await findCodexJsonlFiles(fullPath));
|
||||
} else if (entry.name.endsWith('.jsonl')) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Skip directories we can't read
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
async function buildCodexSessionsIndex() {
|
||||
const codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions');
|
||||
const sessionsByProject = new Map();
|
||||
|
||||
try {
|
||||
await fs.access(codexSessionsDir);
|
||||
} catch (error) {
|
||||
return sessionsByProject;
|
||||
}
|
||||
|
||||
const jsonlFiles = await findCodexJsonlFiles(codexSessionsDir);
|
||||
|
||||
for (const filePath of jsonlFiles) {
|
||||
try {
|
||||
const sessionData = await parseCodexSessionFile(filePath);
|
||||
if (!sessionData || !sessionData.id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalizedProjectPath = normalizeComparablePath(sessionData.cwd);
|
||||
if (!normalizedProjectPath) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const session = {
|
||||
id: sessionData.id,
|
||||
summary: sessionData.summary || 'Codex Session',
|
||||
messageCount: sessionData.messageCount || 0,
|
||||
lastActivity: sessionData.timestamp ? new Date(sessionData.timestamp) : new Date(),
|
||||
cwd: sessionData.cwd,
|
||||
model: sessionData.model,
|
||||
filePath,
|
||||
provider: 'codex',
|
||||
};
|
||||
|
||||
if (!sessionsByProject.has(normalizedProjectPath)) {
|
||||
sessionsByProject.set(normalizedProjectPath, []);
|
||||
}
|
||||
|
||||
sessionsByProject.get(normalizedProjectPath).push(session);
|
||||
} catch (error) {
|
||||
console.warn(`Could not parse Codex session file ${filePath}:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
for (const sessions of sessionsByProject.values()) {
|
||||
sessions.sort((a, b) => new Date(b.lastActivity) - new Date(a.lastActivity));
|
||||
}
|
||||
|
||||
return sessionsByProject;
|
||||
}
|
||||
|
||||
// Fetch Codex sessions for a given project path
|
||||
async function getCodexSessions(projectPath, options = {}) {
|
||||
const { limit = 5 } = options;
|
||||
const { limit = 5, indexRef = null } = options;
|
||||
try {
|
||||
const codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions');
|
||||
const sessions = [];
|
||||
|
||||
// Check if the directory exists
|
||||
try {
|
||||
await fs.access(codexSessionsDir);
|
||||
} catch (error) {
|
||||
// No Codex sessions directory
|
||||
const normalizedProjectPath = normalizeComparablePath(projectPath);
|
||||
if (!normalizedProjectPath) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Recursively find all .jsonl files in the sessions directory
|
||||
const findJsonlFiles = async (dir) => {
|
||||
const files = [];
|
||||
try {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...await findJsonlFiles(fullPath));
|
||||
} else if (entry.name.endsWith('.jsonl')) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Skip directories we can't read
|
||||
}
|
||||
return files;
|
||||
};
|
||||
|
||||
const jsonlFiles = await findJsonlFiles(codexSessionsDir);
|
||||
|
||||
// Process each file to find sessions matching the project path
|
||||
for (const filePath of jsonlFiles) {
|
||||
try {
|
||||
const sessionData = await parseCodexSessionFile(filePath);
|
||||
|
||||
// Check if this session matches the project path
|
||||
// Handle Windows long paths with \\?\ prefix
|
||||
const sessionCwd = sessionData?.cwd || '';
|
||||
const cleanSessionCwd = sessionCwd.startsWith('\\\\?\\') ? sessionCwd.slice(4) : sessionCwd;
|
||||
const cleanProjectPath = projectPath.startsWith('\\\\?\\') ? projectPath.slice(4) : projectPath;
|
||||
|
||||
if (sessionData && (sessionData.cwd === projectPath || cleanSessionCwd === cleanProjectPath || path.relative(cleanSessionCwd, cleanProjectPath) === '')) {
|
||||
sessions.push({
|
||||
id: sessionData.id,
|
||||
summary: sessionData.summary || 'Codex Session',
|
||||
messageCount: sessionData.messageCount || 0,
|
||||
lastActivity: sessionData.timestamp ? new Date(sessionData.timestamp) : new Date(),
|
||||
cwd: sessionData.cwd,
|
||||
model: sessionData.model,
|
||||
filePath: filePath,
|
||||
provider: 'codex'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Could not parse Codex session file ${filePath}:`, error.message);
|
||||
}
|
||||
if (indexRef && !indexRef.sessionsByProject) {
|
||||
indexRef.sessionsByProject = await buildCodexSessionsIndex();
|
||||
}
|
||||
|
||||
// Sort sessions by last activity (newest first)
|
||||
sessions.sort((a, b) => new Date(b.lastActivity) - new Date(a.lastActivity));
|
||||
const sessionsByProject = indexRef?.sessionsByProject || await buildCodexSessionsIndex();
|
||||
const sessions = sessionsByProject.get(normalizedProjectPath) || [];
|
||||
|
||||
// Return limited sessions for performance (0 = unlimited for deletion)
|
||||
return limit > 0 ? sessions.slice(0, limit) : sessions;
|
||||
return limit > 0 ? sessions.slice(0, limit) : [...sessions];
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching Codex sessions:', error);
|
||||
|
||||
@@ -11,6 +11,7 @@ import { spawnCursor } from '../cursor-cli.js';
|
||||
import { queryCodex } from '../openai-codex.js';
|
||||
import { Octokit } from '@octokit/rest';
|
||||
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../shared/modelConstants.js';
|
||||
import { IS_PLATFORM } from '../constants/config.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -18,7 +19,7 @@ const router = express.Router();
|
||||
* Middleware to authenticate agent API requests.
|
||||
*
|
||||
* Supports two authentication modes:
|
||||
* 1. Platform mode (VITE_IS_PLATFORM=true): For managed/hosted deployments where
|
||||
* 1. Platform mode (IS_PLATFORM=true): For managed/hosted deployments where
|
||||
* authentication is handled by an external proxy. Requests are trusted and
|
||||
* the default user context is used.
|
||||
*
|
||||
@@ -28,7 +29,7 @@ const router = express.Router();
|
||||
const validateExternalApiKey = (req, res, next) => {
|
||||
// Platform mode: Authentication is handled externally (e.g., by a proxy layer).
|
||||
// Trust the request and use the default user context.
|
||||
if (process.env.VITE_IS_PLATFORM === 'true') {
|
||||
if (IS_PLATFORM) {
|
||||
try {
|
||||
const user = userDb.getFirstUser();
|
||||
if (!user) {
|
||||
|
||||
@@ -53,11 +53,11 @@ router.post('/register', async (req, res) => {
|
||||
// Generate token
|
||||
const token = generateToken(user);
|
||||
|
||||
// Update last login
|
||||
db.prepare('COMMIT').run();
|
||||
|
||||
// Update last login (non-fatal, outside transaction)
|
||||
userDb.updateLastLogin(user.id);
|
||||
|
||||
db.prepare('COMMIT').run();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
user: { id: user.id, username: user.username },
|
||||
|
||||
@@ -209,6 +209,86 @@ Custom commands can be created in:
|
||||
};
|
||||
},
|
||||
|
||||
'/cost': async (args, context) => {
|
||||
const tokenUsage = context?.tokenUsage || {};
|
||||
const provider = context?.provider || 'claude';
|
||||
const model =
|
||||
context?.model ||
|
||||
(provider === 'cursor'
|
||||
? CURSOR_MODELS.DEFAULT
|
||||
: provider === 'codex'
|
||||
? CODEX_MODELS.DEFAULT
|
||||
: CLAUDE_MODELS.DEFAULT);
|
||||
|
||||
const used = Number(tokenUsage.used ?? tokenUsage.totalUsed ?? tokenUsage.total_tokens ?? 0) || 0;
|
||||
const total =
|
||||
Number(
|
||||
tokenUsage.total ??
|
||||
tokenUsage.contextWindow ??
|
||||
parseInt(process.env.CONTEXT_WINDOW || '160000', 10),
|
||||
) || 160000;
|
||||
const percentage = total > 0 ? Number(((used / total) * 100).toFixed(1)) : 0;
|
||||
|
||||
const inputTokensRaw =
|
||||
Number(
|
||||
tokenUsage.inputTokens ??
|
||||
tokenUsage.input ??
|
||||
tokenUsage.cumulativeInputTokens ??
|
||||
tokenUsage.promptTokens ??
|
||||
0,
|
||||
) || 0;
|
||||
const outputTokens =
|
||||
Number(
|
||||
tokenUsage.outputTokens ??
|
||||
tokenUsage.output ??
|
||||
tokenUsage.cumulativeOutputTokens ??
|
||||
tokenUsage.completionTokens ??
|
||||
0,
|
||||
) || 0;
|
||||
const cacheTokens =
|
||||
Number(
|
||||
tokenUsage.cacheReadTokens ??
|
||||
tokenUsage.cacheCreationTokens ??
|
||||
tokenUsage.cacheTokens ??
|
||||
tokenUsage.cachedTokens ??
|
||||
0,
|
||||
) || 0;
|
||||
|
||||
// If we only have total used tokens, treat them as input for display/estimation.
|
||||
const inputTokens =
|
||||
inputTokensRaw > 0 || outputTokens > 0 || cacheTokens > 0 ? inputTokensRaw + cacheTokens : used;
|
||||
|
||||
// Rough default rates by provider (USD / 1M tokens).
|
||||
const pricingByProvider = {
|
||||
claude: { input: 3, output: 15 },
|
||||
cursor: { input: 3, output: 15 },
|
||||
codex: { input: 1.5, output: 6 },
|
||||
};
|
||||
const rates = pricingByProvider[provider] || pricingByProvider.claude;
|
||||
|
||||
const inputCost = (inputTokens / 1_000_000) * rates.input;
|
||||
const outputCost = (outputTokens / 1_000_000) * rates.output;
|
||||
const totalCost = inputCost + outputCost;
|
||||
|
||||
return {
|
||||
type: 'builtin',
|
||||
action: 'cost',
|
||||
data: {
|
||||
tokenUsage: {
|
||||
used,
|
||||
total,
|
||||
percentage,
|
||||
},
|
||||
cost: {
|
||||
input: inputCost.toFixed(4),
|
||||
output: outputCost.toFixed(4),
|
||||
total: totalCost.toFixed(4),
|
||||
},
|
||||
model,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
'/status': async (args, context) => {
|
||||
// Read version from package.json
|
||||
const packageJsonPath = path.join(path.dirname(__dirname), '..', 'package.json');
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import express from 'express';
|
||||
import { exec } from 'child_process';
|
||||
import { exec, spawn } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import path from 'path';
|
||||
import { promises as fs } from 'fs';
|
||||
@@ -10,6 +10,43 @@ import { spawnCursor } from '../cursor-cli.js';
|
||||
const router = express.Router();
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
function spawnAsync(command, args, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
...options,
|
||||
shell: false,
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
child.stdout.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
child.stderr.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
child.on('error', (error) => {
|
||||
reject(error);
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve({ stdout, stderr });
|
||||
return;
|
||||
}
|
||||
|
||||
const error = new Error(`Command failed: ${command} ${args.join(' ')}`);
|
||||
error.code = code;
|
||||
error.stdout = stdout;
|
||||
error.stderr = stderr;
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Helper function to get the actual project path from the encoded project name
|
||||
async function getActualProjectPath(projectName) {
|
||||
try {
|
||||
@@ -60,19 +97,16 @@ async function validateGitRepository(projectPath) {
|
||||
}
|
||||
|
||||
try {
|
||||
// Use --show-toplevel to get the root of the git repository
|
||||
const { stdout: gitRoot } = await execAsync('git rev-parse --show-toplevel', { cwd: projectPath });
|
||||
const normalizedGitRoot = path.resolve(gitRoot.trim());
|
||||
const normalizedProjectPath = path.resolve(projectPath);
|
||||
|
||||
// Ensure the git root matches our project path (prevent using parent git repos)
|
||||
if (normalizedGitRoot !== normalizedProjectPath) {
|
||||
throw new Error(`Project directory is not a git repository. This directory is inside a git repository at ${normalizedGitRoot}, but git operations should be run from the repository root.`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.message.includes('Project directory is not a git repository')) {
|
||||
throw error;
|
||||
// Allow any directory that is inside a work tree (repo root or nested folder).
|
||||
const { stdout: insideWorkTreeOutput } = await execAsync('git rev-parse --is-inside-work-tree', { cwd: projectPath });
|
||||
const isInsideWorkTree = insideWorkTreeOutput.trim() === 'true';
|
||||
if (!isInsideWorkTree) {
|
||||
throw new Error('Not inside a git work tree');
|
||||
}
|
||||
|
||||
// Ensure git can resolve the repository root for this directory.
|
||||
await execAsync('git rev-parse --show-toplevel', { cwd: projectPath });
|
||||
} catch {
|
||||
throw new Error('Not a git repository. This directory does not contain a .git folder. Initialize a git repository with "git init" to use source control features.');
|
||||
}
|
||||
}
|
||||
@@ -445,11 +479,17 @@ router.get('/commits', async (req, res) => {
|
||||
|
||||
try {
|
||||
const projectPath = await getActualProjectPath(project);
|
||||
await validateGitRepository(projectPath);
|
||||
const parsedLimit = Number.parseInt(String(limit), 10);
|
||||
const safeLimit = Number.isFinite(parsedLimit) && parsedLimit > 0
|
||||
? Math.min(parsedLimit, 100)
|
||||
: 10;
|
||||
|
||||
// Get commit log with stats
|
||||
const { stdout } = await execAsync(
|
||||
`git log --pretty=format:'%H|%an|%ae|%ad|%s' --date=relative -n ${limit}`,
|
||||
{ cwd: projectPath }
|
||||
const { stdout } = await spawnAsync(
|
||||
'git',
|
||||
['log', '--pretty=format:%H|%an|%ae|%ad|%s', '--date=relative', '-n', String(safeLimit)],
|
||||
{ cwd: projectPath },
|
||||
);
|
||||
|
||||
const commits = stdout
|
||||
@@ -1125,4 +1165,4 @@ router.post('/delete-untracked', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
export default router;
|
||||
|
||||
@@ -7,11 +7,17 @@ import { addProjectManually } from '../projects.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
function sanitizeGitError(message, token) {
|
||||
if (!message || !token) return message;
|
||||
return message.replace(new RegExp(token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), '***');
|
||||
}
|
||||
|
||||
// Configure allowed workspace root (defaults to user's home directory)
|
||||
const WORKSPACES_ROOT = process.env.WORKSPACES_ROOT || os.homedir();
|
||||
export const WORKSPACES_ROOT = process.env.WORKSPACES_ROOT || os.homedir();
|
||||
|
||||
// System-critical paths that should never be used as workspace directories
|
||||
const FORBIDDEN_PATHS = [
|
||||
export const FORBIDDEN_PATHS = [
|
||||
// Unix
|
||||
'/',
|
||||
'/etc',
|
||||
'/bin',
|
||||
@@ -27,7 +33,14 @@ const FORBIDDEN_PATHS = [
|
||||
'/lib64',
|
||||
'/opt',
|
||||
'/tmp',
|
||||
'/run'
|
||||
'/run',
|
||||
// Windows
|
||||
'C:\\Windows',
|
||||
'C:\\Program Files',
|
||||
'C:\\Program Files (x86)',
|
||||
'C:\\ProgramData',
|
||||
'C:\\System Volume Information',
|
||||
'C:\\$Recycle.Bin'
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -35,7 +48,7 @@ const FORBIDDEN_PATHS = [
|
||||
* @param {string} requestedPath - The path to validate
|
||||
* @returns {Promise<{valid: boolean, resolvedPath?: string, error?: string}>}
|
||||
*/
|
||||
async function validateWorkspacePath(requestedPath) {
|
||||
export async function validateWorkspacePath(requestedPath) {
|
||||
try {
|
||||
// Resolve to absolute path
|
||||
let absolutePath = path.resolve(requestedPath);
|
||||
@@ -212,20 +225,7 @@ router.post('/create-workspace', async (req, res) => {
|
||||
|
||||
// Handle new workspace creation
|
||||
if (workspaceType === 'new') {
|
||||
// Check if path already exists
|
||||
try {
|
||||
await fs.access(absolutePath);
|
||||
return res.status(400).json({
|
||||
error: 'Path already exists. Please choose a different path or use "existing workspace" option.'
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
// Path doesn't exist - good, we can create it
|
||||
}
|
||||
|
||||
// Create the directory
|
||||
// Create the directory if it doesn't exist
|
||||
await fs.mkdir(absolutePath, { recursive: true });
|
||||
|
||||
// If GitHub URL is provided, clone the repository
|
||||
@@ -246,30 +246,55 @@ router.post('/create-workspace', async (req, res) => {
|
||||
githubToken = newGithubToken;
|
||||
}
|
||||
|
||||
// Clone the repository
|
||||
// Extract repo name from URL for the clone destination
|
||||
const normalizedUrl = githubUrl.replace(/\/+$/, '').replace(/\.git$/, '');
|
||||
const repoName = normalizedUrl.split('/').pop() || 'repository';
|
||||
const clonePath = path.join(absolutePath, repoName);
|
||||
|
||||
// Check if clone destination already exists to prevent data loss
|
||||
try {
|
||||
await cloneGitHubRepository(githubUrl, absolutePath, githubToken);
|
||||
await fs.access(clonePath);
|
||||
return res.status(409).json({
|
||||
error: 'Directory already exists',
|
||||
details: `The destination path "${clonePath}" already exists. Please choose a different location or remove the existing directory.`
|
||||
});
|
||||
} catch (err) {
|
||||
// Directory doesn't exist, which is what we want
|
||||
}
|
||||
|
||||
// Clone the repository into a subfolder
|
||||
try {
|
||||
await cloneGitHubRepository(githubUrl, clonePath, githubToken);
|
||||
} catch (error) {
|
||||
// Clean up created directory on failure
|
||||
// Only clean up if clone created partial data (check if dir exists and is empty or partial)
|
||||
try {
|
||||
await fs.rm(absolutePath, { recursive: true, force: true });
|
||||
const stats = await fs.stat(clonePath);
|
||||
if (stats.isDirectory()) {
|
||||
await fs.rm(clonePath, { recursive: true, force: true });
|
||||
}
|
||||
} catch (cleanupError) {
|
||||
console.error('Failed to clean up directory after clone failure:', cleanupError);
|
||||
// Continue to throw original error
|
||||
// Directory doesn't exist or cleanup failed - ignore
|
||||
}
|
||||
throw new Error(`Failed to clone repository: ${error.message}`);
|
||||
}
|
||||
|
||||
// Add the cloned repo path to the project list
|
||||
const project = await addProjectManually(clonePath);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
project,
|
||||
message: 'New workspace created and repository cloned successfully'
|
||||
});
|
||||
}
|
||||
|
||||
// Add the new workspace to the project list
|
||||
// Add the new workspace to the project list (no clone)
|
||||
const project = await addProjectManually(absolutePath);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
project,
|
||||
message: githubUrl
|
||||
? 'New workspace created and repository cloned successfully'
|
||||
: 'New workspace created successfully'
|
||||
message: 'New workspace created successfully'
|
||||
});
|
||||
}
|
||||
|
||||
@@ -305,31 +330,179 @@ async function getGithubTokenById(tokenId, userId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clone repository with progress streaming (SSE)
|
||||
* GET /api/projects/clone-progress
|
||||
*/
|
||||
router.get('/clone-progress', async (req, res) => {
|
||||
const { path: workspacePath, githubUrl, githubTokenId, newGithubToken } = req.query;
|
||||
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.flushHeaders();
|
||||
|
||||
const sendEvent = (type, data) => {
|
||||
res.write(`data: ${JSON.stringify({ type, ...data })}\n\n`);
|
||||
};
|
||||
|
||||
try {
|
||||
if (!workspacePath || !githubUrl) {
|
||||
sendEvent('error', { message: 'workspacePath and githubUrl are required' });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const validation = await validateWorkspacePath(workspacePath);
|
||||
if (!validation.valid) {
|
||||
sendEvent('error', { message: validation.error });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const absolutePath = validation.resolvedPath;
|
||||
|
||||
await fs.mkdir(absolutePath, { recursive: true });
|
||||
|
||||
let githubToken = null;
|
||||
if (githubTokenId) {
|
||||
const token = await getGithubTokenById(parseInt(githubTokenId), req.user.id);
|
||||
if (!token) {
|
||||
await fs.rm(absolutePath, { recursive: true, force: true });
|
||||
sendEvent('error', { message: 'GitHub token not found' });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
githubToken = token.github_token;
|
||||
} else if (newGithubToken) {
|
||||
githubToken = newGithubToken;
|
||||
}
|
||||
|
||||
const normalizedUrl = githubUrl.replace(/\/+$/, '').replace(/\.git$/, '');
|
||||
const repoName = normalizedUrl.split('/').pop() || 'repository';
|
||||
const clonePath = path.join(absolutePath, repoName);
|
||||
|
||||
// Check if clone destination already exists to prevent data loss
|
||||
try {
|
||||
await fs.access(clonePath);
|
||||
sendEvent('error', { message: `Directory "${repoName}" already exists. Please choose a different location or remove the existing directory.` });
|
||||
res.end();
|
||||
return;
|
||||
} catch (err) {
|
||||
// Directory doesn't exist, which is what we want
|
||||
}
|
||||
|
||||
let cloneUrl = githubUrl;
|
||||
if (githubToken) {
|
||||
try {
|
||||
const url = new URL(githubUrl);
|
||||
url.username = githubToken;
|
||||
url.password = '';
|
||||
cloneUrl = url.toString();
|
||||
} catch (error) {
|
||||
// SSH URL or invalid - use as-is
|
||||
}
|
||||
}
|
||||
|
||||
sendEvent('progress', { message: `Cloning into '${repoName}'...` });
|
||||
|
||||
const gitProcess = spawn('git', ['clone', '--progress', cloneUrl, clonePath], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: {
|
||||
...process.env,
|
||||
GIT_TERMINAL_PROMPT: '0'
|
||||
}
|
||||
});
|
||||
|
||||
let lastError = '';
|
||||
|
||||
gitProcess.stdout.on('data', (data) => {
|
||||
const message = data.toString().trim();
|
||||
if (message) {
|
||||
sendEvent('progress', { message });
|
||||
}
|
||||
});
|
||||
|
||||
gitProcess.stderr.on('data', (data) => {
|
||||
const message = data.toString().trim();
|
||||
lastError = message;
|
||||
if (message) {
|
||||
sendEvent('progress', { message });
|
||||
}
|
||||
});
|
||||
|
||||
gitProcess.on('close', async (code) => {
|
||||
if (code === 0) {
|
||||
try {
|
||||
const project = await addProjectManually(clonePath);
|
||||
sendEvent('complete', { project, message: 'Repository cloned successfully' });
|
||||
} catch (error) {
|
||||
sendEvent('error', { message: `Clone succeeded but failed to add project: ${error.message}` });
|
||||
}
|
||||
} else {
|
||||
const sanitizedError = sanitizeGitError(lastError, githubToken);
|
||||
let errorMessage = 'Git clone failed';
|
||||
if (lastError.includes('Authentication failed') || lastError.includes('could not read Username')) {
|
||||
errorMessage = 'Authentication failed. Please check your credentials.';
|
||||
} else if (lastError.includes('Repository not found')) {
|
||||
errorMessage = 'Repository not found. Please check the URL and ensure you have access.';
|
||||
} else if (lastError.includes('already exists')) {
|
||||
errorMessage = 'Directory already exists';
|
||||
} else if (sanitizedError) {
|
||||
errorMessage = sanitizedError;
|
||||
}
|
||||
try {
|
||||
await fs.rm(clonePath, { recursive: true, force: true });
|
||||
} catch (cleanupError) {
|
||||
console.error('Failed to clean up after clone failure:', sanitizeGitError(cleanupError.message, githubToken));
|
||||
}
|
||||
sendEvent('error', { message: errorMessage });
|
||||
}
|
||||
res.end();
|
||||
});
|
||||
|
||||
gitProcess.on('error', (error) => {
|
||||
if (error.code === 'ENOENT') {
|
||||
sendEvent('error', { message: 'Git is not installed or not in PATH' });
|
||||
} else {
|
||||
sendEvent('error', { message: error.message });
|
||||
}
|
||||
res.end();
|
||||
});
|
||||
|
||||
req.on('close', () => {
|
||||
gitProcess.kill();
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
sendEvent('error', { message: error.message });
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper function to clone a GitHub repository
|
||||
*/
|
||||
function cloneGitHubRepository(githubUrl, destinationPath, githubToken = null) {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Parse GitHub URL and inject token if provided
|
||||
let cloneUrl = githubUrl;
|
||||
|
||||
if (githubToken) {
|
||||
try {
|
||||
const url = new URL(githubUrl);
|
||||
// Format: https://TOKEN@github.com/user/repo.git
|
||||
url.username = githubToken;
|
||||
url.password = '';
|
||||
cloneUrl = url.toString();
|
||||
} catch (error) {
|
||||
return reject(new Error('Invalid GitHub URL format'));
|
||||
// SSH URL - use as-is
|
||||
}
|
||||
}
|
||||
|
||||
const gitProcess = spawn('git', ['clone', cloneUrl, destinationPath], {
|
||||
const gitProcess = spawn('git', ['clone', '--progress', cloneUrl, destinationPath], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: {
|
||||
...process.env,
|
||||
GIT_TERMINAL_PROMPT: '0' // Disable git password prompts
|
||||
GIT_TERMINAL_PROMPT: '0'
|
||||
}
|
||||
});
|
||||
|
||||
@@ -348,7 +521,6 @@ function cloneGitHubRepository(githubUrl, destinationPath, githubToken = null) {
|
||||
if (code === 0) {
|
||||
resolve({ stdout, stderr });
|
||||
} else {
|
||||
// Parse git error messages to provide helpful feedback
|
||||
let errorMessage = 'Git clone failed';
|
||||
|
||||
if (stderr.includes('Authentication failed') || stderr.includes('could not read Username')) {
|
||||
|
||||
@@ -55,11 +55,13 @@ export const CURSOR_MODELS = {
|
||||
*/
|
||||
export const CODEX_MODELS = {
|
||||
OPTIONS: [
|
||||
{ value: 'gpt-5.3-codex', label: 'GPT-5.3 Codex' },
|
||||
{ value: 'gpt-5.2-codex', label: 'GPT-5.2 Codex' },
|
||||
{ value: 'gpt-5.2', label: 'GPT-5.2' },
|
||||
{ value: 'gpt-5.1-codex-max', label: 'GPT-5.1 Codex Max' },
|
||||
{ value: 'o3', label: 'O3' },
|
||||
{ value: 'o4-mini', label: 'O4-mini' }
|
||||
],
|
||||
|
||||
DEFAULT: 'gpt-5.2'
|
||||
DEFAULT: 'gpt-5.3-codex'
|
||||
};
|
||||
|
||||
1009
src/App.jsx
1009
src/App.jsx
File diff suppressed because it is too large
Load Diff
35
src/App.tsx
Normal file
35
src/App.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { BrowserRouter as Router, Route, Routes } from 'react-router-dom';
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
import { ThemeProvider } from './contexts/ThemeContext';
|
||||
import { AuthProvider } from './contexts/AuthContext';
|
||||
import { TaskMasterProvider } from './contexts/TaskMasterContext';
|
||||
import { TasksSettingsProvider } from './contexts/TasksSettingsContext';
|
||||
import { WebSocketProvider } from './contexts/WebSocketContext';
|
||||
import ProtectedRoute from './components/ProtectedRoute';
|
||||
import AppContent from './components/app/AppContent';
|
||||
import i18n from './i18n/config.js';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<WebSocketProvider>
|
||||
<TasksSettingsProvider>
|
||||
<TaskMasterProvider>
|
||||
<ProtectedRoute>
|
||||
<Router basename={window.__ROUTER_BASENAME__ || ''}>
|
||||
<Routes>
|
||||
<Route path="/" element={<AppContent />} />
|
||||
<Route path="/session/:sessionId" element={<AppContent />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
</ProtectedRoute>
|
||||
</TaskMasterProvider>
|
||||
</TasksSettingsProvider>
|
||||
</WebSocketProvider>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</I18nextProvider>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,14 +7,142 @@ import { css } from '@codemirror/lang-css';
|
||||
import { json } from '@codemirror/lang-json';
|
||||
import { markdown } from '@codemirror/lang-markdown';
|
||||
import { oneDark } from '@codemirror/theme-one-dark';
|
||||
import { StreamLanguage } from '@codemirror/language';
|
||||
import { EditorView, showPanel, ViewPlugin } from '@codemirror/view';
|
||||
import { unifiedMergeView, getChunks } from '@codemirror/merge';
|
||||
import { showMinimap } from '@replit/codemirror-minimap';
|
||||
import { X, Save, Download, Maximize2, Minimize2 } from 'lucide-react';
|
||||
import { X, Save, Download, Maximize2, Minimize2, Settings as SettingsIcon } from 'lucide-react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import remarkMath from 'remark-math';
|
||||
import rehypeKatex from 'rehype-katex';
|
||||
import rehypeRaw from 'rehype-raw';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { oneDark as prismOneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
import { api } from '../utils/api';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Eye, Code2 } from 'lucide-react';
|
||||
|
||||
function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded = false, onToggleExpand = null }) {
|
||||
// Custom .env file syntax highlighting
|
||||
const envLanguage = StreamLanguage.define({
|
||||
token(stream) {
|
||||
// Comments
|
||||
if (stream.match(/^#.*/)) return 'comment';
|
||||
// Key (before =)
|
||||
if (stream.sol() && stream.match(/^[A-Za-z_][A-Za-z0-9_.]*(?==)/)) return 'variableName.definition';
|
||||
// Equals sign
|
||||
if (stream.match(/^=/)) return 'operator';
|
||||
// Double-quoted string
|
||||
if (stream.match(/^"(?:[^"\\]|\\.)*"?/)) return 'string';
|
||||
// Single-quoted string
|
||||
if (stream.match(/^'(?:[^'\\]|\\.)*'?/)) return 'string';
|
||||
// Variable interpolation ${...}
|
||||
if (stream.match(/^\$\{[^}]*\}?/)) return 'variableName.special';
|
||||
// Variable reference $VAR
|
||||
if (stream.match(/^\$[A-Za-z_][A-Za-z0-9_]*/)) return 'variableName.special';
|
||||
// Numbers
|
||||
if (stream.match(/^\d+/)) return 'number';
|
||||
// Skip other characters
|
||||
stream.next();
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
function MarkdownCodeBlock({ inline, className, children, ...props }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const raw = Array.isArray(children) ? children.join('') : String(children ?? '');
|
||||
const looksMultiline = /[\r\n]/.test(raw);
|
||||
const shouldInline = inline || !looksMultiline;
|
||||
|
||||
if (shouldInline) {
|
||||
return (
|
||||
<code
|
||||
className={`font-mono text-[0.9em] px-1.5 py-0.5 rounded-md bg-gray-100 text-gray-900 border border-gray-200 dark:bg-gray-800/60 dark:text-gray-100 dark:border-gray-700 whitespace-pre-wrap break-words ${className || ''}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
const language = match ? match[1] : 'text';
|
||||
|
||||
return (
|
||||
<div className="relative group my-2">
|
||||
{language && language !== 'text' && (
|
||||
<div className="absolute top-2 left-3 z-10 text-xs text-gray-400 font-medium uppercase">{language}</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
navigator.clipboard?.writeText(raw).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
});
|
||||
}}
|
||||
className="absolute top-2 right-2 z-10 opacity-0 group-hover:opacity-100 transition-opacity text-xs px-2 py-1 rounded-md bg-gray-700/80 hover:bg-gray-700 text-white border border-gray-600"
|
||||
>
|
||||
{copied ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
<SyntaxHighlighter
|
||||
language={language}
|
||||
style={prismOneDark}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
borderRadius: '0.5rem',
|
||||
fontSize: '0.875rem',
|
||||
padding: language && language !== 'text' ? '2rem 1rem 1rem 1rem' : '1rem',
|
||||
}}
|
||||
>
|
||||
{raw}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const markdownPreviewComponents = {
|
||||
code: MarkdownCodeBlock,
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="border-l-4 border-gray-300 dark:border-gray-600 pl-4 italic text-gray-600 dark:text-gray-400 my-2">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
a: ({ href, children }) => (
|
||||
<a href={href} className="text-blue-600 dark:text-blue-400 hover:underline" target="_blank" rel="noopener noreferrer">
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
table: ({ children }) => (
|
||||
<div className="overflow-x-auto my-2">
|
||||
<table className="min-w-full border-collapse border border-gray-200 dark:border-gray-700">{children}</table>
|
||||
</div>
|
||||
),
|
||||
thead: ({ children }) => <thead className="bg-gray-50 dark:bg-gray-800">{children}</thead>,
|
||||
th: ({ children }) => (
|
||||
<th className="px-3 py-2 text-left text-sm font-semibold border border-gray-200 dark:border-gray-700">{children}</th>
|
||||
),
|
||||
td: ({ children }) => (
|
||||
<td className="px-3 py-2 align-top text-sm border border-gray-200 dark:border-gray-700">{children}</td>
|
||||
),
|
||||
};
|
||||
|
||||
function MarkdownPreview({ content }) {
|
||||
const remarkPlugins = useMemo(() => [remarkGfm, remarkMath], []);
|
||||
const rehypePlugins = useMemo(() => [rehypeRaw, rehypeKatex], []);
|
||||
|
||||
return (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={remarkPlugins}
|
||||
rehypePlugins={rehypePlugins}
|
||||
components={markdownPreviewComponents}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded = false, onToggleExpand = null, onPopOut = null }) {
|
||||
const { t } = useTranslation('codeEditor');
|
||||
const [content, setContent] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -36,10 +164,17 @@ function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded
|
||||
return localStorage.getItem('codeEditorLineNumbers') !== 'false';
|
||||
});
|
||||
const [fontSize, setFontSize] = useState(() => {
|
||||
return localStorage.getItem('codeEditorFontSize') || '14';
|
||||
return localStorage.getItem('codeEditorFontSize') || '12';
|
||||
});
|
||||
const [markdownPreview, setMarkdownPreview] = useState(false);
|
||||
const editorRef = useRef(null);
|
||||
|
||||
// Check if file is markdown
|
||||
const isMarkdownFile = useMemo(() => {
|
||||
const ext = file.name.split('.').pop()?.toLowerCase();
|
||||
return ext === 'md' || ext === 'markdown';
|
||||
}, [file.name]);
|
||||
|
||||
// Create minimap extension with chunk-based gutters
|
||||
const minimapExtension = useMemo(() => {
|
||||
if (!file.diffInfo || !showDiff || !minimapEnabled) return [];
|
||||
@@ -105,8 +240,13 @@ function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded
|
||||
];
|
||||
}, [file.diffInfo, showDiff]);
|
||||
|
||||
// Create editor toolbar panel - always visible
|
||||
// Whether toolbar has any buttons worth showing
|
||||
const hasToolbarButtons = !!(file.diffInfo || (isSidebar && onPopOut) || (isSidebar && onToggleExpand));
|
||||
|
||||
// Create editor toolbar panel - only when there are buttons to show
|
||||
const editorToolbarPanel = useMemo(() => {
|
||||
if (!hasToolbarButtons) return [];
|
||||
|
||||
const createPanel = (view) => {
|
||||
const dom = document.createElement('div');
|
||||
dom.className = 'cm-editor-toolbar-panel';
|
||||
@@ -159,14 +299,16 @@ function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded
|
||||
`;
|
||||
}
|
||||
|
||||
// Settings button
|
||||
toolbarHTML += `
|
||||
<button class="cm-toolbar-btn cm-settings-btn" title="${t('toolbar.settings')}">
|
||||
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
`;
|
||||
// Pop out button (only in sidebar mode with onPopOut)
|
||||
if (isSidebar && onPopOut) {
|
||||
toolbarHTML += `
|
||||
<button class="cm-toolbar-btn cm-popout-btn" title="Open in modal">
|
||||
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6M15 3h6v6M10 14L21 3" />
|
||||
</svg>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
// Expand button (only in sidebar mode)
|
||||
if (isSidebar && onToggleExpand) {
|
||||
@@ -187,7 +329,6 @@ function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded
|
||||
|
||||
dom.innerHTML = toolbarHTML;
|
||||
|
||||
// Attach event listeners for diff navigation
|
||||
if (hasDiff) {
|
||||
const prevBtn = dom.querySelector('.cm-diff-nav-prev');
|
||||
const nextBtn = dom.querySelector('.cm-diff-nav-next');
|
||||
@@ -219,7 +360,6 @@ function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded
|
||||
});
|
||||
}
|
||||
|
||||
// Attach event listener for toggle diff button
|
||||
if (file.diffInfo) {
|
||||
const toggleDiffBtn = dom.querySelector('.cm-toggle-diff-btn');
|
||||
toggleDiffBtn?.addEventListener('click', () => {
|
||||
@@ -227,15 +367,13 @@ function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded
|
||||
});
|
||||
}
|
||||
|
||||
// Attach event listener for settings button
|
||||
const settingsBtn = dom.querySelector('.cm-settings-btn');
|
||||
settingsBtn?.addEventListener('click', () => {
|
||||
if (window.openSettings) {
|
||||
window.openSettings('appearance');
|
||||
}
|
||||
});
|
||||
if (isSidebar && onPopOut) {
|
||||
const popoutBtn = dom.querySelector('.cm-popout-btn');
|
||||
popoutBtn?.addEventListener('click', () => {
|
||||
onPopOut();
|
||||
});
|
||||
}
|
||||
|
||||
// Attach event listener for expand button
|
||||
if (isSidebar && onToggleExpand) {
|
||||
const expandBtn = dom.querySelector('.cm-expand-btn');
|
||||
expandBtn?.addEventListener('click', () => {
|
||||
@@ -254,10 +392,15 @@ function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded
|
||||
};
|
||||
|
||||
return [showPanel.of(createPanel)];
|
||||
}, [file.diffInfo, showDiff, isSidebar, isExpanded, onToggleExpand]);
|
||||
}, [file.diffInfo, showDiff, isSidebar, isExpanded, onToggleExpand, onPopOut]);
|
||||
|
||||
// Get language extension based on file extension
|
||||
const getLanguageExtension = (filename) => {
|
||||
const lowerName = filename.toLowerCase();
|
||||
// Handle dotfiles like .env, .env.local, .env.production, etc.
|
||||
if (lowerName === '.env' || lowerName.startsWith('.env.')) {
|
||||
return [envLanguage];
|
||||
}
|
||||
const ext = filename.split('.').pop()?.toLowerCase();
|
||||
switch (ext) {
|
||||
case 'js':
|
||||
@@ -279,6 +422,8 @@ function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded
|
||||
case 'md':
|
||||
case 'markdown':
|
||||
return [markdown()];
|
||||
case 'env':
|
||||
return [envLanguage];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
@@ -469,7 +614,7 @@ function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="fixed inset-0 z-40 md:bg-black/50 md:flex md:items-center md:justify-center">
|
||||
<div className="fixed inset-0 z-[9999] md:bg-black/50 md:flex md:items-center md:justify-center">
|
||||
<div className="code-editor-loading w-full h-full md:rounded-lg md:w-auto md:h-auto p-8 flex items-center justify-center">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
|
||||
@@ -523,16 +668,16 @@ function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded
|
||||
|
||||
/* Editor toolbar panel styling */
|
||||
.cm-editor-toolbar-panel {
|
||||
padding: 8px 12px;
|
||||
padding: 4px 10px;
|
||||
background-color: ${isDarkMode ? '#1f2937' : '#ffffff'};
|
||||
border-bottom: 1px solid ${isDarkMode ? '#374151' : '#e5e7eb'};
|
||||
color: ${isDarkMode ? '#d1d5db' : '#374151'};
|
||||
font-size: 14px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.cm-diff-nav-btn,
|
||||
.cm-toolbar-btn {
|
||||
padding: 4px;
|
||||
padding: 3px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
@@ -557,7 +702,7 @@ function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded
|
||||
</style>
|
||||
<div className={isSidebar ?
|
||||
'w-full h-full flex flex-col' :
|
||||
`fixed inset-0 z-40 ${
|
||||
`fixed inset-0 z-[9999] ${
|
||||
// Mobile: native fullscreen, Desktop: modal with backdrop
|
||||
'md:bg-black/50 md:flex md:items-center md:justify-center md:p-4'
|
||||
} ${isFullscreen ? 'md:p-0' : ''}`}>
|
||||
@@ -569,58 +714,75 @@ function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded
|
||||
(isFullscreen ? ' md:w-full md:h-full md:rounded-none' : ' md:w-full md:max-w-6xl md:h-[80vh] md:max-h-[80vh]')
|
||||
}`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-border flex-shrink-0 min-w-0">
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border flex-shrink-0 min-w-0">
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<h3 className="font-medium text-gray-900 dark:text-white truncate">{file.name}</h3>
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-white truncate">{file.name}</h3>
|
||||
{file.diffInfo && (
|
||||
<span className="text-xs bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-300 px-2 py-1 rounded whitespace-nowrap">
|
||||
<span className="text-[10px] bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-300 px-1.5 py-0.5 rounded whitespace-nowrap">
|
||||
{t('header.showingChanges')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 truncate">{file.path}</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">{file.path}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 md:gap-2 flex-shrink-0">
|
||||
<div className="flex items-center gap-0.5 md:gap-1 flex-shrink-0">
|
||||
{isMarkdownFile && (
|
||||
<button
|
||||
onClick={() => setMarkdownPreview(!markdownPreview)}
|
||||
className={`p-1.5 rounded-md min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 flex items-center justify-center transition-colors ${
|
||||
markdownPreview
|
||||
? 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/30'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
title={markdownPreview ? t('actions.editMarkdown') : t('actions.previewMarkdown')}
|
||||
>
|
||||
{markdownPreview ? <Code2 className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => window.openSettings?.('appearance')}
|
||||
className="p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 flex items-center justify-center"
|
||||
title={t('toolbar.settings')}
|
||||
>
|
||||
<SettingsIcon className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="p-2 md:p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[44px] min-h-[44px] md:min-w-0 md:min-h-0 flex items-center justify-center"
|
||||
className="p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 flex items-center justify-center"
|
||||
title={t('actions.download')}
|
||||
>
|
||||
<Download className="w-5 h-5 md:w-4 md:h-4" />
|
||||
<Download className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className={`px-3 py-2 text-white rounded-md disabled:opacity-50 flex items-center gap-2 transition-colors min-h-[44px] md:min-h-0 ${
|
||||
className={`p-1.5 rounded-md disabled:opacity-50 flex items-center justify-center transition-colors min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 ${
|
||||
saveSuccess
|
||||
? 'bg-green-600 hover:bg-green-700'
|
||||
: 'bg-blue-600 hover:bg-blue-700'
|
||||
? 'text-green-600 dark:text-green-400 bg-green-50 dark:bg-green-900/30'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
title={saveSuccess ? t('actions.saved') : saving ? t('actions.saving') : t('actions.save')}
|
||||
>
|
||||
{saveSuccess ? (
|
||||
<>
|
||||
<svg className="w-5 h-5 md:w-4 md:h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span className="hidden sm:inline">{t('actions.saved')}</span>
|
||||
</>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : (
|
||||
<>
|
||||
<Save className="w-5 h-5 md:w-4 md:h-4" />
|
||||
<span className="hidden sm:inline">{saving ? t('actions.saving') : t('actions.save')}</span>
|
||||
</>
|
||||
<Save className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{!isSidebar && (
|
||||
<button
|
||||
onClick={toggleFullscreen}
|
||||
className="hidden md:flex p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 items-center justify-center"
|
||||
className="hidden md:flex p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 items-center justify-center"
|
||||
title={isFullscreen ? t('actions.exitFullscreen') : t('actions.fullscreen')}
|
||||
>
|
||||
{isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
|
||||
@@ -629,70 +791,78 @@ function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 md:p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[44px] min-h-[44px] md:min-w-0 md:min-h-0 flex items-center justify-center"
|
||||
className="p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 flex items-center justify-center"
|
||||
title={t('actions.close')}
|
||||
>
|
||||
<X className="w-6 h-6 md:w-4 md:h-4" />
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Editor */}
|
||||
{/* Editor / Markdown Preview */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<CodeMirror
|
||||
ref={editorRef}
|
||||
value={content}
|
||||
onChange={setContent}
|
||||
extensions={[
|
||||
...getLanguageExtension(file.name),
|
||||
// Always show the toolbar
|
||||
...editorToolbarPanel,
|
||||
// Only show diff-related extensions when diff is enabled
|
||||
...(file.diffInfo && showDiff && file.diffInfo.old_string !== undefined
|
||||
? [
|
||||
unifiedMergeView({
|
||||
original: file.diffInfo.old_string,
|
||||
mergeControls: false,
|
||||
highlightChanges: true,
|
||||
syntaxHighlightDeletions: false,
|
||||
gutter: true
|
||||
// NOTE: NO collapseUnchanged - this shows the full file!
|
||||
}),
|
||||
...minimapExtension,
|
||||
...scrollToFirstChunkExtension
|
||||
]
|
||||
: []),
|
||||
...(wordWrap ? [EditorView.lineWrapping] : [])
|
||||
]}
|
||||
theme={isDarkMode ? oneDark : undefined}
|
||||
height="100%"
|
||||
style={{
|
||||
fontSize: `${fontSize}px`,
|
||||
height: '100%',
|
||||
}}
|
||||
basicSetup={{
|
||||
lineNumbers: showLineNumbers,
|
||||
foldGutter: true,
|
||||
dropCursor: false,
|
||||
allowMultipleSelections: false,
|
||||
indentOnInput: true,
|
||||
bracketMatching: true,
|
||||
closeBrackets: true,
|
||||
autocompletion: true,
|
||||
highlightSelectionMatches: true,
|
||||
searchKeymap: true,
|
||||
}}
|
||||
/>
|
||||
{markdownPreview && isMarkdownFile ? (
|
||||
<div className="h-full overflow-y-auto bg-white dark:bg-gray-900">
|
||||
<div className="max-w-4xl mx-auto px-8 py-6 prose prose-sm dark:prose-invert prose-headings:font-semibold prose-a:text-blue-600 dark:prose-a:text-blue-400 prose-code:text-sm prose-pre:bg-gray-900 prose-img:rounded-lg max-w-none">
|
||||
<MarkdownPreview content={content} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<CodeMirror
|
||||
ref={editorRef}
|
||||
value={content}
|
||||
onChange={setContent}
|
||||
extensions={[
|
||||
...getLanguageExtension(file.name),
|
||||
// Always show the toolbar
|
||||
...editorToolbarPanel,
|
||||
// Only show diff-related extensions when diff is enabled
|
||||
...(file.diffInfo && showDiff && file.diffInfo.old_string !== undefined
|
||||
? [
|
||||
unifiedMergeView({
|
||||
original: file.diffInfo.old_string,
|
||||
mergeControls: false,
|
||||
highlightChanges: true,
|
||||
syntaxHighlightDeletions: false,
|
||||
gutter: true
|
||||
// NOTE: NO collapseUnchanged - this shows the full file!
|
||||
}),
|
||||
...minimapExtension,
|
||||
...scrollToFirstChunkExtension
|
||||
]
|
||||
: []),
|
||||
...(wordWrap ? [EditorView.lineWrapping] : [])
|
||||
]}
|
||||
theme={isDarkMode ? oneDark : undefined}
|
||||
height="100%"
|
||||
style={{
|
||||
fontSize: `${fontSize}px`,
|
||||
height: '100%',
|
||||
}}
|
||||
basicSetup={{
|
||||
lineNumbers: showLineNumbers,
|
||||
foldGutter: true,
|
||||
dropCursor: false,
|
||||
allowMultipleSelections: false,
|
||||
indentOnInput: true,
|
||||
bracketMatching: true,
|
||||
closeBrackets: true,
|
||||
autocompletion: true,
|
||||
highlightSelectionMatches: true,
|
||||
searchKeymap: true,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between p-3 border-t border-border bg-muted flex-shrink-0">
|
||||
<div className="flex items-center gap-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
<div className="flex items-center justify-between px-3 py-1.5 border-t border-border bg-muted flex-shrink-0">
|
||||
<div className="flex items-center gap-3 text-xs text-gray-600 dark:text-gray-400">
|
||||
<span>{t('footer.lines')} {content.split('\n').length}</span>
|
||||
<span>{t('footer.characters')} {content.length}</span>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{t('footer.shortcuts')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,53 +4,61 @@ import React, { useEffect, useRef } from 'react';
|
||||
* CommandMenu - Autocomplete dropdown for slash commands
|
||||
*
|
||||
* @param {Array} commands - Array of command objects to display
|
||||
* @param {number} selectedIndex - Currently selected command index
|
||||
* @param {number} selectedIndex - Currently selected command index (index in `commands`)
|
||||
* @param {Function} onSelect - Callback when a command is selected
|
||||
* @param {Function} onClose - Callback when menu should close
|
||||
* @param {Object} position - Position object { top, left } for absolute positioning
|
||||
* @param {boolean} isOpen - Whether the menu is open
|
||||
* @param {Array} frequentCommands - Array of frequently used command objects
|
||||
*/
|
||||
const CommandMenu = ({ commands = [], selectedIndex = -1, onSelect, onClose, position = { top: 0, left: 0 }, isOpen = false, frequentCommands = [] }) => {
|
||||
const CommandMenu = ({
|
||||
commands = [],
|
||||
selectedIndex = -1,
|
||||
onSelect,
|
||||
onClose,
|
||||
position = { top: 0, left: 0 },
|
||||
isOpen = false,
|
||||
frequentCommands = [],
|
||||
}) => {
|
||||
const menuRef = useRef(null);
|
||||
const selectedItemRef = useRef(null);
|
||||
|
||||
// Calculate responsive positioning
|
||||
// Calculate responsive menu positioning.
|
||||
// Mobile: dock above chat input. Desktop: clamp to viewport.
|
||||
const getMenuPosition = () => {
|
||||
const isMobile = window.innerWidth < 640;
|
||||
const viewportHeight = window.innerHeight;
|
||||
const menuHeight = 300; // Max height of menu
|
||||
|
||||
if (isMobile) {
|
||||
// On mobile, calculate bottom position dynamically to appear above the input
|
||||
// Use the bottom value which is calculated as: window.innerHeight - textarea.top + spacing
|
||||
const inputBottom = position.bottom || 90; // Use provided bottom or default
|
||||
// On mobile, calculate bottom position dynamically to appear above the input.
|
||||
// Use the bottom value calculated as: window.innerHeight - textarea.top + spacing.
|
||||
const inputBottom = position.bottom || 90;
|
||||
|
||||
return {
|
||||
position: 'fixed',
|
||||
bottom: `${inputBottom}px`, // Position above the input with spacing already included
|
||||
bottom: `${inputBottom}px`, // Position above the input with spacing already included.
|
||||
left: '16px',
|
||||
right: '16px',
|
||||
width: 'auto',
|
||||
maxWidth: 'calc(100vw - 32px)',
|
||||
maxHeight: 'min(50vh, 300px)' // Limit to smaller of 50vh or 300px
|
||||
maxHeight: 'min(50vh, 300px)', // Limit to smaller of 50vh or 300px.
|
||||
};
|
||||
}
|
||||
|
||||
// On desktop, use provided position but ensure it stays on screen
|
||||
// On desktop, use provided position but ensure it stays on screen.
|
||||
return {
|
||||
position: 'fixed',
|
||||
top: `${Math.max(16, Math.min(position.top, viewportHeight - 316))}px`,
|
||||
left: `${position.left}px`,
|
||||
width: 'min(400px, calc(100vw - 32px))',
|
||||
maxWidth: 'calc(100vw - 32px)',
|
||||
maxHeight: '300px'
|
||||
maxHeight: '300px',
|
||||
};
|
||||
};
|
||||
|
||||
const menuPosition = getMenuPosition();
|
||||
|
||||
// Close menu when clicking outside
|
||||
// Close menu when clicking outside.
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target) && isOpen) {
|
||||
@@ -64,9 +72,11 @@ const CommandMenu = ({ commands = [], selectedIndex = -1, onSelect, onClose, pos
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
// Scroll selected item into view
|
||||
// Keep selected keyboard item visible while navigating.
|
||||
useEffect(() => {
|
||||
if (selectedItemRef.current && menuRef.current) {
|
||||
const menuRect = menuRef.current.getBoundingClientRect();
|
||||
@@ -84,7 +94,7 @@ const CommandMenu = ({ commands = [], selectedIndex = -1, onSelect, onClose, pos
|
||||
return null;
|
||||
}
|
||||
|
||||
// Show a message if no commands are available
|
||||
// Show a message if no commands are available.
|
||||
if (commands.length === 0) {
|
||||
return (
|
||||
<div
|
||||
@@ -100,7 +110,7 @@ const CommandMenu = ({ commands = [], selectedIndex = -1, onSelect, onClose, pos
|
||||
opacity: 1,
|
||||
transform: 'translateY(0)',
|
||||
transition: 'opacity 150ms ease-in-out, transform 150ms ease-in-out',
|
||||
textAlign: 'center'
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
No commands available
|
||||
@@ -108,11 +118,20 @@ const CommandMenu = ({ commands = [], selectedIndex = -1, onSelect, onClose, pos
|
||||
);
|
||||
}
|
||||
|
||||
// Add frequent commands as a special group if provided
|
||||
// Add frequent commands as a special group if provided.
|
||||
const hasFrequentCommands = frequentCommands.length > 0;
|
||||
|
||||
// Group commands by namespace
|
||||
const getCommandKey = (command) =>
|
||||
`${command.name}::${command.namespace || command.type || 'other'}::${command.path || ''}`;
|
||||
const frequentCommandKeys = new Set(frequentCommands.map(getCommandKey));
|
||||
|
||||
// Group commands by namespace for section rendering.
|
||||
// When frequent commands are shown, avoid duplicate rows in other sections.
|
||||
const groupedCommands = commands.reduce((groups, command) => {
|
||||
if (hasFrequentCommands && frequentCommandKeys.has(getCommandKey(command))) {
|
||||
return groups;
|
||||
}
|
||||
|
||||
const namespace = command.namespace || command.type || 'other';
|
||||
if (!groups[namespace]) {
|
||||
groups[namespace] = [];
|
||||
@@ -121,36 +140,33 @@ const CommandMenu = ({ commands = [], selectedIndex = -1, onSelect, onClose, pos
|
||||
return groups;
|
||||
}, {});
|
||||
|
||||
// Add frequent commands as a separate group
|
||||
// Add frequent commands as a separate group.
|
||||
if (hasFrequentCommands) {
|
||||
groupedCommands['frequent'] = frequentCommands;
|
||||
groupedCommands.frequent = frequentCommands;
|
||||
}
|
||||
|
||||
// Order: frequent, builtin, project, user, other
|
||||
// Order: frequent, builtin, project, user, other.
|
||||
const namespaceOrder = hasFrequentCommands
|
||||
? ['frequent', 'builtin', 'project', 'user', 'other']
|
||||
: ['builtin', 'project', 'user', 'other'];
|
||||
const orderedNamespaces = namespaceOrder.filter(ns => groupedCommands[ns]);
|
||||
const orderedNamespaces = namespaceOrder.filter((ns) => groupedCommands[ns]);
|
||||
|
||||
const namespaceLabels = {
|
||||
frequent: '⭐ Frequently Used',
|
||||
frequent: '\u2B50 Frequently Used',
|
||||
builtin: 'Built-in Commands',
|
||||
project: 'Project Commands',
|
||||
user: 'User Commands',
|
||||
other: 'Other Commands'
|
||||
other: 'Other Commands',
|
||||
};
|
||||
|
||||
// Calculate global index for each command
|
||||
let globalIndex = 0;
|
||||
const commandsWithIndex = [];
|
||||
orderedNamespaces.forEach(namespace => {
|
||||
groupedCommands[namespace].forEach(command => {
|
||||
commandsWithIndex.push({
|
||||
...command,
|
||||
globalIndex: globalIndex++,
|
||||
namespace
|
||||
});
|
||||
});
|
||||
// Keep all selection indices aligned to `commands` (filteredCommands from the hook).
|
||||
// This prevents mismatches between mouse selection (rendered list) and keyboard selection.
|
||||
const commandIndexByKey = new Map();
|
||||
commands.forEach((command, index) => {
|
||||
const key = getCommandKey(command);
|
||||
if (!commandIndexByKey.has(key)) {
|
||||
commandIndexByKey.set(key, index);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -169,7 +185,7 @@ const CommandMenu = ({ commands = [], selectedIndex = -1, onSelect, onClose, pos
|
||||
padding: '8px',
|
||||
opacity: isOpen ? 1 : 0,
|
||||
transform: isOpen ? 'translateY(0)' : 'translateY(-10px)',
|
||||
transition: 'opacity 150ms ease-in-out, transform 150ms ease-in-out'
|
||||
transition: 'opacity 150ms ease-in-out, transform 150ms ease-in-out',
|
||||
}}
|
||||
>
|
||||
{orderedNamespaces.map((namespace) => (
|
||||
@@ -182,25 +198,35 @@ const CommandMenu = ({ commands = [], selectedIndex = -1, onSelect, onClose, pos
|
||||
textTransform: 'uppercase',
|
||||
color: '#6b7280',
|
||||
padding: '8px 12px 4px',
|
||||
letterSpacing: '0.05em'
|
||||
letterSpacing: '0.05em',
|
||||
}}
|
||||
>
|
||||
{namespaceLabels[namespace] || namespace}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groupedCommands[namespace].map((command) => {
|
||||
const cmdWithIndex = commandsWithIndex.find(c => c.name === command.name && c.namespace === namespace);
|
||||
const isSelected = cmdWithIndex && cmdWithIndex.globalIndex === selectedIndex;
|
||||
const commandKey = getCommandKey(command);
|
||||
const commandIndex = commandIndexByKey.get(commandKey) ?? -1;
|
||||
const isSelected = commandIndex === selectedIndex;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${namespace}-${command.name}`}
|
||||
key={`${namespace}-${command.name}-${command.path || ''}`}
|
||||
ref={isSelected ? selectedItemRef : null}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
className="command-item"
|
||||
onMouseEnter={() => onSelect && onSelect(command, cmdWithIndex.globalIndex, true)}
|
||||
onClick={() => onSelect && onSelect(command, cmdWithIndex.globalIndex, false)}
|
||||
onMouseEnter={() => {
|
||||
if (onSelect && commandIndex >= 0) {
|
||||
onSelect(command, commandIndex, true);
|
||||
}
|
||||
}}
|
||||
onClick={() => {
|
||||
if (onSelect) {
|
||||
onSelect(command, commandIndex, false);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
@@ -209,9 +235,10 @@ const CommandMenu = ({ commands = [], selectedIndex = -1, onSelect, onClose, pos
|
||||
cursor: 'pointer',
|
||||
backgroundColor: isSelected ? '#eff6ff' : 'transparent',
|
||||
transition: 'background-color 100ms ease-in-out',
|
||||
marginBottom: '2px'
|
||||
marginBottom: '2px',
|
||||
}}
|
||||
onMouseDown={(e) => e.preventDefault()} // Prevent textarea blur
|
||||
// Prevent textarea blur when clicking a menu item.
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
@@ -219,20 +246,16 @@ const CommandMenu = ({ commands = [], selectedIndex = -1, onSelect, onClose, pos
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
marginBottom: command.description ? '4px' : 0
|
||||
marginBottom: command.description ? '4px' : 0,
|
||||
}}
|
||||
>
|
||||
{/* Command icon based on namespace */}
|
||||
<span
|
||||
style={{
|
||||
fontSize: '16px',
|
||||
flexShrink: 0
|
||||
}}
|
||||
>
|
||||
{namespace === 'builtin' && '⚡'}
|
||||
{namespace === 'project' && '📁'}
|
||||
{namespace === 'user' && '👤'}
|
||||
{namespace === 'other' && '📝'}
|
||||
<span style={{ fontSize: '16px', flexShrink: 0 }}>
|
||||
{namespace === 'builtin' && '\u26A1'}
|
||||
{namespace === 'project' && '\uD83D\uDCC1'}
|
||||
{namespace === 'user' && '\uD83D\uDC64'}
|
||||
{namespace === 'other' && '\uD83D\uDCDD'}
|
||||
{namespace === 'frequent' && '\u2B50'}
|
||||
</span>
|
||||
|
||||
{/* Command name */}
|
||||
@@ -241,7 +264,7 @@ const CommandMenu = ({ commands = [], selectedIndex = -1, onSelect, onClose, pos
|
||||
fontWeight: 600,
|
||||
fontSize: '14px',
|
||||
color: '#111827',
|
||||
fontFamily: 'monospace'
|
||||
fontFamily: 'monospace',
|
||||
}}
|
||||
>
|
||||
{command.name}
|
||||
@@ -257,7 +280,7 @@ const CommandMenu = ({ commands = [], selectedIndex = -1, onSelect, onClose, pos
|
||||
borderRadius: '4px',
|
||||
backgroundColor: '#f3f4f6',
|
||||
color: '#6b7280',
|
||||
fontWeight: 500
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{command.metadata.type}
|
||||
@@ -274,7 +297,7 @@ const CommandMenu = ({ commands = [], selectedIndex = -1, onSelect, onClose, pos
|
||||
marginLeft: '24px',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis'
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{command.description}
|
||||
@@ -289,10 +312,10 @@ const CommandMenu = ({ commands = [], selectedIndex = -1, onSelect, onClose, pos
|
||||
marginLeft: '8px',
|
||||
color: '#3b82f6',
|
||||
fontSize: '12px',
|
||||
fontWeight: 600
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
↵
|
||||
{'\u21B5'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@ import React from 'react';
|
||||
function DiffViewer({ diff, fileName, isMobile, wrapText }) {
|
||||
if (!diff) {
|
||||
return (
|
||||
<div className="p-4 text-center text-gray-500 dark:text-gray-400 text-sm">
|
||||
<div className="p-4 text-center text-muted-foreground text-sm">
|
||||
No diff available
|
||||
</div>
|
||||
);
|
||||
@@ -17,13 +17,13 @@ function DiffViewer({ diff, fileName, isMobile, wrapText }) {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`font-mono text-xs p-2 ${
|
||||
className={`font-mono text-xs px-3 py-0.5 ${
|
||||
isMobile && wrapText ? 'whitespace-pre-wrap break-all' : 'whitespace-pre overflow-x-auto'
|
||||
} ${
|
||||
isAddition ? 'bg-green-50 dark:bg-green-950 text-green-700 dark:text-green-300' :
|
||||
isDeletion ? 'bg-red-50 dark:bg-red-950 text-red-700 dark:text-red-300' :
|
||||
isHeader ? 'bg-blue-50 dark:bg-blue-950 text-blue-700 dark:text-blue-300' :
|
||||
'text-gray-600 dark:text-gray-400'
|
||||
isAddition ? 'bg-green-50 dark:bg-green-950/50 text-green-700 dark:text-green-300' :
|
||||
isDeletion ? 'bg-red-50 dark:bg-red-950/50 text-red-700 dark:text-red-300' :
|
||||
isHeader ? 'bg-primary/5 text-primary' :
|
||||
'text-muted-foreground/70'
|
||||
}`}
|
||||
>
|
||||
{line}
|
||||
|
||||
@@ -3,20 +3,269 @@ import { useTranslation } from 'react-i18next';
|
||||
import { ScrollArea } from './ui/scroll-area';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
import { Folder, FolderOpen, File, FileText, FileCode, List, TableProperties, Eye, Search, X } from 'lucide-react';
|
||||
import {
|
||||
Folder, FolderOpen, File, FileText, FileCode, List, TableProperties, Eye, Search, X,
|
||||
ChevronRight,
|
||||
FileJson, FileType, FileSpreadsheet, FileArchive,
|
||||
Hash, Braces, Terminal, Database, Globe, Palette, Music2, Video, Archive,
|
||||
Lock, Shield, Settings, Image, BookOpen, Cpu, Box, Gem, Coffee,
|
||||
Flame, Hexagon, FileCode2, Code2, Cog, FileWarning, Binary, SquareFunction,
|
||||
Scroll, FlaskConical, NotebookPen, FileCheck, Workflow, Blocks
|
||||
} from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
import CodeEditor from './CodeEditor';
|
||||
import ImageViewer from './ImageViewer';
|
||||
import { api } from '../utils/api';
|
||||
|
||||
function FileTree({ selectedProject }) {
|
||||
// ─── File Icon Registry ──────────────────────────────────────────────
|
||||
// Maps file extensions (and special filenames) to { icon, colorClass } pairs.
|
||||
// Uses lucide-react icons mapped semantically to file types.
|
||||
|
||||
const ICON_SIZE = 'w-4 h-4 flex-shrink-0';
|
||||
|
||||
const FILE_ICON_MAP = {
|
||||
// ── JavaScript / TypeScript ──
|
||||
js: { icon: FileCode, color: 'text-yellow-500' },
|
||||
jsx: { icon: FileCode, color: 'text-yellow-500' },
|
||||
mjs: { icon: FileCode, color: 'text-yellow-500' },
|
||||
cjs: { icon: FileCode, color: 'text-yellow-500' },
|
||||
ts: { icon: FileCode2, color: 'text-blue-500' },
|
||||
tsx: { icon: FileCode2, color: 'text-blue-500' },
|
||||
mts: { icon: FileCode2, color: 'text-blue-500' },
|
||||
|
||||
// ── Python ──
|
||||
py: { icon: Code2, color: 'text-emerald-500' },
|
||||
pyw: { icon: Code2, color: 'text-emerald-500' },
|
||||
pyi: { icon: Code2, color: 'text-emerald-400' },
|
||||
ipynb:{ icon: NotebookPen, color: 'text-orange-500' },
|
||||
|
||||
// ── Rust ──
|
||||
rs: { icon: Cog, color: 'text-orange-600' },
|
||||
toml: { icon: Settings, color: 'text-gray-500' },
|
||||
|
||||
// ── Go ──
|
||||
go: { icon: Hexagon, color: 'text-cyan-500' },
|
||||
|
||||
// ── Ruby ──
|
||||
rb: { icon: Gem, color: 'text-red-500' },
|
||||
erb: { icon: Gem, color: 'text-red-400' },
|
||||
|
||||
// ── PHP ──
|
||||
php: { icon: Blocks, color: 'text-violet-500' },
|
||||
|
||||
// ── Java / Kotlin ──
|
||||
java: { icon: Coffee, color: 'text-red-600' },
|
||||
jar: { icon: Coffee, color: 'text-red-500' },
|
||||
kt: { icon: Hexagon, color: 'text-violet-500' },
|
||||
kts: { icon: Hexagon, color: 'text-violet-400' },
|
||||
|
||||
// ── C / C++ ──
|
||||
c: { icon: Cpu, color: 'text-blue-600' },
|
||||
h: { icon: Cpu, color: 'text-blue-400' },
|
||||
cpp: { icon: Cpu, color: 'text-blue-700' },
|
||||
hpp: { icon: Cpu, color: 'text-blue-500' },
|
||||
cc: { icon: Cpu, color: 'text-blue-700' },
|
||||
|
||||
// ── C# ──
|
||||
cs: { icon: Hexagon, color: 'text-purple-600' },
|
||||
|
||||
// ── Swift ──
|
||||
swift:{ icon: Flame, color: 'text-orange-500' },
|
||||
|
||||
// ── Lua ──
|
||||
lua: { icon: SquareFunction, color: 'text-blue-500' },
|
||||
|
||||
// ── R ──
|
||||
r: { icon: FlaskConical, color: 'text-blue-600' },
|
||||
|
||||
// ── Web ──
|
||||
html: { icon: Globe, color: 'text-orange-600' },
|
||||
htm: { icon: Globe, color: 'text-orange-600' },
|
||||
css: { icon: Hash, color: 'text-blue-500' },
|
||||
scss: { icon: Hash, color: 'text-pink-500' },
|
||||
sass: { icon: Hash, color: 'text-pink-400' },
|
||||
less: { icon: Hash, color: 'text-indigo-500' },
|
||||
vue: { icon: FileCode2, color: 'text-emerald-500' },
|
||||
svelte:{ icon: FileCode2, color: 'text-orange-500' },
|
||||
|
||||
// ── Data / Config ──
|
||||
json: { icon: Braces, color: 'text-yellow-600' },
|
||||
jsonc:{ icon: Braces, color: 'text-yellow-500' },
|
||||
json5:{ icon: Braces, color: 'text-yellow-500' },
|
||||
yaml: { icon: Settings, color: 'text-purple-400' },
|
||||
yml: { icon: Settings, color: 'text-purple-400' },
|
||||
xml: { icon: FileCode, color: 'text-orange-500' },
|
||||
csv: { icon: FileSpreadsheet, color: 'text-green-600' },
|
||||
tsv: { icon: FileSpreadsheet, color: 'text-green-500' },
|
||||
sql: { icon: Database, color: 'text-blue-500' },
|
||||
graphql:{ icon: Workflow, color: 'text-pink-500' },
|
||||
gql: { icon: Workflow, color: 'text-pink-500' },
|
||||
proto:{ icon: Box, color: 'text-green-500' },
|
||||
env: { icon: Shield, color: 'text-yellow-600' },
|
||||
|
||||
// ── Documents ──
|
||||
md: { icon: BookOpen, color: 'text-blue-500' },
|
||||
mdx: { icon: BookOpen, color: 'text-blue-400' },
|
||||
txt: { icon: FileText, color: 'text-gray-500' },
|
||||
doc: { icon: FileText, color: 'text-blue-600' },
|
||||
docx: { icon: FileText, color: 'text-blue-600' },
|
||||
pdf: { icon: FileCheck, color: 'text-red-600' },
|
||||
rtf: { icon: FileText, color: 'text-gray-500' },
|
||||
tex: { icon: Scroll, color: 'text-teal-600' },
|
||||
rst: { icon: FileText, color: 'text-gray-400' },
|
||||
|
||||
// ── Shell / Scripts ──
|
||||
sh: { icon: Terminal, color: 'text-green-500' },
|
||||
bash: { icon: Terminal, color: 'text-green-500' },
|
||||
zsh: { icon: Terminal, color: 'text-green-400' },
|
||||
fish: { icon: Terminal, color: 'text-green-400' },
|
||||
ps1: { icon: Terminal, color: 'text-blue-400' },
|
||||
bat: { icon: Terminal, color: 'text-gray-500' },
|
||||
cmd: { icon: Terminal, color: 'text-gray-500' },
|
||||
|
||||
// ── Images ──
|
||||
png: { icon: Image, color: 'text-purple-500' },
|
||||
jpg: { icon: Image, color: 'text-purple-500' },
|
||||
jpeg: { icon: Image, color: 'text-purple-500' },
|
||||
gif: { icon: Image, color: 'text-purple-400' },
|
||||
webp: { icon: Image, color: 'text-purple-400' },
|
||||
ico: { icon: Image, color: 'text-purple-400' },
|
||||
bmp: { icon: Image, color: 'text-purple-400' },
|
||||
tiff: { icon: Image, color: 'text-purple-400' },
|
||||
svg: { icon: Palette, color: 'text-amber-500' },
|
||||
|
||||
// ── Audio ──
|
||||
mp3: { icon: Music2, color: 'text-pink-500' },
|
||||
wav: { icon: Music2, color: 'text-pink-500' },
|
||||
ogg: { icon: Music2, color: 'text-pink-400' },
|
||||
flac: { icon: Music2, color: 'text-pink-400' },
|
||||
aac: { icon: Music2, color: 'text-pink-400' },
|
||||
m4a: { icon: Music2, color: 'text-pink-400' },
|
||||
|
||||
// ── Video ──
|
||||
mp4: { icon: Video, color: 'text-rose-500' },
|
||||
mov: { icon: Video, color: 'text-rose-500' },
|
||||
avi: { icon: Video, color: 'text-rose-500' },
|
||||
webm: { icon: Video, color: 'text-rose-400' },
|
||||
mkv: { icon: Video, color: 'text-rose-400' },
|
||||
|
||||
// ── Fonts ──
|
||||
ttf: { icon: FileType, color: 'text-red-500' },
|
||||
otf: { icon: FileType, color: 'text-red-500' },
|
||||
woff: { icon: FileType, color: 'text-red-400' },
|
||||
woff2:{ icon: FileType, color: 'text-red-400' },
|
||||
eot: { icon: FileType, color: 'text-red-400' },
|
||||
|
||||
// ── Archives ──
|
||||
zip: { icon: Archive, color: 'text-amber-600' },
|
||||
tar: { icon: Archive, color: 'text-amber-600' },
|
||||
gz: { icon: Archive, color: 'text-amber-600' },
|
||||
bz2: { icon: Archive, color: 'text-amber-600' },
|
||||
rar: { icon: Archive, color: 'text-amber-500' },
|
||||
'7z': { icon: Archive, color: 'text-amber-500' },
|
||||
|
||||
// ── Lock files ──
|
||||
lock: { icon: Lock, color: 'text-gray-500' },
|
||||
|
||||
// ── Binary / Executable ──
|
||||
exe: { icon: Binary, color: 'text-gray-500' },
|
||||
bin: { icon: Binary, color: 'text-gray-500' },
|
||||
dll: { icon: Binary, color: 'text-gray-400' },
|
||||
so: { icon: Binary, color: 'text-gray-400' },
|
||||
dylib:{ icon: Binary, color: 'text-gray-400' },
|
||||
wasm: { icon: Binary, color: 'text-purple-500' },
|
||||
|
||||
// ── Misc config ──
|
||||
ini: { icon: Settings, color: 'text-gray-500' },
|
||||
cfg: { icon: Settings, color: 'text-gray-500' },
|
||||
conf: { icon: Settings, color: 'text-gray-500' },
|
||||
log: { icon: Scroll, color: 'text-gray-400' },
|
||||
map: { icon: File, color: 'text-gray-400' },
|
||||
};
|
||||
|
||||
// Special full-filename matches (highest priority)
|
||||
const FILENAME_ICON_MAP = {
|
||||
'Dockerfile': { icon: Box, color: 'text-blue-500' },
|
||||
'docker-compose.yml': { icon: Box, color: 'text-blue-500' },
|
||||
'docker-compose.yaml': { icon: Box, color: 'text-blue-500' },
|
||||
'.dockerignore': { icon: Box, color: 'text-gray-500' },
|
||||
'.gitignore': { icon: Settings, color: 'text-gray-500' },
|
||||
'.gitmodules': { icon: Settings, color: 'text-gray-500' },
|
||||
'.gitattributes': { icon: Settings, color: 'text-gray-500' },
|
||||
'.editorconfig': { icon: Settings, color: 'text-gray-500' },
|
||||
'.prettierrc': { icon: Settings, color: 'text-pink-400' },
|
||||
'.prettierignore': { icon: Settings, color: 'text-gray-500' },
|
||||
'.eslintrc': { icon: Settings, color: 'text-violet-500' },
|
||||
'.eslintrc.js': { icon: Settings, color: 'text-violet-500' },
|
||||
'.eslintrc.json': { icon: Settings, color: 'text-violet-500' },
|
||||
'.eslintrc.cjs': { icon: Settings, color: 'text-violet-500' },
|
||||
'eslint.config.js': { icon: Settings, color: 'text-violet-500' },
|
||||
'eslint.config.mjs':{ icon: Settings, color: 'text-violet-500' },
|
||||
'.env': { icon: Shield, color: 'text-yellow-600' },
|
||||
'.env.local': { icon: Shield, color: 'text-yellow-600' },
|
||||
'.env.development': { icon: Shield, color: 'text-yellow-500' },
|
||||
'.env.production': { icon: Shield, color: 'text-yellow-600' },
|
||||
'.env.example': { icon: Shield, color: 'text-yellow-400' },
|
||||
'package.json': { icon: Braces, color: 'text-green-500' },
|
||||
'package-lock.json':{ icon: Lock, color: 'text-gray-500' },
|
||||
'yarn.lock': { icon: Lock, color: 'text-blue-400' },
|
||||
'pnpm-lock.yaml': { icon: Lock, color: 'text-orange-400' },
|
||||
'bun.lockb': { icon: Lock, color: 'text-gray-400' },
|
||||
'Cargo.toml': { icon: Cog, color: 'text-orange-600' },
|
||||
'Cargo.lock': { icon: Lock, color: 'text-orange-400' },
|
||||
'Gemfile': { icon: Gem, color: 'text-red-500' },
|
||||
'Gemfile.lock': { icon: Lock, color: 'text-red-400' },
|
||||
'Makefile': { icon: Terminal, color: 'text-gray-500' },
|
||||
'CMakeLists.txt': { icon: Cog, color: 'text-blue-500' },
|
||||
'tsconfig.json': { icon: Braces, color: 'text-blue-500' },
|
||||
'jsconfig.json': { icon: Braces, color: 'text-yellow-500' },
|
||||
'vite.config.ts': { icon: Flame, color: 'text-purple-500' },
|
||||
'vite.config.js': { icon: Flame, color: 'text-purple-500' },
|
||||
'webpack.config.js':{ icon: Cog, color: 'text-blue-500' },
|
||||
'tailwind.config.js':{ icon: Hash, color: 'text-cyan-500' },
|
||||
'tailwind.config.ts':{ icon: Hash, color: 'text-cyan-500' },
|
||||
'postcss.config.js':{ icon: Cog, color: 'text-red-400' },
|
||||
'babel.config.js': { icon: Settings, color: 'text-yellow-500' },
|
||||
'.babelrc': { icon: Settings, color: 'text-yellow-500' },
|
||||
'README.md': { icon: BookOpen, color: 'text-blue-500' },
|
||||
'LICENSE': { icon: FileCheck, color: 'text-gray-500' },
|
||||
'LICENSE.md': { icon: FileCheck, color: 'text-gray-500' },
|
||||
'CHANGELOG.md': { icon: Scroll, color: 'text-blue-400' },
|
||||
'requirements.txt': { icon: FileText, color: 'text-emerald-400' },
|
||||
'go.mod': { icon: Hexagon, color: 'text-cyan-500' },
|
||||
'go.sum': { icon: Lock, color: 'text-cyan-400' },
|
||||
};
|
||||
|
||||
function getFileIconData(filename) {
|
||||
// 1. Exact filename match
|
||||
if (FILENAME_ICON_MAP[filename]) {
|
||||
return FILENAME_ICON_MAP[filename];
|
||||
}
|
||||
|
||||
// 2. Check for .env prefix pattern
|
||||
if (filename.startsWith('.env')) {
|
||||
return { icon: Shield, color: 'text-yellow-600' };
|
||||
}
|
||||
|
||||
// 3. Extension-based lookup
|
||||
const ext = filename.split('.').pop()?.toLowerCase();
|
||||
if (ext && FILE_ICON_MAP[ext]) {
|
||||
return FILE_ICON_MAP[ext];
|
||||
}
|
||||
|
||||
// 4. Fallback
|
||||
return { icon: File, color: 'text-muted-foreground' };
|
||||
}
|
||||
|
||||
|
||||
// ─── Component ───────────────────────────────────────────────────────
|
||||
|
||||
function FileTree({ selectedProject, onFileOpen }) {
|
||||
const { t } = useTranslation();
|
||||
const [files, setFiles] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [expandedDirs, setExpandedDirs] = useState(new Set());
|
||||
const [selectedFile, setSelectedFile] = useState(null);
|
||||
const [selectedImage, setSelectedImage] = useState(null);
|
||||
const [viewMode, setViewMode] = useState('detailed'); // 'simple', 'detailed', 'compact'
|
||||
const [viewMode, setViewMode] = useState('detailed');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [filteredFiles, setFilteredFiles] = useState([]);
|
||||
|
||||
@@ -26,7 +275,6 @@ function FileTree({ selectedProject }) {
|
||||
}
|
||||
}, [selectedProject]);
|
||||
|
||||
// Load view mode preference from localStorage
|
||||
useEffect(() => {
|
||||
const savedViewMode = localStorage.getItem('file-tree-view-mode');
|
||||
if (savedViewMode && ['simple', 'detailed', 'compact'].includes(savedViewMode)) {
|
||||
@@ -34,7 +282,6 @@ function FileTree({ selectedProject }) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Filter files based on search query
|
||||
useEffect(() => {
|
||||
if (!searchQuery.trim()) {
|
||||
setFilteredFiles(files);
|
||||
@@ -42,7 +289,6 @@ function FileTree({ selectedProject }) {
|
||||
const filtered = filterFiles(files, searchQuery.toLowerCase());
|
||||
setFilteredFiles(filtered);
|
||||
|
||||
// Auto-expand directories that contain matches
|
||||
const expandMatches = (items) => {
|
||||
items.forEach(item => {
|
||||
if (item.type === 'directory' && item.children && item.children.length > 0) {
|
||||
@@ -55,7 +301,6 @@ function FileTree({ selectedProject }) {
|
||||
}
|
||||
}, [files, searchQuery]);
|
||||
|
||||
// Recursively filter files and directories based on search query
|
||||
const filterFiles = (items, query) => {
|
||||
return items.reduce((filtered, item) => {
|
||||
const matchesName = item.name.toLowerCase().includes(query);
|
||||
@@ -65,9 +310,6 @@ function FileTree({ selectedProject }) {
|
||||
filteredChildren = filterFiles(item.children, query);
|
||||
}
|
||||
|
||||
// Include item if:
|
||||
// 1. It matches the search query, or
|
||||
// 2. It's a directory with matching children
|
||||
if (matchesName || filteredChildren.length > 0) {
|
||||
filtered.push({
|
||||
...item,
|
||||
@@ -83,14 +325,14 @@ function FileTree({ selectedProject }) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await api.getFiles(selectedProject.name);
|
||||
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('❌ File fetch failed:', response.status, errorText);
|
||||
setFiles([]);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const data = await response.json();
|
||||
setFiles(data);
|
||||
} catch (error) {
|
||||
@@ -111,13 +353,11 @@ function FileTree({ selectedProject }) {
|
||||
setExpandedDirs(newExpanded);
|
||||
};
|
||||
|
||||
// Change view mode and save preference
|
||||
const changeViewMode = (mode) => {
|
||||
setViewMode(mode);
|
||||
localStorage.setItem('file-tree-view-mode', mode);
|
||||
};
|
||||
|
||||
// Format file size
|
||||
const formatFileSize = (bytes) => {
|
||||
if (!bytes || bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
@@ -126,7 +366,6 @@ function FileTree({ selectedProject }) {
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
// Format date as relative time
|
||||
const formatRelativeTime = (date) => {
|
||||
if (!date) return '-';
|
||||
const now = new Date();
|
||||
@@ -140,65 +379,6 @@ function FileTree({ selectedProject }) {
|
||||
return past.toLocaleDateString();
|
||||
};
|
||||
|
||||
const renderFileTree = (items, level = 0) => {
|
||||
return items.map((item) => (
|
||||
<div key={item.path} className="select-none">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"w-full justify-start p-2 h-auto font-normal text-left hover:bg-accent",
|
||||
)}
|
||||
style={{ paddingLeft: `${level * 16 + 12}px` }}
|
||||
onClick={() => {
|
||||
if (item.type === 'directory') {
|
||||
toggleDirectory(item.path);
|
||||
} else if (isImageFile(item.name)) {
|
||||
// Open image in viewer
|
||||
setSelectedImage({
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
projectPath: selectedProject.path,
|
||||
projectName: selectedProject.name
|
||||
});
|
||||
} else {
|
||||
// Open file in editor
|
||||
setSelectedFile({
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
projectPath: selectedProject.path,
|
||||
projectName: selectedProject.name
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0 w-full">
|
||||
{item.type === 'directory' ? (
|
||||
expandedDirs.has(item.path) ? (
|
||||
<FolderOpen className="w-4 h-4 text-blue-500 flex-shrink-0" />
|
||||
) : (
|
||||
<Folder className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
)
|
||||
) : (
|
||||
getFileIcon(item.name)
|
||||
)}
|
||||
<span className="text-sm truncate text-foreground">
|
||||
{item.name}
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
{item.type === 'directory' &&
|
||||
expandedDirs.has(item.path) &&
|
||||
item.children &&
|
||||
item.children.length > 0 && (
|
||||
<div>
|
||||
{renderFileTree(item.children, level + 1)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
));
|
||||
};
|
||||
|
||||
const isImageFile = (filename) => {
|
||||
const ext = filename.split('.').pop()?.toLowerCase();
|
||||
const imageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'bmp'];
|
||||
@@ -206,196 +386,272 @@ function FileTree({ selectedProject }) {
|
||||
};
|
||||
|
||||
const getFileIcon = (filename) => {
|
||||
const ext = filename.split('.').pop()?.toLowerCase();
|
||||
|
||||
const codeExtensions = ['js', 'jsx', 'ts', 'tsx', 'py', 'java', 'cpp', 'c', 'php', 'rb', 'go', 'rs'];
|
||||
const docExtensions = ['md', 'txt', 'doc', 'pdf'];
|
||||
const imageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'bmp'];
|
||||
|
||||
if (codeExtensions.includes(ext)) {
|
||||
return <FileCode className="w-4 h-4 text-green-500 flex-shrink-0" />;
|
||||
} else if (docExtensions.includes(ext)) {
|
||||
return <FileText className="w-4 h-4 text-blue-500 flex-shrink-0" />;
|
||||
} else if (imageExtensions.includes(ext)) {
|
||||
return <File className="w-4 h-4 text-purple-500 flex-shrink-0" />;
|
||||
} else {
|
||||
return <File className="w-4 h-4 text-muted-foreground flex-shrink-0" />;
|
||||
const { icon: Icon, color } = getFileIconData(filename);
|
||||
return <Icon className={cn(ICON_SIZE, color)} />;
|
||||
};
|
||||
|
||||
// ── Click handler shared across all view modes ──
|
||||
const handleItemClick = (item) => {
|
||||
if (item.type === 'directory') {
|
||||
toggleDirectory(item.path);
|
||||
} else if (isImageFile(item.name)) {
|
||||
setSelectedImage({
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
projectPath: selectedProject.path,
|
||||
projectName: selectedProject.name
|
||||
});
|
||||
} else if (onFileOpen) {
|
||||
onFileOpen(item.path);
|
||||
}
|
||||
};
|
||||
|
||||
// Render detailed view with table-like layout
|
||||
// ── Indent guide + folder/file icon rendering ──
|
||||
const renderIndentGuides = (level) => {
|
||||
if (level === 0) return null;
|
||||
return (
|
||||
<span className="flex items-center flex-shrink-0" aria-hidden="true">
|
||||
{Array.from({ length: level }).map((_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="inline-block w-4 h-full border-l border-border/50"
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const renderItemIcons = (item) => {
|
||||
const isDir = item.type === 'directory';
|
||||
const isOpen = expandedDirs.has(item.path);
|
||||
|
||||
if (isDir) {
|
||||
return (
|
||||
<span className="flex items-center gap-0.5 flex-shrink-0">
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'w-3.5 h-3.5 text-muted-foreground/70 transition-transform duration-150',
|
||||
isOpen && 'rotate-90'
|
||||
)}
|
||||
/>
|
||||
{isOpen ? (
|
||||
<FolderOpen className="w-4 h-4 text-blue-500 flex-shrink-0" />
|
||||
) : (
|
||||
<Folder className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="flex items-center flex-shrink-0 ml-[18px]">
|
||||
{getFileIcon(item.name)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Simple (Tree) View ────────────────────────────────────────────
|
||||
const renderFileTree = (items, level = 0) => {
|
||||
return items.map((item) => {
|
||||
const isDir = item.type === 'directory';
|
||||
const isOpen = isDir && expandedDirs.has(item.path);
|
||||
|
||||
return (
|
||||
<div key={item.path} className="select-none">
|
||||
<div
|
||||
className={cn(
|
||||
'group flex items-center gap-1.5 py-[3px] pr-2 cursor-pointer rounded-sm',
|
||||
'hover:bg-accent/60 transition-colors duration-100',
|
||||
isDir && isOpen && 'border-l-2 border-primary/30',
|
||||
isDir && !isOpen && 'border-l-2 border-transparent',
|
||||
!isDir && 'border-l-2 border-transparent',
|
||||
)}
|
||||
style={{ paddingLeft: `${level * 16 + 4}px` }}
|
||||
onClick={() => handleItemClick(item)}
|
||||
>
|
||||
{renderItemIcons(item)}
|
||||
<span className={cn(
|
||||
'text-[13px] leading-tight truncate',
|
||||
isDir ? 'font-medium text-foreground' : 'text-foreground/90'
|
||||
)}>
|
||||
{item.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{isDir && isOpen && item.children && item.children.length > 0 && (
|
||||
<div className="relative">
|
||||
<span
|
||||
className="absolute top-0 bottom-0 border-l border-border/40"
|
||||
style={{ left: `${level * 16 + 14}px` }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{renderFileTree(item.children, level + 1)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
// ─── Detailed View ────────────────────────────────────────────────
|
||||
const renderDetailedView = (items, level = 0) => {
|
||||
return items.map((item) => (
|
||||
<div key={item.path} className="select-none">
|
||||
<div
|
||||
className={cn(
|
||||
"grid grid-cols-12 gap-2 p-2 hover:bg-accent cursor-pointer items-center",
|
||||
)}
|
||||
style={{ paddingLeft: `${level * 16 + 12}px` }}
|
||||
onClick={() => {
|
||||
if (item.type === 'directory') {
|
||||
toggleDirectory(item.path);
|
||||
} else if (isImageFile(item.name)) {
|
||||
setSelectedImage({
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
projectPath: selectedProject.path,
|
||||
projectName: selectedProject.name
|
||||
});
|
||||
} else {
|
||||
setSelectedFile({
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
projectPath: selectedProject.path,
|
||||
projectName: selectedProject.name
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="col-span-5 flex items-center gap-2 min-w-0">
|
||||
{item.type === 'directory' ? (
|
||||
expandedDirs.has(item.path) ? (
|
||||
<FolderOpen className="w-4 h-4 text-blue-500 flex-shrink-0" />
|
||||
) : (
|
||||
<Folder className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
)
|
||||
) : (
|
||||
getFileIcon(item.name)
|
||||
return items.map((item) => {
|
||||
const isDir = item.type === 'directory';
|
||||
const isOpen = isDir && expandedDirs.has(item.path);
|
||||
|
||||
return (
|
||||
<div key={item.path} className="select-none">
|
||||
<div
|
||||
className={cn(
|
||||
'group grid grid-cols-12 gap-2 py-[3px] pr-2 hover:bg-accent/60 cursor-pointer items-center rounded-sm transition-colors duration-100',
|
||||
isDir && isOpen && 'border-l-2 border-primary/30',
|
||||
isDir && !isOpen && 'border-l-2 border-transparent',
|
||||
!isDir && 'border-l-2 border-transparent',
|
||||
)}
|
||||
<span className="text-sm truncate text-foreground">
|
||||
{item.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="col-span-2 text-sm text-muted-foreground">
|
||||
{item.type === 'file' ? formatFileSize(item.size) : '-'}
|
||||
</div>
|
||||
<div className="col-span-3 text-sm text-muted-foreground">
|
||||
{formatRelativeTime(item.modified)}
|
||||
</div>
|
||||
<div className="col-span-2 text-sm text-muted-foreground font-mono">
|
||||
{item.permissionsRwx || '-'}
|
||||
style={{ paddingLeft: `${level * 16 + 4}px` }}
|
||||
onClick={() => handleItemClick(item)}
|
||||
>
|
||||
<div className="col-span-5 flex items-center gap-1.5 min-w-0">
|
||||
{renderItemIcons(item)}
|
||||
<span className={cn(
|
||||
'text-[13px] leading-tight truncate',
|
||||
isDir ? 'font-medium text-foreground' : 'text-foreground/90'
|
||||
)}>
|
||||
{item.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="col-span-2 text-sm text-muted-foreground tabular-nums">
|
||||
{item.type === 'file' ? formatFileSize(item.size) : ''}
|
||||
</div>
|
||||
<div className="col-span-3 text-sm text-muted-foreground">
|
||||
{formatRelativeTime(item.modified)}
|
||||
</div>
|
||||
<div className="col-span-2 text-sm text-muted-foreground font-mono">
|
||||
{item.permissionsRwx || ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isDir && isOpen && item.children && (
|
||||
<div className="relative">
|
||||
<span
|
||||
className="absolute top-0 bottom-0 border-l border-border/40"
|
||||
style={{ left: `${level * 16 + 14}px` }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{renderDetailedView(item.children, level + 1)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{item.type === 'directory' &&
|
||||
expandedDirs.has(item.path) &&
|
||||
item.children &&
|
||||
renderDetailedView(item.children, level + 1)}
|
||||
</div>
|
||||
));
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
// Render compact view with inline details
|
||||
// ─── Compact View ──────────────────────────────────────────────────
|
||||
const renderCompactView = (items, level = 0) => {
|
||||
return items.map((item) => (
|
||||
<div key={item.path} className="select-none">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between p-2 hover:bg-accent cursor-pointer",
|
||||
return items.map((item) => {
|
||||
const isDir = item.type === 'directory';
|
||||
const isOpen = isDir && expandedDirs.has(item.path);
|
||||
|
||||
return (
|
||||
<div key={item.path} className="select-none">
|
||||
<div
|
||||
className={cn(
|
||||
'group flex items-center justify-between py-[3px] pr-2 hover:bg-accent/60 cursor-pointer rounded-sm transition-colors duration-100',
|
||||
isDir && isOpen && 'border-l-2 border-primary/30',
|
||||
isDir && !isOpen && 'border-l-2 border-transparent',
|
||||
!isDir && 'border-l-2 border-transparent',
|
||||
)}
|
||||
style={{ paddingLeft: `${level * 16 + 4}px` }}
|
||||
onClick={() => handleItemClick(item)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
{renderItemIcons(item)}
|
||||
<span className={cn(
|
||||
'text-[13px] leading-tight truncate',
|
||||
isDir ? 'font-medium text-foreground' : 'text-foreground/90'
|
||||
)}>
|
||||
{item.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground flex-shrink-0 ml-2">
|
||||
{item.type === 'file' && (
|
||||
<>
|
||||
<span className="tabular-nums">{formatFileSize(item.size)}</span>
|
||||
<span className="font-mono">{item.permissionsRwx}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isDir && isOpen && item.children && (
|
||||
<div className="relative">
|
||||
<span
|
||||
className="absolute top-0 bottom-0 border-l border-border/40"
|
||||
style={{ left: `${level * 16 + 14}px` }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{renderCompactView(item.children, level + 1)}
|
||||
</div>
|
||||
)}
|
||||
style={{ paddingLeft: `${level * 16 + 12}px` }}
|
||||
onClick={() => {
|
||||
if (item.type === 'directory') {
|
||||
toggleDirectory(item.path);
|
||||
} else if (isImageFile(item.name)) {
|
||||
setSelectedImage({
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
projectPath: selectedProject.path,
|
||||
projectName: selectedProject.name
|
||||
});
|
||||
} else {
|
||||
setSelectedFile({
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
projectPath: selectedProject.path,
|
||||
projectName: selectedProject.name
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{item.type === 'directory' ? (
|
||||
expandedDirs.has(item.path) ? (
|
||||
<FolderOpen className="w-4 h-4 text-blue-500 flex-shrink-0" />
|
||||
) : (
|
||||
<Folder className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
)
|
||||
) : (
|
||||
getFileIcon(item.name)
|
||||
)}
|
||||
<span className="text-sm truncate text-foreground">
|
||||
{item.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
{item.type === 'file' && (
|
||||
<>
|
||||
<span>{formatFileSize(item.size)}</span>
|
||||
<span className="font-mono">{item.permissionsRwx}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{item.type === 'directory' &&
|
||||
expandedDirs.has(item.path) &&
|
||||
item.children &&
|
||||
renderCompactView(item.children, level + 1)}
|
||||
</div>
|
||||
));
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
// ─── Loading state ─────────────────────────────────────────────────
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="text-gray-500 dark:text-gray-400">
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{t('fileTree.loading')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main render ───────────────────────────────────────────────────
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-card">
|
||||
{/* Header with Search and View Mode Toggle */}
|
||||
<div className="p-4 border-b border-border space-y-3">
|
||||
<div className="h-full flex flex-col bg-background">
|
||||
{/* Header */}
|
||||
<div className="px-3 pt-3 pb-2 border-b border-border space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-foreground">{t('fileTree.files')}</h3>
|
||||
<div className="flex gap-1">
|
||||
<h3 className="text-sm font-medium text-foreground">
|
||||
{t('fileTree.files')}
|
||||
</h3>
|
||||
<div className="flex gap-0.5">
|
||||
<Button
|
||||
variant={viewMode === 'simple' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={() => changeViewMode('simple')}
|
||||
title={t('fileTree.simpleView')}
|
||||
>
|
||||
<List className="w-4 h-4" />
|
||||
<List className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'compact' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={() => changeViewMode('compact')}
|
||||
title={t('fileTree.compactView')}
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
<Eye className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'detailed' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={() => changeViewMode('detailed')}
|
||||
title={t('fileTree.detailedView')}
|
||||
>
|
||||
<TableProperties className="w-4 h-4" />
|
||||
<TableProperties className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search Bar */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Search className="absolute left-2 top-1/2 transform -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={t('fileTree.searchPlaceholder')}
|
||||
@@ -407,7 +663,7 @@ function FileTree({ selectedProject }) {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-1 top-1/2 transform -translate-y-1/2 h-6 w-6 p-0 hover:bg-accent"
|
||||
className="absolute right-0.5 top-1/2 transform -translate-y-1/2 h-5 w-5 p-0 hover:bg-accent"
|
||||
onClick={() => setSearchQuery('')}
|
||||
title={t('fileTree.clearSearch')}
|
||||
>
|
||||
@@ -419,8 +675,8 @@ function FileTree({ selectedProject }) {
|
||||
|
||||
{/* Column Headers for Detailed View */}
|
||||
{viewMode === 'detailed' && filteredFiles.length > 0 && (
|
||||
<div className="px-4 pt-2 pb-1 border-b border-border">
|
||||
<div className="grid grid-cols-12 gap-2 px-2 text-xs font-medium text-muted-foreground">
|
||||
<div className="px-3 pt-1.5 pb-1 border-b border-border">
|
||||
<div className="grid grid-cols-12 gap-2 px-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/70">
|
||||
<div className="col-span-5">{t('fileTree.name')}</div>
|
||||
<div className="col-span-2">{t('fileTree.size')}</div>
|
||||
<div className="col-span-3">{t('fileTree.modified')}</div>
|
||||
@@ -429,7 +685,7 @@ function FileTree({ selectedProject }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ScrollArea className="flex-1 p-4">
|
||||
<ScrollArea className="flex-1 px-2 py-1">
|
||||
{files.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="w-12 h-12 bg-muted rounded-lg flex items-center justify-center mx-auto mb-3">
|
||||
@@ -451,23 +707,14 @@ function FileTree({ selectedProject }) {
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className={viewMode === 'detailed' ? '' : 'space-y-1'}>
|
||||
<div>
|
||||
{viewMode === 'simple' && renderFileTree(filteredFiles)}
|
||||
{viewMode === 'compact' && renderCompactView(filteredFiles)}
|
||||
{viewMode === 'detailed' && renderDetailedView(filteredFiles)}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
{/* Code Editor Modal */}
|
||||
{selectedFile && (
|
||||
<CodeEditor
|
||||
file={selectedFile}
|
||||
onClose={() => setSelectedFile(null)}
|
||||
projectPath={selectedFile.projectPath}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
{/* Image Viewer Modal */}
|
||||
{selectedImage && (
|
||||
<ImageViewer
|
||||
@@ -479,4 +726,4 @@ function FileTree({ selectedProject }) {
|
||||
);
|
||||
}
|
||||
|
||||
export default FileTree;
|
||||
export default FileTree;
|
||||
|
||||
@@ -53,14 +53,28 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedProject) {
|
||||
fetchGitStatus();
|
||||
fetchBranches();
|
||||
fetchRemoteStatus();
|
||||
if (activeView === 'history') {
|
||||
fetchRecentCommits();
|
||||
}
|
||||
// Clear stale repo-scoped state when project changes.
|
||||
setCurrentBranch('');
|
||||
setBranches([]);
|
||||
setGitStatus(null);
|
||||
setRemoteStatus(null);
|
||||
setSelectedFiles(new Set());
|
||||
|
||||
if (!selectedProject) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetchGitStatus();
|
||||
fetchBranches();
|
||||
fetchRemoteStatus();
|
||||
}, [selectedProject]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedProject || activeView !== 'history') {
|
||||
return;
|
||||
}
|
||||
|
||||
fetchRecentCommits();
|
||||
}, [selectedProject, activeView]);
|
||||
|
||||
// Handle click outside dropdown
|
||||
@@ -88,6 +102,8 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
|
||||
if (data.error) {
|
||||
console.error('Git status error:', data.error);
|
||||
setGitStatus({ error: data.error, details: data.details });
|
||||
setCurrentBranch('');
|
||||
setSelectedFiles(new Set());
|
||||
} else {
|
||||
setGitStatus(data);
|
||||
setCurrentBranch(data.branch || 'main');
|
||||
@@ -117,6 +133,9 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching git status:', error);
|
||||
setGitStatus({ error: 'Git operation failed', details: String(error) });
|
||||
setCurrentBranch('');
|
||||
setSelectedFiles(new Set());
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -129,9 +148,12 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
|
||||
|
||||
if (!data.error && data.branches) {
|
||||
setBranches(data.branches);
|
||||
} else {
|
||||
setBranches([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching branches:', error);
|
||||
setBranches([]);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -618,36 +640,36 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
|
||||
const renderCommitItem = (commit) => {
|
||||
const isExpanded = expandedCommits.has(commit.hash);
|
||||
const diff = commitDiffs[commit.hash];
|
||||
|
||||
|
||||
return (
|
||||
<div key={commit.hash} className="border-b border-gray-200 dark:border-gray-700 last:border-0">
|
||||
<div
|
||||
className="flex items-start p-3 hover:bg-gray-50 dark:hover:bg-gray-800 cursor-pointer"
|
||||
<div key={commit.hash} className="border-b border-border last:border-0">
|
||||
<div
|
||||
className="flex items-start p-3 hover:bg-accent/50 cursor-pointer transition-colors"
|
||||
onClick={() => toggleCommitExpanded(commit.hash)}
|
||||
>
|
||||
<div className="mr-2 mt-1 p-0.5 hover:bg-gray-200 dark:hover:bg-gray-700 rounded">
|
||||
<div className="mr-2 mt-1 p-0.5 hover:bg-accent rounded">
|
||||
{isExpanded ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white truncate">
|
||||
<p className="text-sm font-medium text-foreground truncate">
|
||||
{commit.message}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{commit.author} • {commit.date}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-xs font-mono text-gray-400 dark:text-gray-500 flex-shrink-0">
|
||||
<span className="text-sm font-mono text-muted-foreground/60 flex-shrink-0">
|
||||
{commit.hash.substring(0, 7)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{isExpanded && diff && (
|
||||
<div className="bg-gray-50 dark:bg-gray-900">
|
||||
<div className="bg-muted/50">
|
||||
<div className="max-h-96 overflow-y-auto p-2">
|
||||
<div className="text-xs font-mono text-gray-600 dark:text-gray-400 mb-2">
|
||||
<div className="text-sm font-mono text-muted-foreground mb-2">
|
||||
{commit.stats}
|
||||
</div>
|
||||
<DiffViewer diff={diff} fileName="commit" isMobile={isMobile} wrapText={wrapText} />
|
||||
@@ -662,22 +684,20 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
|
||||
const isExpanded = expandedFiles.has(filePath);
|
||||
const isSelected = selectedFiles.has(filePath);
|
||||
const diff = gitDiff[filePath];
|
||||
|
||||
|
||||
return (
|
||||
<div key={filePath} className="border-b border-gray-200 dark:border-gray-700 last:border-0">
|
||||
<div className={`flex items-center hover:bg-gray-50 dark:hover:bg-gray-800 ${isMobile ? 'px-2 py-1.5' : 'px-3 py-2'}`}>
|
||||
<div key={filePath} className="border-b border-border last:border-0">
|
||||
<div className={`flex items-center hover:bg-accent/50 transition-colors ${isMobile ? 'px-2 py-1.5' : 'px-3 py-2'}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => toggleFileSelected(filePath)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className={`rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 dark:focus:ring-blue-400 dark:bg-gray-800 dark:checked:bg-blue-600 ${isMobile ? 'mr-1.5' : 'mr-2'}`}
|
||||
className={`rounded border-border text-primary focus:ring-primary/40 bg-background checked:bg-primary ${isMobile ? 'mr-1.5' : 'mr-2'}`}
|
||||
/>
|
||||
<div
|
||||
className="flex items-center flex-1"
|
||||
>
|
||||
<div className="flex items-center flex-1">
|
||||
<div
|
||||
className={`p-0.5 hover:bg-gray-200 dark:hover:bg-gray-700 rounded cursor-pointer ${isMobile ? 'mr-1' : 'mr-2'}`}
|
||||
className={`p-0.5 hover:bg-accent rounded cursor-pointer ${isMobile ? 'mr-1' : 'mr-2'}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleFileExpanded(filePath);
|
||||
@@ -686,7 +706,7 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
|
||||
<ChevronRight className={`w-3 h-3 transition-transform duration-200 ease-in-out ${isExpanded ? 'rotate-90' : 'rotate-0'}`} />
|
||||
</div>
|
||||
<span
|
||||
className={`flex-1 truncate ${isMobile ? 'text-xs' : 'text-sm'} cursor-pointer hover:text-blue-600 dark:hover:text-blue-400 hover:underline`}
|
||||
className={`flex-1 truncate ${isMobile ? 'text-xs' : 'text-sm'} cursor-pointer hover:text-primary hover:underline`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleFileOpen(filePath);
|
||||
@@ -700,16 +720,16 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setConfirmAction({
|
||||
type: 'discard',
|
||||
setConfirmAction({
|
||||
type: 'discard',
|
||||
file: filePath,
|
||||
message: `Discard all changes to "${filePath}"? This action cannot be undone.`
|
||||
message: `Discard all changes to "${filePath}"? This action cannot be undone.`
|
||||
});
|
||||
}}
|
||||
className={`${isMobile ? 'px-2 py-1 text-xs' : 'p-1'} hover:bg-red-100 dark:hover:bg-red-900 rounded text-red-600 dark:text-red-400 font-medium flex items-center gap-1`}
|
||||
className={`${isMobile ? 'px-2 py-1 text-xs' : 'p-1'} hover:bg-destructive/10 rounded text-destructive font-medium flex items-center gap-1`}
|
||||
title="Discard changes"
|
||||
>
|
||||
<Trash2 className={`${isMobile ? 'w-3 h-3' : 'w-3 h-3'}`} />
|
||||
<Trash2 className="w-3 h-3" />
|
||||
{isMobile && <span>Discard</span>}
|
||||
</button>
|
||||
)}
|
||||
@@ -717,25 +737,25 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setConfirmAction({
|
||||
type: 'delete',
|
||||
setConfirmAction({
|
||||
type: 'delete',
|
||||
file: filePath,
|
||||
message: `Delete untracked file "${filePath}"? This action cannot be undone.`
|
||||
message: `Delete untracked file "${filePath}"? This action cannot be undone.`
|
||||
});
|
||||
}}
|
||||
className={`${isMobile ? 'px-2 py-1 text-xs' : 'p-1'} hover:bg-red-100 dark:hover:bg-red-900 rounded text-red-600 dark:text-red-400 font-medium flex items-center gap-1`}
|
||||
className={`${isMobile ? 'px-2 py-1 text-xs' : 'p-1'} hover:bg-destructive/10 rounded text-destructive font-medium flex items-center gap-1`}
|
||||
title="Delete untracked file"
|
||||
>
|
||||
<Trash2 className={`${isMobile ? 'w-3 h-3' : 'w-3 h-3'}`} />
|
||||
<Trash2 className="w-3 h-3" />
|
||||
{isMobile && <span>Delete</span>}
|
||||
</button>
|
||||
)}
|
||||
<span
|
||||
className={`inline-flex items-center justify-center w-5 h-5 rounded text-xs font-bold border ${
|
||||
status === 'M' ? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300 border-yellow-200 dark:border-yellow-800' :
|
||||
status === 'A' ? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300 border-green-200 dark:border-green-800' :
|
||||
status === 'D' ? 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300 border-red-200 dark:border-red-800' :
|
||||
'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300 border-gray-300 dark:border-gray-600'
|
||||
<span
|
||||
className={`inline-flex items-center justify-center w-5 h-5 rounded text-[10px] font-bold border ${
|
||||
status === 'M' ? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300 border-yellow-200 dark:border-yellow-800/50' :
|
||||
status === 'A' ? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300 border-green-200 dark:border-green-800/50' :
|
||||
status === 'D' ? 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300 border-red-200 dark:border-red-800/50' :
|
||||
'bg-muted text-muted-foreground border-border'
|
||||
}`}
|
||||
title={getStatusLabel(status)}
|
||||
>
|
||||
@@ -744,25 +764,25 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`bg-gray-50 dark:bg-gray-900 transition-all duration-400 ease-in-out overflow-hidden ${
|
||||
isExpanded && diff
|
||||
? 'max-h-[600px] opacity-100 translate-y-0'
|
||||
<div className={`bg-muted/50 transition-all duration-400 ease-in-out overflow-hidden ${
|
||||
isExpanded && diff
|
||||
? 'max-h-[600px] opacity-100 translate-y-0'
|
||||
: 'max-h-0 opacity-0 -translate-y-1'
|
||||
}`}>
|
||||
{/* Operation header */}
|
||||
<div className="flex items-center justify-between p-2 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between p-2 border-b border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`inline-flex items-center justify-center w-5 h-5 rounded text-xs font-bold border ${
|
||||
status === 'M' ? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300 border-yellow-200 dark:border-yellow-800' :
|
||||
status === 'A' ? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300 border-green-200 dark:border-green-800' :
|
||||
status === 'D' ? 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300 border-red-200 dark:border-red-800' :
|
||||
'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300 border-gray-300 dark:border-gray-600'
|
||||
<span
|
||||
className={`inline-flex items-center justify-center w-5 h-5 rounded text-[10px] font-bold border ${
|
||||
status === 'M' ? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300 border-yellow-200 dark:border-yellow-800/50' :
|
||||
status === 'A' ? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300 border-green-200 dark:border-green-800/50' :
|
||||
status === 'D' ? 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300 border-red-200 dark:border-red-800/50' :
|
||||
'bg-muted text-muted-foreground border-border'
|
||||
}`}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{getStatusLabel(status)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -772,7 +792,7 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
|
||||
e.stopPropagation();
|
||||
setWrapText(!wrapText);
|
||||
}}
|
||||
className="text-xs text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
title={wrapText ? "Switch to horizontal scroll" : "Switch to text wrap"}
|
||||
>
|
||||
{wrapText ? '↔️ Scroll' : '↩️ Wrap'}
|
||||
@@ -789,7 +809,7 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
|
||||
|
||||
if (!selectedProject) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center text-gray-500 dark:text-gray-400">
|
||||
<div className="h-full flex items-center justify-center text-muted-foreground">
|
||||
<p>Select a project to view source control</p>
|
||||
</div>
|
||||
);
|
||||
@@ -798,13 +818,13 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-background">
|
||||
{/* Header */}
|
||||
<div className={`flex items-center justify-between border-b border-gray-200 dark:border-gray-700 ${isMobile ? 'px-3 py-2' : 'px-4 py-3'}`}>
|
||||
<div className={`flex items-center justify-between border-b border-border/60 ${isMobile ? 'px-3 py-2' : 'px-4 py-3'}`}>
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={() => setShowBranchDropdown(!showBranchDropdown)}
|
||||
className={`flex items-center hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors ${isMobile ? 'space-x-1 px-2 py-1' : 'space-x-2 px-3 py-1.5'}`}
|
||||
className={`flex items-center hover:bg-accent rounded-lg transition-colors ${isMobile ? 'space-x-1 px-2 py-1' : 'space-x-2 px-3 py-1.5'}`}
|
||||
>
|
||||
<GitBranch className={`text-gray-600 dark:text-gray-400 ${isMobile ? 'w-3 h-3' : 'w-4 h-4'}`} />
|
||||
<GitBranch className={`text-muted-foreground ${isMobile ? 'w-3 h-3' : 'w-4 h-4'}`} />
|
||||
<div className="flex items-center gap-1">
|
||||
<span className={`font-medium ${isMobile ? 'text-xs' : 'text-sm'}`}>{currentBranch}</span>
|
||||
{/* Remote status indicators */}
|
||||
@@ -816,47 +836,47 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
|
||||
</span>
|
||||
)}
|
||||
{remoteStatus.behind > 0 && (
|
||||
<span className="text-blue-600 dark:text-blue-400" title={`${remoteStatus.behind} commit${remoteStatus.behind !== 1 ? 's' : ''} behind`}>
|
||||
<span className="text-primary" title={`${remoteStatus.behind} commit${remoteStatus.behind !== 1 ? 's' : ''} behind`}>
|
||||
↓{remoteStatus.behind}
|
||||
</span>
|
||||
)}
|
||||
{remoteStatus.isUpToDate && (
|
||||
<span className="text-gray-500 dark:text-gray-400" title="Up to date with remote">
|
||||
<span className="text-muted-foreground" title="Up to date with remote">
|
||||
✓
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ChevronDown className={`w-3 h-3 text-gray-500 transition-transform ${showBranchDropdown ? 'rotate-180' : ''}`} />
|
||||
<ChevronDown className={`w-3 h-3 text-muted-foreground transition-transform ${showBranchDropdown ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
|
||||
{/* Branch Dropdown */}
|
||||
{showBranchDropdown && (
|
||||
<div className="absolute top-full left-0 mt-1 w-64 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 z-50">
|
||||
<div className="absolute top-full left-0 mt-1 w-64 bg-card rounded-xl shadow-lg border border-border z-50 overflow-hidden">
|
||||
<div className="py-1 max-h-64 overflow-y-auto">
|
||||
{branches.map(branch => (
|
||||
<button
|
||||
key={branch}
|
||||
onClick={() => switchBranch(branch)}
|
||||
className={`w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 ${
|
||||
branch === currentBranch ? 'bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-gray-100' : 'text-gray-700 dark:text-gray-300'
|
||||
className={`w-full text-left px-4 py-2 text-sm hover:bg-accent transition-colors ${
|
||||
branch === currentBranch ? 'bg-accent/50 text-foreground' : 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
{branch === currentBranch && <Check className="w-3 h-3 text-green-600 dark:text-green-400" />}
|
||||
{branch === currentBranch && <Check className="w-3 h-3 text-primary" />}
|
||||
<span className={branch === currentBranch ? 'font-medium' : ''}>{branch}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 py-1">
|
||||
<div className="border-t border-border py-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowNewBranchModal(true);
|
||||
setShowBranchDropdown(false);
|
||||
}}
|
||||
className="w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center space-x-2"
|
||||
className="w-full text-left px-4 py-2 text-sm hover:bg-accent transition-colors flex items-center space-x-2"
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
<span>Create new branch</span>
|
||||
@@ -873,12 +893,12 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
|
||||
{/* Publish button - show when branch doesn't exist on remote */}
|
||||
{!remoteStatus?.hasUpstream && (
|
||||
<button
|
||||
onClick={() => setConfirmAction({
|
||||
type: 'publish',
|
||||
message: `Publish branch "${currentBranch}" to ${remoteStatus.remoteName}?`
|
||||
onClick={() => setConfirmAction({
|
||||
type: 'publish',
|
||||
message: `Publish branch "${currentBranch}" to ${remoteStatus.remoteName}?`
|
||||
})}
|
||||
disabled={isPublishing}
|
||||
className="px-2 py-1 text-xs bg-purple-600 text-white rounded hover:bg-purple-700 disabled:opacity-50 flex items-center gap-1"
|
||||
className="px-2.5 py-1 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 flex items-center gap-1 transition-colors"
|
||||
title={`Publish branch "${currentBranch}" to ${remoteStatus.remoteName}`}
|
||||
>
|
||||
<Upload className={`w-3 h-3 ${isPublishing ? 'animate-pulse' : ''}`} />
|
||||
@@ -892,41 +912,41 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
|
||||
{/* Pull button - show when behind (primary action) */}
|
||||
{remoteStatus.behind > 0 && (
|
||||
<button
|
||||
onClick={() => setConfirmAction({
|
||||
type: 'pull',
|
||||
message: `Pull ${remoteStatus.behind} commit${remoteStatus.behind !== 1 ? 's' : ''} from ${remoteStatus.remoteName}?`
|
||||
onClick={() => setConfirmAction({
|
||||
type: 'pull',
|
||||
message: `Pull ${remoteStatus.behind} commit${remoteStatus.behind !== 1 ? 's' : ''} from ${remoteStatus.remoteName}?`
|
||||
})}
|
||||
disabled={isPulling}
|
||||
className="px-2 py-1 text-xs bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50 flex items-center gap-1"
|
||||
className="px-2.5 py-1 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 flex items-center gap-1 transition-colors"
|
||||
title={`Pull ${remoteStatus.behind} commit${remoteStatus.behind !== 1 ? 's' : ''} from ${remoteStatus.remoteName}`}
|
||||
>
|
||||
<Download className={`w-3 h-3 ${isPulling ? 'animate-pulse' : ''}`} />
|
||||
<span>{isPulling ? 'Pulling...' : `Pull ${remoteStatus.behind}`}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
|
||||
{/* Push button - show when ahead (primary action when ahead only) */}
|
||||
{remoteStatus.ahead > 0 && (
|
||||
<button
|
||||
onClick={() => setConfirmAction({
|
||||
type: 'push',
|
||||
message: `Push ${remoteStatus.ahead} commit${remoteStatus.ahead !== 1 ? 's' : ''} to ${remoteStatus.remoteName}?`
|
||||
onClick={() => setConfirmAction({
|
||||
type: 'push',
|
||||
message: `Push ${remoteStatus.ahead} commit${remoteStatus.ahead !== 1 ? 's' : ''} to ${remoteStatus.remoteName}?`
|
||||
})}
|
||||
disabled={isPushing}
|
||||
className="px-2 py-1 text-xs bg-orange-600 text-white rounded hover:bg-orange-700 disabled:opacity-50 flex items-center gap-1"
|
||||
className="px-2.5 py-1 text-sm bg-orange-600 text-white rounded-lg hover:bg-orange-700 disabled:opacity-50 flex items-center gap-1 transition-colors"
|
||||
title={`Push ${remoteStatus.ahead} commit${remoteStatus.ahead !== 1 ? 's' : ''} to ${remoteStatus.remoteName}`}
|
||||
>
|
||||
<Upload className={`w-3 h-3 ${isPushing ? 'animate-pulse' : ''}`} />
|
||||
<span>{isPushing ? 'Pushing...' : `Push ${remoteStatus.ahead}`}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
|
||||
{/* Fetch button - show when ahead only or when diverged (secondary action) */}
|
||||
{(remoteStatus.ahead > 0 || (remoteStatus.behind > 0 && remoteStatus.ahead > 0)) && (
|
||||
<button
|
||||
onClick={handleFetch}
|
||||
disabled={isFetching}
|
||||
className="px-2 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 flex items-center gap-1"
|
||||
className="px-2.5 py-1 text-sm bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50 flex items-center gap-1 transition-colors"
|
||||
title={`Fetch from ${remoteStatus.remoteName}`}
|
||||
>
|
||||
<RefreshCw className={`w-3 h-3 ${isFetching ? 'animate-spin' : ''}`} />
|
||||
@@ -945,41 +965,43 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
|
||||
fetchRemoteStatus();
|
||||
}}
|
||||
disabled={isLoading}
|
||||
className={`hover:bg-gray-100 dark:hover:bg-gray-800 rounded ${isMobile ? 'p-1' : 'p-1.5'}`}
|
||||
className={`hover:bg-accent rounded-lg transition-colors ${isMobile ? 'p-1' : 'p-1.5'}`}
|
||||
>
|
||||
<RefreshCw className={`${isLoading ? 'animate-spin' : ''} ${isMobile ? 'w-3 h-3' : 'w-4 h-4'}`} />
|
||||
<RefreshCw className={`text-muted-foreground ${isLoading ? 'animate-spin' : ''} ${isMobile ? 'w-3 h-3' : 'w-4 h-4'}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Git Repository Not Found Message */}
|
||||
{gitStatus?.error ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-gray-500 dark:text-gray-400 px-6 py-12">
|
||||
<GitBranch className="w-20 h-20 mb-6 opacity-30" />
|
||||
<h3 className="text-xl font-medium mb-3 text-center">{gitStatus.error}</h3>
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground px-6 py-12">
|
||||
<div className="w-16 h-16 rounded-2xl bg-muted/50 flex items-center justify-center mb-6">
|
||||
<GitBranch className="w-8 h-8 opacity-40" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium mb-3 text-center text-foreground">{gitStatus.error}</h3>
|
||||
{gitStatus.details && (
|
||||
<p className="text-sm text-center leading-relaxed mb-6 max-w-md">{gitStatus.details}</p>
|
||||
)}
|
||||
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800 max-w-md">
|
||||
<p className="text-sm text-blue-700 dark:text-blue-300 text-center">
|
||||
<strong>Tip:</strong> Run <code className="bg-blue-100 dark:bg-blue-900 px-2 py-1 rounded font-mono text-xs">git init</code> in your project directory to initialize git source control.
|
||||
<div className="p-4 bg-primary/5 rounded-xl border border-primary/10 max-w-md">
|
||||
<p className="text-sm text-primary text-center">
|
||||
<strong>Tip:</strong> Run <code className="bg-primary/10 px-2 py-1 rounded-md font-mono text-xs">git init</code> in your project directory to initialize git source control.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Tab Navigation - Only show when git is available and no files expanded */}
|
||||
<div className={`flex border-b border-gray-200 dark:border-gray-700 transition-all duration-300 ease-in-out ${
|
||||
expandedFiles.size === 0
|
||||
? 'max-h-16 opacity-100 translate-y-0'
|
||||
<div className={`flex border-b border-border/60 transition-all duration-300 ease-in-out ${
|
||||
expandedFiles.size === 0
|
||||
? 'max-h-16 opacity-100 translate-y-0'
|
||||
: 'max-h-0 opacity-0 -translate-y-2 overflow-hidden'
|
||||
}`}>
|
||||
<button
|
||||
onClick={() => setActiveView('changes')}
|
||||
className={`flex-1 px-4 py-2 text-sm font-medium transition-colors ${
|
||||
activeView === 'changes'
|
||||
? 'text-blue-600 dark:text-blue-400 border-b-2 border-blue-600 dark:border-blue-400'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
|
||||
? 'text-primary border-b-2 border-primary'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
@@ -991,8 +1013,8 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
|
||||
onClick={() => setActiveView('history')}
|
||||
className={`flex-1 px-4 py-2 text-sm font-medium transition-colors ${
|
||||
activeView === 'history'
|
||||
? 'text-blue-600 dark:text-blue-400 border-b-2 border-blue-600 dark:border-blue-400'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
|
||||
? 'text-primary border-b-2 border-primary'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
@@ -1012,10 +1034,10 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
|
||||
: 'max-h-0 opacity-0 -translate-y-2 overflow-hidden'
|
||||
}`}>
|
||||
{isMobile && isCommitAreaCollapsed ? (
|
||||
<div className="px-4 py-2 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="px-4 py-2 border-b border-border/60">
|
||||
<button
|
||||
onClick={() => setIsCommitAreaCollapsed(false)}
|
||||
className="w-full flex items-center justify-center gap-2 px-3 py-2 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700"
|
||||
className="w-full flex items-center justify-center gap-2 px-3 py-2 text-sm bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
<GitCommit className="w-4 h-4" />
|
||||
<span>Commit {selectedFiles.size} file{selectedFiles.size !== 1 ? 's' : ''}</span>
|
||||
@@ -1025,27 +1047,27 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
|
||||
) : (
|
||||
<>
|
||||
{/* Commit Message Input */}
|
||||
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="px-4 py-3 border-b border-border/60">
|
||||
{/* Mobile collapse button */}
|
||||
{isMobile && (
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium">Commit Changes</span>
|
||||
<span className="text-sm font-medium text-foreground">Commit Changes</span>
|
||||
<button
|
||||
onClick={() => setIsCommitAreaCollapsed(true)}
|
||||
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-800 rounded"
|
||||
className="p-1 hover:bg-accent rounded-lg transition-colors"
|
||||
>
|
||||
<ChevronDown className="w-4 h-4 rotate-180" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
<div className="relative">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={commitMessage}
|
||||
onChange={(e) => setCommitMessage(e.target.value)}
|
||||
placeholder="Message (Ctrl+Enter to commit)"
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 resize-none pr-20"
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-xl bg-background text-foreground placeholder:text-muted-foreground resize-none pr-20 focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary/30"
|
||||
rows="3"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||
@@ -1057,7 +1079,7 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
|
||||
<button
|
||||
onClick={generateCommitMessage}
|
||||
disabled={selectedFiles.size === 0 || isGeneratingMessage}
|
||||
className="p-1.5 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="p-1.5 text-muted-foreground hover:text-foreground disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
title="Generate commit message"
|
||||
>
|
||||
{isGeneratingMessage ? (
|
||||
@@ -1076,16 +1098,16 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<span className="text-xs text-gray-500">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{selectedFiles.size} file{selectedFiles.size !== 1 ? 's' : ''} selected
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setConfirmAction({
|
||||
type: 'commit',
|
||||
message: `Commit ${selectedFiles.size} file${selectedFiles.size !== 1 ? 's' : ''} with message: "${commitMessage.trim()}"?`
|
||||
onClick={() => setConfirmAction({
|
||||
type: 'commit',
|
||||
message: `Commit ${selectedFiles.size} file${selectedFiles.size !== 1 ? 's' : ''} with message: "${commitMessage.trim()}"?`
|
||||
})}
|
||||
disabled={!commitMessage.trim() || selectedFiles.size === 0 || isCommitting}
|
||||
className="px-3 py-1 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-1"
|
||||
className="px-3 py-1.5 text-sm bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-1 transition-colors"
|
||||
>
|
||||
<Check className="w-3 h-3" />
|
||||
<span>{isCommitting ? 'Committing...' : 'Commit'}</span>
|
||||
@@ -1100,12 +1122,12 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
|
||||
|
||||
{/* File Selection Controls - Only show in changes view and when git is working and no files expanded */}
|
||||
{activeView === 'changes' && gitStatus && !gitStatus.error && (
|
||||
<div className={`border-b border-gray-200 dark:border-gray-700 flex items-center justify-between transition-all duration-300 ease-in-out ${isMobile ? 'px-3 py-1.5' : 'px-4 py-2'} ${
|
||||
expandedFiles.size === 0
|
||||
? 'max-h-16 opacity-100 translate-y-0'
|
||||
<div className={`border-b border-border/60 flex items-center justify-between transition-all duration-300 ease-in-out ${isMobile ? 'px-3 py-1.5' : 'px-4 py-2'} ${
|
||||
expandedFiles.size === 0
|
||||
? 'max-h-16 opacity-100 translate-y-0'
|
||||
: 'max-h-0 opacity-0 -translate-y-2 overflow-hidden'
|
||||
}`}>
|
||||
<span className={`text-gray-600 dark:text-gray-400 ${isMobile ? 'text-xs' : 'text-xs'}`}>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{selectedFiles.size} of {(gitStatus?.modified?.length || 0) + (gitStatus?.added?.length || 0) + (gitStatus?.deleted?.length || 0) + (gitStatus?.untracked?.length || 0)} {isMobile ? '' : 'files'} selected
|
||||
</span>
|
||||
<div className={`flex ${isMobile ? 'gap-1' : 'gap-2'}`}>
|
||||
@@ -1119,14 +1141,14 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
|
||||
]);
|
||||
setSelectedFiles(allFiles);
|
||||
}}
|
||||
className={`text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 ${isMobile ? 'text-xs' : 'text-xs'}`}
|
||||
className="text-sm text-primary hover:text-primary/80 transition-colors"
|
||||
>
|
||||
{isMobile ? 'All' : 'Select All'}
|
||||
</button>
|
||||
<span className="text-gray-300 dark:text-gray-600">|</span>
|
||||
<span className="text-border">|</span>
|
||||
<button
|
||||
onClick={() => setSelectedFiles(new Set())}
|
||||
className={`text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 ${isMobile ? 'text-xs' : 'text-xs'}`}
|
||||
className="text-sm text-primary hover:text-primary/80 transition-colors"
|
||||
>
|
||||
{isMobile ? 'None' : 'Deselect All'}
|
||||
</button>
|
||||
@@ -1136,42 +1158,42 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
|
||||
|
||||
{/* Status Legend Toggle - Hide on mobile by default */}
|
||||
{!gitStatus?.error && !isMobile && (
|
||||
<div className="border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="border-b border-border/60">
|
||||
<button
|
||||
onClick={() => setShowLegend(!showLegend)}
|
||||
className="w-full px-4 py-2 bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-750 text-xs text-gray-600 dark:text-gray-400 flex items-center justify-center gap-1"
|
||||
className="w-full px-4 py-2 bg-muted/30 hover:bg-muted/50 text-sm text-muted-foreground flex items-center justify-center gap-1 transition-colors"
|
||||
>
|
||||
<Info className="w-3 h-3" />
|
||||
<span>File Status Guide</span>
|
||||
{showLegend ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
|
||||
</button>
|
||||
|
||||
|
||||
{showLegend && (
|
||||
<div className="px-4 py-3 bg-gray-50 dark:bg-gray-800 text-xs">
|
||||
<div className="px-4 py-3 bg-muted/30 text-sm">
|
||||
<div className={`${isMobile ? 'grid grid-cols-2 gap-3 justify-items-center' : 'flex justify-center gap-6'}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center justify-center w-5 h-5 bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300 rounded border border-yellow-200 dark:border-yellow-800 font-bold text-xs">
|
||||
<span className="inline-flex items-center justify-center w-5 h-5 bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300 rounded border border-yellow-200 dark:border-yellow-800/50 font-bold text-[10px]">
|
||||
M
|
||||
</span>
|
||||
<span className="text-gray-600 dark:text-gray-400 italic">Modified</span>
|
||||
<span className="text-muted-foreground italic">Modified</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center justify-center w-5 h-5 bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300 rounded border border-green-200 dark:border-green-800 font-bold text-xs">
|
||||
<span className="inline-flex items-center justify-center w-5 h-5 bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300 rounded border border-green-200 dark:border-green-800/50 font-bold text-[10px]">
|
||||
A
|
||||
</span>
|
||||
<span className="text-gray-600 dark:text-gray-400 italic">Added</span>
|
||||
<span className="text-muted-foreground italic">Added</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center justify-center w-5 h-5 bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300 rounded border border-red-200 dark:border-red-800 font-bold text-xs">
|
||||
<span className="inline-flex items-center justify-center w-5 h-5 bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300 rounded border border-red-200 dark:border-red-800/50 font-bold text-[10px]">
|
||||
D
|
||||
</span>
|
||||
<span className="text-gray-600 dark:text-gray-400 italic">Deleted</span>
|
||||
<span className="text-muted-foreground italic">Deleted</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center justify-center w-5 h-5 bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300 rounded border border-gray-300 dark:border-gray-600 font-bold text-xs">
|
||||
<span className="inline-flex items-center justify-center w-5 h-5 bg-muted text-muted-foreground rounded border border-border font-bold text-[10px]">
|
||||
U
|
||||
</span>
|
||||
<span className="text-gray-600 dark:text-gray-400 italic">Untracked</span>
|
||||
<span className="text-muted-foreground italic">Untracked</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1186,19 +1208,21 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
|
||||
<div className={`flex-1 overflow-y-auto ${isMobile ? 'pb-mobile-nav' : ''}`}>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<RefreshCw className="w-6 h-6 animate-spin text-gray-400" />
|
||||
<RefreshCw className="w-5 h-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : gitStatus?.hasCommits === false ? (
|
||||
<div className="flex flex-col items-center justify-center p-8 text-center">
|
||||
<GitBranch className="w-16 h-16 mb-4 opacity-30 text-gray-400 dark:text-gray-500" />
|
||||
<h3 className="text-lg font-medium mb-2 text-gray-900 dark:text-white">No commits yet</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-6 max-w-md">
|
||||
<div className="w-14 h-14 rounded-2xl bg-muted/50 flex items-center justify-center mb-4">
|
||||
<GitBranch className="w-7 h-7 text-muted-foreground/50" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium mb-2 text-foreground">No commits yet</h3>
|
||||
<p className="text-sm text-muted-foreground mb-6 max-w-md">
|
||||
This repository doesn't have any commits yet. Create your first commit to start tracking changes.
|
||||
</p>
|
||||
<button
|
||||
onClick={createInitialCommit}
|
||||
disabled={isCreatingInitialCommit}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 transition-colors"
|
||||
>
|
||||
{isCreatingInitialCommit ? (
|
||||
<>
|
||||
@@ -1214,8 +1238,8 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
|
||||
</button>
|
||||
</div>
|
||||
) : !gitStatus || (!gitStatus.modified?.length && !gitStatus.added?.length && !gitStatus.deleted?.length && !gitStatus.untracked?.length) ? (
|
||||
<div className="flex flex-col items-center justify-center h-32 text-gray-500 dark:text-gray-400">
|
||||
<GitCommit className="w-12 h-12 mb-2 opacity-50" />
|
||||
<div className="flex flex-col items-center justify-center h-32 text-muted-foreground">
|
||||
<GitCommit className="w-10 h-10 mb-2 opacity-40" />
|
||||
<p className="text-sm">No changes detected</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -1234,11 +1258,11 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
|
||||
<div className={`flex-1 overflow-y-auto ${isMobile ? 'pb-mobile-nav' : ''}`}>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<RefreshCw className="w-6 h-6 animate-spin text-gray-400" />
|
||||
<RefreshCw className="w-5 h-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : recentCommits.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-32 text-gray-500 dark:text-gray-400">
|
||||
<History className="w-12 h-12 mb-2 opacity-50" />
|
||||
<div className="flex flex-col items-center justify-center h-32 text-muted-foreground">
|
||||
<History className="w-10 h-10 mb-2 opacity-40" />
|
||||
<p className="text-sm">No commits found</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -1252,12 +1276,12 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
|
||||
{/* New Branch Modal */}
|
||||
{showNewBranchModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50" onClick={() => setShowNewBranchModal(false)} />
|
||||
<div className="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full">
|
||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setShowNewBranchModal(false)} />
|
||||
<div className="relative bg-card border border-border rounded-xl shadow-2xl max-w-md w-full overflow-hidden">
|
||||
<div className="p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Create New Branch</h3>
|
||||
<h3 className="text-lg font-semibold text-foreground mb-4">Create New Branch</h3>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
<label className="block text-sm font-medium text-foreground/80 mb-2">
|
||||
Branch Name
|
||||
</label>
|
||||
<input
|
||||
@@ -1270,11 +1294,11 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
|
||||
}
|
||||
}}
|
||||
placeholder="feature/new-feature"
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className="w-full px-3 py-2 border border-border rounded-xl bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary/30"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mb-4">
|
||||
<div className="text-sm text-muted-foreground mb-4">
|
||||
This will create a new branch from the current branch ({currentBranch})
|
||||
</div>
|
||||
<div className="flex justify-end space-x-3">
|
||||
@@ -1283,14 +1307,14 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
|
||||
setShowNewBranchModal(false);
|
||||
setNewBranchName('');
|
||||
}}
|
||||
className="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md"
|
||||
className="px-4 py-2 text-sm text-muted-foreground hover:text-foreground hover:bg-accent rounded-lg transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={createBranch}
|
||||
disabled={!newBranchName.trim() || isCreatingBranch}
|
||||
className="px-4 py-2 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-2"
|
||||
className="px-4 py-2 text-sm bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-2 transition-colors"
|
||||
>
|
||||
{isCreatingBranch ? (
|
||||
<>
|
||||
@@ -1313,44 +1337,44 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
|
||||
{/* Confirmation Modal */}
|
||||
{confirmAction && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50" onClick={() => setConfirmAction(null)} />
|
||||
<div className="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full">
|
||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setConfirmAction(null)} />
|
||||
<div className="relative bg-card border border-border rounded-xl shadow-2xl max-w-md w-full overflow-hidden">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center mb-4">
|
||||
<div className={`p-2 rounded-full mr-3 ${
|
||||
(confirmAction.type === 'discard' || confirmAction.type === 'delete') ? 'bg-red-100 dark:bg-red-900' : 'bg-yellow-100 dark:bg-yellow-900'
|
||||
(confirmAction.type === 'discard' || confirmAction.type === 'delete') ? 'bg-red-100 dark:bg-red-900/30' : 'bg-yellow-100 dark:bg-yellow-900/30'
|
||||
}`}>
|
||||
<AlertTriangle className={`w-5 h-5 ${
|
||||
(confirmAction.type === 'discard' || confirmAction.type === 'delete') ? 'text-red-600 dark:text-red-400' : 'text-yellow-600 dark:text-yellow-400'
|
||||
}`} />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold">
|
||||
{confirmAction.type === 'discard' ? 'Discard Changes' :
|
||||
<h3 className="text-lg font-semibold text-foreground">
|
||||
{confirmAction.type === 'discard' ? 'Discard Changes' :
|
||||
confirmAction.type === 'delete' ? 'Delete File' :
|
||||
confirmAction.type === 'commit' ? 'Confirm Commit' :
|
||||
confirmAction.type === 'pull' ? 'Confirm Pull' :
|
||||
confirmAction.type === 'commit' ? 'Confirm Commit' :
|
||||
confirmAction.type === 'pull' ? 'Confirm Pull' :
|
||||
confirmAction.type === 'publish' ? 'Publish Branch' : 'Confirm Push'}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-6">
|
||||
|
||||
<p className="text-sm text-muted-foreground mb-6">
|
||||
{confirmAction.message}
|
||||
</p>
|
||||
|
||||
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
onClick={() => setConfirmAction(null)}
|
||||
className="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md"
|
||||
className="px-4 py-2 text-sm text-muted-foreground hover:text-foreground hover:bg-accent rounded-lg transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={confirmAndExecute}
|
||||
className={`px-4 py-2 text-sm text-white rounded-md ${
|
||||
className={`px-4 py-2 text-sm text-white rounded-lg transition-colors ${
|
||||
(confirmAction.type === 'discard' || confirmAction.type === 'delete')
|
||||
? 'bg-red-600 hover:bg-red-700'
|
||||
? 'bg-red-600 hover:bg-red-700'
|
||||
: confirmAction.type === 'commit'
|
||||
? 'bg-blue-600 hover:bg-blue-700'
|
||||
? 'bg-primary hover:bg-primary/90'
|
||||
: confirmAction.type === 'pull'
|
||||
? 'bg-green-600 hover:bg-green-700'
|
||||
: confirmAction.type === 'publish'
|
||||
@@ -1399,4 +1423,4 @@ function GitPanel({ selectedProject, isMobile, onFileOpen }) {
|
||||
);
|
||||
}
|
||||
|
||||
export default GitPanel;
|
||||
export default GitPanel;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { X } from 'lucide-react';
|
||||
import StandaloneShell from './StandaloneShell';
|
||||
import { IS_PLATFORM } from '../constants/config';
|
||||
|
||||
/**
|
||||
* Reusable login modal component for Claude, Cursor, and Codex CLI authentication
|
||||
@@ -20,24 +21,23 @@ function LoginModal({
|
||||
project,
|
||||
onComplete,
|
||||
customCommand,
|
||||
isAuthenticated = false
|
||||
isAuthenticated = false,
|
||||
isOnboarding = false
|
||||
}) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
const getCommand = () => {
|
||||
if (customCommand) return customCommand;
|
||||
|
||||
const isPlatform = import.meta.env.VITE_IS_PLATFORM === 'true';
|
||||
|
||||
switch (provider) {
|
||||
case 'claude':
|
||||
return isAuthenticated ? 'claude /login --dangerously-skip-permissions' : 'claude setup-token --dangerously-skip-permissions';
|
||||
return isAuthenticated ? 'claude setup-token --dangerously-skip-permissions' : isOnboarding ? 'claude /exit --dangerously-skip-permissions' : 'claude /login --dangerously-skip-permissions';
|
||||
case 'cursor':
|
||||
return 'cursor-agent login';
|
||||
case 'codex':
|
||||
return isPlatform ? 'codex login --device-auth' : 'codex login';
|
||||
return IS_PLATFORM ? 'codex login --device-auth' : 'codex login';
|
||||
default:
|
||||
return isAuthenticated ? 'claude /login --dangerously-skip-permissions' : 'claude setup-token --dangerously-skip-permissions';
|
||||
return isAuthenticated ? 'claude setup-token --dangerously-skip-permissions' : isOnboarding ? 'claude /exit --dangerously-skip-permissions' : 'claude /login --dangerously-skip-permissions';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -58,9 +58,7 @@ function LoginModal({
|
||||
if (onComplete) {
|
||||
onComplete(exitCode);
|
||||
}
|
||||
if (exitCode === 0) {
|
||||
onClose();
|
||||
}
|
||||
// Keep modal open so users can read login output and close explicitly.
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,686 +0,0 @@
|
||||
/*
|
||||
* MainContent.jsx - Main Content Area with Session Protection Props Passthrough
|
||||
*
|
||||
* SESSION PROTECTION PASSTHROUGH:
|
||||
* ===============================
|
||||
*
|
||||
* This component serves as a passthrough layer for Session Protection functions:
|
||||
* - Receives session management functions from App.jsx
|
||||
* - Passes them down to ChatInterface.jsx
|
||||
*
|
||||
* No session protection logic is implemented here - it's purely a props bridge.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import ChatInterface from './ChatInterface';
|
||||
import FileTree from './FileTree';
|
||||
import CodeEditor from './CodeEditor';
|
||||
import StandaloneShell from './StandaloneShell';
|
||||
import GitPanel from './GitPanel';
|
||||
import ErrorBoundary from './ErrorBoundary';
|
||||
import ClaudeLogo from './ClaudeLogo';
|
||||
import CursorLogo from './CursorLogo';
|
||||
import TaskList from './TaskList';
|
||||
import TaskDetail from './TaskDetail';
|
||||
import PRDEditor from './PRDEditor';
|
||||
import Tooltip from './Tooltip';
|
||||
import { useTaskMaster } from '../contexts/TaskMasterContext';
|
||||
import { useTasksSettings } from '../contexts/TasksSettingsContext';
|
||||
import { api } from '../utils/api';
|
||||
|
||||
function MainContent({
|
||||
selectedProject,
|
||||
selectedSession,
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
ws,
|
||||
sendMessage,
|
||||
messages,
|
||||
isMobile,
|
||||
isPWA,
|
||||
onMenuClick,
|
||||
isLoading,
|
||||
onInputFocusChange,
|
||||
// Session Protection Props: Functions passed down from App.jsx to manage active session state
|
||||
// These functions control when project updates are paused during active conversations
|
||||
onSessionActive, // Mark session as active when user sends message
|
||||
onSessionInactive, // Mark session as inactive when conversation completes/aborts
|
||||
onSessionProcessing, // Mark session as processing (thinking/working)
|
||||
onSessionNotProcessing, // Mark session as not processing (finished thinking)
|
||||
processingSessions, // Set of session IDs currently processing
|
||||
onReplaceTemporarySession, // Replace temporary session ID with real session ID from WebSocket
|
||||
onNavigateToSession, // Navigate to a specific session (for Claude CLI session duplication workaround)
|
||||
onShowSettings, // Show tools settings panel
|
||||
autoExpandTools, // Auto-expand tool accordions
|
||||
showRawParameters, // Show raw parameters in tool accordions
|
||||
showThinking, // Show thinking/reasoning sections
|
||||
autoScrollToBottom, // Auto-scroll to bottom when new messages arrive
|
||||
sendByCtrlEnter, // Send by Ctrl+Enter mode for East Asian language input
|
||||
externalMessageUpdate // Trigger for external CLI updates to current session
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [editingFile, setEditingFile] = useState(null);
|
||||
const [selectedTask, setSelectedTask] = useState(null);
|
||||
const [showTaskDetail, setShowTaskDetail] = useState(false);
|
||||
const [editorWidth, setEditorWidth] = useState(600);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const [editorExpanded, setEditorExpanded] = useState(false);
|
||||
const resizeRef = useRef(null);
|
||||
|
||||
// PRD Editor state
|
||||
const [showPRDEditor, setShowPRDEditor] = useState(false);
|
||||
const [selectedPRD, setSelectedPRD] = useState(null);
|
||||
const [existingPRDs, setExistingPRDs] = useState([]);
|
||||
const [prdNotification, setPRDNotification] = useState(null);
|
||||
|
||||
// TaskMaster context
|
||||
const { tasks, currentProject, refreshTasks, setCurrentProject } = useTaskMaster();
|
||||
const { tasksEnabled, isTaskMasterInstalled, isTaskMasterReady } = useTasksSettings();
|
||||
|
||||
// Only show tasks tab if TaskMaster is installed and enabled
|
||||
const shouldShowTasksTab = tasksEnabled && isTaskMasterInstalled;
|
||||
|
||||
// Sync selectedProject with TaskMaster context
|
||||
useEffect(() => {
|
||||
if (selectedProject && selectedProject !== currentProject) {
|
||||
setCurrentProject(selectedProject);
|
||||
}
|
||||
}, [selectedProject, currentProject, setCurrentProject]);
|
||||
|
||||
// Switch away from tasks tab when tasks are disabled or TaskMaster is not installed
|
||||
useEffect(() => {
|
||||
if (!shouldShowTasksTab && activeTab === 'tasks') {
|
||||
setActiveTab('chat');
|
||||
}
|
||||
}, [shouldShowTasksTab, activeTab, setActiveTab]);
|
||||
|
||||
// Load existing PRDs when current project changes
|
||||
useEffect(() => {
|
||||
const loadExistingPRDs = async () => {
|
||||
if (!currentProject?.name) {
|
||||
setExistingPRDs([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await api.get(`/taskmaster/prd/${encodeURIComponent(currentProject.name)}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setExistingPRDs(data.prdFiles || []);
|
||||
} else {
|
||||
setExistingPRDs([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load existing PRDs:', error);
|
||||
setExistingPRDs([]);
|
||||
}
|
||||
};
|
||||
|
||||
loadExistingPRDs();
|
||||
}, [currentProject?.name]);
|
||||
|
||||
const handleFileOpen = (filePath, diffInfo = null) => {
|
||||
// Create a file object that CodeEditor expects
|
||||
const file = {
|
||||
name: filePath.split('/').pop(),
|
||||
path: filePath,
|
||||
projectName: selectedProject?.name,
|
||||
diffInfo: diffInfo // Pass along diff information if available
|
||||
};
|
||||
setEditingFile(file);
|
||||
};
|
||||
|
||||
const handleCloseEditor = () => {
|
||||
setEditingFile(null);
|
||||
setEditorExpanded(false);
|
||||
};
|
||||
|
||||
const handleToggleEditorExpand = () => {
|
||||
setEditorExpanded(!editorExpanded);
|
||||
};
|
||||
|
||||
const handleTaskClick = (task) => {
|
||||
// If task is just an ID (from dependency click), find the full task object
|
||||
if (typeof task === 'object' && task.id && !task.title) {
|
||||
const fullTask = tasks?.find(t => t.id === task.id);
|
||||
if (fullTask) {
|
||||
setSelectedTask(fullTask);
|
||||
setShowTaskDetail(true);
|
||||
}
|
||||
} else {
|
||||
setSelectedTask(task);
|
||||
setShowTaskDetail(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTaskDetailClose = () => {
|
||||
setShowTaskDetail(false);
|
||||
setSelectedTask(null);
|
||||
};
|
||||
|
||||
const handleTaskStatusChange = (taskId, newStatus) => {
|
||||
// This would integrate with TaskMaster API to update task status
|
||||
console.log('Update task status:', taskId, newStatus);
|
||||
refreshTasks?.();
|
||||
};
|
||||
|
||||
// Handle resize functionality
|
||||
const handleMouseDown = (e) => {
|
||||
if (isMobile) return; // Disable resize on mobile
|
||||
setIsResizing(true);
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e) => {
|
||||
if (!isResizing) return;
|
||||
|
||||
const container = resizeRef.current?.parentElement;
|
||||
if (!container) return;
|
||||
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const newWidth = containerRect.right - e.clientX;
|
||||
|
||||
// Min width: 300px, Max width: 80% of container
|
||||
const minWidth = 300;
|
||||
const maxWidth = containerRect.width * 0.8;
|
||||
|
||||
if (newWidth >= minWidth && newWidth <= maxWidth) {
|
||||
setEditorWidth(newWidth);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsResizing(false);
|
||||
};
|
||||
|
||||
if (isResizing) {
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
};
|
||||
}, [isResizing]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Header with menu button for mobile */}
|
||||
{isMobile && (
|
||||
<div
|
||||
className="bg-background border-b border-border p-2 sm:p-3 pwa-header-safe flex-shrink-0"
|
||||
>
|
||||
<button
|
||||
onClick={onMenuClick}
|
||||
className="p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-700 pwa-menu-button"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center text-gray-500 dark:text-gray-400">
|
||||
<div className="w-12 h-12 mx-auto mb-4">
|
||||
<div
|
||||
className="w-full h-full rounded-full border-4 border-gray-200 border-t-blue-500"
|
||||
style={{
|
||||
animation: 'spin 1s linear infinite',
|
||||
WebkitAnimation: 'spin 1s linear infinite',
|
||||
MozAnimation: 'spin 1s linear infinite'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold mb-2">{t('mainContent.loading')}</h2>
|
||||
<p>{t('mainContent.settingUpWorkspace')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!selectedProject) {
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Header with menu button for mobile */}
|
||||
{isMobile && (
|
||||
<div
|
||||
className="bg-background border-b border-border p-2 sm:p-3 pwa-header-safe flex-shrink-0"
|
||||
>
|
||||
<button
|
||||
onClick={onMenuClick}
|
||||
className="p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-700 pwa-menu-button"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center text-gray-500 dark:text-gray-400 max-w-md mx-auto px-6">
|
||||
<div className="w-16 h-16 mx-auto mb-6 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center">
|
||||
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-5l-2-2H5a2 2 0 00-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-2xl font-semibold mb-3 text-gray-900 dark:text-white">{t('mainContent.chooseProject')}</h2>
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-6 leading-relaxed">
|
||||
{t('mainContent.selectProjectDescription')}
|
||||
</p>
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4 border border-blue-200 dark:border-blue-800">
|
||||
<p className="text-sm text-blue-700 dark:text-blue-300">
|
||||
💡 <strong>{t('mainContent.tip')}:</strong> {isMobile ? t('mainContent.createProjectMobile') : t('mainContent.createProjectDesktop')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Header with tabs */}
|
||||
<div
|
||||
className="bg-background border-b border-border p-2 sm:p-3 pwa-header-safe flex-shrink-0"
|
||||
>
|
||||
<div className="flex items-center justify-between relative">
|
||||
<div className="flex items-center space-x-2 min-w-0 flex-1">
|
||||
{isMobile && (
|
||||
<button
|
||||
onClick={onMenuClick}
|
||||
onTouchStart={(e) => {
|
||||
e.preventDefault();
|
||||
onMenuClick();
|
||||
}}
|
||||
className="p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-700 touch-manipulation active:scale-95 pwa-menu-button flex-shrink-0"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<div className="min-w-0 flex items-center gap-2 flex-1 overflow-x-auto scrollbar-hide">
|
||||
{activeTab === 'chat' && selectedSession && (
|
||||
<div className="w-5 h-5 flex-shrink-0 flex items-center justify-center">
|
||||
{selectedSession.__provider === 'cursor' ? (
|
||||
<CursorLogo className="w-4 h-4" />
|
||||
) : (
|
||||
<ClaudeLogo className="w-4 h-4" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
{activeTab === 'chat' && selectedSession ? (
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-sm sm:text-base font-semibold text-gray-900 dark:text-white whitespace-nowrap overflow-x-auto scrollbar-hide">
|
||||
{selectedSession.__provider === 'cursor' ? (selectedSession.name || 'Untitled Session') : (selectedSession.summary || 'New Session')}
|
||||
</h2>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
|
||||
{selectedProject.displayName}
|
||||
</div>
|
||||
</div>
|
||||
) : activeTab === 'chat' && !selectedSession ? (
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-sm sm:text-base font-semibold text-gray-900 dark:text-white">
|
||||
{t('mainContent.newSession')}
|
||||
</h2>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
|
||||
{selectedProject.displayName}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-sm sm:text-base font-semibold text-gray-900 dark:text-white">
|
||||
{activeTab === 'files' ? t('mainContent.projectFiles') :
|
||||
activeTab === 'git' ? t('tabs.git') :
|
||||
(activeTab === 'tasks' && shouldShowTasksTab) ? 'TaskMaster' :
|
||||
'Project'}
|
||||
</h2>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
|
||||
{selectedProject.displayName}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modern Tab Navigation - Right Side */}
|
||||
<div className="flex-shrink-0 hidden sm:block">
|
||||
<div className="relative flex bg-gray-100 dark:bg-gray-800 rounded-lg p-1">
|
||||
<Tooltip content={t('tabs.chat')} position="bottom">
|
||||
<button
|
||||
onClick={() => setActiveTab('chat')}
|
||||
className={`relative px-2 sm:px-3 py-1.5 text-xs sm:text-sm font-medium rounded-md ${
|
||||
activeTab === 'chat'
|
||||
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-1 sm:gap-1.5">
|
||||
<svg className="w-3 sm:w-3.5 h-3 sm:h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
</svg>
|
||||
<span className="hidden md:hidden lg:inline">{t('tabs.chat')}</span>
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip content={t('tabs.shell')} position="bottom">
|
||||
<button
|
||||
onClick={() => setActiveTab('shell')}
|
||||
className={`relative px-2 sm:px-3 py-1.5 text-xs sm:text-sm font-medium rounded-md transition-all duration-200 ${
|
||||
activeTab === 'shell'
|
||||
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-1 sm:gap-1.5">
|
||||
<svg className="w-3 sm:w-3.5 h-3 sm:h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span className="hidden md:hidden lg:inline">{t('tabs.shell')}</span>
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip content={t('tabs.files')} position="bottom">
|
||||
<button
|
||||
onClick={() => setActiveTab('files')}
|
||||
className={`relative px-2 sm:px-3 py-1.5 text-xs sm:text-sm font-medium rounded-md transition-all duration-200 ${
|
||||
activeTab === 'files'
|
||||
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-1 sm:gap-1.5">
|
||||
<svg className="w-3 sm:w-3.5 h-3 sm:h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-5l-2-2H5a2 2 0 00-2 2z" />
|
||||
</svg>
|
||||
<span className="hidden md:hidden lg:inline">{t('tabs.files')}</span>
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip content={t('tabs.git')} position="bottom">
|
||||
<button
|
||||
onClick={() => setActiveTab('git')}
|
||||
className={`relative px-2 sm:px-3 py-1.5 text-xs sm:text-sm font-medium rounded-md transition-all duration-200 ${
|
||||
activeTab === 'git'
|
||||
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-1 sm:gap-1.5">
|
||||
<svg className="w-3 sm:w-3.5 h-3 sm:h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
<span className="hidden md:hidden lg:inline">{t('tabs.git')}</span>
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
{shouldShowTasksTab && (
|
||||
<Tooltip content={t('tabs.tasks')} position="bottom">
|
||||
<button
|
||||
onClick={() => setActiveTab('tasks')}
|
||||
className={`relative px-2 sm:px-3 py-1.5 text-xs sm:text-sm font-medium rounded-md transition-all duration-200 ${
|
||||
activeTab === 'tasks'
|
||||
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-1 sm:gap-1.5">
|
||||
<svg className="w-3 sm:w-3.5 h-3 sm:h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
|
||||
</svg>
|
||||
<span className="hidden md:hidden lg:inline">{t('tabs.tasks')}</span>
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* <button
|
||||
onClick={() => setActiveTab('preview')}
|
||||
className={`relative px-2 sm:px-3 py-1.5 text-xs sm:text-sm font-medium rounded-md transition-all duration-200 ${
|
||||
activeTab === 'preview'
|
||||
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-1 sm:gap-1.5">
|
||||
<svg className="w-3 sm:w-3.5 h-3 sm:h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
||||
</svg>
|
||||
<span className="hidden sm:inline">Preview</span>
|
||||
</span>
|
||||
</button> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content Area with Right Sidebar */}
|
||||
<div className="flex-1 flex min-h-0 overflow-hidden">
|
||||
{/* Main Content */}
|
||||
<div className={`flex-1 flex flex-col min-h-0 overflow-hidden ${editingFile ? 'mr-0' : ''} ${editorExpanded ? 'hidden' : ''}`}>
|
||||
<div className={`h-full ${activeTab === 'chat' ? 'block' : 'hidden'}`}>
|
||||
<ErrorBoundary showDetails={true}>
|
||||
<ChatInterface
|
||||
selectedProject={selectedProject}
|
||||
selectedSession={selectedSession}
|
||||
ws={ws}
|
||||
sendMessage={sendMessage}
|
||||
messages={messages}
|
||||
onFileOpen={handleFileOpen}
|
||||
onInputFocusChange={onInputFocusChange}
|
||||
onSessionActive={onSessionActive}
|
||||
onSessionInactive={onSessionInactive}
|
||||
onSessionProcessing={onSessionProcessing}
|
||||
onSessionNotProcessing={onSessionNotProcessing}
|
||||
processingSessions={processingSessions}
|
||||
onReplaceTemporarySession={onReplaceTemporarySession}
|
||||
onNavigateToSession={onNavigateToSession}
|
||||
onShowSettings={onShowSettings}
|
||||
autoExpandTools={autoExpandTools}
|
||||
showRawParameters={showRawParameters}
|
||||
showThinking={showThinking}
|
||||
autoScrollToBottom={autoScrollToBottom}
|
||||
sendByCtrlEnter={sendByCtrlEnter}
|
||||
externalMessageUpdate={externalMessageUpdate}
|
||||
onShowAllTasks={tasksEnabled ? () => setActiveTab('tasks') : null}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
{activeTab === 'files' && (
|
||||
<div className="h-full overflow-hidden">
|
||||
<FileTree selectedProject={selectedProject} />
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'shell' && (
|
||||
<div className="h-full w-full overflow-hidden">
|
||||
<StandaloneShell
|
||||
project={selectedProject}
|
||||
session={selectedSession}
|
||||
showHeader={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'git' && (
|
||||
<div className="h-full overflow-hidden">
|
||||
<GitPanel selectedProject={selectedProject} isMobile={isMobile} onFileOpen={handleFileOpen} />
|
||||
</div>
|
||||
)}
|
||||
{shouldShowTasksTab && (
|
||||
<div className={`h-full ${activeTab === 'tasks' ? 'block' : 'hidden'}`}>
|
||||
<div className="h-full flex flex-col overflow-hidden">
|
||||
<TaskList
|
||||
tasks={tasks || []}
|
||||
onTaskClick={handleTaskClick}
|
||||
showParentTasks={true}
|
||||
className="flex-1 overflow-y-auto p-4"
|
||||
currentProject={currentProject}
|
||||
onTaskCreated={refreshTasks}
|
||||
onShowPRDEditor={(prd = null) => {
|
||||
setSelectedPRD(prd);
|
||||
setShowPRDEditor(true);
|
||||
}}
|
||||
existingPRDs={existingPRDs}
|
||||
onRefreshPRDs={(showNotification = false) => {
|
||||
// Reload existing PRDs
|
||||
if (currentProject?.name) {
|
||||
api.get(`/taskmaster/prd/${encodeURIComponent(currentProject.name)}`)
|
||||
.then(response => response.ok ? response.json() : Promise.reject())
|
||||
.then(data => {
|
||||
setExistingPRDs(data.prdFiles || []);
|
||||
if (showNotification) {
|
||||
setPRDNotification('PRD saved successfully!');
|
||||
setTimeout(() => setPRDNotification(null), 3000);
|
||||
}
|
||||
})
|
||||
.catch(error => console.error('Failed to refresh PRDs:', error));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className={`h-full overflow-hidden ${activeTab === 'preview' ? 'block' : 'hidden'}`}>
|
||||
{/* <LivePreviewPanel
|
||||
selectedProject={selectedProject}
|
||||
serverStatus={serverStatus}
|
||||
serverUrl={serverUrl}
|
||||
availableScripts={availableScripts}
|
||||
onStartServer={(script) => {
|
||||
sendMessage({
|
||||
type: 'server:start',
|
||||
projectPath: selectedProject?.fullPath,
|
||||
script: script
|
||||
});
|
||||
}}
|
||||
onStopServer={() => {
|
||||
sendMessage({
|
||||
type: 'server:stop',
|
||||
projectPath: selectedProject?.fullPath
|
||||
});
|
||||
}}
|
||||
onScriptSelect={setCurrentScript}
|
||||
currentScript={currentScript}
|
||||
isMobile={isMobile}
|
||||
serverLogs={serverLogs}
|
||||
onClearLogs={() => setServerLogs([])}
|
||||
/> */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Code Editor Right Sidebar - Desktop only, Mobile uses modal */}
|
||||
{editingFile && !isMobile && (
|
||||
<>
|
||||
{/* Resize Handle - Hidden when expanded */}
|
||||
{!editorExpanded && (
|
||||
<div
|
||||
ref={resizeRef}
|
||||
onMouseDown={handleMouseDown}
|
||||
className="flex-shrink-0 w-1 bg-gray-200 dark:bg-gray-700 hover:bg-blue-500 dark:hover:bg-blue-600 cursor-col-resize transition-colors relative group"
|
||||
title="Drag to resize"
|
||||
>
|
||||
{/* Visual indicator on hover */}
|
||||
<div className="absolute inset-y-0 left-1/2 -translate-x-1/2 w-1 bg-blue-500 dark:bg-blue-600 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Editor Sidebar */}
|
||||
<div
|
||||
className={`flex-shrink-0 border-l border-gray-200 dark:border-gray-700 h-full overflow-hidden ${editorExpanded ? 'flex-1' : ''}`}
|
||||
style={editorExpanded ? {} : { width: `${editorWidth}px` }}
|
||||
>
|
||||
<CodeEditor
|
||||
file={editingFile}
|
||||
onClose={handleCloseEditor}
|
||||
projectPath={selectedProject?.path}
|
||||
isSidebar={true}
|
||||
isExpanded={editorExpanded}
|
||||
onToggleExpand={handleToggleEditorExpand}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Code Editor Modal for Mobile */}
|
||||
{editingFile && isMobile && (
|
||||
<CodeEditor
|
||||
file={editingFile}
|
||||
onClose={handleCloseEditor}
|
||||
projectPath={selectedProject?.path}
|
||||
isSidebar={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Task Detail Modal */}
|
||||
{shouldShowTasksTab && showTaskDetail && selectedTask && (
|
||||
<TaskDetail
|
||||
task={selectedTask}
|
||||
isOpen={showTaskDetail}
|
||||
onClose={handleTaskDetailClose}
|
||||
onStatusChange={handleTaskStatusChange}
|
||||
onTaskClick={handleTaskClick}
|
||||
/>
|
||||
)}
|
||||
{/* PRD Editor Modal */}
|
||||
{showPRDEditor && (
|
||||
<PRDEditor
|
||||
project={currentProject}
|
||||
projectPath={currentProject?.fullPath || currentProject?.path}
|
||||
onClose={() => {
|
||||
setShowPRDEditor(false);
|
||||
setSelectedPRD(null);
|
||||
}}
|
||||
isNewFile={!selectedPRD?.isExisting}
|
||||
file={{
|
||||
name: selectedPRD?.name || 'prd.txt',
|
||||
content: selectedPRD?.content || ''
|
||||
}}
|
||||
onSave={async () => {
|
||||
setShowPRDEditor(false);
|
||||
setSelectedPRD(null);
|
||||
|
||||
// Reload existing PRDs with notification
|
||||
try {
|
||||
const response = await api.get(`/taskmaster/prd/${encodeURIComponent(currentProject.name)}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setExistingPRDs(data.prdFiles || []);
|
||||
setPRDNotification('PRD saved successfully!');
|
||||
setTimeout(() => setPRDNotification(null), 3000);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh PRDs:', error);
|
||||
}
|
||||
|
||||
refreshTasks?.();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* PRD Notification */}
|
||||
{prdNotification && (
|
||||
<div className="fixed bottom-4 right-4 z-50 animate-in slide-in-from-bottom-2 duration-300">
|
||||
<div className="bg-green-600 text-white px-4 py-3 rounded-lg shadow-lg flex items-center gap-3">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span className="font-medium">{prdNotification}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(MainContent);
|
||||
@@ -1,74 +1,90 @@
|
||||
import React from 'react';
|
||||
import { MessageSquare, Folder, Terminal, GitBranch, Globe, CheckSquare } from 'lucide-react';
|
||||
import { MessageSquare, Folder, Terminal, GitBranch, ClipboardCheck } from 'lucide-react';
|
||||
import { useTasksSettings } from '../contexts/TasksSettingsContext';
|
||||
import { useTaskMaster } from '../contexts/TaskMasterContext';
|
||||
|
||||
function MobileNav({ activeTab, setActiveTab, isInputFocused }) {
|
||||
const { tasksEnabled } = useTasksSettings();
|
||||
const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings();
|
||||
const shouldShowTasksTab = Boolean(tasksEnabled && isTaskMasterInstalled);
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
id: 'chat',
|
||||
icon: MessageSquare,
|
||||
label: 'Chat',
|
||||
onClick: () => setActiveTab('chat')
|
||||
},
|
||||
{
|
||||
id: 'shell',
|
||||
icon: Terminal,
|
||||
label: 'Shell',
|
||||
onClick: () => setActiveTab('shell')
|
||||
},
|
||||
{
|
||||
id: 'files',
|
||||
icon: Folder,
|
||||
label: 'Files',
|
||||
onClick: () => setActiveTab('files')
|
||||
},
|
||||
{
|
||||
id: 'git',
|
||||
icon: GitBranch,
|
||||
label: 'Git',
|
||||
onClick: () => setActiveTab('git')
|
||||
},
|
||||
// Conditionally add tasks tab if enabled
|
||||
...(tasksEnabled ? [{
|
||||
...(shouldShowTasksTab ? [{
|
||||
id: 'tasks',
|
||||
icon: CheckSquare,
|
||||
icon: ClipboardCheck,
|
||||
label: 'Tasks',
|
||||
onClick: () => setActiveTab('tasks')
|
||||
}] : [])
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed bottom-0 left-0 right-0 bg-background border-t border-border z-50 ios-bottom-safe transform transition-transform duration-300 ease-in-out shadow-lg ${
|
||||
className={`fixed bottom-0 left-0 right-0 z-50 px-3 pb-[max(8px,env(safe-area-inset-bottom))] transform transition-transform duration-300 ease-in-out ${
|
||||
isInputFocused ? 'translate-y-full' : 'translate-y-0'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-around py-1">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = activeTab === item.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={item.onClick}
|
||||
onTouchStart={(e) => {
|
||||
e.preventDefault();
|
||||
item.onClick();
|
||||
}}
|
||||
className={`flex items-center justify-center p-2 rounded-lg min-h-[40px] min-w-[40px] relative touch-manipulation ${
|
||||
isActive
|
||||
? 'text-blue-600 dark:text-blue-400'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
|
||||
}`}
|
||||
aria-label={item.id}
|
||||
>
|
||||
<Icon className="w-5 h-5" />
|
||||
{isActive && (
|
||||
<div className="absolute top-0 left-1/2 transform -translate-x-1/2 w-6 h-0.5 bg-blue-600 dark:bg-blue-400 rounded-full" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<div className="nav-glass mobile-nav-float rounded-2xl border border-border/30">
|
||||
<div className="flex items-center justify-around px-1 py-1.5 gap-0.5">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = activeTab === item.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={item.onClick}
|
||||
onTouchStart={(e) => {
|
||||
e.preventDefault();
|
||||
item.onClick();
|
||||
}}
|
||||
className={`flex flex-col items-center justify-center gap-0.5 px-3 py-2 rounded-xl flex-1 relative touch-manipulation transition-all duration-200 active:scale-95 ${
|
||||
isActive
|
||||
? 'text-primary'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
aria-label={item.label}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
>
|
||||
{isActive && (
|
||||
<div className="absolute inset-0 bg-primary/8 dark:bg-primary/12 rounded-xl" />
|
||||
)}
|
||||
<Icon
|
||||
className={`relative z-10 transition-all duration-200 ${isActive ? 'w-5 h-5' : 'w-[18px] h-[18px]'}`}
|
||||
strokeWidth={isActive ? 2.4 : 1.8}
|
||||
/>
|
||||
<span className={`relative z-10 text-[10px] font-medium transition-all duration-200 ${isActive ? 'opacity-100' : 'opacity-60'}`}>
|
||||
{item.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MobileNav;
|
||||
export default MobileNav;
|
||||
|
||||
@@ -6,6 +6,7 @@ import CodexLogo from './CodexLogo';
|
||||
import LoginModal from './LoginModal';
|
||||
import { authenticatedFetch } from '../utils/api';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { IS_PLATFORM } from '../constants/config';
|
||||
|
||||
const Onboarding = ({ onComplete }) => {
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
@@ -15,7 +16,7 @@ const Onboarding = ({ onComplete }) => {
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const [activeLoginProvider, setActiveLoginProvider] = useState(null);
|
||||
const [selectedProject] = useState({ name: 'default', fullPath: '' });
|
||||
const [selectedProject] = useState({ name: 'default', fullPath: IS_PLATFORM ? '/workspace' : '' });
|
||||
|
||||
const [claudeAuthStatus, setClaudeAuthStatus] = useState({
|
||||
authenticated: false,
|
||||
@@ -576,6 +577,7 @@ const Onboarding = ({ onComplete }) => {
|
||||
provider={activeLoginProvider}
|
||||
project={selectedProject}
|
||||
onComplete={handleLoginComplete}
|
||||
isOnboarding={true}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, FolderPlus, GitBranch, Key, ChevronRight, ChevronLeft, Check, Loader2, AlertCircle, FolderOpen, Eye, EyeOff } from 'lucide-react';
|
||||
import { X, FolderPlus, GitBranch, Key, ChevronRight, ChevronLeft, Check, Loader2, AlertCircle, FolderOpen, Eye, EyeOff, Plus } from 'lucide-react';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
import { api } from '../utils/api';
|
||||
@@ -30,6 +30,10 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
|
||||
const [browserFolders, setBrowserFolders] = useState([]);
|
||||
const [loadingFolders, setLoadingFolders] = useState(false);
|
||||
const [showHiddenFolders, setShowHiddenFolders] = useState(false);
|
||||
const [showNewFolderInput, setShowNewFolderInput] = useState(false);
|
||||
const [newFolderName, setNewFolderName] = useState('');
|
||||
const [creatingFolder, setCreatingFolder] = useState(false);
|
||||
const [cloneProgress, setCloneProgress] = useState('');
|
||||
|
||||
// Load available GitHub tokens when needed
|
||||
useEffect(() => {
|
||||
@@ -78,9 +82,10 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
|
||||
const data = await response.json();
|
||||
|
||||
if (data.suggestions) {
|
||||
// Filter suggestions based on the input
|
||||
// Filter suggestions based on the input, excluding exact match
|
||||
const filtered = data.suggestions.filter(s =>
|
||||
s.path.toLowerCase().startsWith(inputPath.toLowerCase())
|
||||
s.path.toLowerCase().startsWith(inputPath.toLowerCase()) &&
|
||||
s.path.toLowerCase() !== inputPath.toLowerCase()
|
||||
);
|
||||
setPathSuggestions(filtered.slice(0, 5));
|
||||
setShowPathDropdown(filtered.length > 0);
|
||||
@@ -118,32 +123,69 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
|
||||
const handleCreate = async () => {
|
||||
setIsCreating(true);
|
||||
setError(null);
|
||||
setCloneProgress('');
|
||||
|
||||
try {
|
||||
if (workspaceType === 'new' && githubUrl) {
|
||||
const params = new URLSearchParams({
|
||||
path: workspacePath.trim(),
|
||||
githubUrl: githubUrl.trim(),
|
||||
});
|
||||
|
||||
if (tokenMode === 'stored' && selectedGithubToken) {
|
||||
params.append('githubTokenId', selectedGithubToken);
|
||||
} else if (tokenMode === 'new' && newGithubToken) {
|
||||
params.append('newGithubToken', newGithubToken.trim());
|
||||
}
|
||||
|
||||
const token = localStorage.getItem('auth-token');
|
||||
const url = `/api/projects/clone-progress?${params}${token ? `&token=${token}` : ''}`;
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
const eventSource = new EventSource(url);
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
if (data.type === 'progress') {
|
||||
setCloneProgress(data.message);
|
||||
} else if (data.type === 'complete') {
|
||||
eventSource.close();
|
||||
if (onProjectCreated) {
|
||||
onProjectCreated(data.project);
|
||||
}
|
||||
onClose();
|
||||
resolve();
|
||||
} else if (data.type === 'error') {
|
||||
eventSource.close();
|
||||
reject(new Error(data.message));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error parsing SSE event:', e);
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = () => {
|
||||
eventSource.close();
|
||||
reject(new Error('Connection lost during clone'));
|
||||
};
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
workspaceType,
|
||||
path: workspacePath.trim(),
|
||||
};
|
||||
|
||||
// Add GitHub info if creating new workspace with GitHub URL
|
||||
if (workspaceType === 'new' && githubUrl) {
|
||||
payload.githubUrl = githubUrl.trim();
|
||||
|
||||
if (tokenMode === 'stored' && selectedGithubToken) {
|
||||
payload.githubTokenId = parseInt(selectedGithubToken);
|
||||
} else if (tokenMode === 'new' && newGithubToken) {
|
||||
payload.newGithubToken = newGithubToken.trim();
|
||||
}
|
||||
}
|
||||
|
||||
const response = await api.createWorkspace(payload);
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || t('projectWizard.errors.failedToCreate'));
|
||||
throw new Error(data.details || data.error || t('projectWizard.errors.failedToCreate'));
|
||||
}
|
||||
|
||||
// Success!
|
||||
if (onProjectCreated) {
|
||||
onProjectCreated(data.project);
|
||||
}
|
||||
@@ -170,9 +212,9 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
|
||||
const loadBrowserFolders = async (path) => {
|
||||
try {
|
||||
setLoadingFolders(true);
|
||||
setBrowserCurrentPath(path);
|
||||
const response = await api.browseFilesystem(path);
|
||||
const data = await response.json();
|
||||
setBrowserCurrentPath(data.path || path);
|
||||
setBrowserFolders(data.suggestions || []);
|
||||
} catch (error) {
|
||||
console.error('Error loading folders:', error);
|
||||
@@ -193,6 +235,29 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
|
||||
await loadBrowserFolders(folderPath);
|
||||
};
|
||||
|
||||
const createNewFolder = async () => {
|
||||
if (!newFolderName.trim()) return;
|
||||
setCreatingFolder(true);
|
||||
setError(null);
|
||||
try {
|
||||
const separator = browserCurrentPath.includes('\\') ? '\\' : '/';
|
||||
const folderPath = `${browserCurrentPath}${separator}${newFolderName.trim()}`;
|
||||
const response = await api.createFolder(folderPath);
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || t('projectWizard.errors.failedToCreateFolder', 'Failed to create folder'));
|
||||
}
|
||||
setNewFolderName('');
|
||||
setShowNewFolderInput(false);
|
||||
await loadBrowserFolders(data.path || folderPath);
|
||||
} catch (error) {
|
||||
console.error('Error creating folder:', error);
|
||||
setError(error.message || t('projectWizard.errors.failedToCreateFolder', 'Failed to create folder'));
|
||||
} finally {
|
||||
setCreatingFolder(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed top-0 left-0 right-0 bottom-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-[60] p-0 sm:p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-none sm:rounded-lg shadow-xl w-full h-full sm:h-auto sm:max-w-2xl border-0 sm:border border-gray-200 dark:border-gray-700 overflow-y-auto">
|
||||
@@ -388,8 +453,8 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* GitHub Token (only if GitHub URL is provided) */}
|
||||
{githubUrl && (
|
||||
{/* GitHub Token (only for HTTPS URLs - SSH uses SSH keys) */}
|
||||
{githubUrl && !githubUrl.startsWith('git@') && !githubUrl.startsWith('ssh://') && (
|
||||
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-start gap-3 mb-4">
|
||||
<Key className="w-5 h-5 text-gray-600 dark:text-gray-400 flex-shrink-0 mt-0.5" />
|
||||
@@ -551,6 +616,8 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
|
||||
? `${t('projectWizard.step3.usingStoredToken')} ${availableTokens.find(t => t.id.toString() === selectedGithubToken)?.credential_name || 'Unknown'}`
|
||||
: tokenMode === 'new' && newGithubToken
|
||||
? t('projectWizard.step3.usingProvidedToken')
|
||||
: (githubUrl.startsWith('git@') || githubUrl.startsWith('ssh://'))
|
||||
? t('projectWizard.step3.sshKey', 'SSH Key')
|
||||
: t('projectWizard.step3.noAuthentication')}
|
||||
</span>
|
||||
</div>
|
||||
@@ -560,13 +627,22 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4 border border-blue-200 dark:border-blue-800">
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||
{workspaceType === 'existing'
|
||||
? t('projectWizard.step3.existingInfo')
|
||||
: githubUrl
|
||||
? t('projectWizard.step3.newWithClone')
|
||||
: t('projectWizard.step3.newEmpty')}
|
||||
</p>
|
||||
{isCreating && cloneProgress ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-blue-800 dark:text-blue-200">{t('projectWizard.step3.cloningRepository', 'Cloning repository...')}</p>
|
||||
<code className="block text-xs font-mono text-blue-700 dark:text-blue-300 whitespace-pre-wrap break-all">
|
||||
{cloneProgress}
|
||||
</code>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||
{workspaceType === 'existing'
|
||||
? t('projectWizard.step3.existingInfo')
|
||||
: githubUrl
|
||||
? t('projectWizard.step3.newWithClone')
|
||||
: t('projectWizard.step3.newEmpty')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -596,7 +672,7 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
|
||||
{isCreating ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
{t('projectWizard.buttons.creating')}
|
||||
{githubUrl ? t('projectWizard.buttons.cloning', 'Cloning...') : t('projectWizard.buttons.creating')}
|
||||
</>
|
||||
) : step === 3 ? (
|
||||
<>
|
||||
@@ -639,6 +715,17 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
|
||||
>
|
||||
{showHiddenFolders ? <Eye className="w-5 h-5" /> : <EyeOff className="w-5 h-5" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowNewFolderInput(!showNewFolderInput)}
|
||||
className={`p-2 rounded-md transition-colors ${
|
||||
showNewFolderInput
|
||||
? 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/30'
|
||||
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
title="Create new folder"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowFolderBrowser(false)}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
@@ -648,23 +735,67 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* New Folder Input */}
|
||||
{showNewFolderInput && (
|
||||
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700 bg-blue-50 dark:bg-blue-900/20">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
value={newFolderName}
|
||||
onChange={(e) => setNewFolderName(e.target.value)}
|
||||
placeholder="New folder name"
|
||||
className="flex-1"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') createNewFolder();
|
||||
if (e.key === 'Escape') {
|
||||
setShowNewFolderInput(false);
|
||||
setNewFolderName('');
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={createNewFolder}
|
||||
disabled={!newFolderName.trim() || creatingFolder}
|
||||
>
|
||||
{creatingFolder ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Create'}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setShowNewFolderInput(false);
|
||||
setNewFolderName('');
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Folder List */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{loadingFolders ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-gray-400" />
|
||||
</div>
|
||||
) : 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 !== '/' && (
|
||||
{/* Parent Directory - check for Windows root (e.g., C:\) and Unix root */}
|
||||
{browserCurrentPath !== '~' && browserCurrentPath !== '/' && !/^[A-Za-z]:\\?$/.test(browserCurrentPath) && (
|
||||
<button
|
||||
onClick={() => {
|
||||
const parentPath = browserCurrentPath.substring(0, browserCurrentPath.lastIndexOf('/')) || '/';
|
||||
const lastSlash = Math.max(browserCurrentPath.lastIndexOf('/'), browserCurrentPath.lastIndexOf('\\'));
|
||||
let parentPath;
|
||||
if (lastSlash <= 0) {
|
||||
parentPath = '/';
|
||||
} else if (lastSlash === 2 && /^[A-Za-z]:/.test(browserCurrentPath)) {
|
||||
parentPath = browserCurrentPath.substring(0, 3);
|
||||
} else {
|
||||
parentPath = browserCurrentPath.substring(0, lastSlash);
|
||||
}
|
||||
navigateToFolder(parentPath);
|
||||
}}
|
||||
className="w-full px-4 py-3 text-left hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg flex items-center gap-3"
|
||||
@@ -675,28 +806,34 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
{browserFolders.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
No subfolders found
|
||||
</div>
|
||||
))}
|
||||
) : (
|
||||
browserFolders
|
||||
.filter(folder => showHiddenFolders || !folder.name.startsWith('.'))
|
||||
.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()))
|
||||
.map((folder, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => navigateToFolder(folder.path)}
|
||||
className="flex-1 px-4 py-3 text-left hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg flex items-center gap-3"
|
||||
>
|
||||
<FolderPlus className="w-5 h-5 text-blue-500" />
|
||||
<span className="font-medium text-gray-900 dark:text-white">{folder.name}</span>
|
||||
</button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => selectFolder(folder.path, workspaceType === 'existing')}
|
||||
className="text-xs px-3"
|
||||
>
|
||||
Select
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -712,13 +849,17 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
|
||||
<div className="flex items-center justify-end gap-2 p-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowFolderBrowser(false)}
|
||||
onClick={() => {
|
||||
setShowFolderBrowser(false);
|
||||
setShowNewFolderInput(false);
|
||||
setNewFolderName('');
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => selectFolder(browserCurrentPath, true)}
|
||||
onClick={() => selectFolder(browserCurrentPath, workspaceType === 'existing')}
|
||||
>
|
||||
Use this folder
|
||||
</Button>
|
||||
|
||||
@@ -4,6 +4,7 @@ import SetupForm from './SetupForm';
|
||||
import LoginForm from './LoginForm';
|
||||
import Onboarding from './Onboarding';
|
||||
import { MessageSquare } from 'lucide-react';
|
||||
import { IS_PLATFORM } from '../constants/config';
|
||||
|
||||
const LoadingScreen = () => (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center p-4">
|
||||
@@ -27,7 +28,7 @@ const LoadingScreen = () => (
|
||||
const ProtectedRoute = ({ children }) => {
|
||||
const { user, isLoading, needsSetup, hasCompletedOnboarding, refreshOnboardingStatus } = useAuth();
|
||||
|
||||
if (import.meta.env.VITE_IS_PLATFORM === 'true') {
|
||||
if (IS_PLATFORM) {
|
||||
if (isLoading) {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
@@ -17,31 +17,27 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import DarkModeToggle from './DarkModeToggle';
|
||||
|
||||
import { useUiPreferences } from '../hooks/useUiPreferences';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
import LanguageSelector from './LanguageSelector';
|
||||
|
||||
const QuickSettingsPanel = ({
|
||||
isOpen,
|
||||
onToggle,
|
||||
autoExpandTools,
|
||||
onAutoExpandChange,
|
||||
showRawParameters,
|
||||
onShowRawParametersChange,
|
||||
showThinking,
|
||||
onShowThinkingChange,
|
||||
autoScrollToBottom,
|
||||
onAutoScrollChange,
|
||||
sendByCtrlEnter,
|
||||
onSendByCtrlEnterChange,
|
||||
isMobile
|
||||
}) => {
|
||||
import { useDeviceSettings } from '../hooks/useDeviceSettings';
|
||||
|
||||
|
||||
const QuickSettingsPanel = () => {
|
||||
const { t } = useTranslation('settings');
|
||||
const [localIsOpen, setLocalIsOpen] = useState(isOpen);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [whisperMode, setWhisperMode] = useState(() => {
|
||||
return localStorage.getItem('whisperMode') || 'default';
|
||||
});
|
||||
const { isDarkMode } = useTheme();
|
||||
|
||||
const { isMobile } = useDeviceSettings({ trackPWA: false });
|
||||
|
||||
const { preferences, setPreference } = useUiPreferences();
|
||||
const { autoExpandTools, showRawParameters, showThinking, autoScrollToBottom, sendByCtrlEnter } = preferences;
|
||||
|
||||
// Draggable handle state
|
||||
const [handlePosition, setHandlePosition] = useState(() => {
|
||||
const saved = localStorage.getItem('quickSettingsHandlePosition');
|
||||
@@ -66,10 +62,6 @@ const QuickSettingsPanel = ({
|
||||
const constraintsRef = useRef({ min: 10, max: 90 }); // Percentage constraints
|
||||
const dragThreshold = 5; // Pixels to move before it's considered a drag
|
||||
|
||||
useEffect(() => {
|
||||
setLocalIsOpen(isOpen);
|
||||
}, [isOpen]);
|
||||
|
||||
// Save handle position to localStorage when it changes
|
||||
useEffect(() => {
|
||||
localStorage.setItem('quickSettingsHandlePosition', JSON.stringify({ y: handlePosition }));
|
||||
@@ -206,9 +198,7 @@ const QuickSettingsPanel = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const newState = !localIsOpen;
|
||||
setLocalIsOpen(newState);
|
||||
onToggle(newState);
|
||||
setIsOpen((previous) => !previous);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -226,19 +216,19 @@ const QuickSettingsPanel = ({
|
||||
handleDragStart(e);
|
||||
}}
|
||||
className={`fixed ${
|
||||
localIsOpen ? 'right-64' : 'right-0'
|
||||
isOpen ? 'right-64' : 'right-0'
|
||||
} z-50 ${isDragging ? '' : 'transition-all duration-150 ease-out'} bg-white dark:bg-gray-800 border ${
|
||||
isDragging ? 'border-blue-500 dark:border-blue-400' : 'border-gray-200 dark:border-gray-700'
|
||||
} rounded-l-md p-2 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors shadow-lg ${
|
||||
isDragging ? 'cursor-grabbing' : 'cursor-pointer'
|
||||
} touch-none`}
|
||||
style={{ ...getPositionStyle(), touchAction: 'none', WebkitTouchCallout: 'none', WebkitUserSelect: 'none' }}
|
||||
aria-label={isDragging ? t('quickSettings.dragHandle.dragging') : localIsOpen ? t('quickSettings.dragHandle.closePanel') : t('quickSettings.dragHandle.openPanel')}
|
||||
aria-label={isDragging ? t('quickSettings.dragHandle.dragging') : isOpen ? t('quickSettings.dragHandle.closePanel') : t('quickSettings.dragHandle.openPanel')}
|
||||
title={isDragging ? t('quickSettings.dragHandle.draggingStatus') : t('quickSettings.dragHandle.toggleAndMove')}
|
||||
>
|
||||
{isDragging ? (
|
||||
<GripVertical className="h-5 w-5 text-blue-500 dark:text-blue-400" />
|
||||
) : localIsOpen ? (
|
||||
) : isOpen ? (
|
||||
<ChevronRight className="h-5 w-5 text-gray-600 dark:text-gray-400" />
|
||||
) : (
|
||||
<ChevronLeft className="h-5 w-5 text-gray-600 dark:text-gray-400" />
|
||||
@@ -248,7 +238,7 @@ const QuickSettingsPanel = ({
|
||||
{/* Panel */}
|
||||
<div
|
||||
className={`fixed top-0 right-0 h-full w-64 bg-background border-l border-border shadow-xl transform transition-transform duration-150 ease-out z-40 ${
|
||||
localIsOpen ? 'translate-x-0' : 'translate-x-full'
|
||||
isOpen ? 'translate-x-0' : 'translate-x-full'
|
||||
} ${isMobile ? 'h-screen' : ''}`}
|
||||
>
|
||||
<div className="h-full flex flex-col">
|
||||
@@ -292,7 +282,7 @@ const QuickSettingsPanel = ({
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoExpandTools}
|
||||
onChange={(e) => onAutoExpandChange(e.target.checked)}
|
||||
onChange={(e) => setPreference('autoExpandTools', e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 focus:ring-2 dark:focus:ring-blue-400 bg-gray-100 dark:bg-gray-800 checked:bg-blue-600 dark:checked:bg-blue-600"
|
||||
/>
|
||||
</label>
|
||||
@@ -305,7 +295,7 @@ const QuickSettingsPanel = ({
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showRawParameters}
|
||||
onChange={(e) => onShowRawParametersChange(e.target.checked)}
|
||||
onChange={(e) => setPreference('showRawParameters', e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 focus:ring-2 dark:focus:ring-blue-400 bg-gray-100 dark:bg-gray-800 checked:bg-blue-600 dark:checked:bg-blue-600"
|
||||
/>
|
||||
</label>
|
||||
@@ -318,7 +308,7 @@ const QuickSettingsPanel = ({
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showThinking}
|
||||
onChange={(e) => onShowThinkingChange(e.target.checked)}
|
||||
onChange={(e) => setPreference('showThinking', e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 focus:ring-2 dark:focus:ring-blue-400 bg-gray-100 dark:bg-gray-800 checked:bg-blue-600 dark:checked:bg-blue-600"
|
||||
/>
|
||||
</label>
|
||||
@@ -335,7 +325,7 @@ const QuickSettingsPanel = ({
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoScrollToBottom}
|
||||
onChange={(e) => onAutoScrollChange(e.target.checked)}
|
||||
onChange={(e) => setPreference('autoScrollToBottom', e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 focus:ring-2 dark:focus:ring-blue-400 bg-gray-100 dark:bg-gray-800 checked:bg-blue-600 dark:checked:bg-blue-600"
|
||||
/>
|
||||
</label>
|
||||
@@ -353,7 +343,7 @@ const QuickSettingsPanel = ({
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={sendByCtrlEnter}
|
||||
onChange={(e) => onSendByCtrlEnterChange(e.target.checked)}
|
||||
onChange={(e) => setPreference('sendByCtrlEnter', e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 focus:ring-2 dark:focus:ring-blue-400 bg-gray-100 dark:bg-gray-800 checked:bg-blue-600 dark:checked:bg-blue-600"
|
||||
/>
|
||||
</label>
|
||||
@@ -445,7 +435,7 @@ const QuickSettingsPanel = ({
|
||||
</div>
|
||||
|
||||
{/* Backdrop */}
|
||||
{localIsOpen && (
|
||||
{isOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-background/80 backdrop-blur-sm z-30 transition-opacity duration-150 ease-out"
|
||||
onClick={handleToggle}
|
||||
|
||||
24
src/components/SessionProviderLogo.tsx
Normal file
24
src/components/SessionProviderLogo.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { SessionProvider } from '../types/app';
|
||||
import ClaudeLogo from './ClaudeLogo';
|
||||
import CodexLogo from './CodexLogo';
|
||||
import CursorLogo from './CursorLogo';
|
||||
|
||||
type SessionProviderLogoProps = {
|
||||
provider?: SessionProvider | string | null;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function SessionProviderLogo({
|
||||
provider = 'claude',
|
||||
className = 'w-5 h-5',
|
||||
}: SessionProviderLogoProps) {
|
||||
if (provider === 'cursor') {
|
||||
return <CursorLogo className={className} />;
|
||||
}
|
||||
|
||||
if (provider === 'codex') {
|
||||
return <CodexLogo className={className} />;
|
||||
}
|
||||
|
||||
return <ClaudeLogo className={className} />;
|
||||
}
|
||||
@@ -5,9 +5,6 @@ import { Badge } from './ui/badge';
|
||||
import { X, Plus, Settings as SettingsIcon, Shield, AlertTriangle, Moon, Sun, Server, Edit3, Trash2, Globe, Terminal, Zap, FolderOpen, LogIn, Key, GitBranch, Check } from 'lucide-react';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import ClaudeLogo from './ClaudeLogo';
|
||||
import CursorLogo from './CursorLogo';
|
||||
import CodexLogo from './CodexLogo';
|
||||
import CredentialsSettings from './CredentialsSettings';
|
||||
import GitSettings from './GitSettings';
|
||||
import TasksSettings from './TasksSettings';
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import ClaudeLogo from './ClaudeLogo';
|
||||
|
||||
const SetupForm = () => {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
@@ -48,7 +46,7 @@ const SetupForm = () => {
|
||||
{/* Logo and Title */}
|
||||
<div className="text-center">
|
||||
<div className="flex justify-center mb-4">
|
||||
<ClaudeLogo size={64} />
|
||||
<img src="/logo.svg" alt="CloudCLI" className="w-16 h-16" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Welcome to Claude Code UI</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
|
||||
@@ -5,6 +5,7 @@ import { WebglAddon } from '@xterm/addon-webgl';
|
||||
import { WebLinksAddon } from '@xterm/addon-web-links';
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IS_PLATFORM } from '../constants/config';
|
||||
|
||||
const xtermStyles = `
|
||||
.xterm .xterm-screen {
|
||||
@@ -25,6 +26,37 @@ if (typeof document !== 'undefined') {
|
||||
document.head.appendChild(styleSheet);
|
||||
}
|
||||
|
||||
function fallbackCopyToClipboard(text) {
|
||||
if (!text || typeof document === 'undefined') return false;
|
||||
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.setAttribute('readonly', '');
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.opacity = '0';
|
||||
textarea.style.pointerEvents = 'none';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.focus();
|
||||
textarea.select();
|
||||
|
||||
let copied = false;
|
||||
try {
|
||||
copied = document.execCommand('copy');
|
||||
} catch {
|
||||
copied = false;
|
||||
} finally {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
|
||||
return copied;
|
||||
}
|
||||
|
||||
const CODEX_DEVICE_AUTH_URL = 'https://auth.openai.com/codex/device';
|
||||
|
||||
function isCodexLoginCommand(command) {
|
||||
return typeof command === 'string' && /\bcodex\s+login\b/i.test(command);
|
||||
}
|
||||
|
||||
function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell = false, onProcessComplete, minimal = false, autoConnect = false }) {
|
||||
const { t } = useTranslation('chat');
|
||||
const terminalRef = useRef(null);
|
||||
@@ -36,12 +68,16 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
|
||||
const [isRestarting, setIsRestarting] = useState(false);
|
||||
const [lastSessionId, setLastSessionId] = useState(null);
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const [authUrl, setAuthUrl] = useState('');
|
||||
const [authUrlCopyStatus, setAuthUrlCopyStatus] = useState('idle');
|
||||
const [isAuthPanelHidden, setIsAuthPanelHidden] = useState(false);
|
||||
|
||||
const selectedProjectRef = useRef(selectedProject);
|
||||
const selectedSessionRef = useRef(selectedSession);
|
||||
const initialCommandRef = useRef(initialCommand);
|
||||
const isPlainShellRef = useRef(isPlainShell);
|
||||
const onProcessCompleteRef = useRef(onProcessComplete);
|
||||
const authUrlRef = useRef('');
|
||||
|
||||
useEffect(() => {
|
||||
selectedProjectRef.current = selectedProject;
|
||||
@@ -51,14 +87,49 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
|
||||
onProcessCompleteRef.current = onProcessComplete;
|
||||
});
|
||||
|
||||
const openAuthUrlInBrowser = useCallback((url = authUrlRef.current) => {
|
||||
if (!url) return false;
|
||||
|
||||
const popup = window.open(url, '_blank', 'noopener,noreferrer');
|
||||
if (popup) {
|
||||
try {
|
||||
popup.opener = null;
|
||||
} catch {
|
||||
// Ignore cross-origin restrictions when trying to null opener
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}, []);
|
||||
|
||||
const copyAuthUrlToClipboard = useCallback(async (url = authUrlRef.current) => {
|
||||
if (!url) return false;
|
||||
|
||||
let copied = false;
|
||||
try {
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(url);
|
||||
copied = true;
|
||||
}
|
||||
} catch {
|
||||
copied = false;
|
||||
}
|
||||
|
||||
if (!copied) {
|
||||
copied = fallbackCopyToClipboard(url);
|
||||
}
|
||||
|
||||
return copied;
|
||||
}, []);
|
||||
|
||||
const connectWebSocket = useCallback(async () => {
|
||||
if (isConnecting || isConnected) return;
|
||||
|
||||
try {
|
||||
const isPlatform = import.meta.env.VITE_IS_PLATFORM === 'true';
|
||||
let wsUrl;
|
||||
|
||||
if (isPlatform) {
|
||||
if (IS_PLATFORM) {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
wsUrl = `${protocol}//${window.location.host}/shell`;
|
||||
} else {
|
||||
@@ -77,6 +148,10 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
|
||||
ws.current.onopen = () => {
|
||||
setIsConnected(true);
|
||||
setIsConnecting(false);
|
||||
authUrlRef.current = '';
|
||||
setAuthUrl('');
|
||||
setAuthUrlCopyStatus('idle');
|
||||
setIsAuthPanelHidden(false);
|
||||
|
||||
setTimeout(() => {
|
||||
if (fitAddon.current && terminal.current) {
|
||||
@@ -87,7 +162,7 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
|
||||
projectPath: selectedProjectRef.current.fullPath || selectedProjectRef.current.path,
|
||||
sessionId: isPlainShellRef.current ? null : selectedSessionRef.current?.id,
|
||||
hasSession: isPlainShellRef.current ? false : !!selectedSessionRef.current,
|
||||
provider: isPlainShellRef.current ? 'plain-shell' : (selectedSessionRef.current?.__provider || 'claude'),
|
||||
provider: isPlainShellRef.current ? 'plain-shell' : (selectedSessionRef.current?.__provider || localStorage.getItem('selected-provider') || 'claude'),
|
||||
cols: terminal.current.cols,
|
||||
rows: terminal.current.rows,
|
||||
initialCommand: initialCommandRef.current,
|
||||
@@ -119,8 +194,18 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
|
||||
if (terminal.current) {
|
||||
terminal.current.write(output);
|
||||
}
|
||||
} else if (data.type === 'auth_url' && data.url) {
|
||||
authUrlRef.current = data.url;
|
||||
setAuthUrl(data.url);
|
||||
setAuthUrlCopyStatus('idle');
|
||||
setIsAuthPanelHidden(false);
|
||||
} else if (data.type === 'url_open') {
|
||||
window.open(data.url, '_blank');
|
||||
if (data.url) {
|
||||
authUrlRef.current = data.url;
|
||||
setAuthUrl(data.url);
|
||||
setAuthUrlCopyStatus('idle');
|
||||
setIsAuthPanelHidden(false);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Shell] Error handling WebSocket message:', error, event.data);
|
||||
@@ -130,6 +215,8 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
|
||||
ws.current.onclose = (event) => {
|
||||
setIsConnected(false);
|
||||
setIsConnecting(false);
|
||||
setAuthUrlCopyStatus('idle');
|
||||
setIsAuthPanelHidden(false);
|
||||
|
||||
if (terminal.current) {
|
||||
terminal.current.clear();
|
||||
@@ -145,7 +232,7 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
|
||||
setIsConnected(false);
|
||||
setIsConnecting(false);
|
||||
}
|
||||
}, [isConnecting, isConnected]);
|
||||
}, [isConnecting, isConnected, openAuthUrlInBrowser]);
|
||||
|
||||
const connectToShell = useCallback(() => {
|
||||
if (!isInitialized || isConnected || isConnecting) return;
|
||||
@@ -166,6 +253,10 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
|
||||
|
||||
setIsConnected(false);
|
||||
setIsConnecting(false);
|
||||
authUrlRef.current = '';
|
||||
setAuthUrl('');
|
||||
setAuthUrlCopyStatus('idle');
|
||||
setIsAuthPanelHidden(false);
|
||||
}, []);
|
||||
|
||||
const sessionDisplayName = useMemo(() => {
|
||||
@@ -201,6 +292,10 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
|
||||
|
||||
setIsConnected(false);
|
||||
setIsInitialized(false);
|
||||
authUrlRef.current = '';
|
||||
setAuthUrl('');
|
||||
setAuthUrlCopyStatus('idle');
|
||||
setIsAuthPanelHidden(false);
|
||||
|
||||
setTimeout(() => {
|
||||
setIsRestarting(false);
|
||||
@@ -234,7 +329,7 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
|
||||
tabStopWidth: 4,
|
||||
windowsMode: false,
|
||||
macOptionIsMeta: true,
|
||||
macOptionClickForcesSelection: false,
|
||||
macOptionClickForcesSelection: true,
|
||||
theme: {
|
||||
background: '#1e1e1e',
|
||||
foreground: '#d4d4d4',
|
||||
@@ -272,7 +367,10 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
|
||||
const webLinksAddon = new WebLinksAddon();
|
||||
|
||||
terminal.current.loadAddon(fitAddon.current);
|
||||
terminal.current.loadAddon(webLinksAddon);
|
||||
// Disable xterm link auto-detection in minimal (login) mode to avoid partial wrapped URL links.
|
||||
if (!minimal) {
|
||||
terminal.current.loadAddon(webLinksAddon);
|
||||
}
|
||||
// Note: ClipboardAddon removed - we handle clipboard operations manually in attachCustomKeyEventHandler
|
||||
|
||||
try {
|
||||
@@ -284,12 +382,45 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
|
||||
terminal.current.open(terminalRef.current);
|
||||
|
||||
terminal.current.attachCustomKeyEventHandler((event) => {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === 'c' && terminal.current.hasSelection()) {
|
||||
const activeAuthUrl = isCodexLoginCommand(initialCommandRef.current)
|
||||
? CODEX_DEVICE_AUTH_URL
|
||||
: authUrlRef.current;
|
||||
|
||||
if (
|
||||
event.type === 'keydown' &&
|
||||
minimal &&
|
||||
isPlainShellRef.current &&
|
||||
activeAuthUrl &&
|
||||
!event.ctrlKey &&
|
||||
!event.metaKey &&
|
||||
!event.altKey &&
|
||||
event.key?.toLowerCase() === 'c'
|
||||
) {
|
||||
copyAuthUrlToClipboard(activeAuthUrl).catch(() => {});
|
||||
}
|
||||
|
||||
if (
|
||||
event.type === 'keydown' &&
|
||||
(event.ctrlKey || event.metaKey) &&
|
||||
event.key?.toLowerCase() === 'c' &&
|
||||
terminal.current.hasSelection()
|
||||
) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
document.execCommand('copy');
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === 'v') {
|
||||
if (
|
||||
event.type === 'keydown' &&
|
||||
(event.ctrlKey || event.metaKey) &&
|
||||
event.key?.toLowerCase() === 'v'
|
||||
) {
|
||||
// Block native browser/xterm paste so clipboard data is only sent after
|
||||
// the explicit clipboard-read flow resolves (avoids duplicate pastes).
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
navigator.clipboard.readText().then(text => {
|
||||
if (ws.current && ws.current.readyState === WebSocket.OPEN) {
|
||||
ws.current.send(JSON.stringify({
|
||||
@@ -359,7 +490,7 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
|
||||
terminal.current = null;
|
||||
}
|
||||
};
|
||||
}, [selectedProject?.path || selectedProject?.fullPath, isRestarting]);
|
||||
}, [selectedProject?.path || selectedProject?.fullPath, isRestarting, minimal, copyAuthUrlToClipboard]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoConnect || !isInitialized || isConnecting || isConnected) return;
|
||||
@@ -383,9 +514,72 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
|
||||
}
|
||||
|
||||
if (minimal) {
|
||||
const displayAuthUrl = isCodexLoginCommand(initialCommand)
|
||||
? CODEX_DEVICE_AUTH_URL
|
||||
: authUrl;
|
||||
const hasAuthUrl = Boolean(displayAuthUrl);
|
||||
const showMobileAuthPanel = hasAuthUrl && !isAuthPanelHidden;
|
||||
const showMobileAuthPanelToggle = hasAuthUrl && isAuthPanelHidden;
|
||||
|
||||
return (
|
||||
<div className="h-full w-full bg-gray-900">
|
||||
<div className="h-full w-full bg-gray-900 relative">
|
||||
<div ref={terminalRef} className="h-full w-full focus:outline-none" style={{ outline: 'none' }} />
|
||||
{showMobileAuthPanel && (
|
||||
<div className="absolute inset-x-0 bottom-14 z-20 border-t border-gray-700/80 bg-gray-900/95 p-3 backdrop-blur-sm md:hidden">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-xs text-gray-300">Open or copy the login URL:</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsAuthPanelHidden(true)}
|
||||
className="rounded bg-gray-700 px-2 py-1 text-[10px] font-medium uppercase tracking-wide text-gray-100 hover:bg-gray-600"
|
||||
>
|
||||
Hide
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={displayAuthUrl}
|
||||
readOnly
|
||||
onClick={(event) => event.currentTarget.select()}
|
||||
className="w-full rounded border border-gray-600 bg-gray-800 px-2 py-1 text-xs text-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
aria-label="Authentication URL"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
openAuthUrlInBrowser(displayAuthUrl);
|
||||
}}
|
||||
className="flex-1 rounded bg-blue-600 px-3 py-2 text-xs font-medium text-white hover:bg-blue-700"
|
||||
>
|
||||
Open URL
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
const copied = await copyAuthUrlToClipboard(displayAuthUrl);
|
||||
setAuthUrlCopyStatus(copied ? 'copied' : 'failed');
|
||||
}}
|
||||
className="flex-1 rounded bg-gray-700 px-3 py-2 text-xs font-medium text-white hover:bg-gray-600"
|
||||
>
|
||||
{authUrlCopyStatus === 'copied' ? 'Copied' : 'Copy URL'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{showMobileAuthPanelToggle && (
|
||||
<div className="absolute bottom-14 right-3 z-20 md:hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsAuthPanelHidden(false)}
|
||||
className="rounded bg-gray-800/95 px-3 py-2 text-xs font-medium text-gray-100 shadow-lg backdrop-blur-sm hover:bg-gray-700"
|
||||
>
|
||||
Show login URL
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -495,4 +689,4 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
|
||||
);
|
||||
}
|
||||
|
||||
export default Shell;
|
||||
export default Shell;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@ import CreateTaskModal from './CreateTaskModal';
|
||||
import { useTaskMaster } from '../contexts/TaskMasterContext';
|
||||
import Shell from './Shell';
|
||||
import { api } from '../utils/api';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const TaskList = ({
|
||||
tasks = [],
|
||||
@@ -31,8 +32,9 @@ const TaskList = ({
|
||||
const [showHelpGuide, setShowHelpGuide] = useState(false);
|
||||
const [isTaskMasterComplete, setIsTaskMasterComplete] = useState(false);
|
||||
const [showPRDDropdown, setShowPRDDropdown] = useState(false);
|
||||
|
||||
|
||||
const { projectTaskMaster, refreshProjects, refreshTasks, setCurrentProject } = useTaskMaster();
|
||||
const { t } = useTranslation('tasks');
|
||||
|
||||
// Close PRD dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
@@ -143,45 +145,45 @@ const TaskList = ({
|
||||
// Organize tasks by status for Kanban view
|
||||
const kanbanColumns = useMemo(() => {
|
||||
const allColumns = [
|
||||
{
|
||||
id: 'pending',
|
||||
title: '📋 To Do',
|
||||
status: 'pending',
|
||||
{
|
||||
id: 'pending',
|
||||
title: t('kanban.pending'),
|
||||
status: 'pending',
|
||||
color: 'bg-slate-50 dark:bg-slate-900/50 border-slate-200 dark:border-slate-700',
|
||||
headerColor: 'bg-slate-100 dark:bg-slate-800 text-slate-800 dark:text-slate-200'
|
||||
},
|
||||
{
|
||||
id: 'in-progress',
|
||||
title: '🚀 In Progress',
|
||||
status: 'in-progress',
|
||||
{
|
||||
id: 'in-progress',
|
||||
title: t('kanban.inProgress'),
|
||||
status: 'in-progress',
|
||||
color: 'bg-blue-50 dark:bg-blue-900/50 border-blue-200 dark:border-blue-700',
|
||||
headerColor: 'bg-blue-100 dark:bg-blue-800 text-blue-800 dark:text-blue-200'
|
||||
},
|
||||
{
|
||||
id: 'done',
|
||||
title: '✅ Done',
|
||||
status: 'done',
|
||||
{
|
||||
id: 'done',
|
||||
title: t('kanban.done'),
|
||||
status: 'done',
|
||||
color: 'bg-emerald-50 dark:bg-emerald-900/50 border-emerald-200 dark:border-emerald-700',
|
||||
headerColor: 'bg-emerald-100 dark:bg-emerald-800 text-emerald-800 dark:text-emerald-200'
|
||||
},
|
||||
{
|
||||
id: 'blocked',
|
||||
title: '🚫 Blocked',
|
||||
status: 'blocked',
|
||||
{
|
||||
id: 'blocked',
|
||||
title: t('kanban.blocked'),
|
||||
status: 'blocked',
|
||||
color: 'bg-red-50 dark:bg-red-900/50 border-red-200 dark:border-red-700',
|
||||
headerColor: 'bg-red-100 dark:bg-red-800 text-red-800 dark:text-red-200'
|
||||
},
|
||||
{
|
||||
id: 'deferred',
|
||||
title: '⏳ Deferred',
|
||||
status: 'deferred',
|
||||
{
|
||||
id: 'deferred',
|
||||
title: t('kanban.deferred'),
|
||||
status: 'deferred',
|
||||
color: 'bg-amber-50 dark:bg-amber-900/50 border-amber-200 dark:border-amber-700',
|
||||
headerColor: 'bg-amber-100 dark:bg-amber-800 text-amber-800 dark:text-amber-200'
|
||||
},
|
||||
{
|
||||
id: 'cancelled',
|
||||
title: '❌ Cancelled',
|
||||
status: 'cancelled',
|
||||
{
|
||||
id: 'cancelled',
|
||||
title: t('kanban.cancelled'),
|
||||
status: 'cancelled',
|
||||
color: 'bg-gray-50 dark:bg-gray-900/50 border-gray-200 dark:border-gray-700',
|
||||
headerColor: 'bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200'
|
||||
}
|
||||
@@ -199,7 +201,7 @@ const TaskList = ({
|
||||
...column,
|
||||
tasks: filteredAndSortedTasks.filter(task => task.status === column.status)
|
||||
}));
|
||||
}, [filteredAndSortedTasks]);
|
||||
}, [filteredAndSortedTasks, t]);
|
||||
|
||||
const handleSortChange = (newSortBy) => {
|
||||
if (sortBy === newSortBy) {
|
||||
@@ -236,26 +238,26 @@ const TaskList = ({
|
||||
<Settings className="w-12 h-12 mx-auto mb-4" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||
TaskMaster AI is not configured
|
||||
{t('notConfigured.title')}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-6">
|
||||
TaskMaster helps break down complex projects into manageable tasks with AI-powered assistance
|
||||
{t('notConfigured.description')}
|
||||
</p>
|
||||
|
||||
|
||||
{/* What is TaskMaster section */}
|
||||
<div className="mb-6 p-4 bg-blue-50 dark:bg-blue-950 rounded-lg text-left">
|
||||
<h4 className="text-sm font-medium text-blue-900 dark:text-blue-100 mb-3">
|
||||
🎯 What is TaskMaster?
|
||||
{t('notConfigured.whatIsTitle')}
|
||||
</h4>
|
||||
<div className="text-xs text-blue-800 dark:text-blue-200 space-y-1">
|
||||
<p>• <strong>AI-Powered Task Management:</strong> Break complex projects into manageable subtasks</p>
|
||||
<p>• <strong>PRD Templates:</strong> Generate tasks from Product Requirements Documents</p>
|
||||
<p>• <strong>Dependency Tracking:</strong> Understand task relationships and execution order</p>
|
||||
<p>• <strong>Progress Visualization:</strong> Kanban boards and detailed task analytics</p>
|
||||
<p>• <strong>CLI Integration:</strong> Use taskmaster commands for advanced workflows</p>
|
||||
<p>• {t('notConfigured.features.aiPowered')}</p>
|
||||
<p>• {t('notConfigured.features.prdTemplates')}</p>
|
||||
<p>• {t('notConfigured.features.dependencyTracking')}</p>
|
||||
<p>• {t('notConfigured.features.progressVisualization')}</p>
|
||||
<p>• {t('notConfigured.features.cliIntegration')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsTaskMasterComplete(false); // Reset completion state
|
||||
@@ -264,7 +266,7 @@ const TaskList = ({
|
||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium transition-colors flex items-center gap-2 mx-auto"
|
||||
>
|
||||
<Terminal className="w-4 h-4" />
|
||||
Initialize TaskMaster AI
|
||||
{t('notConfigured.initializeButton')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
@@ -276,8 +278,8 @@ const TaskList = ({
|
||||
<FileText className="w-5 h-5 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">Getting Started with TaskMaster</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">TaskMaster is initialized! Here's what to do next:</p>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">{t('gettingStarted.title')}</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">{t('gettingStarted.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -287,8 +289,8 @@ const TaskList = ({
|
||||
<div className="flex gap-3 p-3 bg-white dark:bg-gray-800/50 rounded-lg border border-blue-100 dark:border-blue-800/50">
|
||||
<div className="flex-shrink-0 w-6 h-6 bg-blue-600 text-white text-xs font-semibold rounded-full flex items-center justify-center">1</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white mb-1">Create a Product Requirements Document (PRD)</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">Discuss your project idea and create a PRD that describes what you want to build.</p>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white mb-1">{t('gettingStarted.steps.createPRD.title')}</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">{t('gettingStarted.steps.createPRD.description')}</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
onShowPRDEditor?.();
|
||||
@@ -296,13 +298,13 @@ const TaskList = ({
|
||||
className="inline-flex items-center gap-1 text-xs bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 px-2 py-1 rounded hover:bg-purple-200 dark:hover:bg-purple-900/50 transition-colors"
|
||||
>
|
||||
<FileText className="w-3 h-3" />
|
||||
Add PRD
|
||||
{t('gettingStarted.steps.createPRD.addButton')}
|
||||
</button>
|
||||
|
||||
|
||||
{/* Show existing PRDs if any */}
|
||||
{existingPRDs.length > 0 && (
|
||||
<div className="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-2">Existing PRDs:</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-2">{t('gettingStarted.steps.createPRD.existingPRDs')}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{existingPRDs.map((prd) => (
|
||||
<button
|
||||
@@ -341,8 +343,8 @@ const TaskList = ({
|
||||
<div className="flex gap-3 p-3 bg-white dark:bg-gray-800/50 rounded-lg border border-blue-100 dark:border-blue-800/50">
|
||||
<div className="flex-shrink-0 w-6 h-6 bg-blue-600 text-white text-xs font-semibold rounded-full flex items-center justify-center">2</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white mb-1">Generate Tasks from PRD</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Once you have a PRD, ask your AI assistant to parse it and TaskMaster will automatically break it down into manageable tasks with implementation details.</p>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white mb-1">{t('gettingStarted.steps.generateTasks.title')}</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">{t('gettingStarted.steps.generateTasks.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -350,8 +352,8 @@ const TaskList = ({
|
||||
<div className="flex gap-3 p-3 bg-white dark:bg-gray-800/50 rounded-lg border border-blue-100 dark:border-blue-800/50">
|
||||
<div className="flex-shrink-0 w-6 h-6 bg-blue-600 text-white text-xs font-semibold rounded-full flex items-center justify-center">3</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white mb-1">Analyze & Expand Tasks</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Ask your AI assistant to analyze task complexity and expand them into detailed subtasks for easier implementation.</p>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white mb-1">{t('gettingStarted.steps.analyzeTasks.title')}</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">{t('gettingStarted.steps.analyzeTasks.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -359,8 +361,8 @@ const TaskList = ({
|
||||
<div className="flex gap-3 p-3 bg-white dark:bg-gray-800/50 rounded-lg border border-blue-100 dark:border-blue-800/50">
|
||||
<div className="flex-shrink-0 w-6 h-6 bg-blue-600 text-white text-xs font-semibold rounded-full flex items-center justify-center">4</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white mb-1">Start Building</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Ask your AI assistant to begin working on tasks, update their status, and add new tasks as your project evolves.</p>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white mb-1">{t('gettingStarted.steps.startBuilding.title')}</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">{t('gettingStarted.steps.startBuilding.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -376,7 +378,7 @@ const TaskList = ({
|
||||
style={{ zIndex: 10 }}
|
||||
>
|
||||
<FileText className="w-4 h-4" />
|
||||
Add PRD
|
||||
{t('buttons.addPRD')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -384,7 +386,7 @@ const TaskList = ({
|
||||
|
||||
<div className="text-center">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 mb-2">
|
||||
💡 <strong>Tip:</strong> Start with a PRD to get the most out of TaskMaster's AI-powered task generation
|
||||
{t('gettingStarted.tip')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -401,8 +403,8 @@ const TaskList = ({
|
||||
<Terminal className="w-4 h-4 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">TaskMaster Setup</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Interactive CLI for {currentProject?.displayName}</p>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">{t('setupModal.title')}</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">{t('setupModal.subtitle', { projectName: currentProject?.displayName })}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@@ -464,10 +466,10 @@ const TaskList = ({
|
||||
{isTaskMasterComplete ? (
|
||||
<span className="flex items-center gap-2 text-green-600 dark:text-green-400">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||
TaskMaster setup completed! You can now close this window.
|
||||
{t('setupModal.completed')}
|
||||
</span>
|
||||
) : (
|
||||
"TaskMaster initialization will start automatically"
|
||||
t('setupModal.willStart')
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
@@ -485,12 +487,12 @@ const TaskList = ({
|
||||
}}
|
||||
className={cn(
|
||||
"px-4 py-2 text-sm font-medium rounded-md transition-colors",
|
||||
isTaskMasterComplete
|
||||
? "bg-green-600 hover:bg-green-700 text-white"
|
||||
isTaskMasterComplete
|
||||
? "bg-green-600 hover:bg-green-700 text-white"
|
||||
: "text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600"
|
||||
)}
|
||||
>
|
||||
{isTaskMasterComplete ? "Close & Continue" : "Close"}
|
||||
{isTaskMasterComplete ? t('setupModal.closeContinueButton') : t('setupModal.closeButton')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -510,7 +512,7 @@ const TaskList = ({
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search tasks..."
|
||||
placeholder={t('search.placeholder')}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10 pr-4 py-2 w-full border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
@@ -529,7 +531,7 @@ const TaskList = ({
|
||||
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'
|
||||
)}
|
||||
title="Kanban view"
|
||||
title={t('views.kanban')}
|
||||
>
|
||||
<Columns className="w-4 h-4" />
|
||||
</button>
|
||||
@@ -537,11 +539,11 @@ const TaskList = ({
|
||||
onClick={() => setViewMode('list')}
|
||||
className={cn(
|
||||
'p-2 rounded-md transition-colors',
|
||||
viewMode === 'list'
|
||||
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
|
||||
viewMode === 'list'
|
||||
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'
|
||||
)}
|
||||
title="List view"
|
||||
title={t('views.list')}
|
||||
>
|
||||
<List className="w-4 h-4" />
|
||||
</button>
|
||||
@@ -549,11 +551,11 @@ const TaskList = ({
|
||||
onClick={() => setViewMode('grid')}
|
||||
className={cn(
|
||||
'p-2 rounded-md transition-colors',
|
||||
viewMode === 'grid'
|
||||
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
|
||||
viewMode === 'grid'
|
||||
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'
|
||||
)}
|
||||
title="Grid view"
|
||||
title={t('views.grid')}
|
||||
>
|
||||
<Grid className="w-4 h-4" />
|
||||
</button>
|
||||
@@ -570,7 +572,7 @@ const TaskList = ({
|
||||
)}
|
||||
>
|
||||
<Filter className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Filters</span>
|
||||
<span className="hidden sm:inline">{t('filters.button')}</span>
|
||||
<ChevronDown className={cn('w-4 h-4 transition-transform', showFilters && 'rotate-180')} />
|
||||
</button>
|
||||
|
||||
@@ -581,7 +583,7 @@ const TaskList = ({
|
||||
<button
|
||||
onClick={() => setShowHelpGuide(true)}
|
||||
className="p-2 text-gray-600 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors border border-gray-300 dark:border-gray-600"
|
||||
title="TaskMaster Getting Started Guide"
|
||||
title={t('buttons.help')}
|
||||
>
|
||||
<HelpCircle className="w-4 h-4" />
|
||||
</button>
|
||||
@@ -594,16 +596,16 @@ const TaskList = ({
|
||||
<button
|
||||
onClick={() => setShowPRDDropdown(!showPRDDropdown)}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg transition-colors font-medium"
|
||||
title={`${existingPRDs.length} PRD${existingPRDs.length > 1 ? 's' : ''} available`}
|
||||
title={t('buttons.prdsAvailable', { count: existingPRDs.length })}
|
||||
>
|
||||
<FileText className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">PRDs</span>
|
||||
<span className="hidden sm:inline">{t('buttons.prds')}</span>
|
||||
<span className="px-1.5 py-0.5 text-xs bg-purple-500 rounded-full min-w-[1.25rem] text-center">
|
||||
{existingPRDs.length}
|
||||
</span>
|
||||
<ChevronDown className={cn('w-3 h-3 transition-transform hidden sm:block', showPRDDropdown && 'rotate-180')} />
|
||||
</button>
|
||||
|
||||
|
||||
{showPRDDropdown && (
|
||||
<div className="absolute right-0 top-full mt-2 w-56 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-xl z-30">
|
||||
<div className="p-2">
|
||||
@@ -615,10 +617,10 @@ const TaskList = ({
|
||||
className="w-full text-left px-3 py-2 text-sm font-medium text-purple-700 dark:text-purple-300 hover:bg-purple-50 dark:hover:bg-purple-900/30 rounded flex items-center gap-2"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Create New PRD
|
||||
{t('buttons.createNewPRD')}
|
||||
</button>
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 my-1"></div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 px-3 py-1 font-medium">Existing PRDs:</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 px-3 py-1 font-medium">{t('gettingStarted.steps.createPRD.existingPRDs')}</div>
|
||||
{existingPRDs.map((prd) => (
|
||||
<button
|
||||
key={prd.name}
|
||||
@@ -639,7 +641,7 @@ const TaskList = ({
|
||||
}
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded flex items-center gap-2"
|
||||
title={`Modified: ${new Date(prd.modified).toLocaleDateString()}`}
|
||||
title={t('prd.modified', { date: new Date(prd.modified).toLocaleDateString() })}
|
||||
>
|
||||
<FileText className="w-4 h-4" />
|
||||
<span className="truncate">{prd.name}</span>
|
||||
@@ -656,10 +658,10 @@ const TaskList = ({
|
||||
onShowPRDEditor?.();
|
||||
}}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg transition-colors font-medium"
|
||||
title="Create Product Requirements Document"
|
||||
title={t('buttons.addPRD')}
|
||||
>
|
||||
<FileText className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Add PRD</span>
|
||||
<span className="hidden sm:inline">{t('buttons.addPRD')}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -669,10 +671,10 @@ const TaskList = ({
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors font-medium"
|
||||
title="Add a new task"
|
||||
title={t('buttons.addTask')}
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Add Task</span>
|
||||
<span className="hidden sm:inline">{t('buttons.addTask')}</span>
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
@@ -687,17 +689,17 @@ const TaskList = ({
|
||||
{/* Status Filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Status
|
||||
{t('filters.status')}
|
||||
</label>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="all">All Statuses</option>
|
||||
<option value="all">{t('filters.allStatuses')}</option>
|
||||
{statuses.map(status => (
|
||||
<option key={status} value={status}>
|
||||
{status.charAt(0).toUpperCase() + status.slice(1).replace('-', ' ')}
|
||||
{t(`statuses.${status}`, status.charAt(0).toUpperCase() + status.slice(1).replace('-', ' '))}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -706,17 +708,17 @@ const TaskList = ({
|
||||
{/* Priority Filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Priority
|
||||
{t('filters.priority')}
|
||||
</label>
|
||||
<select
|
||||
value={priorityFilter}
|
||||
onChange={(e) => setPriorityFilter(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="all">All Priorities</option>
|
||||
<option value="all">{t('filters.allPriorities')}</option>
|
||||
{priorities.map(priority => (
|
||||
<option key={priority} value={priority}>
|
||||
{priority.charAt(0).toUpperCase() + priority.slice(1)}
|
||||
{t(`priorities.${priority}`, priority.charAt(0).toUpperCase() + priority.slice(1))}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -725,7 +727,7 @@ const TaskList = ({
|
||||
{/* Sort By */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Sort By
|
||||
{t('filters.sortBy')}
|
||||
</label>
|
||||
<select
|
||||
value={`${sortBy}-${sortOrder}`}
|
||||
@@ -736,14 +738,14 @@ const TaskList = ({
|
||||
}}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="id-asc">ID (Ascending)</option>
|
||||
<option value="id-desc">ID (Descending)</option>
|
||||
<option value="title-asc">Title (A-Z)</option>
|
||||
<option value="title-desc">Title (Z-A)</option>
|
||||
<option value="status-asc">Status (Pending First)</option>
|
||||
<option value="status-desc">Status (Done First)</option>
|
||||
<option value="priority-asc">Priority (High First)</option>
|
||||
<option value="priority-desc">Priority (Low First)</option>
|
||||
<option value="id-asc">{t('sort.idAsc')}</option>
|
||||
<option value="id-desc">{t('sort.idDesc')}</option>
|
||||
<option value="title-asc">{t('sort.titleAsc')}</option>
|
||||
<option value="title-desc">{t('sort.titleDesc')}</option>
|
||||
<option value="status-asc">{t('sort.statusAsc')}</option>
|
||||
<option value="status-desc">{t('sort.statusDesc')}</option>
|
||||
<option value="priority-asc">{t('sort.priorityAsc')}</option>
|
||||
<option value="priority-desc">{t('sort.priorityDesc')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -751,13 +753,13 @@ const TaskList = ({
|
||||
{/* Filter Actions */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Showing {filteredAndSortedTasks.length} of {tasks.length} tasks
|
||||
{t('filters.showing', { filtered: filteredAndSortedTasks.length, total: tasks.length })}
|
||||
</div>
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
className="text-sm text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 font-medium"
|
||||
>
|
||||
Clear Filters
|
||||
{t('filters.clearFilters')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -769,34 +771,34 @@ const TaskList = ({
|
||||
onClick={() => handleSortChange('id')}
|
||||
className={cn(
|
||||
'flex items-center gap-1 px-3 py-1.5 rounded-md text-sm transition-colors',
|
||||
sortBy === 'id'
|
||||
? 'bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300'
|
||||
sortBy === 'id'
|
||||
? 'bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300'
|
||||
: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||
)}
|
||||
>
|
||||
ID {getSortIcon('id')}
|
||||
{t('sort.id')} {getSortIcon('id')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleSortChange('status')}
|
||||
className={cn(
|
||||
'flex items-center gap-1 px-3 py-1.5 rounded-md text-sm transition-colors',
|
||||
sortBy === 'status'
|
||||
? 'bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300'
|
||||
sortBy === 'status'
|
||||
? 'bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300'
|
||||
: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||
)}
|
||||
>
|
||||
Status {getSortIcon('status')}
|
||||
{t('sort.status')} {getSortIcon('status')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleSortChange('priority')}
|
||||
className={cn(
|
||||
'flex items-center gap-1 px-3 py-1.5 rounded-md text-sm transition-colors',
|
||||
sortBy === 'priority'
|
||||
? 'bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300'
|
||||
sortBy === 'priority'
|
||||
? 'bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300'
|
||||
: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||
)}
|
||||
>
|
||||
Priority {getSortIcon('priority')}
|
||||
{t('sort.priority')} {getSortIcon('priority')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -805,8 +807,8 @@ const TaskList = ({
|
||||
<div className="text-center py-12">
|
||||
<div className="text-gray-500 dark:text-gray-400">
|
||||
<Search className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
||||
<h3 className="text-lg font-medium mb-2">No tasks match your filters</h3>
|
||||
<p className="text-sm">Try adjusting your search or filter criteria.</p>
|
||||
<h3 className="text-lg font-medium mb-2">{t('noMatchingTasks.title')}</h3>
|
||||
<p className="text-sm">{t('noMatchingTasks.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : viewMode === 'kanban' ? (
|
||||
@@ -844,13 +846,13 @@ const TaskList = ({
|
||||
<div className="w-3 h-3 rounded-full bg-gray-300 dark:bg-gray-600"></div>
|
||||
</div>
|
||||
<div className="text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
No tasks yet
|
||||
{t('kanban.noTasksYet')}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||
{column.status === 'pending' ? 'Tasks will appear here' :
|
||||
column.status === 'in-progress' ? 'Move tasks here when started' :
|
||||
column.status === 'done' ? 'Completed tasks appear here' :
|
||||
'Tasks with this status will appear here'}
|
||||
{column.status === 'pending' ? t('kanban.tasksWillAppear') :
|
||||
column.status === 'in-progress' ? t('kanban.moveTasksHere') :
|
||||
column.status === 'done' ? t('kanban.completedTasksHere') :
|
||||
t('kanban.statusTasksHere')}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -911,8 +913,8 @@ const TaskList = ({
|
||||
<FileText className="w-5 h-5 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">Getting Started with TaskMaster</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Your guide to productive task management</p>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">{t('helpGuide.title')}</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">{t('helpGuide.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@@ -930,8 +932,8 @@ const TaskList = ({
|
||||
<div className="flex gap-4 p-4 bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-950/50 dark:to-indigo-950/50 rounded-lg border border-blue-200 dark:border-blue-800">
|
||||
<div className="flex-shrink-0 w-8 h-8 bg-blue-600 text-white text-sm font-semibold rounded-full flex items-center justify-center">1</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white mb-2">Create a Product Requirements Document (PRD)</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">Discuss your project idea and create a PRD that describes what you want to build.</p>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white mb-2">{t('gettingStarted.steps.createPRD.title')}</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">{t('gettingStarted.steps.createPRD.description')}</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
onShowPRDEditor?.();
|
||||
@@ -940,7 +942,7 @@ const TaskList = ({
|
||||
className="inline-flex items-center gap-2 text-sm bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 px-3 py-1.5 rounded-lg hover:bg-purple-200 dark:hover:bg-purple-900/50 transition-colors"
|
||||
>
|
||||
<FileText className="w-4 h-4" />
|
||||
Add PRD
|
||||
{t('buttons.addPRD')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -949,12 +951,11 @@ const TaskList = ({
|
||||
<div className="flex gap-4 p-4 bg-gradient-to-r from-green-50 to-emerald-50 dark:from-green-950/50 dark:to-emerald-950/50 rounded-lg border border-green-200 dark:border-green-800">
|
||||
<div className="flex-shrink-0 w-8 h-8 bg-green-600 text-white text-sm font-semibold rounded-full flex items-center justify-center">2</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white mb-2">Generate Tasks from PRD</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">Once you have a PRD, ask your AI assistant to parse it and TaskMaster will automatically break it down into manageable tasks with implementation details.</p>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white mb-2">{t('gettingStarted.steps.generateTasks.title')}</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">{t('gettingStarted.steps.generateTasks.description')}</p>
|
||||
<div className="bg-white dark:bg-gray-800/50 rounded border border-green-200 dark:border-green-700/50 p-3 mb-2">
|
||||
<p className="text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">💬 Example:</p>
|
||||
<p className="text-xs text-gray-900 dark:text-white font-mono">
|
||||
"I've just initialized a new project with Claude Task Master. I have a PRD at .taskmaster/docs/prd.txt. Can you help me parse it and set up the initial tasks?"
|
||||
<p className="text-xs text-gray-900 dark:text-white font-mono whitespace-pre-wrap">
|
||||
{t('helpGuide.examples.parsePRD')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -964,12 +965,11 @@ const TaskList = ({
|
||||
<div className="flex gap-4 p-4 bg-gradient-to-r from-amber-50 to-orange-50 dark:from-amber-950/50 dark:to-orange-950/50 rounded-lg border border-amber-200 dark:border-amber-800">
|
||||
<div className="flex-shrink-0 w-8 h-8 bg-amber-600 text-white text-sm font-semibold rounded-full flex items-center justify-center">3</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white mb-2">Analyze & Expand Tasks</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">Ask your AI assistant to analyze task complexity and expand them into detailed subtasks for easier implementation.</p>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white mb-2">{t('gettingStarted.steps.analyzeTasks.title')}</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">{t('gettingStarted.steps.analyzeTasks.description')}</p>
|
||||
<div className="bg-white dark:bg-gray-800/50 rounded border border-amber-200 dark:border-amber-700/50 p-3 mb-2">
|
||||
<p className="text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">💬 Example:</p>
|
||||
<p className="text-xs text-gray-900 dark:text-white font-mono">
|
||||
"Task 5 seems complex. Can you break it down into subtasks?"
|
||||
<p className="text-xs text-gray-900 dark:text-white font-mono whitespace-pre-wrap">
|
||||
{t('helpGuide.examples.expandTask')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -979,12 +979,11 @@ const TaskList = ({
|
||||
<div className="flex gap-4 p-4 bg-gradient-to-r from-purple-50 to-pink-50 dark:from-purple-950/50 dark:to-pink-950/50 rounded-lg border border-purple-200 dark:border-purple-800">
|
||||
<div className="flex-shrink-0 w-8 h-8 bg-purple-600 text-white text-sm font-semibold rounded-full flex items-center justify-center">4</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white mb-2">Start Building</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">Ask your AI assistant to begin working on tasks, update their status, and add new tasks as your project evolves.</p>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white mb-2">{t('gettingStarted.steps.startBuilding.title')}</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">{t('gettingStarted.steps.startBuilding.description')}</p>
|
||||
<div className="bg-white dark:bg-gray-800/50 rounded border border-purple-200 dark:border-purple-700/50 p-3 mb-3">
|
||||
<p className="text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">💬 Example:</p>
|
||||
<p className="text-xs text-gray-900 dark:text-white font-mono">
|
||||
"Please add a new task to implement user profile image uploads using Cloudinary, research the best approach."
|
||||
<p className="text-xs text-gray-900 dark:text-white font-mono whitespace-pre-wrap">
|
||||
{t('helpGuide.examples.addTask')}
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
@@ -993,50 +992,50 @@ const TaskList = ({
|
||||
rel="noopener noreferrer"
|
||||
className="inline-block text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 underline"
|
||||
>
|
||||
View more examples and usage patterns →
|
||||
{t('helpGuide.moreExamples')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pro Tips */}
|
||||
<div className="mt-6 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<h4 className="font-medium text-gray-900 dark:text-white mb-3">💡 Pro Tips</h4>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white mb-3">{t('helpGuide.proTips.title')}</h4>
|
||||
<ul className="space-y-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="w-1.5 h-1.5 bg-blue-500 rounded-full mt-2 flex-shrink-0"></span>
|
||||
Use the search bar to quickly find specific tasks
|
||||
{t('helpGuide.proTips.search')}
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="w-1.5 h-1.5 bg-green-500 rounded-full mt-2 flex-shrink-0"></span>
|
||||
Switch between Kanban, List, and Grid views using the view toggles
|
||||
{t('helpGuide.proTips.views')}
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="w-1.5 h-1.5 bg-purple-500 rounded-full mt-2 flex-shrink-0"></span>
|
||||
Use filters to focus on specific task statuses or priorities
|
||||
{t('helpGuide.proTips.filters')}
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="w-1.5 h-1.5 bg-orange-500 rounded-full mt-2 flex-shrink-0"></span>
|
||||
Click on any task to view detailed information and manage subtasks
|
||||
{t('helpGuide.proTips.details')}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Learn More Section */}
|
||||
<div className="mt-6 p-4 bg-blue-50 dark:bg-blue-950/50 rounded-lg border border-blue-200 dark:border-blue-800">
|
||||
<h4 className="font-medium text-blue-900 dark:text-blue-100 mb-3">📚 Learn More</h4>
|
||||
<h4 className="font-medium text-blue-900 dark:text-blue-100 mb-3">{t('helpGuide.learnMore.title')}</h4>
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200 mb-3">
|
||||
TaskMaster AI is an advanced task management system built for developers. Get documentation, examples, and contribute to the project.
|
||||
{t('helpGuide.learnMore.description')}
|
||||
</p>
|
||||
<a
|
||||
href="https://github.com/eyaltoledano/claude-task-master"
|
||||
target="_blank"
|
||||
<a
|
||||
href="https://github.com/eyaltoledano/claude-task-master"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 text-sm bg-blue-600 hover:bg-blue-700 text-white px-3 py-2 rounded-lg font-medium transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 0C4.477 0 0 4.484 0 10.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0110 4.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.203 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.942.359.31.678.921.678 1.856 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0020 10.017C20 4.484 15.522 0 10 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
View on GitHub
|
||||
{t('helpGuide.learnMore.githubButton')}
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
|
||||
@@ -10,12 +10,12 @@ const TodoList = ({ todos, isResult = false }) => {
|
||||
const getStatusIcon = (status) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return <CheckCircle2 className="w-4 h-4 text-green-500 dark:text-green-400" />;
|
||||
return <CheckCircle2 className="w-3.5 h-3.5 text-green-500 dark:text-green-400" />;
|
||||
case 'in_progress':
|
||||
return <Clock className="w-4 h-4 text-blue-500 dark:text-blue-400" />;
|
||||
return <Clock className="w-3.5 h-3.5 text-blue-500 dark:text-blue-400" />;
|
||||
case 'pending':
|
||||
default:
|
||||
return <Circle className="w-4 h-4 text-gray-400 dark:text-gray-500" />;
|
||||
return <Circle className="w-3.5 h-3.5 text-gray-400 dark:text-gray-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -44,38 +44,38 @@ const TodoList = ({ todos, isResult = false }) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
{isResult && (
|
||||
<div className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
<div className="text-xs font-medium text-gray-600 dark:text-gray-400 mb-1.5">
|
||||
Todo List ({todos.length} {todos.length === 1 ? 'item' : 'items'})
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{todos.map((todo, index) => (
|
||||
<div
|
||||
key={todo.id || `todo-${index}`}
|
||||
className="flex items-start gap-3 p-3 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm hover:shadow-md dark:shadow-gray-900/50 transition-shadow"
|
||||
className="flex items-start gap-2 p-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded transition-colors"
|
||||
>
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
{getStatusIcon(todo.status)}
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2 mb-2">
|
||||
<p className={`text-sm font-medium ${todo.status === 'completed' ? 'line-through text-gray-500 dark:text-gray-400' : 'text-gray-900 dark:text-gray-100'}`}>
|
||||
<div className="flex items-start justify-between gap-2 mb-0.5">
|
||||
<p className={`text-xs font-medium ${todo.status === 'completed' ? 'line-through text-gray-500 dark:text-gray-400' : 'text-gray-900 dark:text-gray-100'}`}>
|
||||
{todo.content}
|
||||
</p>
|
||||
|
||||
|
||||
<div className="flex gap-1 flex-shrink-0">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-xs px-2 py-0.5 ${getPriorityColor(todo.priority)}`}
|
||||
className={`text-[10px] px-1.5 py-px ${getPriorityColor(todo.priority)}`}
|
||||
>
|
||||
{todo.priority}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-xs px-2 py-0.5 ${getStatusColor(todo.status)}`}
|
||||
className={`text-[10px] px-1.5 py-px ${getStatusColor(todo.status)}`}
|
||||
>
|
||||
{todo.status.replace('_', ' ')}
|
||||
</Badge>
|
||||
@@ -88,4 +88,4 @@ const TodoList = ({ todos, isResult = false }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default TodoList;
|
||||
export default TodoList;
|
||||
|
||||
144
src/components/app/AppContent.tsx
Normal file
144
src/components/app/AppContent.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import Sidebar from '../sidebar/view/Sidebar';
|
||||
import MainContent from '../main-content/view/MainContent';
|
||||
import MobileNav from '../MobileNav';
|
||||
|
||||
import { useWebSocket } from '../../contexts/WebSocketContext';
|
||||
import { useDeviceSettings } from '../../hooks/useDeviceSettings';
|
||||
import { useSessionProtection } from '../../hooks/useSessionProtection';
|
||||
import { useProjectsState } from '../../hooks/useProjectsState';
|
||||
|
||||
export default function AppContent() {
|
||||
const navigate = useNavigate();
|
||||
const { sessionId } = useParams<{ sessionId?: string }>();
|
||||
const { t } = useTranslation('common');
|
||||
const { isMobile } = useDeviceSettings({ trackPWA: false });
|
||||
const { ws, sendMessage, latestMessage } = useWebSocket();
|
||||
|
||||
const {
|
||||
activeSessions,
|
||||
processingSessions,
|
||||
markSessionAsActive,
|
||||
markSessionAsInactive,
|
||||
markSessionAsProcessing,
|
||||
markSessionAsNotProcessing,
|
||||
replaceTemporarySession,
|
||||
} = useSessionProtection();
|
||||
|
||||
const {
|
||||
selectedProject,
|
||||
selectedSession,
|
||||
activeTab,
|
||||
sidebarOpen,
|
||||
isLoadingProjects,
|
||||
isInputFocused,
|
||||
externalMessageUpdate,
|
||||
setActiveTab,
|
||||
setSidebarOpen,
|
||||
setIsInputFocused,
|
||||
setShowSettings,
|
||||
openSettings,
|
||||
fetchProjects,
|
||||
sidebarSharedProps,
|
||||
} = useProjectsState({
|
||||
sessionId,
|
||||
navigate,
|
||||
latestMessage,
|
||||
isMobile,
|
||||
activeSessions,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
window.refreshProjects = fetchProjects;
|
||||
|
||||
return () => {
|
||||
if (window.refreshProjects === fetchProjects) {
|
||||
delete window.refreshProjects;
|
||||
}
|
||||
};
|
||||
}, [fetchProjects]);
|
||||
|
||||
useEffect(() => {
|
||||
window.openSettings = openSettings;
|
||||
|
||||
return () => {
|
||||
if (window.openSettings === openSettings) {
|
||||
delete window.openSettings;
|
||||
}
|
||||
};
|
||||
}, [openSettings]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 flex bg-background">
|
||||
{!isMobile ? (
|
||||
<div className="h-full flex-shrink-0 border-r border-border/50">
|
||||
<Sidebar {...sidebarSharedProps} />
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={`fixed inset-0 z-50 flex transition-all duration-150 ease-out ${sidebarOpen ? 'opacity-100 visible' : 'opacity-0 invisible'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
className="fixed inset-0 bg-background/60 backdrop-blur-sm transition-opacity duration-150 ease-out"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setSidebarOpen(false);
|
||||
}}
|
||||
onTouchStart={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setSidebarOpen(false);
|
||||
}}
|
||||
aria-label={t('versionUpdate.ariaLabels.closeSidebar')}
|
||||
/>
|
||||
<div
|
||||
className={`relative w-[85vw] max-w-sm sm:w-80 h-full bg-card border-r border-border/40 transform transition-transform duration-150 ease-out ${sidebarOpen ? 'translate-x-0' : '-translate-x-full'
|
||||
}`}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onTouchStart={(event) => event.stopPropagation()}
|
||||
>
|
||||
<Sidebar {...sidebarSharedProps} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`flex-1 flex flex-col min-w-0 ${isMobile ? 'pb-mobile-nav' : ''}`}>
|
||||
<MainContent
|
||||
selectedProject={selectedProject}
|
||||
selectedSession={selectedSession}
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
ws={ws}
|
||||
sendMessage={sendMessage}
|
||||
latestMessage={latestMessage}
|
||||
isMobile={isMobile}
|
||||
onMenuClick={() => setSidebarOpen(true)}
|
||||
isLoading={isLoadingProjects}
|
||||
onInputFocusChange={setIsInputFocused}
|
||||
onSessionActive={markSessionAsActive}
|
||||
onSessionInactive={markSessionAsInactive}
|
||||
onSessionProcessing={markSessionAsProcessing}
|
||||
onSessionNotProcessing={markSessionAsNotProcessing}
|
||||
processingSessions={processingSessions}
|
||||
onReplaceTemporarySession={replaceTemporarySession}
|
||||
onNavigateToSession={(targetSessionId: string) => navigate(`/session/${targetSessionId}`)}
|
||||
onShowSettings={() => setShowSettings(true)}
|
||||
externalMessageUpdate={externalMessageUpdate}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isMobile && (
|
||||
<MobileNav
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
isInputFocused={isInputFocused}
|
||||
/>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
44
src/components/chat/constants/thinkingModes.ts
Normal file
44
src/components/chat/constants/thinkingModes.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Brain, Zap, Sparkles, Atom } from 'lucide-react';
|
||||
|
||||
export const thinkingModes = [
|
||||
{
|
||||
id: 'none',
|
||||
name: 'Standard',
|
||||
description: 'Regular Claude response',
|
||||
icon: null,
|
||||
prefix: '',
|
||||
color: 'text-gray-600'
|
||||
},
|
||||
{
|
||||
id: 'think',
|
||||
name: 'Think',
|
||||
description: 'Basic extended thinking',
|
||||
icon: Brain,
|
||||
prefix: 'think',
|
||||
color: 'text-blue-600'
|
||||
},
|
||||
{
|
||||
id: 'think-hard',
|
||||
name: 'Think Hard',
|
||||
description: 'More thorough evaluation',
|
||||
icon: Zap,
|
||||
prefix: 'think hard',
|
||||
color: 'text-purple-600'
|
||||
},
|
||||
{
|
||||
id: 'think-harder',
|
||||
name: 'Think Harder',
|
||||
description: 'Deep analysis with alternatives',
|
||||
icon: Sparkles,
|
||||
prefix: 'think harder',
|
||||
color: 'text-indigo-600'
|
||||
},
|
||||
{
|
||||
id: 'ultrathink',
|
||||
name: 'Ultrathink',
|
||||
description: 'Maximum thinking budget',
|
||||
icon: Atom,
|
||||
prefix: 'ultrathink',
|
||||
color: 'text-red-600'
|
||||
}
|
||||
];
|
||||
987
src/components/chat/hooks/useChatComposerState.ts
Normal file
987
src/components/chat/hooks/useChatComposerState.ts
Normal file
@@ -0,0 +1,987 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type {
|
||||
ChangeEvent,
|
||||
ClipboardEvent,
|
||||
Dispatch,
|
||||
FormEvent,
|
||||
KeyboardEvent,
|
||||
MouseEvent,
|
||||
SetStateAction,
|
||||
TouchEvent,
|
||||
} from 'react';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
import { authenticatedFetch } from '../../../utils/api';
|
||||
|
||||
import { thinkingModes } from '../constants/thinkingModes';
|
||||
|
||||
import { grantClaudeToolPermission } from '../utils/chatPermissions';
|
||||
import { safeLocalStorage } from '../utils/chatStorage';
|
||||
import type {
|
||||
ChatMessage,
|
||||
PendingPermissionRequest,
|
||||
PermissionMode,
|
||||
} from '../types/types';
|
||||
import { useFileMentions } from './useFileMentions';
|
||||
import { type SlashCommand, useSlashCommands } from './useSlashCommands';
|
||||
import type { Project, ProjectSession, SessionProvider } from '../../../types/app';
|
||||
import { escapeRegExp } from '../utils/chatFormatting';
|
||||
|
||||
type PendingViewSession = {
|
||||
sessionId: string | null;
|
||||
startedAt: number;
|
||||
};
|
||||
|
||||
interface UseChatComposerStateArgs {
|
||||
selectedProject: Project | null;
|
||||
selectedSession: ProjectSession | null;
|
||||
currentSessionId: string | null;
|
||||
provider: SessionProvider;
|
||||
permissionMode: PermissionMode | string;
|
||||
cyclePermissionMode: () => void;
|
||||
cursorModel: string;
|
||||
claudeModel: string;
|
||||
codexModel: string;
|
||||
isLoading: boolean;
|
||||
canAbortSession: boolean;
|
||||
tokenBudget: Record<string, unknown> | null;
|
||||
sendMessage: (message: unknown) => void;
|
||||
sendByCtrlEnter?: boolean;
|
||||
onSessionActive?: (sessionId?: string | null) => void;
|
||||
onInputFocusChange?: (focused: boolean) => void;
|
||||
onFileOpen?: (filePath: string, diffInfo?: unknown) => void;
|
||||
onShowSettings?: () => void;
|
||||
pendingViewSessionRef: { current: PendingViewSession | null };
|
||||
scrollToBottom: () => void;
|
||||
setChatMessages: Dispatch<SetStateAction<ChatMessage[]>>;
|
||||
setSessionMessages?: Dispatch<SetStateAction<any[]>>;
|
||||
setIsLoading: (loading: boolean) => void;
|
||||
setCanAbortSession: (canAbort: boolean) => void;
|
||||
setClaudeStatus: (status: { text: string; tokens: number; can_interrupt: boolean } | null) => void;
|
||||
setIsUserScrolledUp: (isScrolledUp: boolean) => void;
|
||||
setPendingPermissionRequests: Dispatch<SetStateAction<PendingPermissionRequest[]>>;
|
||||
}
|
||||
|
||||
interface MentionableFile {
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface CommandExecutionResult {
|
||||
type: 'builtin' | 'custom';
|
||||
action?: string;
|
||||
data?: any;
|
||||
content?: string;
|
||||
hasBashCommands?: boolean;
|
||||
hasFileIncludes?: boolean;
|
||||
}
|
||||
|
||||
const createFakeSubmitEvent = () => {
|
||||
return { preventDefault: () => undefined } as unknown as FormEvent<HTMLFormElement>;
|
||||
};
|
||||
|
||||
const isTemporarySessionId = (sessionId: string | null | undefined) =>
|
||||
Boolean(sessionId && sessionId.startsWith('new-session-'));
|
||||
|
||||
export function useChatComposerState({
|
||||
selectedProject,
|
||||
selectedSession,
|
||||
currentSessionId,
|
||||
provider,
|
||||
permissionMode,
|
||||
cyclePermissionMode,
|
||||
cursorModel,
|
||||
claudeModel,
|
||||
codexModel,
|
||||
isLoading,
|
||||
canAbortSession,
|
||||
tokenBudget,
|
||||
sendMessage,
|
||||
sendByCtrlEnter,
|
||||
onSessionActive,
|
||||
onInputFocusChange,
|
||||
onFileOpen,
|
||||
onShowSettings,
|
||||
pendingViewSessionRef,
|
||||
scrollToBottom,
|
||||
setChatMessages,
|
||||
setSessionMessages,
|
||||
setIsLoading,
|
||||
setCanAbortSession,
|
||||
setClaudeStatus,
|
||||
setIsUserScrolledUp,
|
||||
setPendingPermissionRequests,
|
||||
}: UseChatComposerStateArgs) {
|
||||
const [input, setInput] = useState(() => {
|
||||
if (typeof window !== 'undefined' && selectedProject) {
|
||||
return safeLocalStorage.getItem(`draft_input_${selectedProject.name}`) || '';
|
||||
}
|
||||
return '';
|
||||
});
|
||||
const [attachedImages, setAttachedImages] = useState<File[]>([]);
|
||||
const [uploadingImages, setUploadingImages] = useState<Map<string, number>>(new Map());
|
||||
const [imageErrors, setImageErrors] = useState<Map<string, string>>(new Map());
|
||||
const [isTextareaExpanded, setIsTextareaExpanded] = useState(false);
|
||||
const [thinkingMode, setThinkingMode] = useState('none');
|
||||
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const inputHighlightRef = useRef<HTMLDivElement>(null);
|
||||
const handleSubmitRef = useRef<
|
||||
((event: FormEvent<HTMLFormElement> | MouseEvent | TouchEvent | KeyboardEvent<HTMLTextAreaElement>) => Promise<void>) | null
|
||||
>(null);
|
||||
const inputValueRef = useRef(input);
|
||||
|
||||
const handleBuiltInCommand = useCallback(
|
||||
(result: CommandExecutionResult) => {
|
||||
const { action, data } = result;
|
||||
switch (action) {
|
||||
case 'clear':
|
||||
setChatMessages([]);
|
||||
setSessionMessages?.([]);
|
||||
break;
|
||||
|
||||
case 'help':
|
||||
setChatMessages((previous) => [
|
||||
...previous,
|
||||
{
|
||||
type: 'assistant',
|
||||
content: data.content,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'model':
|
||||
setChatMessages((previous) => [
|
||||
...previous,
|
||||
{
|
||||
type: 'assistant',
|
||||
content: `**Current Model**: ${data.current.model}\n\n**Available Models**:\n\nClaude: ${data.available.claude.join(', ')}\n\nCursor: ${data.available.cursor.join(', ')}`,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'cost': {
|
||||
const costMessage = `**Token Usage**: ${data.tokenUsage.used.toLocaleString()} / ${data.tokenUsage.total.toLocaleString()} (${data.tokenUsage.percentage}%)\n\n**Estimated Cost**:\n- Input: $${data.cost.input}\n- Output: $${data.cost.output}\n- **Total**: $${data.cost.total}\n\n**Model**: ${data.model}`;
|
||||
setChatMessages((previous) => [
|
||||
...previous,
|
||||
{ type: 'assistant', content: costMessage, timestamp: Date.now() },
|
||||
]);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'status': {
|
||||
const statusMessage = `**System Status**\n\n- Version: ${data.version}\n- Uptime: ${data.uptime}\n- Model: ${data.model}\n- Provider: ${data.provider}\n- Node.js: ${data.nodeVersion}\n- Platform: ${data.platform}`;
|
||||
setChatMessages((previous) => [
|
||||
...previous,
|
||||
{ type: 'assistant', content: statusMessage, timestamp: Date.now() },
|
||||
]);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'memory':
|
||||
if (data.error) {
|
||||
setChatMessages((previous) => [
|
||||
...previous,
|
||||
{
|
||||
type: 'assistant',
|
||||
content: `⚠️ ${data.message}`,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
setChatMessages((previous) => [
|
||||
...previous,
|
||||
{
|
||||
type: 'assistant',
|
||||
content: `📝 ${data.message}\n\nPath: \`${data.path}\``,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]);
|
||||
if (data.exists && onFileOpen) {
|
||||
onFileOpen(data.path);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'config':
|
||||
onShowSettings?.();
|
||||
break;
|
||||
|
||||
case 'rewind':
|
||||
if (data.error) {
|
||||
setChatMessages((previous) => [
|
||||
...previous,
|
||||
{
|
||||
type: 'assistant',
|
||||
content: `⚠️ ${data.message}`,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
setChatMessages((previous) => previous.slice(0, -data.steps * 2));
|
||||
setChatMessages((previous) => [
|
||||
...previous,
|
||||
{
|
||||
type: 'assistant',
|
||||
content: `⏪ ${data.message}`,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn('Unknown built-in command action:', action);
|
||||
}
|
||||
},
|
||||
[onFileOpen, onShowSettings, setChatMessages, setSessionMessages],
|
||||
);
|
||||
|
||||
const handleCustomCommand = useCallback(async (result: CommandExecutionResult) => {
|
||||
const { content, hasBashCommands } = result;
|
||||
|
||||
if (hasBashCommands) {
|
||||
const confirmed = window.confirm(
|
||||
'This command contains bash commands that will be executed. Do you want to proceed?',
|
||||
);
|
||||
if (!confirmed) {
|
||||
setChatMessages((previous) => [
|
||||
...previous,
|
||||
{
|
||||
type: 'assistant',
|
||||
content: '❌ Command execution cancelled',
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const commandContent = content || '';
|
||||
setInput(commandContent);
|
||||
inputValueRef.current = commandContent;
|
||||
|
||||
// Defer submit to next tick so the command text is reflected in UI before dispatching.
|
||||
setTimeout(() => {
|
||||
if (handleSubmitRef.current) {
|
||||
handleSubmitRef.current(createFakeSubmitEvent());
|
||||
}
|
||||
}, 0);
|
||||
}, [setChatMessages]);
|
||||
|
||||
const executeCommand = useCallback(
|
||||
async (command: SlashCommand, rawInput?: string) => {
|
||||
if (!command || !selectedProject) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const effectiveInput = rawInput ?? input;
|
||||
const commandMatch = effectiveInput.match(new RegExp(`${escapeRegExp(command.name)}\\s*(.*)`));
|
||||
const args =
|
||||
commandMatch && commandMatch[1] ? commandMatch[1].trim().split(/\s+/) : [];
|
||||
|
||||
const context = {
|
||||
projectPath: selectedProject.fullPath || selectedProject.path,
|
||||
projectName: selectedProject.name,
|
||||
sessionId: currentSessionId,
|
||||
provider,
|
||||
model: provider === 'cursor' ? cursorModel : provider === 'codex' ? codexModel : claudeModel,
|
||||
tokenUsage: tokenBudget,
|
||||
};
|
||||
|
||||
const response = await authenticatedFetch('/api/commands/execute', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
commandName: command.name,
|
||||
commandPath: command.path,
|
||||
args,
|
||||
context,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = `Failed to execute command (${response.status})`;
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
errorMessage = errorData?.message || errorData?.error || errorMessage;
|
||||
} catch {
|
||||
// Ignore JSON parse failures and use fallback message.
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
const result = (await response.json()) as CommandExecutionResult;
|
||||
if (result.type === 'builtin') {
|
||||
handleBuiltInCommand(result);
|
||||
setInput('');
|
||||
inputValueRef.current = '';
|
||||
} else if (result.type === 'custom') {
|
||||
await handleCustomCommand(result);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
console.error('Error executing command:', error);
|
||||
setChatMessages((previous) => [
|
||||
...previous,
|
||||
{
|
||||
type: 'assistant',
|
||||
content: `Error executing command: ${message}`,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]);
|
||||
}
|
||||
},
|
||||
[
|
||||
claudeModel,
|
||||
codexModel,
|
||||
currentSessionId,
|
||||
cursorModel,
|
||||
handleBuiltInCommand,
|
||||
handleCustomCommand,
|
||||
input,
|
||||
provider,
|
||||
selectedProject,
|
||||
setChatMessages,
|
||||
tokenBudget,
|
||||
],
|
||||
);
|
||||
|
||||
const {
|
||||
slashCommands,
|
||||
slashCommandsCount,
|
||||
filteredCommands,
|
||||
frequentCommands,
|
||||
commandQuery,
|
||||
showCommandMenu,
|
||||
selectedCommandIndex,
|
||||
resetCommandMenuState,
|
||||
handleCommandSelect,
|
||||
handleToggleCommandMenu,
|
||||
handleCommandInputChange,
|
||||
handleCommandMenuKeyDown,
|
||||
} = useSlashCommands({
|
||||
selectedProject,
|
||||
input,
|
||||
setInput,
|
||||
textareaRef,
|
||||
onExecuteCommand: executeCommand,
|
||||
});
|
||||
|
||||
const {
|
||||
showFileDropdown,
|
||||
filteredFiles,
|
||||
selectedFileIndex,
|
||||
renderInputWithMentions,
|
||||
selectFile,
|
||||
setCursorPosition,
|
||||
handleFileMentionsKeyDown,
|
||||
} = useFileMentions({
|
||||
selectedProject,
|
||||
input,
|
||||
setInput,
|
||||
textareaRef,
|
||||
});
|
||||
|
||||
const syncInputOverlayScroll = useCallback((target: HTMLTextAreaElement) => {
|
||||
if (!inputHighlightRef.current || !target) {
|
||||
return;
|
||||
}
|
||||
inputHighlightRef.current.scrollTop = target.scrollTop;
|
||||
inputHighlightRef.current.scrollLeft = target.scrollLeft;
|
||||
}, []);
|
||||
|
||||
const handleImageFiles = useCallback((files: File[]) => {
|
||||
const validFiles = files.filter((file) => {
|
||||
try {
|
||||
if (!file || typeof file !== 'object') {
|
||||
console.warn('Invalid file object:', file);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!file.type || !file.type.startsWith('image/')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!file.size || file.size > 5 * 1024 * 1024) {
|
||||
const fileName = file.name || 'Unknown file';
|
||||
setImageErrors((previous) => {
|
||||
const next = new Map(previous);
|
||||
next.set(fileName, 'File too large (max 5MB)');
|
||||
return next;
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error validating file:', error, file);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
if (validFiles.length > 0) {
|
||||
setAttachedImages((previous) => [...previous, ...validFiles].slice(0, 5));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handlePaste = useCallback(
|
||||
(event: ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
const items = Array.from(event.clipboardData.items);
|
||||
|
||||
items.forEach((item) => {
|
||||
if (!item.type.startsWith('image/')) {
|
||||
return;
|
||||
}
|
||||
const file = item.getAsFile();
|
||||
if (file) {
|
||||
handleImageFiles([file]);
|
||||
}
|
||||
});
|
||||
|
||||
if (items.length === 0 && event.clipboardData.files.length > 0) {
|
||||
const files = Array.from(event.clipboardData.files);
|
||||
const imageFiles = files.filter((file) => file.type.startsWith('image/'));
|
||||
if (imageFiles.length > 0) {
|
||||
handleImageFiles(imageFiles);
|
||||
}
|
||||
}
|
||||
},
|
||||
[handleImageFiles],
|
||||
);
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive, open } = useDropzone({
|
||||
accept: {
|
||||
'image/*': ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'],
|
||||
},
|
||||
maxSize: 5 * 1024 * 1024,
|
||||
maxFiles: 5,
|
||||
onDrop: handleImageFiles,
|
||||
noClick: true,
|
||||
noKeyboard: true,
|
||||
});
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (
|
||||
event: FormEvent<HTMLFormElement> | MouseEvent | TouchEvent | KeyboardEvent<HTMLTextAreaElement>,
|
||||
) => {
|
||||
event.preventDefault();
|
||||
const currentInput = inputValueRef.current;
|
||||
if (!currentInput.trim() || isLoading || !selectedProject) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Intercept slash commands: if input starts with /commandName, execute as command with args
|
||||
const trimmedInput = currentInput.trim();
|
||||
if (trimmedInput.startsWith('/')) {
|
||||
const firstSpace = trimmedInput.indexOf(' ');
|
||||
const commandName = firstSpace > 0 ? trimmedInput.slice(0, firstSpace) : trimmedInput;
|
||||
const matchedCommand = slashCommands.find((cmd: SlashCommand) => cmd.name === commandName);
|
||||
if (matchedCommand) {
|
||||
executeCommand(matchedCommand, trimmedInput);
|
||||
setInput('');
|
||||
inputValueRef.current = '';
|
||||
setAttachedImages([]);
|
||||
setUploadingImages(new Map());
|
||||
setImageErrors(new Map());
|
||||
resetCommandMenuState();
|
||||
setIsTextareaExpanded(false);
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto';
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let messageContent = currentInput;
|
||||
const selectedThinkingMode = thinkingModes.find((mode: { id: string; prefix?: string }) => mode.id === thinkingMode);
|
||||
if (selectedThinkingMode && selectedThinkingMode.prefix) {
|
||||
messageContent = `${selectedThinkingMode.prefix}: ${currentInput}`;
|
||||
}
|
||||
|
||||
let uploadedImages: unknown[] = [];
|
||||
if (attachedImages.length > 0) {
|
||||
const formData = new FormData();
|
||||
attachedImages.forEach((file) => {
|
||||
formData.append('images', file);
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await authenticatedFetch(`/api/projects/${selectedProject.name}/upload-images`, {
|
||||
method: 'POST',
|
||||
headers: {},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to upload images');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
uploadedImages = result.images;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
console.error('Image upload failed:', error);
|
||||
setChatMessages((previous) => [
|
||||
...previous,
|
||||
{
|
||||
type: 'error',
|
||||
content: `Failed to upload images: ${message}`,
|
||||
timestamp: new Date(),
|
||||
},
|
||||
]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const userMessage: ChatMessage = {
|
||||
type: 'user',
|
||||
content: currentInput,
|
||||
images: uploadedImages as any,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
setChatMessages((previous) => [...previous, userMessage]);
|
||||
setIsLoading(true);
|
||||
setCanAbortSession(true);
|
||||
setClaudeStatus({
|
||||
text: 'Processing',
|
||||
tokens: 0,
|
||||
can_interrupt: true,
|
||||
});
|
||||
|
||||
setIsUserScrolledUp(false);
|
||||
setTimeout(() => scrollToBottom(), 100);
|
||||
|
||||
const effectiveSessionId =
|
||||
currentSessionId || selectedSession?.id || sessionStorage.getItem('cursorSessionId');
|
||||
const sessionToActivate = effectiveSessionId || `new-session-${Date.now()}`;
|
||||
|
||||
if (!effectiveSessionId && !selectedSession?.id) {
|
||||
if (typeof window !== 'undefined') {
|
||||
// Reset stale pending IDs from previous interrupted runs before creating a new one.
|
||||
sessionStorage.removeItem('pendingSessionId');
|
||||
}
|
||||
pendingViewSessionRef.current = { sessionId: null, startedAt: Date.now() };
|
||||
}
|
||||
onSessionActive?.(sessionToActivate);
|
||||
|
||||
const getToolsSettings = () => {
|
||||
try {
|
||||
const settingsKey =
|
||||
provider === 'cursor'
|
||||
? 'cursor-tools-settings'
|
||||
: provider === 'codex'
|
||||
? 'codex-settings'
|
||||
: 'claude-settings';
|
||||
const savedSettings = safeLocalStorage.getItem(settingsKey);
|
||||
if (savedSettings) {
|
||||
return JSON.parse(savedSettings);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading tools settings:', error);
|
||||
}
|
||||
|
||||
return {
|
||||
allowedTools: [],
|
||||
disallowedTools: [],
|
||||
skipPermissions: false,
|
||||
};
|
||||
};
|
||||
|
||||
const toolsSettings = getToolsSettings();
|
||||
const resolvedProjectPath = selectedProject.fullPath || selectedProject.path || '';
|
||||
|
||||
if (provider === 'cursor') {
|
||||
sendMessage({
|
||||
type: 'cursor-command',
|
||||
command: messageContent,
|
||||
sessionId: effectiveSessionId,
|
||||
options: {
|
||||
cwd: resolvedProjectPath,
|
||||
projectPath: resolvedProjectPath,
|
||||
sessionId: effectiveSessionId,
|
||||
resume: Boolean(effectiveSessionId),
|
||||
model: cursorModel,
|
||||
skipPermissions: toolsSettings?.skipPermissions || false,
|
||||
toolsSettings,
|
||||
},
|
||||
});
|
||||
} else if (provider === 'codex') {
|
||||
sendMessage({
|
||||
type: 'codex-command',
|
||||
command: messageContent,
|
||||
sessionId: effectiveSessionId,
|
||||
options: {
|
||||
cwd: resolvedProjectPath,
|
||||
projectPath: resolvedProjectPath,
|
||||
sessionId: effectiveSessionId,
|
||||
resume: Boolean(effectiveSessionId),
|
||||
model: codexModel,
|
||||
permissionMode: permissionMode === 'plan' ? 'default' : permissionMode,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
sendMessage({
|
||||
type: 'claude-command',
|
||||
command: messageContent,
|
||||
options: {
|
||||
projectPath: resolvedProjectPath,
|
||||
cwd: resolvedProjectPath,
|
||||
sessionId: effectiveSessionId,
|
||||
resume: Boolean(effectiveSessionId),
|
||||
toolsSettings,
|
||||
permissionMode,
|
||||
model: claudeModel,
|
||||
images: uploadedImages,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
setInput('');
|
||||
inputValueRef.current = '';
|
||||
resetCommandMenuState();
|
||||
setAttachedImages([]);
|
||||
setUploadingImages(new Map());
|
||||
setImageErrors(new Map());
|
||||
setIsTextareaExpanded(false);
|
||||
setThinkingMode('none');
|
||||
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto';
|
||||
}
|
||||
|
||||
safeLocalStorage.removeItem(`draft_input_${selectedProject.name}`);
|
||||
},
|
||||
[
|
||||
attachedImages,
|
||||
claudeModel,
|
||||
codexModel,
|
||||
currentSessionId,
|
||||
cursorModel,
|
||||
executeCommand,
|
||||
isLoading,
|
||||
onSessionActive,
|
||||
pendingViewSessionRef,
|
||||
permissionMode,
|
||||
provider,
|
||||
resetCommandMenuState,
|
||||
scrollToBottom,
|
||||
selectedProject,
|
||||
selectedSession?.id,
|
||||
sendMessage,
|
||||
setCanAbortSession,
|
||||
setChatMessages,
|
||||
setClaudeStatus,
|
||||
setIsLoading,
|
||||
setIsUserScrolledUp,
|
||||
slashCommands,
|
||||
thinkingMode,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
handleSubmitRef.current = handleSubmit;
|
||||
}, [handleSubmit]);
|
||||
|
||||
useEffect(() => {
|
||||
inputValueRef.current = input;
|
||||
}, [input]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedProject) {
|
||||
return;
|
||||
}
|
||||
const savedInput = safeLocalStorage.getItem(`draft_input_${selectedProject.name}`) || '';
|
||||
setInput((previous) => {
|
||||
const next = previous === savedInput ? previous : savedInput;
|
||||
inputValueRef.current = next;
|
||||
return next;
|
||||
});
|
||||
}, [selectedProject?.name]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedProject) {
|
||||
return;
|
||||
}
|
||||
if (input !== '') {
|
||||
safeLocalStorage.setItem(`draft_input_${selectedProject.name}`, input);
|
||||
} else {
|
||||
safeLocalStorage.removeItem(`draft_input_${selectedProject.name}`);
|
||||
}
|
||||
}, [input, selectedProject]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!textareaRef.current) {
|
||||
return;
|
||||
}
|
||||
// Re-run when input changes so restored drafts get the same autosize behavior as typed text.
|
||||
textareaRef.current.style.height = 'auto';
|
||||
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
|
||||
const lineHeight = parseInt(window.getComputedStyle(textareaRef.current).lineHeight);
|
||||
const expanded = textareaRef.current.scrollHeight > lineHeight * 2;
|
||||
setIsTextareaExpanded(expanded);
|
||||
}, [input]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!textareaRef.current || input.trim()) {
|
||||
return;
|
||||
}
|
||||
textareaRef.current.style.height = 'auto';
|
||||
setIsTextareaExpanded(false);
|
||||
}, [input]);
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
(event: ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const newValue = event.target.value;
|
||||
const cursorPos = event.target.selectionStart;
|
||||
|
||||
setInput(newValue);
|
||||
inputValueRef.current = newValue;
|
||||
setCursorPosition(cursorPos);
|
||||
|
||||
if (!newValue.trim()) {
|
||||
event.target.style.height = 'auto';
|
||||
setIsTextareaExpanded(false);
|
||||
resetCommandMenuState();
|
||||
return;
|
||||
}
|
||||
|
||||
handleCommandInputChange(newValue, cursorPos);
|
||||
},
|
||||
[handleCommandInputChange, resetCommandMenuState, setCursorPosition],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (handleCommandMenuKeyDown(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (handleFileMentionsKeyDown(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Tab' && !showFileDropdown && !showCommandMenu) {
|
||||
event.preventDefault();
|
||||
cyclePermissionMode();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
if (event.nativeEvent.isComposing) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ((event.ctrlKey || event.metaKey) && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
handleSubmit(event);
|
||||
} else if (!event.shiftKey && !event.ctrlKey && !event.metaKey && !sendByCtrlEnter) {
|
||||
event.preventDefault();
|
||||
handleSubmit(event);
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
cyclePermissionMode,
|
||||
handleCommandMenuKeyDown,
|
||||
handleFileMentionsKeyDown,
|
||||
handleSubmit,
|
||||
sendByCtrlEnter,
|
||||
showCommandMenu,
|
||||
showFileDropdown,
|
||||
],
|
||||
);
|
||||
|
||||
const handleTextareaClick = useCallback(
|
||||
(event: MouseEvent<HTMLTextAreaElement>) => {
|
||||
setCursorPosition(event.currentTarget.selectionStart);
|
||||
},
|
||||
[setCursorPosition],
|
||||
);
|
||||
|
||||
const handleTextareaInput = useCallback(
|
||||
(event: FormEvent<HTMLTextAreaElement>) => {
|
||||
const target = event.currentTarget;
|
||||
target.style.height = 'auto';
|
||||
target.style.height = `${target.scrollHeight}px`;
|
||||
setCursorPosition(target.selectionStart);
|
||||
syncInputOverlayScroll(target);
|
||||
|
||||
const lineHeight = parseInt(window.getComputedStyle(target).lineHeight);
|
||||
setIsTextareaExpanded(target.scrollHeight > lineHeight * 2);
|
||||
},
|
||||
[setCursorPosition, syncInputOverlayScroll],
|
||||
);
|
||||
|
||||
const handleClearInput = useCallback(() => {
|
||||
setInput('');
|
||||
inputValueRef.current = '';
|
||||
resetCommandMenuState();
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto';
|
||||
textareaRef.current.focus();
|
||||
}
|
||||
setIsTextareaExpanded(false);
|
||||
}, [resetCommandMenuState]);
|
||||
|
||||
const handleAbortSession = useCallback(() => {
|
||||
if (!canAbortSession) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pendingSessionId =
|
||||
typeof window !== 'undefined' ? sessionStorage.getItem('pendingSessionId') : null;
|
||||
const cursorSessionId =
|
||||
typeof window !== 'undefined' ? sessionStorage.getItem('cursorSessionId') : null;
|
||||
|
||||
const candidateSessionIds = [
|
||||
currentSessionId,
|
||||
pendingViewSessionRef.current?.sessionId || null,
|
||||
pendingSessionId,
|
||||
provider === 'cursor' ? cursorSessionId : null,
|
||||
selectedSession?.id || null,
|
||||
];
|
||||
|
||||
const targetSessionId =
|
||||
candidateSessionIds.find((sessionId) => Boolean(sessionId) && !isTemporarySessionId(sessionId)) || null;
|
||||
|
||||
if (!targetSessionId) {
|
||||
console.warn('Abort requested but no concrete session ID is available yet.');
|
||||
return;
|
||||
}
|
||||
|
||||
sendMessage({
|
||||
type: 'abort-session',
|
||||
sessionId: targetSessionId,
|
||||
provider,
|
||||
});
|
||||
}, [canAbortSession, currentSessionId, pendingViewSessionRef, provider, selectedSession?.id, sendMessage]);
|
||||
|
||||
const handleTranscript = useCallback((text: string) => {
|
||||
if (!text.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setInput((previousInput) => {
|
||||
const newInput = previousInput.trim() ? `${previousInput} ${text}` : text;
|
||||
inputValueRef.current = newInput;
|
||||
|
||||
setTimeout(() => {
|
||||
if (!textareaRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
textareaRef.current.style.height = 'auto';
|
||||
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
|
||||
const lineHeight = parseInt(window.getComputedStyle(textareaRef.current).lineHeight);
|
||||
setIsTextareaExpanded(textareaRef.current.scrollHeight > lineHeight * 2);
|
||||
}, 0);
|
||||
|
||||
return newInput;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleGrantToolPermission = useCallback(
|
||||
(suggestion: { entry: string; toolName: string }) => {
|
||||
if (!suggestion || provider !== 'claude') {
|
||||
return { success: false };
|
||||
}
|
||||
return grantClaudeToolPermission(suggestion.entry);
|
||||
},
|
||||
[provider],
|
||||
);
|
||||
|
||||
const handlePermissionDecision = useCallback(
|
||||
(
|
||||
requestIds: string | string[],
|
||||
decision: { allow?: boolean; message?: string; rememberEntry?: string | null; updatedInput?: unknown },
|
||||
) => {
|
||||
const ids = Array.isArray(requestIds) ? requestIds : [requestIds];
|
||||
const validIds = ids.filter(Boolean);
|
||||
if (validIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
validIds.forEach((requestId) => {
|
||||
sendMessage({
|
||||
type: 'claude-permission-response',
|
||||
requestId,
|
||||
allow: Boolean(decision?.allow),
|
||||
updatedInput: decision?.updatedInput,
|
||||
message: decision?.message,
|
||||
rememberEntry: decision?.rememberEntry,
|
||||
});
|
||||
});
|
||||
|
||||
setPendingPermissionRequests((previous) => {
|
||||
const next = previous.filter((request) => !validIds.includes(request.requestId));
|
||||
if (next.length === 0) {
|
||||
setClaudeStatus(null);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[sendMessage, setClaudeStatus, setPendingPermissionRequests],
|
||||
);
|
||||
|
||||
const [isInputFocused, setIsInputFocused] = useState(false);
|
||||
|
||||
const handleInputFocusChange = useCallback(
|
||||
(focused: boolean) => {
|
||||
setIsInputFocused(focused);
|
||||
onInputFocusChange?.(focused);
|
||||
},
|
||||
[onInputFocusChange],
|
||||
);
|
||||
|
||||
return {
|
||||
input,
|
||||
setInput,
|
||||
textareaRef,
|
||||
inputHighlightRef,
|
||||
isTextareaExpanded,
|
||||
thinkingMode,
|
||||
setThinkingMode,
|
||||
slashCommandsCount,
|
||||
filteredCommands,
|
||||
frequentCommands,
|
||||
commandQuery,
|
||||
showCommandMenu,
|
||||
selectedCommandIndex,
|
||||
resetCommandMenuState,
|
||||
handleCommandSelect,
|
||||
handleToggleCommandMenu,
|
||||
showFileDropdown,
|
||||
filteredFiles: filteredFiles as MentionableFile[],
|
||||
selectedFileIndex,
|
||||
renderInputWithMentions,
|
||||
selectFile,
|
||||
attachedImages,
|
||||
setAttachedImages,
|
||||
uploadingImages,
|
||||
imageErrors,
|
||||
getRootProps,
|
||||
getInputProps,
|
||||
isDragActive,
|
||||
openImagePicker: open,
|
||||
handleSubmit,
|
||||
handleInputChange,
|
||||
handleKeyDown,
|
||||
handlePaste,
|
||||
handleTextareaClick,
|
||||
handleTextareaInput,
|
||||
syncInputOverlayScroll,
|
||||
handleClearInput,
|
||||
handleAbortSession,
|
||||
handleTranscript,
|
||||
handlePermissionDecision,
|
||||
handleGrantToolPermission,
|
||||
handleInputFocusChange,
|
||||
isInputFocused,
|
||||
};
|
||||
}
|
||||
114
src/components/chat/hooks/useChatProviderState.ts
Normal file
114
src/components/chat/hooks/useChatProviderState.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { authenticatedFetch } from '../../../utils/api';
|
||||
import { CLAUDE_MODELS, CODEX_MODELS, CURSOR_MODELS } from '../../../../shared/modelConstants';
|
||||
import type { PendingPermissionRequest, PermissionMode, Provider } from '../types/types';
|
||||
import type { ProjectSession, SessionProvider } from '../../../types/app';
|
||||
|
||||
interface UseChatProviderStateArgs {
|
||||
selectedSession: ProjectSession | null;
|
||||
}
|
||||
|
||||
export function useChatProviderState({ selectedSession }: UseChatProviderStateArgs) {
|
||||
const [permissionMode, setPermissionMode] = useState<PermissionMode>('default');
|
||||
const [pendingPermissionRequests, setPendingPermissionRequests] = useState<PendingPermissionRequest[]>([]);
|
||||
const [provider, setProvider] = useState<SessionProvider>(() => {
|
||||
return (localStorage.getItem('selected-provider') as SessionProvider) || 'claude';
|
||||
});
|
||||
const [cursorModel, setCursorModel] = useState<string>(() => {
|
||||
return localStorage.getItem('cursor-model') || CURSOR_MODELS.DEFAULT;
|
||||
});
|
||||
const [claudeModel, setClaudeModel] = useState<string>(() => {
|
||||
return localStorage.getItem('claude-model') || CLAUDE_MODELS.DEFAULT;
|
||||
});
|
||||
const [codexModel, setCodexModel] = useState<string>(() => {
|
||||
return localStorage.getItem('codex-model') || CODEX_MODELS.DEFAULT;
|
||||
});
|
||||
|
||||
const lastProviderRef = useRef(provider);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedSession?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const savedMode = localStorage.getItem(`permissionMode-${selectedSession.id}`);
|
||||
setPermissionMode((savedMode as PermissionMode) || 'default');
|
||||
}, [selectedSession?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedSession?.__provider || selectedSession.__provider === provider) {
|
||||
return;
|
||||
}
|
||||
|
||||
setProvider(selectedSession.__provider);
|
||||
localStorage.setItem('selected-provider', selectedSession.__provider);
|
||||
}, [provider, selectedSession]);
|
||||
|
||||
useEffect(() => {
|
||||
if (lastProviderRef.current === provider) {
|
||||
return;
|
||||
}
|
||||
setPendingPermissionRequests([]);
|
||||
lastProviderRef.current = provider;
|
||||
}, [provider]);
|
||||
|
||||
useEffect(() => {
|
||||
setPendingPermissionRequests((previous) =>
|
||||
previous.filter((request) => !request.sessionId || request.sessionId === selectedSession?.id),
|
||||
);
|
||||
}, [selectedSession?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (provider !== 'cursor') {
|
||||
return;
|
||||
}
|
||||
|
||||
authenticatedFetch('/api/cursor/config')
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
if (!data.success || !data.config?.model?.modelId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const modelId = data.config.model.modelId as string;
|
||||
if (!localStorage.getItem('cursor-model')) {
|
||||
setCursorModel(modelId);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Error loading Cursor config:', error);
|
||||
});
|
||||
}, [provider]);
|
||||
|
||||
const cyclePermissionMode = useCallback(() => {
|
||||
const modes: PermissionMode[] =
|
||||
provider === 'codex'
|
||||
? ['default', 'acceptEdits', 'bypassPermissions']
|
||||
: ['default', 'acceptEdits', 'bypassPermissions', 'plan'];
|
||||
|
||||
const currentIndex = modes.indexOf(permissionMode);
|
||||
const nextIndex = (currentIndex + 1) % modes.length;
|
||||
const nextMode = modes[nextIndex];
|
||||
setPermissionMode(nextMode);
|
||||
|
||||
if (selectedSession?.id) {
|
||||
localStorage.setItem(`permissionMode-${selectedSession.id}`, nextMode);
|
||||
}
|
||||
}, [permissionMode, provider, selectedSession?.id]);
|
||||
|
||||
return {
|
||||
provider,
|
||||
setProvider,
|
||||
cursorModel,
|
||||
setCursorModel,
|
||||
claudeModel,
|
||||
setClaudeModel,
|
||||
codexModel,
|
||||
setCodexModel,
|
||||
permissionMode,
|
||||
setPermissionMode,
|
||||
pendingPermissionRequests,
|
||||
setPendingPermissionRequests,
|
||||
cyclePermissionMode,
|
||||
};
|
||||
}
|
||||
1028
src/components/chat/hooks/useChatRealtimeHandlers.ts
Normal file
1028
src/components/chat/hooks/useChatRealtimeHandlers.ts
Normal file
File diff suppressed because it is too large
Load Diff
745
src/components/chat/hooks/useChatSessionState.ts
Normal file
745
src/components/chat/hooks/useChatSessionState.ts
Normal file
@@ -0,0 +1,745 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { MutableRefObject } from 'react';
|
||||
|
||||
import { api, authenticatedFetch } from '../../../utils/api';
|
||||
import type { ChatMessage, Provider } from '../types/types';
|
||||
import type { Project, ProjectSession } from '../../../types/app';
|
||||
import { safeLocalStorage } from '../utils/chatStorage';
|
||||
import {
|
||||
convertCursorSessionMessages,
|
||||
convertSessionMessages,
|
||||
createCachedDiffCalculator,
|
||||
type DiffCalculator,
|
||||
} from '../utils/messageTransforms';
|
||||
|
||||
const MESSAGES_PER_PAGE = 20;
|
||||
const INITIAL_VISIBLE_MESSAGES = 100;
|
||||
|
||||
type PendingViewSession = {
|
||||
sessionId: string | null;
|
||||
startedAt: number;
|
||||
};
|
||||
|
||||
interface UseChatSessionStateArgs {
|
||||
selectedProject: Project | null;
|
||||
selectedSession: ProjectSession | null;
|
||||
ws: WebSocket | null;
|
||||
sendMessage: (message: unknown) => void;
|
||||
autoScrollToBottom?: boolean;
|
||||
externalMessageUpdate?: number;
|
||||
processingSessions?: Set<string>;
|
||||
resetStreamingState: () => void;
|
||||
pendingViewSessionRef: MutableRefObject<PendingViewSession | null>;
|
||||
}
|
||||
|
||||
interface ScrollRestoreState {
|
||||
height: number;
|
||||
top: number;
|
||||
}
|
||||
|
||||
export function useChatSessionState({
|
||||
selectedProject,
|
||||
selectedSession,
|
||||
ws,
|
||||
sendMessage,
|
||||
autoScrollToBottom,
|
||||
externalMessageUpdate,
|
||||
processingSessions,
|
||||
resetStreamingState,
|
||||
pendingViewSessionRef,
|
||||
}: UseChatSessionStateArgs) {
|
||||
const [chatMessages, setChatMessages] = useState<ChatMessage[]>(() => {
|
||||
if (typeof window !== 'undefined' && selectedProject) {
|
||||
const saved = safeLocalStorage.getItem(`chat_messages_${selectedProject.name}`);
|
||||
if (saved) {
|
||||
try {
|
||||
return JSON.parse(saved) as ChatMessage[];
|
||||
} catch {
|
||||
console.error('Failed to parse saved chat messages, resetting');
|
||||
safeLocalStorage.removeItem(`chat_messages_${selectedProject.name}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [currentSessionId, setCurrentSessionId] = useState<string | null>(selectedSession?.id || null);
|
||||
const [sessionMessages, setSessionMessages] = useState<any[]>([]);
|
||||
const [isLoadingSessionMessages, setIsLoadingSessionMessages] = useState(false);
|
||||
const [isLoadingMoreMessages, setIsLoadingMoreMessages] = useState(false);
|
||||
const [hasMoreMessages, setHasMoreMessages] = useState(false);
|
||||
const [totalMessages, setTotalMessages] = useState(0);
|
||||
const [isSystemSessionChange, setIsSystemSessionChange] = useState(false);
|
||||
const [canAbortSession, setCanAbortSession] = useState(false);
|
||||
const [isUserScrolledUp, setIsUserScrolledUp] = useState(false);
|
||||
const [tokenBudget, setTokenBudget] = useState<Record<string, unknown> | null>(null);
|
||||
const [visibleMessageCount, setVisibleMessageCount] = useState(INITIAL_VISIBLE_MESSAGES);
|
||||
const [claudeStatus, setClaudeStatus] = useState<{ text: string; tokens: number; can_interrupt: boolean } | null>(null);
|
||||
const [allMessagesLoaded, setAllMessagesLoaded] = useState(false);
|
||||
const [isLoadingAllMessages, setIsLoadingAllMessages] = useState(false);
|
||||
const [loadAllJustFinished, setLoadAllJustFinished] = useState(false);
|
||||
const [showLoadAllOverlay, setShowLoadAllOverlay] = useState(false);
|
||||
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const isLoadingSessionRef = useRef(false);
|
||||
const isLoadingMoreRef = useRef(false);
|
||||
const allMessagesLoadedRef = useRef(false);
|
||||
const topLoadLockRef = useRef(false);
|
||||
const pendingScrollRestoreRef = useRef<ScrollRestoreState | null>(null);
|
||||
const pendingInitialScrollRef = useRef(true);
|
||||
const messagesOffsetRef = useRef(0);
|
||||
const scrollPositionRef = useRef({ height: 0, top: 0 });
|
||||
const loadAllFinishedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const loadAllOverlayTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const createDiff = useMemo<DiffCalculator>(() => createCachedDiffCalculator(), []);
|
||||
|
||||
const loadSessionMessages = useCallback(
|
||||
async (projectName: string, sessionId: string, loadMore = false, provider: Provider | string = 'claude') => {
|
||||
if (!projectName || !sessionId) {
|
||||
return [] as any[];
|
||||
}
|
||||
|
||||
const isInitialLoad = !loadMore;
|
||||
if (isInitialLoad) {
|
||||
setIsLoadingSessionMessages(true);
|
||||
} else {
|
||||
setIsLoadingMoreMessages(true);
|
||||
}
|
||||
|
||||
try {
|
||||
const currentOffset = loadMore ? messagesOffsetRef.current : 0;
|
||||
const response = await (api.sessionMessages as any)(
|
||||
projectName,
|
||||
sessionId,
|
||||
MESSAGES_PER_PAGE,
|
||||
currentOffset,
|
||||
provider,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load session messages');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (isInitialLoad && data.tokenUsage) {
|
||||
setTokenBudget(data.tokenUsage);
|
||||
}
|
||||
|
||||
if (data.hasMore !== undefined) {
|
||||
const loadedCount = data.messages?.length || 0;
|
||||
setHasMoreMessages(Boolean(data.hasMore));
|
||||
setTotalMessages(Number(data.total || 0));
|
||||
messagesOffsetRef.current = currentOffset + loadedCount;
|
||||
return data.messages || [];
|
||||
}
|
||||
|
||||
const messages = data.messages || [];
|
||||
setHasMoreMessages(false);
|
||||
setTotalMessages(messages.length);
|
||||
messagesOffsetRef.current = messages.length;
|
||||
return messages;
|
||||
} catch (error) {
|
||||
console.error('Error loading session messages:', error);
|
||||
return [];
|
||||
} finally {
|
||||
if (isInitialLoad) {
|
||||
setIsLoadingSessionMessages(false);
|
||||
} else {
|
||||
setIsLoadingMoreMessages(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const loadCursorSessionMessages = useCallback(async (projectPath: string, sessionId: string) => {
|
||||
if (!projectPath || !sessionId) {
|
||||
return [] as ChatMessage[];
|
||||
}
|
||||
|
||||
setIsLoadingSessionMessages(true);
|
||||
try {
|
||||
const url = `/api/cursor/sessions/${encodeURIComponent(sessionId)}?projectPath=${encodeURIComponent(projectPath)}`;
|
||||
const response = await authenticatedFetch(url);
|
||||
if (!response.ok) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const blobs = (data?.session?.messages || []) as any[];
|
||||
return convertCursorSessionMessages(blobs, projectPath);
|
||||
} catch (error) {
|
||||
console.error('Error loading Cursor session messages:', error);
|
||||
return [];
|
||||
} finally {
|
||||
setIsLoadingSessionMessages(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const convertedMessages = useMemo(() => {
|
||||
return convertSessionMessages(sessionMessages);
|
||||
}, [sessionMessages]);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}, []);
|
||||
|
||||
const scrollToBottomAndReset = useCallback(() => {
|
||||
scrollToBottom();
|
||||
if (allMessagesLoaded) {
|
||||
setVisibleMessageCount(INITIAL_VISIBLE_MESSAGES);
|
||||
setAllMessagesLoaded(false);
|
||||
allMessagesLoadedRef.current = false;
|
||||
}
|
||||
}, [allMessagesLoaded, scrollToBottom]);
|
||||
|
||||
const isNearBottom = useCallback(() => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) {
|
||||
return false;
|
||||
}
|
||||
const { scrollTop, scrollHeight, clientHeight } = container;
|
||||
return scrollHeight - scrollTop - clientHeight < 50;
|
||||
}, []);
|
||||
|
||||
const loadOlderMessages = useCallback(
|
||||
async (container: HTMLDivElement) => {
|
||||
if (!container || isLoadingMoreRef.current || isLoadingMoreMessages) {
|
||||
return false;
|
||||
}
|
||||
if (allMessagesLoadedRef.current) return false;
|
||||
if (!hasMoreMessages || !selectedSession || !selectedProject) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const sessionProvider = selectedSession.__provider || 'claude';
|
||||
if (sessionProvider === 'cursor') {
|
||||
return false;
|
||||
}
|
||||
|
||||
isLoadingMoreRef.current = true;
|
||||
const previousScrollHeight = container.scrollHeight;
|
||||
const previousScrollTop = container.scrollTop;
|
||||
|
||||
try {
|
||||
const moreMessages = await loadSessionMessages(
|
||||
selectedProject.name,
|
||||
selectedSession.id,
|
||||
true,
|
||||
sessionProvider,
|
||||
);
|
||||
|
||||
if (moreMessages.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
pendingScrollRestoreRef.current = {
|
||||
height: previousScrollHeight,
|
||||
top: previousScrollTop,
|
||||
};
|
||||
setSessionMessages((previous) => [...moreMessages, ...previous]);
|
||||
// Keep the rendered window in sync with top-pagination so newly loaded history becomes visible.
|
||||
setVisibleMessageCount((previousCount) => previousCount + moreMessages.length);
|
||||
return true;
|
||||
} finally {
|
||||
isLoadingMoreRef.current = false;
|
||||
}
|
||||
},
|
||||
[hasMoreMessages, isLoadingMoreMessages, loadSessionMessages, selectedProject, selectedSession],
|
||||
);
|
||||
|
||||
const handleScroll = useCallback(async () => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nearBottom = isNearBottom();
|
||||
setIsUserScrolledUp(!nearBottom);
|
||||
|
||||
if (!allMessagesLoadedRef.current) {
|
||||
const scrolledNearTop = container.scrollTop < 100;
|
||||
if (!scrolledNearTop) {
|
||||
topLoadLockRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (topLoadLockRef.current) {
|
||||
if (container.scrollTop > 20) {
|
||||
topLoadLockRef.current = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const didLoad = await loadOlderMessages(container);
|
||||
if (didLoad) {
|
||||
topLoadLockRef.current = true;
|
||||
}
|
||||
}
|
||||
}, [isNearBottom, loadOlderMessages]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!pendingScrollRestoreRef.current || !scrollContainerRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { height, top } = pendingScrollRestoreRef.current;
|
||||
const container = scrollContainerRef.current;
|
||||
const newScrollHeight = container.scrollHeight;
|
||||
const scrollDiff = newScrollHeight - height;
|
||||
container.scrollTop = top + Math.max(scrollDiff, 0);
|
||||
pendingScrollRestoreRef.current = null;
|
||||
}, [chatMessages.length]);
|
||||
|
||||
useEffect(() => {
|
||||
pendingInitialScrollRef.current = true;
|
||||
topLoadLockRef.current = false;
|
||||
pendingScrollRestoreRef.current = null;
|
||||
setVisibleMessageCount(INITIAL_VISIBLE_MESSAGES);
|
||||
setIsUserScrolledUp(false);
|
||||
}, [selectedProject?.name, selectedSession?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingInitialScrollRef.current || !scrollContainerRef.current || isLoadingSessionMessages) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (chatMessages.length === 0) {
|
||||
pendingInitialScrollRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
pendingInitialScrollRef.current = false;
|
||||
setTimeout(() => {
|
||||
scrollToBottom();
|
||||
}, 200);
|
||||
}, [chatMessages.length, isLoadingSessionMessages, scrollToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadMessages = async () => {
|
||||
if (selectedSession && selectedProject) {
|
||||
const provider = (localStorage.getItem('selected-provider') as Provider) || 'claude';
|
||||
isLoadingSessionRef.current = true;
|
||||
|
||||
const sessionChanged = currentSessionId !== null && currentSessionId !== selectedSession.id;
|
||||
if (sessionChanged) {
|
||||
if (!isSystemSessionChange) {
|
||||
resetStreamingState();
|
||||
pendingViewSessionRef.current = null;
|
||||
setChatMessages([]);
|
||||
setSessionMessages([]);
|
||||
setClaudeStatus(null);
|
||||
setCanAbortSession(false);
|
||||
}
|
||||
|
||||
messagesOffsetRef.current = 0;
|
||||
setHasMoreMessages(false);
|
||||
setTotalMessages(0);
|
||||
setVisibleMessageCount(INITIAL_VISIBLE_MESSAGES);
|
||||
setAllMessagesLoaded(false);
|
||||
allMessagesLoadedRef.current = false;
|
||||
setIsLoadingAllMessages(false);
|
||||
setLoadAllJustFinished(false);
|
||||
setShowLoadAllOverlay(false);
|
||||
if (loadAllOverlayTimerRef.current) clearTimeout(loadAllOverlayTimerRef.current);
|
||||
if (loadAllFinishedTimerRef.current) clearTimeout(loadAllFinishedTimerRef.current);
|
||||
setTokenBudget(null);
|
||||
setIsLoading(false);
|
||||
|
||||
if (ws) {
|
||||
sendMessage({
|
||||
type: 'check-session-status',
|
||||
sessionId: selectedSession.id,
|
||||
provider,
|
||||
});
|
||||
}
|
||||
} else if (currentSessionId === null) {
|
||||
messagesOffsetRef.current = 0;
|
||||
setHasMoreMessages(false);
|
||||
setTotalMessages(0);
|
||||
|
||||
if (ws) {
|
||||
sendMessage({
|
||||
type: 'check-session-status',
|
||||
sessionId: selectedSession.id,
|
||||
provider,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (provider === 'cursor') {
|
||||
setCurrentSessionId(selectedSession.id);
|
||||
sessionStorage.setItem('cursorSessionId', selectedSession.id);
|
||||
|
||||
if (!isSystemSessionChange) {
|
||||
const projectPath = selectedProject.fullPath || selectedProject.path || '';
|
||||
const converted = await loadCursorSessionMessages(projectPath, selectedSession.id);
|
||||
setSessionMessages([]);
|
||||
setChatMessages(converted);
|
||||
} else {
|
||||
setIsSystemSessionChange(false);
|
||||
}
|
||||
} else {
|
||||
setCurrentSessionId(selectedSession.id);
|
||||
|
||||
if (!isSystemSessionChange) {
|
||||
const messages = await loadSessionMessages(
|
||||
selectedProject.name,
|
||||
selectedSession.id,
|
||||
false,
|
||||
selectedSession.__provider || 'claude',
|
||||
);
|
||||
setSessionMessages(messages);
|
||||
} else {
|
||||
setIsSystemSessionChange(false);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (!isSystemSessionChange) {
|
||||
resetStreamingState();
|
||||
pendingViewSessionRef.current = null;
|
||||
setChatMessages([]);
|
||||
setSessionMessages([]);
|
||||
setClaudeStatus(null);
|
||||
setCanAbortSession(false);
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
setCurrentSessionId(null);
|
||||
sessionStorage.removeItem('cursorSessionId');
|
||||
messagesOffsetRef.current = 0;
|
||||
setHasMoreMessages(false);
|
||||
setTotalMessages(0);
|
||||
setTokenBudget(null);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
isLoadingSessionRef.current = false;
|
||||
}, 250);
|
||||
};
|
||||
|
||||
loadMessages();
|
||||
}, [
|
||||
// Intentionally exclude currentSessionId: this effect sets it and should not retrigger another full load.
|
||||
isSystemSessionChange,
|
||||
loadCursorSessionMessages,
|
||||
loadSessionMessages,
|
||||
pendingViewSessionRef,
|
||||
resetStreamingState,
|
||||
selectedProject,
|
||||
selectedSession,
|
||||
sendMessage,
|
||||
ws,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!externalMessageUpdate || !selectedSession || !selectedProject) {
|
||||
return;
|
||||
}
|
||||
|
||||
const reloadExternalMessages = async () => {
|
||||
try {
|
||||
const provider = (localStorage.getItem('selected-provider') as Provider) || 'claude';
|
||||
|
||||
if (provider === 'cursor') {
|
||||
const projectPath = selectedProject.fullPath || selectedProject.path || '';
|
||||
const converted = await loadCursorSessionMessages(projectPath, selectedSession.id);
|
||||
setSessionMessages([]);
|
||||
setChatMessages(converted);
|
||||
return;
|
||||
}
|
||||
|
||||
const messages = await loadSessionMessages(
|
||||
selectedProject.name,
|
||||
selectedSession.id,
|
||||
false,
|
||||
selectedSession.__provider || 'claude',
|
||||
);
|
||||
setSessionMessages(messages);
|
||||
|
||||
const shouldAutoScroll = Boolean(autoScrollToBottom) && isNearBottom();
|
||||
if (shouldAutoScroll) {
|
||||
setTimeout(() => scrollToBottom(), 200);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error reloading messages from external update:', error);
|
||||
}
|
||||
};
|
||||
|
||||
reloadExternalMessages();
|
||||
}, [
|
||||
autoScrollToBottom,
|
||||
externalMessageUpdate,
|
||||
isNearBottom,
|
||||
loadCursorSessionMessages,
|
||||
loadSessionMessages,
|
||||
scrollToBottom,
|
||||
selectedProject,
|
||||
selectedSession,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedSession?.id) {
|
||||
pendingViewSessionRef.current = null;
|
||||
}
|
||||
}, [pendingViewSessionRef, selectedSession?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sessionMessages.length > 0) {
|
||||
setChatMessages(convertedMessages);
|
||||
}
|
||||
}, [convertedMessages, sessionMessages.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedProject && chatMessages.length > 0) {
|
||||
safeLocalStorage.setItem(`chat_messages_${selectedProject.name}`, JSON.stringify(chatMessages));
|
||||
}
|
||||
}, [chatMessages, selectedProject]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedProject || !selectedSession?.id || selectedSession.id.startsWith('new-session-')) {
|
||||
setTokenBudget(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionProvider = selectedSession.__provider || 'claude';
|
||||
if (sessionProvider !== 'claude') {
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchInitialTokenUsage = async () => {
|
||||
try {
|
||||
const url = `/api/projects/${selectedProject.name}/sessions/${selectedSession.id}/token-usage`;
|
||||
const response = await authenticatedFetch(url);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setTokenBudget(data);
|
||||
} else {
|
||||
setTokenBudget(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch initial token usage:', error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchInitialTokenUsage();
|
||||
}, [selectedProject, selectedSession?.id, selectedSession?.__provider]);
|
||||
|
||||
const visibleMessages = useMemo(() => {
|
||||
if (chatMessages.length <= visibleMessageCount) {
|
||||
return chatMessages;
|
||||
}
|
||||
return chatMessages.slice(-visibleMessageCount);
|
||||
}, [chatMessages, visibleMessageCount]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoScrollToBottom && scrollContainerRef.current) {
|
||||
const container = scrollContainerRef.current;
|
||||
scrollPositionRef.current = {
|
||||
height: container.scrollHeight,
|
||||
top: container.scrollTop,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!scrollContainerRef.current || chatMessages.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isLoadingMoreRef.current || isLoadingMoreMessages || pendingScrollRestoreRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (autoScrollToBottom) {
|
||||
if (!isUserScrolledUp) {
|
||||
setTimeout(() => scrollToBottom(), 50);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const container = scrollContainerRef.current;
|
||||
const prevHeight = scrollPositionRef.current.height;
|
||||
const prevTop = scrollPositionRef.current.top;
|
||||
const newHeight = container.scrollHeight;
|
||||
const heightDiff = newHeight - prevHeight;
|
||||
|
||||
if (heightDiff > 0 && prevTop > 0) {
|
||||
container.scrollTop = prevTop + heightDiff;
|
||||
}
|
||||
}, [autoScrollToBottom, chatMessages.length, isLoadingMoreMessages, isUserScrolledUp, scrollToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
container.addEventListener('scroll', handleScroll);
|
||||
return () => container.removeEventListener('scroll', handleScroll);
|
||||
}, [handleScroll]);
|
||||
|
||||
useEffect(() => {
|
||||
const activeViewSessionId = selectedSession?.id || currentSessionId;
|
||||
if (!activeViewSessionId || !processingSessions) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldBeProcessing = processingSessions.has(activeViewSessionId);
|
||||
if (shouldBeProcessing && !isLoading) {
|
||||
setIsLoading(true);
|
||||
setCanAbortSession(true);
|
||||
}
|
||||
}, [currentSessionId, isLoading, processingSessions, selectedSession?.id]);
|
||||
|
||||
// Show "Load all" overlay after a batch finishes loading, persist for 2s then hide
|
||||
const prevLoadingRef = useRef(false);
|
||||
useEffect(() => {
|
||||
const wasLoading = prevLoadingRef.current;
|
||||
prevLoadingRef.current = isLoadingMoreMessages;
|
||||
|
||||
if (wasLoading && !isLoadingMoreMessages && hasMoreMessages) {
|
||||
if (loadAllOverlayTimerRef.current) clearTimeout(loadAllOverlayTimerRef.current);
|
||||
setShowLoadAllOverlay(true);
|
||||
loadAllOverlayTimerRef.current = setTimeout(() => {
|
||||
setShowLoadAllOverlay(false);
|
||||
}, 2000);
|
||||
}
|
||||
if (!hasMoreMessages && !isLoadingMoreMessages) {
|
||||
if (loadAllOverlayTimerRef.current) clearTimeout(loadAllOverlayTimerRef.current);
|
||||
setShowLoadAllOverlay(false);
|
||||
}
|
||||
return () => {
|
||||
if (loadAllOverlayTimerRef.current) clearTimeout(loadAllOverlayTimerRef.current);
|
||||
};
|
||||
}, [isLoadingMoreMessages, hasMoreMessages]);
|
||||
|
||||
const loadAllMessages = useCallback(async () => {
|
||||
if (!selectedSession || !selectedProject) return;
|
||||
if (isLoadingAllMessages) return;
|
||||
const sessionProvider = selectedSession.__provider || 'claude';
|
||||
if (sessionProvider === 'cursor') {
|
||||
setVisibleMessageCount(Infinity);
|
||||
setAllMessagesLoaded(true);
|
||||
allMessagesLoadedRef.current = true;
|
||||
setLoadAllJustFinished(true);
|
||||
if (loadAllFinishedTimerRef.current) clearTimeout(loadAllFinishedTimerRef.current);
|
||||
loadAllFinishedTimerRef.current = setTimeout(() => {
|
||||
setLoadAllJustFinished(false);
|
||||
setShowLoadAllOverlay(false);
|
||||
}, 1000);
|
||||
return;
|
||||
}
|
||||
|
||||
const requestSessionId = selectedSession.id;
|
||||
|
||||
allMessagesLoadedRef.current = true;
|
||||
isLoadingMoreRef.current = true;
|
||||
setIsLoadingAllMessages(true);
|
||||
setShowLoadAllOverlay(true);
|
||||
|
||||
const container = scrollContainerRef.current;
|
||||
const previousScrollHeight = container ? container.scrollHeight : 0;
|
||||
const previousScrollTop = container ? container.scrollTop : 0;
|
||||
|
||||
try {
|
||||
const response = await (api.sessionMessages as any)(
|
||||
selectedProject.name,
|
||||
requestSessionId,
|
||||
null,
|
||||
0,
|
||||
sessionProvider,
|
||||
);
|
||||
|
||||
if (currentSessionId !== requestSessionId) return;
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const allMessages = data.messages || data;
|
||||
|
||||
if (container) {
|
||||
pendingScrollRestoreRef.current = {
|
||||
height: previousScrollHeight,
|
||||
top: previousScrollTop,
|
||||
};
|
||||
}
|
||||
|
||||
setSessionMessages(Array.isArray(allMessages) ? allMessages : []);
|
||||
setHasMoreMessages(false);
|
||||
setTotalMessages(Array.isArray(allMessages) ? allMessages.length : 0);
|
||||
messagesOffsetRef.current = Array.isArray(allMessages) ? allMessages.length : 0;
|
||||
|
||||
setVisibleMessageCount(Infinity);
|
||||
setAllMessagesLoaded(true);
|
||||
|
||||
setLoadAllJustFinished(true);
|
||||
if (loadAllFinishedTimerRef.current) clearTimeout(loadAllFinishedTimerRef.current);
|
||||
loadAllFinishedTimerRef.current = setTimeout(() => {
|
||||
setLoadAllJustFinished(false);
|
||||
setShowLoadAllOverlay(false);
|
||||
}, 1000);
|
||||
} else {
|
||||
allMessagesLoadedRef.current = false;
|
||||
setShowLoadAllOverlay(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading all messages:', error);
|
||||
allMessagesLoadedRef.current = false;
|
||||
setShowLoadAllOverlay(false);
|
||||
} finally {
|
||||
isLoadingMoreRef.current = false;
|
||||
setIsLoadingAllMessages(false);
|
||||
}
|
||||
}, [selectedSession, selectedProject, isLoadingAllMessages, currentSessionId]);
|
||||
|
||||
const loadEarlierMessages = useCallback(() => {
|
||||
setVisibleMessageCount((previousCount) => previousCount + 100);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
chatMessages,
|
||||
setChatMessages,
|
||||
isLoading,
|
||||
setIsLoading,
|
||||
currentSessionId,
|
||||
setCurrentSessionId,
|
||||
sessionMessages,
|
||||
setSessionMessages,
|
||||
isLoadingSessionMessages,
|
||||
isLoadingMoreMessages,
|
||||
hasMoreMessages,
|
||||
totalMessages,
|
||||
isSystemSessionChange,
|
||||
setIsSystemSessionChange,
|
||||
canAbortSession,
|
||||
setCanAbortSession,
|
||||
isUserScrolledUp,
|
||||
setIsUserScrolledUp,
|
||||
tokenBudget,
|
||||
setTokenBudget,
|
||||
visibleMessageCount,
|
||||
visibleMessages,
|
||||
loadEarlierMessages,
|
||||
loadAllMessages,
|
||||
allMessagesLoaded,
|
||||
isLoadingAllMessages,
|
||||
loadAllJustFinished,
|
||||
showLoadAllOverlay,
|
||||
claudeStatus,
|
||||
setClaudeStatus,
|
||||
createDiff,
|
||||
scrollContainerRef,
|
||||
scrollToBottom,
|
||||
scrollToBottomAndReset,
|
||||
isNearBottom,
|
||||
handleScroll,
|
||||
loadSessionMessages,
|
||||
loadCursorSessionMessages,
|
||||
};
|
||||
}
|
||||
268
src/components/chat/hooks/useFileMentions.tsx
Normal file
268
src/components/chat/hooks/useFileMentions.tsx
Normal file
@@ -0,0 +1,268 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import type { Dispatch, KeyboardEvent, RefObject, SetStateAction } from 'react';
|
||||
import { api } from '../../../utils/api';
|
||||
import { escapeRegExp } from '../utils/chatFormatting';
|
||||
import type { Project } from '../../../types/app';
|
||||
|
||||
interface ProjectFileNode {
|
||||
name: string;
|
||||
type: 'file' | 'directory';
|
||||
path?: string;
|
||||
children?: ProjectFileNode[];
|
||||
}
|
||||
|
||||
export interface MentionableFile {
|
||||
name: string;
|
||||
path: string;
|
||||
relativePath?: string;
|
||||
}
|
||||
|
||||
interface UseFileMentionsOptions {
|
||||
selectedProject: Project | null;
|
||||
input: string;
|
||||
setInput: Dispatch<SetStateAction<string>>;
|
||||
textareaRef: RefObject<HTMLTextAreaElement>;
|
||||
}
|
||||
|
||||
const flattenFileTree = (files: ProjectFileNode[], basePath = ''): MentionableFile[] => {
|
||||
let flattened: MentionableFile[] = [];
|
||||
|
||||
files.forEach((file) => {
|
||||
const fullPath = basePath ? `${basePath}/${file.name}` : file.name;
|
||||
if (file.type === 'directory' && file.children) {
|
||||
flattened = flattened.concat(flattenFileTree(file.children, fullPath));
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.type === 'file') {
|
||||
flattened.push({
|
||||
name: file.name,
|
||||
path: fullPath,
|
||||
relativePath: file.path,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return flattened;
|
||||
};
|
||||
|
||||
export function useFileMentions({ selectedProject, input, setInput, textareaRef }: UseFileMentionsOptions) {
|
||||
const [fileList, setFileList] = useState<MentionableFile[]>([]);
|
||||
const [fileMentions, setFileMentions] = useState<string[]>([]);
|
||||
const [filteredFiles, setFilteredFiles] = useState<MentionableFile[]>([]);
|
||||
const [showFileDropdown, setShowFileDropdown] = useState(false);
|
||||
const [selectedFileIndex, setSelectedFileIndex] = useState(-1);
|
||||
const [cursorPosition, setCursorPosition] = useState(0);
|
||||
const [atSymbolPosition, setAtSymbolPosition] = useState(-1);
|
||||
|
||||
useEffect(() => {
|
||||
const abortController = new AbortController();
|
||||
|
||||
const fetchProjectFiles = async () => {
|
||||
const projectName = selectedProject?.name;
|
||||
setFileList([]);
|
||||
setFilteredFiles([]);
|
||||
if (!projectName) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
const response = await api.getFiles(projectName, { signal: abortController.signal });
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const files = (await response.json()) as ProjectFileNode[];
|
||||
setFileList(flattenFileTree(files));
|
||||
} catch (error) {
|
||||
// Ignore aborts from rapid project switches; we only care about the latest request.
|
||||
if ((error as { name?: string })?.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
console.error('Error fetching files:', error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchProjectFiles();
|
||||
return () => {
|
||||
abortController.abort();
|
||||
};
|
||||
}, [selectedProject?.name]);
|
||||
|
||||
useEffect(() => {
|
||||
const textBeforeCursor = input.slice(0, cursorPosition);
|
||||
const lastAtIndex = textBeforeCursor.lastIndexOf('@');
|
||||
|
||||
if (lastAtIndex === -1) {
|
||||
setShowFileDropdown(false);
|
||||
setAtSymbolPosition(-1);
|
||||
return;
|
||||
}
|
||||
|
||||
const textAfterAt = textBeforeCursor.slice(lastAtIndex + 1);
|
||||
if (textAfterAt.includes(' ')) {
|
||||
setShowFileDropdown(false);
|
||||
setAtSymbolPosition(-1);
|
||||
return;
|
||||
}
|
||||
|
||||
setAtSymbolPosition(lastAtIndex);
|
||||
setShowFileDropdown(true);
|
||||
setSelectedFileIndex(-1);
|
||||
|
||||
const matchingFiles = fileList
|
||||
.filter(
|
||||
(file) =>
|
||||
file.name.toLowerCase().includes(textAfterAt.toLowerCase()) ||
|
||||
file.path.toLowerCase().includes(textAfterAt.toLowerCase()),
|
||||
)
|
||||
.slice(0, 10);
|
||||
|
||||
setFilteredFiles(matchingFiles);
|
||||
}, [input, cursorPosition, fileList]);
|
||||
|
||||
const activeFileMentions = useMemo(() => {
|
||||
if (!input || fileMentions.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return fileMentions.filter((path) => input.includes(path));
|
||||
}, [fileMentions, input]);
|
||||
|
||||
const sortedFileMentions = useMemo(() => {
|
||||
if (activeFileMentions.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const uniqueMentions = Array.from(new Set(activeFileMentions));
|
||||
return uniqueMentions.sort((mentionA, mentionB) => mentionB.length - mentionA.length);
|
||||
}, [activeFileMentions]);
|
||||
|
||||
const fileMentionRegex = useMemo(() => {
|
||||
if (sortedFileMentions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const pattern = sortedFileMentions.map(escapeRegExp).join('|');
|
||||
return new RegExp(`(${pattern})`, 'g');
|
||||
}, [sortedFileMentions]);
|
||||
|
||||
const fileMentionSet = useMemo(() => new Set(sortedFileMentions), [sortedFileMentions]);
|
||||
|
||||
const renderInputWithMentions = useCallback(
|
||||
(text: string) => {
|
||||
if (!text) {
|
||||
return '';
|
||||
}
|
||||
if (!fileMentionRegex) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const parts = text.split(fileMentionRegex);
|
||||
return parts.map((part, index) =>
|
||||
fileMentionSet.has(part) ? (
|
||||
<span
|
||||
key={`mention-${index}`}
|
||||
className="bg-blue-200/70 -ml-0.5 dark:bg-blue-300/40 px-0.5 rounded-md box-decoration-clone text-transparent"
|
||||
>
|
||||
{part}
|
||||
</span>
|
||||
) : (
|
||||
<span key={`text-${index}`}>{part}</span>
|
||||
),
|
||||
);
|
||||
},
|
||||
[fileMentionRegex, fileMentionSet],
|
||||
);
|
||||
|
||||
const selectFile = useCallback(
|
||||
(file: MentionableFile) => {
|
||||
const textBeforeAt = input.slice(0, atSymbolPosition);
|
||||
const textAfterAtQuery = input.slice(atSymbolPosition);
|
||||
const spaceIndex = textAfterAtQuery.indexOf(' ');
|
||||
const textAfterQuery = spaceIndex !== -1 ? textAfterAtQuery.slice(spaceIndex) : '';
|
||||
|
||||
const newInput = `${textBeforeAt}${file.path} ${textAfterQuery}`;
|
||||
const newCursorPosition = textBeforeAt.length + file.path.length + 1;
|
||||
|
||||
if (textareaRef.current && !textareaRef.current.matches(':focus')) {
|
||||
textareaRef.current.focus();
|
||||
}
|
||||
|
||||
setInput(newInput);
|
||||
setCursorPosition(newCursorPosition);
|
||||
setFileMentions((previousMentions) =>
|
||||
previousMentions.includes(file.path) ? previousMentions : [...previousMentions, file.path],
|
||||
);
|
||||
|
||||
setShowFileDropdown(false);
|
||||
setAtSymbolPosition(-1);
|
||||
|
||||
if (!textareaRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (!textareaRef.current) {
|
||||
return;
|
||||
}
|
||||
textareaRef.current.setSelectionRange(newCursorPosition, newCursorPosition);
|
||||
if (!textareaRef.current.matches(':focus')) {
|
||||
textareaRef.current.focus();
|
||||
}
|
||||
});
|
||||
},
|
||||
[input, atSymbolPosition, textareaRef, setInput],
|
||||
);
|
||||
|
||||
const handleFileMentionsKeyDown = useCallback(
|
||||
(event: KeyboardEvent<HTMLTextAreaElement>): boolean => {
|
||||
if (!showFileDropdown || filteredFiles.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
setSelectedFileIndex((previousIndex) =>
|
||||
previousIndex < filteredFiles.length - 1 ? previousIndex + 1 : 0,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
setSelectedFileIndex((previousIndex) =>
|
||||
previousIndex > 0 ? previousIndex - 1 : filteredFiles.length - 1,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === 'Tab' || event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
if (selectedFileIndex >= 0) {
|
||||
selectFile(filteredFiles[selectedFileIndex]);
|
||||
} else if (filteredFiles.length > 0) {
|
||||
selectFile(filteredFiles[0]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
setShowFileDropdown(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
[showFileDropdown, filteredFiles, selectedFileIndex, selectFile],
|
||||
);
|
||||
|
||||
return {
|
||||
showFileDropdown,
|
||||
filteredFiles,
|
||||
selectedFileIndex,
|
||||
renderInputWithMentions,
|
||||
selectFile,
|
||||
setCursorPosition,
|
||||
handleFileMentionsKeyDown,
|
||||
};
|
||||
}
|
||||
375
src/components/chat/hooks/useSlashCommands.ts
Normal file
375
src/components/chat/hooks/useSlashCommands.ts
Normal file
@@ -0,0 +1,375 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { Dispatch, KeyboardEvent, RefObject, SetStateAction } from 'react';
|
||||
import Fuse from 'fuse.js';
|
||||
import { authenticatedFetch } from '../../../utils/api';
|
||||
import { safeLocalStorage } from '../utils/chatStorage';
|
||||
import type { Project } from '../../../types/app';
|
||||
|
||||
const COMMAND_QUERY_DEBOUNCE_MS = 150;
|
||||
|
||||
export interface SlashCommand {
|
||||
name: string;
|
||||
description?: string;
|
||||
namespace?: string;
|
||||
path?: string;
|
||||
type?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface UseSlashCommandsOptions {
|
||||
selectedProject: Project | null;
|
||||
input: string;
|
||||
setInput: Dispatch<SetStateAction<string>>;
|
||||
textareaRef: RefObject<HTMLTextAreaElement>;
|
||||
onExecuteCommand: (command: SlashCommand, rawInput?: string) => void | Promise<void>;
|
||||
}
|
||||
|
||||
const getCommandHistoryKey = (projectName: string) => `command_history_${projectName}`;
|
||||
|
||||
const readCommandHistory = (projectName: string): Record<string, number> => {
|
||||
const history = safeLocalStorage.getItem(getCommandHistoryKey(projectName));
|
||||
if (!history) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(history);
|
||||
} catch (error) {
|
||||
console.error('Error parsing command history:', error);
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const saveCommandHistory = (projectName: string, history: Record<string, number>) => {
|
||||
safeLocalStorage.setItem(getCommandHistoryKey(projectName), JSON.stringify(history));
|
||||
};
|
||||
|
||||
const isPromiseLike = (value: unknown): value is Promise<unknown> =>
|
||||
Boolean(value) && typeof (value as Promise<unknown>).then === 'function';
|
||||
|
||||
export function useSlashCommands({
|
||||
selectedProject,
|
||||
input,
|
||||
setInput,
|
||||
textareaRef,
|
||||
onExecuteCommand,
|
||||
}: UseSlashCommandsOptions) {
|
||||
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
|
||||
const [filteredCommands, setFilteredCommands] = useState<SlashCommand[]>([]);
|
||||
const [showCommandMenu, setShowCommandMenu] = useState(false);
|
||||
const [commandQuery, setCommandQuery] = useState('');
|
||||
const [selectedCommandIndex, setSelectedCommandIndex] = useState(-1);
|
||||
const [slashPosition, setSlashPosition] = useState(-1);
|
||||
|
||||
const commandQueryTimerRef = useRef<number | null>(null);
|
||||
|
||||
const clearCommandQueryTimer = useCallback(() => {
|
||||
if (commandQueryTimerRef.current !== null) {
|
||||
window.clearTimeout(commandQueryTimerRef.current);
|
||||
commandQueryTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const resetCommandMenuState = useCallback(() => {
|
||||
setShowCommandMenu(false);
|
||||
setSlashPosition(-1);
|
||||
setCommandQuery('');
|
||||
setSelectedCommandIndex(-1);
|
||||
clearCommandQueryTimer();
|
||||
}, [clearCommandQueryTimer]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchCommands = async () => {
|
||||
if (!selectedProject) {
|
||||
setSlashCommands([]);
|
||||
setFilteredCommands([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await authenticatedFetch('/api/commands/list', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
projectPath: selectedProject.path,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch commands');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const allCommands: SlashCommand[] = [
|
||||
...((data.builtIn || []) as SlashCommand[]).map((command) => ({
|
||||
...command,
|
||||
type: 'built-in',
|
||||
})),
|
||||
...((data.custom || []) as SlashCommand[]).map((command) => ({
|
||||
...command,
|
||||
type: 'custom',
|
||||
})),
|
||||
];
|
||||
|
||||
const parsedHistory = readCommandHistory(selectedProject.name);
|
||||
const sortedCommands = [...allCommands].sort((commandA, commandB) => {
|
||||
const commandAUsage = parsedHistory[commandA.name] || 0;
|
||||
const commandBUsage = parsedHistory[commandB.name] || 0;
|
||||
return commandBUsage - commandAUsage;
|
||||
});
|
||||
|
||||
setSlashCommands(sortedCommands);
|
||||
} catch (error) {
|
||||
console.error('Error fetching slash commands:', error);
|
||||
setSlashCommands([]);
|
||||
}
|
||||
};
|
||||
|
||||
fetchCommands();
|
||||
}, [selectedProject]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showCommandMenu) {
|
||||
setSelectedCommandIndex(-1);
|
||||
}
|
||||
}, [showCommandMenu]);
|
||||
|
||||
const fuse = useMemo(() => {
|
||||
if (!slashCommands.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Fuse(slashCommands, {
|
||||
keys: [
|
||||
{ name: 'name', weight: 2 },
|
||||
{ name: 'description', weight: 1 },
|
||||
],
|
||||
threshold: 0.4,
|
||||
includeScore: true,
|
||||
minMatchCharLength: 1,
|
||||
});
|
||||
}, [slashCommands]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!commandQuery) {
|
||||
setFilteredCommands(slashCommands);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fuse) {
|
||||
setFilteredCommands([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const results = fuse.search(commandQuery);
|
||||
setFilteredCommands(results.map((result) => result.item));
|
||||
}, [commandQuery, slashCommands, fuse]);
|
||||
|
||||
const frequentCommands = useMemo(() => {
|
||||
if (!selectedProject || slashCommands.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const parsedHistory = readCommandHistory(selectedProject.name);
|
||||
|
||||
return slashCommands
|
||||
.map((command) => ({
|
||||
...command,
|
||||
usageCount: parsedHistory[command.name] || 0,
|
||||
}))
|
||||
.filter((command) => command.usageCount > 0)
|
||||
.sort((commandA, commandB) => commandB.usageCount - commandA.usageCount)
|
||||
.slice(0, 5);
|
||||
}, [selectedProject, slashCommands]);
|
||||
|
||||
const trackCommandUsage = useCallback(
|
||||
(command: SlashCommand) => {
|
||||
if (!selectedProject) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedHistory = readCommandHistory(selectedProject.name);
|
||||
parsedHistory[command.name] = (parsedHistory[command.name] || 0) + 1;
|
||||
saveCommandHistory(selectedProject.name, parsedHistory);
|
||||
},
|
||||
[selectedProject],
|
||||
);
|
||||
|
||||
const selectCommandFromKeyboard = useCallback(
|
||||
(command: SlashCommand) => {
|
||||
const textBeforeSlash = input.slice(0, slashPosition);
|
||||
const textAfterSlash = input.slice(slashPosition);
|
||||
const spaceIndex = textAfterSlash.indexOf(' ');
|
||||
const textAfterQuery = spaceIndex !== -1 ? textAfterSlash.slice(spaceIndex) : '';
|
||||
const newInput = `${textBeforeSlash}${command.name} ${textAfterQuery}`;
|
||||
|
||||
setInput(newInput);
|
||||
resetCommandMenuState();
|
||||
|
||||
const executionResult = onExecuteCommand(command);
|
||||
if (isPromiseLike(executionResult)) {
|
||||
executionResult.catch(() => {
|
||||
// Keep behavior silent; execution errors are handled by caller.
|
||||
});
|
||||
}
|
||||
},
|
||||
[input, slashPosition, setInput, resetCommandMenuState, onExecuteCommand],
|
||||
);
|
||||
|
||||
const handleCommandSelect = useCallback(
|
||||
(command: SlashCommand | null, index: number, isHover: boolean) => {
|
||||
if (!command || !selectedProject) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isHover) {
|
||||
setSelectedCommandIndex(index);
|
||||
return;
|
||||
}
|
||||
|
||||
trackCommandUsage(command);
|
||||
const executionResult = onExecuteCommand(command);
|
||||
|
||||
if (isPromiseLike(executionResult)) {
|
||||
executionResult.then(() => {
|
||||
resetCommandMenuState();
|
||||
});
|
||||
executionResult.catch(() => {
|
||||
// Keep behavior silent; execution errors are handled by caller.
|
||||
});
|
||||
} else {
|
||||
resetCommandMenuState();
|
||||
}
|
||||
},
|
||||
[selectedProject, trackCommandUsage, onExecuteCommand, resetCommandMenuState],
|
||||
);
|
||||
|
||||
const handleToggleCommandMenu = useCallback(() => {
|
||||
const isOpening = !showCommandMenu;
|
||||
setShowCommandMenu(isOpening);
|
||||
setCommandQuery('');
|
||||
setSelectedCommandIndex(-1);
|
||||
|
||||
if (isOpening) {
|
||||
setFilteredCommands(slashCommands);
|
||||
}
|
||||
|
||||
textareaRef.current?.focus();
|
||||
}, [showCommandMenu, slashCommands, textareaRef]);
|
||||
|
||||
const handleCommandInputChange = useCallback(
|
||||
(newValue: string, cursorPos: number) => {
|
||||
if (!newValue.trim()) {
|
||||
resetCommandMenuState();
|
||||
return;
|
||||
}
|
||||
|
||||
const textBeforeCursor = newValue.slice(0, cursorPos);
|
||||
const backticksBefore = (textBeforeCursor.match(/```/g) || []).length;
|
||||
const inCodeBlock = backticksBefore % 2 === 1;
|
||||
|
||||
if (inCodeBlock) {
|
||||
resetCommandMenuState();
|
||||
return;
|
||||
}
|
||||
|
||||
const slashPattern = /(^|\s)\/(\S*)$/;
|
||||
const match = textBeforeCursor.match(slashPattern);
|
||||
|
||||
if (!match) {
|
||||
resetCommandMenuState();
|
||||
return;
|
||||
}
|
||||
|
||||
const slashPos = (match.index || 0) + match[1].length;
|
||||
const query = match[2];
|
||||
|
||||
setSlashPosition(slashPos);
|
||||
setShowCommandMenu(true);
|
||||
setSelectedCommandIndex(-1);
|
||||
|
||||
clearCommandQueryTimer();
|
||||
commandQueryTimerRef.current = window.setTimeout(() => {
|
||||
setCommandQuery(query);
|
||||
}, COMMAND_QUERY_DEBOUNCE_MS);
|
||||
},
|
||||
[resetCommandMenuState, clearCommandQueryTimer],
|
||||
);
|
||||
|
||||
const handleCommandMenuKeyDown = useCallback(
|
||||
(event: KeyboardEvent<HTMLTextAreaElement>): boolean => {
|
||||
if (!showCommandMenu) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!filteredCommands.length) {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
resetCommandMenuState();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
setSelectedCommandIndex((previousIndex) =>
|
||||
previousIndex < filteredCommands.length - 1 ? previousIndex + 1 : 0,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
setSelectedCommandIndex((previousIndex) =>
|
||||
previousIndex > 0 ? previousIndex - 1 : filteredCommands.length - 1,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === 'Tab' || event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
if (selectedCommandIndex >= 0) {
|
||||
selectCommandFromKeyboard(filteredCommands[selectedCommandIndex]);
|
||||
} else if (filteredCommands.length > 0) {
|
||||
selectCommandFromKeyboard(filteredCommands[0]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
resetCommandMenuState();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
[showCommandMenu, filteredCommands, resetCommandMenuState, selectCommandFromKeyboard, selectedCommandIndex],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
clearCommandQueryTimer();
|
||||
},
|
||||
[clearCommandQueryTimer],
|
||||
);
|
||||
|
||||
return {
|
||||
slashCommands,
|
||||
slashCommandsCount: slashCommands.length,
|
||||
filteredCommands,
|
||||
frequentCommands,
|
||||
commandQuery,
|
||||
showCommandMenu,
|
||||
selectedCommandIndex,
|
||||
resetCommandMenuState,
|
||||
handleCommandSelect,
|
||||
handleToggleCommandMenu,
|
||||
handleCommandInputChange,
|
||||
handleCommandMenuKeyDown,
|
||||
};
|
||||
}
|
||||
224
src/components/chat/tools/README.md
Normal file
224
src/components/chat/tools/README.md
Normal file
@@ -0,0 +1,224 @@
|
||||
# Tool Rendering System
|
||||
|
||||
## Overview
|
||||
|
||||
Config-driven architecture for rendering tool executions in chat. All tool display behavior is defined in `toolConfigs.ts` — no scattered conditionals. Two base display patterns: **OneLineDisplay** for compact tools, **CollapsibleDisplay** for tools with expandable content.
|
||||
|
||||
Non-error tool results route through `ToolRenderer` with `mode="result"` (single source of truth). Error results are handled inline in `MessageComponent` with a red error box.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
tools/
|
||||
├── components/
|
||||
│ ├── OneLineDisplay.tsx # Compact one-line tool display
|
||||
│ ├── CollapsibleDisplay.tsx # Expandable tool display (uses children pattern)
|
||||
│ ├── CollapsibleSection.tsx # <details>/<summary> wrapper
|
||||
│ ├── ContentRenderers/
|
||||
│ │ ├── DiffViewer.tsx # File diff viewer (memoized)
|
||||
│ │ ├── MarkdownContent.tsx # Markdown renderer
|
||||
│ │ ├── FileListContent.tsx # Comma-separated clickable file list
|
||||
│ │ ├── TodoListContent.tsx # Todo items with status badges
|
||||
│ │ ├── TaskListContent.tsx # Task tracker with progress bar
|
||||
│ │ └── TextContent.tsx # Plain text / JSON / code
|
||||
├── configs/
|
||||
│ └── toolConfigs.ts # All tool configs + ToolDisplayConfig type
|
||||
├── ToolRenderer.tsx # Main router (React.memo wrapped)
|
||||
└── README.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Display Patterns
|
||||
|
||||
### OneLineDisplay
|
||||
|
||||
Used by: Bash, Read, Grep, Glob, TodoRead, TaskCreate, TaskUpdate, TaskGet
|
||||
|
||||
Renders as a single line with `border-l-2` accent. Supports multiple rendering modes based on `action`:
|
||||
|
||||
- **terminal** (`style: 'terminal'`) — Dark pill around command text, green `$` prompt
|
||||
- **open-file** — Shows filename only (truncated from full path), clickable to open
|
||||
- **jump-to-results** — Shows pattern with anchor link to result section
|
||||
- **copy** — Shows value with hover copy button
|
||||
- **none** — Plain display
|
||||
|
||||
```tsx
|
||||
<OneLineDisplay
|
||||
toolName="Read"
|
||||
icon="terminal" // Optional icon or style keyword
|
||||
label="Read" // Tool label
|
||||
value="/path/to/file.ts" // Main display value
|
||||
secondary="description" // Optional secondary text (italic)
|
||||
action="open-file" // Action type
|
||||
onAction={() => ...} // Click handler
|
||||
colorScheme={{ // Per-tool colors
|
||||
primary: 'text-...',
|
||||
border: 'border-...',
|
||||
icon: 'text-...'
|
||||
}}
|
||||
resultId="tool-result-x" // For jump-to-results anchor
|
||||
toolResult={...} // For conditional jump arrow
|
||||
toolId="x" // Tool use ID
|
||||
/>
|
||||
```
|
||||
|
||||
### CollapsibleDisplay
|
||||
|
||||
Used by: Edit, Write, ApplyPatch, Grep/Glob results, TodoWrite, TaskList/TaskGet results, ExitPlanMode, Default
|
||||
|
||||
Wraps `CollapsibleSection` (`<details>`/`<summary>`) with a `border-l-2` accent colored by tool category. Accepts **children** directly (not contentProps).
|
||||
|
||||
```tsx
|
||||
<CollapsibleDisplay
|
||||
toolName="Edit"
|
||||
toolId="123"
|
||||
title="filename.ts" // Section title (can be clickable)
|
||||
defaultOpen={false}
|
||||
onTitleClick={() => ...} // Makes title a clickable link (for edit tools)
|
||||
showRawParameters={true} // Show raw JSON toggle
|
||||
rawContent="..." // Raw JSON string
|
||||
toolCategory="edit" // Drives border color
|
||||
>
|
||||
<DiffViewer {...} /> // Content as children
|
||||
</CollapsibleDisplay>
|
||||
```
|
||||
|
||||
**Tool category colors** (via `border-l-2`):
|
||||
| Category | Tools | Color |
|
||||
|----------|-------|-------|
|
||||
| `edit` | Edit, Write, ApplyPatch | amber |
|
||||
| `bash` | Bash | green |
|
||||
| `search` | Grep, Glob | gray |
|
||||
| `todo` | TodoWrite, TodoRead | violet |
|
||||
| `task` | TaskCreate/Update/List/Get | violet |
|
||||
| `plan` | ExitPlanMode | indigo |
|
||||
| `default` | everything else | neutral gray |
|
||||
|
||||
---
|
||||
|
||||
## Content Renderers
|
||||
|
||||
Specialized components for different content types, rendered as children of `CollapsibleDisplay`:
|
||||
|
||||
| contentType | Component | Used by |
|
||||
|---|---|---|
|
||||
| `diff` | `DiffViewer` | Edit, Write, ApplyPatch |
|
||||
| `markdown` | `MarkdownContent` | ExitPlanMode |
|
||||
| `file-list` | `FileListContent` | Grep/Glob results |
|
||||
| `todo-list` | `TodoListContent` | TodoWrite, TodoRead |
|
||||
| `task` | `TaskListContent` | TaskList, TaskGet results |
|
||||
| `text` | `TextContent` | Default fallback |
|
||||
| `success-message` | inline SVG | TodoWrite result |
|
||||
|
||||
---
|
||||
|
||||
## Adding a New Tool
|
||||
|
||||
**Step 1:** Add config to `configs/toolConfigs.ts`
|
||||
|
||||
```typescript
|
||||
MyTool: {
|
||||
input: {
|
||||
type: 'one-line', // or 'collapsible'
|
||||
label: 'MyTool',
|
||||
getValue: (input) => input.some_field,
|
||||
action: 'open-file',
|
||||
colorScheme: {
|
||||
primary: 'text-purple-600 dark:text-purple-400',
|
||||
border: 'border-purple-400 dark:border-purple-500'
|
||||
}
|
||||
},
|
||||
result: {
|
||||
hideOnSuccess: true // Only show errors
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2:** If the tool needs a category color, add it to `getToolCategory()` in `ToolRenderer.tsx`.
|
||||
|
||||
**That's it.** The ToolRenderer auto-routes based on config.
|
||||
|
||||
---
|
||||
|
||||
## Configuration Reference
|
||||
|
||||
### ToolDisplayConfig
|
||||
|
||||
```typescript
|
||||
interface ToolDisplayConfig {
|
||||
input: {
|
||||
type: 'one-line' | 'collapsible' | 'hidden';
|
||||
|
||||
// One-line
|
||||
icon?: string;
|
||||
label?: string;
|
||||
getValue?: (input) => string;
|
||||
getSecondary?: (input) => string | undefined;
|
||||
action?: 'copy' | 'open-file' | 'jump-to-results' | 'none';
|
||||
style?: string; // 'terminal' for Bash
|
||||
wrapText?: boolean;
|
||||
colorScheme?: {
|
||||
primary?: string;
|
||||
secondary?: string;
|
||||
background?: string;
|
||||
border?: string;
|
||||
icon?: string;
|
||||
};
|
||||
|
||||
// Collapsible
|
||||
title?: string | ((input) => string);
|
||||
defaultOpen?: boolean;
|
||||
contentType?: 'diff' | 'markdown' | 'file-list' | 'todo-list' | 'text' | 'task';
|
||||
getContentProps?: (input, helpers?) => any;
|
||||
actionButton?: 'none';
|
||||
};
|
||||
|
||||
result?: {
|
||||
hidden?: boolean; // Never show
|
||||
hideOnSuccess?: boolean; // Only show errors
|
||||
type?: 'one-line' | 'collapsible' | 'special';
|
||||
title?: string | ((result) => string);
|
||||
defaultOpen?: boolean;
|
||||
contentType?: 'markdown' | 'file-list' | 'todo-list' | 'text' | 'success-message' | 'task';
|
||||
getMessage?: (result) => string;
|
||||
getContentProps?: (result) => any;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## All Configured Tools
|
||||
|
||||
| Tool | Input | Result | Notes |
|
||||
|------|-------|--------|-------|
|
||||
| Bash | terminal one-line | hide success | Dark command pill, green accent |
|
||||
| Read | one-line (open-file) | hidden | Shows filename, clicks to open |
|
||||
| Edit | collapsible (diff) | hide success | Amber border, clickable filename |
|
||||
| Write | collapsible (diff) | hide success | "New" badge on diff |
|
||||
| ApplyPatch | collapsible (diff) | hide success | "Patch" badge on diff |
|
||||
| Grep | one-line (jump) | collapsible file-list | Collapsed by default |
|
||||
| Glob | one-line (jump) | collapsible file-list | Collapsed by default |
|
||||
| TodoWrite | collapsible (todo-list) | success message | |
|
||||
| TodoRead | one-line | collapsible todo-list | |
|
||||
| TaskCreate | one-line | hide success | Shows task subject |
|
||||
| TaskUpdate | one-line | hide success | Shows `#id → status` |
|
||||
| TaskList | one-line | collapsible task | Progress bar, status icons |
|
||||
| TaskGet | one-line | collapsible task | Task details with status |
|
||||
| ExitPlanMode | collapsible (markdown) | collapsible markdown | Also registered as `exit_plan_mode` |
|
||||
| Default | collapsible (code) | collapsible text | Fallback for unknown tools |
|
||||
|
||||
---
|
||||
|
||||
## Performance
|
||||
|
||||
- **ToolRenderer** is wrapped with `React.memo` — skips re-render when props haven't changed
|
||||
- **parsedData** is memoized with `useMemo` — JSON parsing only runs when input changes
|
||||
- **DiffViewer** memoizes `createDiff()` — expensive diff computation cached
|
||||
- **MessageComponent** caches `localStorage` reads and timestamp formatting via `useMemo`
|
||||
- Tool results route through `ToolRenderer` (no duplicate rendering paths)
|
||||
- `CollapsibleDisplay` uses children pattern (no wasteful contentProps indirection)
|
||||
- Configs are static module-level objects — zero runtime overhead for lookups
|
||||
242
src/components/chat/tools/ToolRenderer.tsx
Normal file
242
src/components/chat/tools/ToolRenderer.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
import React, { memo, useMemo, useCallback } from 'react';
|
||||
import { getToolConfig } from './configs/toolConfigs';
|
||||
import { OneLineDisplay, CollapsibleDisplay, DiffViewer, MarkdownContent, FileListContent, TodoListContent, TaskListContent, TextContent, QuestionAnswerContent, SubagentContainer } from './components';
|
||||
import type { Project } from '../../../types/app';
|
||||
import type { SubagentChildTool } from '../types/types';
|
||||
|
||||
type DiffLine = {
|
||||
type: string;
|
||||
content: string;
|
||||
lineNum: number;
|
||||
};
|
||||
|
||||
interface ToolRendererProps {
|
||||
toolName: string;
|
||||
toolInput: any;
|
||||
toolResult?: any;
|
||||
toolId?: string;
|
||||
mode: 'input' | 'result';
|
||||
onFileOpen?: (filePath: string, diffInfo?: any) => void;
|
||||
createDiff?: (oldStr: string, newStr: string) => DiffLine[];
|
||||
selectedProject?: Project | null;
|
||||
autoExpandTools?: boolean;
|
||||
showRawParameters?: boolean;
|
||||
rawToolInput?: string;
|
||||
isSubagentContainer?: boolean;
|
||||
subagentState?: {
|
||||
childTools: SubagentChildTool[];
|
||||
currentToolIndex: number;
|
||||
isComplete: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
function getToolCategory(toolName: string): string {
|
||||
if (['Edit', 'Write', 'ApplyPatch'].includes(toolName)) return 'edit';
|
||||
if (['Grep', 'Glob'].includes(toolName)) return 'search';
|
||||
if (toolName === 'Bash') return 'bash';
|
||||
if (['TodoWrite', 'TodoRead'].includes(toolName)) return 'todo';
|
||||
if (['TaskCreate', 'TaskUpdate', 'TaskList', 'TaskGet'].includes(toolName)) return 'task';
|
||||
if (toolName === 'Task') return 'agent'; // Subagent task
|
||||
if (toolName === 'exit_plan_mode' || toolName === 'ExitPlanMode') return 'plan';
|
||||
if (toolName === 'AskUserQuestion') return 'question';
|
||||
return 'default';
|
||||
}
|
||||
|
||||
/**
|
||||
* Main tool renderer router
|
||||
* Routes to OneLineDisplay or CollapsibleDisplay based on tool config
|
||||
*/
|
||||
export const ToolRenderer: React.FC<ToolRendererProps> = memo(({
|
||||
toolName,
|
||||
toolInput,
|
||||
toolResult,
|
||||
toolId,
|
||||
mode,
|
||||
onFileOpen,
|
||||
createDiff,
|
||||
selectedProject,
|
||||
autoExpandTools = false,
|
||||
showRawParameters = false,
|
||||
rawToolInput,
|
||||
isSubagentContainer,
|
||||
subagentState
|
||||
}) => {
|
||||
// Route subagent containers to dedicated component
|
||||
if (isSubagentContainer && subagentState) {
|
||||
if (mode === 'result') {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<SubagentContainer
|
||||
toolInput={toolInput}
|
||||
toolResult={toolResult}
|
||||
subagentState={subagentState}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const config = getToolConfig(toolName);
|
||||
const displayConfig: any = mode === 'input' ? config.input : config.result;
|
||||
|
||||
const parsedData = useMemo(() => {
|
||||
try {
|
||||
const rawData = mode === 'input' ? toolInput : toolResult;
|
||||
return typeof rawData === 'string' ? JSON.parse(rawData) : rawData;
|
||||
} catch {
|
||||
return mode === 'input' ? toolInput : toolResult;
|
||||
}
|
||||
}, [mode, toolInput, toolResult]);
|
||||
|
||||
const handleAction = useCallback(() => {
|
||||
if (displayConfig?.action === 'open-file' && onFileOpen) {
|
||||
const value = displayConfig.getValue?.(parsedData) || '';
|
||||
onFileOpen(value);
|
||||
}
|
||||
}, [displayConfig, parsedData, onFileOpen]);
|
||||
|
||||
// Keep hooks above this guard so hook call order stays stable across renders.
|
||||
if (!displayConfig) return null;
|
||||
|
||||
if (displayConfig.type === 'one-line') {
|
||||
const value = displayConfig.getValue?.(parsedData) || '';
|
||||
const secondary = displayConfig.getSecondary?.(parsedData);
|
||||
|
||||
return (
|
||||
<OneLineDisplay
|
||||
toolName={toolName}
|
||||
toolResult={toolResult}
|
||||
toolId={toolId}
|
||||
icon={displayConfig.icon}
|
||||
label={displayConfig.label}
|
||||
value={value}
|
||||
secondary={secondary}
|
||||
action={displayConfig.action}
|
||||
onAction={handleAction}
|
||||
style={displayConfig.style}
|
||||
wrapText={displayConfig.wrapText}
|
||||
colorScheme={displayConfig.colorScheme}
|
||||
resultId={mode === 'input' ? `tool-result-${toolId}` : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (displayConfig.type === 'collapsible') {
|
||||
const title = typeof displayConfig.title === 'function'
|
||||
? displayConfig.title(parsedData)
|
||||
: displayConfig.title || 'Details';
|
||||
|
||||
const defaultOpen = displayConfig.defaultOpen !== undefined
|
||||
? displayConfig.defaultOpen
|
||||
: autoExpandTools;
|
||||
|
||||
const contentProps = displayConfig.getContentProps?.(parsedData, {
|
||||
selectedProject,
|
||||
createDiff,
|
||||
onFileOpen
|
||||
}) || {};
|
||||
|
||||
// Build the content component based on contentType
|
||||
let contentComponent: React.ReactNode = null;
|
||||
|
||||
switch (displayConfig.contentType) {
|
||||
case 'diff':
|
||||
if (createDiff) {
|
||||
contentComponent = (
|
||||
<DiffViewer
|
||||
{...contentProps}
|
||||
createDiff={createDiff}
|
||||
onFileClick={() => onFileOpen?.(contentProps.filePath)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'markdown':
|
||||
contentComponent = <MarkdownContent content={contentProps.content || ''} />;
|
||||
break;
|
||||
|
||||
case 'file-list':
|
||||
contentComponent = (
|
||||
<FileListContent
|
||||
files={contentProps.files || []}
|
||||
onFileClick={onFileOpen}
|
||||
title={contentProps.title}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
|
||||
case 'todo-list':
|
||||
if (contentProps.todos?.length > 0) {
|
||||
contentComponent = (
|
||||
<TodoListContent
|
||||
todos={contentProps.todos}
|
||||
isResult={contentProps.isResult}
|
||||
/>
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'task':
|
||||
contentComponent = <TaskListContent content={contentProps.content || ''} />;
|
||||
break;
|
||||
|
||||
case 'question-answer':
|
||||
contentComponent = (
|
||||
<QuestionAnswerContent
|
||||
questions={contentProps.questions || []}
|
||||
answers={contentProps.answers || {}}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
|
||||
case 'text':
|
||||
contentComponent = (
|
||||
<TextContent
|
||||
content={contentProps.content || ''}
|
||||
format={contentProps.format || 'plain'}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
|
||||
case 'success-message': {
|
||||
const msg = displayConfig.getMessage?.(parsedData) || 'Success';
|
||||
contentComponent = (
|
||||
<div className="flex items-center gap-1.5 text-xs text-green-600 dark:text-green-400">
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
{msg}
|
||||
</div>
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// For edit tools, make the title (filename) clickable to open the file
|
||||
const handleTitleClick = (toolName === 'Edit' || toolName === 'Write' || toolName === 'ApplyPatch') && contentProps.filePath && onFileOpen
|
||||
? () => onFileOpen(contentProps.filePath, {
|
||||
old_string: contentProps.oldContent,
|
||||
new_string: contentProps.newContent
|
||||
})
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<CollapsibleDisplay
|
||||
toolName={toolName}
|
||||
toolId={toolId}
|
||||
title={title}
|
||||
defaultOpen={defaultOpen}
|
||||
onTitleClick={handleTitleClick}
|
||||
showRawParameters={mode === 'input' && showRawParameters}
|
||||
rawContent={rawToolInput}
|
||||
toolCategory={getToolCategory(toolName)}
|
||||
>
|
||||
{contentComponent}
|
||||
</CollapsibleDisplay>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
ToolRenderer.displayName = 'ToolRenderer';
|
||||
77
src/components/chat/tools/components/CollapsibleDisplay.tsx
Normal file
77
src/components/chat/tools/components/CollapsibleDisplay.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import React from 'react';
|
||||
import { CollapsibleSection } from './CollapsibleSection';
|
||||
|
||||
interface CollapsibleDisplayProps {
|
||||
toolName: string;
|
||||
toolId?: string;
|
||||
title: string;
|
||||
defaultOpen?: boolean;
|
||||
action?: React.ReactNode;
|
||||
onTitleClick?: () => void;
|
||||
children: React.ReactNode;
|
||||
showRawParameters?: boolean;
|
||||
rawContent?: string;
|
||||
className?: string;
|
||||
toolCategory?: string;
|
||||
}
|
||||
|
||||
const borderColorMap: Record<string, string> = {
|
||||
edit: 'border-l-amber-500 dark:border-l-amber-400',
|
||||
search: 'border-l-gray-400 dark:border-l-gray-500',
|
||||
bash: 'border-l-green-500 dark:border-l-green-400',
|
||||
todo: 'border-l-violet-500 dark:border-l-violet-400',
|
||||
task: 'border-l-violet-500 dark:border-l-violet-400',
|
||||
agent: 'border-l-purple-500 dark:border-l-purple-400',
|
||||
plan: 'border-l-indigo-500 dark:border-l-indigo-400',
|
||||
question: 'border-l-blue-500 dark:border-l-blue-400',
|
||||
default: 'border-l-gray-300 dark:border-l-gray-600',
|
||||
};
|
||||
|
||||
export const CollapsibleDisplay: React.FC<CollapsibleDisplayProps> = ({
|
||||
toolName,
|
||||
title,
|
||||
defaultOpen = false,
|
||||
action,
|
||||
onTitleClick,
|
||||
children,
|
||||
showRawParameters = false,
|
||||
rawContent,
|
||||
className = '',
|
||||
toolCategory
|
||||
}) => {
|
||||
// Fall back to default styling for unknown/new categories so className never includes "undefined".
|
||||
const borderColor = borderColorMap[toolCategory || 'default'] || borderColorMap.default;
|
||||
|
||||
return (
|
||||
<div className={`border-l-2 ${borderColor} pl-3 py-0.5 my-1 ${className}`}>
|
||||
<CollapsibleSection
|
||||
title={title}
|
||||
toolName={toolName}
|
||||
open={defaultOpen}
|
||||
action={action}
|
||||
onTitleClick={onTitleClick}
|
||||
>
|
||||
{children}
|
||||
|
||||
{showRawParameters && rawContent && (
|
||||
<details className="relative mt-2 group/raw">
|
||||
<summary className="flex items-center gap-1.5 text-[11px] text-gray-400 dark:text-gray-500 cursor-pointer hover:text-gray-600 dark:hover:text-gray-300 py-0.5">
|
||||
<svg
|
||||
className="w-2.5 h-2.5 transition-transform duration-150 group-open/raw:rotate-90"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
raw params
|
||||
</summary>
|
||||
<pre className="mt-1 text-[11px] bg-gray-50 dark:bg-gray-900/50 border border-gray-200/40 dark:border-gray-700/40 p-2 rounded whitespace-pre-wrap break-words overflow-hidden text-gray-600 dark:text-gray-400 font-mono">
|
||||
{rawContent}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
61
src/components/chat/tools/components/CollapsibleSection.tsx
Normal file
61
src/components/chat/tools/components/CollapsibleSection.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import React from 'react';
|
||||
|
||||
interface CollapsibleSectionProps {
|
||||
title: string;
|
||||
toolName?: string;
|
||||
open?: boolean;
|
||||
action?: React.ReactNode;
|
||||
onTitleClick?: () => void;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable collapsible section with consistent styling
|
||||
*/
|
||||
export const CollapsibleSection: React.FC<CollapsibleSectionProps> = ({
|
||||
title,
|
||||
toolName,
|
||||
open = false,
|
||||
action,
|
||||
onTitleClick,
|
||||
children,
|
||||
className = ''
|
||||
}) => {
|
||||
return (
|
||||
<details className={`relative group/details ${className}`} open={open}>
|
||||
<summary className="flex items-center gap-1.5 text-xs cursor-pointer py-0.5 select-none group-open/details:sticky group-open/details:top-0 group-open/details:z-10 group-open/details:bg-background group-open/details:-mx-1 group-open/details:px-1">
|
||||
<svg
|
||||
className="w-3 h-3 text-gray-400 dark:text-gray-500 transition-transform duration-150 group-open/details:rotate-90 flex-shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
{toolName && (
|
||||
<span className="font-medium text-gray-500 dark:text-gray-400 flex-shrink-0">{toolName}</span>
|
||||
)}
|
||||
{toolName && (
|
||||
<span className="text-gray-300 dark:text-gray-600 text-[10px] flex-shrink-0">/</span>
|
||||
)}
|
||||
{onTitleClick ? (
|
||||
<button
|
||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onTitleClick(); }}
|
||||
className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 font-mono hover:underline truncate flex-1 text-left transition-colors"
|
||||
>
|
||||
{title}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-gray-600 dark:text-gray-400 truncate flex-1">
|
||||
{title}
|
||||
</span>
|
||||
)}
|
||||
{action && <span className="flex-shrink-0 ml-1">{action}</span>}
|
||||
</summary>
|
||||
<div className="mt-1.5 pl-[18px]">
|
||||
{children}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import React from 'react';
|
||||
|
||||
interface FileListItem {
|
||||
path: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
interface FileListContentProps {
|
||||
files: string[] | FileListItem[];
|
||||
onFileClick?: (filePath: string) => void;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a compact comma-separated list of clickable file names
|
||||
* Used by: Grep/Glob results
|
||||
*/
|
||||
export const FileListContent: React.FC<FileListContentProps> = ({
|
||||
files,
|
||||
onFileClick,
|
||||
title
|
||||
}) => {
|
||||
return (
|
||||
<div>
|
||||
{title && (
|
||||
<div className="text-[11px] text-gray-500 dark:text-gray-400 mb-1">
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-x-1 gap-y-0.5 max-h-48 overflow-y-auto">
|
||||
{files.map((file, index) => {
|
||||
const filePath = typeof file === 'string' ? file : file.path;
|
||||
const fileName = filePath.split('/').pop() || filePath;
|
||||
const handleClick = typeof file === 'string'
|
||||
? () => onFileClick?.(file)
|
||||
: file.onClick;
|
||||
|
||||
return (
|
||||
<span key={index} className="inline-flex items-center">
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className="text-[11px] font-mono text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 hover:underline transition-colors"
|
||||
title={filePath}
|
||||
>
|
||||
{fileName}
|
||||
</button>
|
||||
{index < files.length - 1 && (
|
||||
<span className="text-gray-300 dark:text-gray-600 text-[10px] ml-1">,</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
import { Markdown } from '../../../view/subcomponents/Markdown';
|
||||
|
||||
interface MarkdownContentProps {
|
||||
content: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders markdown content with proper styling
|
||||
* Used by: exit_plan_mode, long text results, etc.
|
||||
*/
|
||||
export const MarkdownContent: React.FC<MarkdownContentProps> = ({
|
||||
content,
|
||||
className = 'mt-1 prose prose-sm max-w-none dark:prose-invert'
|
||||
}) => {
|
||||
return (
|
||||
<Markdown className={className}>
|
||||
{content}
|
||||
</Markdown>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,187 @@
|
||||
import React, { useState } from 'react';
|
||||
import type { Question } from '../../../types/types';
|
||||
|
||||
interface QuestionAnswerContentProps {
|
||||
questions: Question[];
|
||||
answers: Record<string, string>;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Exception to the stateless ContentRenderer pattern: multi-question navigation requires local state.
|
||||
export const QuestionAnswerContent: React.FC<QuestionAnswerContentProps> = ({
|
||||
questions,
|
||||
answers,
|
||||
className = '',
|
||||
}) => {
|
||||
const [expandedIdx, setExpandedIdx] = useState<number | null>(null);
|
||||
|
||||
if (!questions || questions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasAnyAnswer = Object.keys(answers || {}).length > 0;
|
||||
const total = questions.length;
|
||||
|
||||
return (
|
||||
<div className={`space-y-2 ${className}`}>
|
||||
{questions.map((q, idx) => {
|
||||
const answer = answers?.[q.question];
|
||||
const answerLabels = answer ? answer.split(', ') : [];
|
||||
const skipped = !answer;
|
||||
const isExpanded = expandedIdx === idx;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className="rounded-lg border border-gray-150 dark:border-gray-700/50 bg-gray-50/50 dark:bg-gray-800/30 overflow-hidden"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpandedIdx(isExpanded ? null : idx)}
|
||||
className="w-full text-left px-3 py-2 flex items-start gap-2.5 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors"
|
||||
>
|
||||
<div className={`mt-0.5 flex-shrink-0 w-4 h-4 rounded-full flex items-center justify-center ${
|
||||
answerLabels.length > 0
|
||||
? 'bg-blue-100 dark:bg-blue-900/40'
|
||||
: 'bg-gray-100 dark:bg-gray-800'
|
||||
}`}>
|
||||
{answerLabels.length > 0 ? (
|
||||
<svg className="w-2.5 h-2.5 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={3}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : (
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-gray-300 dark:bg-gray-600" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{q.header && (
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[9px] font-semibold uppercase tracking-wider bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 border border-blue-100/80 dark:border-blue-800/40">
|
||||
{q.header}
|
||||
</span>
|
||||
)}
|
||||
{total > 1 && (
|
||||
<span className="text-[10px] tabular-nums text-gray-400 dark:text-gray-500">
|
||||
{idx + 1}/{total}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400 mt-0.5 leading-snug">
|
||||
{q.question}
|
||||
</div>
|
||||
|
||||
{!isExpanded && answerLabels.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-1.5">
|
||||
{answerLabels.map((lbl) => {
|
||||
const isCustom = !q.options.some(o => o.label === lbl);
|
||||
return (
|
||||
<span
|
||||
key={lbl}
|
||||
className="inline-flex items-center gap-1 text-[11px] px-1.5 py-0.5 rounded-md bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 font-medium"
|
||||
>
|
||||
{lbl}
|
||||
{isCustom && (
|
||||
<span className="text-[9px] text-blue-400 dark:text-blue-500 font-normal">(custom)</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isExpanded && skipped && hasAnyAnswer && (
|
||||
<span className="inline-block mt-1 text-[10px] text-gray-400 dark:text-gray-500 italic">
|
||||
Skipped
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<svg
|
||||
className={`w-3.5 h-3.5 mt-0.5 text-gray-400 dark:text-gray-500 flex-shrink-0 transition-transform duration-200 ${
|
||||
isExpanded ? 'rotate-180' : ''
|
||||
}`}
|
||||
fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="px-3 pb-2.5 pt-0.5 border-t border-gray-100 dark:border-gray-700/40">
|
||||
<div className="space-y-1 ml-6.5">
|
||||
{q.options.map((opt) => {
|
||||
const wasSelected = answerLabels.includes(opt.label);
|
||||
return (
|
||||
<div
|
||||
key={opt.label}
|
||||
className={`flex items-start gap-2 px-2.5 py-1.5 rounded-lg text-[12px] ${
|
||||
wasSelected
|
||||
? 'bg-blue-50/80 dark:bg-blue-900/20 border border-blue-200/60 dark:border-blue-800/40'
|
||||
: 'text-gray-400 dark:text-gray-500'
|
||||
}`}
|
||||
>
|
||||
<div className={`mt-0.5 flex-shrink-0 w-3.5 h-3.5 ${q.multiSelect ? 'rounded-[3px]' : 'rounded-full'} border-[1.5px] flex items-center justify-center ${
|
||||
wasSelected
|
||||
? 'border-blue-500 dark:border-blue-400 bg-blue-500 dark:bg-blue-500'
|
||||
: 'border-gray-300 dark:border-gray-600'
|
||||
}`}>
|
||||
{wasSelected && (
|
||||
<svg className="w-2 h-2 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={3}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className={wasSelected ? 'text-gray-900 dark:text-gray-100 font-medium' : ''}>
|
||||
{opt.label}
|
||||
</span>
|
||||
{opt.description && (
|
||||
<span className={`block text-[11px] mt-0.5 ${
|
||||
wasSelected ? 'text-blue-600/70 dark:text-blue-300/70' : 'text-gray-400 dark:text-gray-600'
|
||||
}`}>
|
||||
{opt.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{answerLabels.filter(lbl => !q.options.some(o => o.label === lbl)).map(lbl => (
|
||||
<div
|
||||
key={lbl}
|
||||
className="flex items-start gap-2 px-2.5 py-1.5 rounded-lg text-[12px] bg-blue-50/80 dark:bg-blue-900/20 border border-blue-200/60 dark:border-blue-800/40"
|
||||
>
|
||||
<div className={`mt-0.5 flex-shrink-0 w-3.5 h-3.5 ${q.multiSelect ? 'rounded-[3px]' : 'rounded-full'} border-[1.5px] border-blue-500 dark:border-blue-400 bg-blue-500 dark:bg-blue-500 flex items-center justify-center`}>
|
||||
<svg className="w-2 h-2 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={3}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-gray-900 dark:text-gray-100 font-medium">{lbl}</span>
|
||||
<span className="text-[10px] text-blue-500 dark:text-blue-400 ml-1">(custom)</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{skipped && hasAnyAnswer && (
|
||||
<div className="text-[11px] text-gray-400 dark:text-gray-500 italic px-2.5 py-1">
|
||||
No answer provided
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{!hasAnyAnswer && total === 1 && (
|
||||
<div className="text-[11px] text-gray-400 dark:text-gray-500 italic">
|
||||
Skipped
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,125 @@
|
||||
import React from 'react';
|
||||
|
||||
interface TaskItem {
|
||||
id: string;
|
||||
subject: string;
|
||||
status: 'pending' | 'in_progress' | 'completed';
|
||||
owner?: string;
|
||||
blockedBy?: string[];
|
||||
}
|
||||
|
||||
interface TaskListContentProps {
|
||||
content: string;
|
||||
}
|
||||
|
||||
function parseTaskContent(content: string): TaskItem[] {
|
||||
const tasks: TaskItem[] = [];
|
||||
const lines = content.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
// Match patterns like: #15. [in_progress] Subject here
|
||||
// or: - #15 [in_progress] Subject (owner: agent)
|
||||
// or: #15. Subject here (status: in_progress)
|
||||
const match = line.match(/#(\d+)\.?\s*(?:\[(\w+)\]\s*)?(.+?)(?:\s*\((?:owner:\s*\w+)?\))?$/);
|
||||
if (match) {
|
||||
const [, id, status, subject] = match;
|
||||
const blockedMatch = line.match(/blockedBy:\s*\[([^\]]*)\]/);
|
||||
tasks.push({
|
||||
id,
|
||||
subject: subject.trim(),
|
||||
status: (status as TaskItem['status']) || 'pending',
|
||||
blockedBy: blockedMatch ? blockedMatch[1].split(',').map(s => s.trim()).filter(Boolean) : undefined
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return tasks;
|
||||
}
|
||||
|
||||
const statusConfig = {
|
||||
completed: {
|
||||
icon: (
|
||||
<svg className="w-3.5 h-3.5 text-green-500 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
),
|
||||
textClass: 'line-through text-gray-400 dark:text-gray-500',
|
||||
badgeClass: 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 border-green-200 dark:border-green-800'
|
||||
},
|
||||
in_progress: {
|
||||
icon: (
|
||||
<svg className="w-3.5 h-3.5 text-blue-500 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
),
|
||||
textClass: 'text-gray-900 dark:text-gray-100',
|
||||
badgeClass: 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 border-blue-200 dark:border-blue-800'
|
||||
},
|
||||
pending: {
|
||||
icon: (
|
||||
<svg className="w-3.5 h-3.5 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="9" strokeWidth={2} />
|
||||
</svg>
|
||||
),
|
||||
textClass: 'text-gray-700 dark:text-gray-300',
|
||||
badgeClass: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 border-gray-200 dark:border-gray-700'
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders task list results with proper status icons and compact layout
|
||||
* Parses text content from TaskList/TaskGet results
|
||||
*/
|
||||
export const TaskListContent: React.FC<TaskListContentProps> = ({ content }) => {
|
||||
const tasks = parseTaskContent(content);
|
||||
|
||||
// If we couldn't parse any tasks, fall back to text display
|
||||
if (tasks.length === 0) {
|
||||
return (
|
||||
<pre className="text-[11px] font-mono text-gray-600 dark:text-gray-400 whitespace-pre-wrap">
|
||||
{content}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
const completed = tasks.filter(t => t.status === 'completed').length;
|
||||
const total = tasks.length;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<span className="text-[11px] text-gray-500 dark:text-gray-400">
|
||||
{completed}/{total} completed
|
||||
</span>
|
||||
<div className="flex-1 h-1 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-green-500 dark:bg-green-400 rounded-full transition-all"
|
||||
style={{ width: `${total > 0 ? (completed / total) * 100 : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-px">
|
||||
{tasks.map((task) => {
|
||||
const config = statusConfig[task.status] || statusConfig.pending;
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
className="flex items-center gap-1.5 py-0.5 group"
|
||||
>
|
||||
<span className="flex-shrink-0">{config.icon}</span>
|
||||
<span className="text-[11px] font-mono text-gray-400 dark:text-gray-500 flex-shrink-0">
|
||||
#{task.id}
|
||||
</span>
|
||||
<span className={`text-xs truncate flex-1 ${config.textClass}`}>
|
||||
{task.subject}
|
||||
</span>
|
||||
<span className={`text-[10px] px-1 py-px rounded border flex-shrink-0 ${config.badgeClass}`}>
|
||||
{task.status.replace('_', ' ')}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
|
||||
interface TextContentProps {
|
||||
content: string;
|
||||
format?: 'plain' | 'json' | 'code';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders plain text, JSON, or code content
|
||||
* Used by: Raw parameters, generic text results, JSON responses
|
||||
*/
|
||||
export const TextContent: React.FC<TextContentProps> = ({
|
||||
content,
|
||||
format = 'plain',
|
||||
className = ''
|
||||
}) => {
|
||||
if (format === 'json') {
|
||||
let formattedJson = content;
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
formattedJson = JSON.stringify(parsed, null, 2);
|
||||
} catch (e) {
|
||||
// If parsing fails, use original content
|
||||
}
|
||||
|
||||
return (
|
||||
<pre className={`mt-1 text-xs bg-gray-900 dark:bg-gray-950 text-gray-100 p-2.5 rounded overflow-x-auto font-mono ${className}`}>
|
||||
{formattedJson}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
if (format === 'code') {
|
||||
return (
|
||||
<pre className={`mt-1 text-xs bg-gray-50 dark:bg-gray-800/50 border border-gray-200/50 dark:border-gray-700/50 p-2 rounded whitespace-pre-wrap break-words overflow-hidden text-gray-700 dark:text-gray-300 font-mono ${className}`}>
|
||||
{content}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
// Plain text
|
||||
return (
|
||||
<div className={`mt-1 text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap ${className}`}>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
import TodoList from '../../../../TodoList';
|
||||
|
||||
interface TodoListContentProps {
|
||||
todos: Array<{
|
||||
id?: string;
|
||||
content: string;
|
||||
status: string;
|
||||
priority?: string;
|
||||
}>;
|
||||
isResult?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a todo list
|
||||
* Used by: TodoWrite, TodoRead
|
||||
*/
|
||||
export const TodoListContent: React.FC<TodoListContentProps> = ({
|
||||
todos,
|
||||
isResult = false
|
||||
}) => {
|
||||
return <TodoList todos={todos} isResult={isResult} />;
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
export { MarkdownContent } from './MarkdownContent';
|
||||
export { FileListContent } from './FileListContent';
|
||||
export { TodoListContent } from './TodoListContent';
|
||||
export { TaskListContent } from './TaskListContent';
|
||||
export { TextContent } from './TextContent';
|
||||
export { QuestionAnswerContent } from './QuestionAnswerContent';
|
||||
88
src/components/chat/tools/components/DiffViewer.tsx
Normal file
88
src/components/chat/tools/components/DiffViewer.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
type DiffLine = {
|
||||
type: string;
|
||||
content: string;
|
||||
lineNum: number;
|
||||
};
|
||||
|
||||
interface DiffViewerProps {
|
||||
oldContent: string;
|
||||
newContent: string;
|
||||
filePath: string;
|
||||
createDiff: (oldStr: string, newStr: string) => DiffLine[];
|
||||
onFileClick?: () => void;
|
||||
badge?: string;
|
||||
badgeColor?: 'gray' | 'green';
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact diff viewer — VS Code-style
|
||||
*/
|
||||
export const DiffViewer: React.FC<DiffViewerProps> = ({
|
||||
oldContent,
|
||||
newContent,
|
||||
filePath,
|
||||
createDiff,
|
||||
onFileClick,
|
||||
badge = 'Diff',
|
||||
badgeColor = 'gray'
|
||||
}) => {
|
||||
const badgeClasses = badgeColor === 'green'
|
||||
? 'bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400'
|
||||
: 'bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400';
|
||||
|
||||
const diffLines = useMemo(
|
||||
() => createDiff(oldContent, newContent),
|
||||
[createDiff, oldContent, newContent]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="border border-gray-200/60 dark:border-gray-700/50 rounded overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-2.5 py-1 bg-gray-50/80 dark:bg-gray-800/40 border-b border-gray-200/60 dark:border-gray-700/50">
|
||||
{onFileClick ? (
|
||||
<button
|
||||
onClick={onFileClick}
|
||||
className="text-[11px] font-mono text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 truncate cursor-pointer transition-colors"
|
||||
>
|
||||
{filePath}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-[11px] font-mono text-gray-600 dark:text-gray-400 truncate">
|
||||
{filePath}
|
||||
</span>
|
||||
)}
|
||||
<span className={`text-[10px] font-medium px-1.5 py-px rounded ${badgeClasses} flex-shrink-0 ml-2`}>
|
||||
{badge}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Diff lines */}
|
||||
<div className="text-[11px] font-mono leading-[18px]">
|
||||
{diffLines.map((diffLine, i) => (
|
||||
<div key={i} className="flex">
|
||||
<span
|
||||
className={`w-6 text-center select-none flex-shrink-0 ${
|
||||
diffLine.type === 'removed'
|
||||
? 'bg-red-50 dark:bg-red-950/30 text-red-400 dark:text-red-500'
|
||||
: 'bg-green-50 dark:bg-green-950/30 text-green-400 dark:text-green-500'
|
||||
}`}
|
||||
>
|
||||
{diffLine.type === 'removed' ? '-' : '+'}
|
||||
</span>
|
||||
<span
|
||||
className={`px-2 flex-1 whitespace-pre-wrap ${
|
||||
diffLine.type === 'removed'
|
||||
? 'bg-red-50/50 dark:bg-red-950/20 text-red-800 dark:text-red-200'
|
||||
: 'bg-green-50/50 dark:bg-green-950/20 text-green-800 dark:text-green-200'
|
||||
}`}
|
||||
>
|
||||
{diffLine.content}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,384 @@
|
||||
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import type { PermissionPanelProps } from '../../configs/permissionPanelRegistry';
|
||||
import type { Question } from '../../../types/types';
|
||||
|
||||
export const AskUserQuestionPanel: React.FC<PermissionPanelProps> = ({
|
||||
request,
|
||||
onDecision,
|
||||
}) => {
|
||||
const input = request.input as { questions?: Question[] } | undefined;
|
||||
const questions: Question[] = input?.questions || [];
|
||||
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [selections, setSelections] = useState<Map<number, Set<string>>>(() => new Map());
|
||||
const [otherTexts, setOtherTexts] = useState<Map<number, string>>(() => new Map());
|
||||
const [otherActive, setOtherActive] = useState<Map<number, boolean>>(() => new Map());
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const otherInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(() => setMounted(true));
|
||||
}, []);
|
||||
|
||||
// Focus the container for keyboard events when step changes
|
||||
useEffect(() => {
|
||||
if (!otherActive.get(currentStep)) {
|
||||
containerRef.current?.focus();
|
||||
}
|
||||
}, [currentStep, otherActive]);
|
||||
|
||||
useEffect(() => {
|
||||
if (otherActive.get(currentStep)) {
|
||||
otherInputRef.current?.focus();
|
||||
}
|
||||
}, [otherActive, currentStep]);
|
||||
|
||||
const toggleOption = useCallback((qIdx: number, label: string, multiSelect: boolean) => {
|
||||
setSelections(prev => {
|
||||
const next = new Map(prev);
|
||||
const current = new Set(next.get(qIdx) || []);
|
||||
if (multiSelect) {
|
||||
if (current.has(label)) current.delete(label);
|
||||
else current.add(label);
|
||||
} else {
|
||||
current.clear();
|
||||
current.add(label);
|
||||
setOtherActive(p => { const n = new Map(p); n.set(qIdx, false); return n; });
|
||||
}
|
||||
next.set(qIdx, current);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleOther = useCallback((qIdx: number, multiSelect: boolean) => {
|
||||
setOtherActive(prev => {
|
||||
const next = new Map(prev);
|
||||
const wasActive = next.get(qIdx) || false;
|
||||
next.set(qIdx, !wasActive);
|
||||
if (!multiSelect && !wasActive) {
|
||||
setSelections(p => { const n = new Map(p); n.set(qIdx, new Set()); return n; });
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setOtherText = useCallback((qIdx: number, text: string) => {
|
||||
setOtherTexts(prev => { const next = new Map(prev); next.set(qIdx, text); return next; });
|
||||
}, []);
|
||||
|
||||
const buildAnswers = useCallback(() => {
|
||||
const answers: Record<string, string> = {};
|
||||
questions.forEach((q, idx) => {
|
||||
const selected = Array.from(selections.get(idx) || []);
|
||||
const isOther = otherActive.get(idx) || false;
|
||||
const otherText = (otherTexts.get(idx) || '').trim();
|
||||
if (isOther && otherText) selected.push(otherText);
|
||||
if (selected.length > 0) answers[q.question] = selected.join(', ');
|
||||
});
|
||||
return answers;
|
||||
}, [questions, selections, otherActive, otherTexts]);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
onDecision(request.requestId, { allow: true, updatedInput: { ...input, answers: buildAnswers() } });
|
||||
}, [onDecision, request.requestId, input, buildAnswers]);
|
||||
|
||||
const handleSkip = useCallback(() => {
|
||||
onDecision(request.requestId, { allow: true, updatedInput: { ...input, answers: {} } });
|
||||
}, [onDecision, request.requestId, input]);
|
||||
|
||||
// Keyboard handler for number keys and navigation
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
// Don't capture keys when typing in the "Other" input
|
||||
if (e.target instanceof HTMLInputElement) return;
|
||||
|
||||
const q = questions[currentStep];
|
||||
if (!q) return;
|
||||
const multi = q.multiSelect || false;
|
||||
const optCount = q.options.length;
|
||||
|
||||
// Number keys 1-9 for options
|
||||
const num = parseInt(e.key);
|
||||
if (!isNaN(num) && num >= 1 && num <= optCount) {
|
||||
e.preventDefault();
|
||||
toggleOption(currentStep, q.options[num - 1].label, multi);
|
||||
return;
|
||||
}
|
||||
|
||||
// 0 for "Other"
|
||||
if (e.key === '0') {
|
||||
e.preventDefault();
|
||||
toggleOther(currentStep, multi);
|
||||
return;
|
||||
}
|
||||
|
||||
// Enter to advance / submit
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const isLast = currentStep === questions.length - 1;
|
||||
if (isLast) handleSubmit();
|
||||
else setCurrentStep(s => s + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
// Escape to skip
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
handleSkip();
|
||||
return;
|
||||
}
|
||||
}, [currentStep, questions, toggleOption, toggleOther, handleSubmit, handleSkip]);
|
||||
|
||||
if (questions.length === 0) return null;
|
||||
|
||||
const total = questions.length;
|
||||
const isSingle = total === 1;
|
||||
const q = questions[currentStep];
|
||||
const multi = q.multiSelect || false;
|
||||
const selected = selections.get(currentStep) || new Set<string>();
|
||||
const isOtherOn = otherActive.get(currentStep) || false;
|
||||
const isLast = currentStep === total - 1;
|
||||
const isFirst = currentStep === 0;
|
||||
const hasCurrentSelection = selected.size > 0 || (isOtherOn && (otherTexts.get(currentStep) || '').trim().length > 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
tabIndex={-1}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={`w-full outline-none transition-all duration-500 ease-out ${
|
||||
mounted ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-3'
|
||||
}`}
|
||||
>
|
||||
<div className="relative overflow-hidden rounded-2xl border border-gray-200/80 dark:border-gray-700/50 bg-white dark:bg-gray-800/90 shadow-lg dark:shadow-2xl">
|
||||
{/* Accent line */}
|
||||
<div className="absolute top-0 left-0 right-0 h-[2px] bg-gradient-to-r from-blue-500 via-cyan-400 to-teal-400" />
|
||||
|
||||
{/* Header + Question — compact */}
|
||||
<div className="px-4 pt-3.5 pb-2">
|
||||
<div className="flex items-center gap-2.5 mb-1.5">
|
||||
{/* Question icon */}
|
||||
<div className="relative flex-shrink-0">
|
||||
<div className="w-6 h-6 rounded-lg bg-gradient-to-br from-blue-500/10 to-cyan-500/10 dark:from-blue-400/15 dark:to-cyan-400/15 flex items-center justify-center">
|
||||
<svg className="w-3.5 h-3.5 text-blue-600 dark:text-blue-400" fill="none" viewBox="0 0 24 24" strokeWidth={1.75} stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827m0 3h.01" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full bg-cyan-400 dark:bg-cyan-500 animate-pulse" />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<span className="text-[10px] font-medium tracking-wide uppercase text-gray-400 dark:text-gray-500">
|
||||
Claude needs your input
|
||||
</span>
|
||||
{q.header && (
|
||||
<span className="inline-flex items-center px-1.5 py-px rounded text-[9px] font-semibold uppercase tracking-wider bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 border border-blue-100 dark:border-blue-800/50">
|
||||
{q.header}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Step counter */}
|
||||
{!isSingle && (
|
||||
<span className="text-[10px] tabular-nums text-gray-400 dark:text-gray-500 flex-shrink-0">
|
||||
{currentStep + 1}/{total}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress dots (multi-question) */}
|
||||
{!isSingle && (
|
||||
<div className="flex items-center gap-1 mb-2">
|
||||
{questions.map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => setCurrentStep(i)}
|
||||
className={`h-[3px] rounded-full transition-all duration-300 ${
|
||||
i === currentStep
|
||||
? 'w-5 bg-blue-500 dark:bg-blue-400'
|
||||
: i < currentStep
|
||||
? 'w-2.5 bg-blue-300 dark:bg-blue-600'
|
||||
: 'w-2.5 bg-gray-200 dark:bg-gray-700'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Question text */}
|
||||
<p className="text-[14px] leading-snug font-medium text-gray-900 dark:text-gray-100">
|
||||
{q.question}
|
||||
</p>
|
||||
{multi && (
|
||||
<span className="text-[10px] text-gray-400 dark:text-gray-500">Select all that apply</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Options — tight spacing */}
|
||||
<div className="px-4 pb-2 max-h-48 overflow-y-auto scrollbar-thin" role={multi ? 'group' : 'radiogroup'} aria-label={q.question}>
|
||||
<div className="space-y-1">
|
||||
{q.options.map((opt, optIdx) => {
|
||||
const isSelected = selected.has(opt.label);
|
||||
return (
|
||||
<button
|
||||
key={opt.label}
|
||||
type="button"
|
||||
onClick={() => toggleOption(currentStep, opt.label, multi)}
|
||||
className={`group w-full text-left flex items-center gap-2.5 px-3 py-2 rounded-lg border transition-all duration-150 ${
|
||||
isSelected
|
||||
? 'border-blue-300 dark:border-blue-600 bg-blue-50/80 dark:bg-blue-900/25 ring-1 ring-blue-200/50 dark:ring-blue-700/30'
|
||||
: 'border-gray-200 dark:border-gray-700/60 hover:border-gray-300 dark:hover:border-gray-600 hover:bg-gray-50/60 dark:hover:bg-gray-750/50'
|
||||
}`}
|
||||
>
|
||||
{/* Keyboard hint */}
|
||||
<kbd className={`flex-shrink-0 w-5 h-5 rounded text-[10px] font-mono flex items-center justify-center transition-all duration-150 ${
|
||||
isSelected
|
||||
? 'bg-blue-500 dark:bg-blue-500 text-white font-semibold'
|
||||
: 'bg-gray-100 dark:bg-gray-800 text-gray-400 dark:text-gray-500 border border-gray-200 dark:border-gray-700 group-hover:border-gray-300 dark:group-hover:border-gray-600'
|
||||
}`}>
|
||||
{optIdx + 1}
|
||||
</kbd>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={`text-[13px] leading-tight transition-colors duration-150 ${
|
||||
isSelected
|
||||
? 'text-gray-900 dark:text-gray-100 font-medium'
|
||||
: 'text-gray-700 dark:text-gray-300'
|
||||
}`}>
|
||||
{opt.label}
|
||||
</div>
|
||||
{opt.description && (
|
||||
<div className={`text-[11px] leading-snug transition-colors duration-150 ${
|
||||
isSelected
|
||||
? 'text-blue-600/70 dark:text-blue-300/70'
|
||||
: 'text-gray-400 dark:text-gray-500'
|
||||
}`}>
|
||||
{opt.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Selection check */}
|
||||
{isSelected && (
|
||||
<svg className="w-4 h-4 text-blue-500 dark:text-blue-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* "Other" option */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleOther(currentStep, multi)}
|
||||
className={`group w-full text-left flex items-center gap-2.5 px-3 py-2 rounded-lg border transition-all duration-150 ${
|
||||
isOtherOn
|
||||
? 'border-blue-300 dark:border-blue-600 bg-blue-50/80 dark:bg-blue-900/25 ring-1 ring-blue-200/50 dark:ring-blue-700/30'
|
||||
: 'border-dashed border-gray-200 dark:border-gray-700/60 hover:border-gray-300 dark:hover:border-gray-600 hover:bg-gray-50/60 dark:hover:bg-gray-750/50'
|
||||
}`}
|
||||
>
|
||||
<kbd className={`flex-shrink-0 w-5 h-5 rounded text-[10px] font-mono flex items-center justify-center transition-all duration-150 ${
|
||||
isOtherOn
|
||||
? 'bg-blue-500 dark:bg-blue-500 text-white font-semibold'
|
||||
: 'bg-gray-100 dark:bg-gray-800 text-gray-400 dark:text-gray-500 border border-gray-200 dark:border-gray-700 group-hover:border-gray-300 dark:group-hover:border-gray-600'
|
||||
}`}>
|
||||
0
|
||||
</kbd>
|
||||
<span className={`text-[13px] leading-tight transition-colors ${
|
||||
isOtherOn
|
||||
? 'text-gray-900 dark:text-gray-100 font-medium'
|
||||
: 'text-gray-500 dark:text-gray-400'
|
||||
}`}>
|
||||
Other...
|
||||
</span>
|
||||
{isOtherOn && (
|
||||
<svg className="w-4 h-4 text-blue-500 dark:text-blue-400 flex-shrink-0 ml-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Other text input — inline */}
|
||||
{isOtherOn && (
|
||||
<div className="pl-[30px] pr-0.5">
|
||||
<div className="relative">
|
||||
<input
|
||||
ref={otherInputRef}
|
||||
type="text"
|
||||
value={otherTexts.get(currentStep) || ''}
|
||||
onChange={(e) => setOtherText(currentStep, e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (isLast) handleSubmit();
|
||||
else setCurrentStep(s => s + 1);
|
||||
}
|
||||
// Prevent container keydown from firing
|
||||
e.stopPropagation();
|
||||
}}
|
||||
placeholder="Type your answer..."
|
||||
className="w-full text-[13px] rounded-lg border-0 bg-gray-50 dark:bg-gray-900/60 text-gray-900 dark:text-gray-100 px-3 py-1.5 outline-none ring-1 ring-gray-200 dark:ring-gray-700 focus:ring-2 focus:ring-blue-400 dark:focus:ring-blue-500 placeholder:text-gray-400 dark:placeholder:text-gray-600 transition-shadow duration-200"
|
||||
/>
|
||||
<kbd className="absolute right-2 top-1/2 -translate-y-1/2 text-[9px] font-mono text-gray-300 dark:text-gray-600 bg-gray-100 dark:bg-gray-800 px-1 py-0.5 rounded border border-gray-200 dark:border-gray-700">
|
||||
Enter
|
||||
</kbd>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer — compact */}
|
||||
<div className="px-4 py-2 border-t border-gray-100 dark:border-gray-700/50 bg-gray-50/50 dark:bg-gray-800/50 flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSkip}
|
||||
className="text-[11px] text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
>
|
||||
{isSingle ? 'Skip' : 'Skip all'}
|
||||
<span className="ml-1 text-[9px] text-gray-300 dark:text-gray-600">Esc</span>
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
{!isSingle && !isFirst && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCurrentStep(s => s - 1)}
|
||||
className="inline-flex items-center gap-0.5 text-[11px] font-medium px-2.5 py-1.5 rounded-lg text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700/60 transition-all duration-150"
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isLast ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={!hasCurrentSelection && !Object.keys(buildAnswers()).length}
|
||||
className="inline-flex items-center gap-1 text-[11px] font-semibold px-3.5 py-1.5 rounded-lg bg-gradient-to-r from-blue-600 to-blue-500 dark:from-blue-500 dark:to-blue-600 text-white shadow-sm hover:shadow-md disabled:opacity-30 disabled:cursor-not-allowed disabled:shadow-none transition-all duration-200"
|
||||
>
|
||||
Submit
|
||||
<span className="text-[9px] opacity-70 font-mono ml-0.5">Enter</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCurrentStep(s => s + 1)}
|
||||
className="inline-flex items-center gap-1 text-[11px] font-semibold px-3.5 py-1.5 rounded-lg bg-gradient-to-r from-blue-600 to-blue-500 dark:from-blue-500 dark:to-blue-600 text-white shadow-sm hover:shadow-md transition-all duration-200"
|
||||
>
|
||||
Next
|
||||
<span className="text-[9px] opacity-70 font-mono ml-0.5">Enter</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { AskUserQuestionPanel } from './AskUserQuestionPanel';
|
||||
233
src/components/chat/tools/components/OneLineDisplay.tsx
Normal file
233
src/components/chat/tools/components/OneLineDisplay.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
type ActionType = 'copy' | 'open-file' | 'jump-to-results' | 'none';
|
||||
|
||||
interface OneLineDisplayProps {
|
||||
|
||||
toolName: string;
|
||||
icon?: string;
|
||||
label?: string;
|
||||
value: string;
|
||||
secondary?: string;
|
||||
action?: ActionType;
|
||||
onAction?: () => void;
|
||||
style?: string;
|
||||
wrapText?: boolean;
|
||||
colorScheme?: {
|
||||
primary?: string;
|
||||
secondary?: string;
|
||||
background?: string;
|
||||
border?: string;
|
||||
icon?: string;
|
||||
};
|
||||
resultId?: string;
|
||||
toolResult?: any;
|
||||
toolId?: string;
|
||||
}
|
||||
|
||||
// Fallback for environments where the async Clipboard API is unavailable or blocked.
|
||||
const copyWithLegacyExecCommand = (text: string): boolean => {
|
||||
if (typeof document === 'undefined' || !document.body) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.setAttribute('readonly', '');
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.opacity = '0';
|
||||
textarea.style.left = '-9999px';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
textarea.setSelectionRange(0, text.length);
|
||||
|
||||
let copied = false;
|
||||
try {
|
||||
copied = document.execCommand('copy');
|
||||
} catch {
|
||||
copied = false;
|
||||
} finally {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
|
||||
return copied;
|
||||
};
|
||||
|
||||
const copyTextToClipboard = async (text: string): Promise<boolean> => {
|
||||
if (
|
||||
typeof navigator !== 'undefined' &&
|
||||
typeof window !== 'undefined' &&
|
||||
window.isSecureContext &&
|
||||
navigator.clipboard?.writeText
|
||||
) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch {
|
||||
// Fall back below when writeText is rejected (permissions/insecure contexts/browser limits).
|
||||
}
|
||||
}
|
||||
|
||||
return copyWithLegacyExecCommand(text);
|
||||
};
|
||||
|
||||
/**
|
||||
* Unified one-line display for simple tool inputs and results
|
||||
* Used by: Bash, Read, Grep/Glob (minimized), TodoRead, etc.
|
||||
*/
|
||||
export const OneLineDisplay: React.FC<OneLineDisplayProps> = ({
|
||||
toolName,
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
secondary,
|
||||
action = 'none',
|
||||
onAction,
|
||||
style,
|
||||
wrapText = false,
|
||||
colorScheme = {
|
||||
primary: 'text-gray-700 dark:text-gray-300',
|
||||
secondary: 'text-gray-500 dark:text-gray-400',
|
||||
background: '',
|
||||
border: 'border-gray-300 dark:border-gray-600',
|
||||
icon: 'text-gray-500 dark:text-gray-400'
|
||||
},
|
||||
resultId,
|
||||
toolResult,
|
||||
toolId
|
||||
}) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const isTerminal = style === 'terminal';
|
||||
|
||||
const handleAction = async () => {
|
||||
if (action === 'copy' && value) {
|
||||
const didCopy = await copyTextToClipboard(value);
|
||||
if (!didCopy) {
|
||||
return;
|
||||
}
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} else if (onAction) {
|
||||
onAction();
|
||||
}
|
||||
};
|
||||
|
||||
const renderCopyButton = () => (
|
||||
<button
|
||||
onClick={handleAction}
|
||||
className="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-all ml-1 flex-shrink-0"
|
||||
title="Copy to clipboard"
|
||||
aria-label="Copy to clipboard"
|
||||
>
|
||||
{copied ? (
|
||||
<svg className="w-3 h-3 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
|
||||
// Terminal style: dark pill only around the command
|
||||
if (isTerminal) {
|
||||
return (
|
||||
<div className="group my-1">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0 pt-0.5">
|
||||
<svg className="w-3 h-3 text-green-500 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 flex items-start gap-2">
|
||||
<div className="bg-gray-900 dark:bg-black rounded px-2.5 py-1 flex-1 min-w-0">
|
||||
<code className={`text-xs text-green-400 font-mono ${wrapText ? 'whitespace-pre-wrap break-all' : 'block truncate'}`}>
|
||||
<span className="text-green-600 dark:text-green-500 select-none">$ </span>{value}
|
||||
</code>
|
||||
</div>
|
||||
{action === 'copy' && renderCopyButton()}
|
||||
</div>
|
||||
</div>
|
||||
{secondary && (
|
||||
<div className="ml-7 mt-1">
|
||||
<span className="text-[11px] text-gray-400 dark:text-gray-500 italic">
|
||||
{secondary}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// File open style - show filename only, full path on hover
|
||||
if (action === 'open-file') {
|
||||
const displayName = value.split('/').pop() || value;
|
||||
return (
|
||||
<div className={`group flex items-center gap-1.5 border-l-2 ${colorScheme.border} pl-3 py-0.5 my-0.5`}>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0">{label || toolName}</span>
|
||||
<span className="text-gray-300 dark:text-gray-600 text-[10px]">/</span>
|
||||
<button
|
||||
onClick={handleAction}
|
||||
className="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 font-mono hover:underline transition-colors truncate"
|
||||
title={value}
|
||||
>
|
||||
{displayName}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Search / jump-to-results style
|
||||
if (action === 'jump-to-results') {
|
||||
return (
|
||||
<div className={`group flex items-center gap-1.5 border-l-2 ${colorScheme.border} pl-3 py-0.5 my-0.5`}>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0">{label || toolName}</span>
|
||||
<span className="text-gray-300 dark:text-gray-600 text-[10px]">/</span>
|
||||
<span className={`text-xs font-mono truncate flex-1 min-w-0 ${colorScheme.primary}`}>
|
||||
{value}
|
||||
</span>
|
||||
{secondary && (
|
||||
<span className="text-[11px] text-gray-400 dark:text-gray-500 italic flex-shrink-0">
|
||||
{secondary}
|
||||
</span>
|
||||
)}
|
||||
{toolResult && (
|
||||
<a
|
||||
href={`#tool-result-${toolId}`}
|
||||
className="flex-shrink-0 text-[11px] text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors flex items-center gap-0.5"
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Default one-line style
|
||||
return (
|
||||
<div className={`group flex items-center gap-1.5 ${colorScheme.background || ''} border-l-2 ${colorScheme.border} pl-3 py-0.5 my-0.5`}>
|
||||
{icon && icon !== 'terminal' && (
|
||||
<span className={`${colorScheme.icon} flex-shrink-0 text-xs`}>{icon}</span>
|
||||
)}
|
||||
{!icon && (label || toolName) && (
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0">{label || toolName}</span>
|
||||
)}
|
||||
{(icon || label || toolName) && (
|
||||
<span className="text-gray-300 dark:text-gray-600 text-[10px]">/</span>
|
||||
)}
|
||||
<span className={`text-xs font-mono ${wrapText ? 'whitespace-pre-wrap break-all' : 'truncate'} flex-1 min-w-0 ${colorScheme.primary}`}>
|
||||
{value}
|
||||
</span>
|
||||
{secondary && (
|
||||
<span className={`text-[11px] ${colorScheme.secondary} italic flex-shrink-0`}>
|
||||
{secondary}
|
||||
</span>
|
||||
)}
|
||||
{action === 'copy' && renderCopyButton()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
180
src/components/chat/tools/components/SubagentContainer.tsx
Normal file
180
src/components/chat/tools/components/SubagentContainer.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import React from 'react';
|
||||
import { CollapsibleSection } from './CollapsibleSection';
|
||||
import type { SubagentChildTool } from '../../types/types';
|
||||
|
||||
interface SubagentContainerProps {
|
||||
toolInput: unknown;
|
||||
toolResult?: { content?: unknown; isError?: boolean } | null;
|
||||
subagentState: {
|
||||
childTools: SubagentChildTool[];
|
||||
currentToolIndex: number;
|
||||
isComplete: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
const getCompactToolDisplay = (toolName: string, toolInput: unknown): string => {
|
||||
const input = typeof toolInput === 'string' ? (() => {
|
||||
try { return JSON.parse(toolInput); } catch { return {}; }
|
||||
})() : (toolInput || {});
|
||||
|
||||
switch (toolName) {
|
||||
case 'Read':
|
||||
case 'Write':
|
||||
case 'Edit':
|
||||
case 'ApplyPatch':
|
||||
return input.file_path?.split('/').pop() || input.file_path || '';
|
||||
case 'Grep':
|
||||
case 'Glob':
|
||||
return input.pattern || '';
|
||||
case 'Bash':
|
||||
const cmd = input.command || '';
|
||||
return cmd.length > 40 ? `${cmd.slice(0, 40)}...` : cmd;
|
||||
case 'Task':
|
||||
return input.description || input.subagent_type || '';
|
||||
case 'WebFetch':
|
||||
case 'WebSearch':
|
||||
return input.url || input.query || '';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
export const SubagentContainer: React.FC<SubagentContainerProps> = ({
|
||||
toolInput,
|
||||
toolResult,
|
||||
subagentState,
|
||||
}) => {
|
||||
const parsedInput = typeof toolInput === 'string' ? (() => {
|
||||
try { return JSON.parse(toolInput); } catch { return {}; }
|
||||
})() : (toolInput || {});
|
||||
|
||||
const subagentType = parsedInput?.subagent_type || 'Agent';
|
||||
const description = parsedInput?.description || 'Running task';
|
||||
const prompt = parsedInput?.prompt || '';
|
||||
const { childTools, currentToolIndex, isComplete } = subagentState;
|
||||
const currentTool = currentToolIndex >= 0 ? childTools[currentToolIndex] : null;
|
||||
|
||||
const title = `Subagent / ${subagentType}: ${description}`;
|
||||
|
||||
return (
|
||||
<div className="border-l-2 border-l-purple-500 dark:border-l-purple-400 pl-3 py-0.5 my-1">
|
||||
<CollapsibleSection
|
||||
title={title}
|
||||
toolName="Task"
|
||||
open={false}
|
||||
>
|
||||
{/* Prompt/request to the subagent */}
|
||||
{prompt && (
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400 mb-2 whitespace-pre-wrap break-words line-clamp-4">
|
||||
{prompt}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Current tool indicator (while running) */}
|
||||
{currentTool && !isComplete && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
<span className="animate-pulse w-1.5 h-1.5 rounded-full bg-purple-500 dark:bg-purple-400 flex-shrink-0" />
|
||||
<span className="text-gray-400 dark:text-gray-500">Currently:</span>
|
||||
<span className="font-medium text-gray-600 dark:text-gray-300">{currentTool.toolName}</span>
|
||||
{getCompactToolDisplay(currentTool.toolName, currentTool.toolInput) && (
|
||||
<>
|
||||
<span className="text-gray-300 dark:text-gray-600">/</span>
|
||||
<span className="font-mono truncate text-gray-500 dark:text-gray-400">
|
||||
{getCompactToolDisplay(currentTool.toolName, currentTool.toolInput)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Completion status */}
|
||||
{isComplete && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-green-600 dark:text-green-400 mt-1">
|
||||
<svg className="w-3 h-3 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span>Completed ({childTools.length} {childTools.length === 1 ? 'tool' : 'tools'})</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tool history (collapsed) */}
|
||||
{childTools.length > 0 && (
|
||||
<details className="mt-2 group/history">
|
||||
<summary className="cursor-pointer text-[11px] text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 flex items-center gap-1">
|
||||
<svg
|
||||
className="w-2.5 h-2.5 transition-transform duration-150 group-open/history:rotate-90 flex-shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<span>View tool history ({childTools.length})</span>
|
||||
</summary>
|
||||
<div className="mt-1 pl-3 border-l border-gray-200 dark:border-gray-700 space-y-0.5">
|
||||
{childTools.map((child, index) => (
|
||||
<div key={child.toolId} className="flex items-center gap-1.5 text-[11px] text-gray-500 dark:text-gray-400">
|
||||
<span className="text-gray-400 dark:text-gray-500 w-4 text-right flex-shrink-0">{index + 1}.</span>
|
||||
<span className="font-medium">{child.toolName}</span>
|
||||
{getCompactToolDisplay(child.toolName, child.toolInput) && (
|
||||
<span className="font-mono truncate text-gray-400 dark:text-gray-500">
|
||||
{getCompactToolDisplay(child.toolName, child.toolInput)}
|
||||
</span>
|
||||
)}
|
||||
{child.toolResult?.isError && (
|
||||
<span className="text-red-500 flex-shrink-0">(error)</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{/* Final result */}
|
||||
{isComplete && toolResult && (
|
||||
<div className="mt-2 text-xs text-gray-600 dark:text-gray-400">
|
||||
{(() => {
|
||||
let content = toolResult.content;
|
||||
|
||||
// Handle JSON string that needs parsing
|
||||
if (typeof content === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
if (Array.isArray(parsed)) {
|
||||
// Extract text from array format like [{"type":"text","text":"..."}]
|
||||
const textParts = parsed
|
||||
.filter((p: any) => p.type === 'text' && p.text)
|
||||
.map((p: any) => p.text);
|
||||
if (textParts.length > 0) {
|
||||
content = textParts.join('\n');
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Not JSON, use as-is
|
||||
}
|
||||
} else if (Array.isArray(content)) {
|
||||
// Direct array format
|
||||
const textParts = content
|
||||
.filter((p: any) => p.type === 'text' && p.text)
|
||||
.map((p: any) => p.text);
|
||||
if (textParts.length > 0) {
|
||||
content = textParts.join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
return typeof content === 'string' ? (
|
||||
<div className="whitespace-pre-wrap break-words line-clamp-6">
|
||||
{content}
|
||||
</div>
|
||||
) : content ? (
|
||||
<pre className="whitespace-pre-wrap break-words line-clamp-6 font-mono text-[11px]">
|
||||
{JSON.stringify(content, null, 2)}
|
||||
</pre>
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
7
src/components/chat/tools/components/index.ts
Normal file
7
src/components/chat/tools/components/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export { CollapsibleSection } from './CollapsibleSection';
|
||||
export { DiffViewer } from './DiffViewer';
|
||||
export { OneLineDisplay } from './OneLineDisplay';
|
||||
export { CollapsibleDisplay } from './CollapsibleDisplay';
|
||||
export { SubagentContainer } from './SubagentContainer';
|
||||
export * from './ContentRenderers';
|
||||
export * from './InteractiveRenderers';
|
||||
25
src/components/chat/tools/configs/permissionPanelRegistry.ts
Normal file
25
src/components/chat/tools/configs/permissionPanelRegistry.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { ComponentType } from 'react';
|
||||
import type { PendingPermissionRequest } from '../../types/types';
|
||||
|
||||
export interface PermissionPanelProps {
|
||||
request: PendingPermissionRequest;
|
||||
onDecision: (
|
||||
requestIds: string | string[],
|
||||
decision: { allow?: boolean; message?: string; updatedInput?: unknown },
|
||||
) => void;
|
||||
}
|
||||
|
||||
const registry: Record<string, ComponentType<PermissionPanelProps>> = {};
|
||||
|
||||
export function registerPermissionPanel(
|
||||
toolName: string,
|
||||
component: ComponentType<PermissionPanelProps>,
|
||||
): void {
|
||||
registry[toolName] = component;
|
||||
}
|
||||
|
||||
export function getPermissionPanel(
|
||||
toolName: string,
|
||||
): ComponentType<PermissionPanelProps> | null {
|
||||
return registry[toolName] || null;
|
||||
}
|
||||
603
src/components/chat/tools/configs/toolConfigs.ts
Normal file
603
src/components/chat/tools/configs/toolConfigs.ts
Normal file
@@ -0,0 +1,603 @@
|
||||
/**
|
||||
* Centralized tool configuration registry
|
||||
* Defines display behavior for all tool types
|
||||
*/
|
||||
|
||||
export interface ToolDisplayConfig {
|
||||
input: {
|
||||
type: 'one-line' | 'collapsible' | 'hidden';
|
||||
// One-line config
|
||||
icon?: string;
|
||||
label?: string;
|
||||
getValue?: (input: any) => string;
|
||||
getSecondary?: (input: any) => string | undefined;
|
||||
action?: 'copy' | 'open-file' | 'jump-to-results' | 'none';
|
||||
style?: string;
|
||||
wrapText?: boolean;
|
||||
colorScheme?: {
|
||||
primary?: string;
|
||||
secondary?: string;
|
||||
background?: string;
|
||||
border?: string;
|
||||
icon?: string;
|
||||
};
|
||||
// Collapsible config
|
||||
title?: string | ((input: any) => string);
|
||||
defaultOpen?: boolean;
|
||||
contentType?: 'diff' | 'markdown' | 'file-list' | 'todo-list' | 'text' | 'task' | 'question-answer';
|
||||
getContentProps?: (input: any, helpers?: any) => any;
|
||||
actionButton?: 'file-button' | 'none';
|
||||
};
|
||||
result?: {
|
||||
hidden?: boolean;
|
||||
hideOnSuccess?: boolean;
|
||||
type?: 'one-line' | 'collapsible' | 'special';
|
||||
title?: string | ((result: any) => string);
|
||||
defaultOpen?: boolean;
|
||||
// Special result handlers
|
||||
contentType?: 'markdown' | 'file-list' | 'todo-list' | 'text' | 'success-message' | 'task' | 'question-answer';
|
||||
getMessage?: (result: any) => string;
|
||||
getContentProps?: (result: any) => any;
|
||||
};
|
||||
}
|
||||
|
||||
export const TOOL_CONFIGS: Record<string, ToolDisplayConfig> = {
|
||||
// ============================================================================
|
||||
// COMMAND TOOLS
|
||||
// ============================================================================
|
||||
|
||||
Bash: {
|
||||
input: {
|
||||
type: 'one-line',
|
||||
icon: 'terminal',
|
||||
getValue: (input) => input.command,
|
||||
getSecondary: (input) => input.description,
|
||||
action: 'copy',
|
||||
style: 'terminal',
|
||||
wrapText: true,
|
||||
colorScheme: {
|
||||
primary: 'text-green-400 font-mono',
|
||||
secondary: 'text-gray-400',
|
||||
background: '',
|
||||
border: 'border-green-500 dark:border-green-400',
|
||||
icon: 'text-green-500 dark:text-green-400'
|
||||
}
|
||||
},
|
||||
result: {
|
||||
hideOnSuccess: true,
|
||||
type: 'special'
|
||||
}
|
||||
},
|
||||
|
||||
// ============================================================================
|
||||
// FILE OPERATION TOOLS
|
||||
// ============================================================================
|
||||
|
||||
Read: {
|
||||
input: {
|
||||
type: 'one-line',
|
||||
label: 'Read',
|
||||
getValue: (input) => input.file_path || '',
|
||||
action: 'open-file',
|
||||
colorScheme: {
|
||||
primary: 'text-gray-700 dark:text-gray-300',
|
||||
background: '',
|
||||
border: 'border-gray-300 dark:border-gray-600',
|
||||
icon: 'text-gray-500 dark:text-gray-400'
|
||||
}
|
||||
},
|
||||
result: {
|
||||
hidden: true
|
||||
}
|
||||
},
|
||||
|
||||
Edit: {
|
||||
input: {
|
||||
type: 'collapsible',
|
||||
title: (input) => {
|
||||
const filename = input.file_path?.split('/').pop() || input.file_path || 'file';
|
||||
return `${filename}`;
|
||||
},
|
||||
defaultOpen: false,
|
||||
contentType: 'diff',
|
||||
actionButton: 'none',
|
||||
getContentProps: (input) => ({
|
||||
oldContent: input.old_string,
|
||||
newContent: input.new_string,
|
||||
filePath: input.file_path,
|
||||
badge: 'Edit',
|
||||
badgeColor: 'gray'
|
||||
})
|
||||
},
|
||||
result: {
|
||||
hideOnSuccess: true
|
||||
}
|
||||
},
|
||||
|
||||
Write: {
|
||||
input: {
|
||||
type: 'collapsible',
|
||||
title: (input) => {
|
||||
const filename = input.file_path?.split('/').pop() || input.file_path || 'file';
|
||||
return `${filename}`;
|
||||
},
|
||||
defaultOpen: false,
|
||||
contentType: 'diff',
|
||||
actionButton: 'none',
|
||||
getContentProps: (input) => ({
|
||||
oldContent: '',
|
||||
newContent: input.content,
|
||||
filePath: input.file_path,
|
||||
badge: 'New',
|
||||
badgeColor: 'green'
|
||||
})
|
||||
},
|
||||
result: {
|
||||
hideOnSuccess: true
|
||||
}
|
||||
},
|
||||
|
||||
ApplyPatch: {
|
||||
input: {
|
||||
type: 'collapsible',
|
||||
title: (input) => {
|
||||
const filename = input.file_path?.split('/').pop() || input.file_path || 'file';
|
||||
return `${filename}`;
|
||||
},
|
||||
defaultOpen: false,
|
||||
contentType: 'diff',
|
||||
actionButton: 'none',
|
||||
getContentProps: (input) => ({
|
||||
oldContent: input.old_string,
|
||||
newContent: input.new_string,
|
||||
filePath: input.file_path,
|
||||
badge: 'Patch',
|
||||
badgeColor: 'gray'
|
||||
})
|
||||
},
|
||||
result: {
|
||||
hideOnSuccess: true
|
||||
}
|
||||
},
|
||||
|
||||
// ============================================================================
|
||||
// SEARCH TOOLS
|
||||
// ============================================================================
|
||||
|
||||
Grep: {
|
||||
input: {
|
||||
type: 'one-line',
|
||||
label: 'Grep',
|
||||
getValue: (input) => input.pattern,
|
||||
getSecondary: (input) => input.path ? `in ${input.path}` : undefined,
|
||||
action: 'jump-to-results',
|
||||
colorScheme: {
|
||||
primary: 'text-gray-700 dark:text-gray-300',
|
||||
secondary: 'text-gray-500 dark:text-gray-400',
|
||||
background: '',
|
||||
border: 'border-gray-400 dark:border-gray-500',
|
||||
icon: 'text-gray-500 dark:text-gray-400'
|
||||
}
|
||||
},
|
||||
result: {
|
||||
type: 'collapsible',
|
||||
defaultOpen: false,
|
||||
title: (result) => {
|
||||
const toolData = result.toolUseResult || {};
|
||||
const count = toolData.numFiles || toolData.filenames?.length || 0;
|
||||
return `Found ${count} ${count === 1 ? 'file' : 'files'}`;
|
||||
},
|
||||
contentType: 'file-list',
|
||||
getContentProps: (result) => {
|
||||
const toolData = result.toolUseResult || {};
|
||||
return {
|
||||
files: toolData.filenames || []
|
||||
};
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Glob: {
|
||||
input: {
|
||||
type: 'one-line',
|
||||
label: 'Glob',
|
||||
getValue: (input) => input.pattern,
|
||||
getSecondary: (input) => input.path ? `in ${input.path}` : undefined,
|
||||
action: 'jump-to-results',
|
||||
colorScheme: {
|
||||
primary: 'text-gray-700 dark:text-gray-300',
|
||||
secondary: 'text-gray-500 dark:text-gray-400',
|
||||
background: '',
|
||||
border: 'border-gray-400 dark:border-gray-500',
|
||||
icon: 'text-gray-500 dark:text-gray-400'
|
||||
}
|
||||
},
|
||||
result: {
|
||||
type: 'collapsible',
|
||||
defaultOpen: false,
|
||||
title: (result) => {
|
||||
const toolData = result.toolUseResult || {};
|
||||
const count = toolData.numFiles || toolData.filenames?.length || 0;
|
||||
return `Found ${count} ${count === 1 ? 'file' : 'files'}`;
|
||||
},
|
||||
contentType: 'file-list',
|
||||
getContentProps: (result) => {
|
||||
const toolData = result.toolUseResult || {};
|
||||
return {
|
||||
files: toolData.filenames || []
|
||||
};
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// ============================================================================
|
||||
// TODO TOOLS
|
||||
// ============================================================================
|
||||
|
||||
TodoWrite: {
|
||||
input: {
|
||||
type: 'collapsible',
|
||||
title: 'Updating todo list',
|
||||
defaultOpen: false,
|
||||
contentType: 'todo-list',
|
||||
getContentProps: (input) => ({
|
||||
todos: input.todos
|
||||
})
|
||||
},
|
||||
result: {
|
||||
type: 'collapsible',
|
||||
contentType: 'success-message',
|
||||
getMessage: () => 'Todo list updated'
|
||||
}
|
||||
},
|
||||
|
||||
TodoRead: {
|
||||
input: {
|
||||
type: 'one-line',
|
||||
label: 'TodoRead',
|
||||
getValue: () => 'reading list',
|
||||
action: 'none',
|
||||
colorScheme: {
|
||||
primary: 'text-gray-500 dark:text-gray-400',
|
||||
border: 'border-violet-400 dark:border-violet-500'
|
||||
}
|
||||
},
|
||||
result: {
|
||||
type: 'collapsible',
|
||||
contentType: 'todo-list',
|
||||
getContentProps: (result) => {
|
||||
try {
|
||||
const content = String(result.content || '');
|
||||
let todos = null;
|
||||
if (content.startsWith('[')) {
|
||||
todos = JSON.parse(content);
|
||||
}
|
||||
return { todos, isResult: true };
|
||||
} catch (e) {
|
||||
return { todos: [], isResult: true };
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// ============================================================================
|
||||
// TASK TOOLS (TaskCreate, TaskUpdate, TaskList, TaskGet)
|
||||
// ============================================================================
|
||||
|
||||
TaskCreate: {
|
||||
input: {
|
||||
type: 'one-line',
|
||||
label: 'Task',
|
||||
getValue: (input) => input.subject || 'Creating task',
|
||||
getSecondary: (input) => input.status || undefined,
|
||||
action: 'none',
|
||||
colorScheme: {
|
||||
primary: 'text-gray-700 dark:text-gray-300',
|
||||
border: 'border-violet-400 dark:border-violet-500',
|
||||
icon: 'text-violet-500 dark:text-violet-400'
|
||||
}
|
||||
},
|
||||
result: {
|
||||
hideOnSuccess: true
|
||||
}
|
||||
},
|
||||
|
||||
TaskUpdate: {
|
||||
input: {
|
||||
type: 'one-line',
|
||||
label: 'Task',
|
||||
getValue: (input) => {
|
||||
const parts = [];
|
||||
if (input.taskId) parts.push(`#${input.taskId}`);
|
||||
if (input.status) parts.push(input.status);
|
||||
if (input.subject) parts.push(`"${input.subject}"`);
|
||||
return parts.join(' → ') || 'updating';
|
||||
},
|
||||
action: 'none',
|
||||
colorScheme: {
|
||||
primary: 'text-gray-700 dark:text-gray-300',
|
||||
border: 'border-violet-400 dark:border-violet-500',
|
||||
icon: 'text-violet-500 dark:text-violet-400'
|
||||
}
|
||||
},
|
||||
result: {
|
||||
hideOnSuccess: true
|
||||
}
|
||||
},
|
||||
|
||||
TaskList: {
|
||||
input: {
|
||||
type: 'one-line',
|
||||
label: 'Tasks',
|
||||
getValue: () => 'listing tasks',
|
||||
action: 'none',
|
||||
colorScheme: {
|
||||
primary: 'text-gray-500 dark:text-gray-400',
|
||||
border: 'border-violet-400 dark:border-violet-500',
|
||||
icon: 'text-violet-500 dark:text-violet-400'
|
||||
}
|
||||
},
|
||||
result: {
|
||||
type: 'collapsible',
|
||||
defaultOpen: true,
|
||||
title: 'Task list',
|
||||
contentType: 'task',
|
||||
getContentProps: (result) => ({
|
||||
content: String(result?.content || '')
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
TaskGet: {
|
||||
input: {
|
||||
type: 'one-line',
|
||||
label: 'Task',
|
||||
getValue: (input) => input.taskId ? `#${input.taskId}` : 'fetching',
|
||||
action: 'none',
|
||||
colorScheme: {
|
||||
primary: 'text-gray-700 dark:text-gray-300',
|
||||
border: 'border-violet-400 dark:border-violet-500',
|
||||
icon: 'text-violet-500 dark:text-violet-400'
|
||||
}
|
||||
},
|
||||
result: {
|
||||
type: 'collapsible',
|
||||
defaultOpen: true,
|
||||
title: 'Task details',
|
||||
contentType: 'task',
|
||||
getContentProps: (result) => ({
|
||||
content: String(result?.content || '')
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
// ============================================================================
|
||||
// SUBAGENT TASK TOOL
|
||||
// ============================================================================
|
||||
|
||||
Task: {
|
||||
input: {
|
||||
type: 'collapsible',
|
||||
title: (input) => {
|
||||
const subagentType = input.subagent_type || 'Agent';
|
||||
const description = input.description || 'Running task';
|
||||
return `Subagent / ${subagentType}: ${description}`;
|
||||
},
|
||||
defaultOpen: false,
|
||||
contentType: 'markdown',
|
||||
getContentProps: (input) => {
|
||||
// If only prompt exists (and required fields), show just the prompt
|
||||
// Otherwise show all available fields
|
||||
const hasOnlyPrompt = input.prompt &&
|
||||
!input.model &&
|
||||
!input.resume;
|
||||
|
||||
if (hasOnlyPrompt) {
|
||||
return {
|
||||
content: input.prompt || ''
|
||||
};
|
||||
}
|
||||
|
||||
// Format multiple fields
|
||||
const parts = [];
|
||||
|
||||
if (input.model) {
|
||||
parts.push(`**Model:** ${input.model}`);
|
||||
}
|
||||
|
||||
if (input.prompt) {
|
||||
parts.push(`**Prompt:**\n${input.prompt}`);
|
||||
}
|
||||
|
||||
if (input.resume) {
|
||||
parts.push(`**Resuming from:** ${input.resume}`);
|
||||
}
|
||||
|
||||
return {
|
||||
content: parts.join('\n\n')
|
||||
};
|
||||
},
|
||||
colorScheme: {
|
||||
border: 'border-purple-500 dark:border-purple-400',
|
||||
icon: 'text-purple-500 dark:text-purple-400'
|
||||
}
|
||||
},
|
||||
result: {
|
||||
type: 'collapsible',
|
||||
title: 'Subagent result',
|
||||
defaultOpen: false,
|
||||
contentType: 'markdown',
|
||||
getContentProps: (result) => {
|
||||
// Handle agent results which may have complex structure
|
||||
if (result && result.content) {
|
||||
let content = result.content;
|
||||
// If content is a JSON string, try to parse it (agent results may arrive serialized)
|
||||
if (typeof content === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
if (Array.isArray(parsed)) {
|
||||
content = parsed;
|
||||
}
|
||||
} catch {
|
||||
// Not JSON — use as-is
|
||||
return { content };
|
||||
}
|
||||
}
|
||||
// If content is an array (typical for agent responses with multiple text blocks)
|
||||
if (Array.isArray(content)) {
|
||||
const textContent = content
|
||||
.filter((item: any) => item.type === 'text')
|
||||
.map((item: any) => item.text)
|
||||
.join('\n\n');
|
||||
return { content: textContent || 'No response text' };
|
||||
}
|
||||
return { content: String(content) };
|
||||
}
|
||||
// Fallback to string representation
|
||||
return { content: String(result || 'No response') };
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// ============================================================================
|
||||
// INTERACTIVE TOOLS
|
||||
// ============================================================================
|
||||
|
||||
AskUserQuestion: {
|
||||
input: {
|
||||
type: 'collapsible',
|
||||
title: (input: any) => {
|
||||
const count = input.questions?.length || 0;
|
||||
const hasAnswers = input.answers && Object.keys(input.answers).length > 0;
|
||||
if (count === 1) {
|
||||
const header = input.questions[0]?.header || 'Question';
|
||||
return hasAnswers ? `${header} — answered` : header;
|
||||
}
|
||||
return hasAnswers ? `${count} questions — answered` : `${count} questions`;
|
||||
},
|
||||
defaultOpen: true,
|
||||
contentType: 'question-answer',
|
||||
getContentProps: (input: any) => ({
|
||||
questions: input.questions || [],
|
||||
answers: input.answers || {}
|
||||
}),
|
||||
},
|
||||
result: {
|
||||
hideOnSuccess: true
|
||||
}
|
||||
},
|
||||
|
||||
// ============================================================================
|
||||
// PLAN TOOLS
|
||||
// ============================================================================
|
||||
|
||||
exit_plan_mode: {
|
||||
input: {
|
||||
type: 'collapsible',
|
||||
title: 'Implementation plan',
|
||||
defaultOpen: true,
|
||||
contentType: 'markdown',
|
||||
getContentProps: (input) => ({
|
||||
content: input.plan?.replace(/\\n/g, '\n') || input.plan
|
||||
})
|
||||
},
|
||||
result: {
|
||||
type: 'collapsible',
|
||||
contentType: 'markdown',
|
||||
getContentProps: (result) => {
|
||||
try {
|
||||
let parsed = result.content;
|
||||
if (typeof parsed === 'string') {
|
||||
parsed = JSON.parse(parsed);
|
||||
}
|
||||
return {
|
||||
content: parsed.plan?.replace(/\\n/g, '\n') || parsed.plan
|
||||
};
|
||||
} catch (e) {
|
||||
return { content: '' };
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Also register as ExitPlanMode (the actual tool name used by Claude)
|
||||
ExitPlanMode: {
|
||||
input: {
|
||||
type: 'collapsible',
|
||||
title: 'Implementation plan',
|
||||
defaultOpen: true,
|
||||
contentType: 'markdown',
|
||||
getContentProps: (input) => ({
|
||||
content: input.plan?.replace(/\\n/g, '\n') || input.plan
|
||||
})
|
||||
},
|
||||
result: {
|
||||
type: 'collapsible',
|
||||
contentType: 'markdown',
|
||||
getContentProps: (result) => {
|
||||
try {
|
||||
let parsed = result.content;
|
||||
if (typeof parsed === 'string') {
|
||||
parsed = JSON.parse(parsed);
|
||||
}
|
||||
return {
|
||||
content: parsed.plan?.replace(/\\n/g, '\n') || parsed.plan
|
||||
};
|
||||
} catch (e) {
|
||||
return { content: '' };
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// ============================================================================
|
||||
// DEFAULT FALLBACK
|
||||
// ============================================================================
|
||||
|
||||
Default: {
|
||||
input: {
|
||||
type: 'collapsible',
|
||||
title: 'Parameters',
|
||||
defaultOpen: false,
|
||||
contentType: 'text',
|
||||
getContentProps: (input) => ({
|
||||
content: typeof input === 'string' ? input : JSON.stringify(input, null, 2),
|
||||
format: 'code'
|
||||
})
|
||||
},
|
||||
result: {
|
||||
type: 'collapsible',
|
||||
contentType: 'text',
|
||||
getContentProps: (result) => ({
|
||||
content: String(result?.content || ''),
|
||||
format: 'plain'
|
||||
})
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get configuration for a tool, with fallback to default
|
||||
*/
|
||||
export function getToolConfig(toolName: string): ToolDisplayConfig {
|
||||
return TOOL_CONFIGS[toolName] || TOOL_CONFIGS.Default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a tool result should be hidden
|
||||
*/
|
||||
export function shouldHideToolResult(toolName: string, toolResult: any): boolean {
|
||||
const config = getToolConfig(toolName);
|
||||
|
||||
if (!config.result) return false;
|
||||
|
||||
// Always hidden
|
||||
if (config.result.hidden) return true;
|
||||
|
||||
// Hide on success only
|
||||
if (config.result.hideOnSuccess && toolResult && !toolResult.isError) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
3
src/components/chat/tools/index.ts
Normal file
3
src/components/chat/tools/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { ToolRenderer } from './ToolRenderer';
|
||||
export { getToolConfig, shouldHideToolResult } from './configs/toolConfigs';
|
||||
export * from './components';
|
||||
118
src/components/chat/types/types.ts
Normal file
118
src/components/chat/types/types.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import type { Project, ProjectSession, SessionProvider } from '../../../types/app';
|
||||
|
||||
export type Provider = SessionProvider;
|
||||
|
||||
export type PermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan';
|
||||
|
||||
export interface ChatImage {
|
||||
data: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ToolResult {
|
||||
content?: unknown;
|
||||
isError?: boolean;
|
||||
timestamp?: string | number | Date;
|
||||
toolUseResult?: unknown;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface SubagentChildTool {
|
||||
toolId: string;
|
||||
toolName: string;
|
||||
toolInput: unknown;
|
||||
toolResult?: ToolResult | null;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
type: string;
|
||||
content?: string;
|
||||
timestamp: string | number | Date;
|
||||
images?: ChatImage[];
|
||||
reasoning?: string;
|
||||
isThinking?: boolean;
|
||||
isStreaming?: boolean;
|
||||
isInteractivePrompt?: boolean;
|
||||
isToolUse?: boolean;
|
||||
toolName?: string;
|
||||
toolInput?: unknown;
|
||||
toolResult?: ToolResult | null;
|
||||
toolId?: string;
|
||||
toolCallId?: string;
|
||||
isSubagentContainer?: boolean;
|
||||
subagentState?: {
|
||||
childTools: SubagentChildTool[];
|
||||
currentToolIndex: number;
|
||||
isComplete: boolean;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ClaudeSettings {
|
||||
allowedTools: string[];
|
||||
disallowedTools: string[];
|
||||
skipPermissions: boolean;
|
||||
projectSortOrder: string;
|
||||
lastUpdated?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ClaudePermissionSuggestion {
|
||||
toolName: string;
|
||||
entry: string;
|
||||
isAllowed: boolean;
|
||||
}
|
||||
|
||||
export interface PermissionGrantResult {
|
||||
success: boolean;
|
||||
alreadyAllowed?: boolean;
|
||||
updatedSettings?: ClaudeSettings;
|
||||
}
|
||||
|
||||
export interface PendingPermissionRequest {
|
||||
requestId: string;
|
||||
toolName: string;
|
||||
input?: unknown;
|
||||
context?: unknown;
|
||||
sessionId?: string | null;
|
||||
receivedAt?: Date;
|
||||
}
|
||||
|
||||
export interface QuestionOption {
|
||||
label: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface Question {
|
||||
question: string;
|
||||
header?: string;
|
||||
options: QuestionOption[];
|
||||
multiSelect?: boolean;
|
||||
}
|
||||
|
||||
export interface ChatInterfaceProps {
|
||||
selectedProject: Project | null;
|
||||
selectedSession: ProjectSession | null;
|
||||
ws: WebSocket | null;
|
||||
sendMessage: (message: unknown) => void;
|
||||
latestMessage: any;
|
||||
onFileOpen?: (filePath: string, diffInfo?: any) => void;
|
||||
onInputFocusChange?: (focused: boolean) => void;
|
||||
onSessionActive?: (sessionId?: string | null) => void;
|
||||
onSessionInactive?: (sessionId?: string | null) => void;
|
||||
onSessionProcessing?: (sessionId?: string | null) => void;
|
||||
onSessionNotProcessing?: (sessionId?: string | null) => void;
|
||||
processingSessions?: Set<string>;
|
||||
onReplaceTemporarySession?: (sessionId?: string | null) => void;
|
||||
onNavigateToSession?: (targetSessionId: string) => void;
|
||||
onShowSettings?: () => void;
|
||||
autoExpandTools?: boolean;
|
||||
showRawParameters?: boolean;
|
||||
showThinking?: boolean;
|
||||
autoScrollToBottom?: boolean;
|
||||
sendByCtrlEnter?: boolean;
|
||||
externalMessageUpdate?: number;
|
||||
onTaskClick?: (...args: unknown[]) => void;
|
||||
onShowAllTasks?: (() => void) | null;
|
||||
}
|
||||
86
src/components/chat/utils/chatFormatting.ts
Normal file
86
src/components/chat/utils/chatFormatting.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
export function decodeHtmlEntities(text: string) {
|
||||
if (!text) return text;
|
||||
return text
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&/g, '&');
|
||||
}
|
||||
|
||||
export function normalizeInlineCodeFences(text: string) {
|
||||
if (!text || typeof text !== 'string') return text;
|
||||
try {
|
||||
return text.replace(/```\s*([^\n\r]+?)\s*```/g, '`$1`');
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
export function unescapeWithMathProtection(text: string) {
|
||||
if (!text || typeof text !== 'string') return text;
|
||||
|
||||
const mathBlocks: string[] = [];
|
||||
const placeholderPrefix = '__MATH_BLOCK_';
|
||||
const placeholderSuffix = '__';
|
||||
|
||||
let processedText = text.replace(/\$\$([\s\S]*?)\$\$|\$([^\$\n]+?)\$/g, (match) => {
|
||||
const index = mathBlocks.length;
|
||||
mathBlocks.push(match);
|
||||
return `${placeholderPrefix}${index}${placeholderSuffix}`;
|
||||
});
|
||||
|
||||
processedText = processedText.replace(/\\n/g, '\n').replace(/\\t/g, '\t').replace(/\\r/g, '\r');
|
||||
|
||||
processedText = processedText.replace(
|
||||
new RegExp(`${placeholderPrefix}(\\d+)${placeholderSuffix}`, 'g'),
|
||||
(match, index) => {
|
||||
return mathBlocks[parseInt(index, 10)];
|
||||
},
|
||||
);
|
||||
|
||||
return processedText;
|
||||
}
|
||||
|
||||
export function escapeRegExp(value: string) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
export function formatUsageLimitText(text: string) {
|
||||
try {
|
||||
if (typeof text !== 'string') return text;
|
||||
return text.replace(/Claude AI usage limit reached\|(\d{10,13})/g, (match, ts) => {
|
||||
let timestampMs = parseInt(ts, 10);
|
||||
if (!Number.isFinite(timestampMs)) return match;
|
||||
if (timestampMs < 1e12) timestampMs *= 1000;
|
||||
const reset = new Date(timestampMs);
|
||||
|
||||
const timeStr = new Intl.DateTimeFormat(undefined, {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
}).format(reset);
|
||||
|
||||
const offsetMinutesLocal = -reset.getTimezoneOffset();
|
||||
const sign = offsetMinutesLocal >= 0 ? '+' : '-';
|
||||
const abs = Math.abs(offsetMinutesLocal);
|
||||
const offH = Math.floor(abs / 60);
|
||||
const offM = abs % 60;
|
||||
const gmt = `GMT${sign}${offH}${offM ? ':' + String(offM).padStart(2, '0') : ''}`;
|
||||
const tzId = Intl.DateTimeFormat().resolvedOptions().timeZone || '';
|
||||
const cityRaw = tzId.split('/').pop() || '';
|
||||
const city = cityRaw
|
||||
.replace(/_/g, ' ')
|
||||
.toLowerCase()
|
||||
.replace(/\b\w/g, (char) => char.toUpperCase());
|
||||
const tzHuman = city ? `${gmt} (${city})` : gmt;
|
||||
|
||||
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
const dateReadable = `${reset.getDate()} ${months[reset.getMonth()]} ${reset.getFullYear()}`;
|
||||
|
||||
return `Claude usage limit reached. Your limit will reset at **${timeStr} ${tzHuman}** - ${dateReadable}`;
|
||||
});
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
64
src/components/chat/utils/chatPermissions.ts
Normal file
64
src/components/chat/utils/chatPermissions.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { safeJsonParse } from '../../../lib/utils.js';
|
||||
import type { ChatMessage, ClaudePermissionSuggestion, PermissionGrantResult } from '../types/types.js';
|
||||
import { CLAUDE_SETTINGS_KEY, getClaudeSettings, safeLocalStorage } from './chatStorage';
|
||||
|
||||
export function buildClaudeToolPermissionEntry(toolName?: string, toolInput?: unknown) {
|
||||
if (!toolName) return null;
|
||||
if (toolName !== 'Bash') return toolName;
|
||||
|
||||
const parsed = safeJsonParse(toolInput);
|
||||
const command = typeof parsed?.command === 'string' ? parsed.command.trim() : '';
|
||||
if (!command) return toolName;
|
||||
|
||||
const tokens = command.split(/\s+/);
|
||||
if (tokens.length === 0) return toolName;
|
||||
|
||||
if (tokens[0] === 'git' && tokens[1]) {
|
||||
return `Bash(${tokens[0]} ${tokens[1]}:*)`;
|
||||
}
|
||||
return `Bash(${tokens[0]}:*)`;
|
||||
}
|
||||
|
||||
export function formatToolInputForDisplay(input: unknown) {
|
||||
if (input === undefined || input === null) return '';
|
||||
if (typeof input === 'string') return input;
|
||||
try {
|
||||
return JSON.stringify(input, null, 2);
|
||||
} catch {
|
||||
return String(input);
|
||||
}
|
||||
}
|
||||
|
||||
export function getClaudePermissionSuggestion(
|
||||
message: ChatMessage | null | undefined,
|
||||
provider: string,
|
||||
): ClaudePermissionSuggestion | null {
|
||||
if (provider !== 'claude') return null;
|
||||
if (!message?.toolResult?.isError) return null;
|
||||
|
||||
const toolName = message?.toolName;
|
||||
const entry = buildClaudeToolPermissionEntry(toolName, message.toolInput);
|
||||
if (!entry) return null;
|
||||
|
||||
const settings = getClaudeSettings();
|
||||
const isAllowed = settings.allowedTools.includes(entry);
|
||||
return { toolName: toolName || 'UnknownTool', entry, isAllowed };
|
||||
}
|
||||
|
||||
export function grantClaudeToolPermission(entry: string | null): PermissionGrantResult {
|
||||
if (!entry) return { success: false };
|
||||
|
||||
const settings = getClaudeSettings();
|
||||
const alreadyAllowed = settings.allowedTools.includes(entry);
|
||||
const nextAllowed = alreadyAllowed ? settings.allowedTools : [...settings.allowedTools, entry];
|
||||
const nextDisallowed = settings.disallowedTools.filter((tool) => tool !== entry);
|
||||
const updatedSettings = {
|
||||
...settings,
|
||||
allowedTools: nextAllowed,
|
||||
disallowedTools: nextDisallowed,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
|
||||
safeLocalStorage.setItem(CLAUDE_SETTINGS_KEY, JSON.stringify(updatedSettings));
|
||||
return { success: true, alreadyAllowed, updatedSettings };
|
||||
}
|
||||
105
src/components/chat/utils/chatStorage.ts
Normal file
105
src/components/chat/utils/chatStorage.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import type { ClaudeSettings } from '../types/types';
|
||||
|
||||
export const CLAUDE_SETTINGS_KEY = 'claude-settings';
|
||||
|
||||
export const safeLocalStorage = {
|
||||
setItem: (key: string, value: string) => {
|
||||
try {
|
||||
if (key.startsWith('chat_messages_') && typeof value === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (Array.isArray(parsed) && parsed.length > 50) {
|
||||
const truncated = parsed.slice(-50);
|
||||
value = JSON.stringify(truncated);
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.warn('Could not parse chat messages for truncation:', parseError);
|
||||
}
|
||||
}
|
||||
|
||||
localStorage.setItem(key, value);
|
||||
} catch (error: any) {
|
||||
if (error?.name === 'QuotaExceededError') {
|
||||
console.warn('localStorage quota exceeded, clearing old data');
|
||||
|
||||
const keys = Object.keys(localStorage);
|
||||
const chatKeys = keys.filter((k) => k.startsWith('chat_messages_')).sort();
|
||||
|
||||
if (chatKeys.length > 3) {
|
||||
chatKeys.slice(0, chatKeys.length - 3).forEach((k) => {
|
||||
localStorage.removeItem(k);
|
||||
});
|
||||
}
|
||||
|
||||
const draftKeys = keys.filter((k) => k.startsWith('draft_input_'));
|
||||
draftKeys.forEach((k) => {
|
||||
localStorage.removeItem(k);
|
||||
});
|
||||
|
||||
try {
|
||||
localStorage.setItem(key, value);
|
||||
} catch (retryError) {
|
||||
console.error('Failed to save to localStorage even after cleanup:', retryError);
|
||||
if (key.startsWith('chat_messages_') && typeof value === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (Array.isArray(parsed) && parsed.length > 10) {
|
||||
const minimal = parsed.slice(-10);
|
||||
localStorage.setItem(key, JSON.stringify(minimal));
|
||||
}
|
||||
} catch (finalError) {
|
||||
console.error('Final save attempt failed:', finalError);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.error('localStorage error:', error);
|
||||
}
|
||||
}
|
||||
},
|
||||
getItem: (key: string): string | null => {
|
||||
try {
|
||||
return localStorage.getItem(key);
|
||||
} catch (error) {
|
||||
console.error('localStorage getItem error:', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
try {
|
||||
localStorage.removeItem(key);
|
||||
} catch (error) {
|
||||
console.error('localStorage removeItem error:', error);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export function getClaudeSettings(): ClaudeSettings {
|
||||
const raw = safeLocalStorage.getItem(CLAUDE_SETTINGS_KEY);
|
||||
if (!raw) {
|
||||
return {
|
||||
allowedTools: [],
|
||||
disallowedTools: [],
|
||||
skipPermissions: false,
|
||||
projectSortOrder: 'name',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return {
|
||||
...parsed,
|
||||
allowedTools: Array.isArray(parsed.allowedTools) ? parsed.allowedTools : [],
|
||||
disallowedTools: Array.isArray(parsed.disallowedTools) ? parsed.disallowedTools : [],
|
||||
skipPermissions: Boolean(parsed.skipPermissions),
|
||||
projectSortOrder: parsed.projectSortOrder || 'name',
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
allowedTools: [],
|
||||
disallowedTools: [],
|
||||
skipPermissions: false,
|
||||
projectSortOrder: 'name',
|
||||
};
|
||||
}
|
||||
}
|
||||
38
src/components/chat/utils/messageKeys.ts
Normal file
38
src/components/chat/utils/messageKeys.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { ChatMessage } from '../types/types';
|
||||
|
||||
const toMessageKeyPart = (value: unknown): string | null => {
|
||||
if (typeof value !== 'string' && typeof value !== 'number') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = String(value).trim();
|
||||
return normalized.length > 0 ? normalized : null;
|
||||
};
|
||||
|
||||
export const getIntrinsicMessageKey = (message: ChatMessage): string | null => {
|
||||
const candidates = [
|
||||
message.id,
|
||||
message.messageId,
|
||||
message.toolId,
|
||||
message.toolCallId,
|
||||
message.blobId,
|
||||
message.rowid,
|
||||
message.sequence,
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const keyPart = toMessageKeyPart(candidate);
|
||||
if (keyPart) {
|
||||
return `message-${message.type}-${keyPart}`;
|
||||
}
|
||||
}
|
||||
|
||||
const timestamp = new Date(message.timestamp).getTime();
|
||||
if (!Number.isFinite(timestamp)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const contentPreview = typeof message.content === 'string' ? message.content.slice(0, 48) : '';
|
||||
const toolName = typeof message.toolName === 'string' ? message.toolName : '';
|
||||
return `message-${message.type}-${timestamp}-${toolName}-${contentPreview}`;
|
||||
};
|
||||
549
src/components/chat/utils/messageTransforms.ts
Normal file
549
src/components/chat/utils/messageTransforms.ts
Normal file
@@ -0,0 +1,549 @@
|
||||
import type { ChatMessage } from '../types/types';
|
||||
import { decodeHtmlEntities, unescapeWithMathProtection } from './chatFormatting';
|
||||
|
||||
export interface DiffLine {
|
||||
type: 'added' | 'removed';
|
||||
content: string;
|
||||
lineNum: number;
|
||||
}
|
||||
|
||||
export type DiffCalculator = (oldStr: string, newStr: string) => DiffLine[];
|
||||
|
||||
type CursorBlob = {
|
||||
id?: string;
|
||||
sequence?: number;
|
||||
rowid?: number;
|
||||
content?: any;
|
||||
};
|
||||
|
||||
const asArray = <T>(value: unknown): T[] => (Array.isArray(value) ? (value as T[]) : []);
|
||||
|
||||
const normalizeToolInput = (value: unknown): string => {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
};
|
||||
|
||||
const toAbsolutePath = (projectPath: string, filePath?: string) => {
|
||||
if (!filePath) {
|
||||
return filePath;
|
||||
}
|
||||
return filePath.startsWith('/') ? filePath : `${projectPath}/${filePath}`;
|
||||
};
|
||||
|
||||
export const calculateDiff = (oldStr: string, newStr: string): DiffLine[] => {
|
||||
const oldLines = oldStr.split('\n');
|
||||
const newLines = newStr.split('\n');
|
||||
|
||||
// Use LCS alignment so insertions/deletions don't cascade into a full-file "changed" diff.
|
||||
const lcsTable: number[][] = Array.from({ length: oldLines.length + 1 }, () =>
|
||||
new Array<number>(newLines.length + 1).fill(0),
|
||||
);
|
||||
for (let oldIndex = oldLines.length - 1; oldIndex >= 0; oldIndex -= 1) {
|
||||
for (let newIndex = newLines.length - 1; newIndex >= 0; newIndex -= 1) {
|
||||
if (oldLines[oldIndex] === newLines[newIndex]) {
|
||||
lcsTable[oldIndex][newIndex] = lcsTable[oldIndex + 1][newIndex + 1] + 1;
|
||||
} else {
|
||||
lcsTable[oldIndex][newIndex] = Math.max(
|
||||
lcsTable[oldIndex + 1][newIndex],
|
||||
lcsTable[oldIndex][newIndex + 1],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const diffLines: DiffLine[] = [];
|
||||
let oldIndex = 0;
|
||||
let newIndex = 0;
|
||||
|
||||
while (oldIndex < oldLines.length && newIndex < newLines.length) {
|
||||
const oldLine = oldLines[oldIndex];
|
||||
const newLine = newLines[newIndex];
|
||||
|
||||
if (oldLine === newLine) {
|
||||
oldIndex += 1;
|
||||
newIndex += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (lcsTable[oldIndex + 1][newIndex] >= lcsTable[oldIndex][newIndex + 1]) {
|
||||
diffLines.push({ type: 'removed', content: oldLine, lineNum: oldIndex + 1 });
|
||||
oldIndex += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
diffLines.push({ type: 'added', content: newLine, lineNum: newIndex + 1 });
|
||||
newIndex += 1;
|
||||
}
|
||||
|
||||
while (oldIndex < oldLines.length) {
|
||||
diffLines.push({ type: 'removed', content: oldLines[oldIndex], lineNum: oldIndex + 1 });
|
||||
oldIndex += 1;
|
||||
}
|
||||
|
||||
while (newIndex < newLines.length) {
|
||||
diffLines.push({ type: 'added', content: newLines[newIndex], lineNum: newIndex + 1 });
|
||||
newIndex += 1;
|
||||
}
|
||||
|
||||
return diffLines;
|
||||
};
|
||||
|
||||
export const createCachedDiffCalculator = (): DiffCalculator => {
|
||||
const cache = new Map<string, DiffLine[]>();
|
||||
|
||||
return (oldStr: string, newStr: string) => {
|
||||
const key = JSON.stringify([oldStr, newStr]);
|
||||
const cached = cache.get(key);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const calculated = calculateDiff(oldStr, newStr);
|
||||
cache.set(key, calculated);
|
||||
if (cache.size > 100) {
|
||||
const firstKey = cache.keys().next().value;
|
||||
if (firstKey) {
|
||||
cache.delete(firstKey);
|
||||
}
|
||||
}
|
||||
return calculated;
|
||||
};
|
||||
};
|
||||
|
||||
export const convertCursorSessionMessages = (blobs: CursorBlob[], projectPath: string): ChatMessage[] => {
|
||||
const converted: ChatMessage[] = [];
|
||||
const toolUseMap: Record<string, ChatMessage> = {};
|
||||
|
||||
for (let blobIdx = 0; blobIdx < blobs.length; blobIdx += 1) {
|
||||
const blob = blobs[blobIdx];
|
||||
const content = blob.content;
|
||||
let text = '';
|
||||
let role: ChatMessage['type'] = 'assistant';
|
||||
let reasoningText: string | null = null;
|
||||
|
||||
try {
|
||||
if (content?.role && content?.content) {
|
||||
if (content.role === 'system') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (content.role === 'tool') {
|
||||
const toolItems = asArray<any>(content.content);
|
||||
for (const item of toolItems) {
|
||||
if (item?.type !== 'tool-result') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const toolName = item.toolName === 'ApplyPatch' ? 'Edit' : item.toolName || 'Unknown Tool';
|
||||
const toolCallId = item.toolCallId || content.id;
|
||||
const result = item.result || '';
|
||||
|
||||
if (toolCallId && toolUseMap[toolCallId]) {
|
||||
toolUseMap[toolCallId].toolResult = {
|
||||
content: result,
|
||||
isError: false,
|
||||
};
|
||||
} else {
|
||||
converted.push({
|
||||
type: 'assistant',
|
||||
content: '',
|
||||
timestamp: new Date(Date.now() + blobIdx * 1000),
|
||||
blobId: blob.id,
|
||||
sequence: blob.sequence,
|
||||
rowid: blob.rowid,
|
||||
isToolUse: true,
|
||||
toolName,
|
||||
toolId: toolCallId,
|
||||
toolInput: normalizeToolInput(null),
|
||||
toolResult: {
|
||||
content: result,
|
||||
isError: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
role = content.role === 'user' ? 'user' : 'assistant';
|
||||
|
||||
if (Array.isArray(content.content)) {
|
||||
const textParts: string[] = [];
|
||||
|
||||
for (const part of content.content) {
|
||||
if (part?.type === 'text' && part?.text) {
|
||||
textParts.push(decodeHtmlEntities(part.text));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (part?.type === 'reasoning' && part?.text) {
|
||||
reasoningText = decodeHtmlEntities(part.text);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (part?.type === 'tool-call' || part?.type === 'tool_use') {
|
||||
if (textParts.length > 0 || reasoningText) {
|
||||
converted.push({
|
||||
type: role,
|
||||
content: textParts.join('\n'),
|
||||
reasoning: reasoningText ?? undefined,
|
||||
timestamp: new Date(Date.now() + blobIdx * 1000),
|
||||
blobId: blob.id,
|
||||
sequence: blob.sequence,
|
||||
rowid: blob.rowid,
|
||||
});
|
||||
textParts.length = 0;
|
||||
reasoningText = null;
|
||||
}
|
||||
|
||||
const toolNameRaw = part.toolName || part.name || 'Unknown Tool';
|
||||
const toolName = toolNameRaw === 'ApplyPatch' ? 'Edit' : toolNameRaw;
|
||||
const toolId = part.toolCallId || part.id || `tool_${blobIdx}`;
|
||||
let toolInput = part.args || part.input;
|
||||
|
||||
if (toolName === 'Edit' && part.args) {
|
||||
if (part.args.patch) {
|
||||
const patchLines = String(part.args.patch).split('\n');
|
||||
const oldLines: string[] = [];
|
||||
const newLines: string[] = [];
|
||||
let inPatch = false;
|
||||
|
||||
patchLines.forEach((line) => {
|
||||
if (line.startsWith('@@')) {
|
||||
inPatch = true;
|
||||
return;
|
||||
}
|
||||
if (!inPatch) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (line.startsWith('-')) {
|
||||
oldLines.push(line.slice(1));
|
||||
} else if (line.startsWith('+')) {
|
||||
newLines.push(line.slice(1));
|
||||
} else if (line.startsWith(' ')) {
|
||||
oldLines.push(line.slice(1));
|
||||
newLines.push(line.slice(1));
|
||||
}
|
||||
});
|
||||
|
||||
toolInput = {
|
||||
file_path: toAbsolutePath(projectPath, part.args.file_path),
|
||||
old_string: oldLines.join('\n') || part.args.patch,
|
||||
new_string: newLines.join('\n') || part.args.patch,
|
||||
};
|
||||
} else {
|
||||
toolInput = part.args;
|
||||
}
|
||||
} else if (toolName === 'Read' && part.args) {
|
||||
const filePath = part.args.path || part.args.file_path;
|
||||
toolInput = {
|
||||
file_path: toAbsolutePath(projectPath, filePath),
|
||||
};
|
||||
} else if (toolName === 'Write' && part.args) {
|
||||
const filePath = part.args.path || part.args.file_path;
|
||||
toolInput = {
|
||||
file_path: toAbsolutePath(projectPath, filePath),
|
||||
content: part.args.contents || part.args.content,
|
||||
};
|
||||
}
|
||||
|
||||
const toolMessage: ChatMessage = {
|
||||
type: 'assistant',
|
||||
content: '',
|
||||
timestamp: new Date(Date.now() + blobIdx * 1000),
|
||||
blobId: blob.id,
|
||||
sequence: blob.sequence,
|
||||
rowid: blob.rowid,
|
||||
isToolUse: true,
|
||||
toolName,
|
||||
toolId,
|
||||
toolInput: normalizeToolInput(toolInput),
|
||||
toolResult: null,
|
||||
};
|
||||
converted.push(toolMessage);
|
||||
toolUseMap[toolId] = toolMessage;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof part === 'string') {
|
||||
textParts.push(part);
|
||||
}
|
||||
}
|
||||
|
||||
if (textParts.length > 0) {
|
||||
text = textParts.join('\n');
|
||||
if (reasoningText && !text) {
|
||||
converted.push({
|
||||
type: role,
|
||||
content: '',
|
||||
reasoning: reasoningText,
|
||||
timestamp: new Date(Date.now() + blobIdx * 1000),
|
||||
blobId: blob.id,
|
||||
sequence: blob.sequence,
|
||||
rowid: blob.rowid,
|
||||
});
|
||||
text = '';
|
||||
}
|
||||
} else {
|
||||
text = '';
|
||||
}
|
||||
} else if (typeof content.content === 'string') {
|
||||
text = content.content;
|
||||
}
|
||||
} else if (content?.message?.role && content?.message?.content) {
|
||||
if (content.message.role === 'system') {
|
||||
continue;
|
||||
}
|
||||
|
||||
role = content.message.role === 'user' ? 'user' : 'assistant';
|
||||
if (Array.isArray(content.message.content)) {
|
||||
text = content.message.content
|
||||
.map((part: any) => (typeof part === 'string' ? part : part?.text || ''))
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
} else if (typeof content.message.content === 'string') {
|
||||
text = content.message.content;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Error parsing blob content:', error);
|
||||
}
|
||||
|
||||
if (text && text.trim()) {
|
||||
const message: ChatMessage = {
|
||||
type: role,
|
||||
content: text,
|
||||
timestamp: new Date(Date.now() + blobIdx * 1000),
|
||||
blobId: blob.id,
|
||||
sequence: blob.sequence,
|
||||
rowid: blob.rowid,
|
||||
};
|
||||
if (reasoningText) {
|
||||
message.reasoning = reasoningText;
|
||||
}
|
||||
converted.push(message);
|
||||
}
|
||||
}
|
||||
|
||||
converted.sort((messageA, messageB) => {
|
||||
if (messageA.sequence !== undefined && messageB.sequence !== undefined) {
|
||||
return Number(messageA.sequence) - Number(messageB.sequence);
|
||||
}
|
||||
if (messageA.rowid !== undefined && messageB.rowid !== undefined) {
|
||||
return Number(messageA.rowid) - Number(messageB.rowid);
|
||||
}
|
||||
return new Date(messageA.timestamp).getTime() - new Date(messageB.timestamp).getTime();
|
||||
});
|
||||
|
||||
return converted;
|
||||
};
|
||||
|
||||
export const convertSessionMessages = (rawMessages: any[]): ChatMessage[] => {
|
||||
const converted: ChatMessage[] = [];
|
||||
const toolResults = new Map<
|
||||
string,
|
||||
{ content: unknown; isError: boolean; timestamp: Date; toolUseResult: unknown; subagentTools?: unknown[] }
|
||||
>();
|
||||
|
||||
rawMessages.forEach((message) => {
|
||||
if (message.message?.role === 'user' && Array.isArray(message.message?.content)) {
|
||||
message.message.content.forEach((part: any) => {
|
||||
if (part.type !== 'tool_result') {
|
||||
return;
|
||||
}
|
||||
toolResults.set(part.tool_use_id, {
|
||||
content: part.content,
|
||||
isError: Boolean(part.is_error),
|
||||
timestamp: new Date(message.timestamp || Date.now()),
|
||||
toolUseResult: message.toolUseResult || null,
|
||||
subagentTools: message.subagentTools,
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
rawMessages.forEach((message) => {
|
||||
if (message.message?.role === 'user' && message.message?.content) {
|
||||
let content = '';
|
||||
if (Array.isArray(message.message.content)) {
|
||||
const textParts: string[] = [];
|
||||
message.message.content.forEach((part: any) => {
|
||||
if (part.type === 'text') {
|
||||
textParts.push(decodeHtmlEntities(part.text));
|
||||
}
|
||||
});
|
||||
content = textParts.join('\n');
|
||||
} else if (typeof message.message.content === 'string') {
|
||||
content = decodeHtmlEntities(message.message.content);
|
||||
} else {
|
||||
content = decodeHtmlEntities(String(message.message.content));
|
||||
}
|
||||
|
||||
const shouldSkip =
|
||||
!content ||
|
||||
content.startsWith('<command-name>') ||
|
||||
content.startsWith('<command-message>') ||
|
||||
content.startsWith('<command-args>') ||
|
||||
content.startsWith('<local-command-stdout>') ||
|
||||
content.startsWith('<system-reminder>') ||
|
||||
content.startsWith('Caveat:') ||
|
||||
content.startsWith('This session is being continued from a previous') ||
|
||||
content.startsWith('[Request interrupted');
|
||||
|
||||
if (!shouldSkip) {
|
||||
// Parse <task-notification> blocks into compact system messages
|
||||
const taskNotifRegex = /<task-notification>\s*<task-id>[^<]*<\/task-id>\s*<output-file>[^<]*<\/output-file>\s*<status>([^<]*)<\/status>\s*<summary>([^<]*)<\/summary>\s*<\/task-notification>/g;
|
||||
const taskNotifMatch = taskNotifRegex.exec(content);
|
||||
if (taskNotifMatch) {
|
||||
const status = taskNotifMatch[1]?.trim() || 'completed';
|
||||
const summary = taskNotifMatch[2]?.trim() || 'Background task finished';
|
||||
converted.push({
|
||||
type: 'assistant',
|
||||
content: summary,
|
||||
timestamp: message.timestamp || new Date().toISOString(),
|
||||
isTaskNotification: true,
|
||||
taskStatus: status,
|
||||
});
|
||||
} else {
|
||||
converted.push({
|
||||
type: 'user',
|
||||
content: unescapeWithMathProtection(content),
|
||||
timestamp: message.timestamp || new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'thinking' && message.message?.content) {
|
||||
converted.push({
|
||||
type: 'assistant',
|
||||
content: unescapeWithMathProtection(message.message.content),
|
||||
timestamp: message.timestamp || new Date().toISOString(),
|
||||
isThinking: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'tool_use' && message.toolName) {
|
||||
converted.push({
|
||||
type: 'assistant',
|
||||
content: '',
|
||||
timestamp: message.timestamp || new Date().toISOString(),
|
||||
isToolUse: true,
|
||||
toolName: message.toolName,
|
||||
toolInput: normalizeToolInput(message.toolInput),
|
||||
toolCallId: message.toolCallId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'tool_result') {
|
||||
for (let index = converted.length - 1; index >= 0; index -= 1) {
|
||||
const convertedMessage = converted[index];
|
||||
if (!convertedMessage.isToolUse || convertedMessage.toolResult) {
|
||||
continue;
|
||||
}
|
||||
if (!message.toolCallId || convertedMessage.toolCallId === message.toolCallId) {
|
||||
convertedMessage.toolResult = {
|
||||
content: message.output || '',
|
||||
isError: false,
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.message?.role === 'assistant' && message.message?.content) {
|
||||
if (Array.isArray(message.message.content)) {
|
||||
message.message.content.forEach((part: any) => {
|
||||
if (part.type === 'text') {
|
||||
let text = part.text;
|
||||
if (typeof text === 'string') {
|
||||
text = unescapeWithMathProtection(text);
|
||||
}
|
||||
converted.push({
|
||||
type: 'assistant',
|
||||
content: text,
|
||||
timestamp: message.timestamp || new Date().toISOString(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (part.type === 'tool_use') {
|
||||
const toolResult = toolResults.get(part.id);
|
||||
const isSubagentContainer = part.name === 'Task';
|
||||
|
||||
// Build child tools from server-provided subagentTools data
|
||||
const childTools: import('../types/types').SubagentChildTool[] = [];
|
||||
if (isSubagentContainer && toolResult?.subagentTools && Array.isArray(toolResult.subagentTools)) {
|
||||
for (const tool of toolResult.subagentTools as any[]) {
|
||||
childTools.push({
|
||||
toolId: tool.toolId,
|
||||
toolName: tool.toolName,
|
||||
toolInput: tool.toolInput,
|
||||
toolResult: tool.toolResult || null,
|
||||
timestamp: new Date(tool.timestamp || Date.now()),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
converted.push({
|
||||
type: 'assistant',
|
||||
content: '',
|
||||
timestamp: message.timestamp || new Date().toISOString(),
|
||||
isToolUse: true,
|
||||
toolName: part.name,
|
||||
toolInput: normalizeToolInput(part.input),
|
||||
toolId: part.id,
|
||||
toolResult: toolResult
|
||||
? {
|
||||
content:
|
||||
typeof toolResult.content === 'string'
|
||||
? toolResult.content
|
||||
: JSON.stringify(toolResult.content),
|
||||
isError: toolResult.isError,
|
||||
toolUseResult: toolResult.toolUseResult,
|
||||
}
|
||||
: null,
|
||||
toolError: toolResult?.isError || false,
|
||||
toolResultTimestamp: toolResult?.timestamp || new Date(),
|
||||
isSubagentContainer,
|
||||
subagentState: isSubagentContainer
|
||||
? {
|
||||
childTools,
|
||||
currentToolIndex: childTools.length > 0 ? childTools.length - 1 : -1,
|
||||
isComplete: Boolean(toolResult),
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof message.message.content === 'string') {
|
||||
converted.push({
|
||||
type: 'assistant',
|
||||
content: unescapeWithMathProtection(message.message.content),
|
||||
timestamp: message.timestamp || new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return converted;
|
||||
};
|
||||
397
src/components/chat/view/ChatInterface.tsx
Normal file
397
src/components/chat/view/ChatInterface.tsx
Normal file
@@ -0,0 +1,397 @@
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
import QuickSettingsPanel from '../../QuickSettingsPanel';
|
||||
import { useTasksSettings } from '../../../contexts/TasksSettingsContext';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import ChatMessagesPane from './subcomponents/ChatMessagesPane';
|
||||
import ChatComposer from './subcomponents/ChatComposer';
|
||||
import type { ChatInterfaceProps } from '../types/types';
|
||||
import { useChatProviderState } from '../hooks/useChatProviderState';
|
||||
import { useChatSessionState } from '../hooks/useChatSessionState';
|
||||
import { useChatRealtimeHandlers } from '../hooks/useChatRealtimeHandlers';
|
||||
import { useChatComposerState } from '../hooks/useChatComposerState';
|
||||
import type { Provider } from '../types/types';
|
||||
|
||||
type PendingViewSession = {
|
||||
sessionId: string | null;
|
||||
startedAt: number;
|
||||
};
|
||||
|
||||
function ChatInterface({
|
||||
selectedProject,
|
||||
selectedSession,
|
||||
ws,
|
||||
sendMessage,
|
||||
latestMessage,
|
||||
onFileOpen,
|
||||
onInputFocusChange,
|
||||
onSessionActive,
|
||||
onSessionInactive,
|
||||
onSessionProcessing,
|
||||
onSessionNotProcessing,
|
||||
processingSessions,
|
||||
onReplaceTemporarySession,
|
||||
onNavigateToSession,
|
||||
onShowSettings,
|
||||
autoExpandTools,
|
||||
showRawParameters,
|
||||
showThinking,
|
||||
autoScrollToBottom,
|
||||
sendByCtrlEnter,
|
||||
externalMessageUpdate,
|
||||
onShowAllTasks,
|
||||
}: ChatInterfaceProps) {
|
||||
const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings();
|
||||
const { t } = useTranslation('chat');
|
||||
|
||||
const streamBufferRef = useRef('');
|
||||
const streamTimerRef = useRef<number | null>(null);
|
||||
const pendingViewSessionRef = useRef<PendingViewSession | null>(null);
|
||||
|
||||
const resetStreamingState = useCallback(() => {
|
||||
if (streamTimerRef.current) {
|
||||
clearTimeout(streamTimerRef.current);
|
||||
streamTimerRef.current = null;
|
||||
}
|
||||
streamBufferRef.current = '';
|
||||
}, []);
|
||||
|
||||
const {
|
||||
provider,
|
||||
setProvider,
|
||||
cursorModel,
|
||||
setCursorModel,
|
||||
claudeModel,
|
||||
setClaudeModel,
|
||||
codexModel,
|
||||
setCodexModel,
|
||||
permissionMode,
|
||||
pendingPermissionRequests,
|
||||
setPendingPermissionRequests,
|
||||
cyclePermissionMode,
|
||||
} = useChatProviderState({
|
||||
selectedSession,
|
||||
});
|
||||
|
||||
const {
|
||||
chatMessages,
|
||||
setChatMessages,
|
||||
isLoading,
|
||||
setIsLoading,
|
||||
currentSessionId,
|
||||
setCurrentSessionId,
|
||||
sessionMessages,
|
||||
setSessionMessages,
|
||||
isLoadingSessionMessages,
|
||||
isLoadingMoreMessages,
|
||||
hasMoreMessages,
|
||||
totalMessages,
|
||||
isSystemSessionChange,
|
||||
setIsSystemSessionChange,
|
||||
canAbortSession,
|
||||
setCanAbortSession,
|
||||
isUserScrolledUp,
|
||||
setIsUserScrolledUp,
|
||||
tokenBudget,
|
||||
setTokenBudget,
|
||||
visibleMessageCount,
|
||||
visibleMessages,
|
||||
loadEarlierMessages,
|
||||
loadAllMessages,
|
||||
allMessagesLoaded,
|
||||
isLoadingAllMessages,
|
||||
loadAllJustFinished,
|
||||
showLoadAllOverlay,
|
||||
claudeStatus,
|
||||
setClaudeStatus,
|
||||
createDiff,
|
||||
scrollContainerRef,
|
||||
scrollToBottom,
|
||||
scrollToBottomAndReset,
|
||||
handleScroll,
|
||||
} = useChatSessionState({
|
||||
selectedProject,
|
||||
selectedSession,
|
||||
ws,
|
||||
sendMessage,
|
||||
autoScrollToBottom,
|
||||
externalMessageUpdate,
|
||||
processingSessions,
|
||||
resetStreamingState,
|
||||
pendingViewSessionRef,
|
||||
});
|
||||
|
||||
const {
|
||||
input,
|
||||
setInput,
|
||||
textareaRef,
|
||||
inputHighlightRef,
|
||||
isTextareaExpanded,
|
||||
thinkingMode,
|
||||
setThinkingMode,
|
||||
slashCommandsCount,
|
||||
filteredCommands,
|
||||
frequentCommands,
|
||||
commandQuery,
|
||||
showCommandMenu,
|
||||
selectedCommandIndex,
|
||||
resetCommandMenuState,
|
||||
handleCommandSelect,
|
||||
handleToggleCommandMenu,
|
||||
showFileDropdown,
|
||||
filteredFiles,
|
||||
selectedFileIndex,
|
||||
renderInputWithMentions,
|
||||
selectFile,
|
||||
attachedImages,
|
||||
setAttachedImages,
|
||||
uploadingImages,
|
||||
imageErrors,
|
||||
getRootProps,
|
||||
getInputProps,
|
||||
isDragActive,
|
||||
openImagePicker,
|
||||
handleSubmit,
|
||||
handleInputChange,
|
||||
handleKeyDown,
|
||||
handlePaste,
|
||||
handleTextareaClick,
|
||||
handleTextareaInput,
|
||||
syncInputOverlayScroll,
|
||||
handleClearInput,
|
||||
handleAbortSession,
|
||||
handleTranscript,
|
||||
handlePermissionDecision,
|
||||
handleGrantToolPermission,
|
||||
handleInputFocusChange,
|
||||
isInputFocused,
|
||||
} = useChatComposerState({
|
||||
selectedProject,
|
||||
selectedSession,
|
||||
currentSessionId,
|
||||
provider,
|
||||
permissionMode,
|
||||
cyclePermissionMode,
|
||||
cursorModel,
|
||||
claudeModel,
|
||||
codexModel,
|
||||
isLoading,
|
||||
canAbortSession,
|
||||
tokenBudget,
|
||||
sendMessage,
|
||||
sendByCtrlEnter,
|
||||
onSessionActive,
|
||||
onInputFocusChange,
|
||||
onFileOpen,
|
||||
onShowSettings,
|
||||
pendingViewSessionRef,
|
||||
scrollToBottom,
|
||||
setChatMessages,
|
||||
setSessionMessages,
|
||||
setIsLoading,
|
||||
setCanAbortSession,
|
||||
setClaudeStatus,
|
||||
setIsUserScrolledUp,
|
||||
setPendingPermissionRequests,
|
||||
});
|
||||
|
||||
useChatRealtimeHandlers({
|
||||
latestMessage,
|
||||
provider,
|
||||
selectedProject,
|
||||
selectedSession,
|
||||
currentSessionId,
|
||||
setCurrentSessionId,
|
||||
setChatMessages,
|
||||
setIsLoading,
|
||||
setCanAbortSession,
|
||||
setClaudeStatus,
|
||||
setTokenBudget,
|
||||
setIsSystemSessionChange,
|
||||
setPendingPermissionRequests,
|
||||
pendingViewSessionRef,
|
||||
streamBufferRef,
|
||||
streamTimerRef,
|
||||
onSessionInactive,
|
||||
onSessionProcessing,
|
||||
onSessionNotProcessing,
|
||||
onReplaceTemporarySession,
|
||||
onNavigateToSession,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading || !canAbortSession) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleGlobalEscape = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape' || event.repeat || event.defaultPrevented) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
handleAbortSession();
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleGlobalEscape, { capture: true });
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleGlobalEscape, { capture: true });
|
||||
};
|
||||
}, [canAbortSession, handleAbortSession, isLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
const processingSessionId = selectedSession?.id || currentSessionId;
|
||||
if (processingSessionId && isLoading && onSessionProcessing) {
|
||||
onSessionProcessing(processingSessionId);
|
||||
}
|
||||
}, [currentSessionId, isLoading, onSessionProcessing, selectedSession?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
resetStreamingState();
|
||||
};
|
||||
}, [resetStreamingState]);
|
||||
|
||||
if (!selectedProject) {
|
||||
const selectedProviderLabel =
|
||||
provider === 'cursor'
|
||||
? t('messageTypes.cursor')
|
||||
: provider === 'codex'
|
||||
? t('messageTypes.codex')
|
||||
: t('messageTypes.claude');
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<p className="text-sm">
|
||||
{t('projectSelection.startChatWithProvider', {
|
||||
provider: selectedProviderLabel,
|
||||
defaultValue: 'Select a project to start chatting with {{provider}}',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="h-full flex flex-col">
|
||||
<ChatMessagesPane
|
||||
scrollContainerRef={scrollContainerRef}
|
||||
onWheel={handleScroll}
|
||||
onTouchMove={handleScroll}
|
||||
isLoadingSessionMessages={isLoadingSessionMessages}
|
||||
chatMessages={chatMessages}
|
||||
selectedSession={selectedSession}
|
||||
currentSessionId={currentSessionId}
|
||||
provider={provider}
|
||||
setProvider={(nextProvider) => setProvider(nextProvider as Provider)}
|
||||
textareaRef={textareaRef}
|
||||
claudeModel={claudeModel}
|
||||
setClaudeModel={setClaudeModel}
|
||||
cursorModel={cursorModel}
|
||||
setCursorModel={setCursorModel}
|
||||
codexModel={codexModel}
|
||||
setCodexModel={setCodexModel}
|
||||
tasksEnabled={tasksEnabled}
|
||||
isTaskMasterInstalled={isTaskMasterInstalled}
|
||||
onShowAllTasks={onShowAllTasks}
|
||||
setInput={setInput}
|
||||
isLoadingMoreMessages={isLoadingMoreMessages}
|
||||
hasMoreMessages={hasMoreMessages}
|
||||
totalMessages={totalMessages}
|
||||
sessionMessagesCount={sessionMessages.length}
|
||||
visibleMessageCount={visibleMessageCount}
|
||||
visibleMessages={visibleMessages}
|
||||
loadEarlierMessages={loadEarlierMessages}
|
||||
loadAllMessages={loadAllMessages}
|
||||
allMessagesLoaded={allMessagesLoaded}
|
||||
isLoadingAllMessages={isLoadingAllMessages}
|
||||
loadAllJustFinished={loadAllJustFinished}
|
||||
showLoadAllOverlay={showLoadAllOverlay}
|
||||
createDiff={createDiff}
|
||||
onFileOpen={onFileOpen}
|
||||
onShowSettings={onShowSettings}
|
||||
onGrantToolPermission={handleGrantToolPermission}
|
||||
autoExpandTools={autoExpandTools}
|
||||
showRawParameters={showRawParameters}
|
||||
showThinking={showThinking}
|
||||
selectedProject={selectedProject}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
|
||||
<ChatComposer
|
||||
pendingPermissionRequests={pendingPermissionRequests}
|
||||
handlePermissionDecision={handlePermissionDecision}
|
||||
handleGrantToolPermission={handleGrantToolPermission}
|
||||
claudeStatus={claudeStatus}
|
||||
isLoading={isLoading}
|
||||
onAbortSession={handleAbortSession}
|
||||
provider={provider}
|
||||
permissionMode={permissionMode}
|
||||
onModeSwitch={cyclePermissionMode}
|
||||
thinkingMode={thinkingMode}
|
||||
setThinkingMode={setThinkingMode}
|
||||
tokenBudget={tokenBudget}
|
||||
slashCommandsCount={slashCommandsCount}
|
||||
onToggleCommandMenu={handleToggleCommandMenu}
|
||||
hasInput={Boolean(input.trim())}
|
||||
onClearInput={handleClearInput}
|
||||
isUserScrolledUp={isUserScrolledUp}
|
||||
hasMessages={chatMessages.length > 0}
|
||||
onScrollToBottom={scrollToBottomAndReset}
|
||||
onSubmit={handleSubmit}
|
||||
isDragActive={isDragActive}
|
||||
attachedImages={attachedImages}
|
||||
onRemoveImage={(index) =>
|
||||
setAttachedImages((previous) =>
|
||||
previous.filter((_, currentIndex) => currentIndex !== index),
|
||||
)
|
||||
}
|
||||
uploadingImages={uploadingImages}
|
||||
imageErrors={imageErrors}
|
||||
showFileDropdown={showFileDropdown}
|
||||
filteredFiles={filteredFiles}
|
||||
selectedFileIndex={selectedFileIndex}
|
||||
onSelectFile={selectFile}
|
||||
filteredCommands={filteredCommands}
|
||||
selectedCommandIndex={selectedCommandIndex}
|
||||
onCommandSelect={handleCommandSelect}
|
||||
onCloseCommandMenu={resetCommandMenuState}
|
||||
isCommandMenuOpen={showCommandMenu}
|
||||
frequentCommands={commandQuery ? [] : frequentCommands}
|
||||
getRootProps={getRootProps as (...args: unknown[]) => Record<string, unknown>}
|
||||
getInputProps={getInputProps as (...args: unknown[]) => Record<string, unknown>}
|
||||
openImagePicker={openImagePicker}
|
||||
inputHighlightRef={inputHighlightRef}
|
||||
renderInputWithMentions={renderInputWithMentions}
|
||||
textareaRef={textareaRef}
|
||||
input={input}
|
||||
onInputChange={handleInputChange}
|
||||
onTextareaClick={handleTextareaClick}
|
||||
onTextareaKeyDown={handleKeyDown}
|
||||
onTextareaPaste={handlePaste}
|
||||
onTextareaScrollSync={syncInputOverlayScroll}
|
||||
onTextareaInput={handleTextareaInput}
|
||||
onInputFocusChange={handleInputFocusChange}
|
||||
isInputFocused={isInputFocused}
|
||||
placeholder={t('input.placeholder', {
|
||||
provider:
|
||||
provider === 'cursor'
|
||||
? t('messageTypes.cursor')
|
||||
: provider === 'codex'
|
||||
? t('messageTypes.codex')
|
||||
: t('messageTypes.claude'),
|
||||
})}
|
||||
isTextareaExpanded={isTextareaExpanded}
|
||||
sendByCtrlEnter={sendByCtrlEnter}
|
||||
onTranscript={handleTranscript}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<QuickSettingsPanel />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(ChatInterface);
|
||||
@@ -0,0 +1,37 @@
|
||||
import { SessionProvider } from '../../../../types/app';
|
||||
import SessionProviderLogo from '../../../SessionProviderLogo';
|
||||
import type { Provider } from '../../types/types';
|
||||
|
||||
type AssistantThinkingIndicatorProps = {
|
||||
selectedProvider: SessionProvider;
|
||||
}
|
||||
|
||||
|
||||
export default function AssistantThinkingIndicator({ selectedProvider }: AssistantThinkingIndicatorProps) {
|
||||
return (
|
||||
<div className="chat-message assistant">
|
||||
<div className="w-full">
|
||||
<div className="flex items-center space-x-3 mb-2">
|
||||
<div className="w-8 h-8 rounded-full flex items-center justify-center text-white text-sm flex-shrink-0 p-1 bg-transparent">
|
||||
<SessionProviderLogo provider={selectedProvider} className="w-full h-full" />
|
||||
</div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{selectedProvider === 'cursor' ? 'Cursor' : selectedProvider === 'codex' ? 'Codex' : 'Claude'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full text-sm text-gray-500 dark:text-gray-400 pl-3 sm:pl-0">
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className="animate-pulse">.</div>
|
||||
<div className="animate-pulse" style={{ animationDelay: '0.2s' }}>
|
||||
.
|
||||
</div>
|
||||
<div className="animate-pulse" style={{ animationDelay: '0.4s' }}>
|
||||
.
|
||||
</div>
|
||||
<span className="ml-2">Thinking...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
360
src/components/chat/view/subcomponents/ChatComposer.tsx
Normal file
360
src/components/chat/view/subcomponents/ChatComposer.tsx
Normal file
@@ -0,0 +1,360 @@
|
||||
import CommandMenu from '../../../CommandMenu';
|
||||
import ClaudeStatus from '../../../ClaudeStatus';
|
||||
import { MicButton } from '../../../MicButton.jsx';
|
||||
import ImageAttachment from './ImageAttachment';
|
||||
import PermissionRequestsBanner from './PermissionRequestsBanner';
|
||||
import ChatInputControls from './ChatInputControls';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type {
|
||||
ChangeEvent,
|
||||
ClipboardEvent,
|
||||
Dispatch,
|
||||
FormEvent,
|
||||
KeyboardEvent,
|
||||
MouseEvent,
|
||||
ReactNode,
|
||||
RefObject,
|
||||
SetStateAction,
|
||||
TouchEvent,
|
||||
} from 'react';
|
||||
import type { PendingPermissionRequest, PermissionMode, Provider } from '../../types/types';
|
||||
|
||||
interface MentionableFile {
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface SlashCommand {
|
||||
name: string;
|
||||
description?: string;
|
||||
namespace?: string;
|
||||
path?: string;
|
||||
type?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface ChatComposerProps {
|
||||
pendingPermissionRequests: PendingPermissionRequest[];
|
||||
handlePermissionDecision: (
|
||||
requestIds: string | string[],
|
||||
decision: { allow?: boolean; message?: string; rememberEntry?: string | null; updatedInput?: unknown },
|
||||
) => void;
|
||||
handleGrantToolPermission: (suggestion: { entry: string; toolName: string }) => { success: boolean };
|
||||
claudeStatus: { text: string; tokens: number; can_interrupt: boolean } | null;
|
||||
isLoading: boolean;
|
||||
onAbortSession: () => void;
|
||||
provider: Provider | string;
|
||||
permissionMode: PermissionMode | string;
|
||||
onModeSwitch: () => void;
|
||||
thinkingMode: string;
|
||||
setThinkingMode: Dispatch<SetStateAction<string>>;
|
||||
tokenBudget: { used?: number; total?: number } | null;
|
||||
slashCommandsCount: number;
|
||||
onToggleCommandMenu: () => void;
|
||||
hasInput: boolean;
|
||||
onClearInput: () => void;
|
||||
isUserScrolledUp: boolean;
|
||||
hasMessages: boolean;
|
||||
onScrollToBottom: () => void;
|
||||
onSubmit: (event: FormEvent<HTMLFormElement> | MouseEvent<HTMLButtonElement> | TouchEvent<HTMLButtonElement>) => void;
|
||||
isDragActive: boolean;
|
||||
attachedImages: File[];
|
||||
onRemoveImage: (index: number) => void;
|
||||
uploadingImages: Map<string, number>;
|
||||
imageErrors: Map<string, string>;
|
||||
showFileDropdown: boolean;
|
||||
filteredFiles: MentionableFile[];
|
||||
selectedFileIndex: number;
|
||||
onSelectFile: (file: MentionableFile) => void;
|
||||
filteredCommands: SlashCommand[];
|
||||
selectedCommandIndex: number;
|
||||
onCommandSelect: (command: SlashCommand, index: number, isHover: boolean) => void;
|
||||
onCloseCommandMenu: () => void;
|
||||
isCommandMenuOpen: boolean;
|
||||
frequentCommands: SlashCommand[];
|
||||
getRootProps: (...args: unknown[]) => Record<string, unknown>;
|
||||
getInputProps: (...args: unknown[]) => Record<string, unknown>;
|
||||
openImagePicker: () => void;
|
||||
inputHighlightRef: RefObject<HTMLDivElement>;
|
||||
renderInputWithMentions: (text: string) => ReactNode;
|
||||
textareaRef: RefObject<HTMLTextAreaElement>;
|
||||
input: string;
|
||||
onInputChange: (event: ChangeEvent<HTMLTextAreaElement>) => void;
|
||||
onTextareaClick: (event: MouseEvent<HTMLTextAreaElement>) => void;
|
||||
onTextareaKeyDown: (event: KeyboardEvent<HTMLTextAreaElement>) => void;
|
||||
onTextareaPaste: (event: ClipboardEvent<HTMLTextAreaElement>) => void;
|
||||
onTextareaScrollSync: (target: HTMLTextAreaElement) => void;
|
||||
onTextareaInput: (event: FormEvent<HTMLTextAreaElement>) => void;
|
||||
onInputFocusChange?: (focused: boolean) => void;
|
||||
isInputFocused?: boolean;
|
||||
placeholder: string;
|
||||
isTextareaExpanded: boolean;
|
||||
sendByCtrlEnter?: boolean;
|
||||
onTranscript: (text: string) => void;
|
||||
}
|
||||
|
||||
export default function ChatComposer({
|
||||
pendingPermissionRequests,
|
||||
handlePermissionDecision,
|
||||
handleGrantToolPermission,
|
||||
claudeStatus,
|
||||
isLoading,
|
||||
onAbortSession,
|
||||
provider,
|
||||
permissionMode,
|
||||
onModeSwitch,
|
||||
thinkingMode,
|
||||
setThinkingMode,
|
||||
tokenBudget,
|
||||
slashCommandsCount,
|
||||
onToggleCommandMenu,
|
||||
hasInput,
|
||||
onClearInput,
|
||||
isUserScrolledUp,
|
||||
hasMessages,
|
||||
onScrollToBottom,
|
||||
onSubmit,
|
||||
isDragActive,
|
||||
attachedImages,
|
||||
onRemoveImage,
|
||||
uploadingImages,
|
||||
imageErrors,
|
||||
showFileDropdown,
|
||||
filteredFiles,
|
||||
selectedFileIndex,
|
||||
onSelectFile,
|
||||
filteredCommands,
|
||||
selectedCommandIndex,
|
||||
onCommandSelect,
|
||||
onCloseCommandMenu,
|
||||
isCommandMenuOpen,
|
||||
frequentCommands,
|
||||
getRootProps,
|
||||
getInputProps,
|
||||
openImagePicker,
|
||||
inputHighlightRef,
|
||||
renderInputWithMentions,
|
||||
textareaRef,
|
||||
input,
|
||||
onInputChange,
|
||||
onTextareaClick,
|
||||
onTextareaKeyDown,
|
||||
onTextareaPaste,
|
||||
onTextareaScrollSync,
|
||||
onTextareaInput,
|
||||
onInputFocusChange,
|
||||
isInputFocused,
|
||||
placeholder,
|
||||
isTextareaExpanded,
|
||||
sendByCtrlEnter,
|
||||
onTranscript,
|
||||
}: ChatComposerProps) {
|
||||
const { t } = useTranslation('chat');
|
||||
const AnyCommandMenu = CommandMenu as any;
|
||||
const textareaRect = textareaRef.current?.getBoundingClientRect();
|
||||
const commandMenuPosition = {
|
||||
top: textareaRect ? Math.max(16, textareaRect.top - 316) : 0,
|
||||
left: textareaRect ? textareaRect.left : 16,
|
||||
bottom: textareaRect ? window.innerHeight - textareaRect.top + 8 : 90,
|
||||
};
|
||||
|
||||
// Detect if the AskUserQuestion interactive panel is active
|
||||
const hasQuestionPanel = pendingPermissionRequests.some(
|
||||
(r) => r.toolName === 'AskUserQuestion'
|
||||
);
|
||||
|
||||
// On mobile, when input is focused, float the input box at the bottom
|
||||
const mobileFloatingClass = isInputFocused
|
||||
? 'max-sm:fixed max-sm:bottom-0 max-sm:left-0 max-sm:right-0 max-sm:z-50 max-sm:bg-background max-sm:shadow-[0_-4px_20px_rgba(0,0,0,0.15)]'
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div className={`p-2 sm:p-4 md:p-4 flex-shrink-0 pb-2 sm:pb-4 md:pb-6 ${mobileFloatingClass}`}>
|
||||
{!hasQuestionPanel && (
|
||||
<div className="flex-1">
|
||||
<ClaudeStatus
|
||||
status={claudeStatus}
|
||||
isLoading={isLoading}
|
||||
onAbort={onAbortSession}
|
||||
provider={provider}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="max-w-4xl mx-auto mb-3">
|
||||
<PermissionRequestsBanner
|
||||
pendingPermissionRequests={pendingPermissionRequests}
|
||||
handlePermissionDecision={handlePermissionDecision}
|
||||
handleGrantToolPermission={handleGrantToolPermission}
|
||||
/>
|
||||
|
||||
{!hasQuestionPanel && <ChatInputControls
|
||||
permissionMode={permissionMode}
|
||||
onModeSwitch={onModeSwitch}
|
||||
provider={provider}
|
||||
thinkingMode={thinkingMode}
|
||||
setThinkingMode={setThinkingMode}
|
||||
tokenBudget={tokenBudget}
|
||||
slashCommandsCount={slashCommandsCount}
|
||||
onToggleCommandMenu={onToggleCommandMenu}
|
||||
hasInput={hasInput}
|
||||
onClearInput={onClearInput}
|
||||
isUserScrolledUp={isUserScrolledUp}
|
||||
hasMessages={hasMessages}
|
||||
onScrollToBottom={onScrollToBottom}
|
||||
/>}
|
||||
</div>
|
||||
|
||||
{!hasQuestionPanel && <form onSubmit={onSubmit as (event: FormEvent<HTMLFormElement>) => void} className="relative max-w-4xl mx-auto">
|
||||
{isDragActive && (
|
||||
<div className="absolute inset-0 bg-primary/15 border-2 border-dashed border-primary/50 rounded-2xl flex items-center justify-center z-50">
|
||||
<div className="bg-card rounded-xl p-4 shadow-lg border border-border/30">
|
||||
<svg className="w-8 h-8 text-primary mx-auto mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-sm font-medium">Drop images here</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{attachedImages.length > 0 && (
|
||||
<div className="mb-2 p-2 bg-muted/40 rounded-xl">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{attachedImages.map((file, index) => (
|
||||
<ImageAttachment
|
||||
key={index}
|
||||
file={file}
|
||||
onRemove={() => onRemoveImage(index)}
|
||||
uploadProgress={uploadingImages.get(file.name)}
|
||||
error={imageErrors.get(file.name)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showFileDropdown && filteredFiles.length > 0 && (
|
||||
<div className="absolute bottom-full left-0 right-0 mb-2 bg-card/95 backdrop-blur-md border border-border/50 rounded-xl shadow-lg max-h-48 overflow-y-auto z-50">
|
||||
{filteredFiles.map((file, index) => (
|
||||
<div
|
||||
key={file.path}
|
||||
className={`px-4 py-3 cursor-pointer border-b border-border/30 last:border-b-0 touch-manipulation ${
|
||||
index === selectedFileIndex
|
||||
? 'bg-primary/8 text-primary'
|
||||
: 'hover:bg-accent/50 text-foreground'
|
||||
}`}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onSelectFile(file);
|
||||
}}
|
||||
>
|
||||
<div className="font-medium text-sm">{file.name}</div>
|
||||
<div className="text-xs text-muted-foreground font-mono">{file.path}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AnyCommandMenu
|
||||
commands={filteredCommands}
|
||||
selectedIndex={selectedCommandIndex}
|
||||
onSelect={onCommandSelect}
|
||||
onClose={onCloseCommandMenu}
|
||||
position={commandMenuPosition}
|
||||
isOpen={isCommandMenuOpen}
|
||||
frequentCommands={frequentCommands}
|
||||
/>
|
||||
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={`relative bg-card/80 backdrop-blur-sm rounded-2xl shadow-sm border border-border/50 focus-within:shadow-md focus-within:border-primary/30 focus-within:ring-1 focus-within:ring-primary/15 transition-all duration-200 overflow-hidden ${
|
||||
isTextareaExpanded ? 'chat-input-expanded' : ''
|
||||
}`}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<div ref={inputHighlightRef} aria-hidden="true" className="absolute inset-0 pointer-events-none overflow-hidden rounded-2xl">
|
||||
<div className="chat-input-placeholder block w-full pl-12 pr-20 sm:pr-40 py-1.5 sm:py-4 text-transparent text-base leading-6 whitespace-pre-wrap break-words">
|
||||
{renderInputWithMentions(input)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={onInputChange}
|
||||
onClick={onTextareaClick}
|
||||
onKeyDown={onTextareaKeyDown}
|
||||
onPaste={onTextareaPaste}
|
||||
onScroll={(event) => onTextareaScrollSync(event.target as HTMLTextAreaElement)}
|
||||
onFocus={() => onInputFocusChange?.(true)}
|
||||
onBlur={() => onInputFocusChange?.(false)}
|
||||
onInput={onTextareaInput}
|
||||
placeholder={placeholder}
|
||||
disabled={isLoading}
|
||||
className="chat-input-placeholder block w-full pl-12 pr-20 sm:pr-40 py-1.5 sm:py-4 bg-transparent rounded-2xl focus:outline-none text-foreground placeholder-muted-foreground/50 disabled:opacity-50 resize-none min-h-[50px] sm:min-h-[80px] max-h-[40vh] sm:max-h-[300px] overflow-y-auto text-base leading-6 transition-all duration-200"
|
||||
style={{ height: '50px' }}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={openImagePicker}
|
||||
className="absolute left-2 top-1/2 transform -translate-y-1/2 p-2 hover:bg-accent/60 rounded-xl transition-colors"
|
||||
title={t('input.attachImages')}
|
||||
>
|
||||
<svg className="w-5 h-5 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div className="absolute right-16 sm:right-16 top-1/2 transform -translate-y-1/2" style={{ display: 'none' }}>
|
||||
<MicButton onTranscript={onTranscript} className="w-10 h-10 sm:w-10 sm:h-10" />
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!input.trim() || isLoading}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
onSubmit(event);
|
||||
}}
|
||||
onTouchStart={(event) => {
|
||||
event.preventDefault();
|
||||
onSubmit(event);
|
||||
}}
|
||||
className="absolute right-2 top-1/2 transform -translate-y-1/2 w-10 h-10 sm:w-11 sm:h-11 bg-primary hover:bg-primary/90 disabled:bg-muted disabled:text-muted-foreground disabled:cursor-not-allowed rounded-xl flex items-center justify-center transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-primary/30 focus:ring-offset-1 focus:ring-offset-background"
|
||||
>
|
||||
<svg className="w-4 h-4 sm:w-[18px] sm:h-[18px] text-primary-foreground transform rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.2} d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div
|
||||
className={`absolute bottom-1 left-12 right-14 sm:right-40 text-xs text-muted-foreground/50 pointer-events-none hidden sm:block transition-opacity duration-200 ${
|
||||
input.trim() ? 'opacity-0' : 'opacity-100'
|
||||
}`}
|
||||
>
|
||||
{sendByCtrlEnter ? t('input.hintText.ctrlEnter') : t('input.hintText.enter')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
137
src/components/chat/view/subcomponents/ChatInputControls.tsx
Normal file
137
src/components/chat/view/subcomponents/ChatInputControls.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import ThinkingModeSelector from './ThinkingModeSelector';
|
||||
import TokenUsagePie from './TokenUsagePie';
|
||||
import type { PermissionMode, Provider } from '../../types/types';
|
||||
|
||||
interface ChatInputControlsProps {
|
||||
permissionMode: PermissionMode | string;
|
||||
onModeSwitch: () => void;
|
||||
provider: Provider | string;
|
||||
thinkingMode: string;
|
||||
setThinkingMode: React.Dispatch<React.SetStateAction<string>>;
|
||||
tokenBudget: { used?: number; total?: number } | null;
|
||||
slashCommandsCount: number;
|
||||
onToggleCommandMenu: () => void;
|
||||
hasInput: boolean;
|
||||
onClearInput: () => void;
|
||||
isUserScrolledUp: boolean;
|
||||
hasMessages: boolean;
|
||||
onScrollToBottom: () => void;
|
||||
}
|
||||
|
||||
export default function ChatInputControls({
|
||||
permissionMode,
|
||||
onModeSwitch,
|
||||
provider,
|
||||
thinkingMode,
|
||||
setThinkingMode,
|
||||
tokenBudget,
|
||||
slashCommandsCount,
|
||||
onToggleCommandMenu,
|
||||
hasInput,
|
||||
onClearInput,
|
||||
isUserScrolledUp,
|
||||
hasMessages,
|
||||
onScrollToBottom,
|
||||
}: ChatInputControlsProps) {
|
||||
const { t } = useTranslation('chat');
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-2 sm:gap-3 flex-wrap">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onModeSwitch}
|
||||
className={`px-2.5 py-1 sm:px-3 sm:py-1.5 rounded-lg text-sm font-medium border transition-all duration-200 ${
|
||||
permissionMode === 'default'
|
||||
? 'bg-muted/50 text-muted-foreground border-border/60 hover:bg-muted'
|
||||
: permissionMode === 'acceptEdits'
|
||||
? 'bg-green-50 dark:bg-green-900/15 text-green-700 dark:text-green-300 border-green-300/60 dark:border-green-600/40 hover:bg-green-100 dark:hover:bg-green-900/25'
|
||||
: permissionMode === 'bypassPermissions'
|
||||
? 'bg-orange-50 dark:bg-orange-900/15 text-orange-700 dark:text-orange-300 border-orange-300/60 dark:border-orange-600/40 hover:bg-orange-100 dark:hover:bg-orange-900/25'
|
||||
: 'bg-primary/5 text-primary border-primary/20 hover:bg-primary/10'
|
||||
}`}
|
||||
title={t('input.clickToChangeMode')}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div
|
||||
className={`w-1.5 h-1.5 rounded-full ${
|
||||
permissionMode === 'default'
|
||||
? 'bg-muted-foreground'
|
||||
: permissionMode === 'acceptEdits'
|
||||
? 'bg-green-500'
|
||||
: permissionMode === 'bypassPermissions'
|
||||
? 'bg-orange-500'
|
||||
: 'bg-primary'
|
||||
}`}
|
||||
/>
|
||||
<span>
|
||||
{permissionMode === 'default' && t('codex.modes.default')}
|
||||
{permissionMode === 'acceptEdits' && t('codex.modes.acceptEdits')}
|
||||
{permissionMode === 'bypassPermissions' && t('codex.modes.bypassPermissions')}
|
||||
{permissionMode === 'plan' && t('codex.modes.plan')}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{provider === 'claude' && (
|
||||
<ThinkingModeSelector selectedMode={thinkingMode} onModeChange={setThinkingMode} onClose={() => {}} className="" />
|
||||
)}
|
||||
|
||||
<TokenUsagePie used={tokenBudget?.used || 0} total={tokenBudget?.total || parseInt(import.meta.env.VITE_CONTEXT_WINDOW) || 160000} />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleCommandMenu}
|
||||
className="relative w-7 h-7 sm:w-8 sm:h-8 text-muted-foreground hover:text-foreground rounded-lg flex items-center justify-center transition-colors hover:bg-accent/60"
|
||||
title={t('input.showAllCommands')}
|
||||
>
|
||||
<svg className="w-4 h-4 sm:w-5 sm:h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z"
|
||||
/>
|
||||
</svg>
|
||||
{slashCommandsCount > 0 && (
|
||||
<span
|
||||
className="absolute -top-1 -right-1 bg-primary text-primary-foreground text-[10px] font-bold rounded-full w-4 h-4 sm:w-5 sm:h-5 flex items-center justify-center"
|
||||
>
|
||||
{slashCommandsCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{hasInput && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClearInput}
|
||||
className="w-7 h-7 sm:w-8 sm:h-8 bg-card hover:bg-accent/60 border border-border/50 rounded-lg flex items-center justify-center transition-all duration-200 group shadow-sm"
|
||||
title={t('input.clearInput', { defaultValue: 'Clear input' })}
|
||||
>
|
||||
<svg
|
||||
className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-muted-foreground group-hover:text-foreground transition-colors"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isUserScrolledUp && hasMessages && (
|
||||
<button
|
||||
onClick={onScrollToBottom}
|
||||
className="w-7 h-7 sm:w-8 sm:h-8 bg-primary hover:bg-primary/90 text-primary-foreground rounded-lg shadow-sm flex items-center justify-center transition-all duration-200 hover:scale-105"
|
||||
title={t('input.scrollToBottom', { defaultValue: 'Scroll to bottom' })}
|
||||
>
|
||||
<svg className="w-3.5 h-3.5 sm:w-4 sm:h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 14l-7 7m0 0l-7-7m7 7V3" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
265
src/components/chat/view/subcomponents/ChatMessagesPane.tsx
Normal file
265
src/components/chat/view/subcomponents/ChatMessagesPane.tsx
Normal file
@@ -0,0 +1,265 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useCallback, useRef } from 'react';
|
||||
import type { Dispatch, RefObject, SetStateAction } from 'react';
|
||||
|
||||
import MessageComponent from './MessageComponent';
|
||||
import ProviderSelectionEmptyState from './ProviderSelectionEmptyState';
|
||||
import type { ChatMessage } from '../../types/types';
|
||||
import type { Project, ProjectSession, SessionProvider } from '../../../../types/app';
|
||||
import AssistantThinkingIndicator from './AssistantThinkingIndicator';
|
||||
import { getIntrinsicMessageKey } from '../../utils/messageKeys';
|
||||
|
||||
interface ChatMessagesPaneProps {
|
||||
scrollContainerRef: RefObject<HTMLDivElement>;
|
||||
onWheel: () => void;
|
||||
onTouchMove: () => void;
|
||||
isLoadingSessionMessages: boolean;
|
||||
chatMessages: ChatMessage[];
|
||||
selectedSession: ProjectSession | null;
|
||||
currentSessionId: string | null;
|
||||
provider: SessionProvider;
|
||||
setProvider: (provider: SessionProvider) => void;
|
||||
textareaRef: RefObject<HTMLTextAreaElement>;
|
||||
claudeModel: string;
|
||||
setClaudeModel: (model: string) => void;
|
||||
cursorModel: string;
|
||||
setCursorModel: (model: string) => void;
|
||||
codexModel: string;
|
||||
setCodexModel: (model: string) => void;
|
||||
tasksEnabled: boolean;
|
||||
isTaskMasterInstalled: boolean | null;
|
||||
onShowAllTasks?: (() => void) | null;
|
||||
setInput: Dispatch<SetStateAction<string>>;
|
||||
isLoadingMoreMessages: boolean;
|
||||
hasMoreMessages: boolean;
|
||||
totalMessages: number;
|
||||
sessionMessagesCount: number;
|
||||
visibleMessageCount: number;
|
||||
visibleMessages: ChatMessage[];
|
||||
loadEarlierMessages: () => void;
|
||||
loadAllMessages: () => void;
|
||||
allMessagesLoaded: boolean;
|
||||
isLoadingAllMessages: boolean;
|
||||
loadAllJustFinished: boolean;
|
||||
showLoadAllOverlay: boolean;
|
||||
createDiff: any;
|
||||
onFileOpen?: (filePath: string, diffInfo?: unknown) => void;
|
||||
onShowSettings?: () => void;
|
||||
onGrantToolPermission: (suggestion: { entry: string; toolName: string }) => { success: boolean };
|
||||
autoExpandTools?: boolean;
|
||||
showRawParameters?: boolean;
|
||||
showThinking?: boolean;
|
||||
selectedProject: Project;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export default function ChatMessagesPane({
|
||||
scrollContainerRef,
|
||||
onWheel,
|
||||
onTouchMove,
|
||||
isLoadingSessionMessages,
|
||||
chatMessages,
|
||||
selectedSession,
|
||||
currentSessionId,
|
||||
provider,
|
||||
setProvider,
|
||||
textareaRef,
|
||||
claudeModel,
|
||||
setClaudeModel,
|
||||
cursorModel,
|
||||
setCursorModel,
|
||||
codexModel,
|
||||
setCodexModel,
|
||||
tasksEnabled,
|
||||
isTaskMasterInstalled,
|
||||
onShowAllTasks,
|
||||
setInput,
|
||||
isLoadingMoreMessages,
|
||||
hasMoreMessages,
|
||||
totalMessages,
|
||||
sessionMessagesCount,
|
||||
visibleMessageCount,
|
||||
visibleMessages,
|
||||
loadEarlierMessages,
|
||||
loadAllMessages,
|
||||
allMessagesLoaded,
|
||||
isLoadingAllMessages,
|
||||
loadAllJustFinished,
|
||||
showLoadAllOverlay,
|
||||
createDiff,
|
||||
onFileOpen,
|
||||
onShowSettings,
|
||||
onGrantToolPermission,
|
||||
autoExpandTools,
|
||||
showRawParameters,
|
||||
showThinking,
|
||||
selectedProject,
|
||||
isLoading,
|
||||
}: ChatMessagesPaneProps) {
|
||||
const { t } = useTranslation('chat');
|
||||
const messageKeyMapRef = useRef<WeakMap<ChatMessage, string>>(new WeakMap());
|
||||
const allocatedKeysRef = useRef<Set<string>>(new Set());
|
||||
const generatedMessageKeyCounterRef = useRef(0);
|
||||
|
||||
// Keep keys stable across prepends so existing MessageComponent instances retain local state.
|
||||
const getMessageKey = useCallback((message: ChatMessage) => {
|
||||
const existingKey = messageKeyMapRef.current.get(message);
|
||||
if (existingKey) {
|
||||
return existingKey;
|
||||
}
|
||||
|
||||
const intrinsicKey = getIntrinsicMessageKey(message);
|
||||
let candidateKey = intrinsicKey;
|
||||
|
||||
if (!candidateKey || allocatedKeysRef.current.has(candidateKey)) {
|
||||
do {
|
||||
generatedMessageKeyCounterRef.current += 1;
|
||||
candidateKey = intrinsicKey
|
||||
? `${intrinsicKey}-${generatedMessageKeyCounterRef.current}`
|
||||
: `message-generated-${generatedMessageKeyCounterRef.current}`;
|
||||
} while (allocatedKeysRef.current.has(candidateKey));
|
||||
}
|
||||
|
||||
allocatedKeysRef.current.add(candidateKey);
|
||||
messageKeyMapRef.current.set(message, candidateKey);
|
||||
return candidateKey;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
onWheel={onWheel}
|
||||
onTouchMove={onTouchMove}
|
||||
className="flex-1 overflow-y-auto overflow-x-hidden px-0 py-3 sm:p-4 space-y-3 sm:space-y-4 relative"
|
||||
>
|
||||
{isLoadingSessionMessages && chatMessages.length === 0 ? (
|
||||
<div className="text-center text-gray-500 dark:text-gray-400 mt-8">
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-gray-400" />
|
||||
<p>{t('session.loading.sessionMessages')}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : chatMessages.length === 0 ? (
|
||||
<ProviderSelectionEmptyState
|
||||
selectedSession={selectedSession}
|
||||
currentSessionId={currentSessionId}
|
||||
provider={provider}
|
||||
setProvider={setProvider}
|
||||
textareaRef={textareaRef}
|
||||
claudeModel={claudeModel}
|
||||
setClaudeModel={setClaudeModel}
|
||||
cursorModel={cursorModel}
|
||||
setCursorModel={setCursorModel}
|
||||
codexModel={codexModel}
|
||||
setCodexModel={setCodexModel}
|
||||
tasksEnabled={tasksEnabled}
|
||||
isTaskMasterInstalled={isTaskMasterInstalled}
|
||||
onShowAllTasks={onShowAllTasks}
|
||||
setInput={setInput}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* Loading indicator for older messages (hide when load-all is active) */}
|
||||
{isLoadingMoreMessages && !isLoadingAllMessages && !allMessagesLoaded && (
|
||||
<div className="text-center text-gray-500 dark:text-gray-400 py-3">
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-gray-400" />
|
||||
<p className="text-sm">{t('session.loading.olderMessages')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Indicator showing there are more messages to load (hide when all loaded) */}
|
||||
{hasMoreMessages && !isLoadingMoreMessages && !allMessagesLoaded && (
|
||||
<div className="text-center text-gray-500 dark:text-gray-400 text-sm py-2 border-b border-gray-200 dark:border-gray-700">
|
||||
{totalMessages > 0 && (
|
||||
<span>
|
||||
{t('session.messages.showingOf', { shown: sessionMessagesCount, total: totalMessages })}{' '}
|
||||
<span className="text-xs">{t('session.messages.scrollToLoad')}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Floating "Load all messages" overlay */}
|
||||
{(showLoadAllOverlay || isLoadingAllMessages || loadAllJustFinished) && (
|
||||
<div className="sticky top-2 z-20 flex justify-center pointer-events-none">
|
||||
{loadAllJustFinished ? (
|
||||
<div className="px-4 py-1.5 text-xs font-medium text-white bg-green-600 dark:bg-green-500 rounded-full shadow-lg flex items-center space-x-2">
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span>{t('session.messages.allLoaded')}</span>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="pointer-events-auto px-4 py-1.5 text-xs font-medium text-white bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 rounded-full shadow-lg transition-all duration-200 hover:scale-105 disabled:opacity-75 disabled:cursor-wait flex items-center space-x-2"
|
||||
onClick={loadAllMessages}
|
||||
disabled={isLoadingAllMessages}
|
||||
>
|
||||
{isLoadingAllMessages && (
|
||||
<div className="animate-spin rounded-full h-3 w-3 border-2 border-white/30 border-t-white" />
|
||||
)}
|
||||
<span>
|
||||
{isLoadingAllMessages
|
||||
? t('session.messages.loadingAll')
|
||||
: <>{t('session.messages.loadAll')} {totalMessages > 0 && `(${totalMessages})`}</>
|
||||
}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Performance warning when all messages are loaded */}
|
||||
{allMessagesLoaded && (
|
||||
<div className="text-center text-amber-600 dark:text-amber-400 text-xs py-1.5 bg-amber-50 dark:bg-amber-900/20 border-b border-amber-200 dark:border-amber-800">
|
||||
{t('session.messages.perfWarning')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Legacy message count indicator (for non-paginated view) */}
|
||||
{!hasMoreMessages && chatMessages.length > visibleMessageCount && (
|
||||
<div className="text-center text-gray-500 dark:text-gray-400 text-sm py-2 border-b border-gray-200 dark:border-gray-700">
|
||||
{t('session.messages.showingLast', { count: visibleMessageCount, total: chatMessages.length })} |
|
||||
<button className="ml-1 text-blue-600 hover:text-blue-700 underline" onClick={loadEarlierMessages}>
|
||||
{t('session.messages.loadEarlier')}
|
||||
</button>
|
||||
{' | '}
|
||||
<button
|
||||
className="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 underline"
|
||||
onClick={loadAllMessages}
|
||||
>
|
||||
{t('session.messages.loadAll')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{visibleMessages.map((message, index) => {
|
||||
const prevMessage = index > 0 ? visibleMessages[index - 1] : null;
|
||||
return (
|
||||
<MessageComponent
|
||||
key={getMessageKey(message)}
|
||||
message={message}
|
||||
index={index}
|
||||
prevMessage={prevMessage}
|
||||
createDiff={createDiff}
|
||||
onFileOpen={onFileOpen}
|
||||
onShowSettings={onShowSettings}
|
||||
onGrantToolPermission={onGrantToolPermission}
|
||||
autoExpandTools={autoExpandTools}
|
||||
showRawParameters={showRawParameters}
|
||||
showThinking={showThinking}
|
||||
selectedProject={selectedProject}
|
||||
provider={provider}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
{isLoading && <AssistantThinkingIndicator selectedProvider={provider} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
50
src/components/chat/view/subcomponents/ImageAttachment.tsx
Normal file
50
src/components/chat/view/subcomponents/ImageAttachment.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface ImageAttachmentProps {
|
||||
file: File;
|
||||
onRemove: () => void;
|
||||
uploadProgress?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const ImageAttachment = ({ file, onRemove, uploadProgress, error }: ImageAttachmentProps) => {
|
||||
const [preview, setPreview] = useState<string | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
const url = URL.createObjectURL(file);
|
||||
setPreview(url);
|
||||
return () => URL.revokeObjectURL(url);
|
||||
}, [file]);
|
||||
|
||||
return (
|
||||
<div className="relative group">
|
||||
<img src={preview} alt={file.name} className="w-20 h-20 object-cover rounded" />
|
||||
{uploadProgress !== undefined && uploadProgress < 100 && (
|
||||
<div className="absolute inset-0 bg-black/50 flex items-center justify-center">
|
||||
<div className="text-white text-xs">{uploadProgress}%</div>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="absolute inset-0 bg-red-500/50 flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full p-1 opacity-100 sm:opacity-0 sm:group-hover:opacity-100 focus:opacity-100 transition-opacity"
|
||||
aria-label="Remove image"
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageAttachment;
|
||||
|
||||
|
||||
188
src/components/chat/view/subcomponents/Markdown.tsx
Normal file
188
src/components/chat/view/subcomponents/Markdown.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import remarkMath from 'remark-math';
|
||||
import rehypeKatex from 'rehype-katex';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { normalizeInlineCodeFences } from '../../utils/chatFormatting';
|
||||
|
||||
type MarkdownProps = {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
type CodeBlockProps = {
|
||||
node?: any;
|
||||
inline?: boolean;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
const CodeBlock = ({ node, inline, className, children, ...props }: CodeBlockProps) => {
|
||||
const { t } = useTranslation('chat');
|
||||
const [copied, setCopied] = useState(false);
|
||||
const raw = Array.isArray(children) ? children.join('') : String(children ?? '');
|
||||
const looksMultiline = /[\r\n]/.test(raw);
|
||||
const inlineDetected = inline || (node && node.type === 'inlineCode');
|
||||
const shouldInline = inlineDetected || !looksMultiline;
|
||||
|
||||
if (shouldInline) {
|
||||
return (
|
||||
<code
|
||||
className={`font-mono text-[0.9em] px-1.5 py-0.5 rounded-md bg-gray-100 text-gray-900 border border-gray-200 dark:bg-gray-800/60 dark:text-gray-100 dark:border-gray-700 whitespace-pre-wrap break-words ${
|
||||
className || ''
|
||||
}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
const language = match ? match[1] : 'text';
|
||||
const textToCopy = raw;
|
||||
|
||||
const handleCopy = () => {
|
||||
const doSet = () => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
};
|
||||
try {
|
||||
if (navigator && navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(textToCopy).then(doSet).catch(() => {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = textToCopy;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.opacity = '0';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
} catch {}
|
||||
document.body.removeChild(ta);
|
||||
doSet();
|
||||
});
|
||||
} else {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = textToCopy;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.opacity = '0';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
} catch {}
|
||||
document.body.removeChild(ta);
|
||||
doSet();
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative group my-2">
|
||||
{language && language !== 'text' && (
|
||||
<div className="absolute top-2 left-3 z-10 text-xs text-gray-400 font-medium uppercase">{language}</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="absolute top-2 right-2 z-10 opacity-0 group-hover:opacity-100 focus:opacity-100 active:opacity-100 transition-opacity text-xs px-2 py-1 rounded-md bg-gray-700/80 hover:bg-gray-700 text-white border border-gray-600"
|
||||
title={copied ? t('codeBlock.copied') : t('codeBlock.copyCode')}
|
||||
aria-label={copied ? t('codeBlock.copied') : t('codeBlock.copyCode')}
|
||||
>
|
||||
{copied ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<svg className="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{t('codeBlock.copied')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1">
|
||||
<svg
|
||||
className="w-3.5 h-3.5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
||||
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"></path>
|
||||
</svg>
|
||||
{t('codeBlock.copy')}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<SyntaxHighlighter
|
||||
language={language}
|
||||
style={oneDark}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
borderRadius: '0.5rem',
|
||||
fontSize: '0.875rem',
|
||||
padding: language && language !== 'text' ? '2rem 1rem 1rem 1rem' : '1rem',
|
||||
}}
|
||||
codeTagProps={{
|
||||
style: {
|
||||
fontFamily:
|
||||
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{raw}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const markdownComponents = {
|
||||
code: CodeBlock,
|
||||
blockquote: ({ children }: { children?: React.ReactNode }) => (
|
||||
<blockquote className="border-l-4 border-gray-300 dark:border-gray-600 pl-4 italic text-gray-600 dark:text-gray-400 my-2">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
a: ({ href, children }: { href?: string; children?: React.ReactNode }) => (
|
||||
<a href={href} className="text-blue-600 dark:text-blue-400 hover:underline" target="_blank" rel="noopener noreferrer">
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
p: ({ children }: { children?: React.ReactNode }) => <div className="mb-2 last:mb-0">{children}</div>,
|
||||
table: ({ children }: { children?: React.ReactNode }) => (
|
||||
<div className="overflow-x-auto my-2">
|
||||
<table className="min-w-full border-collapse border border-gray-200 dark:border-gray-700">{children}</table>
|
||||
</div>
|
||||
),
|
||||
thead: ({ children }: { children?: React.ReactNode }) => <thead className="bg-gray-50 dark:bg-gray-800">{children}</thead>,
|
||||
th: ({ children }: { children?: React.ReactNode }) => (
|
||||
<th className="px-3 py-2 text-left text-sm font-semibold border border-gray-200 dark:border-gray-700">{children}</th>
|
||||
),
|
||||
td: ({ children }: { children?: React.ReactNode }) => (
|
||||
<td className="px-3 py-2 align-top text-sm border border-gray-200 dark:border-gray-700">{children}</td>
|
||||
),
|
||||
};
|
||||
|
||||
export function Markdown({ children, className }: MarkdownProps) {
|
||||
const content = normalizeInlineCodeFences(String(children ?? ''));
|
||||
const remarkPlugins = useMemo(() => [remarkGfm, remarkMath], []);
|
||||
const rehypePlugins = useMemo(() => [rehypeKatex], []);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<ReactMarkdown remarkPlugins={remarkPlugins} rehypePlugins={rehypePlugins} components={markdownComponents as any}>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
456
src/components/chat/view/subcomponents/MessageComponent.tsx
Normal file
456
src/components/chat/view/subcomponents/MessageComponent.tsx
Normal file
@@ -0,0 +1,456 @@
|
||||
import React, { memo, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import SessionProviderLogo from '../../../SessionProviderLogo';
|
||||
import type {
|
||||
ChatMessage,
|
||||
ClaudePermissionSuggestion,
|
||||
PermissionGrantResult,
|
||||
Provider,
|
||||
} from '../../types/types';
|
||||
import { Markdown } from './Markdown';
|
||||
import { formatUsageLimitText } from '../../utils/chatFormatting';
|
||||
import { getClaudePermissionSuggestion } from '../../utils/chatPermissions';
|
||||
import type { Project } from '../../../../types/app';
|
||||
import { ToolRenderer, shouldHideToolResult } from '../../tools';
|
||||
|
||||
type DiffLine = {
|
||||
type: string;
|
||||
content: string;
|
||||
lineNum: number;
|
||||
};
|
||||
|
||||
interface MessageComponentProps {
|
||||
message: ChatMessage;
|
||||
index: number;
|
||||
prevMessage: ChatMessage | null;
|
||||
createDiff: (oldStr: string, newStr: string) => DiffLine[];
|
||||
onFileOpen?: (filePath: string, diffInfo?: unknown) => void;
|
||||
onShowSettings?: () => void;
|
||||
onGrantToolPermission?: (suggestion: ClaudePermissionSuggestion) => PermissionGrantResult | null | undefined;
|
||||
autoExpandTools?: boolean;
|
||||
showRawParameters?: boolean;
|
||||
showThinking?: boolean;
|
||||
selectedProject?: Project | null;
|
||||
provider: Provider | string;
|
||||
}
|
||||
|
||||
type InteractiveOption = {
|
||||
number: string;
|
||||
text: string;
|
||||
isSelected: boolean;
|
||||
};
|
||||
|
||||
type PermissionGrantState = 'idle' | 'granted' | 'error';
|
||||
|
||||
const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFileOpen, onShowSettings, onGrantToolPermission, autoExpandTools, showRawParameters, showThinking, selectedProject, provider }: MessageComponentProps) => {
|
||||
const { t } = useTranslation('chat');
|
||||
const isGrouped = prevMessage && prevMessage.type === message.type &&
|
||||
((prevMessage.type === 'assistant') ||
|
||||
(prevMessage.type === 'user') ||
|
||||
(prevMessage.type === 'tool') ||
|
||||
(prevMessage.type === 'error'));
|
||||
const messageRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||
const permissionSuggestion = getClaudePermissionSuggestion(message, provider);
|
||||
const [permissionGrantState, setPermissionGrantState] = React.useState<PermissionGrantState>('idle');
|
||||
|
||||
|
||||
React.useEffect(() => {
|
||||
setPermissionGrantState('idle');
|
||||
}, [permissionSuggestion?.entry, message.toolId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const node = messageRef.current;
|
||||
if (!autoExpandTools || !node || !message.isToolUse) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting && !isExpanded) {
|
||||
setIsExpanded(true);
|
||||
const details = node.querySelectorAll<HTMLDetailsElement>('details');
|
||||
details.forEach((detail) => {
|
||||
detail.open = true;
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
{ threshold: 0.1 }
|
||||
);
|
||||
|
||||
observer.observe(node);
|
||||
|
||||
return () => {
|
||||
observer.unobserve(node);
|
||||
};
|
||||
}, [autoExpandTools, isExpanded, message.isToolUse]);
|
||||
|
||||
const formattedTime = useMemo(() => new Date(message.timestamp).toLocaleTimeString(), [message.timestamp]);
|
||||
const shouldHideThinkingMessage = Boolean(message.isThinking && !showThinking);
|
||||
|
||||
if (shouldHideThinkingMessage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={messageRef}
|
||||
className={`chat-message ${message.type} ${isGrouped ? 'grouped' : ''} ${message.type === 'user' ? 'flex justify-end px-3 sm:px-0' : 'px-3 sm:px-0'}`}
|
||||
>
|
||||
{message.type === 'user' ? (
|
||||
/* User message bubble on the right */
|
||||
<div className="flex items-end space-x-0 sm:space-x-3 w-full sm:w-auto sm:max-w-[85%] md:max-w-md lg:max-w-lg xl:max-w-xl">
|
||||
<div className="bg-blue-600 text-white rounded-2xl rounded-br-md px-3 sm:px-4 py-2 shadow-sm flex-1 sm:flex-initial">
|
||||
<div className="text-sm whitespace-pre-wrap break-words">
|
||||
{message.content}
|
||||
</div>
|
||||
{message.images && message.images.length > 0 && (
|
||||
<div className="mt-2 grid grid-cols-2 gap-2">
|
||||
{message.images.map((img, idx) => (
|
||||
<img
|
||||
key={img.name || idx}
|
||||
src={img.data}
|
||||
alt={img.name}
|
||||
className="rounded-lg max-w-full h-auto cursor-pointer hover:opacity-90 transition-opacity"
|
||||
onClick={() => window.open(img.data, '_blank')}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-blue-100 mt-1 text-right">
|
||||
{formattedTime}
|
||||
</div>
|
||||
</div>
|
||||
{!isGrouped && (
|
||||
<div className="hidden sm:flex w-8 h-8 bg-blue-600 rounded-full items-center justify-center text-white text-sm flex-shrink-0">
|
||||
U
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : message.isTaskNotification ? (
|
||||
/* Compact task notification on the left */
|
||||
<div className="w-full">
|
||||
<div className="flex items-center gap-2 py-0.5">
|
||||
<span className={`inline-block w-1.5 h-1.5 rounded-full flex-shrink-0 ${message.taskStatus === 'completed' ? 'bg-green-400 dark:bg-green-500' : 'bg-amber-400 dark:bg-amber-500'}`} />
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">{message.content}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Claude/Error/Tool messages on the left */
|
||||
<div className="w-full">
|
||||
{!isGrouped && (
|
||||
<div className="flex items-center space-x-3 mb-2">
|
||||
{message.type === 'error' ? (
|
||||
<div className="w-8 h-8 bg-red-600 rounded-full flex items-center justify-center text-white text-sm flex-shrink-0">
|
||||
!
|
||||
</div>
|
||||
) : message.type === 'tool' ? (
|
||||
<div className="w-8 h-8 bg-gray-600 dark:bg-gray-700 rounded-full flex items-center justify-center text-white text-sm flex-shrink-0">
|
||||
🔧
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-full flex items-center justify-center text-white text-sm flex-shrink-0 p-1">
|
||||
<SessionProviderLogo provider={provider} className="w-full h-full" />
|
||||
</div>
|
||||
)}
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{message.type === 'error' ? t('messageTypes.error') : message.type === 'tool' ? t('messageTypes.tool') : (provider === 'cursor' ? t('messageTypes.cursor') : provider === 'codex' ? t('messageTypes.codex') : t('messageTypes.claude'))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="w-full">
|
||||
|
||||
{message.isToolUse ? (
|
||||
<>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-col">
|
||||
<Markdown className="prose prose-sm max-w-none dark:prose-invert">
|
||||
{String(message.displayText || '')}
|
||||
</Markdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{message.toolInput && (
|
||||
<ToolRenderer
|
||||
toolName={message.toolName || 'UnknownTool'}
|
||||
toolInput={message.toolInput}
|
||||
toolResult={message.toolResult}
|
||||
toolId={message.toolId}
|
||||
mode="input"
|
||||
onFileOpen={onFileOpen}
|
||||
createDiff={createDiff}
|
||||
selectedProject={selectedProject}
|
||||
autoExpandTools={autoExpandTools}
|
||||
showRawParameters={showRawParameters}
|
||||
rawToolInput={typeof message.toolInput === 'string' ? message.toolInput : undefined}
|
||||
isSubagentContainer={message.isSubagentContainer}
|
||||
subagentState={message.subagentState}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Tool Result Section */}
|
||||
{message.toolResult && !shouldHideToolResult(message.toolName || 'UnknownTool', message.toolResult) && (
|
||||
message.toolResult.isError ? (
|
||||
// Error results - red error box with content
|
||||
<div
|
||||
id={`tool-result-${message.toolId}`}
|
||||
className="relative mt-2 p-3 rounded border scroll-mt-4 bg-red-50/50 dark:bg-red-950/10 border-red-200/60 dark:border-red-800/40"
|
||||
>
|
||||
<div className="relative flex items-center gap-1.5 mb-2">
|
||||
<svg className="w-4 h-4 text-red-500 dark:text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
<span className="text-xs font-medium text-red-700 dark:text-red-300">{t('messageTypes.error')}</span>
|
||||
</div>
|
||||
<div className="relative text-sm text-red-900 dark:text-red-100">
|
||||
<Markdown className="prose prose-sm max-w-none prose-red dark:prose-invert">
|
||||
{String(message.toolResult.content || '')}
|
||||
</Markdown>
|
||||
{permissionSuggestion && (
|
||||
<div className="mt-4 border-t border-red-200/60 dark:border-red-800/60 pt-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!onGrantToolPermission) return;
|
||||
const result = onGrantToolPermission(permissionSuggestion);
|
||||
if (result?.success) {
|
||||
setPermissionGrantState('granted');
|
||||
} else {
|
||||
setPermissionGrantState('error');
|
||||
}
|
||||
}}
|
||||
disabled={permissionSuggestion.isAllowed || permissionGrantState === 'granted'}
|
||||
className={`inline-flex items-center gap-2 px-3 py-1.5 rounded-md text-xs font-medium border transition-colors ${
|
||||
permissionSuggestion.isAllowed || permissionGrantState === 'granted'
|
||||
? 'bg-green-100 dark:bg-green-900/30 border-green-300/70 dark:border-green-800/60 text-green-800 dark:text-green-200 cursor-default'
|
||||
: 'bg-white/80 dark:bg-gray-900/40 border-red-300/70 dark:border-red-800/60 text-red-700 dark:text-red-200 hover:bg-white dark:hover:bg-gray-900/70'
|
||||
}`}
|
||||
>
|
||||
{permissionSuggestion.isAllowed || permissionGrantState === 'granted'
|
||||
? t('permissions.added')
|
||||
: t('permissions.grant', { tool: permissionSuggestion.toolName })}
|
||||
</button>
|
||||
{onShowSettings && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); onShowSettings(); }}
|
||||
className="text-xs text-red-700 dark:text-red-200 underline hover:text-red-800 dark:hover:text-red-100"
|
||||
>
|
||||
{t('permissions.openSettings')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-red-700/90 dark:text-red-200/80">
|
||||
{t('permissions.addTo', { entry: permissionSuggestion.entry })}
|
||||
</div>
|
||||
{permissionGrantState === 'error' && (
|
||||
<div className="mt-2 text-xs text-red-700 dark:text-red-200">
|
||||
{t('permissions.error')}
|
||||
</div>
|
||||
)}
|
||||
{(permissionSuggestion.isAllowed || permissionGrantState === 'granted') && (
|
||||
<div className="mt-2 text-xs text-green-700 dark:text-green-200">
|
||||
{t('permissions.retry')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// Non-error results - route through ToolRenderer (single source of truth)
|
||||
<div id={`tool-result-${message.toolId}`} className="scroll-mt-4">
|
||||
<ToolRenderer
|
||||
toolName={message.toolName || 'UnknownTool'}
|
||||
toolInput={message.toolInput}
|
||||
toolResult={message.toolResult}
|
||||
toolId={message.toolId}
|
||||
mode="result"
|
||||
onFileOpen={onFileOpen}
|
||||
createDiff={createDiff}
|
||||
selectedProject={selectedProject}
|
||||
autoExpandTools={autoExpandTools}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
) : message.isInteractivePrompt ? (
|
||||
// Special handling for interactive prompts
|
||||
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-8 h-8 bg-amber-500 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<svg className="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="font-semibold text-amber-900 dark:text-amber-100 text-base mb-3">
|
||||
{t('interactive.title')}
|
||||
</h4>
|
||||
{(() => {
|
||||
const lines = (message.content || '').split('\n').filter((line) => line.trim());
|
||||
const questionLine = lines.find((line) => line.includes('?')) || lines[0] || '';
|
||||
const options: InteractiveOption[] = [];
|
||||
|
||||
// Parse the menu options
|
||||
lines.forEach((line) => {
|
||||
// Match lines like "❯ 1. Yes" or " 2. No"
|
||||
const optionMatch = line.match(/[❯\s]*(\d+)\.\s+(.+)/);
|
||||
if (optionMatch) {
|
||||
const isSelected = line.includes('❯');
|
||||
options.push({
|
||||
number: optionMatch[1],
|
||||
text: optionMatch[2].trim(),
|
||||
isSelected
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className="text-sm text-amber-800 dark:text-amber-200 mb-4">
|
||||
{questionLine}
|
||||
</p>
|
||||
|
||||
{/* Option buttons */}
|
||||
<div className="space-y-2 mb-4">
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.number}
|
||||
className={`w-full text-left px-4 py-3 rounded-lg border-2 transition-all ${
|
||||
option.isSelected
|
||||
? 'bg-amber-600 dark:bg-amber-700 text-white border-amber-600 dark:border-amber-700 shadow-md'
|
||||
: 'bg-white dark:bg-gray-800 text-amber-900 dark:text-amber-100 border-amber-300 dark:border-amber-700'
|
||||
} cursor-not-allowed opacity-75`}
|
||||
disabled
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold ${
|
||||
option.isSelected
|
||||
? 'bg-white/20'
|
||||
: 'bg-amber-100 dark:bg-amber-800/50'
|
||||
}`}>
|
||||
{option.number}
|
||||
</span>
|
||||
<span className="text-sm sm:text-base font-medium flex-1">
|
||||
{option.text}
|
||||
</span>
|
||||
{option.isSelected && (
|
||||
<span className="text-lg">❯</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="bg-amber-100 dark:bg-amber-800/30 rounded-lg p-3">
|
||||
<p className="text-amber-900 dark:text-amber-100 text-sm font-medium mb-1">
|
||||
{t('interactive.waiting')}
|
||||
</p>
|
||||
<p className="text-amber-800 dark:text-amber-200 text-xs">
|
||||
{t('interactive.instruction')}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : message.isThinking ? (
|
||||
/* Thinking messages - collapsible by default */
|
||||
<div className="text-sm text-gray-700 dark:text-gray-300">
|
||||
<details className="group">
|
||||
<summary className="cursor-pointer text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 font-medium flex items-center gap-2">
|
||||
<svg className="w-3 h-3 transition-transform group-open:rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<span>{t('thinking.emoji')}</span>
|
||||
</summary>
|
||||
<div className="mt-2 pl-4 border-l-2 border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400 text-sm">
|
||||
<Markdown className="prose prose-sm max-w-none dark:prose-invert prose-gray">
|
||||
{message.content}
|
||||
</Markdown>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-gray-700 dark:text-gray-300">
|
||||
{/* Thinking accordion for reasoning */}
|
||||
{showThinking && message.reasoning && (
|
||||
<details className="mb-3">
|
||||
<summary className="cursor-pointer text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 font-medium">
|
||||
{t('thinking.emoji')}
|
||||
</summary>
|
||||
<div className="mt-2 pl-4 border-l-2 border-gray-300 dark:border-gray-600 italic text-gray-600 dark:text-gray-400 text-sm">
|
||||
<div className="whitespace-pre-wrap">
|
||||
{message.reasoning}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{(() => {
|
||||
const content = formatUsageLimitText(String(message.content || ''));
|
||||
|
||||
// Detect if content is pure JSON (starts with { or [)
|
||||
const trimmedContent = content.trim();
|
||||
if ((trimmedContent.startsWith('{') || trimmedContent.startsWith('[')) &&
|
||||
(trimmedContent.endsWith('}') || trimmedContent.endsWith(']'))) {
|
||||
try {
|
||||
const parsed = JSON.parse(trimmedContent);
|
||||
const formatted = JSON.stringify(parsed, null, 2);
|
||||
|
||||
return (
|
||||
<div className="my-2">
|
||||
<div className="flex items-center gap-2 mb-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span className="font-medium">{t('json.response')}</span>
|
||||
</div>
|
||||
<div className="bg-gray-800 dark:bg-gray-900 border border-gray-600/30 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
<pre className="p-4 overflow-x-auto">
|
||||
<code className="text-gray-100 dark:text-gray-200 text-sm font-mono block whitespace-pre">
|
||||
{formatted}
|
||||
</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} catch {
|
||||
// Not valid JSON, fall through to normal rendering
|
||||
}
|
||||
}
|
||||
|
||||
// Normal rendering for non-JSON content
|
||||
return message.type === 'assistant' ? (
|
||||
<Markdown className="prose prose-sm max-w-none dark:prose-invert prose-gray">
|
||||
{content}
|
||||
</Markdown>
|
||||
) : (
|
||||
<div className="whitespace-pre-wrap">
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isGrouped && (
|
||||
<div className="text-[11px] text-gray-400 dark:text-gray-500 mt-1">
|
||||
{formattedTime}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default MessageComponent;
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import React from 'react';
|
||||
import type { PendingPermissionRequest } from '../../types/types';
|
||||
import { buildClaudeToolPermissionEntry, formatToolInputForDisplay } from '../../utils/chatPermissions';
|
||||
import { getClaudeSettings } from '../../utils/chatStorage';
|
||||
import { getPermissionPanel, registerPermissionPanel } from '../../tools/configs/permissionPanelRegistry';
|
||||
import { AskUserQuestionPanel } from '../../tools/components/InteractiveRenderers';
|
||||
|
||||
registerPermissionPanel('AskUserQuestion', AskUserQuestionPanel);
|
||||
|
||||
interface PermissionRequestsBannerProps {
|
||||
pendingPermissionRequests: PendingPermissionRequest[];
|
||||
handlePermissionDecision: (
|
||||
requestIds: string | string[],
|
||||
decision: { allow?: boolean; message?: string; rememberEntry?: string | null; updatedInput?: unknown },
|
||||
) => void;
|
||||
handleGrantToolPermission: (suggestion: { entry: string; toolName: string }) => { success: boolean };
|
||||
}
|
||||
|
||||
export default function PermissionRequestsBanner({
|
||||
pendingPermissionRequests,
|
||||
handlePermissionDecision,
|
||||
handleGrantToolPermission,
|
||||
}: PermissionRequestsBannerProps) {
|
||||
if (!pendingPermissionRequests.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-3 space-y-2">
|
||||
{pendingPermissionRequests.map((request) => {
|
||||
const CustomPanel = getPermissionPanel(request.toolName);
|
||||
if (CustomPanel) {
|
||||
return (
|
||||
<CustomPanel
|
||||
key={request.requestId}
|
||||
request={request}
|
||||
onDecision={handlePermissionDecision}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const rawInput = formatToolInputForDisplay(request.input);
|
||||
const permissionEntry = buildClaudeToolPermissionEntry(request.toolName, rawInput);
|
||||
const settings = getClaudeSettings();
|
||||
const alreadyAllowed = permissionEntry ? settings.allowedTools.includes(permissionEntry) : false;
|
||||
const rememberLabel = alreadyAllowed ? 'Allow (saved)' : 'Allow & remember';
|
||||
const matchingRequestIds = permissionEntry
|
||||
? pendingPermissionRequests
|
||||
.filter(
|
||||
(item) =>
|
||||
buildClaudeToolPermissionEntry(item.toolName, formatToolInputForDisplay(item.input)) === permissionEntry,
|
||||
)
|
||||
.map((item) => item.requestId)
|
||||
: [request.requestId];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={request.requestId}
|
||||
className="rounded-lg border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20 p-3 shadow-sm"
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-amber-900 dark:text-amber-100">Permission required</div>
|
||||
<div className="text-xs text-amber-800 dark:text-amber-200">
|
||||
Tool: <span className="font-mono">{request.toolName}</span>
|
||||
</div>
|
||||
</div>
|
||||
{permissionEntry && (
|
||||
<div className="text-xs text-amber-700 dark:text-amber-300">
|
||||
Allow rule: <span className="font-mono">{permissionEntry}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{rawInput && (
|
||||
<details className="mt-2">
|
||||
<summary className="cursor-pointer text-xs text-amber-800 dark:text-amber-200 hover:text-amber-900 dark:hover:text-amber-100">
|
||||
View tool input
|
||||
</summary>
|
||||
<pre className="mt-2 max-h-40 overflow-auto rounded-md bg-white/80 dark:bg-gray-900/60 border border-amber-200/60 dark:border-amber-800/60 p-2 text-xs text-amber-900 dark:text-amber-100 whitespace-pre-wrap">
|
||||
{rawInput}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handlePermissionDecision(request.requestId, { allow: true })}
|
||||
className="inline-flex items-center gap-2 rounded-md bg-amber-600 text-white text-xs font-medium px-3 py-1.5 hover:bg-amber-700 transition-colors"
|
||||
>
|
||||
Allow once
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (permissionEntry && !alreadyAllowed) {
|
||||
handleGrantToolPermission({ entry: permissionEntry, toolName: request.toolName });
|
||||
}
|
||||
handlePermissionDecision(matchingRequestIds, { allow: true, rememberEntry: permissionEntry });
|
||||
}}
|
||||
className={`inline-flex items-center gap-2 rounded-md text-xs font-medium px-3 py-1.5 border transition-colors ${
|
||||
permissionEntry
|
||||
? 'border-amber-300 text-amber-800 hover:bg-amber-100 dark:border-amber-700 dark:text-amber-100 dark:hover:bg-amber-900/30'
|
||||
: 'border-gray-300 text-gray-400 cursor-not-allowed'
|
||||
}`}
|
||||
disabled={!permissionEntry}
|
||||
>
|
||||
{rememberLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handlePermissionDecision(request.requestId, { allow: false, message: 'User denied tool use' })}
|
||||
className="inline-flex items-center gap-2 rounded-md text-xs font-medium px-3 py-1.5 border border-red-300 text-red-700 hover:bg-red-50 dark:border-red-800 dark:text-red-200 dark:hover:bg-red-900/30 transition-colors"
|
||||
>
|
||||
Deny
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import React from 'react';
|
||||
import { Check, ChevronDown } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import SessionProviderLogo from '../../../SessionProviderLogo';
|
||||
import NextTaskBanner from '../../../NextTaskBanner.jsx';
|
||||
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../../../../shared/modelConstants';
|
||||
import type { ProjectSession, SessionProvider } from '../../../../types/app';
|
||||
|
||||
interface ProviderSelectionEmptyStateProps {
|
||||
selectedSession: ProjectSession | null;
|
||||
currentSessionId: string | null;
|
||||
provider: SessionProvider;
|
||||
setProvider: (next: SessionProvider) => void;
|
||||
textareaRef: React.RefObject<HTMLTextAreaElement>;
|
||||
claudeModel: string;
|
||||
setClaudeModel: (model: string) => void;
|
||||
cursorModel: string;
|
||||
setCursorModel: (model: string) => void;
|
||||
codexModel: string;
|
||||
setCodexModel: (model: string) => void;
|
||||
tasksEnabled: boolean;
|
||||
isTaskMasterInstalled: boolean | null;
|
||||
onShowAllTasks?: (() => void) | null;
|
||||
setInput: React.Dispatch<React.SetStateAction<string>>;
|
||||
}
|
||||
|
||||
type ProviderDef = {
|
||||
id: SessionProvider;
|
||||
name: string;
|
||||
infoKey: string;
|
||||
accent: string;
|
||||
ring: string;
|
||||
check: string;
|
||||
};
|
||||
|
||||
const PROVIDERS: ProviderDef[] = [
|
||||
{
|
||||
id: 'claude',
|
||||
name: 'Claude Code',
|
||||
infoKey: 'providerSelection.providerInfo.anthropic',
|
||||
accent: 'border-primary',
|
||||
ring: 'ring-primary/15',
|
||||
check: 'bg-primary text-primary-foreground',
|
||||
},
|
||||
{
|
||||
id: 'cursor',
|
||||
name: 'Cursor',
|
||||
infoKey: 'providerSelection.providerInfo.cursorEditor',
|
||||
accent: 'border-violet-500 dark:border-violet-400',
|
||||
ring: 'ring-violet-500/15',
|
||||
check: 'bg-violet-500 text-white',
|
||||
},
|
||||
{
|
||||
id: 'codex',
|
||||
name: 'Codex',
|
||||
infoKey: 'providerSelection.providerInfo.openai',
|
||||
accent: 'border-emerald-600 dark:border-emerald-400',
|
||||
ring: 'ring-emerald-600/15',
|
||||
check: 'bg-emerald-600 dark:bg-emerald-500 text-white',
|
||||
},
|
||||
];
|
||||
|
||||
function getModelConfig(p: SessionProvider) {
|
||||
if (p === 'claude') return CLAUDE_MODELS;
|
||||
if (p === 'codex') return CODEX_MODELS;
|
||||
return CURSOR_MODELS;
|
||||
}
|
||||
|
||||
function getModelValue(p: SessionProvider, c: string, cu: string, co: string) {
|
||||
if (p === 'claude') return c;
|
||||
if (p === 'codex') return co;
|
||||
return cu;
|
||||
}
|
||||
|
||||
export default function ProviderSelectionEmptyState({
|
||||
selectedSession,
|
||||
currentSessionId,
|
||||
provider,
|
||||
setProvider,
|
||||
textareaRef,
|
||||
claudeModel,
|
||||
setClaudeModel,
|
||||
cursorModel,
|
||||
setCursorModel,
|
||||
codexModel,
|
||||
setCodexModel,
|
||||
tasksEnabled,
|
||||
isTaskMasterInstalled,
|
||||
onShowAllTasks,
|
||||
setInput,
|
||||
}: ProviderSelectionEmptyStateProps) {
|
||||
const { t } = useTranslation('chat');
|
||||
const nextTaskPrompt = t('tasks.nextTaskPrompt', { defaultValue: 'Start the next task' });
|
||||
|
||||
const selectProvider = (next: SessionProvider) => {
|
||||
setProvider(next);
|
||||
localStorage.setItem('selected-provider', next);
|
||||
setTimeout(() => textareaRef.current?.focus(), 100);
|
||||
};
|
||||
|
||||
const handleModelChange = (value: string) => {
|
||||
if (provider === 'claude') { setClaudeModel(value); localStorage.setItem('claude-model', value); }
|
||||
else if (provider === 'codex') { setCodexModel(value); localStorage.setItem('codex-model', value); }
|
||||
else { setCursorModel(value); localStorage.setItem('cursor-model', value); }
|
||||
};
|
||||
|
||||
const modelConfig = getModelConfig(provider);
|
||||
const currentModel = getModelValue(provider, claudeModel, cursorModel, codexModel);
|
||||
|
||||
/* ── New session — provider picker ── */
|
||||
if (!selectedSession && !currentSessionId) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full px-4">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Heading */}
|
||||
<div className="text-center mb-8">
|
||||
<h2 className="text-lg sm:text-xl font-semibold text-foreground tracking-tight">
|
||||
{t('providerSelection.title')}
|
||||
</h2>
|
||||
<p className="text-[13px] text-muted-foreground mt-1">
|
||||
{t('providerSelection.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Provider cards — horizontal row, equal width */}
|
||||
<div className="grid grid-cols-3 gap-2 sm:gap-2.5 mb-6">
|
||||
{PROVIDERS.map((p) => {
|
||||
const active = provider === p.id;
|
||||
return (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => selectProvider(p.id)}
|
||||
className={`
|
||||
relative flex flex-col items-center gap-2.5 pt-5 pb-4 px-2
|
||||
rounded-xl border-[1.5px] transition-all duration-150
|
||||
active:scale-[0.97]
|
||||
${active
|
||||
? `${p.accent} ${p.ring} ring-2 bg-card shadow-sm`
|
||||
: 'border-border bg-card/60 hover:bg-card hover:border-border/80'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<SessionProviderLogo
|
||||
provider={p.id}
|
||||
className={`w-9 h-9 transition-transform duration-150 ${active ? 'scale-110' : ''}`}
|
||||
/>
|
||||
<div className="text-center">
|
||||
<p className="text-[13px] font-semibold text-foreground leading-none">{p.name}</p>
|
||||
<p className="text-[10px] text-muted-foreground mt-1 leading-tight">{t(p.infoKey)}</p>
|
||||
</div>
|
||||
{/* Check badge */}
|
||||
{active && (
|
||||
<div className={`absolute -top-1 -right-1 w-[18px] h-[18px] rounded-full ${p.check} flex items-center justify-center shadow-sm`}>
|
||||
<Check className="w-2.5 h-2.5" strokeWidth={3} />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Model picker — appears after provider is chosen */}
|
||||
<div className={`transition-all duration-200 ${provider ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-1 pointer-events-none'}`}>
|
||||
<div className="flex items-center justify-center gap-2 mb-5">
|
||||
<span className="text-sm text-muted-foreground">{t('providerSelection.selectModel')}</span>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={currentModel}
|
||||
onChange={(e) => handleModelChange(e.target.value)}
|
||||
tabIndex={-1}
|
||||
className="appearance-none pl-3 pr-7 py-1.5 text-sm font-medium bg-muted/50 border border-border/60 rounded-lg text-foreground cursor-pointer hover:bg-muted transition-colors focus:outline-none focus:ring-2 focus:ring-primary/20"
|
||||
>
|
||||
{modelConfig.OPTIONS.map(({ value, label }: { value: string; label: string }) => (
|
||||
<option key={value} value={value}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 w-3 h-3 text-muted-foreground pointer-events-none" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-sm text-muted-foreground/70">
|
||||
{provider === 'claude'
|
||||
? t('providerSelection.readyPrompt.claude', { model: claudeModel })
|
||||
: provider === 'cursor'
|
||||
? t('providerSelection.readyPrompt.cursor', { model: cursorModel })
|
||||
: provider === 'codex'
|
||||
? t('providerSelection.readyPrompt.codex', { model: codexModel })
|
||||
: t('providerSelection.readyPrompt.default')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Task banner */}
|
||||
{provider && tasksEnabled && isTaskMasterInstalled && (
|
||||
<div className="mt-5">
|
||||
<NextTaskBanner onStartTask={() => setInput(nextTaskPrompt)} onShowAllTasks={onShowAllTasks} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Existing session — continue prompt ── */
|
||||
if (selectedSession) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center px-6 max-w-md">
|
||||
<p className="text-lg font-semibold text-foreground mb-1.5">{t('session.continue.title')}</p>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">{t('session.continue.description')}</p>
|
||||
|
||||
{tasksEnabled && isTaskMasterInstalled && (
|
||||
<div className="mt-5">
|
||||
<NextTaskBanner onStartTask={() => setInput(nextTaskPrompt)} onShowAllTasks={onShowAllTasks} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,55 +1,21 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Brain, Zap, Sparkles, Atom, X } from 'lucide-react';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Brain, 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'
|
||||
}
|
||||
];
|
||||
import { thinkingModes } from '../../constants/thinkingModes';
|
||||
|
||||
function ThinkingModeSelector({ selectedMode, onModeChange, onClose, className = '' }) {
|
||||
type ThinkingModeSelectorProps = {
|
||||
selectedMode: string;
|
||||
onModeChange: (modeId: string) => void;
|
||||
onClose?: () => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function ThinkingModeSelector({ selectedMode, onModeChange, onClose, className = '' }: ThinkingModeSelectorProps) {
|
||||
const { t } = useTranslation('chat');
|
||||
|
||||
// Mapping from mode ID to translation key
|
||||
const modeKeyMap = {
|
||||
const modeKeyMap: Record<string, string> = {
|
||||
'think-hard': 'thinkHard',
|
||||
'think-harder': 'thinkHarder'
|
||||
};
|
||||
@@ -65,11 +31,11 @@ function ThinkingModeSelector({ selectedMode, onModeChange, onClose, className =
|
||||
});
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
if (onClose) onClose();
|
||||
}
|
||||
@@ -87,11 +53,10 @@ function ThinkingModeSelector({ selectedMode, onModeChange, onClose, className =
|
||||
<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'
|
||||
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}`} />
|
||||
@@ -123,7 +88,7 @@ function ThinkingModeSelector({ selectedMode, onModeChange, onClose, className =
|
||||
{translatedModes.map((mode) => {
|
||||
const ModeIcon = mode.icon;
|
||||
const isSelected = mode.id === selectedMode;
|
||||
|
||||
|
||||
return (
|
||||
<button
|
||||
key={mode.id}
|
||||
@@ -132,9 +97,8 @@ function ThinkingModeSelector({ selectedMode, onModeChange, onClose, className =
|
||||
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' : ''
|
||||
}`}
|
||||
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'}`}>
|
||||
@@ -142,9 +106,8 @@ function ThinkingModeSelector({ selectedMode, onModeChange, onClose, className =
|
||||
</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'
|
||||
}`}>
|
||||
<span className={`font-medium text-sm ${isSelected ? 'text-gray-900 dark:text-white' : 'text-gray-700 dark:text-gray-300'
|
||||
}`}>
|
||||
{mode.name}
|
||||
</span>
|
||||
{isSelected && (
|
||||
@@ -179,5 +142,4 @@ function ThinkingModeSelector({ selectedMode, onModeChange, onClose, className =
|
||||
);
|
||||
}
|
||||
|
||||
export default ThinkingModeSelector;
|
||||
export { thinkingModes };
|
||||
export default ThinkingModeSelector;
|
||||
@@ -1,9 +1,12 @@
|
||||
import React from 'react';
|
||||
type TokenUsagePieProps = {
|
||||
used: number;
|
||||
total: number;
|
||||
};
|
||||
|
||||
function TokenUsagePie({ used, total }) {
|
||||
export default function TokenUsagePie({ used, total }: TokenUsagePieProps) {
|
||||
// Token usage visualization component
|
||||
// Only bail out on missing values or non‐positive totals; allow used===0 to render 0%
|
||||
if (used == null || total == null || total <= 0) return null;
|
||||
if (used == null || total == null || total <= 0) return null;
|
||||
|
||||
const percentage = Math.min(100, (used / total) * 100);
|
||||
const radius = 10;
|
||||
@@ -48,6 +51,4 @@ function TokenUsagePie({ used, total }) {
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TokenUsagePie;
|
||||
}
|
||||
110
src/components/main-content/hooks/useEditorSidebar.ts
Normal file
110
src/components/main-content/hooks/useEditorSidebar.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { MouseEvent as ReactMouseEvent } from 'react';
|
||||
import type { Project } from '../../../types/app';
|
||||
import type { DiffInfo, EditingFile } from '../types/types';
|
||||
|
||||
type UseEditorSidebarOptions = {
|
||||
selectedProject: Project | null;
|
||||
isMobile: boolean;
|
||||
initialWidth?: number;
|
||||
};
|
||||
|
||||
export function useEditorSidebar({
|
||||
selectedProject,
|
||||
isMobile,
|
||||
initialWidth = 600,
|
||||
}: UseEditorSidebarOptions) {
|
||||
const [editingFile, setEditingFile] = useState<EditingFile | null>(null);
|
||||
const [editorWidth, setEditorWidth] = useState(initialWidth);
|
||||
const [editorExpanded, setEditorExpanded] = useState(false);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const resizeHandleRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const handleFileOpen = useCallback(
|
||||
(filePath: string, diffInfo: DiffInfo | null = null) => {
|
||||
const normalizedPath = filePath.replace(/\\/g, '/');
|
||||
const fileName = normalizedPath.split('/').pop() || filePath;
|
||||
|
||||
setEditingFile({
|
||||
name: fileName,
|
||||
path: filePath,
|
||||
projectName: selectedProject?.name,
|
||||
diffInfo,
|
||||
});
|
||||
},
|
||||
[selectedProject?.name],
|
||||
);
|
||||
|
||||
const handleCloseEditor = useCallback(() => {
|
||||
setEditingFile(null);
|
||||
setEditorExpanded(false);
|
||||
}, []);
|
||||
|
||||
const handleToggleEditorExpand = useCallback(() => {
|
||||
setEditorExpanded((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const handleResizeStart = useCallback(
|
||||
(event: ReactMouseEvent<HTMLDivElement>) => {
|
||||
if (isMobile) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsResizing(true);
|
||||
event.preventDefault();
|
||||
},
|
||||
[isMobile],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (event: globalThis.MouseEvent) => {
|
||||
if (!isResizing) {
|
||||
return;
|
||||
}
|
||||
|
||||
const container = resizeHandleRef.current?.parentElement;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const newWidth = containerRect.right - event.clientX;
|
||||
|
||||
const minWidth = 300;
|
||||
const maxWidth = containerRect.width * 0.8;
|
||||
|
||||
if (newWidth >= minWidth && newWidth <= maxWidth) {
|
||||
setEditorWidth(newWidth);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsResizing(false);
|
||||
};
|
||||
|
||||
if (isResizing) {
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
};
|
||||
}, [isResizing]);
|
||||
|
||||
return {
|
||||
editingFile,
|
||||
editorWidth,
|
||||
editorExpanded,
|
||||
resizeHandleRef,
|
||||
handleFileOpen,
|
||||
handleCloseEditor,
|
||||
handleToggleEditorExpand,
|
||||
handleResizeStart,
|
||||
};
|
||||
}
|
||||
50
src/components/main-content/hooks/useMobileMenuHandlers.ts
Normal file
50
src/components/main-content/hooks/useMobileMenuHandlers.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import type { MouseEvent, TouchEvent } from 'react';
|
||||
|
||||
type MenuEvent = MouseEvent<HTMLButtonElement> | TouchEvent<HTMLButtonElement>;
|
||||
|
||||
export function useMobileMenuHandlers(onMenuClick: () => void) {
|
||||
const suppressNextMenuClickRef = useRef(false);
|
||||
|
||||
const openMobileMenu = useCallback(
|
||||
(event?: MenuEvent) => {
|
||||
if (event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
onMenuClick();
|
||||
},
|
||||
[onMenuClick],
|
||||
);
|
||||
|
||||
const handleMobileMenuTouchEnd = useCallback(
|
||||
(event: TouchEvent<HTMLButtonElement>) => {
|
||||
suppressNextMenuClickRef.current = true;
|
||||
openMobileMenu(event);
|
||||
|
||||
window.setTimeout(() => {
|
||||
suppressNextMenuClickRef.current = false;
|
||||
}, 350);
|
||||
},
|
||||
[openMobileMenu],
|
||||
);
|
||||
|
||||
const handleMobileMenuClick = useCallback(
|
||||
(event: MouseEvent<HTMLButtonElement>) => {
|
||||
if (suppressNextMenuClickRef.current) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
openMobileMenu(event);
|
||||
},
|
||||
[openMobileMenu],
|
||||
);
|
||||
|
||||
return {
|
||||
handleMobileMenuClick,
|
||||
handleMobileMenuTouchEnd,
|
||||
};
|
||||
}
|
||||
108
src/components/main-content/types/types.ts
Normal file
108
src/components/main-content/types/types.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import type { Dispatch, MouseEvent, RefObject, SetStateAction } from 'react';
|
||||
import type { AppTab, Project, ProjectSession } from '../../../types/app';
|
||||
|
||||
export type SessionLifecycleHandler = (sessionId?: string | null) => void;
|
||||
|
||||
export interface DiffInfo {
|
||||
old_string?: string;
|
||||
new_string?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface EditingFile {
|
||||
name: string;
|
||||
path: string;
|
||||
projectName?: string;
|
||||
diffInfo?: DiffInfo | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface TaskMasterTask {
|
||||
id: string | number;
|
||||
title?: string;
|
||||
description?: string;
|
||||
status?: string;
|
||||
priority?: string;
|
||||
details?: string;
|
||||
testStrategy?: string;
|
||||
parentId?: string | number;
|
||||
dependencies?: Array<string | number>;
|
||||
subtasks?: TaskMasterTask[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface TaskReference {
|
||||
id: string | number;
|
||||
title?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export type TaskSelection = TaskMasterTask | TaskReference;
|
||||
|
||||
export interface PrdFile {
|
||||
name: string;
|
||||
content?: string;
|
||||
isExisting?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface MainContentProps {
|
||||
selectedProject: Project | null;
|
||||
selectedSession: ProjectSession | null;
|
||||
activeTab: AppTab;
|
||||
setActiveTab: Dispatch<SetStateAction<AppTab>>;
|
||||
ws: WebSocket | null;
|
||||
sendMessage: (message: unknown) => void;
|
||||
latestMessage: unknown;
|
||||
isMobile: boolean;
|
||||
onMenuClick: () => void;
|
||||
isLoading: boolean;
|
||||
onInputFocusChange: (focused: boolean) => void;
|
||||
onSessionActive: SessionLifecycleHandler;
|
||||
onSessionInactive: SessionLifecycleHandler;
|
||||
onSessionProcessing: SessionLifecycleHandler;
|
||||
onSessionNotProcessing: SessionLifecycleHandler;
|
||||
processingSessions: Set<string>;
|
||||
onReplaceTemporarySession: SessionLifecycleHandler;
|
||||
onNavigateToSession: (targetSessionId: string) => void;
|
||||
onShowSettings: () => void;
|
||||
externalMessageUpdate: number;
|
||||
}
|
||||
|
||||
export interface MainContentHeaderProps {
|
||||
activeTab: AppTab;
|
||||
setActiveTab: Dispatch<SetStateAction<AppTab>>;
|
||||
selectedProject: Project;
|
||||
selectedSession: ProjectSession | null;
|
||||
shouldShowTasksTab: boolean;
|
||||
isMobile: boolean;
|
||||
onMenuClick: () => void;
|
||||
}
|
||||
|
||||
export interface MainContentStateViewProps {
|
||||
mode: 'loading' | 'empty';
|
||||
isMobile: boolean;
|
||||
onMenuClick: () => void;
|
||||
}
|
||||
|
||||
export interface MobileMenuButtonProps {
|
||||
onMenuClick: () => void;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export interface EditorSidebarProps {
|
||||
editingFile: EditingFile | null;
|
||||
isMobile: boolean;
|
||||
editorExpanded: boolean;
|
||||
editorWidth: number;
|
||||
resizeHandleRef: RefObject<HTMLDivElement>;
|
||||
onResizeStart: (event: MouseEvent<HTMLDivElement>) => void;
|
||||
onCloseEditor: () => void;
|
||||
onToggleEditorExpand: () => void;
|
||||
projectPath?: string;
|
||||
fillSpace?: boolean;
|
||||
}
|
||||
|
||||
export interface TaskMasterPanelProps {
|
||||
isVisible: boolean;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user