diff --git a/.github/workflows/bugbot.yml b/.github/workflows/bugbot.yml
index 65277a6..e4b53fc 100644
--- a/.github/workflows/bugbot.yml
+++ b/.github/workflows/bugbot.yml
@@ -19,7 +19,7 @@ jobs:
uses: actions/checkout@v4
- name: Run Claude BugBot
- uses: rekpero/claude-bugbot-github-action@v1.0.6
+ uses: rekpero/claude-bugbot-github-action@v1.0.8
with:
claude-setup-token: ${{ secrets.CLAUDE_SETUP_TOKEN }}
github-token: ${{ secrets.GH_TOKEN }}
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 6d589c6..fbe1d6e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,4 +8,7 @@ __pycache__/
venv/
*.egg-info/
dist/
-build/
\ No newline at end of file
+build/
+frontend/node_modules/
+frontend/dist/
+orchestrator/static
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 07bb7af..00afad2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,47 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [1.4.0] - 2026-03-14
+
+### Added
+- **React 18 + Vite 5 frontend**: full SPA in `frontend/` replacing the inline HTML dashboard — uses Tailwind CSS 3, React Query v5, Lucide React icons, and date-fns
+- **Complete component tree** preserving the existing dark color palette via CSS custom properties (`tokens.css`):
+ - Layout: `Header`, `WorkspaceSwitcher`, `TabNav`
+ - Metrics: `MetricsBar`, `MetricCard` (7-card grid)
+ - Agents: `ActiveAgents`, `AgentCard`, `AgentLogViewer`, `AgentStatusBadge`
+ - Issues: `IssueQueue`, `IssueStatusBadge` with per-row Retry button for `needs_human`
+ - PRs: `PRTracker`, `ReviewThreads`
+ - Modals: `AddWorkspaceModal`, `WorkspaceSettingsModal`, `EnvEditor`
+ - Planner: `PlannerModal` with support for creating GitHub issues from specific assistant messages
+ - UI primitives: `Card`, `Badge`, `Button` (polymorphic `as` prop), `Modal`, `Spinner`, `EmptyState`
+- **Custom React hooks**: `useAgents`, `useIssues`, `useMetrics`, `usePRs`, `useWorkspaces`, `usePlanning`, `useGitSync` — encapsulate all API polling and mutation logic
+- **WorkspaceContext** persists `selectedWorkspaceId` to `localStorage`
+- **SPA catch-all route** in `dashboard.py` (registered after all `/api/*` routes) so React client-side navigation works on direct URL loads; guards against serving `index.html` for `api/*` paths (returns 404 instead)
+- **`build-ui` command** in `run.sh` (`cd frontend && npm install && npm run build`), integrated into the `install` flow; UI is also rebuilt on orchestrator restart
+- **Rich tool-use logging**: `WebSearch`, `WebFetch`, `Grep`, `Glob`, and `Agent` tool uses now display their key parameters (query, URL, pattern, description) in both the backend stream parser and frontend log viewer
+- **`buildGitHubUrl` helper** with `owner/repo` validation and `encodeURIComponent` sanitization in `PRTracker` and `IssueQueue` to prevent URL injection
+
+### Changed
+- **Dashboard fully migrated from inline HTML + vanilla JS to React SPA** — Vite builds output directly to `orchestrator/static/` via `vite.config.js` `outDir` setting
+- **PR statuses enriched from issue state** (merged/needs_human) and PRs sorted by status priority
+- **Issues sorted by status priority** in the dashboard
+- **Agents sorted with running instances first** in the dashboard
+- **Reattached agents** (surviving an orchestrator restart) now compute `turns_used` from the `agent_events` table on completion; dashboard fallback dynamically calculates turns for any agent with `turns_used=0`
+- **`/assets` static mount** is conditional — server logs a warning instead of crashing when the frontend hasn't been built yet
+- `.gitignore` updated with `frontend/node_modules/` and `frontend/dist/`
+
+### Fixed
+- **AgentLogViewer**: accumulate events with ID-based deduplication instead of replacing on each poll; synchronous cursor reset with `cursorAgentIdRef` guard to prevent stale cached data from overwriting cursor on agent switch
+- **EnvEditor**: merge-overwrite semantics for paste and file upload (existing keys are updated, not dropped); stable row IDs derived from variable names instead of `Math.random()`; separate `fileReadError` state so file-read errors aren't silently cleared by save actions; cancellation flag in `useEffect` to prevent race conditions on rapid file switches
+- **WorkspaceSettingsModal**: error handlers on update and delete mutations; form fields reinitialise when workspace fields change while modal is open; `confirmDelete` resets when modal closes; form resets when workspace becomes null after deletion
+- **PRTracker**: composite key `${github_repo}-${pr_number}` to prevent duplicate React keys across repos
+- **IssueQueue**: per-row `retryingIssue` state so a single retry doesn't disable all Retry buttons
+- **PlannerModal**: optional chaining guard on `streamEvents?.length`; `setTimeout` cleanup on rapid open/close; stable `loadSessions` reference in `useEffect` dependency array
+- **API client**: fix header merging by destructuring `headers` from options before spread, preventing `options.headers` from overwriting the merged `Content-Type` header
+- **EnvEditor `.env` parsing**: balanced quote matching regex instead of independent quote stripping
+
+---
+
## [1.3.1] - 2026-03-13
### Changed
diff --git a/frontend/index.html b/frontend/index.html
new file mode 100644
index 0000000..f0a7be6
--- /dev/null
+++ b/frontend/index.html
@@ -0,0 +1,15 @@
+
+
+
+
+
+ Claude Code Swarm
+
+
+
+
+
+
+
+
+
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
new file mode 100644
index 0000000..3a6aa5f
--- /dev/null
+++ b/frontend/package-lock.json
@@ -0,0 +1,2729 @@
+{
+ "name": "claude-code-swarm-dashboard",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "claude-code-swarm-dashboard",
+ "version": "0.1.0",
+ "dependencies": {
+ "@tanstack/react-query": "^5.56.2",
+ "date-fns": "^4.1.0",
+ "dompurify": "^3.3.3",
+ "lucide-react": "^0.462.0",
+ "marked": "^17.0.4",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1"
+ },
+ "devDependencies": {
+ "@types/react": "^18.3.11",
+ "@types/react-dom": "^18.3.1",
+ "@vitejs/plugin-react": "^4.3.2",
+ "autoprefixer": "^10.4.20",
+ "postcss": "^8.4.47",
+ "tailwindcss": "^3.4.14",
+ "vite": "^5.4.9"
+ }
+ },
+ "node_modules/@alloc/quick-lru": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
+ "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@babel/code-frame": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
+ "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.28.5",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/compat-data": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz",
+ "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/core": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
+ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.0",
+ "@babel/generator": "^7.29.0",
+ "@babel/helper-compilation-targets": "^7.28.6",
+ "@babel/helper-module-transforms": "^7.28.6",
+ "@babel/helpers": "^7.28.6",
+ "@babel/parser": "^7.29.0",
+ "@babel/template": "^7.28.6",
+ "@babel/traverse": "^7.29.0",
+ "@babel/types": "^7.29.0",
+ "@jridgewell/remapping": "^2.3.5",
+ "convert-source-map": "^2.0.0",
+ "debug": "^4.1.0",
+ "gensync": "^1.0.0-beta.2",
+ "json5": "^2.2.3",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/babel"
+ }
+ },
+ "node_modules/@babel/generator": {
+ "version": "7.29.1",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
+ "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.0",
+ "@babel/types": "^7.29.0",
+ "@jridgewell/gen-mapping": "^0.3.12",
+ "@jridgewell/trace-mapping": "^0.3.28",
+ "jsesc": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
+ "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/compat-data": "^7.28.6",
+ "@babel/helper-validator-option": "^7.27.1",
+ "browserslist": "^4.24.0",
+ "lru-cache": "^5.1.1",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-globals": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
+ "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-imports": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
+ "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.28.6",
+ "@babel/types": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-transforms": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
+ "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.28.6",
+ "@babel/helper-validator-identifier": "^7.28.5",
+ "@babel/traverse": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-plugin-utils": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz",
+ "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
+ "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
+ "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-option": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
+ "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helpers": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz",
+ "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/template": "^7.28.6",
+ "@babel/types": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz",
+ "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.0"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-self": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz",
+ "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-source": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz",
+ "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/template": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
+ "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.28.6",
+ "@babel/parser": "^7.28.6",
+ "@babel/types": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/traverse": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
+ "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.0",
+ "@babel/generator": "^7.29.0",
+ "@babel/helper-globals": "^7.28.0",
+ "@babel/parser": "^7.29.0",
+ "@babel/template": "^7.28.6",
+ "@babel/types": "^7.29.0",
+ "debug": "^4.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
+ "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.27.1",
+ "@babel/helper-validator-identifier": "^7.28.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
+ "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
+ "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
+ "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
+ "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
+ "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
+ "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
+ "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
+ "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
+ "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
+ "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
+ "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
+ "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
+ "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
+ "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
+ "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
+ "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
+ "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
+ "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
+ "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@nodelib/fs.scandir": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
+ "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.stat": "2.0.5",
+ "run-parallel": "^1.1.9"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.stat": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
+ "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.walk": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
+ "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.scandir": "2.1.5",
+ "fastq": "^1.6.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-beta.27",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
+ "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
+ "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
+ "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
+ "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
+ "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
+ "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
+ "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
+ "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
+ "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
+ "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
+ "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
+ "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
+ "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
+ "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
+ "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
+ "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
+ "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
+ "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
+ "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
+ "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
+ "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
+ "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
+ "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
+ "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
+ "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
+ "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@tanstack/query-core": {
+ "version": "5.90.20",
+ "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz",
+ "integrity": "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
+ "node_modules/@tanstack/react-query": {
+ "version": "5.90.21",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.21.tgz",
+ "integrity": "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==",
+ "license": "MIT",
+ "dependencies": {
+ "@tanstack/query-core": "5.90.20"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "react": "^18 || ^19"
+ }
+ },
+ "node_modules/@types/babel__core": {
+ "version": "7.20.5",
+ "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
+ "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.20.7",
+ "@babel/types": "^7.20.7",
+ "@types/babel__generator": "*",
+ "@types/babel__template": "*",
+ "@types/babel__traverse": "*"
+ }
+ },
+ "node_modules/@types/babel__generator": {
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
+ "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__template": {
+ "version": "7.4.4",
+ "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
+ "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.1.0",
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__traverse": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
+ "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.28.2"
+ }
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/prop-types": {
+ "version": "15.7.15",
+ "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
+ "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/react": {
+ "version": "18.3.28",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
+ "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/prop-types": "*",
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "18.3.7",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
+ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^18.0.0"
+ }
+ },
+ "node_modules/@types/trusted-types": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
+ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/@vitejs/plugin-react": {
+ "version": "4.7.0",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
+ "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.28.0",
+ "@babel/plugin-transform-react-jsx-self": "^7.27.1",
+ "@babel/plugin-transform-react-jsx-source": "^7.27.1",
+ "@rolldown/pluginutils": "1.0.0-beta.27",
+ "@types/babel__core": "^7.20.5",
+ "react-refresh": "^0.17.0"
+ },
+ "engines": {
+ "node": "^14.18.0 || >=16.0.0"
+ },
+ "peerDependencies": {
+ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
+ }
+ },
+ "node_modules/any-promise": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
+ "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/anymatch": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
+ "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "normalize-path": "^3.0.0",
+ "picomatch": "^2.0.4"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/arg": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
+ "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/autoprefixer": {
+ "version": "10.4.27",
+ "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz",
+ "integrity": "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/autoprefixer"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "browserslist": "^4.28.1",
+ "caniuse-lite": "^1.0.30001774",
+ "fraction.js": "^5.3.4",
+ "picocolors": "^1.1.1",
+ "postcss-value-parser": "^4.2.0"
+ },
+ "bin": {
+ "autoprefixer": "bin/autoprefixer"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ },
+ "peerDependencies": {
+ "postcss": "^8.1.0"
+ }
+ },
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.10.7",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.7.tgz",
+ "integrity": "sha512-1ghYO3HnxGec0TCGBXiDLVns4eCSx4zJpxnHrlqFQajmhfKMQBzUGDdkMK7fUW7PTHTeLf+j87aTuKuuwWzMGw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.cjs"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/binary-extensions": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
+ "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/braces": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
+ "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fill-range": "^7.1.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/browserslist": {
+ "version": "4.28.1",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
+ "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "baseline-browser-mapping": "^2.9.0",
+ "caniuse-lite": "^1.0.30001759",
+ "electron-to-chromium": "^1.5.263",
+ "node-releases": "^2.0.27",
+ "update-browserslist-db": "^1.2.0"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/camelcase-css": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
+ "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001778",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001778.tgz",
+ "integrity": "sha512-PN7uxFL+ExFJO61aVmP1aIEG4i9whQd4eoSCebav62UwDyp5OHh06zN4jqKSMePVgxHifCw1QJxdRkA1Pisekg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/chokidar": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
+ "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "anymatch": "~3.1.2",
+ "braces": "~3.0.2",
+ "glob-parent": "~5.1.2",
+ "is-binary-path": "~2.1.0",
+ "is-glob": "~4.0.1",
+ "normalize-path": "~3.0.0",
+ "readdirp": "~3.6.0"
+ },
+ "engines": {
+ "node": ">= 8.10.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/chokidar/node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/commander": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
+ "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/cssesc": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
+ "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "cssesc": "bin/cssesc"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/date-fns": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
+ "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/kossnocorp"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/didyoumean": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
+ "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/dlv": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
+ "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/dompurify": {
+ "version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz",
+ "integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==",
+ "license": "(MPL-2.0 OR Apache-2.0)",
+ "optionalDependencies": {
+ "@types/trusted-types": "^2.0.7"
+ }
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.313",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.313.tgz",
+ "integrity": "sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/esbuild": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
+ "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.21.5",
+ "@esbuild/android-arm": "0.21.5",
+ "@esbuild/android-arm64": "0.21.5",
+ "@esbuild/android-x64": "0.21.5",
+ "@esbuild/darwin-arm64": "0.21.5",
+ "@esbuild/darwin-x64": "0.21.5",
+ "@esbuild/freebsd-arm64": "0.21.5",
+ "@esbuild/freebsd-x64": "0.21.5",
+ "@esbuild/linux-arm": "0.21.5",
+ "@esbuild/linux-arm64": "0.21.5",
+ "@esbuild/linux-ia32": "0.21.5",
+ "@esbuild/linux-loong64": "0.21.5",
+ "@esbuild/linux-mips64el": "0.21.5",
+ "@esbuild/linux-ppc64": "0.21.5",
+ "@esbuild/linux-riscv64": "0.21.5",
+ "@esbuild/linux-s390x": "0.21.5",
+ "@esbuild/linux-x64": "0.21.5",
+ "@esbuild/netbsd-x64": "0.21.5",
+ "@esbuild/openbsd-x64": "0.21.5",
+ "@esbuild/sunos-x64": "0.21.5",
+ "@esbuild/win32-arm64": "0.21.5",
+ "@esbuild/win32-ia32": "0.21.5",
+ "@esbuild/win32-x64": "0.21.5"
+ }
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/fast-glob": {
+ "version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
+ "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.stat": "^2.0.2",
+ "@nodelib/fs.walk": "^1.2.3",
+ "glob-parent": "^5.1.2",
+ "merge2": "^1.3.0",
+ "micromatch": "^4.0.8"
+ },
+ "engines": {
+ "node": ">=8.6.0"
+ }
+ },
+ "node_modules/fast-glob/node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/fastq": {
+ "version": "1.20.1",
+ "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
+ "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "reusify": "^1.0.4"
+ }
+ },
+ "node_modules/fill-range": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
+ "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "to-regex-range": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/fraction.js": {
+ "version": "5.3.4",
+ "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
+ "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/rawify"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/gensync": {
+ "version": "1.0.0-beta.2",
+ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/glob-parent": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
+ "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
+ "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/is-binary-path": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
+ "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "binary-extensions": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-core-module": {
+ "version": "2.16.1",
+ "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
+ "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-number": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.12.0"
+ }
+ },
+ "node_modules/jiti": {
+ "version": "1.21.7",
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
+ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jiti": "bin/jiti.js"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "license": "MIT"
+ },
+ "node_modules/jsesc": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/json5": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "json5": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/lilconfig": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
+ "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antonk52"
+ }
+ },
+ "node_modules/lines-and-columns": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
+ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/loose-envify": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^3.0.0 || ^4.0.0"
+ },
+ "bin": {
+ "loose-envify": "cli.js"
+ }
+ },
+ "node_modules/lru-cache": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "node_modules/lucide-react": {
+ "version": "0.462.0",
+ "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.462.0.tgz",
+ "integrity": "sha512-NTL7EbAao9IFtuSivSZgrAh4fZd09Lr+6MTkqIxuHaH2nnYiYIzXPo06cOxHg9wKLdj6LL8TByG4qpePqwgx/g==",
+ "license": "ISC",
+ "peerDependencies": {
+ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
+ }
+ },
+ "node_modules/marked": {
+ "version": "17.0.4",
+ "resolved": "https://registry.npmjs.org/marked/-/marked-17.0.4.tgz",
+ "integrity": "sha512-NOmVMM+KAokHMvjWmC5N/ZOvgmSWuqJB8FoYI019j4ogb/PeRMKoKIjReZ2w3376kkA8dSJIP8uD993Kxc0iRQ==",
+ "license": "MIT",
+ "bin": {
+ "marked": "bin/marked.js"
+ },
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/merge2": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
+ "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/micromatch": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
+ "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "braces": "^3.0.3",
+ "picomatch": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=8.6"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/mz": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
+ "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "any-promise": "^1.0.0",
+ "object-assign": "^4.0.1",
+ "thenify-all": "^1.0.0"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.11",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/node-releases": {
+ "version": "2.0.36",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz",
+ "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/normalize-path": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-hash": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
+ "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/path-parse": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
+ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
+ "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/pify": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
+ "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/pirates": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
+ "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.8",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
+ "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.11",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/postcss-import": {
+ "version": "15.1.0",
+ "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz",
+ "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "postcss-value-parser": "^4.0.0",
+ "read-cache": "^1.0.0",
+ "resolve": "^1.1.7"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "postcss": "^8.0.0"
+ }
+ },
+ "node_modules/postcss-js": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz",
+ "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "camelcase-css": "^2.0.1"
+ },
+ "engines": {
+ "node": "^12 || ^14 || >= 16"
+ },
+ "peerDependencies": {
+ "postcss": "^8.4.21"
+ }
+ },
+ "node_modules/postcss-load-config": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz",
+ "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "lilconfig": "^3.1.1"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "peerDependencies": {
+ "jiti": ">=1.21.0",
+ "postcss": ">=8.0.9",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "jiti": {
+ "optional": true
+ },
+ "postcss": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/postcss-nested": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz",
+ "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "postcss-selector-parser": "^6.1.1"
+ },
+ "engines": {
+ "node": ">=12.0"
+ },
+ "peerDependencies": {
+ "postcss": "^8.2.14"
+ }
+ },
+ "node_modules/postcss-selector-parser": {
+ "version": "6.1.2",
+ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
+ "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cssesc": "^3.0.0",
+ "util-deprecate": "^1.0.2"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postcss-value-parser": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
+ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/queue-microtask": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
+ "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/react": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
+ "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
+ "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0",
+ "scheduler": "^0.23.2"
+ },
+ "peerDependencies": {
+ "react": "^18.3.1"
+ }
+ },
+ "node_modules/react-refresh": {
+ "version": "0.17.0",
+ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
+ "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/read-cache": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
+ "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "pify": "^2.3.0"
+ }
+ },
+ "node_modules/readdirp": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
+ "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "picomatch": "^2.2.1"
+ },
+ "engines": {
+ "node": ">=8.10.0"
+ }
+ },
+ "node_modules/resolve": {
+ "version": "1.22.11",
+ "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
+ "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-core-module": "^2.16.1",
+ "path-parse": "^1.0.7",
+ "supports-preserve-symlinks-flag": "^1.0.0"
+ },
+ "bin": {
+ "resolve": "bin/resolve"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/reusify": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
+ "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "iojs": ">=1.0.0",
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
+ "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.8"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.59.0",
+ "@rollup/rollup-android-arm64": "4.59.0",
+ "@rollup/rollup-darwin-arm64": "4.59.0",
+ "@rollup/rollup-darwin-x64": "4.59.0",
+ "@rollup/rollup-freebsd-arm64": "4.59.0",
+ "@rollup/rollup-freebsd-x64": "4.59.0",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
+ "@rollup/rollup-linux-arm-musleabihf": "4.59.0",
+ "@rollup/rollup-linux-arm64-gnu": "4.59.0",
+ "@rollup/rollup-linux-arm64-musl": "4.59.0",
+ "@rollup/rollup-linux-loong64-gnu": "4.59.0",
+ "@rollup/rollup-linux-loong64-musl": "4.59.0",
+ "@rollup/rollup-linux-ppc64-gnu": "4.59.0",
+ "@rollup/rollup-linux-ppc64-musl": "4.59.0",
+ "@rollup/rollup-linux-riscv64-gnu": "4.59.0",
+ "@rollup/rollup-linux-riscv64-musl": "4.59.0",
+ "@rollup/rollup-linux-s390x-gnu": "4.59.0",
+ "@rollup/rollup-linux-x64-gnu": "4.59.0",
+ "@rollup/rollup-linux-x64-musl": "4.59.0",
+ "@rollup/rollup-openbsd-x64": "4.59.0",
+ "@rollup/rollup-openharmony-arm64": "4.59.0",
+ "@rollup/rollup-win32-arm64-msvc": "4.59.0",
+ "@rollup/rollup-win32-ia32-msvc": "4.59.0",
+ "@rollup/rollup-win32-x64-gnu": "4.59.0",
+ "@rollup/rollup-win32-x64-msvc": "4.59.0",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/run-parallel": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
+ "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "queue-microtask": "^1.2.2"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.23.2",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
+ "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ }
+ },
+ "node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/sucrase": {
+ "version": "3.35.1",
+ "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
+ "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.2",
+ "commander": "^4.0.0",
+ "lines-and-columns": "^1.1.6",
+ "mz": "^2.7.0",
+ "pirates": "^4.0.1",
+ "tinyglobby": "^0.2.11",
+ "ts-interface-checker": "^0.1.9"
+ },
+ "bin": {
+ "sucrase": "bin/sucrase",
+ "sucrase-node": "bin/sucrase-node"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ }
+ },
+ "node_modules/supports-preserve-symlinks-flag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
+ "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/tailwindcss": {
+ "version": "3.4.19",
+ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
+ "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@alloc/quick-lru": "^5.2.0",
+ "arg": "^5.0.2",
+ "chokidar": "^3.6.0",
+ "didyoumean": "^1.2.2",
+ "dlv": "^1.1.3",
+ "fast-glob": "^3.3.2",
+ "glob-parent": "^6.0.2",
+ "is-glob": "^4.0.3",
+ "jiti": "^1.21.7",
+ "lilconfig": "^3.1.3",
+ "micromatch": "^4.0.8",
+ "normalize-path": "^3.0.0",
+ "object-hash": "^3.0.0",
+ "picocolors": "^1.1.1",
+ "postcss": "^8.4.47",
+ "postcss-import": "^15.1.0",
+ "postcss-js": "^4.0.1",
+ "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0",
+ "postcss-nested": "^6.2.0",
+ "postcss-selector-parser": "^6.1.2",
+ "resolve": "^1.22.8",
+ "sucrase": "^3.35.0"
+ },
+ "bin": {
+ "tailwind": "lib/cli.js",
+ "tailwindcss": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/thenify": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
+ "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "any-promise": "^1.0.0"
+ }
+ },
+ "node_modules/thenify-all": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz",
+ "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "thenify": ">= 3.1.0 < 4"
+ },
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.15",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
+ "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinyglobby/node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/tinyglobby/node_modules/picomatch": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/to-regex-range": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-number": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=8.0"
+ }
+ },
+ "node_modules/ts-interface-checker": {
+ "version": "0.1.13",
+ "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
+ "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
+ "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/vite": {
+ "version": "5.4.21",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
+ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.21.3",
+ "postcss": "^8.4.43",
+ "rollup": "^4.20.0"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || >=20.0.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "dev": true,
+ "license": "ISC"
+ }
+ }
+}
diff --git a/frontend/package.json b/frontend/package.json
new file mode 100644
index 0000000..234c8d4
--- /dev/null
+++ b/frontend/package.json
@@ -0,0 +1,29 @@
+{
+ "name": "claude-code-swarm-dashboard",
+ "private": true,
+ "version": "0.1.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@tanstack/react-query": "^5.56.2",
+ "date-fns": "^4.1.0",
+ "dompurify": "^3.3.3",
+ "lucide-react": "^0.462.0",
+ "marked": "^17.0.4",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1"
+ },
+ "devDependencies": {
+ "@types/react": "^18.3.11",
+ "@types/react-dom": "^18.3.1",
+ "@vitejs/plugin-react": "^4.3.2",
+ "autoprefixer": "^10.4.20",
+ "postcss": "^8.4.47",
+ "tailwindcss": "^3.4.14",
+ "vite": "^5.4.9"
+ }
+}
diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js
new file mode 100644
index 0000000..2e7af2b
--- /dev/null
+++ b/frontend/postcss.config.js
@@ -0,0 +1,6 @@
+export default {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+}
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
new file mode 100644
index 0000000..31c9380
--- /dev/null
+++ b/frontend/src/App.jsx
@@ -0,0 +1,72 @@
+import { useState } from 'react'
+import { Header } from './components/layout/Header'
+import { TabNav } from './components/layout/TabNav'
+import { MetricsBar } from './components/metrics/MetricsBar'
+import { ActiveAgents } from './components/agents/ActiveAgents'
+import { IssueQueue } from './components/issues/IssueQueue'
+import { PRTracker } from './components/prs/PRTracker'
+import { AddWorkspaceModal } from './components/modals/AddWorkspaceModal'
+import { WorkspaceSettingsModal } from './components/modals/WorkspaceSettingsModal'
+import { PlannerModal } from './components/planner/PlannerModal'
+import { useMetrics } from './hooks/useMetrics'
+import { useAgents } from './hooks/useAgents'
+import { useIssues } from './hooks/useIssues'
+import { usePRs } from './hooks/usePRs'
+import { useWorkspaceContext } from './context/WorkspaceContext'
+
+function ErrorBanner({ error }) {
+ if (!error) return null
+ return (
+
+ Cannot connect to backend \u2014 {error.message}
+
+ )
+}
+
+export function App() {
+ const [activeTab, setActiveTab] = useState('agents')
+ const [addWorkspaceOpen, setAddWorkspaceOpen] = useState(false)
+ const [settingsOpen, setSettingsOpen] = useState(false)
+ const [plannerOpen, setPlannerOpen] = useState(false)
+ const { selectedWorkspaceId } = useWorkspaceContext()
+
+ const { error: metricsError } = useMetrics(selectedWorkspaceId)
+ const { data: agentsData } = useAgents(selectedWorkspaceId)
+ const { data: issuesData } = useIssues(selectedWorkspaceId)
+ const { data: prsData } = usePRs(selectedWorkspaceId)
+
+ const counts = {
+ agents: agentsData?.total ?? 0,
+ issues: issuesData?.issues?.length ?? 0,
+ prs: prsData?.prs?.length ?? 0,
+ }
+
+ return (
+
+
setAddWorkspaceOpen(true)}
+ onOpenSettings={() => setSettingsOpen(true)}
+ onOpenPlanner={() => setPlannerOpen(true)}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+ setAddWorkspaceOpen(false)} />
+ setSettingsOpen(false)} />
+ setPlannerOpen(false)} />
+
+ )
+}
diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js
new file mode 100644
index 0000000..20d8c7c
--- /dev/null
+++ b/frontend/src/api/client.js
@@ -0,0 +1,118 @@
+const BASE = ''
+
+async function apiFetch(path, options = {}) {
+ const { headers: customHeaders, ...rest } = options
+ const res = await fetch(`${BASE}${path}`, {
+ headers: { 'Content-Type': 'application/json', ...customHeaders },
+ ...rest,
+ })
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({ error: res.statusText }))
+ throw new Error(err.error || `HTTP ${res.status}`)
+ }
+ return res.json()
+}
+
+// Metrics
+export const getMetrics = (wsId) =>
+ apiFetch(`/api/metrics${wsId ? `?workspace_id=${encodeURIComponent(wsId)}` : ''}`)
+
+// Agents
+export const getAgents = (wsId, limit = 20, offset = 0) =>
+ apiFetch(`/api/agents?limit=${limit}&offset=${offset}${wsId ? `&workspace_id=${encodeURIComponent(wsId)}` : ''}`)
+
+export const getAgentLogs = (agentId, since = 0) =>
+ apiFetch(`/api/agents/${agentId}/logs?since=${since}`)
+
+export const restartAgent = (agentId) =>
+ apiFetch(`/api/agents/${agentId}/restart`, { method: 'POST' })
+
+// Issues
+export const getIssues = (wsId) =>
+ apiFetch(`/api/issues${wsId ? `?workspace_id=${encodeURIComponent(wsId)}` : ''}`)
+
+export const updateIssueStatus = (issueNumber, status, wsId) =>
+ apiFetch(`/api/issues/${issueNumber}/status${wsId ? `?workspace_id=${encodeURIComponent(wsId)}` : ''}`, {
+ method: 'PUT',
+ body: JSON.stringify({ status }),
+ })
+
+// PRs
+export const getPRs = (wsId) =>
+ apiFetch(`/api/prs${wsId ? `?workspace_id=${encodeURIComponent(wsId)}` : ''}`)
+
+// Workspaces
+export const getWorkspaces = () => apiFetch('/api/workspaces')
+
+export const createWorkspace = (data) =>
+ apiFetch('/api/workspaces', { method: 'POST', body: JSON.stringify(data) })
+
+export const updateWorkspace = (id, data) =>
+ apiFetch(`/api/workspaces/${id}`, { method: 'PUT', body: JSON.stringify(data) })
+
+export const deleteWorkspace = (id) =>
+ apiFetch(`/api/workspaces/${id}`, { method: 'DELETE' })
+
+export const getWorkspaceStructure = (wsId) =>
+ apiFetch(`/api/workspaces/${wsId}/structure`)
+
+// Git sync
+export const getGitStatus = (wsId) =>
+ apiFetch(`/api/workspaces/${wsId}/git-status`)
+
+export const gitPull = (wsId) =>
+ apiFetch(`/api/workspaces/${wsId}/git-pull`, { method: 'POST' })
+
+// Env files
+export const getEnvFiles = (wsId) =>
+ apiFetch(`/api/workspaces/${wsId}/env-files`)
+
+export const getEnv = (wsId, file = '.env') =>
+ apiFetch(`/api/workspaces/${wsId}/env?env_file=${encodeURIComponent(file)}`)
+
+export const saveEnv = (wsId, file, vars) =>
+ apiFetch(`/api/workspaces/${wsId}/env`, {
+ method: 'PUT',
+ body: JSON.stringify({ vars, env_file: file }),
+ })
+
+export const deleteEnvFile = (wsId, file) =>
+ apiFetch(`/api/workspaces/${wsId}/env?env_file=${encodeURIComponent(file)}`, {
+ method: 'DELETE',
+ })
+
+export const loadEnvFromDisk = (wsId, file = '.env') =>
+ apiFetch(`/api/workspaces/${wsId}/env-load?env_file=${encodeURIComponent(file)}`, {
+ method: 'POST',
+ })
+
+// Planning
+export const getPlanningSession = (sessionId) =>
+ apiFetch(`/api/planning/${sessionId}`)
+
+export const getPlanningEvents = (sessionId, since = 0) =>
+ apiFetch(`/api/planning/${sessionId}/events?since=${since}`)
+
+export const startPlanning = (wsId, message) =>
+ apiFetch('/api/planning', { method: 'POST', body: JSON.stringify({ workspace_id: wsId, message }) })
+
+export const refinePlan = (sessionId, message) =>
+ apiFetch(`/api/planning/${sessionId}/messages`, {
+ method: 'POST',
+ body: JSON.stringify({ message }),
+ })
+
+export const createIssueFromPlan = (sessionId, title = '', messageIndex = null) =>
+ apiFetch(`/api/planning/${sessionId}/create-issue`, {
+ method: 'POST',
+ body: JSON.stringify({ title, message_index: messageIndex }),
+ })
+
+export const cancelPlanning = (sessionId) =>
+ apiFetch(`/api/planning/${sessionId}/cancel`, { method: 'POST' })
+
+export const deletePlanningSession = (sessionId) =>
+ apiFetch(`/api/planning/${sessionId}`, { method: 'DELETE' })
+
+export const listPlanningSessions = (wsId) =>
+ apiFetch(`/api/workspaces/${wsId}/planning-sessions`)
diff --git a/frontend/src/components/agents/ActiveAgents.jsx b/frontend/src/components/agents/ActiveAgents.jsx
new file mode 100644
index 0000000..804cf3f
--- /dev/null
+++ b/frontend/src/components/agents/ActiveAgents.jsx
@@ -0,0 +1,75 @@
+import { useState, useEffect } from 'react'
+import { Bot, ChevronLeft, ChevronRight } from 'lucide-react'
+import { AgentCard } from './AgentCard'
+import { EmptyState } from '../ui/EmptyState'
+import { Button } from '../ui/Button'
+import { Spinner } from '../ui/Spinner'
+import { useAgents } from '../../hooks/useAgents'
+import { useWorkspaceContext } from '../../context/WorkspaceContext'
+import { useWorkspaces } from '../../hooks/useWorkspaces'
+
+const PAGE_SIZE = 20
+
+export function ActiveAgents() {
+ const [offset, setOffset] = useState(0)
+ const { selectedWorkspaceId } = useWorkspaceContext()
+ const { data: wsData } = useWorkspaces()
+ const wsMap = Object.fromEntries((wsData?.workspaces || []).map(w => [w.id, w.name || w.repo_url]))
+ const showWorkspace = !selectedWorkspaceId
+
+ useEffect(() => { setOffset(0) }, [selectedWorkspaceId])
+ const { data, isLoading, refetch } = useAgents(selectedWorkspaceId, { limit: PAGE_SIZE, offset })
+
+ const agents = data?.agents || []
+ const total = data?.total ?? 0
+ const totalPages = Math.ceil(total / PAGE_SIZE)
+ const currentPage = Math.floor(offset / PAGE_SIZE) + 1
+
+ return (
+
+
+
+ Active Agents
+ {total > 0 && ({total}) }
+
+ {totalPages > 1 && (
+
+ setOffset((v) => Math.max(0, v - PAGE_SIZE))}
+ >
+
+
+
+ {currentPage} / {totalPages}
+
+ = total}
+ onClick={() => setOffset((v) => v + PAGE_SIZE)}
+ >
+
+
+
+ )}
+
+
+ {isLoading ? (
+
+
+
+ ) : agents.length === 0 ? (
+
+ ) : (
+
+ {agents.map((agent) => (
+
+ ))}
+
+ )}
+
+ )
+}
diff --git a/frontend/src/components/agents/AgentCard.jsx b/frontend/src/components/agents/AgentCard.jsx
new file mode 100644
index 0000000..78da4e2
--- /dev/null
+++ b/frontend/src/components/agents/AgentCard.jsx
@@ -0,0 +1,156 @@
+import { useState, useEffect, useRef } from 'react'
+import { ChevronDown, ChevronUp, Code, MessageSquare, RotateCw } from 'lucide-react'
+import { AgentStatusBadge } from './AgentStatusBadge'
+import { AgentLogViewer } from './AgentLogViewer'
+import { restartAgent } from '../../api/client'
+import { formatDuration, intervalToDuration } from 'date-fns'
+
+const AGENT_TYPE_META = {
+ implement: { label: 'Implementing Issue', icon: Code, color: 'text-[var(--accent)]' },
+ fix_review: { label: 'Fixing PR Review', icon: MessageSquare, color: 'text-[var(--blue)]' },
+}
+
+function ElapsedTime({ startedAt, status }) {
+ const [elapsed, setElapsed] = useState('')
+
+ useEffect(() => {
+ if (!startedAt) return
+ const update = () => {
+ const start = new Date(startedAt)
+ const now = new Date()
+ const dur = intervalToDuration({ start, end: now })
+ const parts = []
+ if (dur.hours) parts.push(`${dur.hours}h`)
+ if (dur.minutes) parts.push(`${dur.minutes}m`)
+ parts.push(`${dur.seconds ?? 0}s`)
+ setElapsed(parts.join(' '))
+ }
+ update()
+ let t
+ if (status === 'running') {
+ t = setInterval(update, 1000)
+ }
+ return () => clearInterval(t)
+ }, [startedAt, status])
+
+ return {elapsed}
+}
+
+export function AgentCard({ agent, workspaceName, onRestarted }) {
+ const isRunning = agent.status === 'running'
+ const [expanded, setExpanded] = useState(isRunning)
+ const [restarting, setRestarting] = useState(false)
+ const [restartError, setRestartError] = useState(null)
+ const mountedRef = useRef(true)
+
+ useEffect(() => {
+ return () => { mountedRef.current = false }
+ }, [])
+
+ useEffect(() => {
+ if (isRunning) {
+ setExpanded(true)
+ }
+ }, [isRunning])
+
+ return (
+
+ {/* Running accent bar */}
+ {isRunning && (
+
+ )}
+
+
setExpanded((v) => !v)}
+ >
+
+
+
+
+ {(() => {
+ const meta = AGENT_TYPE_META[agent.agent_type]
+ const TypeIcon = meta?.icon
+ return meta ? (
+
+ {TypeIcon && }
+ {meta.label}
+
+ ) : (
+
+ {agent.agent_type || 'agent'}
+
+ )
+ })()}
+ {agent.issue_number && (
+
+ issue #{agent.issue_number}
+
+ )}
+ {agent.pr_number && (
+
+ PR #{agent.pr_number}
+
+ )}
+
+
+
+ {agent.branch_name || agent.agent_id}
+
+ {workspaceName && (
+
+ {workspaceName}
+
+ )}
+
+
+
+
+
+
+ {agent.turns_used ?? 0}
+ {agent.max_turns ? `/${agent.max_turns}` : ''} turns
+
+ {isRunning && (
+
+ {
+ e.stopPropagation()
+ if (restarting) return
+ setRestarting(true)
+ setRestartError(null)
+ restartAgent(agent.agent_id)
+ .then(() => { Promise.resolve(onRestarted?.()).catch(() => {}) })
+ .catch((err) => { if (mountedRef.current) setRestartError(err?.message || 'Restart failed') })
+ .finally(() => { if (mountedRef.current) setRestarting(false) })
+ }}
+ disabled={restarting}
+ className="flex items-center gap-1 px-2 py-1 text-[9px] font-medium text-[var(--yellow)] bg-[rgba(234,179,8,0.08)] border border-[rgba(234,179,8,0.15)] rounded hover:bg-[rgba(234,179,8,0.15)] transition-colors disabled:opacity-40"
+ title="Kill and restart this agent"
+ >
+
+ {restarting ? 'Restarting...' : 'Restart'}
+
+ {restartError && (
+ {restartError}
+ )}
+
+ )}
+
+ {expanded ? : }
+
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/components/agents/AgentLogViewer.jsx b/frontend/src/components/agents/AgentLogViewer.jsx
new file mode 100644
index 0000000..0041879
--- /dev/null
+++ b/frontend/src/components/agents/AgentLogViewer.jsx
@@ -0,0 +1,189 @@
+import { useEffect, useRef, useState } from 'react'
+import { useAgentLogs } from '../../hooks/useAgents'
+import { Spinner } from '../ui/Spinner'
+
+const MAX_DISPLAY = 500
+
+function eventTypeClass(eventType) {
+ switch (eventType) {
+ case 'assistant': return 'text-[var(--accent)]'
+ case 'tool_use': return 'text-[var(--yellow)]'
+ case 'tool_result': return 'text-[var(--text-muted)]'
+ case 'result': return 'text-[var(--green)]'
+ case 'error': return 'text-[var(--red)]'
+ case 'system': return 'text-[var(--blue)]'
+ case 'user': return 'text-[var(--text-muted)]'
+ case 'rate_limit_event': return 'text-[var(--yellow)]'
+ default: return 'text-[var(--text-muted)]'
+ }
+}
+
+function formatLogTime(ts) {
+ if (!ts) return ''
+ try {
+ const d = new Date(ts)
+ return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
+ } catch {
+ return ''
+ }
+}
+
+function formatToolUse(b) {
+ const tool = b.name || 'unknown'
+ const input = b.input || {}
+ if (tool === 'Bash') return `$ ${(input.command || '').substring(0, 120)}`
+ if (tool === 'Read') return `Read ${input.file_path || '?'}`
+ if (tool === 'Edit' || tool === 'Write') return `${tool} ${input.file_path || '?'}`
+ if (tool === 'Grep') return `Grep "${input.pattern || ''}"`
+ if (tool === 'Glob') return `Glob ${input.pattern || ''}`
+ if (tool === 'Skill') return `Skill: ${input.skill || '?'}`
+ if (tool === 'WebSearch') return `WebSearch: ${input.query || '?'}`
+ if (tool === 'WebFetch') return `WebFetch: ${input.url || '?'}`
+ if (tool === 'Agent') return `Agent: ${input.description || '?'}`
+ return `${tool}`
+}
+
+function tryParseEventData(raw, eventType) {
+ try {
+ const d = typeof raw === 'string' ? JSON.parse(raw) : raw
+ if (eventType === 'assistant' || d?.type === 'assistant') {
+ const blocks = d.message?.content || []
+ const parts = []
+ for (const b of blocks) {
+ if (b.type === 'text' && b.text) parts.push(b.text)
+ else if (b.type === 'tool_use') parts.push(formatToolUse(b))
+ else if (b.type === 'thinking' && b.thinking) parts.push('(thinking) ' + b.thinking)
+ else if (typeof b === 'string') parts.push(b)
+ }
+ return parts.join(' ') || null
+ }
+ if (eventType === 'user' || d?.type === 'user') return null
+ if (eventType === 'tool_use' || d?.type === 'tool_use') {
+ const tool = d.tool || d.name || 'unknown'
+ const input = d.input || {}
+ if (tool === 'Bash') return `$ ${input.command || ''}`
+ if (tool === 'Read') return `Read ${input.file_path || '?'}`
+ if (tool === 'Edit' || tool === 'Write') return `${tool} ${input.file_path || '?'}`
+ if (tool === 'Grep') return `Grep "${input.pattern || ''}"`
+ if (tool === 'Glob') return `Glob ${input.pattern || ''}`
+ if (tool === 'WebSearch') return `WebSearch: ${input.query || '?'}`
+ if (tool === 'WebFetch') return `WebFetch: ${input.url || '?'}`
+ if (tool === 'Agent') return `Agent: ${input.description || '?'}`
+ return `${tool}: ${JSON.stringify(input)}`
+ }
+ if (eventType === 'tool_result' || d?.type === 'tool_result') return null
+ if (eventType === 'result' || d?.type === 'result') {
+ const r = d.result
+ if (typeof r === 'string') return r
+ if (r && typeof r === 'object') return JSON.stringify(r)
+ return 'Agent finished'
+ }
+ if (eventType === 'error' || d?.type === 'error') {
+ const err = d.error
+ if (typeof err === 'string') return err
+ if (err && err.message) return err.message
+ return 'Error occurred'
+ }
+ if (eventType === 'system' || d?.type === 'system') {
+ if (d.subtype === 'init') return `Session started in ${d.cwd || '?'}`
+ return d.message || d.text || null
+ }
+ if (eventType === 'rate_limit_event') return 'Rate limit event'
+ return null
+ } catch {
+ return raw || null
+ }
+}
+
+export function AgentLogViewer({ agentId, isRunning }) {
+ const containerRef = useRef(null)
+ const [allEvents, setAllEvents] = useState([])
+ // Mirror of allEvents state — kept in sync so cursor and events can be
+ // updated atomically in the same synchronous block (fixes the race condition
+ // where the cursor could advance even when React deferred the state update).
+ const allEventsRef = useRef([])
+ // Track the agentId for which the cursor was last reset so the data effect
+ // can guard against overwriting the reset with stale cached data.
+ const cursorAgentIdRef = useRef(agentId)
+
+ const { data, isLoading, cursorRef } = useAgentLogs(agentId, { refetchInterval: isRunning ? 3000 : false })
+
+ useEffect(() => {
+ cursorAgentIdRef.current = agentId
+ setAllEvents([])
+ allEventsRef.current = []
+ // cursorRef.current is already reset synchronously during render in
+ // useAgentLogs, which ensures the reset happens before TanStack Query's
+ // microtask captures the cursor value. Resetting here (post-paint) would
+ // race with the first fetch and cause unnecessary re-fetching of already-seen events.
+ }, [agentId, cursorRef])
+
+ useEffect(() => {
+ // Guard the entire update with the agentId check to prevent stale cached
+ // events from a previous agent being appended before the reset effect runs.
+ if (data?.events?.length > 0 && cursorAgentIdRef.current === agentId) {
+ const existingIds = new Set(allEventsRef.current.map(e => e.id))
+ const newEvents = data.events.filter(e => !existingIds.has(e.id))
+ // Only advance the cursor and update state when there are genuinely new
+ // events. Advancing unconditionally would skip events on the next poll
+ // whenever deduplication discards an entire response (fixes Thread 3).
+ // Updating cursor and state in the same synchronous block ensures they
+ // stay atomic — no window where the cursor has moved but state hasn't
+ // been committed (fixes Thread 4).
+ if (newEvents.length > 0) {
+ const newCursor = data.events[data.events.length - 1].id
+ const merged = [...allEventsRef.current, ...newEvents]
+ allEventsRef.current = merged
+ setAllEvents(merged)
+ cursorRef.current = newCursor
+ }
+ }
+ }, [data, cursorRef, agentId])
+
+ // Only auto-scroll if the user is already near the bottom (within 50px).
+ // This prevents hijacking the scroll when the user is reading earlier logs.
+ useEffect(() => {
+ const el = containerRef.current
+ if (!isRunning || !el) return
+ const nearBottom = (el.scrollHeight - el.scrollTop - el.clientHeight) < 50
+ if (nearBottom) {
+ el.scrollTop = el.scrollHeight
+ }
+ }, [allEvents, isRunning])
+
+ const displayEvents = allEvents.slice(-MAX_DISPLAY)
+
+ if (isLoading && allEvents.length === 0) {
+ return (
+
+
+
+ )
+ }
+
+ const formattedEvents = displayEvents
+ .map((event) => {
+ const summary = tryParseEventData(event.event_data, event.event_type)
+ if (!summary) return null
+ return { ...event, summary }
+ })
+ .filter(Boolean)
+
+ return (
+
+ {formattedEvents.length === 0 ? (
+
Waiting for events...
+ ) : (
+ formattedEvents.map((event) => (
+
+ {formatLogTime(event.timestamp)}
+
+ {event.event_type}
+
+ {event.summary}
+
+ ))
+ )}
+
+ )
+}
diff --git a/frontend/src/components/agents/AgentStatusBadge.jsx b/frontend/src/components/agents/AgentStatusBadge.jsx
new file mode 100644
index 0000000..66c65bd
--- /dev/null
+++ b/frontend/src/components/agents/AgentStatusBadge.jsx
@@ -0,0 +1,16 @@
+import { Badge } from '../ui/Badge'
+
+const STATUS_MAP = {
+ running: { variant: 'purple', label: 'Running' },
+ completed: { variant: 'green', label: 'Completed' },
+ failed: { variant: 'red', label: 'Failed' },
+ rate_limited: { variant: 'yellow', label: 'Rate Limited' },
+ timed_out: { variant: 'red', label: 'Timed Out' },
+ stopped: { variant: 'yellow', label: 'Stopped' },
+ resumed: { variant: 'dim', label: 'Resumed' },
+}
+
+export function AgentStatusBadge({ status }) {
+ const { variant, label } = STATUS_MAP[status] || { variant: 'dim', label: status }
+ return {label}
+}
diff --git a/frontend/src/components/issues/IssueQueue.jsx b/frontend/src/components/issues/IssueQueue.jsx
new file mode 100644
index 0000000..5ae78f3
--- /dev/null
+++ b/frontend/src/components/issues/IssueQueue.jsx
@@ -0,0 +1,146 @@
+import { useState } from 'react'
+import { ExternalLink, RefreshCw, Inbox } from 'lucide-react'
+import { IssueStatusBadge } from './IssueStatusBadge'
+import { EmptyState } from '../ui/EmptyState'
+import { Button } from '../ui/Button'
+import { Spinner } from '../ui/Spinner'
+import { useIssues, useUpdateIssueStatus } from '../../hooks/useIssues'
+import { useWorkspaceContext } from '../../context/WorkspaceContext'
+import { useWorkspaces } from '../../hooks/useWorkspaces'
+import { formatDistanceToNow } from 'date-fns'
+
+const REPO_RE = /^[\w.-]+\/[\w.-]+$/
+function buildGitHubUrl(repo, section, number) {
+ if (!repo || !REPO_RE.test(repo)) return null
+ if (!Number.isInteger(number) || number <= 0) return null
+ const [owner, name] = repo.split('/')
+ return `https://github.com/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/${section}/${number}`
+}
+
+export function IssueQueue() {
+ const { selectedWorkspaceId } = useWorkspaceContext()
+ const { data, isLoading } = useIssues(selectedWorkspaceId)
+ const { mutate: updateStatus } = useUpdateIssueStatus(selectedWorkspaceId)
+ const { data: wsData } = useWorkspaces()
+ const workspaces = wsData?.workspaces || []
+ const wsMap = Object.fromEntries(workspaces.map(w => [w.id, w.name || w.repo_url]))
+ const wsRepoMap = Object.fromEntries(workspaces.map(w => [w.id, w.github_repo]))
+ const showWorkspace = !selectedWorkspaceId
+ const [retryingIssues, setRetryingIssues] = useState(new Set())
+
+ const issues = data?.issues || []
+
+ return (
+
+
+
+ Issue Queue
+ {issues.length > 0 && (
+ ({issues.length})
+ )}
+
+
+
+ {isLoading ? (
+
+
+
+ ) : issues.length === 0 ? (
+
+ ) : (
+
+
+
+
+ #
+ Title
+ {showWorkspace && Workspace }
+ Status
+ Tries
+ PR
+ Updated
+
+
+
+
+ {issues.map((issue) => (
+
+
+ {issue.issue_number}
+
+
+
+ {issue.title || `Issue #${issue.issue_number}`}
+
+
+
+ {showWorkspace && (
+
+
+ {wsMap[issue.workspace_id] || '\u2014'}
+
+
+ )}
+
+
+
+
+ {issue.attempts ?? 0}
+
+
+ {issue.pr_number ? (
+
+ #{issue.pr_number}
+
+ ) : (
+ \u2014
+ )}
+
+
+ {issue.updated_at
+ ? formatDistanceToNow(new Date(issue.updated_at), { addSuffix: true })
+ : '\u2014'}
+
+
+ {issue.status === 'needs_human' && (
+ {
+ const key = `${issue.workspace_id}-${issue.issue_number}`
+ setRetryingIssues(prev => new Set(prev).add(key))
+ updateStatus(
+ { issueNumber: issue.issue_number, status: 'pending', workspaceId: issue.workspace_id },
+ { onSettled: () => setRetryingIssues(prev => { const next = new Set(prev); next.delete(key); return next }) }
+ )
+ }}
+ title="Retry this issue"
+ >
+
+ Retry
+
+ )}
+
+
+ ))}
+
+
+
+ )}
+
+ )
+}
diff --git a/frontend/src/components/issues/IssueStatusBadge.jsx b/frontend/src/components/issues/IssueStatusBadge.jsx
new file mode 100644
index 0000000..5c0cf91
--- /dev/null
+++ b/frontend/src/components/issues/IssueStatusBadge.jsx
@@ -0,0 +1,14 @@
+import { Badge } from '../ui/Badge'
+
+const STATUS_MAP = {
+ resolved: { variant: 'green', label: 'Resolved' },
+ in_progress: { variant: 'purple', label: 'In Progress' },
+ pending: { variant: 'yellow', label: 'Pending' },
+ needs_human: { variant: 'red', label: 'Needs Human' },
+ pr_created: { variant: 'blue', label: 'PR Created' },
+}
+
+export function IssueStatusBadge({ status }) {
+ const { variant, label } = STATUS_MAP[status] || { variant: 'dim', label: status }
+ return {label}
+}
diff --git a/frontend/src/components/layout/Header.jsx b/frontend/src/components/layout/Header.jsx
new file mode 100644
index 0000000..83a6fd2
--- /dev/null
+++ b/frontend/src/components/layout/Header.jsx
@@ -0,0 +1,88 @@
+import { Settings, Plus, Check, AlertTriangle, RefreshCw } from 'lucide-react'
+import { WorkspaceSwitcher } from './WorkspaceSwitcher'
+import { useMetrics } from '../../hooks/useMetrics'
+import { useGitSync } from '../../hooks/useGitSync'
+import { useWorkspaceContext } from '../../context/WorkspaceContext'
+import { formatDistanceToNow } from 'date-fns'
+
+function SyncIndicator({ wsId }) {
+ const { data, isLoading } = useGitSync(wsId)
+
+ if (!wsId || isLoading || !data) return null
+
+ if (data.synced) {
+ return (
+
+
+ Synced
+
+ )
+ }
+
+ const behind = data.behind || 0
+ const label = behind > 0 ? `${behind} behind` : 'Out of sync'
+
+ return (
+
+
+ {label}
+
+ )
+}
+
+export function Header({ onAddWorkspace, onOpenSettings, onOpenPlanner }) {
+ const { selectedWorkspaceId } = useWorkspaceContext()
+ const { dataUpdatedAt } = useMetrics(selectedWorkspaceId)
+
+ const lastUpdated = dataUpdatedAt
+ ? formatDistanceToNow(new Date(dataUpdatedAt), { addSuffix: true })
+ : null
+
+ return (
+
+ )
+}
diff --git a/frontend/src/components/layout/TabNav.jsx b/frontend/src/components/layout/TabNav.jsx
new file mode 100644
index 0000000..f3d1511
--- /dev/null
+++ b/frontend/src/components/layout/TabNav.jsx
@@ -0,0 +1,43 @@
+const TABS = [
+ { id: 'agents', label: 'Agents' },
+ { id: 'issues', label: 'Issues' },
+ { id: 'prs', label: 'PRs' },
+]
+
+export function TabNav({ activeTab, onTabChange, counts = {} }) {
+ return (
+
+ {TABS.map((tab) => {
+ const count = counts[tab.id]
+ const isActive = activeTab === tab.id
+ return (
+ onTabChange(tab.id)}
+ className={`relative px-5 py-2.5 text-[12px] font-medium transition-colors ${
+ isActive
+ ? 'text-[var(--text)]'
+ : 'text-[var(--text-muted)] hover:text-[var(--text-dim)]'
+ }`}
+ >
+ {tab.label}
+ {count != null && count > 0 && (
+
+ {count}
+
+ )}
+ {isActive && (
+
+ )}
+
+ )
+ })}
+
+ )
+}
diff --git a/frontend/src/components/layout/WorkspaceSwitcher.jsx b/frontend/src/components/layout/WorkspaceSwitcher.jsx
new file mode 100644
index 0000000..2f7338f
--- /dev/null
+++ b/frontend/src/components/layout/WorkspaceSwitcher.jsx
@@ -0,0 +1,100 @@
+import { useState, useRef, useEffect } from 'react'
+import { ChevronDown, Plus, Layers } from 'lucide-react'
+import { useWorkspaces } from '../../hooks/useWorkspaces'
+import { useWorkspaceContext } from '../../context/WorkspaceContext'
+
+function StatusDot({ status }) {
+ const colors = {
+ active: 'bg-[var(--green)] shadow-[0_0_4px_var(--green)]',
+ cloning: 'bg-[var(--yellow)] shadow-[0_0_4px_var(--yellow)]',
+ error: 'bg-[var(--red)] shadow-[0_0_4px_var(--red)]',
+ }
+ return (
+
+ )
+}
+
+export function WorkspaceSwitcher({ onAddWorkspace }) {
+ const [open, setOpen] = useState(false)
+ const ref = useRef(null)
+ const { data, isLoading } = useWorkspaces()
+ const { selectedWorkspaceId, setSelectedWorkspaceId } = useWorkspaceContext()
+
+ const workspaces = data?.workspaces || []
+ const selected = workspaces.find((w) => w.id === selectedWorkspaceId)
+
+ useEffect(() => {
+ const handler = (e) => {
+ if (ref.current && !ref.current.contains(e.target)) setOpen(false)
+ }
+ document.addEventListener('mousedown', handler)
+ return () => document.removeEventListener('mousedown', handler)
+ }, [])
+
+ useEffect(() => {
+ if (!isLoading && selectedWorkspaceId && workspaces.length > 0 && !selected) {
+ setSelectedWorkspaceId(null)
+ }
+ }, [isLoading, selectedWorkspaceId, workspaces, selected, setSelectedWorkspaceId])
+
+ return (
+
+
setOpen((v) => !v)}
+ className="flex items-center gap-2 px-3 py-1.5 bg-[var(--bg-raised)] border border-[var(--border)] rounded-lg text-[12px] cursor-pointer hover:border-[var(--text-muted)] transition-all"
+ >
+ {selected ? (
+ <>
+
+ {selected.name || selected.repo_url}
+ >
+ ) : (
+ <>
+
+ All Workspaces
+ >
+ )}
+
+
+
+ {open && (
+
+
{ setSelectedWorkspaceId(null); setOpen(false) }}
+ >
+
+ All Workspaces
+
+
+ {workspaces.length > 0 && (
+
+ )}
+
+ {workspaces.map((ws) => (
+
{ setSelectedWorkspaceId(ws.id); setOpen(false) }}
+ >
+
+ {ws.name || ws.repo_url}
+
+ {ws.repo_url?.replace(/^https?:\/\/github\.com\//, '')}
+
+
+ ))}
+
+
+
{ onAddWorkspace?.(); setOpen(false) }}
+ >
+
+
Add Workspace
+
+
+ )}
+
+ )
+}
diff --git a/frontend/src/components/metrics/MetricCard.jsx b/frontend/src/components/metrics/MetricCard.jsx
new file mode 100644
index 0000000..948ce4b
--- /dev/null
+++ b/frontend/src/components/metrics/MetricCard.jsx
@@ -0,0 +1,38 @@
+export function MetricCard({ label, value, color }) {
+ const colorMap = {
+ green: 'var(--green)',
+ blue: 'var(--blue)',
+ yellow: 'var(--yellow)',
+ red: 'var(--red)',
+ accent: 'var(--accent)',
+ text: 'var(--text)',
+ }
+ const dimMap = {
+ green: 'var(--green-dim)',
+ blue: 'var(--blue-dim)',
+ yellow: 'var(--yellow-dim)',
+ red: 'var(--red-dim)',
+ accent: 'var(--accent-dim)',
+ text: 'rgba(220,223,232,0.04)',
+ }
+ const c = colorMap[color] || 'var(--text)'
+ const bg = dimMap[color] || 'rgba(220,223,232,0.04)'
+
+ return (
+
+
+
+ {value ?? '\u2014'}
+
+
+ {label}
+
+
+ )
+}
diff --git a/frontend/src/components/metrics/MetricsBar.jsx b/frontend/src/components/metrics/MetricsBar.jsx
new file mode 100644
index 0000000..5f4398b
--- /dev/null
+++ b/frontend/src/components/metrics/MetricsBar.jsx
@@ -0,0 +1,27 @@
+import { MetricCard } from './MetricCard'
+import { useMetrics } from '../../hooks/useMetrics'
+import { useWorkspaceContext } from '../../context/WorkspaceContext'
+
+export function MetricsBar() {
+ const { selectedWorkspaceId } = useWorkspaceContext()
+ const { data, isLoading } = useMetrics(selectedWorkspaceId)
+
+ const m = data || {}
+ const cards = [
+ { label: 'Resolved', value: isLoading ? '\u2014' : (m.resolved ?? 0), color: 'green' },
+ { label: 'In Queue', value: isLoading ? '\u2014' : (m.pending ?? 0), color: 'text' },
+ { label: 'In Progress', value: isLoading ? '\u2014' : (m.in_progress ?? 0), color: 'accent' },
+ { label: 'PRs Open', value: isLoading ? '\u2014' : (m.prs_open ?? 0), color: 'blue' },
+ { label: 'Needs Human', value: isLoading ? '\u2014' : (m.needs_human ?? 0), color: 'red' },
+ { label: 'Rate Limited', value: isLoading ? '\u2014' : (m.rate_limited ?? 0), color: 'yellow' },
+ { label: 'Avg Turns', value: isLoading ? '\u2014' : (m.avg_turns != null ? m.avg_turns.toFixed(1) : '0.0'), color: 'text' },
+ ]
+
+ return (
+
+ {cards.map((card) => (
+
+ ))}
+
+ )
+}
diff --git a/frontend/src/components/modals/AddWorkspaceModal.jsx b/frontend/src/components/modals/AddWorkspaceModal.jsx
new file mode 100644
index 0000000..2134e6d
--- /dev/null
+++ b/frontend/src/components/modals/AddWorkspaceModal.jsx
@@ -0,0 +1,75 @@
+import { useState, useEffect } from 'react'
+import { Modal } from '../ui/Modal'
+import { Button } from '../ui/Button'
+import { useCreateWorkspace } from '../../hooks/useWorkspaces'
+import { useWorkspaceContext } from '../../context/WorkspaceContext'
+
+export function AddWorkspaceModal({ open, onClose }) {
+ const { mutate: create, isPending } = useCreateWorkspace()
+ const { setSelectedWorkspaceId } = useWorkspaceContext()
+ const [form, setForm] = useState({ repo_url: '', name: '', base_branch: 'main' })
+ const [error, setError] = useState('')
+
+ useEffect(() => {
+ if (!open) {
+ setForm({ repo_url: '', name: '', base_branch: 'main' })
+ setError('')
+ }
+ }, [open])
+
+ const set = (field) => (e) => setForm((f) => ({ ...f, [field]: e.target.value }))
+
+ const inputClass = 'w-full px-3 py-2 text-[11px] bg-[var(--bg)] border border-[var(--border)] rounded-md text-[var(--text)] font-mono focus:border-[var(--accent)] outline-none placeholder:text-[var(--text-muted)] transition-colors'
+
+ const handleSubmit = (e) => {
+ e.preventDefault()
+ setError('')
+ if (!form.repo_url.trim()) {
+ setError('Repository URL is required')
+ return
+ }
+ create(
+ { repo_url: form.repo_url.trim(), name: form.name.trim() || undefined, base_branch: form.base_branch || 'main' },
+ {
+ onSuccess: (data) => {
+ if (data?.workspace?.id) setSelectedWorkspaceId(data.workspace.id)
+ onClose()
+ setForm({ repo_url: '', name: '', base_branch: 'main' })
+ },
+ onError: (err) => setError(err.message),
+ }
+ )
+ }
+
+ return (
+
+
+
+ )
+}
diff --git a/frontend/src/components/modals/EnvEditor.jsx b/frontend/src/components/modals/EnvEditor.jsx
new file mode 100644
index 0000000..765b22c
--- /dev/null
+++ b/frontend/src/components/modals/EnvEditor.jsx
@@ -0,0 +1,361 @@
+import { useState, useEffect, useRef } from 'react'
+import { Plus, Trash2, Upload, Save, RefreshCw } from 'lucide-react'
+import { Button } from '../ui/Button'
+import { Spinner } from '../ui/Spinner'
+import { getEnvFiles, getEnv, saveEnv, deleteEnvFile, loadEnvFromDisk } from '../../api/client'
+
+function parseEnvText(text) {
+ const vars = {}
+ const lines = text.split('\n')
+ let i = 0
+ while (i < lines.length) {
+ const trimmed = lines[i++].trim()
+ if (!trimmed || trimmed.startsWith('#')) continue
+ const idx = trimmed.indexOf('=')
+ if (idx < 0) continue
+ const key = trimmed.slice(0, idx).trim()
+ if (!key) continue
+ let raw = trimmed.slice(idx + 1).trim()
+ // Accumulate continuation lines for multi-line quoted values.
+ // Count unescaped occurrences of the quote char — we need at least
+ // two (opening + closing) before the value is complete.
+ if (raw.startsWith('"') || raw.startsWith("'")) {
+ const q = raw[0]
+ const countUnescaped = (s) => {
+ let count = 0
+ for (let j = 0; j < s.length; j++) {
+ if (s[j] === '\\') { j++; continue }
+ if (s[j] === q) count++
+ }
+ return count
+ }
+ while (countUnescaped(raw) < 2 && i < lines.length) {
+ raw += '\n' + lines[i++]
+ }
+ }
+ const quoteMatch = raw.match(/^(['"])([\s\S]*)\1$/)
+ const val = quoteMatch
+ ? quoteMatch[2].replace(new RegExp(`\\\\${quoteMatch[1]}`, 'g'), quoteMatch[1])
+ : raw
+ vars[key] = val
+ }
+ return vars
+}
+
+export function EnvEditor({ workspaceId }) {
+ const [envFiles, setEnvFiles] = useState([])
+ const [activeFile, setActiveFile] = useState('.env')
+ const [fetchCounter, setFetchCounter] = useState(0)
+ const [rows, setRows] = useState([])
+ const [loading, setLoading] = useState(false)
+ const [saving, setSaving] = useState(false)
+ const [saveError, setSaveError] = useState(null)
+ const [fileReadError, setFileReadError] = useState(null)
+ const [pasteText, setPasteText] = useState('')
+ const [showPaste, setShowPaste] = useState(false)
+ const [syncing, setSyncing] = useState(false)
+ const [dirty, setDirty] = useState(false)
+ // Keep a ref in sync so the load effect can read the current dirty value
+ // without including it in the dependency array (dirty is a guard, not a trigger).
+ const dirtyRef = useRef(false)
+ dirtyRef.current = dirty
+ // Stores the vars from the last successful save so that a Discard action can
+ // revert to the confirmed server state even if the post-save re-fetch was
+ // suppressed by the dirty guard (user typed before the re-fetch fired).
+ const lastSavedRef = useRef(null)
+
+ useEffect(() => {
+ setActiveFile('.env')
+ setDirty(false)
+ }, [workspaceId])
+
+ useEffect(() => {
+ if (!workspaceId) return
+ getEnvFiles(workspaceId).then((data) => {
+ const managed = data.managed || []
+ // discovered can be strings or objects with {path, exists}
+ const discovered = (data.discovered || []).map(d => typeof d === 'string' ? d : d.path)
+ const files = [...new Set(['.env', ...managed, ...discovered])]
+ setEnvFiles(files)
+ }).catch(() => setEnvFiles(['.env']))
+ }, [workspaceId])
+
+ // Reset dirty flag and last-saved snapshot when switching files
+ useEffect(() => {
+ lastSavedRef.current = null
+ setDirty(false)
+ }, [activeFile])
+
+ useEffect(() => {
+ if (!workspaceId || !activeFile) return
+ // Don't overwrite unsaved edits on cache-bust refetches.
+ // Use dirtyRef (not dirty state) so this guard doesn't re-trigger the
+ // effect on every keystroke — dirty is a guard condition, not a trigger.
+ if (dirtyRef.current && fetchCounter > 0) return
+ let cancelled = false
+ setLoading(true)
+ setFileReadError(null)
+
+ const toRows = (vars) =>
+ Object.entries(vars || {}).map(([k, v]) => ({ id: k, key: k, value: v }))
+
+ // First try getEnv (which auto-syncs from disk if file changed).
+ // If that returns empty, explicitly load from disk as a fallback.
+ getEnv(workspaceId, activeFile)
+ .then(async (data) => {
+ if (cancelled) return
+ const vars = data.vars || {}
+ if (Object.keys(vars).length > 0) {
+ setRows(toRows(vars))
+ } else {
+ // Try loading directly from disk
+ try {
+ const diskData = await loadEnvFromDisk(workspaceId, activeFile)
+ if (!cancelled) {
+ setRows(toRows(diskData.vars || {}))
+ }
+ } catch {
+ if (!cancelled) setRows([])
+ }
+ }
+ })
+ .catch(() => {
+ if (!cancelled) setRows([])
+ })
+ .finally(() => {
+ if (!cancelled) setLoading(false)
+ })
+ return () => { cancelled = true }
+ }, [workspaceId, activeFile, fetchCounter])
+
+ const setRow = (i, field, val) => {
+ setDirty(true)
+ setRows((prev) => prev.map((r, idx) => {
+ if (idx !== i) return r
+ // Keep the row's id in sync with the key name so that after save + reload
+ // (where the server rebuilds rows as { id: keyName, key: keyName, value })
+ // React's key prop remains stable and avoids unmounting/remounting inputs.
+ if (field === 'key') return { ...r, key: val, id: val || r.id }
+ return { ...r, [field]: val }
+ }))
+ }
+
+ const addRow = () => { setDirty(true); setRows((prev) => [...prev, { id: crypto.randomUUID(), key: '', value: '' }]) }
+ const removeRow = (i) => { setDirty(true); setRows((prev) => prev.filter((_, idx) => idx !== i)) }
+
+ const handleSave = async () => {
+ if (!workspaceId) return
+
+ // Check for duplicate keys before saving
+ const keysSeen = new Set()
+ const duplicates = new Set()
+ for (const { key } of rows) {
+ const trimmed = key.trim()
+ if (!trimmed) continue
+ if (keysSeen.has(trimmed)) {
+ duplicates.add(trimmed)
+ }
+ keysSeen.add(trimmed)
+ }
+ if (duplicates.size > 0) {
+ setSaveError(`Duplicate keys detected: ${[...duplicates].join(', ')}. Remove or rename duplicate rows before saving.`)
+ return
+ }
+
+ setSaving(true)
+ setSaveError(null)
+ const vars = {}
+ for (const { key, value } of rows) {
+ if (key.trim()) vars[key.trim()] = value
+ }
+ try {
+ await saveEnv(workspaceId, activeFile, vars)
+ lastSavedRef.current = vars
+ setDirty(false)
+ setFetchCounter(c => c + 1)
+ } catch (err) {
+ setSaveError(err?.message || 'Failed to save')
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ const handleDiscard = () => {
+ if (lastSavedRef.current !== null) {
+ const saved = lastSavedRef.current
+ setRows(Object.entries(saved).map(([k, v]) => ({ id: k, key: k, value: v })))
+ setDirty(false)
+ } else {
+ setFetchCounter(c => c + 1)
+ setDirty(false)
+ }
+ }
+
+ const handlePaste = () => {
+ const parsed = parseEnvText(pasteText)
+ const newRows = Object.entries(parsed).map(([k, v]) => ({ id: k, key: k, value: v }))
+ setRows((prev) => {
+ // Keep at most one blank row to prevent accumulation on repeated paste.
+ const existingBlankRow = prev.find(r => !r.key.trim())
+ const blankRows = existingBlankRow ? [existingBlankRow] : []
+ const existingMap = new Map(prev.filter(r => r.key.trim()).map((r) => [r.key, r]))
+ newRows.forEach((r) => {
+ if (existingMap.has(r.key)) {
+ existingMap.set(r.key, { ...existingMap.get(r.key), value: r.value })
+ } else {
+ existingMap.set(r.key, r)
+ }
+ })
+ return [...Array.from(existingMap.values()), ...blankRows]
+ })
+ setDirty(true)
+ setPasteText('')
+ setShowPaste(false)
+ }
+
+ const handleFileUpload = (e) => {
+ const file = e.target.files?.[0]
+ if (!file) return
+ const reader = new FileReader()
+ reader.onload = (ev) => {
+ setFileReadError(null)
+ const parsed = parseEnvText(ev.target.result)
+ const newRows = Object.entries(parsed).map(([k, v]) => ({ id: k, key: k, value: v }))
+ setRows((prev) => {
+ // Keep at most one blank row to prevent accumulation on repeated upload.
+ const existingBlankRow = prev.find(r => !r.key.trim())
+ const blankRows = existingBlankRow ? [existingBlankRow] : []
+ const existingMap = new Map(prev.filter(r => r.key.trim()).map((r) => [r.key, r]))
+ newRows.forEach((r) => {
+ if (existingMap.has(r.key)) {
+ existingMap.set(r.key, { ...existingMap.get(r.key), value: r.value })
+ } else {
+ existingMap.set(r.key, r)
+ }
+ })
+ return [...Array.from(existingMap.values()), ...blankRows]
+ })
+ setDirty(true)
+ }
+ reader.onerror = () => {
+ setFileReadError('Failed to read file')
+ }
+ reader.readAsText(file)
+ e.target.value = ''
+ }
+
+ const handleSyncFromDisk = async () => {
+ if (!workspaceId || !activeFile) return
+ setSyncing(true)
+ try {
+ const data = await loadEnvFromDisk(workspaceId, activeFile)
+ const entries = Object.entries(data.vars || {}).map(([k, v]) => ({ id: k, key: k, value: v }))
+ setRows(entries)
+ setDirty(false)
+ } catch (err) {
+ setFileReadError(err?.message || 'Failed to sync from disk')
+ } finally {
+ setSyncing(false)
+ }
+ }
+
+ const inputClass = 'px-2.5 py-1.5 text-[10px] bg-[var(--bg)] border border-[var(--border)] rounded text-[var(--text)] font-mono focus:border-[var(--accent)] outline-none transition-colors'
+
+ if (loading) {
+ return
+ }
+
+ return (
+
+
+ {envFiles.map((f) => (
+ setActiveFile(f)}
+ className={`relative px-2.5 py-1 text-[10px] rounded-md border font-mono transition-all ${
+ activeFile === f
+ ? 'border-[var(--accent-border)] text-[var(--accent)] bg-[var(--accent-dim)]'
+ : 'border-[var(--border)] text-[var(--text-muted)] hover:border-[var(--text-muted)] hover:text-[var(--text-dim)]'
+ }`}
+ >
+ {f}
+
+ ))}
+
+
+
+
+ {showPaste && (
+
+ )}
+
+ {fileReadError &&
{fileReadError}
}
+ {saveError &&
{saveError}
}
+
+
+
+ Add row
+
+
setShowPaste((v) => !v)}>
+ Paste .env
+
+
+
+ Upload
+
+
+
+
+ Sync from disk
+
+
+ {dirty && (
+
+ Discard
+
+ )}
+
+ Save
+
+
+
+
+ )
+}
diff --git a/frontend/src/components/modals/WorkspaceSettingsModal.jsx b/frontend/src/components/modals/WorkspaceSettingsModal.jsx
new file mode 100644
index 0000000..fc6b737
--- /dev/null
+++ b/frontend/src/components/modals/WorkspaceSettingsModal.jsx
@@ -0,0 +1,364 @@
+import { useState, useEffect, useRef } from 'react'
+import { FolderTree, GitBranch, FileCode, Package, RefreshCw, Check, AlertTriangle, ArrowDown } from 'lucide-react'
+import { Modal } from '../ui/Modal'
+import { Button } from '../ui/Button'
+import { Badge } from '../ui/Badge'
+import { Spinner } from '../ui/Spinner'
+import { EnvEditor } from './EnvEditor'
+import { useUpdateWorkspace, useDeleteWorkspace, useWorkspaces } from '../../hooks/useWorkspaces'
+import { useWorkspaceContext } from '../../context/WorkspaceContext'
+import { useGitSync } from '../../hooks/useGitSync'
+import { getWorkspaceStructure } from '../../api/client'
+
+const TABS = ['General', 'Env Files', 'Structure']
+
+function GitPullSection({ workspace }) {
+ const { data: syncStatus, isLoading, refetch, pull } = useGitSync(workspace?.id)
+
+ const isSynced = syncStatus?.synced
+ const behind = syncStatus?.behind || 0
+ const ahead = syncStatus?.ahead || 0
+
+ return (
+
+
+
+
+ Git Sync
+
+
refetch()}
+ disabled={isLoading}
+ className="p-1 rounded text-[var(--text-muted)] hover:text-[var(--text-dim)] hover:bg-[var(--surface-hover)] transition-colors disabled:opacity-40"
+ title="Refresh status"
+ >
+
+
+
+
+ {isLoading && !syncStatus && (
+
+ )}
+
+ {syncStatus && (
+
+
+ {isSynced ? (
+
+ Synced
+
+ ) : (
+
+ Out of sync
+
+ )}
+
+
+
+ Local
+ {syncStatus.local_sha || '\u2014'}
+ Remote
+ {syncStatus.remote_sha || '\u2014'}
+ {behind > 0 && (
+ <>
+ Behind
+ {behind} commit{behind !== 1 ? 's' : ''}
+ >
+ )}
+ {ahead > 0 && (
+ <>
+ Ahead
+ {ahead} commit{ahead !== 1 ? 's' : ''}
+ >
+ )}
+
+
+ {pull.error && (
+
+
+ {pull.error.message}
+
+ )}
+
+
pull.mutate()}
+ disabled={pull.isPending}
+ className="flex items-center gap-1.5 px-3 py-1.5 text-[10px] font-medium bg-[var(--accent)] text-white rounded-md hover:brightness-110 transition-all disabled:opacity-50 w-full justify-center"
+ >
+ {pull.isPending ? (
+ <> Pulling...>
+ ) : (
+ <> Pull Latest>
+ )}
+
+
+ )}
+
+ )
+}
+
+function StructureTab({ workspace }) {
+ const [structure, setStructure] = useState(null)
+ const [loading, setLoading] = useState(false)
+
+ useEffect(() => {
+ if (!workspace?.id) return
+ setLoading(true)
+ getWorkspaceStructure(workspace.id)
+ .then(data => setStructure(data.structure || {}))
+ .catch(() => setStructure(null))
+ .finally(() => setLoading(false))
+ }, [workspace?.id])
+
+ if (loading) {
+ return
+ }
+
+ const statusColors = {
+ active: 'green',
+ cloning: 'yellow',
+ error: 'red',
+ }
+
+ return (
+
+ {/* Git sync & pull */}
+ {workspace.status === 'active' &&
}
+
+ {/* Workspace info */}
+
+
+
+ Workspace
+
+
+ Path
+ {workspace.local_path || '\u2014'}
+ Status
+ {workspace.status || 'unknown'}
+ Branch
+
+
+ {workspace.base_branch || 'main'}
+
+
+
+
+ {/* Repo structure */}
+ {structure && (
+
+
+
+
+ {structure.is_monorepo ? 'Monorepo' : 'Standard'}
+
+
+
+ {/* Packages list */}
+ {structure.packages?.length > 0 && (
+
+
+ Packages ({structure.packages.length})
+
+
+ {structure.packages.map((pkg, i) => (
+
0 ? 'border-t border-[var(--border-subtle)]' : ''
+ }`}
+ >
+
+ {pkg.path}
+ {pkg.name && (
+ {pkg.name}
+ )}
+
+ ))}
+
+
+ )}
+
+ {/* Env files found */}
+ {structure.env_files?.length > 0 && (
+
+
+ Env Files Found
+
+
+ {structure.env_files.map((f, i) => {
+ const path = typeof f === 'string' ? f : f.path
+ return (
+
+
+ {path}
+
+ )
+ })}
+
+
+ )}
+
+ )}
+
+ {!structure && (
+
+ Could not load repo structure.
+
+ )}
+
+ )
+}
+
+export function WorkspaceSettingsModal({ open, onClose }) {
+ const [activeTab, setActiveTab] = useState('General')
+ const [confirmDelete, setConfirmDelete] = useState(false)
+ const [updateError, setUpdateError] = useState(null)
+ const { selectedWorkspaceId, setSelectedWorkspaceId } = useWorkspaceContext()
+ const { data } = useWorkspaces()
+ const { mutate: update, isPending: isUpdating } = useUpdateWorkspace()
+ const { mutate: del, isPending: isDeleting } = useDeleteWorkspace()
+
+ const liveWorkspace = data?.workspaces?.find((w) => w.id === selectedWorkspaceId)
+ const workspaceRef = useRef(liveWorkspace)
+ if (liveWorkspace) workspaceRef.current = liveWorkspace
+ const workspace = liveWorkspace || (open ? workspaceRef.current : null)
+ const [form, setForm] = useState({ name: '', repo_url: '', base_branch: '' })
+
+ useEffect(() => {
+ if (workspace) {
+ setForm({
+ name: workspace.name || '',
+ repo_url: workspace.repo_url || '',
+ base_branch: workspace.base_branch || '',
+ })
+ setUpdateError(null)
+ } else {
+ setForm({ name: '', repo_url: '', base_branch: '' })
+ }
+ setConfirmDelete(false)
+ }, [workspace?.id, workspace?.name, workspace?.repo_url, workspace?.base_branch])
+
+ useEffect(() => {
+ if (!open) {
+ setConfirmDelete(false)
+ setActiveTab('General')
+ } else {
+ setUpdateError(null)
+ }
+ }, [open])
+
+ const inputClass = 'w-full px-3 py-2 text-[11px] bg-[var(--bg)] border border-[var(--border)] rounded-md text-[var(--text)] font-mono focus:border-[var(--accent)] outline-none transition-colors'
+
+ const handleUpdate = (e) => {
+ e.preventDefault()
+ if (!selectedWorkspaceId) return
+ setUpdateError(null)
+ update(
+ { id: selectedWorkspaceId, data: form },
+ {
+ onSuccess: onClose,
+ onError: (err) => setUpdateError(err?.message || 'Failed to update workspace.'),
+ }
+ )
+ }
+
+ const handleDelete = () => {
+ if (!selectedWorkspaceId) return
+ del(selectedWorkspaceId, {
+ onSuccess: () => {
+ setSelectedWorkspaceId(null)
+ onClose()
+ },
+ onError: (err) => setUpdateError(err?.message || 'Failed to delete workspace.'),
+ })
+ }
+
+ if (!workspace) {
+ return (
+
+ No workspace selected. Select a workspace first.
+
+ Close
+
+
+ )
+ }
+
+ return (
+
+
+ {TABS.map((tab) => (
+ { setActiveTab(tab); setConfirmDelete(false); setUpdateError(null) }}
+ className={`relative px-4 py-2 text-[11px] font-medium transition-colors ${
+ activeTab === tab
+ ? 'text-[var(--text)]'
+ : 'text-[var(--text-muted)] hover:text-[var(--text-dim)]'
+ }`}
+ >
+ {tab}
+ {activeTab === tab && (
+
+ )}
+
+ ))}
+
+
+ {activeTab === 'General' && (
+
+ )}
+
+ {activeTab === 'Env Files' && (
+
+ )}
+
+ {activeTab === 'Structure' && (
+
+ )}
+
+ )
+}
diff --git a/frontend/src/components/planner/PlannerModal.jsx b/frontend/src/components/planner/PlannerModal.jsx
new file mode 100644
index 0000000..7a04fc3
--- /dev/null
+++ b/frontend/src/components/planner/PlannerModal.jsx
@@ -0,0 +1,687 @@
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { createPortal } from 'react-dom'
+import {
+ X, Plus, ChevronRight, ChevronDown,
+ Search, FileText, CheckCircle2, Brain,
+ Info, Terminal,
+} from 'lucide-react'
+import { Button } from '../ui/Button'
+import { Spinner } from '../ui/Spinner'
+import { usePlanning } from '../../hooks/usePlanning'
+import { useWorkspaceContext } from '../../context/WorkspaceContext'
+import { useWorkspaces } from '../../hooks/useWorkspaces'
+import { formatDistanceToNow } from 'date-fns'
+import { marked } from 'marked'
+import DOMPurify from 'dompurify'
+
+function renderMarkdown(text) {
+ if (!text) return ''
+ const html = marked.parse(text, { async: false })
+ return typeof html === 'string' ? DOMPurify.sanitize(html) : ''
+}
+
+const EVENT_CONFIG = {
+ tool_use: { Icon: Search, color: 'var(--yellow)', bg: 'var(--yellow-dim)', label: 'Searching' },
+ tool_result: { Icon: CheckCircle2, color: 'var(--green)', bg: 'var(--green-dim)', label: 'Result' },
+ thinking: { Icon: Brain, color: 'var(--accent)', bg: 'var(--accent-dim)', label: 'Thinking' },
+ info: { Icon: Info, color: 'var(--blue)', bg: 'var(--blue-dim)', label: 'Info' },
+ text: { Icon: FileText, color: 'var(--text-dim)',bg: 'rgba(255,255,255,0.04)', label: 'Output' },
+}
+
+function getEventConfig(type) {
+ return EVENT_CONFIG[type] || { Icon: Terminal, color: 'var(--text-muted)', bg: 'rgba(255,255,255,0.04)', label: type }
+}
+
+/* ─── Session sidebar ──────────────────────────────────────────────── */
+
+function SessionSidebar({ sessions, activeSessionId, onSelect, onNew, onDelete, confirmDeleteId, onCancelDelete }) {
+ return (
+
+
+
+ {sessions.length === 0 ? (
+
No sessions yet
+ ) : (
+ sessions.map(s => {
+ const isActive = s.id === activeSessionId
+ const preview = s.first_message || s.title || 'Untitled session'
+ const truncated = preview.length > 55 ? preview.slice(0, 52) + '\u2026' : preview
+ const statusColors = {
+ completed: 'bg-[var(--green-dim)] text-[var(--green)]',
+ active: 'bg-[var(--accent-dim)] text-[var(--accent)]',
+ generating: 'bg-[var(--yellow-dim)] text-[var(--yellow)]',
+ error: 'bg-[var(--red-dim)] text-[var(--red)]',
+ }
+ const statusClass = statusColors[s.status] || statusColors.active
+ const statusLabel = s.status === 'completed' ? '\u2713' : (s.status || 'active')
+ return (
+
onSelect(s.id)}
+ >
+
{truncated}
+
+
+
+ {statusLabel}
+
+ {s.updated_at && formatDistanceToNow(new Date(s.updated_at), { addSuffix: true })}
+
+ {confirmDeleteId === s.id ? (
+ e.stopPropagation()}>
+ onDelete(s.id)}
+ >
+ Confirm
+
+ onCancelDelete()}
+ >
+ Cancel
+
+
+ ) : (
+ { e.stopPropagation(); onDelete(s.id) }}
+ title="Delete"
+ >
+ ×
+
+ )}
+
+
+ )
+ })
+ )}
+
+
+ )
+}
+
+/* ─── Activity feed — replaces StreamingSteps + status bar ─────────── */
+
+function ActivityFeed({ events, isGenerating }) {
+ const [collapsed, setCollapsed] = useState(false)
+ const listRef = useRef(null)
+ const userScrolledUpRef = useRef(false)
+
+ const stepEvents = events.filter(e => e.event_type !== 'draft')
+
+ // Track whether user has scrolled up
+ useEffect(() => {
+ const el = listRef.current
+ if (!el) return
+ const handleScroll = () => {
+ const threshold = 30
+ const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight < threshold
+ userScrolledUpRef.current = !isAtBottom
+ }
+ el.addEventListener('scroll', handleScroll)
+ return () => el.removeEventListener('scroll', handleScroll)
+ }, [collapsed])
+
+ // Auto-scroll only if user hasn't scrolled up
+ useEffect(() => {
+ if (!collapsed && listRef.current && !userScrolledUpRef.current) {
+ listRef.current.scrollTop = listRef.current.scrollHeight
+ }
+ }, [stepEvents.length, collapsed])
+
+ if (stepEvents.length === 0 && !isGenerating) return null
+
+ // When generating with no events yet, show a pulsing indicator
+ if (stepEvents.length === 0 && isGenerating) {
+ return (
+
+
+ {'Starting analysis\u2026'}
+
+ )
+ }
+
+ const lastStep = stepEvents[stepEvents.length - 1]
+ const lastConfig = getEventConfig(lastStep?.event_type)
+ const stepCount = stepEvents.length
+
+ // Completed state — collapsed by default
+ if (!isGenerating) {
+ return (
+
+
setCollapsed(v => !v)}
+ >
+
+
+ Analysis complete — {stepCount} step{stepCount !== 1 ? 's' : ''}
+
+
+ {!collapsed && (
+
+
+
+ )}
+
+ )
+ }
+
+ // Generating state — open by default, with live header
+ return (
+
+ {/* Live status header */}
+
setCollapsed(v => !v)}
+ >
+
+
+
+ {lastStep?.summary || 'Analyzing\u2026'}
+
+
+ {stepCount}
+
+
+
+
+ {/* Scrollable event list */}
+ {!collapsed && (
+
+
+
+ )}
+
+ )
+}
+
+function EventList({ events }) {
+ return (
+
+ {events.map((ev, i) => {
+ const cfg = getEventConfig(ev.event_type)
+ const isThinking = ev.event_type === 'thinking' || ev.event_type === 'text'
+ const isResult = ev.event_type === 'tool_result'
+
+ return (
+
+ {/* Icon — results are indented under their tool_use, no icon */}
+ {!isResult && (
+
+
+
+ )}
+ {isResult && (
+
+ )}
+
+ {/* Content */}
+
+ {ev.summary}
+
+
+ )
+ })}
+
+ )
+}
+
+/* ─── Live draft (growing plan preview) ────────────────────────────── */
+
+function LiveDraft({ events }) {
+ const drafts = events.filter(e => e.event_type === 'draft')
+ if (drafts.length === 0) return null
+ const lastDraft = drafts[drafts.length - 1]
+ return (
+
+ )
+}
+
+/* ─── Create Issue action ──────────────────────────────────────────── */
+
+function PlanIssueAction({ messageIndex, planning }) {
+ const [showTitle, setShowTitle] = useState(false)
+ const [title, setTitle] = useState('')
+ const [createError, setCreateError] = useState(null)
+ const issueResult = planning.issueResults?.[messageIndex]
+ const isCreating = planning.creatingIssue === messageIndex
+ const isOtherCreating = planning.creatingIssue != null && planning.creatingIssue !== messageIndex
+
+ if (issueResult) {
+ return (
+
+
+
+ Issue created:{' '}
+ {issueResult.url?.startsWith('https://') ? (
+
+ {issueResult.url}
+
+ ) : (
+ issueResult.number != null ? `#${issueResult.number}` : 'created'
+ )}
+
+ {issueResult.title && (
+
{issueResult.title}
+ )}
+
+ )
+ }
+
+ return (
+
+
+ {
+ setCreateError(null)
+ try {
+ const result = await planning.createIssue(messageIndex, title.trim())
+ if (result?.error) {
+ setCreateError('Failed to create issue: ' + result.error)
+ }
+ } catch (err) {
+ setCreateError('Failed to create issue: ' + (err?.message || 'Unknown error'))
+ }
+ }}
+ >
+ Create Issue
+
+ {!isCreating && (
+ setShowTitle(v => !v)}
+ >
+ {showTitle ? : }
+ {' '}custom title
+
+ )}
+
+ {showTitle && (
+
setTitle(e.target.value)}
+ placeholder="Leave blank to auto-generate title"
+ className="mt-2 w-full bg-[var(--surface)] border border-[var(--border)] rounded p-2 text-[10px] font-mono text-[var(--text)] focus:outline-none focus:border-[var(--accent-border)] placeholder:text-[var(--text-muted)] transition-colors"
+ />
+ )}
+ {createError && (
+
{createError}
+ )}
+
+ )
+}
+
+/* ─── Workspace picker ─────────────────────────────────────────────── */
+
+function PlannerWorkspacePicker({ workspaceId, onChange }) {
+ const { data } = useWorkspaces()
+ const [open, setOpen] = useState(false)
+ const ref = useRef(null)
+ const workspaces = data?.workspaces || []
+ const selected = workspaces.find(w => w.id === workspaceId)
+
+ useEffect(() => {
+ const handler = (e) => {
+ if (ref.current && !ref.current.contains(e.target)) setOpen(false)
+ }
+ document.addEventListener('mousedown', handler)
+ return () => document.removeEventListener('mousedown', handler)
+ }, [])
+
+ if (workspaces.length === 0) {
+ return (
+ No workspaces configured
+ )
+ }
+
+ return (
+
+
setOpen(v => !v)}
+ className="flex items-center gap-1.5 px-2.5 py-1 bg-[var(--bg)] border border-[var(--border)] rounded-md text-[10px] hover:border-[var(--text-muted)] transition-colors"
+ >
+ {selected ? (
+ <>
+
+ {selected.name || selected.repo_url}
+ >
+ ) : (
+ Select workspace
+ )}
+
+
+
+ {open && (
+
+ {workspaces.map(ws => (
+
{ onChange(ws.id); setOpen(false) }}
+ >
+
+ {ws.name || ws.repo_url}
+
+ {ws.repo_url?.replace(/^https?:\/\/github\.com\//, '')}
+
+
+ ))}
+
+ )}
+
+ )
+}
+
+/* ─── Main modal ───────────────────────────────────────────────────── */
+
+export function PlannerModal({ open, onClose }) {
+ const { selectedWorkspaceId } = useWorkspaceContext()
+ const { data: wsData } = useWorkspaces()
+ const workspaces = wsData?.workspaces || []
+
+ const [plannerWsId, setPlannerWsId] = useState(null)
+ const prevOpenRef = useRef(false)
+
+ useEffect(() => {
+ if (open && !prevOpenRef.current) {
+ const initial = selectedWorkspaceId || (workspaces.length > 0 ? workspaces[0].id : null)
+ setPlannerWsId(initial)
+ }
+ prevOpenRef.current = open
+ }, [open, selectedWorkspaceId, workspaces])
+
+ const effectiveWsId = plannerWsId
+ || selectedWorkspaceId
+ || (workspaces.length > 0 ? workspaces[0].id : null)
+
+ const planning = usePlanning(effectiveWsId)
+ const { loadSessions } = planning
+ // Use a callback ref so the scroll-listener effect can attach even when the
+ // portal hasn't committed the DOM node yet at the time open transitions to
+ // true. setChatEl triggers a re-render → effect re-runs with the real node.
+ const [chatEl, setChatEl] = useState(null)
+ const chatRef = useCallback((node) => setChatEl(node), [])
+ const inputRef = useRef(null)
+ const chatUserScrolledUpRef = useRef(false)
+ const [inputValue, setInputValue] = useState('')
+ const [wsError, setWsError] = useState(null)
+ const [confirmDeleteId, setConfirmDeleteId] = useState(null)
+
+ useEffect(() => {
+ if (open && effectiveWsId) {
+ loadSessions()
+ const id = setTimeout(() => inputRef.current?.focus(), 100)
+ return () => clearTimeout(id)
+ }
+ }, [open, effectiveWsId, loadSessions])
+
+ // Track whether user has scrolled up in chat area.
+ // chatEl is in the dependency array so the effect re-runs once the portal
+ // commits the DOM node (chatEl state updates → effect retries with real el).
+ useEffect(() => {
+ const el = chatEl
+ if (!el) return
+ const handleScroll = () => {
+ const threshold = 50
+ const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight < threshold
+ chatUserScrolledUpRef.current = !isAtBottom
+ }
+ el.addEventListener('scroll', handleScroll)
+ return () => el.removeEventListener('scroll', handleScroll)
+ }, [open, chatEl])
+
+ // Auto-scroll chat only if user hasn't scrolled up
+ useEffect(() => {
+ if (chatEl && !chatUserScrolledUpRef.current) {
+ chatEl.scrollTop = chatEl.scrollHeight
+ }
+ }, [planning.messages, planning.streamEvents, chatEl])
+
+ const handleSend = () => {
+ if (!inputValue.trim() || planning.generating) return
+ if (!effectiveWsId) {
+ setWsError('Please select a workspace first.')
+ return
+ }
+ setWsError(null)
+ planning.sendMessage(inputValue.trim())
+ setInputValue('')
+ }
+
+ const handleKeyDown = (e) => {
+ if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
+ e.preventDefault()
+ handleSend()
+ }
+ }
+
+ const handleDeleteSession = (sid) => {
+ if (confirmDeleteId === sid) {
+ setConfirmDeleteId(null)
+ planning.deleteSession(sid)
+ } else {
+ setConfirmDeleteId(sid)
+ }
+ }
+
+ if (!open) return null
+
+ return createPortal(
+ { if (e.target === e.currentTarget) onClose() }}
+ >
+
+
planning.resumeSession(sid)}
+ onNew={() => planning.startNew()}
+ onDelete={handleDeleteSession}
+ confirmDeleteId={confirmDeleteId}
+ onCancelDelete={() => setConfirmDeleteId(null)}
+ />
+
+
+ {/* Header */}
+
+
+
Plan Issue
+
/
+
{
+ setPlannerWsId(id)
+ setWsError(null)
+ planning.startNew()
+ }}
+ />
+
+
+
+
+
+
+ {/* Chat area */}
+
+ {/* Loading state */}
+ {planning.loadingSession && (
+
+
+
Loading session{'\u2026'}
+
+ )}
+
+ {/* Empty state */}
+ {planning.messages.length === 0 && !planning.generating && !planning.loadingSession && (
+
+
+
+
+
Plan an implementation
+
+ Describe a feature or bug fix and Claude will analyze the codebase to create an implementation plan.
+
+
+ )}
+
+ {/* Messages */}
+ {planning.messages.map((m, i) => (
+ m.role === 'user' ? (
+
+ ) : (
+
+ )
+ ))}
+
+ {/* Activity feed — streaming events */}
+ {(planning.generating || planning.streamEvents?.length > 0) && (
+
+ )}
+
+ {/* Live draft */}
+ {planning.generating && planning.streamEvents && (
+
+ )}
+
+ {/* Issue creation progress */}
+ {planning.creatingIssue != null && (
+
+
+ {'Creating GitHub issue\u2026'}
+
+ )}
+
+
+ {/* Input area */}
+
+
+ {wsError && (
+
{wsError}
+ )}
+
+
+ {(navigator.userAgentData?.platform || navigator.userAgent)?.toLowerCase().includes('mac') ? '\u2318' : 'Ctrl'}+Enter to send
+
+ {planning.hasPlan && !planning.generating && (
+ <>
+ {'\u00b7'}
+
+ Each plan above has its own "Create Issue" button
+
+ >
+ )}
+
+
+
+
+
,
+ document.body
+ )
+}
diff --git a/frontend/src/components/prs/PRTracker.jsx b/frontend/src/components/prs/PRTracker.jsx
new file mode 100644
index 0000000..859ff6f
--- /dev/null
+++ b/frontend/src/components/prs/PRTracker.jsx
@@ -0,0 +1,96 @@
+import { GitPullRequest } from 'lucide-react'
+import { ReviewThreads } from './ReviewThreads'
+import { Badge } from '../ui/Badge'
+import { EmptyState } from '../ui/EmptyState'
+import { Spinner } from '../ui/Spinner'
+import { usePRs } from '../../hooks/usePRs'
+import { useWorkspaceContext } from '../../context/WorkspaceContext'
+import { useWorkspaces } from '../../hooks/useWorkspaces'
+
+const REPO_RE = /^[\w.-]+\/[\w.-]+$/
+function buildGitHubUrl(repo, section, number) {
+ if (!repo || !REPO_RE.test(repo)) return null
+ if (!Number.isInteger(number) || number <= 0) return null
+ const [owner, name] = repo.split('/')
+ return `https://github.com/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/${section}/${number}`
+}
+
+function PRStatusBadge({ status }) {
+ const MAP = {
+ open: { variant: 'blue', label: 'Open' },
+ merged: { variant: 'purple', label: 'Merged' },
+ closed: { variant: 'dim', label: 'Closed' },
+ pending_fix: { variant: 'yellow', label: 'Pending Fix' },
+ needs_human: { variant: 'red', label: 'Needs Human' },
+ }
+ const { variant, label } = MAP[status] || { variant: 'dim', label: status }
+ return {label}
+}
+
+export function PRTracker() {
+ const { selectedWorkspaceId } = useWorkspaceContext()
+ const { data, isLoading } = usePRs(selectedWorkspaceId)
+ const { data: wsData } = useWorkspaces()
+ const workspaces = wsData?.workspaces || []
+ const wsMap = Object.fromEntries(workspaces.map(w => [w.id, w.name || w.repo_url]))
+ const wsRepoMap = Object.fromEntries(workspaces.map(w => [w.id, w.github_repo]))
+ const showWorkspace = !selectedWorkspaceId
+
+ const prs = data?.prs || []
+
+ return (
+
+
+
+ PR Tracker
+ {prs.length > 0 && (
+ ({prs.length})
+ )}
+
+
+
+ {isLoading ? (
+
+
+
+ ) : prs.length === 0 ? (
+
+ ) : (
+
+ {prs.map((pr) => (
+
+
+
+ #{pr.pr_number}
+
+
+
+ {pr.iterations} iter{pr.iterations !== 1 ? 's' : ''}
+
+ {pr.total_comments > 0 && (
+
+ {pr.total_comments} comment{pr.total_comments !== 1 ? 's' : ''}
+
+ )}
+ {showWorkspace && wsMap[pr.workspace_id] && (
+
+ {wsMap[pr.workspace_id]}
+
+ )}
+
+
+
+ ))}
+
+ )}
+
+ )
+}
diff --git a/frontend/src/components/prs/ReviewThreads.jsx b/frontend/src/components/prs/ReviewThreads.jsx
new file mode 100644
index 0000000..e57763e
--- /dev/null
+++ b/frontend/src/components/prs/ReviewThreads.jsx
@@ -0,0 +1,169 @@
+import { useState } from 'react'
+import { ChevronDown, ChevronRight, FileCode, MessageCircle } from 'lucide-react'
+
+function parseSeverity(body) {
+ if (!body) return null
+ const match = body.match(/^\*\*(\w+)\*\*[*:]?\s*/i)
+ if (!match) return null
+ const level = match[1].toUpperCase()
+ if (level === 'HIGH' || level === 'CRITICAL') return { level, color: 'text-[var(--red)]', bg: 'bg-[var(--red-dim)]' }
+ if (level === 'MEDIUM') return { level, color: 'text-[var(--yellow)]', bg: 'bg-[var(--yellow-dim)]' }
+ if (level === 'LOW' || level === 'INFO') return { level, color: 'text-[var(--blue)]', bg: 'bg-[var(--blue-dim)]' }
+ return null
+}
+
+// Sentinel delimiters use null bytes (\x00) which cannot appear in GitHub
+// comment text, preventing collisions with user-authored content.
+const S_CODE_BLOCK_OPEN = '\x00CODE_BLOCK\x00'
+const S_CODE_BLOCK_CLOSE = '\x00/CODE_BLOCK\x00'
+const S_INLINE_CODE_OPEN = '\x00INLINE_CODE\x00'
+const S_INLINE_CODE_CLOSE = '\x00/INLINE_CODE\x00'
+const S_STRONG_OPEN = '\x00STRONG\x00'
+const S_STRONG_CLOSE = '\x00/STRONG\x00'
+
+function formatCommentBody(body) {
+ if (!body) return ''
+ // Strip leading severity marker like **HIGH**: or **MEDIUM**: but only for
+ // known severity keywords so that legitimate bold prefixes such as
+ // **Note**: or **Warning**: are preserved unchanged.
+ let text = body.replace(/^\*\*(HIGH|CRITICAL|MEDIUM|LOW|INFO)\*\*[*:]?\s*/i, '')
+
+ // Basic inline formatting using null-byte sentinels
+ // Code blocks
+ text = text.replace(/```[\s\S]*?```/g, (match) => {
+ const code = match.replace(/```\w*\n?/, '').replace(/```$/, '').trim()
+ return `\n${S_CODE_BLOCK_OPEN}${code}${S_CODE_BLOCK_CLOSE}\n`
+ })
+ // Inline code
+ text = text.replace(/`([^`]+)`/g, `${S_INLINE_CODE_OPEN}$1${S_INLINE_CODE_CLOSE}`)
+ // Bold
+ text = text.replace(/\*\*(.+?)\*\*/g, `${S_STRONG_OPEN}$1${S_STRONG_CLOSE}`)
+
+ return text
+}
+
+function CommentBody({ body }) {
+ const formatted = formatCommentBody(body)
+ const sentinelPattern = new RegExp(
+ `(${S_CODE_BLOCK_OPEN}[\\s\\S]*?${S_CODE_BLOCK_CLOSE}` +
+ `|${S_INLINE_CODE_OPEN}.*?${S_INLINE_CODE_CLOSE}` +
+ `|${S_STRONG_OPEN}.*?${S_STRONG_CLOSE})`,
+ 'g'
+ )
+ const parts = formatted.split(sentinelPattern)
+
+ return (
+
+ {parts.map((part, i) => {
+ if (part.startsWith(S_CODE_BLOCK_OPEN)) {
+ const code = part.slice(S_CODE_BLOCK_OPEN.length, -S_CODE_BLOCK_CLOSE.length)
+ return (
+
+ {code}
+
+ )
+ }
+ if (part.startsWith(S_INLINE_CODE_OPEN)) {
+ const code = part.slice(S_INLINE_CODE_OPEN.length, -S_INLINE_CODE_CLOSE.length)
+ return (
+
+ {code}
+
+ )
+ }
+ if (part.startsWith(S_STRONG_OPEN)) {
+ const text = part.slice(S_STRONG_OPEN.length, -S_STRONG_CLOSE.length)
+ return
{text}
+ }
+ // Handle newlines as separate lines for plain text
+ return part.split('\n').map((line, j, arr) => (
+
+ {line}
+ {j < arr.length - 1 && }
+
+ ))
+ })}
+
+ )
+}
+
+function ThreadCard({ thread, index }) {
+ const comments = thread.comments || []
+ // If no comments array, try to use body/comment directly
+ const hasComments = comments.length > 0
+ const firstComment = hasComments ? comments[0] : null
+ const severity = firstComment ? parseSeverity(firstComment.body) : null
+
+ return (
+
+ {/* File location header */}
+ {thread.path && (
+
+
+
+ {thread.path}
+ {thread.line && :{thread.line} }
+
+ {severity && (
+
+ {severity.level}
+
+ )}
+
+ )}
+
+ {/* Comments */}
+
+ {hasComments ? (
+ comments.map((comment, j) => (
+
+
+
+
+ {comment.author || 'unknown'}
+
+
+
+
+ ))
+ ) : (
+
+
+
+ )}
+
+
+ )
+}
+
+export function ReviewThreads({ threads }) {
+ const [open, setOpen] = useState(false)
+
+ if (!threads || threads.length === 0) return null
+
+ // Count total comments across threads
+ const totalComments = threads.reduce((sum, t) => sum + (t.comments?.length || 1), 0)
+
+ return (
+
+
setOpen((v) => !v)}
+ className="flex items-center gap-1.5 text-[10px] text-[var(--text-muted)] hover:text-[var(--text-dim)] transition-colors font-medium"
+ >
+ {open ? : }
+ {threads.length} review thread{threads.length !== 1 ? 's' : ''}
+
+ ({totalComments} comment{totalComments !== 1 ? 's' : ''})
+
+
+
+ {open && (
+
+ {threads.map((thread, i) => (
+
+ ))}
+
+ )}
+
+ )
+}
diff --git a/frontend/src/components/ui/Badge.jsx b/frontend/src/components/ui/Badge.jsx
new file mode 100644
index 0000000..f75dfd2
--- /dev/null
+++ b/frontend/src/components/ui/Badge.jsx
@@ -0,0 +1,18 @@
+const VARIANTS = {
+ green: 'bg-[var(--green-dim)] text-[var(--green)] border-transparent',
+ blue: 'bg-[var(--blue-dim)] text-[var(--blue)] border-transparent',
+ yellow: 'bg-[var(--yellow-dim)] text-[var(--yellow)] border-transparent',
+ red: 'bg-[var(--red-dim)] text-[var(--red)] border-transparent',
+ purple: 'bg-[var(--accent-dim)] text-[var(--accent)] border-transparent',
+ dim: 'bg-[rgba(92,95,115,0.08)] text-[var(--text-dim)] border-transparent',
+}
+
+export function Badge({ variant = 'dim', children, className = '' }) {
+ return (
+
+ {children}
+
+ )
+}
diff --git a/frontend/src/components/ui/Button.jsx b/frontend/src/components/ui/Button.jsx
new file mode 100644
index 0000000..90ced38
--- /dev/null
+++ b/frontend/src/components/ui/Button.jsx
@@ -0,0 +1,31 @@
+import { Loader2 } from 'lucide-react'
+
+const VARIANTS = {
+ primary: 'bg-[var(--accent)] border-transparent text-white hover:brightness-110 shadow-[0_0_12px_rgba(139,92,246,0.15)]',
+ ghost: 'bg-transparent border-[var(--border)] text-[var(--text-dim)] hover:text-[var(--text)] hover:bg-[var(--surface-hover)] hover:border-[var(--text-muted)]',
+ danger: 'bg-[var(--red-dim)] border-transparent text-[var(--red)] hover:bg-[rgba(248,113,113,0.2)]',
+}
+
+export function Button({
+ as: Component = 'button',
+ variant = 'ghost',
+ size = 'md',
+ loading = false,
+ disabled = false,
+ children,
+ className = '',
+ ...props
+}) {
+ const sizeClass = size === 'sm' ? 'px-2.5 py-1 text-[11px]' : 'px-4 py-2 text-[12px]'
+ const extraProps = Component === 'button' ? { disabled: disabled || loading } : {}
+ return (
+
+ {loading && }
+ {children}
+
+ )
+}
diff --git a/frontend/src/components/ui/Card.jsx b/frontend/src/components/ui/Card.jsx
new file mode 100644
index 0000000..bb30f92
--- /dev/null
+++ b/frontend/src/components/ui/Card.jsx
@@ -0,0 +1,22 @@
+export function Card({ children, className = '', glow = false, ...props }) {
+ return (
+
+ {children}
+
+ )
+}
+
+export function CardHeader({ children, className = '' }) {
+ return (
+
+ {children}
+
+ )
+}
+
+export function CardBody({ children, className = '' }) {
+ return {children}
+}
diff --git a/frontend/src/components/ui/EmptyState.jsx b/frontend/src/components/ui/EmptyState.jsx
new file mode 100644
index 0000000..7687cb6
--- /dev/null
+++ b/frontend/src/components/ui/EmptyState.jsx
@@ -0,0 +1,15 @@
+export function EmptyState({ icon: Icon, message, description }) {
+ return (
+
+ {Icon && (
+
+
+
+ )}
+
{message}
+ {description && (
+
{description}
+ )}
+
+ )
+}
diff --git a/frontend/src/components/ui/Modal.jsx b/frontend/src/components/ui/Modal.jsx
new file mode 100644
index 0000000..09e0b87
--- /dev/null
+++ b/frontend/src/components/ui/Modal.jsx
@@ -0,0 +1,54 @@
+import { useEffect, useRef } from 'react'
+import { createPortal } from 'react-dom'
+import { X } from 'lucide-react'
+
+export function Modal({ open, onClose, title, children, maxWidth = '480px' }) {
+ const overlayRef = useRef(null)
+
+ useEffect(() => {
+ if (!open) return
+ const prev = document.body.style.overflow
+ document.body.style.overflow = 'hidden'
+ return () => {
+ document.body.style.overflow = prev
+ }
+ }, [open])
+
+ useEffect(() => {
+ if (!open) return
+ const handler = (e) => {
+ if (e.key === 'Escape') onClose()
+ }
+ document.addEventListener('keydown', handler)
+ return () => document.removeEventListener('keydown', handler)
+ }, [open, onClose])
+
+ if (!open) return null
+
+ return createPortal(
+ {
+ if (e.target === overlayRef.current) onClose()
+ }}
+ >
+
+
+
{title}
+
+
+
+
+ {children}
+
+
,
+ document.body
+ )
+}
diff --git a/frontend/src/components/ui/Spinner.jsx b/frontend/src/components/ui/Spinner.jsx
new file mode 100644
index 0000000..241c302
--- /dev/null
+++ b/frontend/src/components/ui/Spinner.jsx
@@ -0,0 +1,10 @@
+import { Loader2 } from 'lucide-react'
+
+export function Spinner({ size = 16, className = '' }) {
+ return (
+
+ )
+}
diff --git a/frontend/src/context/WorkspaceContext.jsx b/frontend/src/context/WorkspaceContext.jsx
new file mode 100644
index 0000000..3fe4de6
--- /dev/null
+++ b/frontend/src/context/WorkspaceContext.jsx
@@ -0,0 +1,30 @@
+import { createContext, useContext, useState, useEffect } from 'react'
+
+const WorkspaceContext = createContext(null)
+
+export function WorkspaceProvider({ children }) {
+ const [selectedWorkspaceId, setSelectedWorkspaceIdRaw] = useState(() => {
+ return localStorage.getItem('swarm_workspace_id') || null
+ })
+
+ const setSelectedWorkspaceId = (id) => {
+ setSelectedWorkspaceIdRaw(id)
+ if (id) {
+ localStorage.setItem('swarm_workspace_id', id)
+ } else {
+ localStorage.removeItem('swarm_workspace_id')
+ }
+ }
+
+ return (
+
+ {children}
+
+ )
+}
+
+export function useWorkspaceContext() {
+ const ctx = useContext(WorkspaceContext)
+ if (!ctx) throw new Error('useWorkspaceContext must be used within WorkspaceProvider')
+ return ctx
+}
diff --git a/frontend/src/hooks/useAgents.js b/frontend/src/hooks/useAgents.js
new file mode 100644
index 0000000..e6a5d3e
--- /dev/null
+++ b/frontend/src/hooks/useAgents.js
@@ -0,0 +1,57 @@
+import { useRef } from 'react'
+import { useQuery } from '@tanstack/react-query'
+import { getAgents, getAgentLogs } from '../api/client'
+
+export function useAgents(wsId, { limit = 20, offset = 0 } = {}) {
+ return useQuery({
+ queryKey: ['agents', wsId, limit, offset],
+ queryFn: () => getAgents(wsId, limit, offset),
+ refetchInterval: 3000,
+ staleTime: 0,
+ })
+}
+
+export function useAgentLogs(agentId, { since = 0, refetchInterval = 3000 } = {}) {
+ const cursorRef = useRef(since)
+ const prevAgentIdRef = useRef(agentId)
+
+ // Reset cursor synchronously during render when agentId changes.
+ // TanStack Query v5 schedules its initial fetch via scheduleMicrotask, which
+ // runs before React's useEffect callbacks (posted as MessageChannel macrotasks
+ // after paint). A useEffect-based reset therefore executes too late — queryFn
+ // would read the stale cursor from the previous agent, silently skipping all
+ // earlier events for the new agent. Resetting here (in the render body, guarded
+ // by prevAgentIdRef so it fires only once per agentId change) guarantees
+ // cursorRef.current === 0 when queryFn is invoked for the new agent.
+ // This also prevents AgentLogViewer's useEffect([data]) — which fires after
+ // useEffect([agentId]) and can overwrite a useEffect-based reset with stale
+ // cached cursor values — from causing the first fetch to use a wrong cursor.
+ //
+ // NOTE (React concurrent-mode): Mutating refs during the render body violates
+ // the strict render-purity contract. Under concurrent rendering, this block may
+ // execute more than once for the same agentId transition. This is safe here
+ // because: (a) refs are not observable by React's scheduler and cannot cause
+ // re-renders, (b) the prevAgentIdRef guard makes the assignment idempotent —
+ // repeated executions produce the same result (cursor reset to 0), and (c) the
+ // assignment must happen before the TanStack Query microtask to avoid the stale
+ // cursor problem described above. Any future restructuring should preserve this
+ // ordering guarantee (e.g. by capturing agentId as a closure in queryFn and
+ // resetting cursorRef there when it detects an agentId change).
+ if (prevAgentIdRef.current !== agentId) {
+ prevAgentIdRef.current = agentId
+ cursorRef.current = 0
+ }
+
+ const query = useQuery({
+ queryKey: ['agent-logs', agentId],
+ queryFn: () => {
+ const cursor = cursorRef.current
+ return getAgentLogs(agentId, cursor)
+ },
+ enabled: !!agentId,
+ refetchInterval,
+ staleTime: 0,
+ })
+
+ return { ...query, cursorRef }
+}
diff --git a/frontend/src/hooks/useGitSync.js b/frontend/src/hooks/useGitSync.js
new file mode 100644
index 0000000..2858f3f
--- /dev/null
+++ b/frontend/src/hooks/useGitSync.js
@@ -0,0 +1,29 @@
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
+import { getGitStatus, gitPull } from '../api/client'
+
+export function useGitSync(wsId) {
+ const queryClient = useQueryClient()
+
+ const query = useQuery({
+ queryKey: ['git-sync', wsId],
+ queryFn: () => getGitStatus(wsId),
+ enabled: !!wsId,
+ refetchInterval: 30000,
+ staleTime: 10000,
+ })
+
+ const pull = useMutation({
+ mutationFn: () => gitPull(wsId),
+ onSuccess: (data) => {
+ queryClient.setQueryData(['git-sync', wsId], {
+ synced: data.synced,
+ local_sha: data.local_sha,
+ remote_sha: data.remote_sha,
+ behind: data.behind,
+ ahead: data.ahead,
+ })
+ },
+ })
+
+ return { ...query, pull }
+}
diff --git a/frontend/src/hooks/useIssues.js b/frontend/src/hooks/useIssues.js
new file mode 100644
index 0000000..fb66927
--- /dev/null
+++ b/frontend/src/hooks/useIssues.js
@@ -0,0 +1,21 @@
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
+import { getIssues, updateIssueStatus } from '../api/client'
+
+export function useIssues(wsId) {
+ return useQuery({
+ queryKey: ['issues', wsId],
+ queryFn: () => getIssues(wsId),
+ refetchInterval: 5000,
+ staleTime: 0,
+ })
+}
+
+export function useUpdateIssueStatus(wsId) {
+ const queryClient = useQueryClient()
+ return useMutation({
+ mutationFn: ({ issueNumber, status, workspaceId }) => updateIssueStatus(issueNumber, status, workspaceId ?? wsId),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['issues', wsId] })
+ },
+ })
+}
diff --git a/frontend/src/hooks/useMetrics.js b/frontend/src/hooks/useMetrics.js
new file mode 100644
index 0000000..598ac9b
--- /dev/null
+++ b/frontend/src/hooks/useMetrics.js
@@ -0,0 +1,11 @@
+import { useQuery } from '@tanstack/react-query'
+import { getMetrics } from '../api/client'
+
+export function useMetrics(wsId) {
+ return useQuery({
+ queryKey: ['metrics', wsId],
+ queryFn: () => getMetrics(wsId),
+ refetchInterval: 3000,
+ staleTime: 0,
+ })
+}
diff --git a/frontend/src/hooks/usePRs.js b/frontend/src/hooks/usePRs.js
new file mode 100644
index 0000000..f865de5
--- /dev/null
+++ b/frontend/src/hooks/usePRs.js
@@ -0,0 +1,11 @@
+import { useQuery } from '@tanstack/react-query'
+import { getPRs } from '../api/client'
+
+export function usePRs(wsId) {
+ return useQuery({
+ queryKey: ['prs', wsId],
+ queryFn: () => getPRs(wsId),
+ refetchInterval: 5000,
+ staleTime: 0,
+ })
+}
diff --git a/frontend/src/hooks/usePlanning.js b/frontend/src/hooks/usePlanning.js
new file mode 100644
index 0000000..7ef5869
--- /dev/null
+++ b/frontend/src/hooks/usePlanning.js
@@ -0,0 +1,383 @@
+import { useState, useRef, useCallback, useEffect } from 'react'
+import {
+ listPlanningSessions,
+ getPlanningSession,
+ getPlanningEvents,
+ startPlanning,
+ refinePlan,
+ createIssueFromPlan,
+ cancelPlanning,
+ deletePlanningSession,
+} from '../api/client'
+
+export function usePlanning(workspaceId) {
+ const [sessions, setSessions] = useState([])
+ const [sessionId, setSessionId] = useState(null)
+ const [messages, setMessages] = useState([])
+ const [generating, setGenerating] = useState(false)
+ const [streamEvents, setStreamEvents] = useState([])
+ // Map of message index -> issue result (so each plan can have its own)
+ const [issueResults, setIssueResults] = useState({})
+ const [creatingIssue, setCreatingIssue] = useState(null) // message index or null
+ const [sessionStatus, setSessionStatus] = useState(null)
+ const [loadingSession, setLoadingSession] = useState(false)
+
+ const lastEventIdRef = useRef(0)
+ const pollTimerRef = useRef(null)
+ const sessionIdRef = useRef(null)
+ const pollErrorCountRef = useRef(0)
+ const pollActiveRef = useRef(false)
+ const pollGenRef = useRef(0)
+ const _pollTickRef = useRef(null)
+
+ useEffect(() => {
+ sessionIdRef.current = sessionId
+ }, [sessionId])
+
+ useEffect(() => {
+ return () => {
+ if (pollTimerRef.current) clearTimeout(pollTimerRef.current)
+ }
+ }, [])
+
+ // Reset all state and reload sessions when workspace changes
+ useEffect(() => {
+ if (pollTimerRef.current) {
+ clearTimeout(pollTimerRef.current)
+ pollTimerRef.current = null
+ }
+ setSessionId(null)
+ setMessages([])
+ setStreamEvents([])
+ setGenerating(false)
+ setIssueResults({})
+ setCreatingIssue(null)
+ setSessionStatus(null)
+ lastEventIdRef.current = 0
+ sessionIdRef.current = null
+
+ if (workspaceId) {
+ listPlanningSessions(workspaceId)
+ .then(data => setSessions(data.sessions || []))
+ .catch(() => setSessions([]))
+ } else {
+ setSessions([])
+ }
+ }, [workspaceId])
+
+ const loadSessions = useCallback(async () => {
+ if (!workspaceId) return
+ try {
+ const data = await listPlanningSessions(workspaceId)
+ setSessions(data.sessions || [])
+ } catch { /* ignore */ }
+ }, [workspaceId])
+
+ const stopPolling = useCallback(() => {
+ // Bump the generation so any in-flight tick's finally block can detect it
+ // is stale and skip resetting pollActiveRef (which may already belong to a
+ // newer poll invocation).
+ pollGenRef.current++
+ pollActiveRef.current = false
+ if (pollTimerRef.current) {
+ clearTimeout(pollTimerRef.current)
+ pollTimerRef.current = null
+ }
+ }, [])
+
+ // _pollTick: internal single-tick scheduler. Does NOT call stopPolling() on
+ // entry so recursive calls (success path, backoff retries) never cancel an
+ // in-progress backoff timer. Uses _pollTickRef for self-reference to avoid
+ // circular useCallback dependencies.
+ const _pollTick = useCallback(() => {
+ const sid = sessionIdRef.current
+ if (!sid) return
+ if (pollActiveRef.current) return
+
+ // Capture the generation set by the most recent stopPolling() call so we
+ // can detect when a newer poll() has taken ownership of the active flag.
+ const gen = pollGenRef.current
+ pollActiveRef.current = true
+
+ pollTimerRef.current = setTimeout(async () => {
+ const localSid = sessionIdRef.current
+ if (localSid !== sid) {
+ if (pollGenRef.current === gen) pollActiveRef.current = false
+ return
+ }
+
+ try {
+ const [data, evData] = await Promise.all([
+ getPlanningSession(localSid),
+ getPlanningEvents(localSid, lastEventIdRef.current),
+ ])
+ if (sessionIdRef.current !== localSid) {
+ if (pollGenRef.current === gen) pollActiveRef.current = false
+ return
+ }
+ if (!data) {
+ // Apply the same backoff used for errors so a persistent null response
+ // does not hammer the API at a fixed ~3 req/s rate indefinitely.
+ pollErrorCountRef.current += 1
+ const MAX_POLL_ERRORS = 5
+ if (pollGenRef.current === gen) pollActiveRef.current = false
+ if (pollErrorCountRef.current <= MAX_POLL_ERRORS) {
+ const backoff = Math.min(300 * Math.pow(2, pollErrorCountRef.current), 30000)
+ pollTimerRef.current = setTimeout(() => {
+ if (pollGenRef.current === gen) _pollTickRef.current?.()
+ }, backoff)
+ } else {
+ setGenerating(false)
+ }
+ return
+ }
+ // Valid response — reset the backoff counter.
+ pollErrorCountRef.current = 0
+
+ if (evData?.events?.length) {
+ setStreamEvents(prev => [...prev, ...evData.events])
+ lastEventIdRef.current = evData.events[evData.events.length - 1].id
+ }
+
+ setMessages(data.messages || [])
+ setSessionStatus(data.session?.status || null)
+
+ if (data.session?.status === 'completed' && data.session.issue_url) {
+ // Find the last assistant message index to mark it
+ const msgs = data.messages || []
+ for (let i = msgs.length - 1; i >= 0; i--) {
+ if (msgs[i].role === 'assistant') {
+ setIssueResults(prev => ({ ...prev, [i]: { url: data.session.issue_url } }))
+ break
+ }
+ }
+ }
+
+ // Clear active flag before scheduling the next tick so _pollTick's
+ // guard sees false and can proceed.
+ if (pollGenRef.current === gen) pollActiveRef.current = false
+ if (data.generating || data.session?.status === 'generating') {
+ _pollTickRef.current?.()
+ } else {
+ setGenerating(false)
+ }
+ } catch {
+ pollErrorCountRef.current += 1
+ const MAX_POLL_ERRORS = 5
+ if (pollGenRef.current === gen) pollActiveRef.current = false
+ if (pollErrorCountRef.current <= MAX_POLL_ERRORS) {
+ // Exponential backoff: 600ms, 1.2s, 2.4s, 4.8s, 9.6s
+ const backoff = Math.min(300 * Math.pow(2, pollErrorCountRef.current), 30000)
+ pollTimerRef.current = setTimeout(() => {
+ if (pollGenRef.current === gen) _pollTickRef.current?.()
+ }, backoff)
+ } else {
+ // Stop retrying after MAX_POLL_ERRORS consecutive failures
+ setGenerating(false)
+ }
+ }
+ }, 300)
+ }, [])
+ // Keep ref in sync so recursive setTimeout callbacks always call the latest version.
+ _pollTickRef.current = _pollTick
+
+ // poll: public "start fresh" API. Always calls stopPolling() first to cancel
+ // any in-flight tick or pending backoff timer, then starts a new tick.
+ const poll = useCallback(() => {
+ const sid = sessionIdRef.current
+ if (!sid) return
+ stopPolling()
+ _pollTick()
+ }, [stopPolling, _pollTick])
+
+ const startNew = useCallback(() => {
+ stopPolling()
+ setSessionId(null)
+ setMessages([])
+ setStreamEvents([])
+ setGenerating(false)
+ setIssueResults({})
+ setCreatingIssue(null)
+ setSessionStatus(null)
+ lastEventIdRef.current = 0
+ }, [stopPolling])
+
+ const resumeSession = useCallback(async (sid) => {
+ stopPolling()
+ setStreamEvents([])
+ setMessages([])
+ setSessionId(sid)
+ sessionIdRef.current = sid
+ setIssueResults({})
+ setCreatingIssue(null)
+ lastEventIdRef.current = 0
+ setLoadingSession(true)
+
+ try {
+ const [data, evData] = await Promise.all([
+ getPlanningSession(sid),
+ getPlanningEvents(sid, 0),
+ ])
+ if (!data || data.error) { setGenerating(false); return }
+
+ setMessages(data.messages || [])
+ setSessionStatus(data.session?.status || null)
+
+ // Restore all events so the analysis steps accordion is visible
+ if (evData?.events?.length) {
+ setStreamEvents(evData.events)
+ lastEventIdRef.current = evData.events[evData.events.length - 1].id
+ }
+
+ if (data.session?.status === 'completed' && data.session.issue_url) {
+ // Mark the last assistant message as having an issue
+ const msgs = data.messages || []
+ for (let i = msgs.length - 1; i >= 0; i--) {
+ if (msgs[i].role === 'assistant') {
+ setIssueResults({ [i]: { url: data.session.issue_url } })
+ break
+ }
+ }
+ }
+
+ if (data.generating || data.session?.status === 'generating') {
+ setGenerating(true)
+ poll()
+ } else {
+ setGenerating(false)
+ }
+ } catch { setGenerating(false) } finally {
+ setLoadingSession(false)
+ }
+ }, [stopPolling, poll])
+
+ const sendMessage = useCallback(async (msg) => {
+ if (!msg.trim()) return
+ if (!workspaceId) return
+
+ stopPolling()
+ setStreamEvents([])
+ setGenerating(true)
+
+ try {
+ let data
+ if (!sessionIdRef.current) {
+ data = await startPlanning(workspaceId, msg)
+ if (data.error) { setGenerating(false); return }
+ setSessionId(data.session.id)
+ sessionIdRef.current = data.session.id
+ loadSessions()
+ } else {
+ data = await refinePlan(sessionIdRef.current, msg)
+ if (data.error) { setGenerating(false); return }
+ }
+
+ setMessages(data.messages || [])
+ poll()
+ } catch {
+ setGenerating(false)
+ }
+ }, [workspaceId, poll, loadSessions, stopPolling])
+
+ const cancelGeneration = useCallback(async () => {
+ const sid = sessionIdRef.current
+ if (!sid) return
+ stopPolling()
+ try {
+ await cancelPlanning(sid)
+ setStreamEvents([])
+ setGenerating(false)
+ } catch {
+ poll()
+ }
+ }, [stopPolling, poll])
+
+ const deleteSession = useCallback(async (sid) => {
+ try {
+ await deletePlanningSession(sid)
+ if (sessionIdRef.current === sid) {
+ startNew()
+ }
+ loadSessions()
+ } catch { /* ignore */ }
+ }, [startNew, loadSessions])
+
+ const createIssue = useCallback(async (messageIndex, title = '') => {
+ const sid = sessionIdRef.current
+ if (!sid) return
+
+ setCreatingIssue(messageIndex)
+
+ // Poll for events during issue creation.
+ // Guard against session switches: if the user switches to another session
+ // while the issue is being created, stop fetching events for the old session.
+ // `cancelled` is set to true in the finally block before clearInterval so
+ // that any interval tick that fires between promise resolution and
+ // clearInterval does not append events after creation state is cleared.
+ let cancelled = false
+ const eventPollInterval = setInterval(async () => {
+ if (cancelled) return
+ if (sessionIdRef.current !== sid) {
+ clearInterval(eventPollInterval)
+ return
+ }
+ try {
+ const evData = await getPlanningEvents(sid, lastEventIdRef.current)
+ if (cancelled) return
+ if (sessionIdRef.current !== sid) return
+ if (evData?.events?.length) {
+ lastEventIdRef.current = evData.events[evData.events.length - 1].id
+ setStreamEvents(prev => [...prev, ...evData.events])
+ }
+ } catch { /* ignore */ }
+ }, 800)
+
+ try {
+ const data = await createIssueFromPlan(sid, title, messageIndex)
+ if (data.error) {
+ setCreatingIssue(null)
+ return { error: data.error }
+ }
+
+ setIssueResults(prev => ({
+ ...prev,
+ [messageIndex]: {
+ url: data.issue_url,
+ title: data.title,
+ number: data.issue_number,
+ },
+ }))
+ setCreatingIssue(null)
+ loadSessions()
+ return data
+ } catch (e) {
+ setCreatingIssue(null)
+ return { error: e.message }
+ } finally {
+ cancelled = true
+ clearInterval(eventPollInterval)
+ }
+ }, [loadSessions])
+
+ const hasPlan = messages.some(m => m.role === 'assistant')
+
+ return {
+ sessions,
+ sessionId,
+ messages,
+ generating,
+ streamEvents,
+ issueResults,
+ creatingIssue,
+ sessionStatus,
+ loadingSession,
+ hasPlan,
+ loadSessions,
+ startNew,
+ resumeSession,
+ sendMessage,
+ cancelGeneration,
+ deleteSession,
+ createIssue,
+ }
+}
diff --git a/frontend/src/hooks/useWorkspaces.js b/frontend/src/hooks/useWorkspaces.js
new file mode 100644
index 0000000..f648de4
--- /dev/null
+++ b/frontend/src/hooks/useWorkspaces.js
@@ -0,0 +1,45 @@
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
+import {
+ getWorkspaces,
+ createWorkspace,
+ updateWorkspace,
+ deleteWorkspace,
+} from '../api/client'
+
+export function useWorkspaces() {
+ return useQuery({
+ queryKey: ['workspaces'],
+ queryFn: getWorkspaces,
+ staleTime: 10000,
+ })
+}
+
+export function useCreateWorkspace() {
+ const queryClient = useQueryClient()
+ return useMutation({
+ mutationFn: createWorkspace,
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['workspaces'] })
+ },
+ })
+}
+
+export function useUpdateWorkspace() {
+ const queryClient = useQueryClient()
+ return useMutation({
+ mutationFn: ({ id, data }) => updateWorkspace(id, data),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['workspaces'] })
+ },
+ })
+}
+
+export function useDeleteWorkspace() {
+ const queryClient = useQueryClient()
+ return useMutation({
+ mutationFn: deleteWorkspace,
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['workspaces'] })
+ },
+ })
+}
diff --git a/frontend/src/index.css b/frontend/src/index.css
new file mode 100644
index 0000000..9a10823
--- /dev/null
+++ b/frontend/src/index.css
@@ -0,0 +1,215 @@
+@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Outfit:wght@300;400;500;600;700&display=swap');
+@import './tokens.css';
+
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+body {
+ font-family: 'Outfit', -apple-system, BlinkMacSystemFont, sans-serif;
+ background: var(--bg);
+ color: var(--text);
+ font-size: 14px;
+ min-height: 100vh;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+/* Noise overlay for depth */
+body::before {
+ content: '';
+ position: fixed;
+ inset: 0;
+ z-index: 9999;
+ pointer-events: none;
+ opacity: 0.015;
+ background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E");
+ background-repeat: repeat;
+ background-size: 256px;
+}
+
+/* Mono text class */
+.font-mono, code, pre, .mono {
+ font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace;
+}
+
+/* Scrollbars */
+::-webkit-scrollbar {
+ width: 5px;
+ height: 5px;
+}
+::-webkit-scrollbar-track {
+ background: transparent;
+}
+::-webkit-scrollbar-thumb {
+ background: var(--border);
+ border-radius: 99px;
+}
+::-webkit-scrollbar-thumb:hover {
+ background: var(--text-dim);
+}
+
+/* Focus ring */
+*:focus-visible {
+ outline: 1px solid var(--accent);
+ outline-offset: 1px;
+}
+
+/* Smooth transitions */
+a, button, input, textarea, select {
+ transition: all 0.15s ease;
+}
+
+/* Selection */
+::selection {
+ background: rgba(139, 92, 246, 0.3);
+ color: #fff;
+}
+
+/* Glow keyframes */
+@keyframes pulse-glow {
+ 0%, 100% { opacity: 0.4; }
+ 50% { opacity: 1; }
+}
+
+@keyframes fade-in {
+ from { opacity: 0; transform: translateY(4px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+.animate-fade-in {
+ animation: fade-in 0.3s ease forwards;
+}
+
+/* Subtle gradient border effect */
+.glow-border {
+ position: relative;
+}
+.glow-border::after {
+ content: '';
+ position: absolute;
+ inset: -1px;
+ border-radius: inherit;
+ padding: 1px;
+ background: linear-gradient(135deg, var(--accent-border), transparent 60%);
+ -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
+ -webkit-mask-composite: xor;
+ mask-composite: exclude;
+ pointer-events: none;
+}
+
+/* Slide-in for activity feed items */
+@keyframes slide-in {
+ from { opacity: 0; transform: translateX(-6px); }
+ to { opacity: 1; transform: translateX(0); }
+}
+.animate-slide-in {
+ animation: slide-in 0.2s ease forwards;
+}
+
+/* Planner markdown prose */
+.prose-planner {
+ color: var(--text);
+ line-height: 1.7;
+}
+.prose-planner h1,
+.prose-planner h2,
+.prose-planner h3,
+.prose-planner h4 {
+ color: var(--text);
+ font-family: 'Outfit', sans-serif;
+ font-weight: 600;
+ margin-top: 1.2em;
+ margin-bottom: 0.5em;
+ letter-spacing: -0.01em;
+}
+.prose-planner h1 { font-size: 1.15em; }
+.prose-planner h2 { font-size: 1.05em; }
+.prose-planner h3 { font-size: 0.95em; color: var(--text-dim); }
+.prose-planner h4 { font-size: 0.9em; color: var(--text-dim); }
+.prose-planner p {
+ margin-bottom: 0.6em;
+}
+.prose-planner ul,
+.prose-planner ol {
+ padding-left: 1.4em;
+ margin-bottom: 0.6em;
+}
+.prose-planner li {
+ margin-bottom: 0.25em;
+}
+.prose-planner li::marker {
+ color: var(--text-muted);
+}
+.prose-planner code {
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 0.88em;
+ background: rgba(139, 92, 246, 0.08);
+ border: 1px solid var(--border-subtle);
+ border-radius: 4px;
+ padding: 0.15em 0.35em;
+ color: var(--accent);
+}
+.prose-planner pre {
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 0.8em 1em;
+ margin: 0.6em 0;
+ overflow-x: auto;
+}
+.prose-planner pre code {
+ background: none;
+ border: none;
+ padding: 0;
+ color: var(--text-dim);
+ font-size: 0.85em;
+}
+.prose-planner strong {
+ color: var(--text);
+ font-weight: 600;
+}
+.prose-planner a {
+ color: var(--accent);
+ text-decoration: underline;
+ text-decoration-color: var(--accent-border);
+ text-underline-offset: 2px;
+}
+.prose-planner a:hover {
+ text-decoration-color: var(--accent);
+}
+.prose-planner hr {
+ border: none;
+ border-top: 1px solid var(--border-subtle);
+ margin: 1em 0;
+}
+.prose-planner blockquote {
+ border-left: 2px solid var(--accent-border);
+ padding-left: 1em;
+ margin: 0.6em 0;
+ color: var(--text-dim);
+ font-style: italic;
+}
+.prose-planner table {
+ width: 100%;
+ border-collapse: collapse;
+ margin: 0.6em 0;
+ font-size: 0.92em;
+}
+.prose-planner th,
+.prose-planner td {
+ border: 1px solid var(--border);
+ padding: 0.4em 0.6em;
+ text-align: left;
+}
+.prose-planner th {
+ background: var(--surface);
+ font-weight: 600;
+ color: var(--text-dim);
+}
diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx
new file mode 100644
index 0000000..4c5aac8
--- /dev/null
+++ b/frontend/src/main.jsx
@@ -0,0 +1,25 @@
+import { StrictMode } from 'react'
+import { createRoot } from 'react-dom/client'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { WorkspaceProvider } from './context/WorkspaceContext'
+import { App } from './App'
+import './index.css'
+
+const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ staleTime: 0,
+ retry: 1,
+ },
+ },
+})
+
+createRoot(document.getElementById('root')).render(
+
+
+
+
+
+
+
+)
diff --git a/frontend/src/tokens.css b/frontend/src/tokens.css
new file mode 100644
index 0000000..b6aa6ea
--- /dev/null
+++ b/frontend/src/tokens.css
@@ -0,0 +1,23 @@
+:root {
+ --bg: #050507;
+ --bg-raised: #09090b;
+ --surface: #0c0c10;
+ --surface-hover: #111116;
+ --border: #1a1a22;
+ --border-subtle: #14141a;
+ --text: #dcdfe8;
+ --text-dim: #5c5f73;
+ --text-muted: #3a3c4a;
+ --accent: #8b5cf6;
+ --accent-dim: rgba(139, 92, 246, 0.12);
+ --accent-glow: rgba(139, 92, 246, 0.06);
+ --accent-border: rgba(139, 92, 246, 0.25);
+ --green: #34d399;
+ --green-dim: rgba(52, 211, 153, 0.12);
+ --yellow: #fbbf24;
+ --yellow-dim: rgba(251, 191, 36, 0.12);
+ --red: #f87171;
+ --red-dim: rgba(248, 113, 113, 0.12);
+ --blue: #60a5fa;
+ --blue-dim: rgba(96, 165, 250, 0.12);
+}
diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js
new file mode 100644
index 0000000..8e0b760
--- /dev/null
+++ b/frontend/tailwind.config.js
@@ -0,0 +1,29 @@
+/** @type {import('tailwindcss').Config} */
+export default {
+ content: ['./index.html', './src/**/*.{js,jsx}'],
+ theme: {
+ extend: {
+ colors: {
+ bg: 'var(--bg)',
+ 'bg-raised': 'var(--bg-raised)',
+ surface: 'var(--surface)',
+ 'surface-hover': 'var(--surface-hover)',
+ border: 'var(--border)',
+ 'border-subtle': 'var(--border-subtle)',
+ text: 'var(--text)',
+ 'text-dim': 'var(--text-dim)',
+ 'text-muted': 'var(--text-muted)',
+ accent: 'var(--accent)',
+ green: 'var(--green)',
+ yellow: 'var(--yellow)',
+ red: 'var(--red)',
+ blue: 'var(--blue)',
+ },
+ fontFamily: {
+ sans: ['Outfit', '-apple-system', 'BlinkMacSystemFont', 'sans-serif'],
+ mono: ["'JetBrains Mono'", "'SF Mono'", "'Fira Code'", 'monospace'],
+ },
+ },
+ },
+ plugins: [],
+}
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
new file mode 100644
index 0000000..a4f56c2
--- /dev/null
+++ b/frontend/vite.config.js
@@ -0,0 +1,15 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+export default defineConfig({
+ plugins: [react()],
+ build: {
+ outDir: '../orchestrator/static',
+ emptyOutDir: true,
+ },
+ server: {
+ proxy: {
+ '/api': 'http://localhost:8420',
+ },
+ },
+})
diff --git a/orchestrator/agent_pool.py b/orchestrator/agent_pool.py
index b0bc5a0..4453865 100644
--- a/orchestrator/agent_pool.py
+++ b/orchestrator/agent_pool.py
@@ -7,6 +7,7 @@
import subprocess
import threading
import time
+from datetime import datetime, timezone
from pathlib import Path
from typing import Callable
@@ -22,7 +23,10 @@
MAX_CONCURRENT_AGENTS,
MAX_RATE_LIMIT_RESUMES,
SKILLS_ENABLED,
+ WORKSPACES_DIR,
)
+
+AGENT_LOGS_DIR = WORKSPACES_DIR / ".agent-logs"
from orchestrator.prompts import (
build_fix_review_prompt,
build_implement_prompt,
@@ -62,6 +66,16 @@ def _workspace_config(workspace: dict) -> tuple[str, str, str, str]:
)
+def _ensure_log_dir():
+ """Create the agent logs directory if it doesn't exist."""
+ AGENT_LOGS_DIR.mkdir(parents=True, exist_ok=True)
+
+
+def agent_log_path(agent_id: str) -> Path:
+ """Return the path for an agent's stream-json log file."""
+ return AGENT_LOGS_DIR / f"{agent_id}.jsonl"
+
+
class AgentProcess:
"""Wraps a running claude -p subprocess."""
@@ -85,28 +99,75 @@ def __init__(
self.events: list[AgentEvent] = []
self.started_at = time.time()
self._reader_thread: threading.Thread | None = None
+ self._tailer_done: threading.Event = threading.Event()
# Store workspace config for use in completion handlers
self._workspace: dict | None = None
+ self.log_file = agent_log_path(agent_id)
+ # Set to True by AgentPool.mark_externally_stopped() so _monitor_agent
+ # knows to skip completion DB writes when restart_agent kills this process.
+ self.stopped_externally: bool = False
def start_reader(self):
- """Start a background thread to read stream-json output."""
+ """Start a background thread to tail the log file for events."""
self._reader_thread = threading.Thread(
- target=self._read_stream, daemon=True, name=f"reader-{self.agent_id}"
+ target=self._tail_log, daemon=True, name=f"reader-{self.agent_id}"
)
self._reader_thread.start()
- def _read_stream(self):
- """Read stdout line by line and parse stream-json events."""
+ def _tail_log(self):
+ """Tail the agent's log file and ingest events into the DB."""
try:
- for line in self.process.stdout:
- event = parse_stream_line(line)
- if event:
- self.events.append(event)
- db.insert_event(self.agent_id, event.event_type, json.dumps(event.raw))
- if event.event_type == "tool_use":
- logger.info("[%s] %s", self.agent_id, event.summary)
+ # The log file is created by _spawn_agent just before Popen.
+ # In rare cases start_reader() may race ahead of that 'open(..., "a")'
+ # call, so poll briefly before giving up.
+ _deadline = time.time() + 1.0
+ while not os.path.exists(self.log_file):
+ if time.time() >= _deadline:
+ logger.warning("[%s] Log file not found after 1s: %s", self.agent_id, self.log_file)
+ return
+ time.sleep(0.05)
+ with open(self.log_file) as f:
+ while True:
+ line = f.readline()
+ if line:
+ event = parse_stream_line(line)
+ if event:
+ self.events.append(event)
+ try:
+ db.insert_event(self.agent_id, event.event_type, json.dumps(event.raw))
+ except Exception as _db_err:
+ logger.warning("[%s] Failed to insert event: %s", self.agent_id, _db_err)
+ if event.event_type == "tool_use":
+ logger.info("[%s] %s", self.agent_id, event.summary)
+ # Persist offset after every readline so non-JSON lines
+ # also advance the saved position, preventing duplicate
+ # re-reads after a restart.
+ try:
+ db.update_agent(self.agent_id, log_offset=f.tell())
+ except Exception as _db_err:
+ logger.warning("[%s] Failed to update log offset: %s", self.agent_id, _db_err)
+ else:
+ # No new data — check if process exited
+ if self.process.poll() is not None:
+ # Read any final lines
+ for remaining in f:
+ event = parse_stream_line(remaining)
+ if event:
+ self.events.append(event)
+ try:
+ db.insert_event(self.agent_id, event.event_type, json.dumps(event.raw))
+ except Exception as _db_err:
+ logger.warning("[%s] Failed to insert event: %s", self.agent_id, _db_err)
+ try:
+ db.update_agent(self.agent_id, log_offset=f.tell())
+ except Exception as _db_err:
+ logger.warning("[%s] Failed to update log offset: %s", self.agent_id, _db_err)
+ break
+ time.sleep(0.5)
except Exception as e:
logger.error("[%s] Stream reader error: %s", self.agent_id, e)
+ finally:
+ self._tailer_done.set()
@property
def is_running(self) -> bool:
@@ -137,6 +198,12 @@ def __init__(self):
self._agents: dict[str, AgentProcess] = {}
self._lock = threading.Lock()
self._on_agent_complete: Callable[[AgentProcess], None] | None = None
+ # Callback for reattached-agent completion (no AgentProcess object available).
+ # Signature: (agent_id, agent_type, pr_number, workspace)
+ self._on_reattach_complete: Callable[[str, str, int | None, dict | None], None] | None = None
+ # Agent IDs that were stopped externally (via restart_agent) so that
+ # _monitor_agent / _monitor_pid skip their completion DB writes.
+ self._stopped_agent_ids: set[str] = set()
@property
def active_count(self) -> int:
@@ -151,6 +218,45 @@ def set_completion_callback(self, callback: Callable[[AgentProcess], None]):
"""Set a callback to be called when an agent completes."""
self._on_agent_complete = callback
+ def set_reattach_completion_callback(
+ self, callback: Callable[[str, str, int | None, dict | None], None]
+ ) -> None:
+ """Set a callback invoked when a reattached agent completes.
+
+ The callback receives ``(agent_id, agent_type, pr_number, workspace)``
+ so the same downstream automation that fires for freshly-spawned agents
+ via ``_on_agent_complete`` can also fire for reattached agents handled
+ by ``_monitor_pid`` (which has no ``AgentProcess`` object).
+ """
+ self._on_reattach_complete = callback
+
+ def mark_externally_stopped(self, agent_id: str) -> None:
+ """Signal that an agent is being stopped externally (e.g. via restart_agent).
+
+ Must be called *before* sending SIGTERM so that _monitor_agent and
+ _monitor_pid see the flag before they process the process exit and
+ skip their own DB completion writes, preventing a race where the
+ monitor thread overwrites the 'stopped' status set by restart_agent.
+ """
+ with self._lock:
+ self._stopped_agent_ids.add(agent_id)
+ agent = self._agents.get(agent_id)
+ if agent:
+ agent.stopped_externally = True
+
+ def unmark_externally_stopped(self, agent_id: str) -> None:
+ """Remove agent_id from the externally-stopped set.
+
+ Call this when a restart is aborted (e.g. the agent already completed
+ naturally) so the set does not leak entries for the lifetime of the
+ orchestrator process.
+ """
+ with self._lock:
+ self._stopped_agent_ids.discard(agent_id)
+ agent = self._agents.get(agent_id)
+ if agent:
+ agent.stopped_externally = False
+
def dispatch_implement(self, issue_number: int, workspace: dict | None = None) -> str | None:
"""Dispatch an agent to implement an issue. Returns agent_id or None if pool is full."""
if not self.can_dispatch:
@@ -304,6 +410,7 @@ def _spawn_agent(
allowed_tools += ",Skill"
cmd = [
+ "stdbuf", "-oL",
"claude", "-p", prompt,
"--allowedTools", allowed_tools,
"--output-format", "stream-json",
@@ -330,16 +437,29 @@ def _spawn_agent(
ws_env = db.get_workspace_env(workspace_id)
env.update(ws_env)
- logger.info("Spawning agent %s in %s (PID will be independent)", agent_id, worktree_path)
- process = subprocess.Popen(
- cmd,
- cwd=worktree_path,
- stdout=subprocess.PIPE,
- stderr=subprocess.PIPE,
- text=True,
- env=env,
- start_new_session=True, # Agent survives orchestrator restart
- )
+ _ensure_log_dir()
+ log_file = agent_log_path(agent_id)
+ stdout_file = open(log_file, "a")
+ try:
+ logger.info("Spawning agent %s in %s (PID will be independent)", agent_id, worktree_path)
+ process = subprocess.Popen(
+ cmd,
+ cwd=worktree_path,
+ stdout=stdout_file,
+ stderr=subprocess.PIPE,
+ text=True,
+ env=env,
+ start_new_session=True, # Agent survives orchestrator restart
+ )
+ except Exception:
+ try:
+ os.unlink(log_file)
+ except OSError:
+ pass
+ raise
+ finally:
+ # Close our copy of the file descriptor — the child process keeps its own
+ stdout_file.close()
return AgentProcess(
agent_id=agent_id,
@@ -385,7 +505,20 @@ def _monitor_agent(self, agent_id: str):
return
time.sleep(5)
- # Agent finished
+ # Agent finished — skip completion logic if externally stopped to avoid
+ # racing with restart_agent's own DB writes.
+ if agent.stopped_externally:
+ logger.info("Agent %s was externally stopped — skipping completion logic", agent_id)
+ with self._lock:
+ self._stopped_agent_ids.discard(agent_id)
+ return
+
+ # Wait for the tailer thread to finish draining the log before reading
+ # turn count, mirroring the reattached-agent pattern (_tail_log_file /
+ # tailer_done.wait). This prevents _monitor_agent from seeing a partial
+ # event list when the process exits quickly.
+ agent._tailer_done.wait(timeout=30)
+
return_code = agent.process.returncode
stderr_output = agent.process.stderr.read() if agent.process.stderr else ""
turns = len([e for e in agent.events if e.event_type == "assistant"])
@@ -625,10 +758,10 @@ def resume_rate_limited_agent(self, agent_record: dict) -> str | None:
prompt = build_resume_fix_review_prompt(pr_number, unresolved_threads, github_repo=github_repo, target_repo_path=local_path)
max_turns = AGENT_MAX_TURNS_FIX
- new_agent_id = f"agent-resume-{issue_number}-{int(time.time())}"
+ new_agent_id = f"agent-resume-{issue_number or 'unknown'}-{time.monotonic_ns()}"
# Build command
- cmd = ["claude"]
+ cmd = ["stdbuf", "-oL", "claude"]
if old_session_id:
cmd += ["--resume", old_session_id, "-p", prompt]
logger.info("Resuming session %s for agent %s", old_session_id, old_agent_id)
@@ -665,15 +798,29 @@ def resume_rate_limited_agent(self, agent_record: dict) -> str | None:
env.update(ws_env)
try:
- process = subprocess.Popen(
- cmd,
- cwd=worktree_path,
- stdout=subprocess.PIPE,
- stderr=subprocess.PIPE,
- text=True,
- env=env,
- start_new_session=True,
- )
+ _ensure_log_dir()
+ resume_log_file = agent_log_path(new_agent_id)
+ resume_stdout = open(resume_log_file, "a")
+ try:
+ process = subprocess.Popen(
+ cmd,
+ cwd=worktree_path,
+ stdout=resume_stdout,
+ stderr=subprocess.PIPE,
+ text=True,
+ env=env,
+ start_new_session=True,
+ )
+ except Exception:
+ resume_stdout.close()
+ try:
+ os.unlink(resume_log_file)
+ except OSError:
+ pass
+ raise
+ finally:
+ if not resume_stdout.closed:
+ resume_stdout.close()
except Exception as e:
logger.error("Failed to resume agent %s: %s", old_agent_id, e)
return None
@@ -689,7 +836,7 @@ def resume_rate_limited_agent(self, agent_record: dict) -> str | None:
)
agent_proc._workspace = workspace
- # Mark old agent as superseded
+ # Mark old agent as superseded (but keep its log file)
db.update_agent(old_agent_id, status="resumed")
# Create new agent record
@@ -746,6 +893,349 @@ def get_active_agents(self) -> list[dict]:
})
return results
+ # ------------------------------------------------------------------
+ # Reattach surviving agents after orchestrator restart
+ # ------------------------------------------------------------------
+
+ def reattach_agent(self, agent_record: dict, workspace: dict | None = None):
+ """Reattach a monitor thread to an agent that survived a restart.
+
+ Starts a log-file tailer to continue ingesting stream events, and
+ a PID monitor to detect when the agent exits.
+ """
+ agent_id = agent_record["agent_id"]
+ pid = agent_record["pid"]
+ issue_number = agent_record["issue_number"]
+ agent_type = agent_record.get("agent_type", "implement")
+ worktree_path = agent_record.get("worktree_path", "")
+ pr_number = agent_record.get("pr_number")
+ workspace_id = agent_record.get("workspace_id")
+
+ if pid is None:
+ logger.warning(
+ "Skipping reattach for agent %s: PID is None (agent may not have started successfully)",
+ agent_id,
+ )
+ return
+
+ logger.info(
+ "Reattaching monitor for agent %s (PID %d, issue #%s)",
+ agent_id, pid, issue_number,
+ )
+
+ # Start log file tailer to continue ingesting events
+ log_file = agent_log_path(agent_id)
+ tailer_done = threading.Event()
+ if log_file.exists():
+ # Use the persisted byte offset so the tailer resumes from the exact
+ # position it last reached. Falling back to 0 makes the tailer
+ # re-read the whole file (safe, though slower) when no offset has
+ # been stored yet (e.g. for agents started before this migration).
+ log_offset = agent_record.get("log_offset") or 0
+ threading.Thread(
+ target=self._tail_log_file,
+ args=(agent_id, log_file, pid, log_offset, tailer_done),
+ daemon=True,
+ name=f"tail-{agent_id}",
+ ).start()
+ else:
+ # No log file — signal immediately so the monitor doesn't wait
+ tailer_done.set()
+
+ # Parse the agent's original start time from the DB record so that
+ # _monitor_pid uses the true elapsed time rather than resetting the
+ # timeout clock to the moment of reattachment.
+ started_at_str = agent_record.get("started_at")
+ try:
+ agent_started_at = datetime.fromisoformat(
+ started_at_str.replace(" ", "T")
+ ).replace(tzinfo=timezone.utc).timestamp()
+ except (ValueError, AttributeError, TypeError):
+ agent_started_at = time.time()
+
+ threading.Thread(
+ target=self._monitor_pid,
+ args=(agent_id, pid, issue_number, agent_type, worktree_path, pr_number, workspace_id, workspace, tailer_done, agent_started_at),
+ daemon=True,
+ name=f"monitor-reattach-{agent_id}",
+ ).start()
+
+ def _tail_log_file(self, agent_id: str, log_file: Path, pid: int, log_offset: int = 0, done_event: threading.Event | None = None):
+ """Tail an agent's log file and ingest new events into the DB.
+
+ Uses a byte offset (not a line count) to resume from the correct position
+ after an orchestrator restart. Tracking total bytes consumed rather than
+ only successfully-stored events avoids re-processing lines that were read
+ but not stored (e.g. non-JSON output or lines parse_stream_line ignores).
+ """
+ logger.info("Tailing log file for agent %s from offset %d: %s", agent_id, log_offset, log_file)
+ try:
+ with open(log_file) as f:
+ # Seek to the last processed byte offset so we skip lines that
+ # have already been consumed (both stored and non-stored).
+ if log_offset > 0:
+ f.seek(log_offset)
+
+ # Now tail for new lines
+ while True:
+ line = f.readline()
+ if line:
+ event = parse_stream_line(line)
+ if event:
+ try:
+ db.insert_event(agent_id, event.event_type, json.dumps(event.raw))
+ except Exception as _db_err:
+ logger.warning("[%s] Failed to insert event: %s", agent_id, _db_err)
+ if event.event_type == "tool_use":
+ logger.info("[%s] %s", agent_id, event.summary)
+ # Persist offset after every readline so non-JSON lines
+ # also advance the saved position, preventing duplicate
+ # re-reads after a restart.
+ try:
+ db.update_agent(agent_id, log_offset=f.tell())
+ except Exception as _db_err:
+ logger.warning("[%s] Failed to update log offset: %s", agent_id, _db_err)
+ else:
+ # No new data — check if process is still alive
+ try:
+ os.kill(pid, 0)
+ except (OSError, ProcessLookupError):
+ # Process is dead, read any remaining lines
+ for remaining in f:
+ event = parse_stream_line(remaining)
+ if event:
+ try:
+ db.insert_event(agent_id, event.event_type, json.dumps(event.raw))
+ except Exception as _db_err:
+ logger.warning("[%s] Failed to insert event: %s", agent_id, _db_err)
+ try:
+ db.update_agent(agent_id, log_offset=f.tell())
+ except Exception as _db_err:
+ logger.warning("[%s] Failed to update log offset: %s", agent_id, _db_err)
+ break
+ time.sleep(1)
+ except Exception as e:
+ logger.error("[%s] Log tailer error: %s", agent_id, e)
+ finally:
+ if done_event is not None:
+ done_event.set()
+
+ def _monitor_pid(
+ self,
+ agent_id: str,
+ pid: int,
+ issue_number: int,
+ agent_type: str,
+ worktree_path: str,
+ pr_number: int | None,
+ workspace_id: str | None,
+ workspace: dict | None,
+ tailer_done: threading.Event | None = None,
+ started_at: float | None = None,
+ ):
+ """Poll a PID until it exits, then handle agent completion."""
+ if started_at is None:
+ started_at = time.time()
+
+ while True:
+ try:
+ os.kill(pid, 0)
+ except (OSError, ProcessLookupError):
+ # Process has exited
+ break
+
+ elapsed = time.time() - started_at
+ if elapsed > AGENT_TIMEOUT_SECONDS:
+ logger.warning(
+ "Reattached agent %s (PID %d) timed out after %ds, killing",
+ agent_id, pid, AGENT_TIMEOUT_SECONDS,
+ )
+ try:
+ os.killpg(os.getpgid(pid), signal.SIGTERM)
+ time.sleep(5)
+ try:
+ os.killpg(os.getpgid(pid), signal.SIGKILL)
+ except (OSError, ProcessLookupError):
+ pass
+ except (OSError, ProcessLookupError):
+ pass
+ # Wait for the log tailer to finish draining before reading
+ # turn counts, mirroring the normal completion path.
+ if tailer_done is not None:
+ tailer_done.wait(timeout=30)
+ turns = db.get_agent_turn_count(agent_id)
+ if turns:
+ db.update_agent(agent_id, turns_used=turns)
+ db.finish_agent(agent_id, status="timeout", error_message="Agent exceeded timeout (reattached)")
+ if issue_number is not None:
+ db.update_issue(issue_number, workspace_id=workspace_id, status="pending")
+ repo_path = workspace.get("local_path") if workspace else None
+ if worktree_path and repo_path:
+ cleanup_worktree(worktree_path, repo_path=repo_path)
+ elif worktree_path and not repo_path:
+ logger.warning(
+ "Skipping git worktree deregistration for agent %s: workspace is None, "
+ "worktree_path=%s may be orphaned",
+ agent_id,
+ worktree_path,
+ )
+ with self._lock:
+ self._stopped_agent_ids.discard(agent_id)
+ return
+
+ time.sleep(5)
+
+ # PID exited — handle completion
+ logger.info("Reattached agent %s (PID %d) has exited", agent_id, pid)
+
+ # Skip completion logic if this agent was externally stopped (e.g. via
+ # restart_agent) to avoid overwriting the DB state set by the caller.
+ with self._lock:
+ externally_stopped = agent_id in self._stopped_agent_ids
+ self._stopped_agent_ids.discard(agent_id)
+ if externally_stopped:
+ logger.info("Reattached agent %s was externally stopped — skipping completion logic", agent_id)
+ # Wait for the log tailer to finish draining before returning so
+ # that in-flight DB writes (event/turn counts) complete cleanly.
+ if tailer_done is not None:
+ tailer_done.wait(timeout=30)
+ return
+
+ # Wait for the log tailer to finish ingesting remaining events before
+ # reading the turn count so we get a complete picture.
+ if tailer_done is not None:
+ tailer_done.wait(timeout=30)
+
+ # Update turns_used from DB events (reattached agents don't track in-memory)
+ turns = db.get_agent_turn_count(agent_id)
+ if turns:
+ db.update_agent(agent_id, turns_used=turns)
+
+ repo_path = workspace.get("local_path") if workspace else None
+ github_repo = workspace.get("github_repo") if workspace else None
+
+ # Track the effective pr_number so the completion callback receives the
+ # latest value even when it is discovered during this function (e.g. an
+ # auto-created PR or one found via the GitHub API).
+ effective_pr_number: int | None = pr_number
+
+ if agent_type == "implement":
+ if issue_number is None:
+ db.finish_agent(agent_id, status="completed")
+ if worktree_path and repo_path:
+ cleanup_worktree(worktree_path, repo_path=repo_path)
+ elif worktree_path:
+ logger.warning(
+ "repo_path is None for agent %s — skipping git worktree deregistration",
+ agent_id,
+ )
+ return
+ # Check if a PR was created
+ branch_name = f"fix/issue-{issue_number}"
+ found_pr = self._find_pr_for_branch(branch_name, github_repo=github_repo) if github_repo else None
+
+ if found_pr:
+ logger.info("Reattached agent %s created PR #%d for issue #%d", agent_id, found_pr, issue_number)
+ db.finish_agent(agent_id, status="completed")
+ db.update_agent(agent_id, pr_number=found_pr)
+ if issue_number is not None:
+ db.update_issue(issue_number, workspace_id=workspace_id, status="pr_created", pr_number=found_pr)
+ effective_pr_number = found_pr
+ elif worktree_path and self._is_branch_pushed(branch_name, worktree_path):
+ logger.warning("Reattached agent %s pushed branch but no PR — creating automatically", agent_id)
+ auto_pr = self._create_pr_for_branch(branch_name, issue_number, github_repo=github_repo)
+ if auto_pr:
+ db.finish_agent(agent_id, status="completed")
+ db.update_agent(agent_id, pr_number=auto_pr)
+ if issue_number is not None:
+ db.update_issue(issue_number, workspace_id=workspace_id, status="pr_created", pr_number=auto_pr)
+ effective_pr_number = auto_pr
+ else:
+ db.finish_agent(agent_id, status="failed", error_message="Agent exited without creating PR (reattached)")
+ if issue_number is not None:
+ db.update_issue(issue_number, workspace_id=workspace_id, status="pending")
+ else:
+ # Can't tell if it succeeded or failed without stdout — check for unpushed commits
+ base_branch = workspace.get("base_branch", "main") if workspace else "main"
+ if worktree_path and self._has_unpushed_commits(worktree_path, base_branch=base_branch):
+ logger.warning("Reattached agent %s has unpushed commits — pushing and creating PR", agent_id)
+ if self._push_branch(branch_name, worktree_path):
+ auto_pr = self._create_pr_for_branch(branch_name, issue_number, github_repo=github_repo)
+ if auto_pr:
+ db.finish_agent(agent_id, status="completed")
+ db.update_agent(agent_id, pr_number=auto_pr)
+ if issue_number is not None:
+ db.update_issue(issue_number, workspace_id=workspace_id, status="pr_created", pr_number=auto_pr)
+ effective_pr_number = auto_pr
+ if worktree_path and repo_path:
+ cleanup_worktree(worktree_path, repo_path=repo_path)
+ elif worktree_path:
+ logger.warning("repo_path is None for agent %s — skipping git worktree deregistration", agent_id)
+ if self._on_reattach_complete:
+ try:
+ self._on_reattach_complete(agent_id, agent_type, effective_pr_number, workspace)
+ except Exception as e:
+ logger.error("Reattach completion callback error: %s", e)
+ return
+
+ db.finish_agent(agent_id, status="failed", error_message="Agent exited without creating PR (reattached)")
+ if issue_number is not None:
+ db.update_issue(issue_number, workspace_id=workspace_id, status="pending")
+ else:
+ # fix_review agent — determine success from git state.
+ # Try to decode the exit status. For child processes os.waitpid succeeds;
+ # for reattached non-child processes it raises ChildProcessError (ECHILD)
+ # because the process has already been reaped by init.
+ # Use WNOHANG to avoid blocking the monitor thread if the child
+ # has not yet been fully reaped by the OS.
+ try:
+ waited_pid, wait_status = os.waitpid(pid, os.WNOHANG)
+ if waited_pid == 0:
+ # Not yet fully reaped by the OS; exit code unavailable.
+ exit_code = None
+ else:
+ exit_code = os.WEXITSTATUS(wait_status) if os.WIFEXITED(wait_status) else -1
+ except ChildProcessError:
+ exit_code = None
+ agent_succeeded = False
+ if worktree_path and os.path.exists(worktree_path) and started_at:
+ try:
+ result = subprocess.run(
+ ["git", "log", "--oneline", f"--after={datetime.fromtimestamp(started_at, tz=timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')}"],
+ capture_output=True, text=True, cwd=worktree_path, timeout=10,
+ )
+ agent_succeeded = result.returncode == 0 and bool(result.stdout.strip())
+ except Exception as e:
+ logger.warning(
+ "Could not check git state for reattached fix_review agent %s: %s",
+ agent_id, e,
+ )
+
+ if agent_succeeded:
+ logger.info("Reattached fix_review agent %s succeeded — marking completed", agent_id)
+ db.finish_agent(agent_id, status="completed")
+ if issue_number is not None:
+ db.update_issue(issue_number, workspace_id=workspace_id, status="pr_created")
+ else:
+ logger.warning(
+ "Reattached fix_review agent %s did not succeed (exit_code=%s) — marking failed",
+ agent_id, exit_code,
+ )
+ db.finish_agent(agent_id, status="failed", error_message="Fix review agent exited unsuccessfully (reattached)")
+ if issue_number is not None:
+ db.update_issue(issue_number, workspace_id=workspace_id, status="needs_human")
+
+ if worktree_path and repo_path:
+ cleanup_worktree(worktree_path, repo_path=repo_path)
+ elif worktree_path:
+ logger.warning("repo_path is None for agent %s — skipping git worktree deregistration", agent_id)
+
+ if self._on_reattach_complete:
+ try:
+ self._on_reattach_complete(agent_id, agent_type, effective_pr_number, workspace)
+ except Exception as e:
+ logger.error("Reattach completion callback error: %s", e)
+
def shutdown(self):
"""Gracefully shut down the pool — let running agents continue independently."""
logger.info("Shutting down agent pool (agents will keep running)...")
diff --git a/orchestrator/dashboard.py b/orchestrator/dashboard.py
index 407fb7e..699128a 100644
--- a/orchestrator/dashboard.py
+++ b/orchestrator/dashboard.py
@@ -1,22 +1,37 @@
"""FastAPI dashboard server for the swarm orchestrator."""
+import asyncio
import json
+import logging
+import os
+import signal
+import time
import uuid
from pathlib import Path
-from fastapi import FastAPI, Query
-from fastapi.responses import HTMLResponse, JSONResponse
+from fastapi import FastAPI, HTTPException, Query
+from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from orchestrator import db
from orchestrator import planner
+from orchestrator import worktree
from orchestrator import workspace_manager as wm
app = FastAPI(title="Claude Code Swarm Dashboard")
STATIC_DIR = Path(__file__).parent / "static"
+# Set by main.py after the AgentPool is created so dashboard endpoints can
+# dispatch / restart agents.
+_agent_pool = None
+
+
+def set_agent_pool(pool):
+ global _agent_pool
+ _agent_pool = pool
+
# === Pydantic Models ===
@@ -39,10 +54,12 @@ class SaveEnvRequest(BaseModel):
# === Dashboard HTML ===
-@app.get("/", response_class=HTMLResponse)
+@app.get("/", response_class=FileResponse)
async def index():
- index_file = STATIC_DIR / "index.html"
- return index_file.read_text()
+ index = STATIC_DIR / "index.html"
+ if not index.exists():
+ raise HTTPException(status_code=404, detail="Frontend not built")
+ return FileResponse(str(index))
# === Workspace Endpoints ===
@@ -115,6 +132,39 @@ async def get_workspace_structure(workspace_id: str):
return {"structure": structure}
+@app.get("/api/workspaces/{workspace_id}/git-status")
+async def workspace_git_status(workspace_id: str):
+ """Check if the workspace's base branch is in sync with the remote."""
+ workspace = db.get_workspace(workspace_id)
+ if not workspace:
+ return JSONResponse(content={"error": "Workspace not found"}, status_code=404)
+ local_path = workspace.get("local_path")
+ if not local_path or not Path(local_path).exists():
+ return JSONResponse(content={"error": "Workspace repo not cloned yet"}, status_code=400)
+ try:
+ status = await asyncio.to_thread(worktree.get_sync_status, local_path, workspace.get("base_branch", "main"))
+ return status
+ except Exception as e:
+ return JSONResponse(content={"error": str(e)}, status_code=500)
+
+
+@app.post("/api/workspaces/{workspace_id}/git-pull")
+async def workspace_git_pull(workspace_id: str):
+ """Pull latest changes from the remote for the workspace's base branch."""
+ workspace = db.get_workspace(workspace_id)
+ if not workspace:
+ return JSONResponse(content={"error": "Workspace not found"}, status_code=404)
+ local_path = workspace.get("local_path")
+ if not local_path or not Path(local_path).exists():
+ return JSONResponse(content={"error": "Workspace repo not cloned yet"}, status_code=400)
+ try:
+ await asyncio.to_thread(worktree.ensure_repo_updated, local_path, workspace.get("base_branch", "main"))
+ status = await asyncio.to_thread(worktree.get_sync_status, local_path, workspace.get("base_branch", "main"))
+ return {"ok": True, **status}
+ except Exception as e:
+ return JSONResponse(content={"error": str(e)}, status_code=500)
+
+
@app.put("/api/workspaces/{workspace_id}/env")
async def save_workspace_env(workspace_id: str, req: SaveEnvRequest):
"""Save env vars for a workspace (writes to DB + disk)."""
@@ -157,8 +207,27 @@ async def get_workspace_env_files(workspace_id: str):
@app.post("/api/workspaces/{workspace_id}/env-load")
async def load_env_from_disk(workspace_id: str, env_file: str = Query(".env")):
"""Load env vars from an existing .env file on disk into DB."""
- env_vars = wm.load_env_from_disk(workspace_id, env_file)
- return {"vars": env_vars, "env_file": env_file, "count": len(env_vars)}
+ workspace = db.get_workspace(workspace_id)
+ if not workspace:
+ return JSONResponse(content={"error": "Workspace not found"}, status_code=404)
+ local_path = workspace.get("local_path")
+ if not local_path or not Path(local_path).exists():
+ return JSONResponse(content={"error": "Workspace repo not cloned yet"}, status_code=400)
+ file_path = Path(local_path) / env_file
+ # Guard against path traversal: resolve both paths and ensure the file is
+ # contained within the workspace directory. pathlib silently discards
+ # local_path when env_file is absolute, and ../ sequences can escape the
+ # workspace, so we must check after resolving.
+ resolved_workspace = Path(local_path).resolve()
+ if not file_path.resolve().is_relative_to(resolved_workspace):
+ return JSONResponse(content={"error": "Access denied: path is outside the workspace"}, status_code=403)
+ if not file_path.exists():
+ return JSONResponse(content={"error": f"File {env_file} not found on disk"}, status_code=404)
+ try:
+ env_vars = wm.load_env_from_disk(workspace_id, env_file)
+ return {"vars": env_vars, "env_file": env_file, "count": len(env_vars)}
+ except Exception as e:
+ return JSONResponse(content={"error": str(e)}, status_code=500)
# === Existing Endpoints (now workspace-filterable) ===
@@ -185,6 +254,162 @@ async def agent_logs(agent_id: str, since: int = Query(0)):
return {"events": events}
+@app.post("/api/agents/{agent_id}/restart")
+async def restart_agent(agent_id: str):
+ """Kill a running agent and dispatch a fresh one for the same issue/PR."""
+ agent = db.get_agent(agent_id)
+ if not agent:
+ return JSONResponse(content={"error": "Agent not found"}, status_code=404)
+ if agent["status"] != "running":
+ return JSONResponse(content={"error": "Agent is not running"}, status_code=400)
+ if not _agent_pool:
+ return JSONResponse(content={"error": "Agent pool not available"}, status_code=503)
+
+ # Validate workspace before any destructive operations so we never kill
+ # the agent and mutate the DB only to discover we cannot redispatch.
+ workspace_id = agent.get("workspace_id")
+ ws = db.get_workspace(workspace_id) if workspace_id else None
+ if not ws:
+ return JSONResponse(content={"error": "Workspace not found"}, status_code=404)
+
+ # Validate dispatch preconditions BEFORE sending SIGTERM so that if they
+ # fail the old agent is left alive and the issue is never stranded in a
+ # stopped state with no replacement agent.
+ branch = None
+ threads = None
+ if agent.get("agent_type") == "fix_review":
+ if not agent.get("pr_number"):
+ return JSONResponse(
+ content={"error": "Cannot restart: fix_review agent has no pr_number"},
+ status_code=400,
+ )
+ from orchestrator.pr_monitor import get_pr_branch, get_unresolved_threads
+ github_repo = ws.get("github_repo")
+ if not github_repo:
+ return JSONResponse(
+ content={"error": "Workspace has no github_repo configured"},
+ status_code=409,
+ )
+ branch = get_pr_branch(agent["pr_number"], github_repo=github_repo)
+ threads = get_unresolved_threads(agent["pr_number"], github_repo=github_repo)
+ if not branch:
+ return JSONResponse(
+ content={"error": "Cannot restart: could not resolve PR branch"},
+ status_code=400,
+ )
+ else:
+ if agent["issue_number"] is None:
+ return JSONResponse(
+ content={"error": "Cannot restart: no issue number"},
+ status_code=400,
+ )
+
+ # Validate PID before marking externally stopped. If a 'running' agent has
+ # no PID (e.g. the PID write raced with a DB failure), we must not flag it
+ # as stopped without sending SIGTERM — the original process would keep
+ # running alongside any replacement, and _monitor_agent/_monitor_pid would
+ # skip their completion DB writes when it eventually exits.
+ pid = agent.get("pid")
+ if not pid:
+ return JSONResponse(
+ content={"error": "Cannot restart: running agent has no PID recorded"},
+ status_code=409,
+ )
+
+ # Mark the agent as externally stopped *before* sending SIGTERM so that
+ # _monitor_agent / _monitor_pid skip their own completion DB writes and
+ # don't race with the writes below.
+ _agent_pool.mark_externally_stopped(agent_id)
+
+ # Kill the old process group and wait for it to die.
+ # Agents are spawned with start_new_session=True, so they lead their own
+ # process group. Signalling only the agent PID leaves any child processes
+ # it spawned (e.g. subprocess claude invocations) as orphans. Send SIGTERM
+ # to the entire process group instead.
+ if pid:
+ try:
+ try:
+ os.killpg(os.getpgid(pid), signal.SIGTERM)
+ except ProcessLookupError:
+ pass
+ # Wait up to 10s for the process to exit
+ for _ in range(20):
+ await asyncio.sleep(0.5)
+ try:
+ os.kill(pid, 0)
+ except (OSError, ProcessLookupError):
+ break # Process is dead
+ else:
+ # Force kill if still alive
+ try:
+ os.killpg(os.getpgid(pid), signal.SIGKILL)
+ await asyncio.sleep(0.5)
+ except (OSError, ProcessLookupError):
+ pass
+ except (OSError, ProcessLookupError):
+ pass
+ # Re-fetch the agent record after the kill to check whether it had already
+ # completed naturally (e.g. just created a PR) before we sent SIGTERM.
+ # Use this single snapshot for both status decisions and cleanup operations
+ # to avoid acting on a stale worktree_path while using a fresh status.
+ current_agent = db.get_agent(agent_id)
+ if current_agent and current_agent["status"] == "running":
+ # Mark the old agent finished BEFORE dispatching the new one. The
+ # _monitor_agent/_monitor_pid thread was told to skip its own DB
+ # completion writes (via mark_externally_stopped) and may have already
+ # exited by now. Writing finish_agent here — before dispatch — ensures
+ # the old record is never left permanently in 'running' state even if
+ # the dispatch call succeeds but a subsequent DB write fails.
+ try:
+ db.finish_agent(agent_id, status="stopped", error_message="Manually restarted by user")
+ except Exception:
+ _agent_pool.unmark_externally_stopped(agent_id)
+ raise
+
+ # update_issue failure is non-fatal: the agent is already stopped and we
+ # still want to dispatch a replacement. Ignore errors and continue.
+ if current_agent["issue_number"] is not None and current_agent.get("agent_type") == "fix_review":
+ try:
+ db.update_issue(current_agent["issue_number"], workspace_id=workspace_id, status="pending")
+ except Exception:
+ pass
+
+ # Dispatch preconditions were validated before the kill; proceed to dispatch.
+ new_agent_id = None
+ try:
+ if current_agent.get("agent_type") == "fix_review":
+ new_agent_id = _agent_pool.dispatch_fix_review(
+ current_agent["pr_number"], branch, current_agent["issue_number"], ws, threads,
+ )
+ else:
+ new_agent_id = _agent_pool.dispatch_implement(current_agent["issue_number"], workspace=ws)
+ except Exception:
+ _agent_pool.unmark_externally_stopped(agent_id)
+ raise
+
+ if not new_agent_id:
+ # The old agent process is already dead (SIGTERM was sent and
+ # waited on above). finish_agent was already called above.
+ _agent_pool.unmark_externally_stopped(agent_id)
+ return JSONResponse(content={"error": "Failed to dispatch new agent"}, status_code=500)
+
+ # Clean up the old worktree now that the new agent has its own.
+ # Use current_agent to ensure we target the fresh worktree_path.
+ repo_path = ws["local_path"]
+ if current_agent.get("worktree_path"):
+ try:
+ worktree.cleanup_worktree(current_agent["worktree_path"], repo_path=repo_path)
+ except Exception:
+ pass
+
+ return {"ok": True, "old_agent_id": agent_id, "new_agent_id": new_agent_id}
+
+ # The agent had already completed naturally before we could restart it.
+ # Remove agent_id from _stopped_agent_ids so it doesn't leak in the set.
+ _agent_pool.unmark_externally_stopped(agent_id)
+ return JSONResponse(content={"error": "Agent completed before restart could dispatch a new one"}, status_code=409)
+
+
@app.get("/api/issues")
async def list_issues(workspace_id: str | None = Query(None)):
"""List all tracked issues and their state."""
@@ -196,29 +421,56 @@ async def list_issues(workspace_id: str | None = Query(None)):
async def list_prs(workspace_id: str | None = Query(None)):
"""List all tracked PRs and their review loop count."""
reviews = db.get_all_pr_reviews(workspace_id=workspace_id)
- pr_map: dict[int, dict] = {}
+
+ # Build a lookup of (workspace_id, pr_number) -> issue status so we can
+ # mark merged PRs. Using a composite key prevents PRs with the same
+ # number in different workspaces from overwriting each other.
+ all_issues = db.get_all_issues(workspace_id=workspace_id)
+ pr_issue_status: dict[tuple, str] = {}
+ for issue in all_issues:
+ if issue.get("pr_number"):
+ key = (issue.get("workspace_id"), issue["pr_number"])
+ pr_issue_status[key] = issue["status"]
+
+ pr_map: dict[tuple, dict] = {}
for review in reviews:
pr_num = review["pr_number"]
- if pr_num not in pr_map:
- pr_map[pr_num] = {
+ ws_id = review.get("workspace_id")
+ key = (ws_id, pr_num)
+ if key not in pr_map:
+ pr_map[key] = {
"pr_number": pr_num,
"iterations": 0,
"latest_status": review["status"],
"total_comments": 0,
"review_threads": [],
+ "workspace_id": ws_id,
}
- pr_map[pr_num]["iterations"] = max(pr_map[pr_num]["iterations"], review["iteration"])
- pr_map[pr_num]["latest_status"] = review["status"]
- pr_map[pr_num]["total_comments"] += review.get("comments_count", 0)
+ pr_map[key]["iterations"] = max(pr_map[key]["iterations"], review["iteration"])
+ pr_map[key]["latest_status"] = review["status"]
+ pr_map[key]["total_comments"] += review.get("comments_count", 0)
comments_json = review.get("comments_json")
if comments_json:
try:
- pr_map[pr_num]["review_threads"] = json.loads(comments_json)
+ pr_map[key]["review_threads"] = json.loads(comments_json)
except (json.JSONDecodeError, TypeError):
pass
- return {"prs": list(pr_map.values())}
+ # Enrich PR statuses from issue state:
+ # - issue "resolved" → PR was merged
+ # - issue "needs_human" → PR needs human intervention
+ for (ws_id, pr_num), pr_data in pr_map.items():
+ issue_status = pr_issue_status.get((ws_id, pr_num))
+ if issue_status == "resolved":
+ pr_data["latest_status"] = "merged"
+ elif issue_status == "needs_human":
+ pr_data["latest_status"] = "needs_human"
+
+ # Sort: active statuses first, resolved/merged last
+ pr_status_order = {"pending_fix": 0, "pending": 1, "open": 2, "needs_human": 3, "closed": 4, "merged": 5}
+ sorted_prs = sorted(pr_map.values(), key=lambda p: pr_status_order.get(p["latest_status"], 3))
+ return {"prs": sorted_prs}
class StartPlanningRequest(BaseModel):
@@ -232,6 +484,7 @@ class RefinePlanRequest(BaseModel):
class CreateIssueRequest(BaseModel):
title: str = ""
+ message_index: int | None = None
# === Planning Endpoints ===
@@ -253,6 +506,7 @@ def delete_planning_session(session_id: str):
if not session:
return JSONResponse(content={"error": "Session not found"}, status_code=404)
planner.cancel_planning(session_id)
+ planner.cleanup_session_issue_keys(session_id)
db.delete_planning_session(session_id)
return {"ok": True}
@@ -334,7 +588,7 @@ def create_issue_from_plan(session_id: str, req: CreateIssueRequest):
return JSONResponse(content={"error": "Session not found"}, status_code=404)
try:
- result = planner.create_issue_from_plan(session_id, req.title)
+ result = planner.create_issue_from_plan(session_id, req.title, message_index=req.message_index)
return result
except RuntimeError as e:
status_code = 409 if "in progress" in str(e) else 400
@@ -380,3 +634,29 @@ async def get_metrics(workspace_id: str | None = Query(None)):
# Serve static files
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
+
+# Serve Vite-built assets at /assets/ (index.html references them with root-relative /assets/... paths)
+# Only mount if the directory exists; otherwise log a warning and let the spa_fallback return 404.
+_assets_dir = STATIC_DIR / "assets"
+if _assets_dir.exists():
+ app.mount("/assets", StaticFiles(directory=str(_assets_dir)), name="assets")
+else:
+ logging.getLogger(__name__).warning(
+ "Assets directory '%s' not found — frontend build may be missing. "
+ "Requests to /assets/... will return 404.",
+ _assets_dir,
+ )
+
+
+# SPA catch-all — must be registered LAST, after all /api/* routes and static mount.
+# Returns index.html for any non-API, non-static path so React Router handles navigation.
+@app.get("/{full_path:path}")
+async def spa_fallback(full_path: str):
+ if full_path == "api" or full_path.startswith("api/"):
+ raise HTTPException(status_code=404, detail="Not found")
+ if full_path.startswith("assets/") or full_path.startswith("static/"):
+ raise HTTPException(status_code=404)
+ index = STATIC_DIR / "index.html"
+ if not index.exists():
+ raise HTTPException(status_code=404, detail="Frontend not built")
+ return FileResponse(str(index))
diff --git a/orchestrator/db.py b/orchestrator/db.py
index 1bf999e..e565093 100644
--- a/orchestrator/db.py
+++ b/orchestrator/db.py
@@ -152,6 +152,7 @@ def init_db():
_migrate_add_column(conn, "agents", "session_id", "TEXT")
_migrate_add_column(conn, "agents", "resume_count", "INTEGER DEFAULT 0")
_migrate_add_column(conn, "agents", "rate_limited_at", "TIMESTAMP")
+ _migrate_add_column(conn, "agents", "log_offset", "INTEGER DEFAULT 0")
_migrate_add_column(conn, "pr_reviews", "comments_json", "TEXT")
# Multi-workspace migration
_migrate_add_column(conn, "issues", "workspace_id", "TEXT")
@@ -432,13 +433,21 @@ def get_issues_by_status(status: str, workspace_id: str | None = None) -> list[d
def get_all_issues(workspace_id: str | None = None) -> list[dict]:
conn = _get_connection()
+ order = """ORDER BY CASE status
+ WHEN 'pending' THEN 0
+ WHEN 'in_progress' THEN 1
+ WHEN 'pr_created' THEN 2
+ WHEN 'needs_human' THEN 3
+ WHEN 'resolved' THEN 4
+ ELSE 3
+ END, issue_number"""
if workspace_id:
rows = conn.execute(
- "SELECT * FROM issues WHERE workspace_id = ? ORDER BY issue_number", (workspace_id,)
+ f"SELECT * FROM issues WHERE workspace_id = ? {order}", (workspace_id,)
).fetchall()
else:
rows = conn.execute(
- "SELECT * FROM issues ORDER BY issue_number"
+ f"SELECT * FROM issues {order}"
).fetchall()
return [dict(r) for r in rows]
@@ -501,7 +510,7 @@ def get_all_agents(workspace_id: str | None = None, limit: int = 0, offset: int
if workspace_id:
base += " WHERE workspace_id = ?"
params.append(workspace_id)
- base += " ORDER BY started_at DESC"
+ base += """ ORDER BY CASE WHEN status = 'running' THEN 0 ELSE 1 END, started_at DESC"""
if limit > 0:
base += " LIMIT ? OFFSET ?"
params.extend([limit, offset])
@@ -569,6 +578,16 @@ def get_agent_events(agent_id: str, since_id: int = 0, limit: int = 100) -> list
return [dict(r) for r in rows]
+def get_agent_event_count(agent_id: str) -> int:
+ """Count total events for an agent (used to resume log tailing after restart)."""
+ conn = _get_connection()
+ row = conn.execute(
+ "SELECT COUNT(*) FROM agent_events WHERE agent_id = ?",
+ (agent_id,),
+ ).fetchone()
+ return row[0] if row else 0
+
+
def get_agent_turn_count(agent_id: str) -> int:
"""Count assistant events (turns) for an agent from the events table."""
conn = _get_connection()
diff --git a/orchestrator/main.py b/orchestrator/main.py
index 097ef3c..1d1c3eb 100644
--- a/orchestrator/main.py
+++ b/orchestrator/main.py
@@ -41,8 +41,13 @@ def _pid_is_alive(pid: int) -> bool:
return False
-def _recover_stale_agents():
- """On startup, handle agents still marked as 'running' or 'rate_limited' in the DB."""
+def _recover_stale_agents(pool: AgentPool | None = None):
+ """On startup, handle agents still marked as 'running' or 'rate_limited' in the DB.
+
+ If *pool* is provided, surviving agents (PID still alive) get their
+ monitor threads reattached so the orchestrator can detect completion,
+ clean up worktrees, and verify PR creation.
+ """
stale = db.get_running_agents()
if not stale:
return
@@ -55,9 +60,12 @@ def _recover_stale_agents():
if pid and _pid_is_alive(pid):
logger.info(
- "Agent %s (PID %d) for issue #%s is still running — leaving it alone",
+ "Agent %s (PID %d) for issue #%s is still running — reattaching monitor",
agent_id, pid, issue_num,
)
+ if pool:
+ ws = db.get_workspace(workspace_id) if workspace_id else None
+ pool.reattach_agent(agent, workspace=ws)
continue
logger.warning(
@@ -105,11 +113,13 @@ def main():
db.init_db()
logger.info("Database initialized")
- # Recover from previous crash
- _recover_stale_agents()
-
- # Create agent pool
+ # Create agent pool and expose to dashboard
pool = AgentPool()
+ from orchestrator.dashboard import set_agent_pool
+ set_agent_pool(pool)
+
+ # Recover from previous crash — reattach monitors to surviving agents
+ _recover_stale_agents(pool=pool)
# Create PR monitor with dispatch callback (now workspace-aware)
pr_monitor = PRMonitor(
diff --git a/orchestrator/planner.py b/orchestrator/planner.py
index 494ce6f..a5298c8 100644
--- a/orchestrator/planner.py
+++ b/orchestrator/planner.py
@@ -39,6 +39,18 @@
# _issue_creating is protected by _issue_creating_lock.
_issue_creating: set[str] = set()
_issue_creating_lock = threading.Lock()
+# Tracks (session_id, message_index) pairs for which a GitHub issue has already
+# been successfully created. When message_index is provided the session-level
+# status=="completed" guard is intentionally skipped, so this set provides the
+# equivalent deduplication at the (session, message) granularity.
+# Protected by _issue_creating_lock.
+#
+# Implemented as an OrderedDict (insertion-ordered) capped at _MAX_ISSUE_KEYS
+# entries to prevent unbounded growth in long-running deployments. When the cap
+# is reached the oldest entry is evicted (LRU-style). Membership checks remain
+# O(1) because dict lookup is O(1).
+_MAX_ISSUE_KEYS: int = 10_000
+_issue_created_keys: collections.OrderedDict[tuple[str, int], None] = collections.OrderedDict()
def is_generating(session_id: str) -> bool:
@@ -628,8 +640,12 @@ def _generate_title_with_ai(plan_body: str) -> str:
return _generate_title_from_plan(plan_body)
-def create_issue_from_plan(session_id: str, title: str = "") -> dict:
- """Create a GitHub issue from the last assistant message in the session.
+def create_issue_from_plan(session_id: str, title: str = "", message_index: int | None = None) -> dict:
+ """Create a GitHub issue from an assistant message in the session.
+
+ If *message_index* is provided, uses the assistant message at that index
+ (0-based among all messages, must be an assistant message). Otherwise
+ falls back to the last assistant message.
If *title* is empty, a title is auto-generated from the plan content.
Returns a dict with issue_number and issue_url on success, or raises on error.
@@ -650,20 +666,41 @@ def create_issue_from_plan(session_id: str, title: str = "") -> dict:
# Acquire per-session issue-creation lock to prevent duplicate issues from
# concurrent requests (e.g. user double-clicking "Create GitHub Issue").
+ _reserved_key: tuple | None = None
with _issue_creating_lock:
if session_id in _issue_creating:
raise RuntimeError("Issue creation already in progress for this session")
+ # When message_index is provided, also check whether an issue was already
+ # created for this exact (session, message) pair. Concurrent requests are
+ # already serialised by _issue_creating above; this check additionally
+ # blocks sequential duplicate requests that arrive after the first one
+ # completes and is removed from _issue_creating.
+ if message_index is not None and (session_id, message_index) in _issue_created_keys:
+ raise RuntimeError(
+ f"Issue already created for message {message_index} in this session"
+ )
_issue_creating.add(session_id)
-
+ # Reserve the key immediately (inside the same lock acquisition) to close
+ # the TOCTOU race: without this a second request could pass the membership
+ # check above after the first request removes session_id from _issue_creating
+ # but before the first request re-acquires the lock to insert the key.
+ if message_index is not None:
+ _reserved_key = (session_id, message_index)
+ _issue_created_keys[_reserved_key] = None
+ while len(_issue_created_keys) > _MAX_ISSUE_KEYS:
+ _issue_created_keys.popitem(last=False)
+
+ _issue_created = False
try:
session = db.get_planning_session(session_id)
if not session:
raise ValueError(f"Session {session_id} not found")
- # Verify the issue hasn't already been created for this session to
- # prevent duplicate issues from two requests that both passed the
- # _active_lock check before either completed issue creation.
- if session.get("status") == "completed":
+ # When no specific message is targeted, prevent duplicate issues from
+ # concurrent requests. When message_index is given, the user explicitly
+ # chose a specific plan to create from, so allow it even if the session
+ # was already marked completed (they may want an issue from an earlier plan).
+ if message_index is None and session.get("status") == "completed":
raise RuntimeError("Issue already created for this session")
workspace = db.get_workspace(session["workspace_id"])
@@ -671,12 +708,23 @@ def create_issue_from_plan(session_id: str, title: str = "") -> dict:
raise ValueError(f"Workspace {session['workspace_id']} not found")
messages = db.get_planning_messages(session_id)
- # Find the last assistant message as the plan body
+ # Find the target assistant message as the plan body
plan_body = None
- for msg in reversed(messages):
- if msg["role"] == "assistant":
- plan_body = msg["content"]
- break
+ if message_index is not None:
+ if 0 <= message_index < len(messages):
+ target = messages[message_index]
+ if target["role"] == "assistant":
+ plan_body = target["content"]
+ else:
+ raise ValueError(f"Message at index {message_index} is not an assistant message")
+ else:
+ raise ValueError(f"Message index {message_index} out of range")
+ else:
+ # Default: use the last assistant message
+ for msg in reversed(messages):
+ if msg["role"] == "assistant":
+ plan_body = msg["content"]
+ break
if not plan_body:
raise ValueError("No plan found in session — generate a plan first")
@@ -755,10 +803,27 @@ def create_issue_from_plan(session_id: str, title: str = "") -> dict:
session_id, db_err,
)
+ _issue_created = True
return {"issue_number": issue_number, "issue_url": issue_url, "title": title}
finally:
with _issue_creating_lock:
_issue_creating.discard(session_id)
+ # If the key was reserved at the start but issue creation ultimately
+ # failed, remove the reservation so the operation can be retried.
+ if _reserved_key is not None and not _issue_created:
+ _issue_created_keys.pop(_reserved_key, None)
+
+
+def cleanup_session_issue_keys(session_id: str):
+ """Remove all _issue_created_keys entries for the given session_id.
+
+ Call this when a planning session is permanently deleted so the set does
+ not grow without bound in a long-running orchestrator.
+ """
+ with _issue_creating_lock:
+ keys_to_remove = [k for k in _issue_created_keys if k[0] == session_id]
+ for k in keys_to_remove:
+ del _issue_created_keys[k]
def cancel_planning(session_id: str):
diff --git a/orchestrator/pr_monitor.py b/orchestrator/pr_monitor.py
index 464ea09..9ef1b32 100644
--- a/orchestrator/pr_monitor.py
+++ b/orchestrator/pr_monitor.py
@@ -26,6 +26,8 @@ def _run_gh(*args: str) -> subprocess.CompletedProcess:
def get_pr_comments(pr_number: int, github_repo: str | None = None) -> list[dict]:
"""Fetch all review comments on a PR (REST API — no resolution status)."""
+ if not github_repo:
+ return []
repo = github_repo
owner, repo_name = repo.split("/", 1)
result = _run_gh(
@@ -43,19 +45,27 @@ def get_pr_comments(pr_number: int, github_repo: str | None = None) -> list[dict
def get_unresolved_threads(pr_number: int, github_repo: str | None = None) -> list[dict] | None:
- """Fetch unresolved review threads with full details using the GraphQL API."""
+ """Fetch unresolved review threads with full details using the GraphQL API.
+
+ Paginates through all review threads to ensure none are missed when a PR
+ has more than 100 threads. Comments per thread are fetched up to 100
+ (plenty for review context).
+ """
+ if not github_repo:
+ return None
repo = github_repo
owner, repo_name = repo.split("/", 1)
query = """
- query($owner: String!, $repo: String!, $pr: Int!) {
+ query($owner: String!, $repo: String!, $pr: Int!, $cursor: String) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $pr) {
- reviewThreads(first: 100) {
+ reviewThreads(first: 100, after: $cursor) {
+ pageInfo { hasNextPage endCursor }
nodes {
isResolved
path
line
- comments(first: 10) {
+ comments(first: 100) {
nodes {
body
author { login }
@@ -67,34 +77,56 @@ def get_unresolved_threads(pr_number: int, github_repo: str | None = None) -> li
}
}
"""
- result = _run_gh(
- "api", "graphql",
- "-f", f"query={query}",
- "-f", f"owner={owner}",
- "-f", f"repo={repo_name}",
- "-F", f"pr={pr_number}",
- )
- if result.returncode != 0:
- logger.warning("GraphQL query failed for PR #%d: %s", pr_number, result.stderr)
- return None
- try:
- data = json.loads(result.stdout)
- threads = data["data"]["repository"]["pullRequest"]["reviewThreads"]["nodes"]
- unresolved = []
- for t in threads:
- if not t["isResolved"]:
- unresolved.append({
- "path": t.get("path", "unknown"),
- "line": t.get("line"),
- "comments": [
- {"body": c["body"], "author": c.get("author", {}).get("login", "unknown")}
- for c in t.get("comments", {}).get("nodes", [])
- ],
- })
- return unresolved
- except (json.JSONDecodeError, KeyError, TypeError) as e:
- logger.warning("Failed to parse GraphQL response for PR #%d: %s", pr_number, e)
- return None
+
+ unresolved = []
+ cursor = None
+
+ while True:
+ args = [
+ "api", "graphql",
+ "-f", f"query={query}",
+ "-f", f"owner={owner}",
+ "-f", f"repo={repo_name}",
+ "-F", f"pr={pr_number}",
+ ]
+ if cursor:
+ args += ["-f", f"cursor={cursor}"]
+
+ result = _run_gh(*args)
+ if result.returncode != 0:
+ logger.warning("GraphQL query failed for PR #%d: %s", pr_number, result.stderr)
+ return None
+ try:
+ data = json.loads(result.stdout)
+ if data.get("errors"):
+ logger.warning("GraphQL response contained errors for PR #%d: %s", pr_number, data["errors"])
+ return None
+ review_threads = data["data"]["repository"]["pullRequest"]["reviewThreads"]
+ threads = review_threads["nodes"]
+ page_info = review_threads["pageInfo"]
+
+ for t in threads:
+ if not t["isResolved"]:
+ unresolved.append({
+ "path": t.get("path", "unknown"),
+ "line": t.get("line"),
+ "comments": [
+ {"body": c["body"], "author": c.get("author", {}).get("login", "unknown")}
+ for c in t.get("comments", {}).get("nodes", [])
+ ],
+ })
+
+ if not page_info.get("hasNextPage"):
+ break
+ cursor = page_info.get("endCursor")
+ if not cursor:
+ break
+
+ except (json.JSONDecodeError, KeyError, TypeError) as e:
+ logger.warning("Failed to parse GraphQL response for PR #%d: %s", pr_number, e)
+ return None
+
+ return unresolved
def get_pr_checks(pr_number: int, github_repo: str | None = None) -> list[dict]:
diff --git a/orchestrator/static/index.html b/orchestrator/static/index.html
deleted file mode 100644
index 5e41038..0000000
--- a/orchestrator/static/index.html
+++ /dev/null
@@ -1,2262 +0,0 @@
-
-
-
-
-
-Claude Code Swarm Dashboard
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Add Workspace
-
- Repository URL
-
-
-
- Workspace Name (optional)
-
-
-
- Base Branch
-
-
-
-
- Cancel
- Add & Clone
-
-
-
-
-
-
-
-
-
-
-
-
-
- Claude is analyzing the codebase…
-
-
-
-
-
- Create GitHub Issue
- Title auto-generated by AI
-
-
▶ Customize title
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Workspace Settings
-
-
- Delete Workspace
-
- Close
- Save Changes
-
-
-
-
-
-
-
diff --git a/orchestrator/stream_parser.py b/orchestrator/stream_parser.py
index e0c30da..15a791f 100644
--- a/orchestrator/stream_parser.py
+++ b/orchestrator/stream_parser.py
@@ -57,6 +57,18 @@ def parse_stream_line(line: str) -> AgentEvent | None:
text_parts.append(f"[{tool_name} {tool_input.get('file_path', '?')}]")
elif tool_name == "Skill":
text_parts.append(f"[Skill: {tool_input.get('skill', '?')}]")
+ elif tool_name == "WebSearch":
+ text_parts.append(f"[WebSearch: {tool_input.get('query', '?')}]")
+ elif tool_name == "WebFetch":
+ text_parts.append(f"[WebFetch: {tool_input.get('url', '?')}]")
+ elif tool_name == "Grep":
+ text_parts.append(f"[Grep: {tool_input.get('pattern', '?')}]")
+ elif tool_name == "Glob":
+ text_parts.append(f"[Glob: {tool_input.get('pattern', '?')}]")
+ elif tool_name == "Agent":
+ text_parts.append(f"[Agent: {tool_input.get('description', '?')}]")
+ elif tool_name == "TodoWrite":
+ text_parts.append(f"[TodoWrite]")
else:
text_parts.append(f"[{tool_name}]")
elif block.get("type") == "thinking":
@@ -81,6 +93,16 @@ def parse_stream_line(line: str) -> AgentEvent | None:
summary = f"Read: {tool_input.get('file_path', '?')}"
elif tool_name in ("Edit", "Write"):
summary = f"{tool_name}: {tool_input.get('file_path', '?')}"
+ elif tool_name == "WebSearch":
+ summary = f"WebSearch: {tool_input.get('query', '?')}"
+ elif tool_name == "WebFetch":
+ summary = f"WebFetch: {tool_input.get('url', '?')}"
+ elif tool_name == "Grep":
+ summary = f"Grep: {tool_input.get('pattern', '?')}"
+ elif tool_name == "Glob":
+ summary = f"Glob: {tool_input.get('pattern', '?')}"
+ elif tool_name == "Agent":
+ summary = f"Agent: {tool_input.get('description', '?')}"
else:
summary = f"{tool_name}: {json.dumps(tool_input)[:100]}"
return AgentEvent(event_type="tool_use", summary=summary, raw=data)
diff --git a/orchestrator/worktree.py b/orchestrator/worktree.py
index 28f848d..caeb14e 100644
--- a/orchestrator/worktree.py
+++ b/orchestrator/worktree.py
@@ -6,12 +6,12 @@
logger = logging.getLogger(__name__)
-def _run_git(*args: str, repo_path: Path | str | None = None, check: bool = True) -> subprocess.CompletedProcess:
+def _run_git(*args: str, repo_path: Path | str | None = None, check: bool = True, timeout: int = 60) -> subprocess.CompletedProcess:
"""Run a git command against a repo."""
target = str(repo_path)
cmd = ["git", "-C", target] + list(args)
logger.debug("Running: %s", " ".join(cmd))
- result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
if check and result.returncode != 0:
raise RuntimeError(
f"git command failed: {' '.join(cmd)}\nstderr: {result.stderr}"
@@ -25,9 +25,64 @@ def ensure_repo_updated(repo_path: Path | str, base_branch: str = "main"):
branch = base_branch
logger.info("Updating target repo at %s", target)
_run_git("fetch", "origin", repo_path=target)
+ # Ensure we're on the base branch (not a stale feature branch).
+ # Use check=False so that a dirty working tree (e.g. from an interrupted
+ # prior operation) does not raise an exception and break the git-pull
+ # endpoint; log a warning instead so operators can investigate.
+ result = _run_git("checkout", branch, repo_path=target, check=False)
+ if result.returncode != 0:
+ logger.warning(
+ "git checkout %s failed for repo %s (repo may have uncommitted changes): %s",
+ branch, target, result.stderr.strip(),
+ )
_run_git("pull", "origin", branch, repo_path=target, check=False)
+def get_sync_status(repo_path: Path | str, base_branch: str = "main") -> dict:
+ """Check if the local base branch is in sync with origin.
+
+ Returns a dict with:
+ - synced (bool): True if local HEAD matches remote HEAD
+ - local_sha (str): short local commit hash
+ - remote_sha (str): short remote commit hash
+ - behind (int): commits behind remote
+ - ahead (int): commits ahead of remote
+ """
+ target = repo_path
+ # Fetch latest refs from remote (silent, no merge).
+ # Use a short timeout so an unreachable remote yields a fast error
+ # rather than blocking the HTTP endpoint for minutes.
+ _run_git("fetch", "origin", repo_path=target, check=False, timeout=15)
+
+ local = _run_git("rev-parse", "--short", base_branch, repo_path=target, check=False)
+ remote = _run_git("rev-parse", "--short", f"origin/{base_branch}", repo_path=target, check=False)
+
+ local_sha = local.stdout.strip() if local.returncode == 0 else ""
+ remote_sha = remote.stdout.strip() if remote.returncode == 0 else ""
+
+ behind = 0
+ ahead = 0
+ if local_sha and remote_sha:
+ rev_list = _run_git(
+ "rev-list", "--left-right", "--count",
+ f"{base_branch}...origin/{base_branch}",
+ repo_path=target, check=False,
+ )
+ if rev_list.returncode == 0:
+ parts = rev_list.stdout.strip().split()
+ if len(parts) == 2:
+ ahead = int(parts[0])
+ behind = int(parts[1])
+
+ return {
+ "synced": local_sha == remote_sha and local_sha != "",
+ "local_sha": local_sha,
+ "remote_sha": remote_sha,
+ "behind": behind,
+ "ahead": ahead,
+ }
+
+
def create_worktree(
issue_number: int,
repo_path: Path | str,
@@ -154,9 +209,17 @@ def copy_env_files_to_worktree(
def cleanup_worktree(path: str, repo_path: Path | str | None = None):
- """Remove a git worktree."""
+ """Remove a git worktree, force-deleting the directory if git can't."""
logger.info("Cleaning up worktree: %s", path)
_run_git("worktree", "remove", path, "--force", repo_path=repo_path, check=False)
+ # If the directory still exists (locked files, etc.), nuke it
+ wt = Path(path)
+ if wt.exists():
+ logger.warning("Worktree directory still exists after git remove, force-deleting: %s", path)
+ shutil.rmtree(str(wt), ignore_errors=True)
+ # Prune stale worktree references so git doesn't complain
+ if repo_path:
+ _run_git("worktree", "prune", repo_path=repo_path, check=False)
def list_worktrees(repo_path: Path | str | None = None) -> list[dict]:
diff --git a/run.sh b/run.sh
index eadc219..6847c84 100755
--- a/run.sh
+++ b/run.sh
@@ -175,6 +175,7 @@ cmd_stop() {
}
cmd_restart() {
+ cmd_build_ui
if _is_systemd; then
sudo systemctl restart "$SERVICE_NAME"
sudo systemctl status "$SERVICE_NAME" --no-pager
@@ -218,6 +219,7 @@ cmd_install() {
_ensure_deps
_ensure_venv
_ensure_env
+ cmd_build_ui
# Build PATH that includes claude, gh, node, etc.
SVC_PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
@@ -241,6 +243,7 @@ WorkingDirectory=${DIR}
ExecStart=${DIR}/.venv/bin/python -m orchestrator.main
EnvironmentFile=${DIR}/.env
Environment=PATH=${SVC_PATH}
+KillMode=process
KillSignal=SIGTERM
TimeoutStopSec=60
Restart=on-failure
@@ -282,6 +285,21 @@ cmd_uninstall() {
fi
}
+# ── UI Build ─────────────────────────────────────────────
+
+cmd_build_ui() {
+ FRONTEND_DIR="$DIR/frontend"
+ if [ ! -d "$FRONTEND_DIR" ]; then
+ echo "ERROR: frontend/ directory not found at $FRONTEND_DIR"
+ exit 1
+ fi
+ echo "Building dashboard UI..."
+ cd "$FRONTEND_DIR"
+ npm install --silent
+ npm run build
+ echo "Dashboard UI built successfully — output in orchestrator/static/"
+}
+
# ── Skills (powered by skills.sh) ─────────────────────────
# Default skill repos to install. Each entry is "owner/repo".
@@ -475,6 +493,7 @@ case "${1:-help}" in
logs) cmd_logs ;;
install) _require_sudo "$@"; cmd_install ;;
uninstall) _require_sudo "$@"; cmd_uninstall ;;
+ build-ui) cmd_build_ui ;;
install-skills) cmd_install_skills "$@" ;;
uninstall-skills) cmd_uninstall_skills "$@" ;;
list-skills) cmd_list_skills ;;
@@ -491,6 +510,7 @@ case "${1:-help}" in
echo " logs Tail live logs"
echo " install Install as systemd service (auto-start on boot, auto-restart on crash)"
echo " uninstall Remove the systemd service"
+ echo " build-ui Build the React dashboard UI (frontend/ → orchestrator/static/)"
echo ""
echo "Skills (powered by skills.sh):"
echo " install-skills Install default skills into target repo"